diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..b1342397 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +# ACS parser is generated +core/acs_parser.js diff --git a/.eslintrc.json b/.eslintrc.json index c7757f0d..53bd1287 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,11 +3,13 @@ "es6": true, "node": true }, - "extends": "eslint:recommended", + "extends": [ + "eslint:recommended" + ], "rules": { "indent": [ "error", - "tab", + 4, { "SwitchCase" : 1 } @@ -24,6 +26,7 @@ "error", "always" ], - "comma-dangle": 0 + "comma-dangle": 0, + "no-trailing-spaces" :"warn" } } \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 5d3f54be..a8e6a08e 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -3,8 +3,8 @@ For :bug: bug reports, please fill out the information below plus any additional **Short problem description** **Environment** -- [ ] I am using Node.js v6.x or higher -- [ ] `npm install` reports success +- [ ] I am using Node.js v10.x LTS or higher +- [ ] `npm install` or `yarn` reports success - Actual Node.js version (`node --version`): - Operating system (`uname -a` on *nix systems): - Revision (`git rev-parse --short HEAD`): diff --git a/.gitignore b/.gitignore index ee9fc3d8..b55cbcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,13 @@ *.pem # Various directories -logs/ +config/ db/ -dropfiles/ -node_modules/ \ No newline at end of file +drop/ +file_base/ +logs/ +mail/ +node_modules/ +docs/_site/ +docs/.sass-cache/ +.vscode/ diff --git a/LICENSE.TXT b/LICENSE.TXT index 8d256374..af51c707 100644 --- a/LICENSE.TXT +++ b/LICENSE.TXT @@ -1,4 +1,4 @@ -Copyright (c) 2015-2017, Bryan D. Ashby +Copyright (c) 2015-2020, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 39ee0561..e099dcdb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ENiGMA½ BBS Software -![alt text](docs/images/enigma-bbs.png "ENiGMA½ BBS") +![ENiGMA½ BBS](docs/assets/images/enigma-bbs.png "ENiGMA½ BBS") ENiGMA½ is a modern BBS software with a nostalgic flair! @@ -8,38 +8,40 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! ## Features Available Now * Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows) * Unlimited multi node support (for all those BBS "callers"!) - * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods - * [MCI support](docs/mci.md) for lightbars, toggles, input areas, and so on plus many other other bells and whistles + * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based [mods](docs/modding/existing-mods.md) + * [MCI support](docs/art/mci.md) for lightbars, toggles, input areas, and so on plus many other other bells and whistles * Telnet, **SSH**, and both secure and non-secure [WebSocket](https://en.wikipedia.org/wiki/WebSocket) access built in! Additional servers are easy to implement * [CP437](http://www.ascii-codes.com/) and UTF-8 output - * [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior - * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support - * Renegade style pipe color codes - * [SQLite](http://sqlite.org/) storage of users, message areas, and so on - * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption - * [Door support](docs/doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), and [Exodus](https://oddnetwork.org/exodus/) support! - * [Bunyan](https://github.com/trentm/node-bunyan) logging - * [Message networks](docs/msg_networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export - * [Gazelle](https://github.com/WhatCD/Gazelle) inspirted File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/web_server.md). Legacy X/Y/Z modem also supported! + * [SyncTERM](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior. + * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support. + * 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)! + * [Gazelle](https://github.com/WhatCD/Gazelle) inspired File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/servers/web-server.md). Legacy X/Y/Z modem also supported! * Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more! - * ANSI support in the Full Screen Editor (FSE), file descriptions, and so on - + * ANSI support in the Full Screen Editor (FSE), file descriptions, etc. + * A built in achievement system. BBSing gamified! + +## Documentation +[Browse the docs online](https://nuskooler.github.io/enigma-bbs/). Be sure to checkout the [/docs/](/docs/) folder as well for the latest and greatest documentation. + ## In the Works -* More ES6+ usage, and **documentation**! -* More ACS support coverage -* SysOp dashboard (ye ol' WFC) -* A lot more! Feel free to request features via [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) +Many more features are in the pipeline. Checkout the [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) and feel free to request features (or contribute!) features. ## Known Issues -As of now this is considered **alpha** code! Please **expect bugs** :bug: -- and when you find them, log issues and/or submit pull requests. Feature requests, suggestions, and so on are always welcome! I am also **looking for semi dedicated testers, artists, etc**! +As of now this is considered **alpha** code! Please **expect bugs** :bug: -- and when you find them, log issues and/or submit pull requests. With that said, the code is actually quite stable and is used by a number of boards. See [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) for more information. ## Support * 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** -* Discussion on [fsxNet](http://bbs.geek.nz/#fsxNet) available on many boards +* IRC: **#enigma-bbs** on **chat.freenode.net** ([webchat](https://webchat.freenode.net/?channels=enigma-bbs)) +* 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/) @@ -49,21 +51,25 @@ ENiGMA has been tested with many terminals. However, the following are suggested * [SyncTERM](http://syncterm.bbsdev.net/) * [EtherTerm](https://github.com/M-griffin/EtherTerm) * [NetRunner](http://mysticbbs.com/downloads.html) +* [MagiTerm](https://magickabbs.com/index.php/magiterm/) -## Boards -* WQH: :skull: [Xibalba](https://l33t.codes/xibalba-bbs) :skull: (**telnet://xibalba.l33t.codes:44510** or via SSH secure on port 44511) -* [Exotica](https://exoticabbs.com/): (**telnet://exoticabbs.com:8888**) -* [force9](http://bbs.force9.org/): (**telnet://bbs.force9.org**) - +## 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.11-beta/misc/install.sh | bash ``` -Please see the [Quickstart](docs/index.md) for more information. +Please see [Installation Methods](https://nuskooler.github.io/enigma-bbs/installation/installation-methods.html) for Windows, Docker, and so on... ## Special Thanks +* [Dave Stephens aka RiPuk](https://github.com/davestephens) for the awesome [ENiGMA website](https://enigma-bbs.github.io/) and [KICK ASS documentation](https://nuskooler.github.io/enigma-bbs/), code contributions, etc. * [Daniel Mecklenburg Jr.](https://github.com/codewar65) for the awesome VTX terminal and general coding talk * [M. Brutman](http://www.brutman.com/), author of [mTCP](http://www.brutman.com/mTCP/mTCP.html) (Interwebs for DOS!) * [M. Griffin](https://github.com/M-griffin), author of [Enthral BBS](https://github.com/M-griffin/Enthral), [Oblivion/2 XRM](https://github.com/M-griffin/Oblivion2-XRM) and [EtherTerm](https://github.com/M-griffin/EtherTerm)! @@ -71,14 +77,17 @@ Please see the [Quickstart](docs/index.md) for more information. * [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) +* 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 HappyLand BBS and [HappyNet](http://andrew.homeunix.org/doku.php?id=happynet)! +* [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: -Copyright (c) 2015-2017, Bryan D. Ashby +Copyright (c) 2015-2020, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/UPGRADE.md b/UPGRADE.md index 802a8092..3fcd35a8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,65 +1,127 @@ -# Introduction -This document covers basic upgrade notes for major ENiGMA½ version updates. - - -# Before Upgrading -* Always back up your system! -* At least back up the `db` directory and your `menu.hjson` (or renamed equivalent) - - -# General Notes -Upgrades often come with changes to the default `menu.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: - -```hjson -general: { - menuFile: my_bbs.hjson -} -``` - -After updating code, use a program such as DiffMerge to merge in updates to -`my_bbs.hjson` from the shipping `menu.hjson`. - - -# Upgrading the Code -Upgrading from GitHub is easy: - -```bash -cd /path/to/enigma-bbs -git pull -rm -rf npm_modules # do this any time you update Node.js itself -npm install -``` - - -# Problems -Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or -[file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues). - - -# 0.0.1-alpha to 0.0.4-alpha -## Node.js 6.x+ LTS is now **required** -You will need to upgrade Node.js to [6.x+](https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V6.md). If using [nvm](https://github.com/creationix/nvm) (you should be!) the process will go something like this: -```bash -nvm install 6 -nvm alias default 6 -``` - -### ES6 -Newly written code will use ES6 and a lot of code has started the migration process. Of note is the `MenuModule` class. If you have created a mod that inherits from `MenuModule`, you will need to upgrade your class to ES6. - -## Manual Database Upgrade -A few upgrades need to be made to your SQLite databases: - -```bash -rm db/file.sqltie3 # safe to delete this time as it was not used previously -sqlite3 db/message.sqlite -sqlite> INSERT INTO message_fts(message_fts) VALUES('rebuild'); -``` - -## Archiver Changes -If you have overridden or made additions to archivers in your `config.hjson` you will need to update them. See [Archive Configuration](docs/archive.md) and `core/config.js` - -## File Base Configuration -As 0.0.4-alpha contains file bases, you'll want to create a suitable configuration if you wish to use the feature. See [File Base Configuration](docs/file_base.md). +# Introduction +This document covers basic upgrade notes for major ENiGMA½ version updates. + +# Before Upgrading +* Always back up your system! +* Seriously, always back up your system! +* At least back up the `db` directory and your `menu.hjson` (or renamed equivalent) + +# General Notes +## Configuration File Updates +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: + +```hjson +general: { + menuFile: my_bbs.hjson +} +``` + +After updating code, use a program such as DiffMerge to merge in updates to +`my_bbs.hjson` from the shipping `menu.hjson`. + +### theme.hjson +Any custom themes you have created may now be missing features as well. Take a look at the default `luciano_blocktronics/theme.hjson` file. You can use missing sections in your `theme.hjson` (which will generally correspond to sections you've also merged in to your `menu.hjson`). + + +# Upgrading the Code +Upgrading from GitHub is easy: + +```bash +cd /path/to/enigma-bbs +git pull +rm -rf npm_modules # do this any time you update Node.js itself +npm install +``` + +# Problems +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.10-alpha to 0.0.11-beta +* Node.js 12.x LTS is now in use. Follow standard Node.js upgrade procedures (e.g.: `nvm install 12 && nvm use 12`). + +# 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. +* Possible breaking changes in FSE: The MCI code `%TL13` for error indicator is now `%TL4`. This is part of a cleanup and standardization on "custom ranges". You may need to update your `theme.hjson` and related artwork. +* Removed view width auto-size: Some views still can auto-size their height, but in general you should be explicit in your themes +* More standardization using "custom ranges" and `itemFormat` / `focusItemFormat` semantics. Update your themes! +* In addition to using `itemFormat`, the `onelinerz` module uses `userName` vs `username` (note the case) to match other modules +* `loginServers.webSocket` configuration block has changed to be more consistent with other servers. Example: +``` +webSocket: { + ws: { + enabled: true + } + wss: { + enabled: true + port: 1234 + } + proxied: true // X-Forwarded-Proto: https support +} +``` +* The module export `registerEvents` has been deprecated. If you have a module that depends on this, use the new more generic `moduleInitialize` export instead. +* The `system.db` `user_event_log` table has been updated to include a unique session ID. Previously this table was not used, but you will need to perform a slight maintenance task before it can be properly used. After updating to `0.0.9-alpha`, please run the following: `sqlite3 db/system.db DROP TABLE user_event_log;`. The new table format will be created and used at startup. +* If you have art configured for message conference or area selection via the `art` configuration value, you will need to include a `show_art` menu reference. Defaulted to `changeMessageConfPreArt` for conferences and `changeMessageAreaPreArt` for areas & included in the example `menu.hjson`. +* Config `defaults` section was theme related and as such, has been renamed to `theme`. `defaults.theme` is now `theme.default`, and `preLoginTheme` is now `theme.preLogin`. See `config.js` if this isn't clear as mud. +* Similar to the last item, `defaults.general.passwordChar` in `theme.hjson` is now just `defaults.passwordChar`. + + +# 0.0.7-alpha to 0.0.8-alpha +ENiGMA 0.0.8-alpha comes with some structure changes: +* Configuration files are defaulted to `./config`. Related, the `--config` option now points to a configuration **directory** +* `./mods/art` has been moved to `./art/general` +* `./mods` is now reserved for actual user addon modules +* Themes have been moved from `./mods/themes` to `./art/themes` + +With the change to the `./mods` directory, `@systemModule` is now implied for `module` declarations in `menu.hjson`. To use a user module in `./mods` you must specify `@userModule`! + +With the above changes, you'll need to to at least: +* Move your `~/.config/enigma-bbs/config.hjson` to `./config/config.hjson` or utlize the `--config` option. +* Move your `prompt.hjson` and `menu.hjson` (e.g. `myboardname.hjson`) to `./config` +* Move any non-theme art files, and theme directories to their appropriate locations mentioned above +* Move any module directories such as `message_post_evt` to `./mods/` +* Move any certificates, pub/private keys, etc. from `./misc` to `./config` +* Specify user modules as `@userModule:my_module_name` + +# 0.0.6-alpha to 0.0.7-alpha +No issues + +# 0.0.5-alpha to 0.0.6-alpha +No issues + +# 0.0.4-alpha to 0.0.5-alpha +No issues + +# 0.0.1-alpha to 0.0.4-alpha +## Node.js 6.x+ LTS is now **required** +You will need to upgrade Node.js to [6.x+](https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V6.md). If using [nvm](https://github.com/creationix/nvm) (you should be!) the process will go something like this: +```bash +nvm install 6 +nvm alias default 6 +``` + +### ES6 +Newly written code will use ES6 and a lot of code has started the migration process. Of note is the `MenuModule` class. If you have created a mod that inherits from `MenuModule`, you will need to upgrade your class to ES6. + +## Manual Database Upgrade +A few upgrades need to be made to your SQLite databases: + +```bash +rm db/file.sqltie3 # safe to delete this time as it was not used previously +sqlite3 db/message.sqlite +sqlite> INSERT INTO message_fts(message_fts) VALUES('rebuild'); +``` + +## Archiver Changes +If you have overridden or made additions to archivers in your `config.hjson` you will need to update them. See [Archive Configuration](docs/archive.md) and `core/config.js` + +## File Base Configuration +As 0.0.4-alpha contains file bases, you'll want to create a suitable configuration if you wish to use the feature. See [File Base Configuration](docs/file_base.md). diff --git a/WHATSNEW.md b/WHATSNEW.md new file mode 100644 index 00000000..21626d92 --- /dev/null +++ b/WHATSNEW.md @@ -0,0 +1,109 @@ +# Whats New +This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub. + +## 0.0.11-beta +* Upgraded from `alpha` to `beta` -- The software is far along and mature enough at this point! +* Development is now against Node.js 12.x LTS. Other versions may work but are not currently supported! +* [QWK support](/docs/messageareas/qwk.md) +* `oputil fb scan *areaTagWildcard*` scans all areas in which wildcard is matched. +* The archiver configuration `escapeTelnet` has been renamed `escapeIACs`. Support for the old value will be removed in the future. + +## 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!) +* Menu items can now be arrays of *objects* not just arrays of strings. + * The properties `itemFormat` and `focusItemFormat` allow you to supply the string format for items. For example if a menu object is `{ "userName" : "Bob", "age" : 35 }`, a `itemFormat` might be `|04{userName} |08- |14{age}`. + * If no `itemFormat` is supplied, the default formatter is `{text}`. + * Setting the `data` member of an object will cause form submissions to use this value instead of the selected items index. + * See the default `luciano_blocktronics` `matrix` menu for example usage. +* You can now set the `sort` property on a menu to sort items. If `true` items are sorted by `text`. If the value is a string, it represents the key in menu objects to sort by. +* Hot-reload of configuration files such as menu.hjson, config.hjson, your themes.hjson, etc.: When a file is saved, it will be hot-reloaded into the running system + * Note that any custom modules should make use of the new Config.get() method. +* The old concept of `autoScale` has been removed. See https://github.com/NuSkooler/enigma-bbs/issues/166 +* Ability to delete from personal mailbox (finally!) +* 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.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. +* Handling of failed login attempts is now fully in. Disconnect clients, lock out accounts, ability to auto or unlock at (email-driven) password reset, etc. See `users.failedLogin` in `config.hjson`. +* NNTP support! See [NNTP docs](/docs/servers/nntp.md) for more information. +* `oputil.js user rm` and `oputil.js user info` are in! See [oputil CLI](/docs/admin/oputil.md). +* 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. +* File descriptions (FILE_ID.DIZ, etc.) now support Renegade |## pipe, PCBoard, and other less common color codes found commonly in BBS era scene releases. +* New menu stack flags: `noHistory` now works as expected, and a new addition of `popParent`. See the default `menu.hjson` for usage. +* File structure changes making ENiGMA½ much easier to maintain and run in Docker. Thanks to RiPuk ([Dave Stephens](https://github.com/davestephens))! See [UPGRADE.md](UPGRADE.md) for details. +* Switch to pure JS [xxhash](https://github.com/mscdex/node-xxhash) instead of farmhash. Too many issues on ARM and other less popular CPUs with farmhash ([Dave Stephens](https://github.com/davestephens)) +* Native [CombatNet](http://combatnet.us/) support! ([Dave Stephens](https://github.com/davestephens)) +* Fix various issues with legacy DOS Telnet terminals. Note that some may still have issues with extensive CPR usage by ENiGMA½ that will be addressed in a future release. +* 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 +* Correctly parse oddball `INTL`, `TOPT`, `FMPT`, `Via`, etc. FTN kludge lines +* NetMail support! You can now send and receive NetMail. To send a NetMail address a external user using `Name
` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`) +* `oputil.js`: Added `mb areafix` command to quickly send AreaFix messages from the command line. You can manually send them from personal mail as well. +* `oputil.js fb rm|remove|del|delete` functionality to remove file base entries. +* `oputil.js fb desc` for setting/updating a file entry description. +* Users can now (re)set File and Message base pointers +* Add `--update` option to `oputil.js fb scan` +* Fix @watch path support for event scheduler including FTN, e.g. when looking for a `toss!.now` file produced by Binkd. + +...LOTS more! + +## Pre 0.0.8-alpha +See GitHub \ No newline at end of file diff --git a/mods/art/DOORMANY.ANS b/art/general/DOORMANY.ANS similarity index 100% rename from mods/art/DOORMANY.ANS rename to art/general/DOORMANY.ANS diff --git a/mods/art/GNSPMPT.ANS b/art/general/GNSPMPT.ANS similarity index 100% rename from mods/art/GNSPMPT.ANS rename to art/general/GNSPMPT.ANS diff --git a/mods/art/LOGPMPT.ANS b/art/general/LOGPMPT.ANS similarity index 100% rename from mods/art/LOGPMPT.ANS rename to art/general/LOGPMPT.ANS diff --git a/art/general/NEWSCAN.ANS b/art/general/NEWSCAN.ANS new file mode 100644 index 00000000..2762c9f1 Binary files /dev/null and b/art/general/NEWSCAN.ANS differ diff --git a/mods/art/NEWUSER1.ANS b/art/general/NEWUSER1.ANS similarity index 77% rename from mods/art/NEWUSER1.ANS rename to art/general/NEWUSER1.ANS index 70edd11e..267c331c 100644 Binary files a/mods/art/NEWUSER1.ANS and b/art/general/NEWUSER1.ANS differ diff --git a/mods/art/ONEADD.ANS b/art/general/ONEADD.ANS similarity index 100% rename from mods/art/ONEADD.ANS rename to art/general/ONEADD.ANS diff --git a/mods/art/ONELINER.ANS b/art/general/ONELINER.ANS similarity index 100% rename from mods/art/ONELINER.ANS rename to art/general/ONELINER.ANS diff --git a/mods/art/PRELOGAD.ANS b/art/general/PRELOGAD.ANS similarity index 100% rename from mods/art/PRELOGAD.ANS rename to art/general/PRELOGAD.ANS diff --git a/mods/art/WELCOME1.ANS b/art/general/WELCOME1.ANS similarity index 100% rename from mods/art/WELCOME1.ANS rename to art/general/WELCOME1.ANS diff --git a/mods/art/WELCOME2.ANS b/art/general/WELCOME2.ANS similarity index 100% rename from mods/art/WELCOME2.ANS rename to art/general/WELCOME2.ANS diff --git a/mods/art/demo_edit_text_view.ans b/art/general/demo_edit_text_view.ans similarity index 100% rename from mods/art/demo_edit_text_view.ans rename to art/general/demo_edit_text_view.ans diff --git a/mods/art/demo_edit_text_view1.ans b/art/general/demo_edit_text_view1.ans similarity index 100% rename from mods/art/demo_edit_text_view1.ans rename to art/general/demo_edit_text_view1.ans diff --git a/mods/art/demo_fse_local_user.ans b/art/general/demo_fse_local_user.ans similarity index 100% rename from mods/art/demo_fse_local_user.ans rename to art/general/demo_fse_local_user.ans diff --git a/mods/art/demo_fse_netmail_body.ans b/art/general/demo_fse_netmail_body.ans similarity index 100% rename from mods/art/demo_fse_netmail_body.ans rename to art/general/demo_fse_netmail_body.ans diff --git a/mods/art/demo_fse_netmail_footer_edit.ans b/art/general/demo_fse_netmail_footer_edit.ans similarity index 100% rename from mods/art/demo_fse_netmail_footer_edit.ans rename to art/general/demo_fse_netmail_footer_edit.ans diff --git a/mods/art/demo_fse_netmail_footer_edit_menu.ans b/art/general/demo_fse_netmail_footer_edit_menu.ans similarity index 100% rename from mods/art/demo_fse_netmail_footer_edit_menu.ans rename to art/general/demo_fse_netmail_footer_edit_menu.ans diff --git a/mods/art/demo_fse_netmail_header.ans b/art/general/demo_fse_netmail_header.ans similarity index 100% rename from mods/art/demo_fse_netmail_header.ans rename to art/general/demo_fse_netmail_header.ans diff --git a/mods/art/demo_fse_netmail_help.ans b/art/general/demo_fse_netmail_help.ans similarity index 100% rename from mods/art/demo_fse_netmail_help.ans rename to art/general/demo_fse_netmail_help.ans diff --git a/mods/art/demo_horizontal_menu_view1.ans b/art/general/demo_horizontal_menu_view1.ans similarity index 100% rename from mods/art/demo_horizontal_menu_view1.ans rename to art/general/demo_horizontal_menu_view1.ans diff --git a/mods/art/demo_mask_edit_text_view1.ans b/art/general/demo_mask_edit_text_view1.ans similarity index 100% rename from mods/art/demo_mask_edit_text_view1.ans rename to art/general/demo_mask_edit_text_view1.ans diff --git a/mods/art/demo_multi_line_edit_text_view1.ans b/art/general/demo_multi_line_edit_text_view1.ans similarity index 100% rename from mods/art/demo_multi_line_edit_text_view1.ans rename to art/general/demo_multi_line_edit_text_view1.ans diff --git a/mods/art/demo_selection_vm.ans b/art/general/demo_selection_vm.ans similarity index 100% rename from mods/art/demo_selection_vm.ans rename to art/general/demo_selection_vm.ans diff --git a/mods/art/demo_spin_and_toggle.ans b/art/general/demo_spin_and_toggle.ans similarity index 100% rename from mods/art/demo_spin_and_toggle.ans rename to art/general/demo_spin_and_toggle.ans diff --git a/mods/art/demo_vertical_menu_view1.ans b/art/general/demo_vertical_menu_view1.ans similarity index 100% rename from mods/art/demo_vertical_menu_view1.ans rename to art/general/demo_vertical_menu_view1.ans diff --git a/mods/art/menu_prompt.ans b/art/general/menu_prompt.ans similarity index 100% rename from mods/art/menu_prompt.ans rename to art/general/menu_prompt.ans diff --git a/mods/art/msg_area_footer_view.ans b/art/general/msg_area_footer_view.ans similarity index 100% rename from mods/art/msg_area_footer_view.ans rename to art/general/msg_area_footer_view.ans diff --git a/mods/art/msg_area_list.ans b/art/general/msg_area_list.ans similarity index 100% rename from mods/art/msg_area_list.ans rename to art/general/msg_area_list.ans diff --git a/mods/art/msg_area_post_header.ans b/art/general/msg_area_post_header.ans similarity index 100% rename from mods/art/msg_area_post_header.ans rename to art/general/msg_area_post_header.ans diff --git a/mods/art/msg_area_view_header.ans b/art/general/msg_area_view_header.ans similarity index 100% rename from mods/art/msg_area_view_header.ans rename to art/general/msg_area_view_header.ans diff --git a/mods/art/test.ans b/art/general/test.ans similarity index 100% rename from mods/art/test.ans rename to art/general/test.ans diff --git a/art/themes/luciano_blocktronics/2FACONFSCR.ans b/art/themes/luciano_blocktronics/2FACONFSCR.ans new file mode 100644 index 00000000..3c256000 Binary files /dev/null and b/art/themes/luciano_blocktronics/2FACONFSCR.ans differ diff --git a/art/themes/luciano_blocktronics/2FAOTP.ans b/art/themes/luciano_blocktronics/2FAOTP.ans new file mode 100644 index 00000000..7828fe6a Binary files /dev/null and b/art/themes/luciano_blocktronics/2FAOTP.ans differ diff --git a/art/themes/luciano_blocktronics/2FAOTPSECREQ.ans b/art/themes/luciano_blocktronics/2FAOTPSECREQ.ans new file mode 100644 index 00000000..c81720ec Binary files /dev/null and b/art/themes/luciano_blocktronics/2FAOTPSECREQ.ans differ diff --git a/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS b/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS new file mode 100644 index 00000000..d0d2cd0e Binary files /dev/null and b/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS differ diff --git a/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS b/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS new file mode 100644 index 00000000..b743a3cf Binary files /dev/null and b/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS differ diff --git a/mods/themes/luciano_blocktronics/BBSADD.ANS b/art/themes/luciano_blocktronics/BBSADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/BBSADD.ANS rename to art/themes/luciano_blocktronics/BBSADD.ANS diff --git a/mods/themes/luciano_blocktronics/BBSLIST.ANS b/art/themes/luciano_blocktronics/BBSLIST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/BBSLIST.ANS rename to art/themes/luciano_blocktronics/BBSLIST.ANS diff --git a/mods/themes/luciano_blocktronics/CCHANGE.ANS b/art/themes/luciano_blocktronics/CCHANGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/CCHANGE.ANS rename to art/themes/luciano_blocktronics/CCHANGE.ANS diff --git a/art/themes/luciano_blocktronics/CHANGE.ANS b/art/themes/luciano_blocktronics/CHANGE.ANS new file mode 100644 index 00000000..056dd1cf Binary files /dev/null and b/art/themes/luciano_blocktronics/CHANGE.ANS differ diff --git a/mods/themes/luciano_blocktronics/CONFSCR.ANS b/art/themes/luciano_blocktronics/CONFSCR.ANS similarity index 87% rename from mods/themes/luciano_blocktronics/CONFSCR.ANS rename to art/themes/luciano_blocktronics/CONFSCR.ANS index 0bf72430..e3db0cbb 100644 Binary files a/mods/themes/luciano_blocktronics/CONFSCR.ANS and b/art/themes/luciano_blocktronics/CONFSCR.ANS differ diff --git a/mods/themes/luciano_blocktronics/DONE.ANS b/art/themes/luciano_blocktronics/DONE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/DONE.ANS rename to art/themes/luciano_blocktronics/DONE.ANS diff --git a/mods/themes/luciano_blocktronics/DOORMNU.ANS b/art/themes/luciano_blocktronics/DOORMNU.ANS similarity index 77% rename from mods/themes/luciano_blocktronics/DOORMNU.ANS rename to art/themes/luciano_blocktronics/DOORMNU.ANS index bcd28f39..9621ec1d 100644 Binary files a/mods/themes/luciano_blocktronics/DOORMNU.ANS and b/art/themes/luciano_blocktronics/DOORMNU.ANS differ diff --git a/mods/themes/luciano_blocktronics/FAREASEL.ANS b/art/themes/luciano_blocktronics/FAREASEL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FAREASEL.ANS rename to art/themes/luciano_blocktronics/FAREASEL.ANS diff --git a/mods/themes/luciano_blocktronics/FBHELP.ANS b/art/themes/luciano_blocktronics/FBHELP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBHELP.ANS rename to art/themes/luciano_blocktronics/FBHELP.ANS diff --git a/art/themes/luciano_blocktronics/FBLISTEXP.ANS b/art/themes/luciano_blocktronics/FBLISTEXP.ANS new file mode 100644 index 00000000..73a567f0 Binary files /dev/null and b/art/themes/luciano_blocktronics/FBLISTEXP.ANS differ diff --git a/art/themes/luciano_blocktronics/FBLISTEXPSEARCH.ANS b/art/themes/luciano_blocktronics/FBLISTEXPSEARCH.ANS new file mode 100644 index 00000000..3efe5592 Binary files /dev/null and b/art/themes/luciano_blocktronics/FBLISTEXPSEARCH.ANS differ diff --git a/mods/themes/luciano_blocktronics/FBNORES.ANS b/art/themes/luciano_blocktronics/FBNORES.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBNORES.ANS rename to art/themes/luciano_blocktronics/FBNORES.ANS diff --git a/mods/themes/luciano_blocktronics/FBRWSE.ANS b/art/themes/luciano_blocktronics/FBRWSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBRWSE.ANS rename to art/themes/luciano_blocktronics/FBRWSE.ANS diff --git a/mods/themes/luciano_blocktronics/FDETAIL.ANS b/art/themes/luciano_blocktronics/FDETAIL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETAIL.ANS rename to art/themes/luciano_blocktronics/FDETAIL.ANS diff --git a/mods/themes/luciano_blocktronics/FDETGEN.ANS b/art/themes/luciano_blocktronics/FDETGEN.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETGEN.ANS rename to art/themes/luciano_blocktronics/FDETGEN.ANS diff --git a/mods/themes/luciano_blocktronics/FDETLST.ANS b/art/themes/luciano_blocktronics/FDETLST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETLST.ANS rename to art/themes/luciano_blocktronics/FDETLST.ANS diff --git a/mods/themes/luciano_blocktronics/FDETNFO.ANS b/art/themes/luciano_blocktronics/FDETNFO.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETNFO.ANS rename to art/themes/luciano_blocktronics/FDETNFO.ANS diff --git a/mods/themes/luciano_blocktronics/FDLMGR.ANS b/art/themes/luciano_blocktronics/FDLMGR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDLMGR.ANS rename to art/themes/luciano_blocktronics/FDLMGR.ANS diff --git a/mods/themes/luciano_blocktronics/FEMPTYQ.ANS b/art/themes/luciano_blocktronics/FEMPTYQ.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FEMPTYQ.ANS rename to art/themes/luciano_blocktronics/FEMPTYQ.ANS diff --git a/mods/themes/luciano_blocktronics/FFILEDT.ANS b/art/themes/luciano_blocktronics/FFILEDT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FFILEDT.ANS rename to art/themes/luciano_blocktronics/FFILEDT.ANS diff --git a/mods/themes/luciano_blocktronics/FILPMPT.ANS b/art/themes/luciano_blocktronics/FILPMPT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FILPMPT.ANS rename to art/themes/luciano_blocktronics/FILPMPT.ANS diff --git a/mods/themes/luciano_blocktronics/FMENU.ANS b/art/themes/luciano_blocktronics/FMENU.ANS similarity index 84% rename from mods/themes/luciano_blocktronics/FMENU.ANS rename to art/themes/luciano_blocktronics/FMENU.ANS index a187491b..1e340430 100644 Binary files a/mods/themes/luciano_blocktronics/FMENU.ANS and b/art/themes/luciano_blocktronics/FMENU.ANS differ diff --git a/mods/themes/luciano_blocktronics/FNEWBRWSE.ANS b/art/themes/luciano_blocktronics/FNEWBRWSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FNEWBRWSE.ANS rename to art/themes/luciano_blocktronics/FNEWBRWSE.ANS diff --git a/mods/themes/luciano_blocktronics/FORGOTPW.ANS b/art/themes/luciano_blocktronics/FORGOTPW.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FORGOTPW.ANS rename to art/themes/luciano_blocktronics/FORGOTPW.ANS diff --git a/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS b/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS new file mode 100644 index 00000000..c0ebbfd9 Binary files /dev/null and b/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS differ diff --git a/mods/themes/luciano_blocktronics/FPROSEL.ANS b/art/themes/luciano_blocktronics/FPROSEL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FPROSEL.ANS rename to art/themes/luciano_blocktronics/FPROSEL.ANS diff --git a/mods/themes/luciano_blocktronics/FSEARCH.ANS b/art/themes/luciano_blocktronics/FSEARCH.ANS similarity index 96% rename from mods/themes/luciano_blocktronics/FSEARCH.ANS rename to art/themes/luciano_blocktronics/FSEARCH.ANS index efb19617..e97ec3d6 100644 Binary files a/mods/themes/luciano_blocktronics/FSEARCH.ANS and b/art/themes/luciano_blocktronics/FSEARCH.ANS differ diff --git a/art/themes/luciano_blocktronics/FWDLMGR.ANS b/art/themes/luciano_blocktronics/FWDLMGR.ANS new file mode 100644 index 00000000..b0e7fffc Binary files /dev/null and b/art/themes/luciano_blocktronics/FWDLMGR.ANS differ diff --git a/mods/themes/luciano_blocktronics/IDLELOG.ANS b/art/themes/luciano_blocktronics/IDLELOG.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/IDLELOG.ANS rename to art/themes/luciano_blocktronics/IDLELOG.ANS diff --git a/mods/themes/luciano_blocktronics/LASTCALL.ANS b/art/themes/luciano_blocktronics/LASTCALL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/LASTCALL.ANS rename to art/themes/luciano_blocktronics/LASTCALL.ANS diff --git a/mods/themes/luciano_blocktronics/LETTER.ANS b/art/themes/luciano_blocktronics/LETTER.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/LETTER.ANS rename to art/themes/luciano_blocktronics/LETTER.ANS diff --git a/mods/themes/luciano_blocktronics/MAILMNU.ANS b/art/themes/luciano_blocktronics/MAILMNU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MAILMNU.ANS rename to art/themes/luciano_blocktronics/MAILMNU.ANS diff --git a/art/themes/luciano_blocktronics/MATRIX.ANS b/art/themes/luciano_blocktronics/MATRIX.ANS new file mode 100644 index 00000000..14219543 Binary files /dev/null and b/art/themes/luciano_blocktronics/MATRIX.ANS differ diff --git a/mods/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS similarity index 73% rename from mods/themes/luciano_blocktronics/MMENU.ANS rename to art/themes/luciano_blocktronics/MMENU.ANS index 995a5db5..9db39ca9 100644 Binary files a/mods/themes/luciano_blocktronics/MMENU.ANS and b/art/themes/luciano_blocktronics/MMENU.ANS differ diff --git a/mods/themes/luciano_blocktronics/MNUPRMT.ANS b/art/themes/luciano_blocktronics/MNUPRMT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MNUPRMT.ANS rename to art/themes/luciano_blocktronics/MNUPRMT.ANS diff --git a/art/themes/luciano_blocktronics/MSEARCH.ANS b/art/themes/luciano_blocktronics/MSEARCH.ANS new file mode 100644 index 00000000..822ce65f Binary files /dev/null and b/art/themes/luciano_blocktronics/MSEARCH.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGBODY.ANS b/art/themes/luciano_blocktronics/MSGBODY.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGBODY.ANS rename to art/themes/luciano_blocktronics/MSGBODY.ANS diff --git a/art/themes/luciano_blocktronics/MSGDELPMPT.ANS b/art/themes/luciano_blocktronics/MSGDELPMPT.ANS new file mode 100644 index 00000000..74713b1f Binary files /dev/null and b/art/themes/luciano_blocktronics/MSGDELPMPT.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGEFTR.ANS b/art/themes/luciano_blocktronics/MSGEFTR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEFTR.ANS rename to art/themes/luciano_blocktronics/MSGEFTR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEHDR.ANS b/art/themes/luciano_blocktronics/MSGEHDR.ANS similarity index 95% rename from mods/themes/luciano_blocktronics/MSGEHDR.ANS rename to art/themes/luciano_blocktronics/MSGEHDR.ANS index b2ed34e7..c455a9a3 100644 Binary files a/mods/themes/luciano_blocktronics/MSGEHDR.ANS and b/art/themes/luciano_blocktronics/MSGEHDR.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGEHLP.ANS b/art/themes/luciano_blocktronics/MSGEHLP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEHLP.ANS rename to art/themes/luciano_blocktronics/MSGEHLP.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEMFT.ANS b/art/themes/luciano_blocktronics/MSGEMFT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEMFT.ANS rename to art/themes/luciano_blocktronics/MSGEMFT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGLIST.ANS b/art/themes/luciano_blocktronics/MSGLIST.ANS similarity index 76% rename from mods/themes/luciano_blocktronics/MSGLIST.ANS rename to art/themes/luciano_blocktronics/MSGLIST.ANS index 911f1f20..b67b31a6 100644 Binary files a/mods/themes/luciano_blocktronics/MSGLIST.ANS and b/art/themes/luciano_blocktronics/MSGLIST.ANS differ diff --git a/art/themes/luciano_blocktronics/MSGMNU.ANS b/art/themes/luciano_blocktronics/MSGMNU.ANS new file mode 100644 index 00000000..01777dd9 Binary files /dev/null and b/art/themes/luciano_blocktronics/MSGMNU.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGPMPT.ANS b/art/themes/luciano_blocktronics/MSGPMPT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGPMPT.ANS rename to art/themes/luciano_blocktronics/MSGPMPT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGQUOT.ANS b/art/themes/luciano_blocktronics/MSGQUOT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGQUOT.ANS rename to art/themes/luciano_blocktronics/MSGQUOT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVFTR.ANS b/art/themes/luciano_blocktronics/MSGVFTR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVFTR.ANS rename to art/themes/luciano_blocktronics/MSGVFTR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVHDR.ANS b/art/themes/luciano_blocktronics/MSGVHDR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVHDR.ANS rename to art/themes/luciano_blocktronics/MSGVHDR.ANS diff --git a/art/themes/luciano_blocktronics/MSGVHLP.ANS b/art/themes/luciano_blocktronics/MSGVHLP.ANS new file mode 100644 index 00000000..6c79cbe2 Binary files /dev/null and b/art/themes/luciano_blocktronics/MSGVHLP.ANS differ diff --git a/art/themes/luciano_blocktronics/MSRCHLST.ANS b/art/themes/luciano_blocktronics/MSRCHLST.ANS new file mode 100644 index 00000000..4a9982ff Binary files /dev/null and b/art/themes/luciano_blocktronics/MSRCHLST.ANS differ diff --git a/art/themes/luciano_blocktronics/MSRCNORES.ANS b/art/themes/luciano_blocktronics/MSRCNORES.ANS new file mode 100644 index 00000000..81464593 Binary files /dev/null and b/art/themes/luciano_blocktronics/MSRCNORES.ANS differ diff --git a/art/themes/luciano_blocktronics/MYMSGLST.ANS b/art/themes/luciano_blocktronics/MYMSGLST.ANS new file mode 100644 index 00000000..a56197bc Binary files /dev/null and b/art/themes/luciano_blocktronics/MYMSGLST.ANS differ diff --git a/mods/themes/luciano_blocktronics/NEWMSGS.ANS b/art/themes/luciano_blocktronics/NEWMSGS.ANS similarity index 77% rename from mods/themes/luciano_blocktronics/NEWMSGS.ANS rename to art/themes/luciano_blocktronics/NEWMSGS.ANS index 5a58161e..90439992 100644 Binary files a/mods/themes/luciano_blocktronics/NEWMSGS.ANS and b/art/themes/luciano_blocktronics/NEWMSGS.ANS differ diff --git a/art/themes/luciano_blocktronics/NODEMSG.ANS b/art/themes/luciano_blocktronics/NODEMSG.ANS new file mode 100644 index 00000000..ebe742df Binary files /dev/null and b/art/themes/luciano_blocktronics/NODEMSG.ANS differ diff --git a/art/themes/luciano_blocktronics/NODEMSGFTR.ANS b/art/themes/luciano_blocktronics/NODEMSGFTR.ANS new file mode 100644 index 00000000..8cb30568 Binary files /dev/null and b/art/themes/luciano_blocktronics/NODEMSGFTR.ANS differ diff --git a/art/themes/luciano_blocktronics/NODEMSGHDR.ANS b/art/themes/luciano_blocktronics/NODEMSGHDR.ANS new file mode 100644 index 00000000..9e38285a Binary files /dev/null and b/art/themes/luciano_blocktronics/NODEMSGHDR.ANS differ diff --git a/mods/themes/luciano_blocktronics/NUA.ANS b/art/themes/luciano_blocktronics/NUA.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/NUA.ANS rename to art/themes/luciano_blocktronics/NUA.ANS diff --git a/mods/themes/luciano_blocktronics/ONEADD.ANS b/art/themes/luciano_blocktronics/ONEADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ONEADD.ANS rename to art/themes/luciano_blocktronics/ONEADD.ANS diff --git a/mods/themes/luciano_blocktronics/ONELINER.ANS b/art/themes/luciano_blocktronics/ONELINER.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ONELINER.ANS rename to art/themes/luciano_blocktronics/ONELINER.ANS diff --git a/mods/themes/luciano_blocktronics/PAUSE.ANS b/art/themes/luciano_blocktronics/PAUSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/PAUSE.ANS rename to art/themes/luciano_blocktronics/PAUSE.ANS diff --git a/art/themes/luciano_blocktronics/PRVMSGLIST.ANS b/art/themes/luciano_blocktronics/PRVMSGLIST.ANS new file mode 100644 index 00000000..3969d72f Binary files /dev/null and b/art/themes/luciano_blocktronics/PRVMSGLIST.ANS differ diff --git a/mods/themes/luciano_blocktronics/RATEFILE.ANS b/art/themes/luciano_blocktronics/RATEFILE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RATEFILE.ANS rename to art/themes/luciano_blocktronics/RATEFILE.ANS diff --git a/mods/themes/luciano_blocktronics/RUMORADD.ANS b/art/themes/luciano_blocktronics/RUMORADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RUMORADD.ANS rename to art/themes/luciano_blocktronics/RUMORADD.ANS diff --git a/mods/themes/luciano_blocktronics/RUMORS.ANS b/art/themes/luciano_blocktronics/RUMORS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RUMORS.ANS rename to art/themes/luciano_blocktronics/RUMORS.ANS diff --git a/art/themes/luciano_blocktronics/SETFNSDATE.ANS b/art/themes/luciano_blocktronics/SETFNSDATE.ANS new file mode 100644 index 00000000..8294a6f7 Binary files /dev/null and b/art/themes/luciano_blocktronics/SETFNSDATE.ANS differ diff --git a/art/themes/luciano_blocktronics/SETMNSDATE.ANS b/art/themes/luciano_blocktronics/SETMNSDATE.ANS new file mode 100644 index 00000000..61cbb3da Binary files /dev/null and b/art/themes/luciano_blocktronics/SETMNSDATE.ANS differ diff --git a/mods/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS similarity index 60% rename from mods/themes/luciano_blocktronics/STATUS.ANS rename to art/themes/luciano_blocktronics/STATUS.ANS index b90ed2d9..dc2b0ca8 100644 Binary files a/mods/themes/luciano_blocktronics/STATUS.ANS and b/art/themes/luciano_blocktronics/STATUS.ANS differ diff --git a/mods/themes/luciano_blocktronics/SYSSTAT.ANS b/art/themes/luciano_blocktronics/SYSSTAT.ANS similarity index 50% rename from mods/themes/luciano_blocktronics/SYSSTAT.ANS rename to art/themes/luciano_blocktronics/SYSSTAT.ANS index 97beb53d..a76e3bd6 100644 Binary files a/mods/themes/luciano_blocktronics/SYSSTAT.ANS and b/art/themes/luciano_blocktronics/SYSSTAT.ANS differ diff --git a/mods/themes/luciano_blocktronics/TBRIDGE.ANS b/art/themes/luciano_blocktronics/TBRIDGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/TBRIDGE.ANS rename to art/themes/luciano_blocktronics/TBRIDGE.ANS diff --git a/mods/themes/luciano_blocktronics/TOONODE.ANS b/art/themes/luciano_blocktronics/TOONODE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/TOONODE.ANS rename to art/themes/luciano_blocktronics/TOONODE.ANS diff --git a/mods/themes/luciano_blocktronics/ULCHECK.ANS b/art/themes/luciano_blocktronics/ULCHECK.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULCHECK.ANS rename to art/themes/luciano_blocktronics/ULCHECK.ANS diff --git a/mods/themes/luciano_blocktronics/ULDETAIL.ANS b/art/themes/luciano_blocktronics/ULDETAIL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULDETAIL.ANS rename to art/themes/luciano_blocktronics/ULDETAIL.ANS diff --git a/mods/themes/luciano_blocktronics/ULDUPES.ANS b/art/themes/luciano_blocktronics/ULDUPES.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULDUPES.ANS rename to art/themes/luciano_blocktronics/ULDUPES.ANS diff --git a/mods/themes/luciano_blocktronics/ULNOAREA.ANS b/art/themes/luciano_blocktronics/ULNOAREA.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULNOAREA.ANS rename to art/themes/luciano_blocktronics/ULNOAREA.ANS diff --git a/mods/themes/luciano_blocktronics/ULOPTS.ANS b/art/themes/luciano_blocktronics/ULOPTS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULOPTS.ANS rename to art/themes/luciano_blocktronics/ULOPTS.ANS diff --git a/art/themes/luciano_blocktronics/USERACHIEV.ans b/art/themes/luciano_blocktronics/USERACHIEV.ans new file mode 100644 index 00000000..f061f04a Binary files /dev/null and b/art/themes/luciano_blocktronics/USERACHIEV.ans differ diff --git a/mods/themes/luciano_blocktronics/USERLOG.ANS b/art/themes/luciano_blocktronics/USERLOG.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/USERLOG.ANS rename to art/themes/luciano_blocktronics/USERLOG.ANS diff --git a/art/themes/luciano_blocktronics/USERLST.ANS b/art/themes/luciano_blocktronics/USERLST.ANS new file mode 100644 index 00000000..8c67ea58 Binary files /dev/null and b/art/themes/luciano_blocktronics/USERLST.ANS differ diff --git a/mods/themes/luciano_blocktronics/WHOSON.ANS b/art/themes/luciano_blocktronics/WHOSON.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/WHOSON.ANS rename to art/themes/luciano_blocktronics/WHOSON.ANS diff --git a/art/themes/luciano_blocktronics/achievement_global_footer.ans b/art/themes/luciano_blocktronics/achievement_global_footer.ans new file mode 100644 index 00000000..8cb30568 Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_global_footer.ans differ diff --git a/art/themes/luciano_blocktronics/achievement_global_header.ans b/art/themes/luciano_blocktronics/achievement_global_header.ans new file mode 100644 index 00000000..6104c2ca Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_global_header.ans differ diff --git a/art/themes/luciano_blocktronics/achievement_local_footer.ans b/art/themes/luciano_blocktronics/achievement_local_footer.ans new file mode 100644 index 00000000..8cb30568 Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_local_footer.ans differ diff --git a/art/themes/luciano_blocktronics/achievement_local_header.ans b/art/themes/luciano_blocktronics/achievement_local_header.ans new file mode 100644 index 00000000..6104c2ca Binary files /dev/null and b/art/themes/luciano_blocktronics/achievement_local_header.ans differ diff --git a/art/themes/luciano_blocktronics/autosig.ans b/art/themes/luciano_blocktronics/autosig.ans new file mode 100644 index 00000000..ce5b6d0d Binary files /dev/null and b/art/themes/luciano_blocktronics/autosig.ans differ diff --git a/art/themes/luciano_blocktronics/mrc.ans b/art/themes/luciano_blocktronics/mrc.ans new file mode 100644 index 00000000..aba8b142 Binary files /dev/null and b/art/themes/luciano_blocktronics/mrc.ans differ diff --git a/art/themes/luciano_blocktronics/offline_mail.ans b/art/themes/luciano_blocktronics/offline_mail.ans new file mode 100644 index 00000000..ffd21e9e Binary files /dev/null and b/art/themes/luciano_blocktronics/offline_mail.ans differ diff --git a/art/themes/luciano_blocktronics/qwk_export_progress.ans b/art/themes/luciano_blocktronics/qwk_export_progress.ans new file mode 100644 index 00000000..dcde7d82 Binary files /dev/null and b/art/themes/luciano_blocktronics/qwk_export_progress.ans differ diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson similarity index 55% rename from mods/themes/luciano_blocktronics/theme.hjson rename to art/themes/luciano_blocktronics/theme.hjson index d553ed83..d812a9c3 100644 --- a/mods/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -9,9 +9,7 @@ customization: { defaults: { - general: { - passwordChar: * - } + passwordChar: * dateTimeFormat: { short: MMM Do h:mm a @@ -22,7 +20,8 @@ matrix: { mci: { VM1: { - focusTextStyle: first lower + itemFormat: "|03{text}" + focusItemFormat: "|11{text!styleFirstLower}" } } } @@ -87,11 +86,15 @@ fullLoginSequenceOnelinerz: { config: { - listFormat: "|00|11{username:<12}|08: |03{oneliner:<59.58}" + dateTimeFormat: ddd h:mma } 0: { mci: { - VM1: { height: 10 } + VM1: { + height: 10 + width: 20 + itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}" + } TM2: { focusTextStyle: first lower } @@ -108,61 +111,92 @@ } } + mainMenuUserAchievementsEarned: { + config: { + dateTimeFormat: MMM Do h:mma + achievementsInfoFormat10: "|00|07\"|11{title}|07\"" + achievementsInfoFormat11: "|00|03{text}" + } + mci: { + VM1: { + height: 11 + width: 76 + itemFormat: "|00|15{ts} |07- |03{title:<47.46} |15{points:,}|07 pts" + focusItemFormat: "|00|19|15{ts} - {title:<47.46} {points:,} pts" + } + TL10: { + width: 76 + } + TL11: { + width: 76 + } + } + } + mainMenuUserStats: { mci: { - UN1: { width: 17 } - UR2: { width: 17 } - LO3: { width: 17 } - UF4: { width: 17 } - UG5: { width: 17 } - UT6: { width: 17 } - UC7: { width: 17 } - ST8: { width: 17 } + UN1: { width: 15 } + UR2: { width: 15 } + LO3: { width: 15 } + UF4: { width: 15 } + UG5: { width: 15 } + UT6: { width: 15 } + UC7: { width: 15 } + ST8: { width: 15 } } } mainMenuSystemStats: { mci: { BN1: { width: 17 } - VL2: { width: 17 } + VN2: { width: 17 } OS3: { width: 33 } SC4: { width: 33 } - DT5: { width: 33 } - CT6: { width: 33 } AN7: { width: 6 } ND8: { width: 6 } TC9: { width: 6 } + TT11: { width: 6 } + PT12: { width: 6 } + TP13: { width: 6 } + NV14: { width: 17 } } } mainMenuLastCallers: { config: { - listFormat: "|00|11{userName:<17.17}|03{location:<20.20}|11{affils:<17.17}|03{ts:<15}" dateTimeFormat: MMM Do h:mma } mci: { - VM1: { height: 10 } + VM1: { + height: 10, + width: 20 + itemFormat: "|00|11{userName:<17.16} |03{location:<20.19} |11{affils:<18.17} |03{ts:<15}" + } } } mainMenuUserList: { config: { - listFormat: "|00|11{userName:<17.17}|03{affils:<21.21}|11{note:<19.19}|03{lastLoginTs}" - focusListFormat: "|00|19|15{userName:<17.17}{affils:<21.21}{note:<19.19}{lastLoginTs}" dateTimeFormat: MMM Do h:mma } mci: { - VM1: { height: 15 } + VM1: { + height: 15, + width: 50 + itemFormat: "|00|11{userName:<17.17}|03{affils:<21.21}|11{location:<19.19}|03{lastLoginTs}" + focusItemFormat: "|00|19|15{userName:<17.17}{affils:<21.21}{location:<19.19}{lastLoginTs}" + } } } mainMenuWhosOnline: { - config: { - listFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}" - } mci: { - VM1: { height: 10 } + VM1: { + height: 10, + width: 20 + itemFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}" + } } } @@ -182,13 +216,15 @@ } mainMenuOnelinerz: { - // :TODO: Need way to just duplicate entry here & in menu.hjson, e.g. use: someName + must supply next/etc. in menu config: { - listFormat: "|00|11{username:<12}|08: |03{oneliner:<59.58}" + dateTimeFormat: ddd h:mma } 0: { mci: { - VM1: { height: 10 } + VM1: { + height: 10 + itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}" + } TM2: { focusTextStyle: first lower } @@ -206,40 +242,59 @@ } messageAreaMessageList: { - config: { - listFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |01|31{newIndicator}" - focusListFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}" + config: { dateTimeFormat: ddd MMM Do + allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}" } mci: { VM1: { height: 14 + width: 70 + itemFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |15{newIndicator}" + focusItemFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}" } } } messageAreaChangeCurrentConference: { - config: { - listFormat: "|00|15{index} |07- |03{name}" - focusListFormat: "|00|19|15{index} - {name}" - } mci: { VM1: { width: 26 height: 19 + itemFormat: "|00|15{index} |07- |03{name}" + focusItemFormat: "|00|19|15{index} - {name}" } } } messageAreaChangeCurrentArea: { - config: { - listFormat: "|00|15{index} |07- |03{name}" - focusListFormat: "|00|19|15{index} - {name}" - } mci: { VM1: { width: 26 height: 19 + itemFormat: "|00|15{index:.2} |07- |03{name}" + focusItemFormat: "|00|09|15{index:.2} - {name}" + } + } + } + + messageAreaSetNewScanDate: { + mci: { + SM2: { + width: 54 + itemFormat: "|00|07{conf.name} |08- |07{area.name}" + focusItemFormat: "|00|15{conf.name} |07- |15{area.name}" + } + } + } + + qwkExportPacketCurrentConfig: { + mci: { + TL1: { + width: 70 + } + TL2: { + width: 70 } } } @@ -261,25 +316,31 @@ mailMenuInbox: { config: { - listFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |01|31{newIndicator}" - focusListFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}" dateTimeFormat: ddd MMM Do + allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}" } mci: { VM1: { height: 14 + width: 70 + itemFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |15{newIndicator}" + focusItemFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}" + } + XY2: { + width: 30 } } } mainMenuRumorz: { - config: { - listFormat: "|00|11 {rumor}" - focusListFormat: "|00|15> |14{rumor}" - } 0: { mci: { - VM1: { height: 14 } + VM1: { + height: 14, + width: 70 + itemFormat: "|00|11 {rumor}" + focusItemFormat: "|00|15> |14{rumor}" + } TM2: { focusTextStyle: upper items: [ "yes", "no" ] @@ -298,16 +359,14 @@ } bbsList: { - config: { - listFormat: "|00|07{bbsName}" - focusListFormat: "|00|19|15{bbsName!styleFirstLower}" - } 0: { mci: { VM1: { height: 11 width: 22 focusTextStyle: first upper + itemFormat: "|00|07{bbsName}" + focusItemFormat: "|00|19|15{bbsName!styleFirstLower}" } TL2: { width: 28 } TL3: { width: 28 } @@ -337,6 +396,140 @@ } } + 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}" + } + 0: { + mci: { + SM1: { + width: 25 + itemFormat: "|00|03node |07{text} |08(|07{userName}|08)" + focusItemFormat: "|00|11node |15{text} |07(|15{userName}|07)" + } + ET2: { + width: 65 + } + TL3: { + width: 65 + } + } + } + } + + editAutoSignature: { + 0: { + mci: { + MT1: { + height: 8 + width: 73 + } + BT2: { + focusTextStyle: upper + } + } + } + } + + messageSearch: { + 0: { + mci: { + ET1: { + width: 42 + } + BT2: { + focusTextStyle: upper + } + SM3: { + width: 42 + } + SM4: { + width: 42 + } + ET5: { + width: 42 + } + ET6: { + width: 42 + } + BT7: { + focusTextStyle: upper + } + } + } + } + + messageAreaSearchMessageList: { + config: { + allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}" + // Fri Sep 25th + dateTimeFormat: ddd MMM Do + } + mci: { + VM1: { + height: 14 + width: 71 + itemFormat: "|00|15 {msgNum:<4.4} |03{subject:<27.26} |07{toUserName:<13.12} {fromUserName:<13.12} |03{ts:<12.12}" + focusItemFormat: "|00|19> |15{msgNum:<4.4} {subject:<27.26} {toUserName:<13.12} {fromUserName:<13.12} {ts:<12.12}" + } + } + } + + 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: { @@ -410,20 +603,24 @@ fullLoginSequenceLastCallers: { config: { - listFormat: "|00|11{userName:<17.17}|03{location:<20.20}|11{affils:<17.17}|03{ts:<15}" dateTimeFormat: MMM Do h:mma } mci: { - VM1: { height: 10 } + VM1: { + height: 10, + width: 20 + itemFormat: "|00|11{userName:<17.16} |03{location:<20.19} |11{affils:<18.17} |03{ts:<15}" + } } } fullLoginSequenceWhosOnline: { - config: { - listFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}" - } mci: { - VM1: { height: 10 } + VM1: { + height: 10, + width: 20 + itemFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}" + } } } @@ -433,14 +630,14 @@ fullLoginSequenceUserStats: { mci: { - UN1: { width: 17 } - UR2: { width: 17 } - LO3: { width: 17 } - UF4: { width: 17 } - UG5: { width: 17 } - UT6: { width: 17 } - UC7: { width: 17 } - ST8: { width: 17 } + UN1: { width: 15 } + UR2: { width: 15 } + LO3: { width: 15 } + UF4: { width: 15 } + UG5: { width: 15 } + UT6: { width: 15 } + UC7: { width: 15 } + ST8: { width: 15 } } } @@ -467,14 +664,16 @@ } newScanMessageList: { - config: { - listFormat: "|00|15 {msgNum:<5.5}|03{subject:<28.27} |15{fromUserName:<20.20} {ts}" - focusListFormat: "|00|19> |15{msgNum:<5.5}{subject:<28.27} {fromUserName:<20.20} {ts}" + config: { dateTimeFormat: ddd MMM Do + allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}" } mci: { VM1: { height: 14 + width: 70 + itemFormat: "|00|15 {msgNum:<5.5}|03{subject:<28.27} |15{fromUserName:<20.20} {ts}" + focusItemFormat: "|00|19> |15{msgNum:<5.5}{subject:<28.27} {fromUserName:<20.20} {ts}" } } } @@ -493,7 +692,7 @@ fileBaseListEntries: { config: { hashTagsSep: "|08, |07" - browseInfoFormat10: "|00|10{fileName} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}" + browseInfoFormat10: "|00|10{fileName:<.44} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}" browseInfoFormat11: "|00|15{areaName}" browseInfoFormat12: "|00|07{hashTags}" browseInfoFormat13: "|00|07{estReleaseYear}" @@ -525,9 +724,6 @@ detailsGeneralInfoFormat21: "{uploadTimestamp}" detailsGeneralInfoFormat22: "{archiveTypeDesc}" - fileListEntryFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" - focusFileListEntryFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" - notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)" } @@ -586,6 +782,8 @@ VM1: { height: 17 width: 79 + itemFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusItemFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" } } } @@ -594,7 +792,7 @@ newScanFileBaseList: { config: { hashTagsSep: "|08, |07" - browseInfoFormat10: "|00|10{fileName} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}" + browseInfoFormat10: "|00|10{fileName:<44} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}" browseInfoFormat11: "|00|15{areaName}" browseInfoFormat12: "|00|07{hashTags}" browseInfoFormat13: "|00|07{estReleaseYear}" @@ -626,9 +824,6 @@ detailsGeneralInfoFormat21: "{uploadTimestamp}" detailsGeneralInfoFormat22: "{archiveTypeDesc}" - fileListEntryFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" - focusFileListEntryFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" - notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)" } @@ -687,23 +882,22 @@ VM1: { height: 17 width: 79 + itemFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusItemFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" } } } } fileBaseBrowseByAreaSelect: { - config: { - protListFormat: "|00|03{name}" - protListFocusFormat: "|00|19|15{name}" - } - 0: { mci: { VM1: { height: 15 width: 30 focusTextStyle: first lower + itemFormat: "|00|03{name}" + focusItemFormat: "|00|19|15{name}" } } } @@ -722,15 +916,15 @@ } SM4: { width: 14 - justify: right + justify: left } SM5: { width: 14 - justify: right + justify: left } SM6: { width: 14 - justify: right + justify: left } BT7: { focusTextStyle: first lower @@ -738,6 +932,51 @@ } } + fileBaseExportListFilter: { + mci: { + ET1: { + width: 42 + } + BT2: { + focusTextStyle: first lower + } + ET3: { + width: 42 + } + SM4: { + width: 14 + justify: left + } + SM5: { + width: 14 + justify: left + } + SM6: { + width: 14 + justify: left + } + BT7: { + focusTextStyle: first lower + } + } + } + + fileBaseExportList: { + config: { + progBarChar: "|15▒" + mainInfoFormat10: "|07{currentFile} |08/ |07{totalFileCount} |08(|07{progress} %|08)" + } + mci: { + TL1: { + width: 60 + } + TL2: { + width: 56 + fillChar: "|06░" + } + } + } + fileAreaFilterEditor: { mci: { ET1: { @@ -748,15 +987,15 @@ } SM3: { width: 14 - justify: right + justify: left } SM4: { width: 14 - justify: right + justify: left } SM5: { width: 14 - justify: right + justify: left } ET6: { width: 26 @@ -768,16 +1007,34 @@ } fileBaseDownloadManager: { - config: { - queueListFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" - focusQueueListFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" - } - 0: { mci: { VM1: { height: 11 width: 69 + itemFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusItemFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" + } + HM2: { + width: 50 + focusTextStyle: first lower + } + } + } + } + + fileBaseWebDownloadManager: { + config: { + queueManagerInfoFormat10: "|03batch|08: |03{webBatchDlLink}" + queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}" + } + + 0: { + mci: { + VM1: { + height: 8 + itemFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusItemFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" } HM2: { width: 50 @@ -805,7 +1062,7 @@ mci: { SM1: { width: 14 - justify: right + justify: left focusTextStyle: first lower } @@ -874,28 +1131,78 @@ } fileTransferProtocolSelection: { - config: { - protListFormat: "|00|03{name}" - protListFocusFormat: "|00|19|15{name}" - } - 0: { mci: { VM1: { height: 15 width: 30 focusTextStyle: first lower + itemFormat: "|00|03{name}" + focusItemFormat: "|00|19|15{name}" } } } } - - //////////////////////////////// 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 + } + } } } } @@ -917,5 +1224,31 @@ } } } + + achievements: { + defaults: { + format: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + globalFormat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}" + titleSGR: "|10" + pointsSGR: "|12" + textSGR: "|00|03" + globalTextSGR: "|03" + boardNameSGR: "|10" + userNameSGR: "|11" + achievedValueSGR: "|15" + } + + overrides: { + user_login_count: { + match: { + 2: { + // + // You may override title, text, and globalText here + // + } + } + } + } + } } } \ No newline at end of file diff --git a/config/achievements.hjson b/config/achievements.hjson new file mode 100644 index 00000000..758af562 --- /dev/null +++ b/config/achievements.hjson @@ -0,0 +1,469 @@ + /* + ./\/\." ENiGMA½ Achievement Configuration -/--/-------- - -- - + + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + + *-----------------------------------------------------------------------------* + + General Information + ------------------------------- - - + This configuration is in HJSON (http://hjson.org/) format. Strict to-spec + JSON is also perfectly valid. Use "hjson" from npm to convert to/from JSON. + + See http://hjson.org/ for more information and syntax. + + Various editors and IDEs such as Sublime Text 3 Visual Studio Code and so + on have syntax highlighting for the HJSON format which are highly recommended. + + ------------------------------- -- - - + Achievement Configuration + ------------------------------- - - + Achievements are currently fairly limited in what can trigger them. This is + being expanded upon and more will be available in the near future. For now + you should mostly be interested in: + - Perhaps adding additional *levels* of triggers & points + - Applying customizations via the achievements section in theme.hjson + + Some tips: + - For 'userStatSet' types, see user_property.js + + Don"t forget to RTFM ...er uh... see the documentation for more information and + don"t be shy to ask for help: + + BBS : Xibalba @ xibalba.l33t.codes + FTN : BBS Discussion on fsxNet or ArakNet + IRC : #enigma-bbs / FreeNode + Email : bryan@l33t.codes +*/ +{ + // Set to false to disable the achievement system + enabled : true + + art : { + localHeader: achievement_local_header + localFooter: achievement_local_footer + globalHeader: achievement_global_header + globalFooter: achievement_global_footer + } + + achievements: { + user_login_count: { + type: userStatSet + statName: login_count + match: { + 2: { + title: "Return Caller" + globalText: "{userName} has returned to {boardName}!" + text: "You've returned to {boardName}!" + points: 5 + } + 10: { + title: "Curious Caller" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 10 + } + 25: { + title: "Inquisitive" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 10 + } + 75: { + title: "Still Interested!" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 15 + } + 100: { + title: "Regular Customer" + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 25 + } + 250: { + title: "Speed Dial", + globalText: "{userName} has logged into {boardName} {achievedValue} times!" + text: "You've logged into {boardName} {achievedValue} times!" + points: 50 + } + 500: { + title: "System Addict" + globalText: "{userName} the BBS {boardName} addict has logged in {achievedValue} times!" + text: "You're a {boardName} addict! You've logged in {achievedValue} times!" + points: 50 + } + } + } + + user_post_count: { + type: userStatSet + statName: post_count + match: { + 2: { + title: "Poster" + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 5 + } + 5: { + title: "Poster... again!", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 5 + } + 20: { + title: "Just Want to Talk", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 10 + } + 100: { + title: "Probably Just Spam", + globalText: "{userName} has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 25 + } + 250: { + title: "Scribe" + globalText: "{userName} the scribe has posted {achievedValue} messages!" + text: "Such a scribe! You've posted {achievedValue} messages!" + points: 50 + } + 500: { + title: "Writing a Book" + globalText: "{userName} is writing a book and has posted {achievedValue} messages!" + text: "You've posted {achievedValue} messages!" + points: 50 + } + } + } + + user_upload_count: { + type: userStatSet + statName: ul_total_count + match: { + 1: { + title: "Uploader" + globalText: "{userName} has uploaded a file!" + text: "You've uploaded somthing!" + points: 5 + } + 10: { + title: "Moar Uploads!" + globalText: "{userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 10 + } + 50: { + title: "Contributor" + globalText: "{userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 25 + + } + 100: { + title: "Courier" + globalText: "Courier {userName} has uploaded {achievedValue} files!" + text: "You've uploaded {achievedValue} files!" + points: 50 + } + 200: { + title: "Must Be a Drop Site" + globalText: "{userName} has uploaded a whomping {achievedValue} files!" + text: "You've uploaded a whomping {achievedValue} files!" + points: 55 + } + } + } + + user_upload_bytes: { + type: userStatSet + statName: ul_total_bytes + match: { + 10240: { + text: "UNIVAC Drum" + globalText: "{userName} has uploaded 10k. Enough to fill a UNIVAC drum!" + text: "You've uploaded 10k. Enough to fill a UNIVAC drum!" + points: 5 + } + 524288: { + title: "Kickstart" + globalText: "{userName} has uploaded 512KB, enough for a Kickstart!" + text: "You've uploaded 512KB, enough for a Kickstart!" + points: 10 + } + 1474560: { + title: "AOL Disk Anyone?" + globalText: "{userName} has uploaded 1.44M worth of data. Hopefully it's not AOL!" + title: "You've uploaded 1.44M worth of data. Hopefully it's not AOL!" + points: 10 + } + 6291456: { + title: "A Quake of a Upload" + globalText: "{userName} has uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" + text: "You've uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!" + points: 20 + } + 104857600: { + title: "Zip 100" + globalText: "{userName} has uploaded a Zip 100 disk's worth of data!" + text: "You've uploaded a Zip 100 disk's worth of data!" + points: 25 + } + 1073741824: { + title: "Gigabyte!" + globalText: "{userName} has uploaded a Gigabyte worth of data!" + text: "You've uploaded a Gigabyte worth of data!" + points: 50 + } + 3407872000: { + title: "Encarta" + globalText: "{userName} has uploaded 5xCD discs worth of data. That's the size of Encarta!" + text: "You've uploaded 5xCD discs worth of data. That's the size of Encarta!" + points: 50 + } + 7025459200: { + title: "NFL_Madden_2007_USA_BLURAY_DIRFIX_PS3-PARADOX" + globalText: "{userName} has uploaded 67x100 MiB worth of data, the size of the worlds first PS3 rip!" + text: "You've uploaded 67x100 MiB worth of data, the size of the world first PS3 rip!" + points: 100 + } + 25018184499: { + title: "WaYsTeD" + globalText: "{userName} has uploaded 23.3 GiB of data, the size of the first PS4 rip: Watch.Dogs.PS4-WaYsTeD!" + text: "You've uploaded 23.3 GiB of data, the size of the first PS4 rip: Watch.Dogs.PS4-WaYsTeD!" + points: 150 + } + } + } + + user_download_count: { + type: userStatSet + statName: dl_total_count + match: { + 1: { + title: "Downloader" + globalText: "{userName} has downloaded a file!" + text: "You've downloaded somthing!" + points: 5 + } + 10: { + title: "Moar Downloads!" + globalText: "{userName} has downloaded {achievedValue} files!" + text: "You've downloaded {achievedValue} files!" + points: 10 + } + 50: { + title: "Leecher" + globalText: "{userName} has leeched {achievedValue} files!" + text: "You've leeched... er... downloaded {achievedValue} files!" + points: 15 + } + 100: { + title: "Hoarder" + globalText: "{userName} has downloaded {achievedValue} files!" + text: "Hoarding files? You've downloaded {achievedValue} files!" + points: 20 + } + 200: { + title: "Digital Archivist" + globalText: "{userName} the digital archivist has {achievedValue} files!" + text: "Building an archive? You've downloaded {achievedValue} files!" + points: 25 + } + } + } + + user_download_bytes: { + type: userStatSet + statName: dl_total_bytes + match: { + 655360: { + title: "Ought to be Enough" + globalText: "{userName} has downloaded 640K. Ought to be enough for anyone!" + text: "You've downloaded 640K. Ought to be enough for anyone!" + points: 5 + } + 1474560: { + title: "Fits on a Floppy" + globalText: "{userName} has downloaded 1.44MB worth of data!" + text: "You've downloaded 1.44MB of data!" + points: 5 + } + 104857600: { + title: "Click of Death" + globalText: "{userName} has downloaded 100MB... perhaps to a Zip Disk?" + text: "You've downloaded 100MB of data... perhaps to a Zip Disk?" + points: 10 + } + 681574400: { + title: "CD Rip" + globalText: "{userName} has downloaded a CD-ROM's worth of data!" + text: "You've downloaded a CD-ROM's worth of data!" + points: 15 + } + 1073741824: { + title: "Like One Hundred Floppys, Man" + globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!" + text: "You've downloaded {achievedValue!sizeWithAbbr} of data!" + points: 25 + } + 5368709120: { + title: "That's a Lot of Bits!" + globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!" + text: "You've downloaded {achievedValue!sizeWithAbbr} of data!" + } + } + } + + user_door_runs: { + type: userStatSet + statName: door_run_total_count + match: { + 1: { + title: "Nostalgia Toe Dip", + globalText: "{userName} ran a door!" + text: "You ran a door!" + points: 5 + }, + 10: { + title: "This is Kinda Fun" + globalText: "{userName} ran {achievedValue} doors!" + text: "You've run {achievedValue} doors!" + points: 10 + } + 50: { + title: "Gamer" + globalText: "{userName} ran {achievedValue} doors!" + text: "You've run {achievedValue} doors!" + points: 20 + } + 100: { + title: "Trying Them All" + globalText: "{userName} must really like textmode and has run {achievedValue} doors!" + text: "You've run {achievedValue} doors! You must really like textmode!" + points: 50 + } + 200: { + title: "Dropfile Enthusiast" + globalText: "{userName} the dropfile enthusiast ran {achievedValue} doors!" + text: "You're a dropfile enthusiast! You've run {achievedValue} doors!" + points: 55 + } + } + } + + user_individual_door_run_minutes: { + type: userStatInc + statName: door_run_total_minutes + retroactive: false + match: { + 1: { + title: "Nevermind!" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}. Guess it's not their thing!" + text: "You ran a door for only {achievedValue!durationMinutes}. Not your thing?" + points: 5 + } + 10: { + title: "It's OK I Guess" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}!" + text: "You ran a door for {achievedValue!durationMinutes}!" + points: 10 + } + 30: { + title: "Good Game" + globalText: "{userName} ran a door for {achievedValue!durationMinutes}!" + text: "You ran a door for {achievedValue!durationMinutes}!" + points: 20 + } + 60: { + title: "What? Limited Turns?!" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 25 + } + 120: { + title: "It's the Only One I Know!" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 50 + } + 240: { + title: "Possible Addict" + globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!" + text: "You've spent {achievedValue!durationMinutes} in a door!" + points: 55 + } + } + } + + user_door_run_total_minutes: { + type: userStatIncNewVal + statName: door_run_total_minutes + match: { + 10: { + title: "Enough for the Instructions" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 10 + } + 30: { + title: "Probably Just L.O.R.D." + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 20 + } + 60: { + title: "Retro or Bust" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 25 + } + 240: { + title: "Textmode Dragon Slayer" + globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!" + text: "You've spent {achievedValue!durationMinutes} playing doors!" + points: 50 + } + } + } + + user_total_system_online_minutes: { + type: userStatSet + statName: minutes_online_total_count + match: { + 30: { + title: "Just Poking Around" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 5 + } + 60: { + title: "Mildly Interesting" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 15 + } + 120: { + title: "Nothing Better to Do" + globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!" + text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 25 + } + 1440: { + title: "Idle Bot" + globalText: "{userName} is probably a bot. They've spent {achievedValue!durationMinutes} on {boardName}!" + text: "You're a bot, aren't you? You've been on {boardName} for a total of {achievedValue!durationMinutes}!" + points: 55 + } + } + } + } +} diff --git a/core/abracadabra.js b/core/abracadabra.js new file mode 100644 index 00000000..83aa376b --- /dev/null +++ b/core/abracadabra.js @@ -0,0 +1,214 @@ +/* jslint node: true */ +'use strict'; + +const { MenuModule } = require('./menu_module.js'); +const DropFile = require('./dropfile.js'); +const Door = require('./door.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); + +// deps +const async = require('async'); +const assert = require('assert'); +const _ = require('lodash'); +const paths = require('path'); + +const activeDoorNodeInstances = {}; + +exports.moduleInfo = { + name : 'Abracadabra', + desc : 'External BBS Door Module', + author : 'NuSkooler', +}; + +/* + Example configuration for LORD under DOSEMU: + + { + config: { + name: PimpWars + dropFileType: DORINFO + cmd: qemu-system-i386 + args: [ + "-localtime", + "freedos.img", + "-chardev", + "socket,port={srvPort},nowait,host=localhost,id=s0", + "-device", + "isa-serial,chardev=s0" + ] + io: socket + } + } + + listen: socket | stdio + + { + "config" : { + "name" : "LORD", + "dropFileType" : "DOOR", + "cmd" : "/usr/bin/dosemu", + "args" : [ "-quiet", "-f", "/etc/dosemu/dosemu.conf", "X:\\PW\\START.BAT {dropfile} {node}" ] ], + "nodeMax" : 32, + "tooManyArt" : "toomany-lord.ans" + } + } + + :TODO: See Mystic & others for other arg options that we may need to support +*/ + +exports.getModule = class AbracadabraModule extends MenuModule { + constructor(options) { + super(options); + + this.config = options.menuConfig.config; + // :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... } + // .. and/or EnigAssert + assert(_.isString(this.config.name, 'Config \'name\' is required')); + assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required')); + assert(_.isString(this.config.cmd, 'Config \'cmd\' is required')); + + this.config.nodeMax = this.config.nodeMax || 0; + this.config.args = this.config.args || []; + } + + /* + :TODO: + * 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; + + async.series( + [ + function validateNodeCount(callback) { + if(self.config.nodeMax > 0 && + _.isNumber(activeDoorNodeInstances[self.config.name]) && + activeDoorNodeInstances[self.config.name] + 1 > self.config.nodeMax) + { + self.client.log.info( + { + name : self.config.name, + activeCount : activeDoorNodeInstances[self.config.name] + }, + 'Too many active instances'); + + if(_.isString(self.config.tooManyArt)) { + theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() { + self.pausePrompt( () => { + return callback(Errors.AccessDenied('Too many active instances')); + }); + }); + } else { + self.client.term.write('\nToo many active instances. Try again later.\n'); + + // :TODO: Use MenuModule.pausePrompt() + self.pausePrompt( () => { + return callback(Errors.AccessDenied('Too many active instances')); + }); + } + } else { + self.incrementActiveDoorNodeInstances(); + return callback(null); + } + }, + function prepareDoor(callback) { + self.doorInstance = new Door(self.client); + return self.doorInstance.prepare(self.config.io || 'stdio', callback); + }, + function generateDropfile(callback) { + const dropFileOpts = { + fileType : self.config.dropFileType, + }; + + self.dropFile = new DropFile(self.client, dropFileOpts); + return self.dropFile.createFile(callback); + } + ], + function complete(err) { + if(err) { + self.client.log.warn( { error : err.toString() }, 'Could not start door'); + self.lastError = err; + self.prevMenu(); + } else { + self.finishedLoading(); + } + } + ); + } + + runDoor() { + this.client.term.write(ansi.resetScreen()); + + const exeInfo = { + cmd : this.config.cmd, + cwd : this.config.cwd || paths.dirname(this.config.cmd), + args : this.config.args, + io : this.config.io || 'stdio', + encoding : this.config.encoding || 'cp437', + dropFile : this.dropFile.fileName, + dropFilePath : this.dropFile.fullPath, + node : this.client.node, + }; + + const doorTracking = trackDoorRunBegin(this.client, this.config.name); + + 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 + // have been set within the door + // + this.client.term.rawWrite( + ansi.normal() + + ansi.goto(this.client.term.termHeight, this.client.term.termWidth) + + ansi.setScrollRegion() + + ansi.goto(this.client.term.termHeight, 0) + + '\r\n\r\n' + ); + + this.prevMenu(); + }); + } + + leave() { + super.leave(); + this.decrementActiveDoorNodeInstances(); + } + + finishedLoading() { + this.runDoor(); + } +}; diff --git a/core/achievement.js b/core/achievement.js new file mode 100644 index 00000000..20e32603 --- /dev/null +++ b/core/achievement.js @@ -0,0 +1,634 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Events = require('./events.js'); +const Config = require('./config.js').get; +const { + getConfigPath, + getFullConfig, +} = require('./config_util.js'); +const UserDb = require('./database.js').dbs.user; +const { + getISOTimestampString +} = require('./database.js'); +const UserInterruptQueue = require('./user_interrupt_queue.js'); +const { + getConnectionByUserId +} = require('./client_connections.js'); +const UserProps = require('./user_property.js'); +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); +const { getThemeArt } = require('./theme.js'); +const { + pipeToAnsi, + stripMciColorCodes +} = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); +const StatLog = require('./stat_log.js'); +const Log = require('./logger.js').log; +const ConfigCache = require('./config_cache.js'); + +// deps +const _ = require('lodash'); +const async = require('async'); +const moment = require('moment'); +const paths = require('path'); + +exports.getAchievementsEarnedByUser = getAchievementsEarnedByUser; + +class Achievement { + constructor(data) { + this.data = data; + + // achievements are retroactive by default + this.data.retroactive = _.get(this.data, 'retroactive', true); + } + + static factory(data) { + if(!data) { + return; + } + let achievement; + switch(data.type) { + case Achievement.Types.UserStatSet : + case Achievement.Types.UserStatInc : + case Achievement.Types.UserStatIncNewVal : + achievement = new UserStatAchievement(data); + break; + + default : return; + } + + if(achievement.isValid()) { + return achievement; + } + } + + static get Types() { + return { + UserStatSet : 'userStatSet', + UserStatInc : 'userStatInc', + UserStatIncNewVal : 'userStatIncNewVal', + }; + } + + isValid() { + switch(this.data.type) { + case Achievement.Types.UserStatSet : + case Achievement.Types.UserStatInc : + case Achievement.Types.UserStatIncNewVal : + if(!_.isString(this.data.statName)) { + return false; + } + if(!_.isObject(this.data.match)) { + return false; + } + break; + + default : return false; + } + return true; + } + + getMatchDetails(/*matchAgainst*/) { + } + + isValidMatchDetails(details) { + if(!details || !_.isString(details.title) || !_.isString(details.text) || !_.isNumber(details.points)) { + return false; + } + return (_.isString(details.globalText) || !details.globalText); + } +} + +class UserStatAchievement extends Achievement { + constructor(data) { + super(data); + + // sort match keys for quick match lookup + this.matchKeys = Object.keys(this.data.match || {}).map(k => parseInt(k)).sort( (a, b) => b - a); + } + + isValid() { + if(!super.isValid()) { + return false; + } + return !Object.keys(this.data.match).some(k => !parseInt(k)); + } + + getMatchDetails(matchValue) { + let ret = []; + let matchField = this.matchKeys.find(v => matchValue >= v); + if(matchField) { + const match = this.data.match[matchField]; + matchField = parseInt(matchField); + if(this.isValidMatchDetails(match) && !isNaN(matchField)) { + ret = [ match, matchField, matchValue ]; + } + } + return ret; + } +} + +class Achievements { + constructor(events) { + this.events = events; + } + + getAchievementByTag(tag) { + return this.achievementConfig.achievements[tag]; + } + + isEnabled() { + return !_.isUndefined(this.achievementConfig); + } + + init(cb) { + let achievementConfigPath = _.get(Config(), 'general.achievementFile'); + if(!achievementConfigPath) { + Log.info('Achievements are not configured'); + return cb(null); + } + achievementConfigPath = getConfigPath(achievementConfigPath); // qualify + + const configLoaded = (achievementConfig) => { + if(true !== achievementConfig.enabled) { + Log.info('Achievements are not enabled'); + this.stopMonitoringUserStatEvents(); + delete this.achievementConfig; + } else { + Log.info('Achievements are enabled'); + this.achievementConfig = achievementConfig; + this.monitorUserStatEvents(); + } + }; + + const changed = ( { fileName, fileRoot } ) => { + const reCachedPath = paths.join(fileRoot, fileName); + if(reCachedPath === achievementConfigPath) { + getFullConfig(achievementConfigPath, (err, achievementConfig) => { + if(err) { + return Log.error( { error : err.message }, 'Failed to reload achievement config from cache'); + } + configLoaded(achievementConfig); + }); + } + }; + + ConfigCache.getConfigWithOptions( + { + filePath : achievementConfigPath, + forceReCache : true, + callback : changed, + }, + (err, achievementConfig) => { + if(err) { + return cb(err); + } + + configLoaded(achievementConfig); + return cb(null); + } + ); + } + + loadAchievementHitCount(user, achievementTag, field, cb) { + UserDb.get( + `SELECT COUNT() AS count + FROM user_achievement + WHERE user_id = ? AND achievement_tag = ? AND match = ?;`, + [ user.userId, achievementTag, field], + (err, row) => { + return cb(err, row ? row.count : 0); + } + ); + } + + record(info, localInterruptItem, cb) { + StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1); + StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points); + + const cleanTitle = stripMciColorCodes(localInterruptItem.title); + const cleanText = stripMciColorCodes(localInterruptItem.achievText); + + const recordData = [ + info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField, + cleanTitle, cleanText, info.details.points, + ]; + + UserDb.run( + `INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match, title, text, points) + VALUES (?, ?, ?, ?, ?, ?, ?);`, + recordData, + err => { + if(err) { + return cb(err); + } + + this.events.emit( + Events.getSystemEvents().UserAchievementEarned, + { + user : info.client.user, + achievementTag : info.achievementTag, + points : info.details.points, + title : cleanTitle, + text : cleanText, + } + ); + + return cb(null); + } + ); + } + + display(info, interruptItems, cb) { + if(interruptItems.local) { + UserInterruptQueue.queue(interruptItems.local, { clients : info.client } ); + } + + if(interruptItems.global) { + UserInterruptQueue.queue(interruptItems.global, { omit : info.client } ); + } + + return cb(null); + } + + recordAndDisplayAchievement(info, cb) { + async.waterfall( + [ + (callback) => { + return this.createAchievementInterruptItems(info, callback); + }, + (interruptItems, callback) => { + this.record(info, interruptItems.local, err => { + return callback(err, interruptItems); + }); + }, + (interruptItems, callback) => { + return this.display(info, interruptItems, callback); + } + ], + err => { + return cb(err); + } + ); + } + + monitorUserStatEvents() { + if(this.userStatEventListeners) { + return; // already listening + } + + const listenEvents = [ + Events.getSystemEvents().UserStatSet, + Events.getSystemEvents().UserStatIncrement + ]; + + this.userStatEventListeners = this.events.addMultipleEventListener(listenEvents, userStatEvent => { + if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) { + return; + } + + if(!_.isNumber(userStatEvent.statValue) && !_.isNumber(userStatEvent.statIncrementBy)) { + return; + } + + // :TODO: Make this code generic - find + return factory created object + const achievementTags = Object.keys(_.pickBy( + _.get(this.achievementConfig, 'achievements', {}), + achievement => { + if(false === achievement.enabled) { + return false; + } + const acceptedTypes = [ + Achievement.Types.UserStatSet, + Achievement.Types.UserStatInc, + Achievement.Types.UserStatIncNewVal, + ]; + return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName; + } + )); + + if(0 === achievementTags.length) { + return; + } + + async.eachSeries(achievementTags, (achievementTag, nextAchievementTag) => { + const achievement = Achievement.factory(this.getAchievementByTag(achievementTag)); + if(!achievement) { + return nextAchievementTag(null); + } + + const statValue = parseInt( + [ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ? + userStatEvent.statValue : + userStatEvent.statIncrementBy + ); + if(isNaN(statValue)) { + return nextAchievementTag(null); + } + + const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue); + if(!details) { + return nextAchievementTag(null); + } + + async.waterfall( + [ + (callback) => { + this.loadAchievementHitCount(userStatEvent.user, achievementTag, matchField, (err, count) => { + if(err) { + return callback(err); + } + return callback(count > 0 ? Errors.General('Achievement already acquired', ErrorReasons.TooMany) : null); + }); + }, + (callback) => { + const client = getConnectionByUserId(userStatEvent.user.userId); + if(!client) { + return callback(Errors.UnexpectedState('Failed to get client for user ID')); + } + + const info = { + achievementTag, + achievement, + details, + client, + matchField, // match - may be in odd format + matchValue, // actual value + achievedValue : matchField, // achievement value met + user : userStatEvent.user, + timestamp : moment(), + }; + + const achievementsInfo = [ info ]; + return callback(null, achievementsInfo, info); + }, + (achievementsInfo, basicInfo, callback) => { + if(true !== achievement.data.retroactive) { + return callback(null, achievementsInfo); + } + + const index = achievement.matchKeys.findIndex(v => v < matchField); + if(-1 === index || !Array.isArray(achievement.matchKeys)) { + return callback(null, achievementsInfo); + } + + // For userStat, any lesser match keys(values) are also met. Example: + // matchKeys: [ 500, 200, 100, 20, 10, 2 ] + // ^---- we met here + // ^------------^ retroactive range + // + async.eachSeries(achievement.matchKeys.slice(index), (k, nextKey) => { + const [ det, fld, val ] = achievement.getMatchDetails(k); + if(!det) { + return nextKey(null); + } + + this.loadAchievementHitCount(userStatEvent.user, achievementTag, fld, (err, count) => { + if(!err || count && 0 === count) { + achievementsInfo.push(Object.assign( + {}, + basicInfo, + { + details : det, + matchField : fld, + achievedValue : fld, + matchValue : val, + } + )); + } + + return nextKey(null); + }); + }, + () => { + return callback(null, achievementsInfo); + }); + }, + (achievementsInfo, callback) => { + // reverse achievementsInfo so we display smallest > largest + achievementsInfo.reverse(); + + async.eachSeries(achievementsInfo, (achInfo, nextAchInfo) => { + return this.recordAndDisplayAchievement(achInfo, err => { + return nextAchInfo(err); + }); + }, + err => { + return callback(err); + }); + } + ], + err => { + if(err && ErrorReasons.TooMany !== err.reasonCode) { + Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event'); + } + return nextAchievementTag(null); // always try the next, regardless + } + ); + }); + }); + } + + stopMonitoringUserStatEvents() { + if(this.userStatEventListeners) { + this.events.removeMultipleEventListener(this.userStatEventListeners); + delete this.userStatEventListeners; + } + } + + getFormatObject(info) { + return { + userName : info.user.username, + userRealName : info.user.properties[UserProps.RealName], + userLocation : info.user.properties[UserProps.Location], + userAffils : info.user.properties[UserProps.Affiliations], + nodeId : info.client.node, + title : info.details.title, + //text : info.global ? info.details.globalText : info.details.text, + points : info.details.points, + achievedValue : info.achievedValue, + matchField : info.matchField, + matchValue : info.matchValue, + timestamp : moment(info.timestamp).format(info.dateTimeFormat), + boardName : Config().general.boardName, + }; + } + + getFormattedTextFor(info, textType, defaultSgr = '|07') { + const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); + const textTypeSgr = themeDefaults[`${textType}SGR`] || defaultSgr; + + const formatObj = this.getFormatObject(info); + + const wrap = (input) => { + const re = new RegExp(`{(${Object.keys(formatObj).join('|')})([^}]*)}`, 'g'); + return input.replace(re, (m, formatVar, formatOpts) => { + const varSgr = themeDefaults[`${formatVar}SGR`] || textTypeSgr; + let r = `${varSgr}{${formatVar}`; + if(formatOpts) { + r += formatOpts; + } + return `${r}}${textTypeSgr}`; + }); + }; + + return stringFormat(`${textTypeSgr}${wrap(info.details[textType])}`, formatObj); + } + + createAchievementInterruptItems(info, cb) { + info.dateTimeFormat = + info.details.dateTimeFormat || + info.achievement.dateTimeFormat || + info.client.currentTheme.helpers.getDateTimeFormat(); + + const title = this.getFormattedTextFor(info, 'title'); + const text = this.getFormattedTextFor(info, 'text'); + + let globalText; + if(info.details.globalText) { + globalText = this.getFormattedTextFor(info, 'globalText'); + } + + const getArt = (name, callback) => { + const spec = + _.get(info.details, `art.${name}`) || + _.get(info.achievement, `art.${name}`) || + _.get(this.achievementConfig, `art.${name}`); + if(!spec) { + return callback(null); + } + const getArtOpts = { + name : spec, + client : this.client, + random : false, + }; + getThemeArt(getArtOpts, (err, artInfo) => { + // ignore errors + return callback(artInfo ? artInfo.data : null); + }); + }; + + const interruptItems = {}; + let itemTypes = [ 'local' ]; + if(globalText) { + itemTypes.push('global'); + } + + async.each(itemTypes, (itemType, nextItemType) => { + async.waterfall( + [ + (callback) => { + getArt(`${itemType}Header`, headerArt => { + return callback(null, headerArt); + }); + }, + (headerArt, callback) => { + getArt(`${itemType}Footer`, footerArt => { + return callback(null, headerArt, footerArt); + }); + }, + (headerArt, footerArt, callback) => { + const itemText = 'global' === itemType ? globalText : text; + interruptItems[itemType] = { + title, + achievText : itemText, + text : `${title}\r\n${itemText}`, + pause : true, + }; + if(headerArt || footerArt) { + const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {}); + const defaultContentsFormat = '{title}\r\n{message}'; + const contentsFormat = 'global' === itemType ? + themeDefaults.globalFormat || defaultContentsFormat : + themeDefaults.format || defaultContentsFormat; + + const formatObj = Object.assign(this.getFormatObject(info), { + title : this.getFormattedTextFor(info, 'title', ''), // ''=defaultSgr + message : itemText, + }); + + const contents = pipeToAnsi(stringFormat(contentsFormat, formatObj)); + + interruptItems[itemType].contents = + `${headerArt || ''}\r\n${contents}\r\n${footerArt || ''}`; + } + return callback(null); + } + ], + err => { + return nextItemType(err); + } + ); + }, + err => { + return cb(err, interruptItems); + }); + } +} + +let achievementsInstance; + +function getAchievementsEarnedByUser(userId, cb) { + if(!achievementsInstance) { + return cb(Errors.UnexpectedState('Achievements not initialized')); + } + + UserDb.all( + `SELECT achievement_tag, timestamp, match, title, text, points + FROM user_achievement + WHERE user_id = ? + ORDER BY DATETIME(timestamp);`, + [ userId ], + (err, rows) => { + if(err) { + return cb(err); + } + + const earned = rows.map(row => { + + const achievement = Achievement.factory(achievementsInstance.getAchievementByTag(row.achievement_tag)); + if(!achievement) { + return; + } + + const earnedInfo = { + achievementTag : row.achievement_tag, + type : achievement.data.type, + retroactive : achievement.data.retroactive, + title : row.title, + text : row.text, + points : row.points, + timestamp : moment(row.timestamp), + }; + + switch(earnedInfo.type) { + case [ Achievement.Types.UserStatSet ] : + case [ Achievement.Types.UserStatInc ] : + case [ Achievement.Types.UserStatIncNewVal ] : + earnedInfo.statName = achievement.data.statName; + break; + } + + return earnedInfo; + }).filter(a => a); // remove any empty records (ie: no achievement.hjson entry exists anymore). + + return cb(null, earned); + } + ); +} + +exports.moduleInitialize = (initInfo, cb) => { + achievementsInstance = new Achievements(initInfo.events); + achievementsInstance.init( err => { + if(err) { + return cb(err); + } + + return cb(null); + }); +}; diff --git a/core/acs.js b/core/acs.js index f2e04b9f..f1596d5d 100644 --- a/core/acs.js +++ b/core/acs.js @@ -1,86 +1,117 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const checkAcs = require('./acs_parser.js').parse; -const Log = require('./logger.js').log; +// ENiGMA½ +const checkAcs = require('./acs_parser.js').parse; +const Log = require('./logger.js').log; -// deps -const assert = require('assert'); -const _ = require('lodash'); +// deps +const assert = require('assert'); +const _ = require('lodash'); class ACS { - constructor(client) { - this.client = client; - } - - check(acs, scope, defaultAcs) { - acs = acs ? acs[scope] : defaultAcs; - acs = acs || defaultAcs; - try { - return checkAcs(acs, { client : this.client } ); - } catch(e) { - Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS'); - return false; - } - } + constructor(subject) { + this.subject = subject; + } - // - // Message Conferences & Areas - // - hasMessageConfRead(conf) { - return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead); - } + static get Defaults() { + return { + MessageConfRead : 'GM[users]', // list/read + MessageConfWrite : 'GM[users]', // post/write - hasMessageAreaRead(area) { - return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead); - } + MessageAreaRead : 'GM[users]', // list/read; requires parent conf read + MessageAreaWrite : 'GM[users]', // post/write; requires parent conf write - // - // File Base / Areas - // - hasFileAreaRead(area) { - return this.check(area.acs, 'read', ACS.Defaults.FileAreaRead); - } + FileAreaRead : 'GM[users]', // list + FileAreaWrite : 'GM[sysops]', // upload + FileAreaDownload : 'GM[users]', // download + }; + } - hasFileAreaWrite(area) { - return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite); - } + check(acs, scope, defaultAcs) { + acs = acs ? acs[scope] : defaultAcs; + acs = acs || defaultAcs; + try { + return checkAcs(acs, { subject : this.subject } ); + } catch(e) { + Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS'); + return false; + } + } - hasFileAreaDownload(area) { - return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload); - } + // + // Message Conferences & Areas + // + hasMessageConfRead(conf) { + return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead); + } - getConditionalValue(condArray, memberName) { - assert(_.isArray(condArray)); - assert(_.isString(memberName)); + hasMessageConfWrite(conf) { + return this.check(conf.acs, 'write', ACS.Defaults.MessageConfWrite); + } - const matchCond = condArray.find( cond => { - if(_.has(cond, 'acs')) { - try { - return checkAcs(cond.acs, { client : this.client } ); - } catch(e) { - Log.warn( { exception : e, acs : cond }, 'Exception caught checking ACS'); - return false; - } - } else { - return true; // no acs check req. - } - }); + hasMessageAreaRead(area) { + return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead); + } - if(matchCond) { - return matchCond[memberName]; - } - } + hasMessageAreaWrite(area) { + return this.check(area.acs, 'write', ACS.Defaults.MessageAreaWrite); + } + + // + // File Base / Areas + // + hasFileAreaRead(area) { + return this.check(area.acs, 'read', ACS.Defaults.FileAreaRead); + } + + hasFileAreaWrite(area) { + // :TODO: create 'upload' alias? + return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite); + } + + hasFileAreaDownload(area) { + return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload); + } + + hasMenuModuleAccess(modInst) { + const acs = _.get(modInst, 'menuConfig.config.acs'); + if(!_.isString(acs)) { + return true; // no ACS check req. + } + try { + return checkAcs(acs, { subject : this.subject } ); + } catch(e) { + Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS'); + return false; + } + } + + getConditionalValue(condArray, memberName) { + if(!Array.isArray(condArray)) { + // no cond array, just use the value + return condArray; + } + + assert(_.isString(memberName)); + + const matchCond = condArray.find( cond => { + if(_.has(cond, 'acs')) { + try { + return checkAcs(cond.acs, { subject : this.subject } ); + } catch(e) { + Log.warn( { exception : e, acs : cond }, 'Exception caught checking ACS'); + return false; + } + } else { + return true; // no ACS check req. + } + }); + + if(matchCond) { + return matchCond[memberName]; + } + } } -ACS.Defaults = { - MessageAreaRead : 'GM[users]', - MessageConfRead : 'GM[users]', - - FileAreaRead : 'GM[users]', - FileAreaWrite : 'GM[sysops]', - FileAreaDownload : 'GM[users]', -}; - -module.exports = ACS; \ No newline at end of file +module.exports = ACS; diff --git a/core/acs_parser.js b/core/acs_parser.js index 36b9372a..f903d6f1 100644 --- a/core/acs_parser.js +++ b/core/acs_parser.js @@ -844,107 +844,223 @@ function peg$parse(input, options) { } - var client = options.client; - var user = options.client.user; + const UserProps = require('./user_property.js'); + const Log = require('./logger.js').log; + const User = require('./user.js'); - var _ = require('lodash'); - var assert = require('assert'); + const _ = require('lodash'); + const moment = require('moment'); + + const client = _.get(options, 'subject.client'); + const user = _.get(options, 'subject.user'); function checkAccess(acsCode, value) { try { return { LC : function isLocalConnection() { - return client.isLocal(); + return client && client.isLocal(); }, AG : function ageGreaterOrEqualThan() { - return !isNaN(value) && user.getAge() >= value; + return !isNaN(value) && user && user.getAge() >= value; }, AS : function accountStatus() { - if(!_.isArray(value)) { + if(!user) { + return false; + } + if(!Array.isArray(value)) { value = [ value ]; } - - const userAccountStatus = parseInt(user.properties.account_status, 10); - value = value.map(n => parseInt(n, 10)); // ensure we have integers - return value.indexOf(userAccountStatus) > -1; + const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus); + return value.map(n => parseInt(n, 10)).includes(userAccountStatus); }, EC : function isEncoding() { + const encoding = _.get(client, 'term.outputEncoding', '').toLowerCase(); switch(value) { - case 0 : return 'cp437' === client.term.outputEncoding.toLowerCase(); - case 1 : return 'utf-8' === client.term.outputEncoding.toLowerCase(); + case 0 : return 'cp437' === encoding; + case 1 : return 'utf-8' === encoding; default : return false; } }, GM : function isOneOfGroups() { - if(!_.isArray(value)) { + if(!user) { return false; } - - return _.findIndex(value, function cmp(groupName) { - return user.isGroupMember(groupName); - }) > - 1; + if(!Array.isArray(value)) { + return false; + } + return value.some(groupName => user.isGroupMember(groupName)); }, NN : function isNode() { - return client.node === value; + if(!client) { + return false; + } + if(!Array.isArray(value)) { + value = [ value ]; + } + return value.map(n => parseInt(n, 10)).includes(client.node); }, NP : function numberOfPosts() { - const postCount = parseInt(user.properties.post_count, 10); + if(!user) { + return false; + } + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; return !isNaN(value) && postCount >= value; }, NC : function numberOfCalls() { - const loginCount = parseInt(user.properties.login_count, 10); + if(!user) { + return false; + } + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount); return !isNaN(value) && loginCount >= value; }, + AA : function accountAge() { + if(!user) { + return false; + } + const accountCreated = moment(user.getProperty(UserProps.AccountCreated)); + const now = moment(); + const daysOld = accountCreated.diff(moment(), 'days'); + return !isNaN(value) && + accountCreated.isValid() && + now.isAfter(accountCreated) && + daysOld >= value; + }, + BU : function bytesUploaded() { + if(!user) { + return false; + } + const bytesUp = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; + return !isNaN(value) && bytesUp >= value; + }, + UP : function uploads() { + if(!user) { + return false; + } + const uls = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; + return !isNaN(value) && uls >= value; + }, + BD : function bytesDownloaded() { + if(!user) { + return false; + } + const bytesDown = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; + return !isNaN(value) && bytesDown >= value; + }, + DL : function downloads() { + if(!user) { + return false; + } + const dls = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; + return !isNaN(value) && dls >= value; + }, + NR : function uploadDownloadRatioGreaterThan() { + if(!user) { + return false; + } + const ulCount = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; + const dlCount = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; + const ratio = ~~((ulCount / dlCount) * 100); + return !isNaN(value) && ratio >= value; + }, + KR : function uploadDownloadByteRatioGreaterThan() { + if(!user) { + return false; + } + const ulBytes = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; + const dlBytes = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; + const ratio = ~~((ulBytes / dlBytes) * 100); + return !isNaN(value) && ratio >= value; + }, + PC : function postCallRatio() { + if(!user) { + return false; + } + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount) || 0; + const ratio = ~~((postCount / loginCount) * 100); + return !isNaN(value) && ratio >= value; + }, SC : function isSecureConnection() { - return client.session.isSecure; + 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; }, TH : function termHeight() { - return !isNaN(value) && client.term.termHeight >= value; + return !isNaN(value) && _.get(client, 'term.termHeight', 0) >= value; }, TM : function isOneOfThemes() { - if(!_.isArray(value)) { + if(!Array.isArray(value)) { return false; } - - return value.indexOf(client.currentTheme.name) > -1; + return value.includes(_.get(client, 'currentTheme.name')); }, TT : function isOneOfTermTypes() { - if(!_.isArray(value)) { + if(!Array.isArray(value)) { return false; } - - return value.indexOf(client.term.termType) > -1; + return value.includes(_.get(client, 'term.termType')); }, TW : function termWidth() { - return !isNaN(value) && client.term.termWidth >= value; + return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value; }, - ID : function isUserId(value) { - if(!_.isArray(value)) { + ID : function isUserId() { + if(!user) { + return false; + } + if(!Array.isArray(value)) { value = [ value ]; } - - value = value.map(n => parseInt(n, 10)); // ensure we have integers - return value.indexOf(user.userId) > -1; + return value.map(n => parseInt(n, 10)).includes(user.userId); }, WD : function isOneOfDayOfWeek() { - if(!_.isArray(value)) { + if(!Array.isArray(value)) { value = [ value ]; } - - value = value.map(n => parseInt(n, 10)); // ensure we have integers - return value.indexOf(new Date().getDay()) > -1; + return value.map(n => parseInt(n, 10)).includes(new Date().getDay()); }, MM : function isMinutesPastMidnight() { - // :TODO: return true if value is >= minutes past midnight sys time - return false; + const now = moment(); + const midnight = now.clone().startOf('day') + const minutesPastMidnight = now.diff(midnight, 'minutes'); + return !isNaN(value) && minutesPastMidnight >= value; + }, + AC : function achievementCount() { + if(!user) { + return false; + } + const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0; + return !isNan(value) && points >= value; + }, + AP : function achievementPoints() { + if(!user) { + return false; + } + const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0; + return !isNan(value) && points >= value; } }[acsCode](value); } catch (e) { - client.log.warn( { acsCode : acsCode, value : value }, 'Invalid ACS string!'); + const logger = _.get(client, 'log', Log); + logger.warn( { acsCode : acsCode, value : value }, 'Invalid ACS string!'); + return false; } } diff --git a/core/ansi_escape_parser.js b/core/ansi_escape_parser.js index 7e777618..88063bef 100644 --- a/core/ansi_escape_parser.js +++ b/core/ansi_escape_parser.js @@ -1,534 +1,532 @@ /* jslint node: true */ 'use strict'; -const miscUtil = require('./misc_util.js'); -const ansi = require('./ansi_term.js'); +const miscUtil = require('./misc_util.js'); +const ansi = require('./ansi_term.js'); +const Log = require('./logger.js').log; -const events = require('events'); -const util = require('util'); -const _ = require('lodash'); +// deps +const events = require('events'); +const util = require('util'); +const _ = require('lodash'); -exports.ANSIEscapeParser = ANSIEscapeParser; +exports.ANSIEscapeParser = ANSIEscapeParser; const CR = 0x0d; const LF = 0x0a; function ANSIEscapeParser(options) { - var self = this; - - events.EventEmitter.call(this); - - this.column = 1; - this.row = 1; - this.scrollBack = 0; - this.graphicRendition = {}; - - this.parseState = { - re : /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, - }; - - options = miscUtil.valueWithDefault(options, { - mciReplaceChar : '', - termHeight : 25, - termWidth : 80, - trailingLF : 'default', // default|omit|no|yes, ... - }); - - this.mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, ''); - this.termHeight = miscUtil.valueWithDefault(options.termHeight, 25); - this.termWidth = miscUtil.valueWithDefault(options.termWidth, 80); - this.trailingLF = miscUtil.valueWithDefault(options.trailingLF, 'default'); - - self.moveCursor = function(cols, rows) { - self.column += cols; - self.row += rows; - - self.column = Math.max(self.column, 1); - self.column = Math.min(self.column, self.termWidth); // can't move past term width - self.row = Math.max(self.row, 1); - - self.positionUpdated(); - }; - - self.saveCursorPosition = function() { - self.savedPosition = { - row : self.row, - column : self.column - }; - }; - - self.restoreCursorPosition = function() { - self.row = self.savedPosition.row; - self.column = self.savedPosition.column; - delete self.savedPosition; - - self.positionUpdated(); -// self.rowUpdated(); - }; - - self.clearScreen = function() { - // :TODO: should be doing something with row/column? - self.emit('clear screen'); - }; - -/* - self.rowUpdated = function() { - self.emit('row update', self.row + self.scrollBack); - };*/ - - self.positionUpdated = function() { - self.emit('position update', self.row, self.column); - }; - - function literal(text) { - const len = text.length; - let pos = 0; - let start = 0; - let charCode; - - while(pos < len) { - charCode = text.charCodeAt(pos) & 0xff; // 8bit clean - - switch(charCode) { - case CR : - self.emit('literal', text.slice(start, pos)); - start = pos; - - self.column = 1; - - self.positionUpdated(); - break; - - case LF : - self.emit('literal', text.slice(start, pos)); - start = pos; - - self.row += 1; - - self.positionUpdated(); - break; - - default : - if(self.column === self.termWidth) { - self.emit('literal', text.slice(start, pos + 1)); - start = pos + 1; - - self.column = 1; - self.row += 1; - - self.positionUpdated(); - } else { - self.column += 1; - } - break; - } - - ++pos; - } - - // - // Finalize this chunk - // - if(self.column > self.termWidth) { - self.column = 1; - self.row += 1; - - self.positionUpdated(); - } - - const rem = text.slice(start); - if(rem) { - self.emit('literal', rem); - } - } - - function getProcessedMCI(mci) { - if(self.mciReplaceChar.length > 0) { - return ansi.getSGRFromGraphicRendition(self.graphicRendition, true) + new Array(mci.length + 1).join(self.mciReplaceChar); - } else { - return mci; - } - } - - function parseMCI(buffer) { - // :TODO: move this to "constants" seciton @ top - var mciRe = /\%([A-Z]{2})([0-9]{1,2})?(?:\(([0-9A-Za-z,]+)\))*/g; - var pos = 0; - var match; - var mciCode; - var args; - var id; - - do { - pos = mciRe.lastIndex; - match = mciRe.exec(buffer); - - if(null !== match) { - if(match.index > pos) { - literal(buffer.slice(pos, match.index)); - } - - mciCode = match[1]; - id = match[2] || null; - - if(match[3]) { - args = match[3].split(','); - } else { - args = []; - } - - // if MCI codes are changing, save off the current color - var fullMciCode = mciCode + (id || ''); - if(self.lastMciCode !== fullMciCode) { - - self.lastMciCode = fullMciCode; - - self.graphicRenditionForErase = _.clone(self.graphicRendition); - } - - - self.emit('mci', { - mci : mciCode, - id : id ? parseInt(id, 10) : null, - args : args, - SGR : ansi.getSGRFromGraphicRendition(self.graphicRendition, true) - }); - - if(self.mciReplaceChar.length > 0) { - const sgrCtrl = ansi.getSGRFromGraphicRendition(self.graphicRenditionForErase); - - self.emit('control', sgrCtrl, 'm', sgrCtrl.slice(2).split(/[\;m]/).slice(0, 3)); - - literal(new Array(match[0].length + 1).join(self.mciReplaceChar)); - } else { - literal(match[0]); - } - - //literal(getProcessedMCI(match[0])); - - //self.emit('chunk', getProcessedMCI(match[0])); - } - - } while(0 !== mciRe.lastIndex); - - if(pos < buffer.length) { - literal(buffer.slice(pos)); - } - } - - self.reset = function(input) { - self.parseState = { - // ignore anything past EOF marker, if any - buffer : input.split(String.fromCharCode(0x1a), 1)[0], - re : /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, - stop : false, - }; - }; - - self.stop = function() { - self.parseState.stop = true; - }; - - self.parse = function(input) { - if(input) { - self.reset(input); - } - - // :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc. - var pos; - var match; - var opCode; - var args; - var re = self.parseState.re; - var buffer = self.parseState.buffer; - - self.parseState.stop = false; - - do { - if(self.parseState.stop) { - return; - } - - pos = re.lastIndex; - match = re.exec(buffer); - - if(null !== match) { - if(match.index > pos) { - parseMCI(buffer.slice(pos, match.index)); - } - - opCode = match[2]; - args = match[1].split(';').map(v => parseInt(v, 10)); // convert to array of ints - - escape(opCode, args); - - //self.emit('chunk', match[0]); - self.emit('control', match[0], opCode, args); - } - } while(0 !== re.lastIndex); - - if(pos < buffer.length) { - var lastBit = buffer.slice(pos); - - // :TODO: check for various ending LF's, not just DOS \r\n - if('\r\n' === lastBit.slice(-2).toString()) { - switch(self.trailingLF) { - case 'default' : - // - // Default is to *not* omit the trailing LF - // if we're going to end on termHeight - // - if(this.termHeight === self.row) { - lastBit = lastBit.slice(0, -2); - } - break; - - case 'omit' : - case 'no' : - case false : - lastBit = lastBit.slice(0, -2); - break; - } - } - - parseMCI(lastBit) - } - - self.emit('complete'); - }; - -/* - self.parse = function(buffer, savedRe) { - // :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc. - // :TODO: move this to "constants" section @ top - var re = /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g; - var pos = 0; - var match; - var opCode; - var args; - - // ignore anything past EOF marker, if any - buffer = buffer.split(String.fromCharCode(0x1a), 1)[0]; - - do { - pos = re.lastIndex; - match = re.exec(buffer); - - if(null !== match) { - if(match.index > pos) { - parseMCI(buffer.slice(pos, match.index)); - } - - opCode = match[2]; - args = getArgArray(match[1].split(';')); - - escape(opCode, args); - - self.emit('chunk', match[0]); - } - - - - } while(0 !== re.lastIndex); - - if(pos < buffer.length) { - parseMCI(buffer.slice(pos)); - } - - self.emit('complete'); - }; - */ - - function escape(opCode, args) { - let arg; - - switch(opCode) { - // cursor up - case 'A' : - //arg = args[0] || 1; - arg = isNaN(args[0]) ? 1 : args[0]; - self.moveCursor(0, -arg); - break; - - // cursor down - case 'B' : - //arg = args[0] || 1; - arg = isNaN(args[0]) ? 1 : args[0]; - self.moveCursor(0, arg); - break; - - // cursor forward/right - case 'C' : - //arg = args[0] || 1; - arg = isNaN(args[0]) ? 1 : args[0]; - self.moveCursor(arg, 0); - break; - - // cursor back/left - case 'D' : - //arg = args[0] || 1; - arg = isNaN(args[0]) ? 1 : args[0]; - self.moveCursor(-arg, 0); - break; - - case 'f' : // horiz & vertical - case 'H' : // cursor position - //self.row = args[0] || 1; - //self.column = args[1] || 1; - self.row = isNaN(args[0]) ? 1 : args[0]; - self.column = isNaN(args[1]) ? 1 : args[1]; - //self.rowUpdated(); - self.positionUpdated(); - break; - - // save position - case 's' : - self.saveCursorPosition(); - break; - - // restore position - case 'u' : - self.restoreCursorPosition(); - break; - - // set graphic rendition - case 'm' : - self.graphicRendition.reset = false; - - for(let i = 0, len = args.length; i < len; ++i) { - arg = args[i]; - - if(ANSIEscapeParser.foregroundColors[arg]) { - self.graphicRendition.fg = arg; - } else if(ANSIEscapeParser.backgroundColors[arg]) { - self.graphicRendition.bg = arg; - } else if(ANSIEscapeParser.styles[arg]) { - switch(arg) { - case 0 : - // clear out everything - delete self.graphicRendition.intensity; - delete self.graphicRendition.underline; - delete self.graphicRendition.blink; - delete self.graphicRendition.negative; - delete self.graphicRendition.invisible; - - delete self.graphicRendition.fg; - delete self.graphicRendition.bg; - - self.graphicRendition.reset = true; - //self.graphicRendition.fg = 39; - //self.graphicRendition.bg = 49; - break; - - case 1 : - case 2 : - case 22 : - self.graphicRendition.intensity = arg; - break; - - case 4 : - case 24 : - self.graphicRendition.underline = arg; - break; - - case 5 : - case 6 : - case 25 : - self.graphicRendition.blink = arg; - break; - - case 7 : - case 27 : - self.graphicRendition.negative = arg; - break; - - case 8 : - case 28 : - self.graphicRendition.invisible = arg; - break; - - default : - console.log('Unknown attribute: ' + arg); // :TODO: Log properly - break; - } - } - } - - self.emit('sgr update', self.graphicRendition); - break; // m - - // :TODO: s, u, K - - // erase display/screen - case 'J' : - // :TODO: Handle other 'J' types! - if(2 === args[0]) { - self.clearScreen(); - } - break; - } - } + var self = this; + + events.EventEmitter.call(this); + + this.column = 1; + this.row = 1; + this.scrollBack = 0; + this.graphicRendition = {}; + + this.parseState = { + re : /(?:\x1b\x5b)([?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, // eslint-disable-line no-control-regex + }; + + options = miscUtil.valueWithDefault(options, { + mciReplaceChar : '', + termHeight : 25, + termWidth : 80, + trailingLF : 'default', // default|omit|no|yes, ... + }); + + this.mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, ''); + this.termHeight = miscUtil.valueWithDefault(options.termHeight, 25); + this.termWidth = miscUtil.valueWithDefault(options.termWidth, 80); + this.trailingLF = miscUtil.valueWithDefault(options.trailingLF, 'default'); + + self.moveCursor = function(cols, rows) { + self.column += cols; + self.row += rows; + + self.column = Math.max(self.column, 1); + self.column = Math.min(self.column, self.termWidth); // can't move past term width + self.row = Math.max(self.row, 1); + + self.positionUpdated(); + }; + + self.saveCursorPosition = function() { + self.savedPosition = { + row : self.row, + column : self.column + }; + }; + + self.restoreCursorPosition = function() { + self.row = self.savedPosition.row; + self.column = self.savedPosition.column; + delete self.savedPosition; + + self.positionUpdated(); + // self.rowUpdated(); + }; + + self.clearScreen = function() { + // :TODO: should be doing something with row/column? + self.emit('clear screen'); + }; + + /* + self.rowUpdated = function() { + self.emit('row update', self.row + self.scrollBack); + };*/ + + self.positionUpdated = function() { + self.emit('position update', self.row, self.column); + }; + + function literal(text) { + const len = text.length; + let pos = 0; + let start = 0; + let charCode; + let lastCharCode; + + while(pos < len) { + charCode = text.charCodeAt(pos) & 0xff; // 8bit clean + + switch(charCode) { + case CR : + self.emit('literal', text.slice(start, pos)); + start = pos; + + self.column = 1; + + self.positionUpdated(); + break; + + case LF : + // Handle ANSI saved with UNIX-style LF's only + // vs the CRLF pairs + if (lastCharCode !== CR) { + self.column = 1; + } + + self.emit('literal', text.slice(start, pos)); + start = pos; + + self.row += 1; + + self.positionUpdated(); + break; + + default : + if(self.column === self.termWidth) { + self.emit('literal', text.slice(start, pos + 1)); + start = pos + 1; + + self.column = 1; + self.row += 1; + + self.positionUpdated(); + } else { + self.column += 1; + } + break; + } + + ++pos; + lastCharCode = charCode; + } + + // + // Finalize this chunk + // + if(self.column > self.termWidth) { + self.column = 1; + self.row += 1; + + self.positionUpdated(); + } + + const rem = text.slice(start); + if(rem) { + self.emit('literal', rem); + } + } + + function parseMCI(buffer) { + // :TODO: move this to "constants" seciton @ top + var mciRe = /%([A-Z]{2})([0-9]{1,2})?(?:\(([0-9A-Za-z,]+)\))*/g; + var pos = 0; + var match; + var mciCode; + var args; + var id; + + do { + pos = mciRe.lastIndex; + match = mciRe.exec(buffer); + + if(null !== match) { + if(match.index > pos) { + literal(buffer.slice(pos, match.index)); + } + + mciCode = match[1]; + id = match[2] || null; + + if(match[3]) { + args = match[3].split(','); + } else { + args = []; + } + + // if MCI codes are changing, save off the current color + var fullMciCode = mciCode + (id || ''); + if(self.lastMciCode !== fullMciCode) { + + self.lastMciCode = fullMciCode; + + self.graphicRenditionForErase = _.clone(self.graphicRendition); + } + + + self.emit('mci', { + mci : mciCode, + id : id ? parseInt(id, 10) : null, + args : args, + SGR : ansi.getSGRFromGraphicRendition(self.graphicRendition, true) + }); + + if(self.mciReplaceChar.length > 0) { + const sgrCtrl = ansi.getSGRFromGraphicRendition(self.graphicRenditionForErase); + + self.emit('control', sgrCtrl, 'm', sgrCtrl.slice(2).split(/[;m]/).slice(0, 3)); + + literal(new Array(match[0].length + 1).join(self.mciReplaceChar)); + } else { + literal(match[0]); + } + } + + } while(0 !== mciRe.lastIndex); + + if(pos < buffer.length) { + literal(buffer.slice(pos)); + } + } + + self.reset = function(input) { + self.parseState = { + // ignore anything past EOF marker, if any + buffer : input.split(String.fromCharCode(0x1a), 1)[0], + re : /(?:\x1b\x5b)([?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, // eslint-disable-line no-control-regex + stop : false, + }; + }; + + self.stop = function() { + self.parseState.stop = true; + }; + + self.parse = function(input) { + if(input) { + self.reset(input); + } + + // :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc. + var pos; + var match; + var opCode; + var args; + var re = self.parseState.re; + var buffer = self.parseState.buffer; + + self.parseState.stop = false; + + do { + if(self.parseState.stop) { + return; + } + + pos = re.lastIndex; + match = re.exec(buffer); + + if(null !== match) { + if(match.index > pos) { + parseMCI(buffer.slice(pos, match.index)); + } + + opCode = match[2]; + args = match[1].split(';').map(v => parseInt(v, 10)); // convert to array of ints + + escape(opCode, args); + + //self.emit('chunk', match[0]); + self.emit('control', match[0], opCode, args); + } + } while(0 !== re.lastIndex); + + if(pos < buffer.length) { + var lastBit = buffer.slice(pos); + + // :TODO: check for various ending LF's, not just DOS \r\n + if('\r\n' === lastBit.slice(-2).toString()) { + switch(self.trailingLF) { + case 'default' : + // + // Default is to *not* omit the trailing LF + // if we're going to end on termHeight + // + if(this.termHeight === self.row) { + lastBit = lastBit.slice(0, -2); + } + break; + + case 'omit' : + case 'no' : + case false : + lastBit = lastBit.slice(0, -2); + break; + } + } + + parseMCI(lastBit); + } + + self.emit('complete'); + }; + + /* + self.parse = function(buffer, savedRe) { + // :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc. + // :TODO: move this to "constants" section @ top + var re = /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g; + var pos = 0; + var match; + var opCode; + var args; + + // ignore anything past EOF marker, if any + buffer = buffer.split(String.fromCharCode(0x1a), 1)[0]; + + do { + pos = re.lastIndex; + match = re.exec(buffer); + + if(null !== match) { + if(match.index > pos) { + parseMCI(buffer.slice(pos, match.index)); + } + + opCode = match[2]; + args = getArgArray(match[1].split(';')); + + escape(opCode, args); + + self.emit('chunk', match[0]); + } + + + + } while(0 !== re.lastIndex); + + if(pos < buffer.length) { + parseMCI(buffer.slice(pos)); + } + + self.emit('complete'); + }; + */ + + function escape(opCode, args) { + let arg; + + switch(opCode) { + // cursor up + case 'A' : + //arg = args[0] || 1; + arg = isNaN(args[0]) ? 1 : args[0]; + self.moveCursor(0, -arg); + break; + + // cursor down + case 'B' : + //arg = args[0] || 1; + arg = isNaN(args[0]) ? 1 : args[0]; + self.moveCursor(0, arg); + break; + + // cursor forward/right + case 'C' : + //arg = args[0] || 1; + arg = isNaN(args[0]) ? 1 : args[0]; + self.moveCursor(arg, 0); + break; + + // cursor back/left + case 'D' : + //arg = args[0] || 1; + arg = isNaN(args[0]) ? 1 : args[0]; + self.moveCursor(-arg, 0); + break; + + case 'f' : // horiz & vertical + case 'H' : // cursor position + //self.row = args[0] || 1; + //self.column = args[1] || 1; + self.row = isNaN(args[0]) ? 1 : args[0]; + self.column = isNaN(args[1]) ? 1 : args[1]; + //self.rowUpdated(); + self.positionUpdated(); + break; + + // save position + case 's' : + self.saveCursorPosition(); + break; + + // restore position + case 'u' : + self.restoreCursorPosition(); + break; + + // set graphic rendition + case 'm' : + self.graphicRendition.reset = false; + + for(let i = 0, len = args.length; i < len; ++i) { + arg = args[i]; + + if(ANSIEscapeParser.foregroundColors[arg]) { + self.graphicRendition.fg = arg; + } else if(ANSIEscapeParser.backgroundColors[arg]) { + self.graphicRendition.bg = arg; + } else if(ANSIEscapeParser.styles[arg]) { + switch(arg) { + case 0 : + // clear out everything + delete self.graphicRendition.intensity; + delete self.graphicRendition.underline; + delete self.graphicRendition.blink; + delete self.graphicRendition.negative; + delete self.graphicRendition.invisible; + + delete self.graphicRendition.fg; + delete self.graphicRendition.bg; + + self.graphicRendition.reset = true; + //self.graphicRendition.fg = 39; + //self.graphicRendition.bg = 49; + break; + + case 1 : + case 2 : + case 22 : + self.graphicRendition.intensity = arg; + break; + + case 4 : + case 24 : + self.graphicRendition.underline = arg; + break; + + case 5 : + case 6 : + case 25 : + self.graphicRendition.blink = arg; + break; + + case 7 : + case 27 : + self.graphicRendition.negative = arg; + break; + + case 8 : + case 28 : + self.graphicRendition.invisible = arg; + break; + + default : + Log.trace( { attribute : arg }, 'Unknown attribute while parsing ANSI'); + break; + } + } + } + + self.emit('sgr update', self.graphicRendition); + break; // m + + // :TODO: s, u, K + + // erase display/screen + case 'J' : + // :TODO: Handle other 'J' types! + if(2 === args[0]) { + self.clearScreen(); + } + break; + } + } } util.inherits(ANSIEscapeParser, events.EventEmitter); ANSIEscapeParser.foregroundColors = { - 30 : 'black', - 31 : 'red', - 32 : 'green', - 33 : 'yellow', - 34 : 'blue', - 35 : 'magenta', - 36 : 'cyan', - 37 : 'white', - 39 : 'default', // same as white for most implementations + 30 : 'black', + 31 : 'red', + 32 : 'green', + 33 : 'yellow', + 34 : 'blue', + 35 : 'magenta', + 36 : 'cyan', + 37 : 'white', + 39 : 'default', // same as white for most implementations - 90 : 'grey' + 90 : 'grey' }; Object.freeze(ANSIEscapeParser.foregroundColors); ANSIEscapeParser.backgroundColors = { - 40 : 'black', - 41 : 'red', - 42 : 'green', - 43 : 'yellow', - 44 : 'blue', - 45 : 'magenta', - 46 : 'cyan', - 47 : 'white', - 49 : 'default', // same as black for most implementations + 40 : 'black', + 41 : 'red', + 42 : 'green', + 43 : 'yellow', + 44 : 'blue', + 45 : 'magenta', + 46 : 'cyan', + 47 : 'white', + 49 : 'default', // same as black for most implementations }; Object.freeze(ANSIEscapeParser.backgroundColors); -// :TODO: ensure these names all align with that of ansi_term.js +// :TODO: ensure these names all align with that of ansi_term.js // -// See the following specs: -// * http://www.ansi-bbs.org/ansi-bbs-core-server.html -// * http://www.vt100.net/docs/vt510-rm/SGR -// * https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt +// See the following specs: +// * http://www.ansi-bbs.org/ansi-bbs-core-server.html +// * http://www.vt100.net/docs/vt510-rm/SGR +// * https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt // -// Note that these are intentionally not in order such that they -// can be grouped by concept here in code. +// Note that these are intentionally not in order such that they +// can be grouped by concept here in code. // ANSIEscapeParser.styles = { - 0 : 'default', // Everything disabled + 0 : 'default', // Everything disabled - 1 : 'intensityBright', // aka bold - 2 : 'intensityDim', - 22 : 'intensityNormal', + 1 : 'intensityBright', // aka bold + 2 : 'intensityDim', + 22 : 'intensityNormal', - 4 : 'underlineOn', // Not supported by most BBS-like terminals - 24 : 'underlineOff', // Not supported by most BBS-like terminals + 4 : 'underlineOn', // Not supported by most BBS-like terminals + 24 : 'underlineOff', // Not supported by most BBS-like terminals - 5 : 'blinkSlow', // blinkSlow & blinkFast are generally treated the same - 6 : 'blinkFast', // blinkSlow & blinkFast are generally treated the same - 25 : 'blinkOff', + 5 : 'blinkSlow', // blinkSlow & blinkFast are generally treated the same + 6 : 'blinkFast', // blinkSlow & blinkFast are generally treated the same + 25 : 'blinkOff', - 7 : 'negativeImageOn', // Generally not supported or treated as "reverse FG & BG" - 27 : 'negativeImageOff', // Generally not supported or treated as "reverse FG & BG" + 7 : 'negativeImageOn', // Generally not supported or treated as "reverse FG & BG" + 27 : 'negativeImageOff', // Generally not supported or treated as "reverse FG & BG" - 8 : 'invisibleOn', // FG set to BG - 28 : 'invisibleOff', // Not supported by most BBS-like terminals + 8 : 'invisibleOn', // FG set to BG + 28 : 'invisibleOff', // Not supported by most BBS-like terminals }; Object.freeze(ANSIEscapeParser.styles); diff --git a/core/ansi_prep.js b/core/ansi_prep.js index 29bd5ee4..09c9bbf6 100644 --- a/core/ansi_prep.js +++ b/core/ansi_prep.js @@ -1,212 +1,220 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser; -const ANSI = require('./ansi_term.js'); -const { - splitTextAtTerms, - renderStringLength -} = require('./string_util.js'); +// ENiGMA½ +const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser; +const ANSI = require('./ansi_term.js'); +const { + splitTextAtTerms, + renderStringLength +} = require('./string_util.js'); -// deps -const _ = require('lodash'); +// deps +const _ = require('lodash'); module.exports = function ansiPrep(input, options, cb) { - if(!input) { - return cb(null, ''); - } + if(!input) { + return cb(null, ''); + } - options.termWidth = options.termWidth || 80; - options.termHeight = options.termHeight || 25; - options.cols = options.cols || options.termWidth || 80; - options.rows = options.rows || options.termHeight || 'auto'; - options.startCol = options.startCol || 1; - options.exportMode = options.exportMode || false; + options.termWidth = options.termWidth || 80; + options.termHeight = options.termHeight || 25; + options.cols = options.cols || options.termWidth || 80; + options.rows = options.rows || options.termHeight || 'auto'; + options.startCol = options.startCol || 1; + options.exportMode = options.exportMode || false; + options.fillLines = _.get(options, 'fillLines', true); + options.indent = options.indent || 0; - // in auto we start out at 25 rows, but can always expand for more - const canvas = Array.from( { length : 'auto' === options.rows ? 25 : options.rows }, () => Array.from( { length : options.cols}, () => new Object() ) ); - const parser = new ANSIEscapeParser( { termHeight : options.termHeight, termWidth : options.termWidth } ); + // in auto we start out at 25 rows, but can always expand for more + const canvas = Array.from( { length : 'auto' === options.rows ? 25 : options.rows }, () => Array.from( { length : options.cols}, () => new Object() ) ); + const parser = new ANSIEscapeParser( { termHeight : options.termHeight, termWidth : options.termWidth } ); - const state = { - row : 0, - col : 0, - }; + const state = { + row : 0, + col : 0, + }; - let lastRow = 0; + let lastRow = 0; - function ensureRow(row) { - if(canvas[row]) { - return; - } - - canvas[row] = Array.from( { length : options.cols}, () => new Object() ); - } + function ensureRow(row) { + if(canvas[row]) { + return; + } - parser.on('position update', (row, col) => { - state.row = row - 1; - state.col = col - 1; + canvas[row] = Array.from( { length : options.cols}, () => new Object() ); + } - if(0 === state.col) { - state.initialSgr = state.lastSgr; - } + parser.on('position update', (row, col) => { + state.row = row - 1; + state.col = col - 1; - lastRow = Math.max(state.row, lastRow); - }); + if(0 === state.col) { + state.initialSgr = state.lastSgr; + } - parser.on('literal', literal => { - // - // CR/LF are handled for 'position update'; we don't need the chars themselves - // - literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, ''); + lastRow = Math.max(state.row, lastRow); + }); - for(let c of literal) { - if(state.col < options.cols && ('auto' === options.rows || state.row < options.rows)) { - ensureRow(state.row); + parser.on('literal', literal => { + // + // CR/LF are handled for 'position update'; we don't need the chars themselves + // + literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, ''); - if(0 === state.col) { - canvas[state.row][state.col].initialSgr = state.initialSgr; - } + for(let c of literal) { + if(state.col < options.cols && ('auto' === options.rows || state.row < options.rows)) { + ensureRow(state.row); - canvas[state.row][state.col].char = c; + if(0 === state.col) { + canvas[state.row][state.col].initialSgr = state.initialSgr; + } - if(state.sgr) { - canvas[state.row][state.col].sgr = _.clone(state.sgr); - state.lastSgr = canvas[state.row][state.col].sgr; - state.sgr = null; - } - } + canvas[state.row][state.col].char = c; - state.col += 1; - } - }); + if(state.sgr) { + canvas[state.row][state.col].sgr = _.clone(state.sgr); + state.lastSgr = canvas[state.row][state.col].sgr; + state.sgr = null; + } + } - parser.on('sgr update', sgr => { - ensureRow(state.row); + state.col += 1; + } + }); - if(state.col < options.cols) { - canvas[state.row][state.col].sgr = _.clone(sgr); - state.lastSgr = canvas[state.row][state.col].sgr; - } else { - state.sgr = sgr; - } - }); + parser.on('sgr update', sgr => { + ensureRow(state.row); - function getLastPopulatedColumn(row) { - let col = row.length; - while(--col > 0) { - if(row[col].char || row[col].sgr) { - break; - } - } - return col; - } + if(state.col < options.cols) { + canvas[state.row][state.col].sgr = _.clone(sgr); + state.lastSgr = canvas[state.row][state.col].sgr; + } else { + state.sgr = sgr; + } + }); - parser.on('complete', () => { - let output = ''; - let line; - let sgr; + function getLastPopulatedColumn(row) { + let col = row.length; + while(--col > 0) { + if(row[col].char || row[col].sgr) { + break; + } + } + return col; + } - canvas.slice(0, lastRow + 1).forEach(row => { - const lastCol = getLastPopulatedColumn(row) + 1; + parser.on('complete', () => { + let output = ''; + let line; + let sgr; - let i; - line = ''; - for(i = 0; i < lastCol; ++i) { - const col = row[i]; + canvas.slice(0, lastRow + 1).forEach(row => { + const lastCol = getLastPopulatedColumn(row) + 1; - sgr = 0 === i ? - col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' : - ''; - - if(col.sgr) { - sgr += ANSI.getSGRFromGraphicRendition(col.sgr); - } + let i; + line = options.indent ? + output.length > 0 ? ' '.repeat(options.indent) : '' : + ''; - line += `${sgr}${col.char || ' '}`; - } + for(i = 0; i < lastCol; ++i) { + const col = row[i]; - output += line; + sgr = !options.asciiMode && 0 === i ? + col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' : + ''; - if(i < row.length) { - output += `${ANSI.blackBG()}${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`; - } + if(!options.asciiMode && col.sgr) { + sgr += ANSI.getSGRFromGraphicRendition(col.sgr); + } - if(options.startCol + i < options.termWidth || options.forceLineTerm) { - output += '\r\n'; - } - }); + line += `${sgr}${col.char || ' '}`; + } - if(options.exportMode) { - // - // If we're in export mode, we do some additional hackery: - // - // * Hard wrap ALL lines at <= 79 *characters* (not visible columns) - // if a line must wrap early, we'll place a ESC[A ESC[C where - // represents chars to get back to the position we were previously at - // - // * Replace contig spaces with ESC[C as well to save... space. - // - // :TODO: this would be better to do as part of the processing above, but this will do for now - const MAX_CHARS = 79 - 8; // 79 max, - 8 for max ESC seq's we may prefix a line with - let exportOutput = ''; - - let m; - let afterSeq; - let wantMore; - let renderStart; + output += line; - splitTextAtTerms(output).forEach(fullLine => { - renderStart = 0; + if(i < row.length) { + output += `${options.asciiMode ? '' : ANSI.blackBG()}`; + if(options.fillLines) { + output += `${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`; + } + } - while(fullLine.length > 0) { - let splitAt; - const ANSI_REGEXP = ANSI.getFullMatchRegExp(); - wantMore = true; + if(options.startCol + i < options.termWidth || options.forceLineTerm) { + output += '\r\n'; + } + }); - while((m = ANSI_REGEXP.exec(fullLine))) { - afterSeq = m.index + m[0].length; + if(options.exportMode) { + // + // If we're in export mode, we do some additional hackery: + // + // * Hard wrap ALL lines at <= 79 *characters* (not visible columns) + // if a line must wrap early, we'll place a ESC[A ESC[C where + // represents chars to get back to the position we were previously at + // + // * Replace contig spaces with ESC[C as well to save... space. + // + // :TODO: this would be better to do as part of the processing above, but this will do for now + const MAX_CHARS = 79 - 8; // 79 max, - 8 for max ESC seq's we may prefix a line with + let exportOutput = ''; - if(afterSeq < MAX_CHARS) { - // after current seq - splitAt = afterSeq; - } else { - if(m.index < MAX_CHARS) { - // before last found seq - splitAt = m.index; - wantMore = false; // can't eat up any more - } - - break; // seq's beyond this point are >= MAX_CHARS - } - } + let m; + let afterSeq; + let wantMore; + let renderStart; - if(splitAt) { - if(wantMore) { - splitAt = Math.min(fullLine.length, MAX_CHARS - 1); - } - } else { - splitAt = Math.min(fullLine.length, MAX_CHARS - 1); - } + splitTextAtTerms(output).forEach(fullLine => { + renderStart = 0; - const part = fullLine.slice(0, splitAt); - fullLine = fullLine.slice(splitAt); - renderStart += renderStringLength(part); - exportOutput += `${part}\r\n`; + while(fullLine.length > 0) { + let splitAt; + const ANSI_REGEXP = ANSI.getFullMatchRegExp(); + wantMore = true; - if(fullLine.length > 0) { // more to go for this line? - exportOutput += `${ANSI.up()}${ANSI.right(renderStart)}`; - } else { - exportOutput += ANSI.up(); - } - } - }); + while((m = ANSI_REGEXP.exec(fullLine))) { + afterSeq = m.index + m[0].length; - return cb(null, exportOutput); - } + if(afterSeq < MAX_CHARS) { + // after current seq + splitAt = afterSeq; + } else { + if(m.index < MAX_CHARS) { + // before last found seq + splitAt = m.index; + wantMore = false; // can't eat up any more + } - return cb(null, output); - }); + break; // seq's beyond this point are >= MAX_CHARS + } + } - parser.parse(input); + if(splitAt) { + if(wantMore) { + splitAt = Math.min(fullLine.length, MAX_CHARS - 1); + } + } else { + splitAt = Math.min(fullLine.length, MAX_CHARS - 1); + } + + const part = fullLine.slice(0, splitAt); + fullLine = fullLine.slice(splitAt); + renderStart += renderStringLength(part); + exportOutput += `${part}\r\n`; + + if(fullLine.length > 0) { // more to go for this line? + exportOutput += `${ANSI.up()}${ANSI.right(renderStart)}`; + } else { + exportOutput += ANSI.up(); + } + } + }); + + return cb(null, exportOutput); + } + + return cb(null, output); + }); + + parser.parse(input); }; diff --git a/core/ansi_term.js b/core/ansi_term.js index 8b4094f1..cac29681 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -2,486 +2,505 @@ 'use strict'; // -// ANSI Terminal Support Resources -// -// ANSI-BBS -// * http://ansi-bbs.org/ +// ANSI Terminal Support Resources // -// CTerm / SyncTERM -// * https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt +// ANSI-BBS +// * http://ansi-bbs.org/ // -// BananaCom -// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt +// CTerm / SyncTERM +// * https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt // -// ANSI.SYS -// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt -// * http://academic.evergreen.edu/projects/biophysics/technotes/program/ansi_esc.htm +// BananaCom +// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt // -// VTX -// * https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt +// ANSI.SYS +// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt +// * http://academic.evergreen.edu/projects/biophysics/technotes/program/ansi_esc.htm // -// General -// * http://en.wikipedia.org/wiki/ANSI_escape_code -// * http://www.inwap.com/pdp10/ansicode.txt +// Modern Windows (Win10+) +// * https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences // -// Other Implementations -// * https://github.com/chjj/term.js/blob/master/src/term.js +// VT100 +// * http://www.noah.org/python/pexpect/ANSI-X3.64.htm +// +// VTX +// * https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt +// +// General +// * http://en.wikipedia.org/wiki/ANSI_escape_code +// * http://www.inwap.com/pdp10/ansicode.txt +// * Excellent information with many standards covered (for hterm): +// https://chromium.googlesource.com/apps/libapps/+/master/hterm/doc/ControlSequences.md +// +// Other Implementations +// * https://github.com/chjj/term.js/blob/master/src/term.js // // -// For a board, we need to support the semi-standard ANSI-BBS "spec" which -// is bastardized mix of DOS ANSI.SYS, cterm.txt, bansi.txt and a little other. -// This gives us NetRunner, SyncTERM, EtherTerm, most *nix terminals, compatibilitiy -// with legit oldschool DOS terminals, and so on. +// For a board, we need to support the semi-standard ANSI-BBS "spec" which +// is bastardized mix of DOS ANSI.SYS, cterm.txt, bansi.txt and a little other. +// This gives us NetRunner, SyncTERM, EtherTerm, most *nix terminals, compatibilitiy +// with legit oldschool DOS terminals, and so on. // -// ENiGMA½ -const miscUtil = require('./misc_util.js'); +// ENiGMA½ +const miscUtil = require('./misc_util.js'); -// deps -const assert = require('assert'); -const _ = require('lodash'); - -exports.getFullMatchRegExp = getFullMatchRegExp; -exports.getFGColorValue = getFGColorValue; -exports.getBGColorValue = getBGColorValue; -exports.sgr = sgr; -exports.getSGRFromGraphicRendition = getSGRFromGraphicRendition; -exports.clearScreen = clearScreen; -exports.resetScreen = resetScreen; -exports.normal = normal; -exports.goHome = goHome; -exports.disableVT100LineWrapping = disableVT100LineWrapping; -exports.setSyncTERMFont = setSyncTERMFont; -exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias; -exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias; -exports.setCursorStyle = setCursorStyle; -exports.setEmulatedBaudRate = setEmulatedBaudRate; +// deps +const assert = require('assert'); +const _ = require('lodash'); +exports.getFullMatchRegExp = getFullMatchRegExp; +exports.getFGColorValue = getFGColorValue; +exports.getBGColorValue = getBGColorValue; +exports.sgr = sgr; +exports.getSGRFromGraphicRendition = getSGRFromGraphicRendition; +exports.clearScreen = clearScreen; +exports.resetScreen = resetScreen; +exports.normal = normal; +exports.goHome = goHome; +exports.disableVT100LineWrapping = disableVT100LineWrapping; +exports.setSyncTERMFont = setSyncTERMFont; +exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias; +exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias; +exports.setCursorStyle = setCursorStyle; +exports.setEmulatedBaudRate = setEmulatedBaudRate; +exports.vtxHyperlink = vtxHyperlink; // -// See also -// https://github.com/TooTallNate/ansi.js/blob/master/lib/ansi.js +// See also +// https://github.com/TooTallNate/ansi.js/blob/master/lib/ansi.js -const ESC_CSI = '\u001b['; +const ESC_CSI = '\u001b['; const CONTROL = { - up : 'A', - down : 'B', + up : 'A', + down : 'B', - forward : 'C', - right : 'C', + forward : 'C', + right : 'C', - back : 'D', - left : 'D', + back : 'D', + left : 'D', - nextLine : 'E', - prevLine : 'F', - horizAbsolute : 'G', + nextLine : 'E', + prevLine : 'F', + horizAbsolute : 'G', - // - // CSI [ p1 ] J - // Erase in Page / Erase Data - // Defaults: p1 = 0 - // Erases from the current screen according to the value of p1 - // 0 - Erase from the current position to the end of the screen. - // 1 - Erase from the current position to the start of the screen. - // 2 - Erase entire screen. As a violation of ECMA-048, also moves - // the cursor to position 1/1 as a number of BBS programs assume - // this behaviour. - // Erased characters are set to the current attribute. - // - // Support: - // * SyncTERM: Works as expected - // * NetRunner: Always clears a screen *height* (e.g. 25) regardless of p1 - // and screen remainder - // - eraseData : 'J', + // + // CSI [ p1 ] J + // Erase in Page / Erase Data + // Defaults: p1 = 0 + // Erases from the current screen according to the value of p1 + // 0 - Erase from the current position to the end of the screen. + // 1 - Erase from the current position to the start of the screen. + // 2 - Erase entire screen. As a violation of ECMA-048, also moves + // the cursor to position 1/1 as a number of BBS programs assume + // this behaviour. + // Erased characters are set to the current attribute. + // + // Support: + // * SyncTERM: Works as expected + // * NetRunner: Always clears a screen *height* (e.g. 25) regardless of p1 + // and screen remainder + // + eraseData : 'J', - eraseLine : 'K', - insertLine : 'L', + eraseLine : 'K', + insertLine : 'L', - // - // CSI [ p1 ] M - // Delete Line(s) / "ANSI" Music - // Defaults: p1 = 1 - // Deletes the current line and the p1 - 1 lines after it scrolling the - // first non-deleted line up to the current line and filling the newly - // empty lines at the end of the screen with the current attribute. - // If "ANSI" Music is fully enabled (CSI = 2 M), performs "ANSI" music - // instead. - // See "ANSI" MUSIC section for more details. - // - // Support: - // * SyncTERM: Works as expected - // * NetRunner: - // - // General Notes: - // See also notes in bansi.txt and cterm.txt about the various - // incompatibilities & oddities around this sequence. ANSI-BBS - // states that it *should* work with any value of p1. - // - deleteLine : 'M', - ansiMusic : 'M', + // + // CSI [ p1 ] M + // Delete Line(s) / "ANSI" Music + // Defaults: p1 = 1 + // Deletes the current line and the p1 - 1 lines after it scrolling the + // first non-deleted line up to the current line and filling the newly + // empty lines at the end of the screen with the current attribute. + // If "ANSI" Music is fully enabled (CSI = 2 M), performs "ANSI" music + // instead. + // See "ANSI" MUSIC section for more details. + // + // Support: + // * SyncTERM: Works as expected + // * NetRunner: + // + // General Notes: + // See also notes in bansi.txt and cterm.txt about the various + // incompatibilities & oddities around this sequence. ANSI-BBS + // states that it *should* work with any value of p1. + // + deleteLine : 'M', + ansiMusic : 'M', - scrollUp : 'S', - scrollDown : 'T', - setScrollRegion : 'r', - savePos : 's', - restorePos : 'u', - queryPos : '6n', - queryScreenSize : '255n', // See bansi.txt - goto : 'H', // row Pr, column Pc -- same as f - gotoAlt : 'f', // same as H + scrollUp : 'S', + scrollDown : 'T', + setScrollRegion : 'r', + savePos : 's', + restorePos : 'u', + queryPos : '6n', + queryScreenSize : '255n', // See bansi.txt + goto : 'H', // row Pr, column Pc -- same as f + gotoAlt : 'f', // same as H - blinkToBrightIntensity : '?33h', - blinkNormal : '?33l', + blinkToBrightIntensity : '?33h', + blinkNormal : '?33l', - emulationSpeed : '*r', // Set output emulation speed. See cterm.txt + emulationSpeed : '*r', // Set output emulation speed. See cterm.txt - hideCursor : '?25l', // Nonstandard - cterm.txt - showCursor : '?25h', // Nonstandard - cterm.txt + hideCursor : '?25l', // Nonstandard - cterm.txt + showCursor : '?25h', // Nonstandard - cterm.txt - queryDeviceAttributes : 'c', // Nonstandard - cterm.txt + queryDeviceAttributes : 'c', // Nonstandard - cterm.txt - // :TODO: see https://code.google.com/p/conemu-maximus5/wiki/AnsiEscapeCodes - // apparently some terms can report screen size and text area via 18t and 19t + // :TODO: see https://code.google.com/p/conemu-maximus5/wiki/AnsiEscapeCodes + // apparently some terms can report screen size and text area via 18t and 19t }; // -// Select Graphics Rendition -// See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt +// Select Graphics Rendition +// See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt // const SGRValues = { - reset : 0, - bold : 1, - dim : 2, - blink : 5, - fastBlink : 6, - negative : 7, - hidden : 8, + reset : 0, + bold : 1, + dim : 2, + blink : 5, + fastBlink : 6, + negative : 7, + hidden : 8, - normal : 22, // - steady : 25, - positive : 27, + normal : 22, // + steady : 25, + positive : 27, - black : 30, - red : 31, - green : 32, - yellow : 33, - blue : 34, - magenta : 35, - cyan : 36, - white : 37, + black : 30, + red : 31, + green : 32, + yellow : 33, + blue : 34, + magenta : 35, + cyan : 36, + white : 37, - blackBG : 40, - redBG : 41, - greenBG : 42, - yellowBG : 43, - blueBG : 44, - magentaBG : 45, - cyanBG : 46, - whiteBG : 47, + blackBG : 40, + redBG : 41, + greenBG : 42, + yellowBG : 43, + blueBG : 44, + magentaBG : 45, + cyanBG : 46, + whiteBG : 47, }; function getFullMatchRegExp(flags = 'g') { - // :TODO: expand this a bit - see strip-ansi/etc. - // :TODO: \u009b ? - return new RegExp(/[\u001b][[()#;?]*([0-9]{1,4}(?:;[0-9]{0,4})*)?([0-9A-ORZcf-npqrsuy=><])/, flags); // eslint-disable-line no-control-regex + // :TODO: expand this a bit - see strip-ansi/etc. + // :TODO: \u009b ? + return new RegExp(/[\u001b][[()#;?]*([0-9]{1,4}(?:;[0-9]{0,4})*)?([0-9A-ORZcf-npqrsuy=><])/, flags); // eslint-disable-line no-control-regex } function getFGColorValue(name) { - return SGRValues[name]; + return SGRValues[name]; } function getBGColorValue(name) { - return SGRValues[name + 'BG']; + return SGRValues[name + 'BG']; } -// See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt -// :TODO: document -// :TODO: Create mappings for aliases... maybe make this a map to values instead -// :TODO: Break this up in to two parts: -// 1) FONT_AND_CODE_PAGES (e.g. SyncTERM/cterm) -// 2) SAUCE_FONT_MAP: Sauce name(s) -> items in FONT_AND_CODE_PAGES. -// ...we can then have getFontFromSAUCEName(sauceFontName) -// Also, create a SAUCE_ENCODING_MAP: SAUCE font name -> encodings +// See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt +// :TODO: document +// :TODO: Create mappings for aliases... maybe make this a map to values instead +// :TODO: Break this up in to two parts: +// 1) FONT_AND_CODE_PAGES (e.g. SyncTERM/cterm) +// 2) SAUCE_FONT_MAP: Sauce name(s) -> items in FONT_AND_CODE_PAGES. +// ...we can then have getFontFromSAUCEName(sauceFontName) +// Also, create a SAUCE_ENCODING_MAP: SAUCE font name -> encodings // -// An array of CTerm/SyncTERM font/encoding values. Each entry's index -// corresponds to it's escape sequence value (e.g. cp437 = 0) +// An array of CTerm/SyncTERM font/encoding values. Each entry's index +// corresponds to it's escape sequence value (e.g. cp437 = 0) // -// See https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt +// See https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt // const SYNCTERM_FONT_AND_ENCODING_TABLE = [ - 'cp437', - 'cp1251', - 'koi8_r', - 'iso8859_2', - 'iso8859_4', - 'cp866', - 'iso8859_9', - 'haik8', - 'iso8859_8', - 'koi8_u', - 'iso8859_15', - 'iso8859_4', - 'koi8_r_b', - 'iso8859_4', - 'iso8859_5', - 'ARMSCII_8', - 'iso8859_15', - 'cp850', - 'cp850', - 'cp885', - 'cp1251', - 'iso8859_7', - 'koi8-r_c', - 'iso8859_4', - 'iso8859_1', - 'cp866', - 'cp437', - 'cp866', - 'cp885', - 'cp866_u', - 'iso8859_1', - 'cp1131', - 'c64_upper', - 'c64_lower', - 'c128_upper', - 'c128_lower', - 'atari', - 'pot_noodle', - 'mo_soul', - 'microknight_plus', - 'topaz_plus', - 'microknight', - 'topaz', + 'cp437', + 'cp1251', + 'koi8_r', + 'iso8859_2', + 'iso8859_4', + 'cp866', + 'iso8859_9', + 'haik8', + 'iso8859_8', + 'koi8_u', + 'iso8859_15', + 'iso8859_4', + 'koi8_r_b', + 'iso8859_4', + 'iso8859_5', + 'ARMSCII_8', + 'iso8859_15', + 'cp850', + 'cp850', + 'cp885', + 'cp1251', + 'iso8859_7', + 'koi8-r_c', + 'iso8859_4', + 'iso8859_1', + 'cp866', + 'cp437', + 'cp866', + 'cp885', + 'cp866_u', + 'iso8859_1', + 'cp1131', + 'c64_upper', + 'c64_lower', + 'c128_upper', + 'c128_lower', + 'atari', + 'pot_noodle', + 'mo_soul', + 'microknight_plus', + 'topaz_plus', + 'microknight', + 'topaz', ]; // -// A map of various font name/aliases such as those used -// in SAUCE records to SyncTERM/CTerm names +// A map of various font name/aliases such as those used +// in SAUCE records to SyncTERM/CTerm names // -// This table contains lowercased entries with any spaces -// replaced with '_' for lookup purposes. +// This table contains lowercased entries with any spaces +// replaced with '_' for lookup purposes. // const FONT_ALIAS_TO_SYNCTERM_MAP = { - 'cp437' : 'cp437', - 'ibm_vga' : 'cp437', - 'ibmpc' : 'cp437', - 'ibm_pc' : 'cp437', - 'pc' : 'cp437', - 'cp437_art' : 'cp437', - 'ibmpcart' : 'cp437', - 'ibmpc_art' : 'cp437', - 'ibm_pc_art' : 'cp437', - 'msdos_art' : 'cp437', - 'msdosart' : 'cp437', - 'pc_art' : 'cp437', - 'pcart' : 'cp437', + 'cp437' : 'cp437', + 'ibm_vga' : 'cp437', + 'ibmpc' : 'cp437', + 'ibm_pc' : 'cp437', + 'pc' : 'cp437', + 'cp437_art' : 'cp437', + 'ibmpcart' : 'cp437', + 'ibmpc_art' : 'cp437', + 'ibm_pc_art' : 'cp437', + 'msdos_art' : 'cp437', + 'msdosart' : 'cp437', + 'pc_art' : 'cp437', + 'pcart' : 'cp437', - 'ibm_vga50' : 'cp437', - 'ibm_vga25g' : 'cp437', - 'ibm_ega' : 'cp437', - 'ibm_ega43' : 'cp437', + 'ibm_vga50' : 'cp437', + 'ibm_vga25g' : 'cp437', + 'ibm_ega' : 'cp437', + 'ibm_ega43' : 'cp437', - 'topaz' : 'topaz', - 'amiga_topaz_1' : 'topaz', - 'amiga_topaz_1+' : 'topaz_plus', - 'topazplus' : 'topaz_plus', - 'topaz_plus' : 'topaz_plus', - 'amiga_topaz_2' : 'topaz', - 'amiga_topaz_2+' : 'topaz_plus', - 'topaz2plus' : 'topaz_plus', + 'topaz' : 'topaz', + 'amiga_topaz_1' : 'topaz', + 'amiga_topaz_1+' : 'topaz_plus', + 'topazplus' : 'topaz_plus', + 'topaz_plus' : 'topaz_plus', + 'amiga_topaz_2' : 'topaz', + 'amiga_topaz_2+' : 'topaz_plus', + 'topaz2plus' : 'topaz_plus', - 'pot_noodle' : 'pot_noodle', - 'p0tnoodle' : 'pot_noodle', - 'amiga_p0t-noodle' : 'pot_noodle', + 'pot_noodle' : 'pot_noodle', + 'p0tnoodle' : 'pot_noodle', + 'amiga_p0t-noodle' : 'pot_noodle', - 'mo_soul' : 'mo_soul', - 'mosoul' : 'mo_soul', - 'mO\'sOul' : 'mo_soul', + 'mo_soul' : 'mo_soul', + 'mosoul' : 'mo_soul', + 'mO\'sOul' : 'mo_soul', - 'amiga_microknight' : 'microknight', - 'amiga_microknight+' : 'microknight_plus', + 'amiga_microknight' : 'microknight', + 'amiga_microknight+' : 'microknight_plus', - 'atari' : 'atari', - 'atarist' : 'atari', + 'atari' : 'atari', + 'atarist' : 'atari', }; function setSyncTERMFont(name, fontPage) { - const p1 = miscUtil.valueWithDefault(fontPage, 0); + const p1 = miscUtil.valueWithDefault(fontPage, 0); - assert(p1 >= 0 && p1 <= 3); + assert(p1 >= 0 && p1 <= 3); - const p2 = SYNCTERM_FONT_AND_ENCODING_TABLE.indexOf(name); - if(p2 > -1) { - return `${ESC_CSI}${p1};${p2} D`; - } + const p2 = SYNCTERM_FONT_AND_ENCODING_TABLE.indexOf(name); + if(p2 > -1) { + return `${ESC_CSI}${p1};${p2} D`; + } - return ''; + return ''; } function getSyncTERMFontFromAlias(alias) { - return FONT_ALIAS_TO_SYNCTERM_MAP[alias.toLowerCase().replace(/ /g, '_')]; + return FONT_ALIAS_TO_SYNCTERM_MAP[alias.toLowerCase().replace(/ /g, '_')]; } function setSyncTermFontWithAlias(nameOrAlias) { - nameOrAlias = getSyncTERMFontFromAlias(nameOrAlias) || nameOrAlias; - return setSyncTERMFont(nameOrAlias); + nameOrAlias = getSyncTERMFontFromAlias(nameOrAlias) || nameOrAlias; + return setSyncTERMFont(nameOrAlias); } const DEC_CURSOR_STYLE = { - 'blinking block' : 0, - 'default' : 1, - 'steady block' : 2, - 'blinking underline' : 3, - 'steady underline' : 4, - 'blinking bar' : 5, - 'steady bar' : 6, + 'blinking block' : 0, + 'default' : 1, + 'steady block' : 2, + 'blinking underline' : 3, + 'steady underline' : 4, + 'blinking bar' : 5, + 'steady bar' : 6, }; function setCursorStyle(cursorStyle) { - const ps = DEC_CURSOR_STYLE[cursorStyle]; - if(ps) { - return `${ESC_CSI}${ps} q`; - } - return ''; - + const ps = DEC_CURSOR_STYLE[cursorStyle]; + if(ps) { + return `${ESC_CSI}${ps} q`; + } + return ''; + } -// Create methods such as up(), nextLine(),... +// Create methods such as up(), nextLine(),... Object.keys(CONTROL).forEach(function onControlName(name) { - const code = CONTROL[name]; + const code = CONTROL[name]; - exports[name] = function() { - let c = code; - if(arguments.length > 0) { - // arguments are array like -- we want an array - c = Array.prototype.slice.call(arguments).map(Math.round).join(';') + code; - } - return `${ESC_CSI}${c}`; - }; + exports[name] = function() { + let c = code; + if(arguments.length > 0) { + // arguments are array like -- we want an array + c = Array.prototype.slice.call(arguments).map(Math.round).join(';') + code; + } + return `${ESC_CSI}${c}`; + }; }); -// Create various color methods such as white(), yellowBG(), reset(), ... +// Create various color methods such as white(), yellowBG(), reset(), ... Object.keys(SGRValues).forEach( name => { - const code = SGRValues[name]; + const code = SGRValues[name]; - exports[name] = function() { - return `${ESC_CSI}${code}m`; - }; + exports[name] = function() { + return `${ESC_CSI}${code}m`; + }; }); function sgr() { - // - // - Allow an single array or variable number of arguments - // - Each element can be either a integer or string found in SGRValues - // which in turn maps to a integer - // - if(arguments.length <= 0) { - return ''; - } + // + // - Allow an single array or variable number of arguments + // - Each element can be either a integer or string found in SGRValues + // which in turn maps to a integer + // + if(arguments.length <= 0) { + return ''; + } - let result = []; - const args = Array.isArray(arguments[0]) ? arguments[0] : arguments; + let result = []; + const args = Array.isArray(arguments[0]) ? arguments[0] : arguments; - for(let i = 0; i < args.length; ++i) { - const arg = args[i]; - if(_.isString(arg) && arg in SGRValues) { - result.push(SGRValues[arg]); - } else if(_.isNumber(arg)) { - result.push(arg); - } - } + for(let i = 0; i < args.length; ++i) { + const arg = args[i]; + if(_.isString(arg) && arg in SGRValues) { + result.push(SGRValues[arg]); + } else if(_.isNumber(arg)) { + result.push(arg); + } + } - return `${ESC_CSI}${result.join(';')}m`; + return `${ESC_CSI}${result.join(';')}m`; } // -// Converts a Graphic Rendition object used elsewhere -// to a ANSI SGR sequence. +// Converts a Graphic Rendition object used elsewhere +// to a ANSI SGR sequence. // function getSGRFromGraphicRendition(graphicRendition, initialReset) { - let sgrSeq = []; - let styleCount = 0; + let sgrSeq = []; + let styleCount = 0; - [ 'intensity', 'underline', 'blink', 'negative', 'invisible' ].forEach( s => { - if(graphicRendition[s]) { - sgrSeq.push(graphicRendition[s]); - ++styleCount; - } - }); + [ 'intensity', 'underline', 'blink', 'negative', 'invisible' ].forEach( s => { + if(graphicRendition[s]) { + sgrSeq.push(graphicRendition[s]); + ++styleCount; + } + }); - if(graphicRendition.fg) { - sgrSeq.push(graphicRendition.fg); - } + if(graphicRendition.fg) { + sgrSeq.push(graphicRendition.fg); + } - if(graphicRendition.bg) { - sgrSeq.push(graphicRendition.bg); - } + if(graphicRendition.bg) { + sgrSeq.push(graphicRendition.bg); + } - if(0 === styleCount || initialReset) { - sgrSeq.unshift(0); - } + if(0 === styleCount || initialReset) { + sgrSeq.unshift(0); + } - return sgr(sgrSeq); + return sgr(sgrSeq); } /////////////////////////////////////////////////////////////////////////////// -// Shortcuts for common functions +// Shortcuts for common functions /////////////////////////////////////////////////////////////////////////////// function clearScreen() { - return exports.eraseData(2); + return exports.eraseData(2); } function resetScreen() { - return `${exports.reset()}${exports.eraseData(2)}${exports.goHome()}`; + return `${exports.reset()}${exports.eraseData(2)}${exports.goHome()}`; } function normal() { - return sgr( [ 'normal', 'reset' ] ); + return sgr( [ 'normal', 'reset' ] ); } function goHome() { - return exports.goto(); // no params = home = 1,1 + return exports.goto(); // no params = home = 1,1 } // -// Disable auto line wraping @ termWidth +// Disable auto line wraping @ termWidth // -// See: -// http://stjarnhimlen.se/snippets/vt100.txt -// https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt +// See: +// http://stjarnhimlen.se/snippets/vt100.txt +// https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt // -// WARNING: -// * Not honored by all clients -// * If it is honored, ANSI's that rely on this (e.g. do not have \r\n endings -// and use term width -- generally 80 columns -- will display garbled! +// WARNING: +// * Not honored by all clients +// * If it is honored, ANSI's that rely on this (e.g. do not have \r\n endings +// and use term width -- generally 80 columns -- will display garbled! // function disableVT100LineWrapping() { - return `${ESC_CSI}?7l`; + return `${ESC_CSI}?7l`; } function setEmulatedBaudRate(rate) { - const speed = { - unlimited : 0, - off : 0, - 0 : 0, - 300 : 1, - 600 : 2, - 1200 : 3, - 2400 : 4, - 4800 : 5, - 9600 : 6, - 19200 : 7, - 38400 : 8, - 57600 : 9, - 76800 : 10, - 115200 : 11, - }[rate] || 0; - return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed); + const speed = { + unlimited : 0, + off : 0, + 0 : 0, + 300 : 1, + 600 : 2, + 1200 : 3, + 2400 : 4, + 4800 : 5, + 9600 : 6, + 19200 : 7, + 38400 : 8, + 57600 : 9, + 76800 : 10, + 115200 : 11, + }[rate] || 0; + return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed); } + +function vtxHyperlink(client, url, len) { + if(!client.terminalSupports('vtx_hyperlink')) { + return ''; + } + + len = len || url.length; + + url = url.split('').map(c => c.charCodeAt(0)).join(';'); + return `${ESC_CSI}1;${len};1;1;${url}\\`; +} \ No newline at end of file diff --git a/core/archaicnet.js b/core/archaicnet.js new file mode 100644 index 00000000..31ce4dc2 --- /dev/null +++ b/core/archaicnet.js @@ -0,0 +1,135 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const { MenuModule } = require('../core/menu_module.js'); +const { resetScreen } = require('../core/ansi_term.js'); +const { Errors } = require('../core/enig_error.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); +const SSHClient = require('ssh2').Client; + +exports.moduleInfo = { + name : 'ArchaicNET', + desc : 'ArchaicNET Access Module', + author : 'NuSkooler', +}; + +exports.getModule = class ArchaicNETModule extends MenuModule { + constructor(options) { + super(options); + + // establish defaults + this.config = options.menuConfig.config; + this.config.host = this.config.host || 'bbs.archaicbinary.net'; + this.config.sshPort = this.config.sshPort || 2222; + this.config.rloginPort = this.config.rloginPort || 8513; + } + + initSequence() { + let clientTerminated; + const self = this; + + async.series( + [ + function validateConfig(callback) { + const reqConfs = [ 'username', 'password', 'bbsTag' ]; + for(let req of reqConfs) { + if(!_.isString(_.get(self, [ 'config', req ]))) { + return callback(Errors.MissingConfig(`Config requires "${req}"`)); + } + } + return callback(null); + }, + function establishSecureConnection(callback) { + self.client.term.write(resetScreen()); + self.client.term.write('Connecting to ArchaicNET, please wait...\n'); + + const sshClient = new SSHClient(); + + let needRestore = false; + //let pipedStream; + const restorePipe = function() { + if(needRestore && !clientTerminated) { + self.client.restoreDataHandler(); + needRestore = false; + } + }; + + sshClient.on('ready', () => { + // track client termination so we can clean up early + self.client.once('end', () => { + self.client.log.info('Connection ended. Terminating ArchaicNET connection'); + clientTerminated = true; + return sshClient.end(); + }); + + // establish tunnel for rlogin + const fwdPort = self.config.rloginPort + self.client.node; + sshClient.forwardOut('127.0.0.1', fwdPort, self.config.host, self.config.rloginPort, (err, stream) => { + if(err) { + return sshClient.end(); + } + + // + // Send rlogin - [] e.g. [Xibalba]NuSkooler + // + const rlogin = `\x00${self.client.user.username}\x00[${self.config.bbsTag}]${self.client.user.username}\x00${self.client.term.termType}\x00`; + stream.write(rlogin); + + // we need to filter I/O for escape/de-escaping zmodem and the like + self.client.setTemporaryDirectDataHandler(data => { + const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape + stream.write(Buffer.from(tmp, 'binary')); + }); + needRestore = true; + + stream.on('data', data => { + const tmp = data.toString('binary').replace(/\xff/g, '\xff\xff'); // escape + self.client.term.rawWrite(Buffer.from(tmp, 'binary')); + }); + + stream.on('close', () => { + restorePipe(); + return sshClient.end(); + }); + }); + }); + + sshClient.on('error', err => { + return self.client.log.info(`ArchaicNET SSH client error: ${err.message}`); + }); + + sshClient.on('close', hadError => { + if(hadError) { + self.client.warn('Closing ArchaicNET SSH due to error'); + } + restorePipe(); + return callback(null); + }); + + self.client.log.trace( { host : self.config.host, port : self.config.sshPort }, 'Connecting to ArchaicNET'); + sshClient.connect( { + host : self.config.host, + port : self.config.sshPort, + username : self.config.username, + password : self.config.password, + }); + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'ArchaicNET error'); + } + + // if the client is stil here, go to previous + if(!clientTerminated) { + self.prevMenu(); + } + } + ); + } +}; + diff --git a/core/archive_util.js b/core/archive_util.js index 6d2644c8..47291860 100644 --- a/core/archive_util.js +++ b/core/archive_util.js @@ -1,288 +1,362 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const stringFormat = require('./string_format.js'); -const Errors = require('./enig_error.js').Errors; -const resolveMimeType = require('./mime_util.js').resolveMimeType; +// ENiGMA½ +const Config = require('./config.js').get; +const stringFormat = require('./string_format.js'); +const Errors = require('./enig_error.js').Errors; +const resolveMimeType = require('./mime_util.js').resolveMimeType; +const Events = require('./events.js'); -// base/modules -const fs = require('graceful-fs'); -const _ = require('lodash'); -const pty = require('ptyw.js'); +// base/modules +const fs = require('graceful-fs'); +const _ = require('lodash'); +const pty = require('node-pty'); +const paths = require('path'); let archiveUtil; class Archiver { - constructor(config) { - this.compress = config.compress; - this.decompress = config.decompress; - this.list = config.list; - this.extract = config.extract; - } + constructor(config) { + this.compress = config.compress; + this.decompress = config.decompress; + this.list = config.list; + this.extract = config.extract; + } - ok() { - return this.canCompress() && this.canDecompress(); - } + ok() { + return this.canCompress() && this.canDecompress(); + } - can(what) { - if(!_.has(this, [ what, 'cmd' ]) || !_.has(this, [ what, 'args' ])) { - return false; - } + can(what) { + if(!_.has(this, [ what, 'cmd' ]) || !_.has(this, [ what, 'args' ])) { + return false; + } - return _.isString(this[what].cmd) && Array.isArray(this[what].args) && this[what].args.length > 0; - } + return _.isString(this[what].cmd) && Array.isArray(this[what].args) && this[what].args.length > 0; + } - canCompress() { return this.can('compress'); } - canDecompress() { return this.can('decompress'); } - canList() { return this.can('list'); } // :TODO: validate entryMatch - canExtract() { return this.can('extract'); } + canCompress() { return this.can('compress'); } + canDecompress() { return this.can('decompress'); } + canList() { return this.can('list'); } // :TODO: validate entryMatch + canExtract() { return this.can('extract'); } } module.exports = class ArchiveUtil { - - constructor() { - this.archivers = {}; - this.longestSignature = 0; - } - // singleton access - static getInstance() { - if(!archiveUtil) { - archiveUtil = new ArchiveUtil(); - archiveUtil.init(); - } - return archiveUtil; - } + constructor() { + this.archivers = {}; + this.longestSignature = 0; + } - init() { - // - // Load configuration - // - if(_.has(Config, 'archives.archivers')) { - Object.keys(Config.archives.archivers).forEach(archKey => { + // singleton access + static getInstance(noWatch = false) { + if(!archiveUtil) { + archiveUtil = new ArchiveUtil(); + archiveUtil.init(noWatch); + } + return archiveUtil; + } - const archConfig = Config.archives.archivers[archKey]; - const archiver = new Archiver(archConfig); + init(noWatch = false) { + this.reloadConfig(); + if(!noWatch) { + Events.on(Events.getSystemEvents().ConfigChanged, () => { + this.reloadConfig(); + }); + } + } - if(!archiver.ok()) { - // :TODO: Log warning - bad archiver/config - } + reloadConfig() { + const config = Config(); + if(_.has(config, 'archives.archivers')) { + Object.keys(config.archives.archivers).forEach(archKey => { - this.archivers[archKey] = archiver; - }); - } + const archConfig = config.archives.archivers[archKey]; + const archiver = new Archiver(archConfig); - if(_.isObject(Config.fileTypes)) { - Object.keys(Config.fileTypes).forEach(mimeType => { - const fileType = Config.fileTypes[mimeType]; - if(fileType.sig) { - fileType.sig = new Buffer(fileType.sig, 'hex'); - fileType.offset = fileType.offset || 0; + if(!archiver.ok()) { + // :TODO: Log warning - bad archiver/config + } - // :TODO: this is broken: sig is NOT this long, it's sig.length long; offset needs to allow for -negative values as well - const sigLen =fileType.offset + fileType.sig.length; - if(sigLen > this.longestSignature) { - this.longestSignature = sigLen; - } - } - }); - } - } + this.archivers[archKey] = archiver; + }); + } - getArchiver(mimeTypeOrExtension) { - mimeTypeOrExtension = resolveMimeType(mimeTypeOrExtension); - - if(!mimeTypeOrExtension) { // lookup returns false on failure - return; - } + if(_.isObject(config.fileTypes)) { + const updateSig = (ft) => { + ft.sig = Buffer.from(ft.sig, 'hex'); + ft.offset = ft.offset || 0; - const archiveHandler = _.get( Config, [ 'fileTypes', mimeTypeOrExtension, 'archiveHandler'] ); - if(archiveHandler) { - return _.get( Config, [ 'archives', 'archivers', archiveHandler ] ); - } - } - - haveArchiver(archType) { - return this.getArchiver(archType) ? true : false; - } + // :TODO: this is broken: sig is NOT this long, it's sig.length long; offset needs to allow for -negative values as well + const sigLen = ft.offset + ft.sig.length; + if(sigLen > this.longestSignature) { + this.longestSignature = sigLen; + } + }; - detectTypeWithBuf(buf, cb) { - // :TODO: implement me! - } + Object.keys(config.fileTypes).forEach(mimeType => { + const fileType = config.fileTypes[mimeType]; + if(Array.isArray(fileType)) { + fileType.forEach(ft => { + if(ft.sig) { + updateSig(ft); + } + }); + } else if(fileType.sig) { + updateSig(fileType); + } + }); + } + } - detectType(path, cb) { - fs.open(path, 'r', (err, fd) => { - if(err) { - return cb(err); - } - - const buf = new Buffer(this.longestSignature); - fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => { - if(err) { - return cb(err); - } + getArchiver(mimeTypeOrExtension, justExtention) { + const mimeType = resolveMimeType(mimeTypeOrExtension); - const archFormat = _.findKey(Config.fileTypes, fileTypeInfo => { - if(!fileTypeInfo.sig) { - return false; - } + if(!mimeType) { // lookup returns false on failure + return; + } - const lenNeeded = fileTypeInfo.offset + fileTypeInfo.sig.length; + const config = Config(); + let fileType = _.get(config, [ 'fileTypes', mimeType ] ); - if(bytesRead < lenNeeded) { - return false; - } + if(Array.isArray(fileType)) { + if(!justExtention) { + // need extention for lookup; ambiguous as-is :( + return; + } + // further refine by extention + fileType = fileType.find(ft => justExtention === ft.ext); + } - const comp = buf.slice(fileTypeInfo.offset, fileTypeInfo.offset + fileTypeInfo.sig.length); - return (fileTypeInfo.sig.equals(comp)); - }); + if(!_.isObject(fileType)) { + return; + } - return cb(archFormat ? null : Errors.General('Unknown type'), archFormat); - }); - }); - } + if(fileType.archiveHandler) { + return _.get( config, [ 'archives', 'archivers', fileType.archiveHandler ] ); + } + } - spawnHandler(proc, action, cb) { - // pty.js doesn't currently give us a error when things fail, - // so we have this horrible, horrible hack: - let err; - proc.once('data', d => { - if(_.isString(d) && d.startsWith('execvp(3) failed.')) { - err = Errors.ExternalProcess(`${action} failed: ${d.trim()}`); - } - }); - - proc.once('exit', exitCode => { - return cb(exitCode ? Errors.ExternalProcess(`${action} failed with exit code: ${exitCode}`) : err); - }); - } + haveArchiver(archType) { + return this.getArchiver(archType) ? true : false; + } - compressTo(archType, archivePath, files, cb) { - const archiver = this.getArchiver(archType); - - if(!archiver) { - return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); - } + // :TODO: implement me: + /* + detectTypeWithBuf(buf, cb) { + } + */ - const fmtObj = { - archivePath : archivePath, - fileList : files.join(' '), // :TODO: probably need same hack as extractTo here! - }; + detectType(path, cb) { + const closeFile = (fd) => { + fs.close(fd, () => { /* sadface */ }); + }; - const args = archiver.compress.args.map( arg => stringFormat(arg, fmtObj) ); + fs.open(path, 'r', (err, fd) => { + if(err) { + return cb(err); + } - let proc; - try { - proc = pty.spawn(archiver.compress.cmd, args, this.getPtyOpts()); - } catch(e) { - return cb(e); - } + const buf = Buffer.alloc(this.longestSignature); + fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => { + if(err) { + closeFile(fd); + return cb(err); + } - return this.spawnHandler(proc, 'Compression', cb); - } + const archFormat = _.findKey(Config().fileTypes, fileTypeInfo => { + const fileTypeInfos = Array.isArray(fileTypeInfo) ? fileTypeInfo : [ fileTypeInfo ]; + return fileTypeInfos.find(fti => { + if(!fti.sig || !fti.archiveHandler) { + return false; + } - extractTo(archivePath, extractPath, archType, fileList, cb) { - let haveFileList; + const lenNeeded = fti.offset + fti.sig.length; - if(!cb && _.isFunction(fileList)) { - cb = fileList; - fileList = []; - haveFileList = false; - } else { - haveFileList = true; - } + if(bytesRead < lenNeeded) { + return false; + } - const archiver = this.getArchiver(archType); - - if(!archiver) { - return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); - } + const comp = buf.slice(fti.offset, fti.offset + fti.sig.length); + return (fti.sig.equals(comp)); + }); + }); - const fmtObj = { - archivePath : archivePath, - extractPath : extractPath, - }; + closeFile(fd); + return cb(archFormat ? null : Errors.General('Unknown type'), archFormat); + }); + }); + } - const action = haveFileList ? 'extract' : 'decompress'; + spawnHandler(proc, action, cb) { + // pty.js doesn't currently give us a error when things fail, + // so we have this horrible, horrible hack: + let err; + proc.once('data', d => { + if(_.isString(d) && d.startsWith('execvp(3) failed.')) { + err = Errors.ExternalProcess(`${action} failed: ${d.trim()}`); + } + }); - // we need to treat {fileList} special in that it should be broken up to 0:n args - const args = archiver[action].args.map( arg => { - return '{fileList}' === arg ? arg : stringFormat(arg, fmtObj); - }); - - const fileListPos = args.indexOf('{fileList}'); - if(fileListPos > -1) { - // replace {fileList} with 0:n sep file list arguments - args.splice.apply(args, [fileListPos, 1].concat(fileList)); - } + proc.once('exit', exitCode => { + return cb(exitCode ? Errors.ExternalProcess(`${action} failed with exit code: ${exitCode}`) : err); + }); + } - let proc; - try { - proc = pty.spawn(archiver[action].cmd, args, this.getPtyOpts()); - } catch(e) { - return cb(e); - } + compressTo(archType, archivePath, files, workDir, cb) { + const archiver = this.getArchiver(archType, paths.extname(archivePath)); - return this.spawnHandler(proc, (haveFileList ? 'Extraction' : 'Decompression'), cb); - } + if(!archiver) { + return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); + } - listEntries(archivePath, archType, cb) { - const archiver = this.getArchiver(archType); - - if(!archiver) { - return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); - } + if (!cb && _.isFunction(workDir)) { + cb = workDir; + workDir = null; + } - const fmtObj = { - archivePath : archivePath, - }; + const fmtObj = { + archivePath : archivePath, + fileList : files.join(' '), // :TODO: probably need same hack as extractTo here! + }; - const args = archiver.list.args.map( arg => stringFormat(arg, fmtObj) ); - - let proc; - try { - proc = pty.spawn(archiver.list.cmd, args, this.getPtyOpts()); - } catch(e) { - return cb(e); - } + // :TODO: DRY with extractTo() + const args = archiver.compress.args.map( arg => { + return '{fileList}' === arg ? arg : stringFormat(arg, fmtObj); + }); - let output = ''; - proc.on('data', data => { - // :TODO: hack for: execvp(3) failed.: No such file or directory - - output += data; - }); + const fileListPos = args.indexOf('{fileList}'); + if(fileListPos > -1) { + // replace {fileList} with 0:n sep file list arguments + args.splice.apply(args, [fileListPos, 1].concat(files)); + } - proc.once('exit', exitCode => { - if(exitCode) { - return cb(Errors.ExternalProcess(`List failed with exit code: ${exitCode}`)); - } + let proc; + try { + proc = pty.spawn(archiver.compress.cmd, args, this.getPtyOpts(workDir)); + } catch(e) { + return cb(Errors.ExternalProcess( + `Error spawning archiver process "${archiver.compress.cmd}" with args "${args.join(' ')}": ${e.message}`) + ); + } - const entryGroupOrder = archiver.list.entryGroupOrder || { byteSize : 1, fileName : 2 }; + return this.spawnHandler(proc, 'Compression', cb); + } - const entries = []; - const entryMatchRe = new RegExp(archiver.list.entryMatch, 'gm'); - let m; - while((m = entryMatchRe.exec(output))) { - entries.push({ - byteSize : parseInt(m[entryGroupOrder.byteSize]), - fileName : m[entryGroupOrder.fileName].trim(), - }); - } + extractTo(archivePath, extractPath, archType, fileList, cb) { + let haveFileList; - return cb(null, entries); - }); - } - - getPtyOpts() { - return { - // :TODO: cwd - name : 'enigma-archiver', - cols : 80, - rows : 24, - env : process.env, - }; - } + if(!cb && _.isFunction(fileList)) { + cb = fileList; + fileList = []; + haveFileList = false; + } else { + haveFileList = true; + } + + const archiver = this.getArchiver(archType, paths.extname(archivePath)); + + if(!archiver) { + return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); + } + + const fmtObj = { + archivePath : archivePath, + extractPath : extractPath, + }; + + let action = haveFileList ? 'extract' : 'decompress'; + if('extract' === action && !_.isObject(archiver[action])) { + // we're forced to do a full decompress + action = 'decompress'; + haveFileList = false; + } + + // we need to treat {fileList} special in that it should be broken up to 0:n args + const args = archiver[action].args.map( arg => { + return '{fileList}' === arg ? arg : stringFormat(arg, fmtObj); + }); + + const fileListPos = args.indexOf('{fileList}'); + if(fileListPos > -1) { + // replace {fileList} with 0:n sep file list arguments + args.splice.apply(args, [fileListPos, 1].concat(fileList)); + } + + let proc; + try { + proc = pty.spawn(archiver[action].cmd, args, this.getPtyOpts(extractPath)); + } catch(e) { + return cb(Errors.ExternalProcess( + `Error spawning archiver process "${archiver[action].cmd}" with args "${args.join(' ')}": ${e.message}`) + ); + } + + return this.spawnHandler(proc, (haveFileList ? 'Extraction' : 'Decompression'), cb); + } + + listEntries(archivePath, archType, cb) { + const archiver = this.getArchiver(archType, paths.extname(archivePath)); + + if(!archiver) { + return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); + } + + const fmtObj = { + archivePath : archivePath, + }; + + const args = archiver.list.args.map( arg => stringFormat(arg, fmtObj) ); + + let proc; + try { + proc = pty.spawn(archiver.list.cmd, args, this.getPtyOpts()); + } catch(e) { + return cb(Errors.ExternalProcess( + `Error spawning archiver process "${archiver.list.cmd}" with args "${args.join(' ')}": ${e.message}`) + ); + } + + let output = ''; + proc.on('data', data => { + // :TODO: hack for: execvp(3) failed.: No such file or directory + + output += data; + }); + + proc.once('exit', exitCode => { + if(exitCode) { + return cb(Errors.ExternalProcess(`List failed with exit code: ${exitCode}`)); + } + + const entryGroupOrder = archiver.list.entryGroupOrder || { byteSize : 1, fileName : 2 }; + + const entries = []; + const entryMatchRe = new RegExp(archiver.list.entryMatch, 'gm'); + let m; + while((m = entryMatchRe.exec(output))) { + entries.push({ + byteSize : parseInt(m[entryGroupOrder.byteSize]), + fileName : m[entryGroupOrder.fileName].trim(), + }); + } + + return cb(null, entries); + }); + } + + getPtyOpts(cwd) { + const opts = { + name : 'enigma-archiver', + cols : 80, + rows : 24, + env : process.env, + }; + if(cwd) { + opts.cwd = cwd; + } + // :TODO: set cwd to supplied temp path if not sepcific extract + return opts; + } }; diff --git a/core/art.js b/core/art.js index 28657fc0..0ff4835f 100644 --- a/core/art.js +++ b/core/art.js @@ -1,390 +1,401 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const miscUtil = require('./misc_util.js'); -const ansi = require('./ansi_term.js'); -const aep = require('./ansi_escape_parser.js'); -const sauce = require('./sauce.js'); +// ENiGMA½ +const Config = require('./config.js').get; +const miscUtil = require('./misc_util.js'); +const ansi = require('./ansi_term.js'); +const aep = require('./ansi_escape_parser.js'); +const sauce = require('./sauce.js'); +const { Errors } = require('./enig_error.js'); -// deps -const fs = require('graceful-fs'); -const paths = require('path'); -const assert = require('assert'); -const iconv = require('iconv-lite'); -const _ = require('lodash'); -const farmhash = require('farmhash'); +// deps +const fs = require('graceful-fs'); +const paths = require('path'); +const assert = require('assert'); +const iconv = require('iconv-lite'); +const _ = require('lodash'); +const xxhash = require('xxhash'); -exports.getArt = getArt; -exports.getArtFromPath = getArtFromPath; -exports.display = display; -exports.defaultEncodingFromExtension = defaultEncodingFromExtension; +exports.getArt = getArt; +exports.getArtFromPath = getArtFromPath; +exports.display = display; +exports.defaultEncodingFromExtension = defaultEncodingFromExtension; -// :TODO: Return MCI code information -// :TODO: process SAUCE comments -// :TODO: return font + font mapped information from SAUCE +// :TODO: Return MCI code information +// :TODO: process SAUCE comments +// :TODO: return font + font mapped information from SAUCE const SUPPORTED_ART_TYPES = { - // :TODO: the defualt encoding are really useless if they are all the same ... - // perhaps .ansamiga and .ascamiga could be supported as well as overrides via conf - '.ans' : { name : 'ANSI', defaultEncoding : 'cp437', eof : 0x1a }, - '.asc' : { name : 'ASCII', defaultEncoding : 'cp437', eof : 0x1a }, - '.pcb' : { name : 'PCBoard', defaultEncoding : 'cp437', eof : 0x1a }, - '.bbs' : { name : 'Wildcat', defaultEncoding : 'cp437', eof : 0x1a }, + // :TODO: the defualt encoding are really useless if they are all the same ... + // perhaps .ansamiga and .ascamiga could be supported as well as overrides via conf + '.ans' : { name : 'ANSI', defaultEncoding : 'cp437', eof : 0x1a }, + '.asc' : { name : 'ASCII', defaultEncoding : 'cp437', eof : 0x1a }, + '.pcb' : { name : 'PCBoard', defaultEncoding : 'cp437', eof : 0x1a }, + '.bbs' : { name : 'Wildcat', defaultEncoding : 'cp437', eof : 0x1a }, - '.amiga' : { name : 'Amiga', defaultEncoding : 'amiga', eof : 0x1a }, - '.txt' : { name : 'Amiga Text', defaultEncoding : 'cp437', eof : 0x1a }, - // :TODO: extentions for wwiv, renegade, celerity, syncronet, ... - // :TODO: extension for atari - // :TODO: extension for topaz ansi/ascii. + '.amiga' : { name : 'Amiga', defaultEncoding : 'amiga', eof : 0x1a }, + '.txt' : { name : 'Amiga Text', defaultEncoding : 'cp437', eof : 0x1a }, + // :TODO: extentions for wwiv, renegade, celerity, syncronet, ... + // :TODO: extension for atari + // :TODO: extension for topaz ansi/ascii. }; function getFontNameFromSAUCE(sauce) { - if(sauce.Character) { - return sauce.Character.fontName; - } + if(sauce.Character) { + return sauce.Character.fontName; + } } function sliceAtEOF(data, eofMarker) { - let eof = data.length; - const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE) + let eof = data.length; + const stopPos = Math.max(data.length - 256, 0); // 256 = 2 * sizeof(SAUCE) - for(let i = eof - 1; i > stopPos; i--) { - if(eofMarker === data[i]) { - eof = i; - break; - } - } - return data.slice(0, eof); + for(let i = eof - 1; i > stopPos; i--) { + if(eofMarker === data[i]) { + eof = i; + break; + } + } + + if (eof === data.length) { + return data; // nothing to do + } + + // try to prevent goofs + if (eof < 128 && 'SAUCE00' !== data.slice(eof + 1, eof + 8).toString()) { + return data; + } + + return data.slice(0, eof); } function getArtFromPath(path, options, cb) { - fs.readFile(path, (err, data) => { - if(err) { - return cb(err); - } + fs.readFile(path, (err, data) => { + if(err) { + return cb(err); + } - // - // Convert from encodedAs -> j - // - const ext = paths.extname(path).toLowerCase(); - const encoding = options.encodedAs || defaultEncodingFromExtension(ext); + // + // Convert from encodedAs -> j + // + const ext = paths.extname(path).toLowerCase(); + const encoding = options.encodedAs || defaultEncodingFromExtension(ext); - // :TODO: how are BOM's currently handled if present? Are they removed? Do we need to? + // :TODO: how are BOM's currently handled if present? Are they removed? Do we need to? - function sliceOfData() { - if(options.fullFile === true) { - return iconv.decode(data, encoding); - } else { - const eofMarker = defaultEofFromExtension(ext); - return iconv.decode(eofMarker ? sliceAtEOF(data, eofMarker) : data, encoding); - } - } + function sliceOfData() { + if(options.fullFile === true) { + return iconv.decode(data, encoding); + } else { + const eofMarker = defaultEofFromExtension(ext); + return iconv.decode(eofMarker ? sliceAtEOF(data, eofMarker) : data, encoding); + } + } - function getResult(sauce) { - const result = { - data : sliceOfData(), - fromPath : path, - }; + function getResult(sauce) { + const result = { + data : sliceOfData(), + fromPath : path, + }; - if(sauce) { - result.sauce = sauce; - } + if(sauce) { + result.sauce = sauce; + } - return result; - } + return result; + } - if(options.readSauce === true) { - sauce.readSAUCE(data, (err, sauce) => { - if(err) { - return cb(null, getResult()); - } + if(options.readSauce === true) { + sauce.readSAUCE(data, (err, sauce) => { + if(err) { + return cb(null, getResult()); + } - // - // If a encoding was not provided & we have a mapping from - // the information provided by SAUCE, use that. - // - if(!options.encodedAs) { - /* - if(sauce.Character && sauce.Character.fontName) { - var enc = SAUCE_FONT_TO_ENCODING_HINT[sauce.Character.fontName]; - if(enc) { - encoding = enc; - } - } - */ - } - return cb(null, getResult(sauce)); - }); - } else { - return cb(null, getResult()); - } - }); + // + // If a encoding was not provided & we have a mapping from + // the information provided by SAUCE, use that. + // + if(!options.encodedAs) { + /* + if(sauce.Character && sauce.Character.fontName) { + var enc = SAUCE_FONT_TO_ENCODING_HINT[sauce.Character.fontName]; + if(enc) { + encoding = enc; + } + } + */ + } + return cb(null, getResult(sauce)); + }); + } else { + return cb(null, getResult()); + } + }); } function getArt(name, options, cb) { - const ext = paths.extname(name); + const ext = paths.extname(name); - options.basePath = miscUtil.valueWithDefault(options.basePath, Config.paths.art); - options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true); + options.basePath = miscUtil.valueWithDefault(options.basePath, Config().paths.art); + options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true); - // :TODO: make use of asAnsi option and convert from supported -> ansi + // :TODO: make use of asAnsi option and convert from supported -> ansi - if('' !== ext) { - options.types = [ ext.toLowerCase() ]; - } else { - if(_.isUndefined(options.types)) { - options.types = Object.keys(SUPPORTED_ART_TYPES); - } else if(_.isString(options.types)) { - options.types = [ options.types.toLowerCase() ]; - } - } + if('' !== ext) { + options.types = [ ext.toLowerCase() ]; + } else { + if(_.isUndefined(options.types)) { + options.types = Object.keys(SUPPORTED_ART_TYPES); + } else if(_.isString(options.types)) { + options.types = [ options.types.toLowerCase() ]; + } + } - // If an extension is provided, just read the file now - if('' !== ext) { - const directPath = paths.join(options.basePath, name); - return getArtFromPath(directPath, options, cb); - } + // If an extension is provided, just read the file now + if('' !== ext) { + const directPath = paths.isAbsolute(name) ? name : paths.join(options.basePath, name); + return getArtFromPath(directPath, options, cb); + } - fs.readdir(options.basePath, (err, files) => { - if(err) { - return cb(err); - } + fs.readdir(options.basePath, (err, files) => { + if(err) { + return cb(err); + } - const filtered = files.filter( file => { - // - // Ignore anything not allowed in |options.types| - // - const fext = paths.extname(file); - if(!options.types.includes(fext.toLowerCase())) { - return false; - } + const filtered = files.filter( file => { + // + // Ignore anything not allowed in |options.types| + // + const fext = paths.extname(file); + if(!options.types.includes(fext.toLowerCase())) { + return false; + } - const bn = paths.basename(file, fext).toLowerCase(); - if(options.random) { - const suppliedBn = paths.basename(name, fext).toLowerCase(); - - // - // Random selection enabled. We'll allow for - // basename1.ext, basename2.ext, ... - // - if(!bn.startsWith(suppliedBn)) { - return false; - } + const bn = paths.basename(file, fext).toLowerCase(); + if(options.random) { + const suppliedBn = paths.basename(name, fext).toLowerCase(); - const num = bn.substr(suppliedBn.length); - if(num.length > 0) { - if(isNaN(parseInt(num, 10))) { - return false; - } - } - } else { - // - // We've already validated the extension (above). Must be an exact - // match to basename here - // - if(bn != paths.basename(name, fext).toLowerCase()) { - return false; - } - } + // + // Random selection enabled. We'll allow for + // basename1.ext, basename2.ext, ... + // + if(!bn.startsWith(suppliedBn)) { + return false; + } - return true; - }); + const num = bn.substr(suppliedBn.length); + if(num.length > 0) { + if(isNaN(parseInt(num, 10))) { + return false; + } + } + } else { + // + // We've already validated the extension (above). Must be an exact + // match to basename here + // + if(bn != paths.basename(name, fext).toLowerCase()) { + return false; + } + } - if(filtered.length > 0) { - // - // We should now have: - // - Exactly (1) item in |filtered| if non-random - // - 1:n items in |filtered| to choose from if random - // - let readPath; - if(options.random) { - readPath = paths.join(options.basePath, filtered[Math.floor(Math.random() * filtered.length)]); - } else { - assert(1 === filtered.length); - readPath = paths.join(options.basePath, filtered[0]); - } + return true; + }); - return getArtFromPath(readPath, options, cb); - } - - return cb(new Error(`No matching art for supplied criteria: ${name}`)); - }); + if(filtered.length > 0) { + // + // We should now have: + // - Exactly (1) item in |filtered| if non-random + // - 1:n items in |filtered| to choose from if random + // + let readPath; + if(options.random) { + readPath = paths.join(options.basePath, filtered[Math.floor(Math.random() * filtered.length)]); + } else { + assert(1 === filtered.length); + readPath = paths.join(options.basePath, filtered[0]); + } + + return getArtFromPath(readPath, options, cb); + } + + return cb(Errors.DoesNotExist(`No matching art for supplied criteria: ${name}`)); + }); } function defaultEncodingFromExtension(ext) { - const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; - return artType ? artType.defaultEncoding : 'utf8'; + const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; + return artType ? artType.defaultEncoding : 'utf8'; } function defaultEofFromExtension(ext) { - const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; - if(artType) { - return artType.eof; - } + const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; + if(artType) { + return artType.eof; + } } -// :TODO: Implement the following -// * Pause (disabled | termHeight | keyPress ) -// * Cancel (disabled | ) -// * Resume from pause -> continous (disabled | ) +// :TODO: Implement the following +// * Pause (disabled | termHeight | keyPress ) +// * Cancel (disabled | ) +// * Resume from pause -> continous (disabled | ) function display(client, art, options, cb) { - if(_.isFunction(options) && !cb) { - cb = options; - options = {}; - } + if(_.isFunction(options) && !cb) { + cb = options; + options = {}; + } - if(!art || !art.length) { - return cb(new Error('Empty art')); - } + if(!art || !art.length) { + return cb(Errors.Invalid('No art supplied!')); + } - options.mciReplaceChar = options.mciReplaceChar || ' '; - options.disableMciCache = options.disableMciCache || false; + options.mciReplaceChar = options.mciReplaceChar || ' '; + options.disableMciCache = options.disableMciCache || false; - // :TODO: this is going to be broken into two approaches controlled via options: - // 1) Standard - use internal tracking of locations for MCI -- no CPR's/etc. - // 2) CPR driven + // :TODO: this is going to be broken into two approaches controlled via options: + // 1) Standard - use internal tracking of locations for MCI -- no CPR's/etc. + // 2) CPR driven - if(!_.isBoolean(options.iceColors)) { - // try to detect from SAUCE - if(_.has(options, 'sauce.ansiFlags') && (options.sauce.ansiFlags & (1 << 0))) { - options.iceColors = true; - } - } + if(!_.isBoolean(options.iceColors)) { + // try to detect from SAUCE + if(_.has(options, 'sauce.ansiFlags') && (options.sauce.ansiFlags & (1 << 0))) { + options.iceColors = true; + } + } - const ansiParser = new aep.ANSIEscapeParser({ - mciReplaceChar : options.mciReplaceChar, - termHeight : client.term.termHeight, - termWidth : client.term.termWidth, - trailingLF : options.trailingLF, - }); + const ansiParser = new aep.ANSIEscapeParser({ + mciReplaceChar : options.mciReplaceChar, + termHeight : client.term.termHeight, + termWidth : client.term.termWidth, + trailingLF : options.trailingLF, + }); - let parseComplete = false; - let cprListener; - let mciMap; - const mciCprQueue = []; - let artHash; - let mciMapFromCache; + let parseComplete = false; + let cprListener; + let mciMap; + const mciCprQueue = []; + let artHash; + let mciMapFromCache; - function completed() { - if(cprListener) { - client.removeListener('cursor position report', cprListener); - } + function completed() { + if(cprListener) { + client.removeListener('cursor position report', cprListener); + } - if(!options.disableMciCache && !mciMapFromCache) { - // cache our MCI findings... - client.mciCache[artHash] = mciMap; - client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Added MCI map to cache'); - } + if(!options.disableMciCache && !mciMapFromCache) { + // cache our MCI findings... + client.mciCache[artHash] = mciMap; + client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Added MCI map to cache'); + } - ansiParser.removeAllListeners(); // :TODO: Necessary??? + ansiParser.removeAllListeners(); // :TODO: Necessary??? - const extraInfo = { - height : ansiParser.row - 1, - }; + const extraInfo = { + height : ansiParser.row - 1, + }; - return cb(null, mciMap, extraInfo); - } + return cb(null, mciMap, extraInfo); + } - if(!options.disableMciCache) { - artHash = farmhash.hash32(art); + if(!options.disableMciCache) { + artHash = xxhash.hash(Buffer.from(art), 0xCAFEBABE); - // see if we have a mciMap cached for this art - if(client.mciCache) { - mciMap = client.mciCache[artHash]; - } - } + // see if we have a mciMap cached for this art + if(client.mciCache) { + mciMap = client.mciCache[artHash]; + } + } - if(mciMap) { - mciMapFromCache = true; - client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Loaded MCI map from cache'); - } else { - // no cached MCI info - mciMap = {}; + if(mciMap) { + mciMapFromCache = true; + client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Loaded MCI map from cache'); + } else { + // no cached MCI info + mciMap = {}; - cprListener = function(pos) { - if(mciCprQueue.length > 0) { - mciMap[mciCprQueue.shift()].position = pos; + cprListener = function(pos) { + if(mciCprQueue.length > 0) { + mciMap[mciCprQueue.shift()].position = pos; - if(parseComplete && 0 === mciCprQueue.length) { - return completed(); - } - } - }; + if(parseComplete && 0 === mciCprQueue.length) { + return completed(); + } + } + }; - client.on('cursor position report', cprListener); + client.on('cursor position report', cprListener); - let generatedId = 100; + let generatedId = 100; - ansiParser.on('mci', mciInfo => { - // :TODO: ensure generatedId's do not conflict with any existing |id| - const id = _.isNumber(mciInfo.id) ? mciInfo.id : generatedId; - const mapKey = `${mciInfo.mci}${id}`; - const mapEntry = mciMap[mapKey]; + ansiParser.on('mci', mciInfo => { + // :TODO: ensure generatedId's do not conflict with any existing |id| + const id = _.isNumber(mciInfo.id) ? mciInfo.id : generatedId; + const mapKey = `${mciInfo.mci}${id}`; + const mapEntry = mciMap[mapKey]; - if(mapEntry) { - mapEntry.focusSGR = mciInfo.SGR; - mapEntry.focusArgs = mciInfo.args; - } else { - mciMap[mapKey] = { - args : mciInfo.args, - SGR : mciInfo.SGR, - code : mciInfo.mci, - id : id, - }; + if(mapEntry) { + mapEntry.focusSGR = mciInfo.SGR; + mapEntry.focusArgs = mciInfo.args; + } else { + mciMap[mapKey] = { + args : mciInfo.args, + SGR : mciInfo.SGR, + code : mciInfo.mci, + id : id, + }; - if(!mciInfo.id) { - ++generatedId; - } + if(!mciInfo.id) { + ++generatedId; + } - mciCprQueue.push(mapKey); - client.term.rawWrite(ansi.queryPos()); - } + mciCprQueue.push(mapKey); + client.term.rawWrite(ansi.queryPos()); + } - }); - } + }); + } - ansiParser.on('literal', literal => client.term.write(literal, false) ); - ansiParser.on('control', control => client.term.rawWrite(control) ); + ansiParser.on('literal', literal => client.term.write(literal, false) ); + ansiParser.on('control', control => client.term.rawWrite(control) ); - ansiParser.on('complete', () => { - parseComplete = true; + ansiParser.on('complete', () => { + parseComplete = true; - if(0 === mciCprQueue.length) { - return completed(); - } - }); + if(0 === mciCprQueue.length) { + return completed(); + } + }); - let initSeq = ''; - if(options.font) { - initSeq = ansi.setSyncTermFontWithAlias(options.font); - } else if(options.sauce) { - let fontName = getFontNameFromSAUCE(options.sauce); - if(fontName) { - fontName = ansi.getSyncTERMFontFromAlias(fontName); - } + let initSeq = ''; + if(options.font) { + initSeq = ansi.setSyncTermFontWithAlias(options.font); + } else if(options.sauce) { + let fontName = getFontNameFromSAUCE(options.sauce); + if(fontName) { + fontName = ansi.getSyncTERMFontFromAlias(fontName); + } - // - // Set SyncTERM font if we're switching only. Most terminals - // that support this ESC sequence can only show *one* font - // at a time. This applies to detection only (e.g. SAUCE). - // If explicit, we'll set it no matter what (above) - // - if(fontName && client.term.currentSyncFont != fontName) { - client.term.currentSyncFont = fontName; - initSeq = ansi.setSyncTERMFont(fontName); - } - } + // + // Set SyncTERM font if we're switching only. Most terminals + // that support this ESC sequence can only show *one* font + // at a time. This applies to detection only (e.g. SAUCE). + // If explicit, we'll set it no matter what (above) + // + if(fontName && client.term.currentSyncFont != fontName) { + client.term.currentSyncFont = fontName; + initSeq = ansi.setSyncTERMFont(fontName); + } + } - if(options.iceColors) { - initSeq += ansi.blinkToBrightIntensity(); - } + if(options.iceColors) { + initSeq += ansi.blinkToBrightIntensity(); + } - if(initSeq) { - client.term.rawWrite(initSeq); - } + if(initSeq) { + client.term.rawWrite(initSeq); + } - ansiParser.reset(art); - return ansiParser.parse(); + ansiParser.reset(art); + return ansiParser.parse(); } diff --git a/core/asset.js b/core/asset.js index 0731a1b7..b3d62154 100644 --- a/core/asset.js +++ b/core/asset.js @@ -1,127 +1,132 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const StatLog = require('./stat_log.js'); +// ENiGMA½ +const Config = require('./config.js').get; +const StatLog = require('./stat_log.js'); -// deps -const _ = require('lodash'); -const assert = require('assert'); +// deps +const _ = require('lodash'); +const assert = require('assert'); -exports.parseAsset = parseAsset; -exports.getAssetWithShorthand = getAssetWithShorthand; -exports.getArtAsset = getArtAsset; -exports.getModuleAsset = getModuleAsset; -exports.resolveConfigAsset = resolveConfigAsset; -exports.resolveSystemStatAsset = resolveSystemStatAsset; -exports.getViewPropertyAsset = getViewPropertyAsset; +exports.parseAsset = parseAsset; +exports.getAssetWithShorthand = getAssetWithShorthand; +exports.getArtAsset = getArtAsset; +exports.getModuleAsset = getModuleAsset; +exports.resolveConfigAsset = resolveConfigAsset; +exports.resolveSystemStatAsset = resolveSystemStatAsset; +exports.getViewPropertyAsset = getViewPropertyAsset; const ALL_ASSETS = [ - 'art', - 'menu', - 'method', - 'module', - 'systemMethod', - 'systemModule', - 'prompt', - 'config', - 'sysStat', + 'art', + 'menu', + 'method', + 'userModule', + 'systemMethod', + 'systemModule', + 'prompt', + 'config', + 'sysStat', ]; -const ASSET_RE = new RegExp('\\@(' + ALL_ASSETS.join('|') + ')\\:([\\w\\d\\.]*)(?:\\/([\\w\\d\\_]+))*'); +const ASSET_RE = new RegExp( + '^@(' + ALL_ASSETS.join('|') + ')' + + /:(?:([^:]+):)?([A-Za-z0-9_\-.]+)$/.source +); -function parseAsset(s) { - const m = ASSET_RE.exec(s); +function parseAsset(s) { + const m = ASSET_RE.exec(s); + if(m) { + const result = { type : m[1] }; - if(m) { - let result = { type : m[1] }; + if(m[3]) { + result.asset = m[3]; + if(m[2]) { + result.location = m[2]; + } + } else { + result.asset = m[2]; + } - if(m[3]) { - result.location = m[2]; - result.asset = m[3]; - } else { - result.asset = m[2]; - } - - return result; - } + return result; + } } function getAssetWithShorthand(spec, defaultType) { - if(!_.isString(spec)) { - return null; - } + if(!_.isString(spec)) { + return null; + } - if('@' === spec[0]) { - const asset = parseAsset(spec); - assert(_.isString(asset.type)); + if('@' === spec[0]) { + const asset = parseAsset(spec); + assert(_.isString(asset.type)); - return asset; - } else { - return { - type : defaultType, - asset : spec, - }; - } + return asset; + } + + return { + type : defaultType, + asset : spec, + }; } function getArtAsset(spec) { - const asset = getAssetWithShorthand(spec, 'art'); - - if(!asset) { - return null; - } + const asset = getAssetWithShorthand(spec, 'art'); - assert( ['art', 'method' ].indexOf(asset.type) > -1); - return asset; + if(!asset) { + return null; + } + + assert( ['art', 'method' ].indexOf(asset.type) > -1); + return asset; } function getModuleAsset(spec) { - const asset = getAssetWithShorthand(spec, 'module'); - - if(!asset) { - return null; - } + const asset = getAssetWithShorthand(spec, 'systemModule'); - assert( ['module', 'systemModule' ].indexOf(asset.type) > -1); - return asset; + if(!asset) { + return null; + } + + assert( ['userModule', 'systemModule' ].includes(asset.type) ); + + return asset; } function resolveConfigAsset(spec) { - const asset = parseAsset(spec); - if(asset) { - assert('config' === asset.type); + const asset = parseAsset(spec); + if(asset) { + assert('config' === asset.type); - const path = asset.asset.split('.'); - let conf = Config; - for(let i = 0; i < path.length; ++i) { - if(_.isUndefined(conf[path[i]])) { - return spec; - } - conf = conf[path[i]]; - } - return conf; - } else { - return spec; - } + const path = asset.asset.split('.'); + let conf = Config(); + for(let i = 0; i < path.length; ++i) { + if(_.isUndefined(conf[path[i]])) { + return spec; + } + conf = conf[path[i]]; + } + return conf; + } else { + return spec; + } } function resolveSystemStatAsset(spec) { - const asset = parseAsset(spec); - if(!asset) { - return spec; - } + const asset = parseAsset(spec); + if(!asset) { + return spec; + } - assert('sysStat' === asset.type); + assert('sysStat' === asset.type); - return StatLog.getSystemStat(asset.asset) || spec; + return StatLog.getSystemStat(asset.asset) || spec; } function getViewPropertyAsset(src) { - if(!_.isString(src) || '@' !== src.charAt(0)) { - return null; - } + if(!_.isString(src) || '@' !== src.charAt(0)) { + return null; + } - return parseAsset(src); + return parseAsset(src); } diff --git a/core/autosig_edit.js b/core/autosig_edit.js new file mode 100644 index 00000000..c9995280 --- /dev/null +++ b/core/autosig_edit.js @@ -0,0 +1,76 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'User Auto-Sig Editor', + desc : 'Module for editing auto-sigs', + author : 'NuSkooler', +}; + +const FormIds = { + edit : 0, +}; + +const MciViewIds = { + editor : 1, + save : 2, +}; + +exports.getModule = class UserAutoSigEditorModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.menuMethods = { + saveChanges : (formData, extraArgs, cb) => { + return this.saveChanges(cb); + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + return this.prepViewController('edit', FormIds.edit, mciData.menu, callback); + }, + (callback) => { + const requiredCodes = [ MciViewIds.editor, MciViewIds.save ]; + return this.validateMCIByViewIds('edit', requiredCodes, callback); + }, + (callback) => { + const sig = this.client.user.getProperty(UserProps.AutoSignature) || ''; + this.setViewText('edit', MciViewIds.editor, sig); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + saveChanges(cb) { + const sig = this.getView('edit', MciViewIds.editor).getData().trim(); + this.client.user.persistProperty(UserProps.AutoSignature, sig, err => { + if(err) { + this.client.log.error( { error : err.message }, 'Could not save auto-sig'); + } + return this.prevMenu(cb); + }); + } +}; diff --git a/core/bbs.js b/core/bbs.js index c43d63a3..e29b8d27 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -5,264 +5,328 @@ //var SegfaultHandler = require('segfault-handler'); //SegfaultHandler.registerHandler('enigma-bbs-segfault.log'); -// ENiGMA½ -const conf = require('./config.js'); -const logger = require('./logger.js'); -const database = require('./database.js'); -const resolvePath = require('./misc_util.js').resolvePath; +// ENiGMA½ +const conf = require('./config.js'); +const logger = require('./logger.js'); +const database = require('./database.js'); +const resolvePath = require('./misc_util.js').resolvePath; +const UserProps = require('./user_property.js'); +const SysProps = require('./system_property.js'); +const SysLogKeys = require('./system_log.js'); -// deps -const async = require('async'); -const util = require('util'); -const _ = require('lodash'); -const mkdirs = require('fs-extra').mkdirs; -const fs = require('graceful-fs'); -const paths = require('path'); +// deps +const async = require('async'); +const util = require('util'); +const _ = require('lodash'); +const mkdirs = require('fs-extra').mkdirs; +const fs = require('graceful-fs'); +const paths = require('path'); +const moment = require('moment'); -// our main entry point -exports.main = main; +// our main entry point +exports.main = main; -// object with various services we want to de-init/shutdown cleanly if possible +// object with various services we want to de-init/shutdown cleanly if possible const initServices = {}; -const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby'; +// only include bbs.js once @ startup; this should be fine +const COPYRIGHT = fs.readFileSync(paths.join(__dirname, '../LICENSE.TXT'), 'utf8').split(/\r?\n/g)[0]; + +const FULL_COPYRIGHT = `ENiGMA½ ${COPYRIGHT}`; const HELP = -`${ENIGMA_COPYRIGHT} +`${FULL_COPYRIGHT} usage: main.js +eg : main.js --config /enigma_install_path/config/ valid args: --version : display version --help : displays this help - --config PATH : override default config.hjson path + --config PATH : override default config path `; function printHelpAndExit() { - console.info(HELP); - process.exit(); + console.info(HELP); + process.exit(); +} + +function printVersionAndExit() { + console.info(require('../package.json').version); } function main() { - async.waterfall( - [ - function processArgs(callback) { - const argv = require('minimist')(process.argv.slice(2)); + async.waterfall( + [ + function processArgs(callback) { + const argv = require('minimist')(process.argv.slice(2)); - if(argv.help) { - printHelpAndExit(); - } + if(argv.help) { + return printHelpAndExit(); + } - const configOverridePath = argv.config; + if(argv.version) { + return printVersionAndExit(); + } - return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath)); - }, - function initConfig(configPath, configPathSupplied, callback) { - conf.init(resolvePath(configPath), function configInit(err) { + const configOverridePath = argv.config; - // - // If the user supplied a path and we can't read/parse it - // then it's a fatal error - // - if(err) { - if('ENOENT' === err.code) { - if(configPathSupplied) { - console.error('Configuration file does not exist: ' + configPath); - } else { - configPathSupplied = null; // make non-fatal; we'll go with defaults - } - } else { - console.error(err.toString()); - } - } - callback(err); - }); - }, - function initSystem(callback) { - initialize(function init(err) { - if(err) { - console.error('Error initializing: ' + util.inspect(err)); - } - return callback(err); - }); - } - ], - function complete(err) { - // note this is escaped: - fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => { - console.info(ENIGMA_COPYRIGHT); - if(!err) { - console.info(banner); - } - console.info('System started!'); - }); + return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath)); + }, + function initConfig(configPath, configPathSupplied, callback) { + const configFile = configPath + 'config.hjson'; + conf.init(resolvePath(configFile), function configInit(err) { - if(err) { - console.error('Error initializing: ' + util.inspect(err)); - } - } - ); + // + // If the user supplied a path and we can't read/parse it + // then it's a fatal error + // + if(err) { + if('ENOENT' === err.code) { + if(configPathSupplied) { + console.error('Configuration file does not exist: ' + configFile); + } else { + configPathSupplied = null; // make non-fatal; we'll go with defaults + } + } else { + console.error(err.message); + } + } + return callback(err); + }); + }, + function initSystem(callback) { + initialize(function init(err) { + if(err) { + console.error('Error initializing: ' + util.inspect(err)); + } + return callback(err); + }); + } + ], + function complete(err) { + if(!err) { + // note this is escaped: + fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => { + console.info(FULL_COPYRIGHT); + if(!err) { + console.info(banner); + } + console.info('System started!'); + }); + } + + if(err) { + console.error('Error initializing: ' + util.inspect(err)); + } + } + ); } function shutdownSystem() { - const msg = 'Process interrupted. Shutting down...'; - console.info(msg); - logger.log.info(msg); + const msg = 'Process interrupted. Shutting down...'; + console.info(msg); + logger.log.info(msg); - async.series( - [ - function closeConnections(callback) { - const ClientConns = require('./client_connections.js'); - const activeConnections = ClientConns.getActiveConnections(); - let i = activeConnections.length; - while(i--) { - activeConnections[i].term.write('\n\nServer is shutting down NOW! Disconnecting...\n\n'); - ClientConns.removeClient(activeConnections[i]); - } - callback(null); - }, - function stopListeningServers(callback) { - return require('./listening_server.js').shutdown( () => { - return callback(null); // ignore err - }); - }, - function stopEventScheduler(callback) { - if(initServices.eventScheduler) { - return initServices.eventScheduler.shutdown( () => { - return callback(null); // ignore err - }); - } else { - return callback(null); - } - }, - function stopFileAreaWeb(callback) { - require('./file_area_web.js').startup( () => { - return callback(null); // ignore err - }); - }, - function stopMsgNetwork(callback) { - require('./msg_network.js').shutdown(callback); - } - ], - () => { - console.info('Goodbye!'); - return process.exit(); - } - ); + async.series( + [ + function closeConnections(callback) { + const ClientConns = require('./client_connections.js'); + const activeConnections = ClientConns.getActiveConnections(); + let i = activeConnections.length; + while(i--) { + const activeTerm = activeConnections[i].term; + if(activeTerm) { + activeTerm.write('\n\nServer is shutting down NOW! Disconnecting...\n\n'); + } + ClientConns.removeClient(activeConnections[i]); + } + callback(null); + }, + function stopListeningServers(callback) { + return require('./listening_server.js').shutdown( () => { + return callback(null); // ignore err + }); + }, + function stopEventScheduler(callback) { + if(initServices.eventScheduler) { + return initServices.eventScheduler.shutdown( () => { + return callback(null); // ignore err + }); + } else { + return callback(null); + } + }, + function stopFileAreaWeb(callback) { + require('./file_area_web.js').startup( () => { + return callback(null); // ignore err + }); + }, + function stopMsgNetwork(callback) { + require('./msg_network.js').shutdown(callback); + } + ], + () => { + console.info('Goodbye!'); + return process.exit(); + } + ); } function initialize(cb) { - async.series( - [ - function createMissingDirectories(callback) { - async.each(Object.keys(conf.config.paths), function entry(pathKey, next) { - mkdirs(conf.config.paths[pathKey], function dirCreated(err) { - if(err) { - console.error('Could not create path: ' + conf.config.paths[pathKey] + ': ' + err.toString()); - } - return next(err); - }); - }, function dirCreationComplete(err) { - return callback(err); - }); - }, - function basicInit(callback) { - logger.init(); - logger.log.info( - { version : require('../package.json').version }, - '**** ENiGMA½ Bulletin Board System Starting Up! ****'); + async.series( + [ + function createMissingDirectories(callback) { + async.each(Object.keys(conf.config.paths), function entry(pathKey, next) { + mkdirs(conf.config.paths[pathKey], function dirCreated(err) { + if(err) { + console.error('Could not create path: ' + conf.config.paths[pathKey] + ': ' + err.toString()); + } + return next(err); + }); + }, function dirCreationComplete(err) { + return callback(err); + }); + }, + function basicInit(callback) { + logger.init(); + logger.log.info( + { + version : require('../package.json').version, + nodeVersion : process.version, + }, + '**** ENiGMA½ Bulletin Board System Starting Up! ****' + ); - process.on('SIGINT', shutdownSystem); + process.on('SIGINT', shutdownSystem); - require('later').date.localTime(); // use local times for later.js/scheduling + require('later').date.localTime(); // use local times for later.js/scheduling - return callback(null); - }, - function initDatabases(callback) { - return database.initializeDatabases(callback); - }, - function initMimeTypes(callback) { - return require('./mime_util.js').startup(callback); - }, - function initStatLog(callback) { - return require('./stat_log.js').init(callback); - }, - function initThemes(callback) { - // Have to pull in here so it's after Config init - require('./theme.js').initAvailableThemes(function onThemesInit(err, themeCount) { - logger.log.info({ themeCount : themeCount }, 'Themes initialized'); - return callback(err); - }); - }, - function loadSysOpInformation(callback) { - // - // Copy over some +op information from the user DB -> system propertys. - // * Makes this accessible for MCI codes, easy non-blocking access, etc. - // * We do this every time as the op is free to change this information just - // like any other user - // - const User = require('./user.js'); + return callback(null); + }, + function initDatabases(callback) { + return database.initializeDatabases(callback); + }, + function initMimeTypes(callback) { + return require('./mime_util.js').startup(callback); + }, + function initStatLog(callback) { + return require('./stat_log.js').init(callback); + }, + function initConfigs(callback) { + return require('./config_util.js').init(callback); + }, + function initThemes(callback) { + // Have to pull in here so it's after Config init + require('./theme.js').initAvailableThemes( (err, themeCount) => { + logger.log.info({ themeCount }, 'Themes initialized'); + return callback(err); + }); + }, + function loadSysOpInformation(callback) { + // + // Copy over some +op information from the user DB -> system properties. + // * Makes this accessible for MCI codes, easy non-blocking access, etc. + // * We do this every time as the op is free to change this information just + // like any other user + // + const User = require('./user.js'); - async.waterfall( - [ - function getOpUserName(next) { - return User.getUserName(1, next); - }, - function getOpProps(opUserName, next) { - const propLoadOpts = { - names : [ 'real_name', 'sex', 'email_address', 'location', 'affiliation' ], - }; - User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => { - return next(err, opUserName, opProps); - }); - } - ], - (err, opUserName, opProps) => { - const StatLog = require('./stat_log.js'); + const propLoadOpts = { + names : [ + UserProps.RealName, UserProps.Sex, UserProps.EmailAddress, + UserProps.Location, UserProps.Affiliations, + ], + }; - if(err) { - [ 'username', 'real_name', 'sex', 'email_address', 'location', 'affiliation' ].forEach(v => { - StatLog.setNonPeristentSystemStat(`sysop_${v}`, 'N/A'); - }); - } else { - opProps.username = opUserName; + async.waterfall( + [ + function getOpUserName(next) { + return User.getUserName(1, next); + }, + function getOpProps(opUserName, next) { + User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => { + return next(err, opUserName, opProps); + }); + }, + ], + (err, opUserName, opProps) => { + const StatLog = require('./stat_log.js'); - _.each(opProps, (v, k) => { - StatLog.setNonPeristentSystemStat(`sysop_${k}`, v); - }); - } + if(err) { + propLoadOpts.names.concat('username').forEach(v => { + StatLog.setNonPersistentSystemStat(`sysop_${v}`, 'N/A'); + }); + } else { + opProps.username = opUserName; - return callback(null); - } - ); - }, - function initMCI(callback) { - return require('./predefined_mci.js').init(callback); - }, - function readyMessageNetworkSupport(callback) { - return require('./msg_network.js').startup(callback); - }, - function readyEvents(callback) { - return require('./events.js').startup(callback); - }, - function listenConnections(callback) { - return require('./listening_server.js').startup(callback); - }, - function readyFileAreaWeb(callback) { - return require('./file_area_web.js').startup(callback); - }, - function readyPasswordReset(callback) { - const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset; - return WebPasswordReset.startup(callback); - }, - function readyEventScheduler(callback) { - const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule; - EventSchedulerModule.loadAndStart( (err, modInst) => { - initServices.eventScheduler = modInst; - return callback(err); - }); - } - ], - function onComplete(err) { - return cb(err); - } - ); + _.each(opProps, (v, k) => { + StatLog.setNonPersistentSystemStat(`sysop_${k}`, v); + }); + } + + return callback(null); + } + ); + }, + function initCallsToday(callback) { + const StatLog = require('./stat_log.js'); + const filter = { + logName : SysLogKeys.UserLoginHistory, + resultType : 'count', + date : moment(), + }; + + StatLog.findSystemLogEntries(filter, (err, callsToday) => { + if(!err) { + StatLog.setNonPersistentSystemStat(SysProps.LoginsToday, callsToday); + } + return callback(null); + }); + }, + function initMessageStats(callback) { + return require('./message_area.js').startup(callback); + }, + function initMCI(callback) { + return require('./predefined_mci.js').init(callback); + }, + function readyMessageNetworkSupport(callback) { + return require('./msg_network.js').startup(callback); + }, + function readyEvents(callback) { + return require('./events.js').startup(callback); + }, + function genericModulesInit(callback) { + return require('./module_util.js').initializeModules(callback); + }, + function listenConnections(callback) { + return require('./listening_server.js').startup(callback); + }, + function readyFileBaseArea(callback) { + return require('./file_base_area.js').startup(callback); + }, + function readyFileAreaWeb(callback) { + return require('./file_area_web.js').startup(callback); + }, + function readyPasswordReset(callback) { + 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) => { + initServices.eventScheduler = modInst; + return callback(err); + }); + }, + function listenUserEventsForStatLog(callback) { + return require('./stat_log.js').initUserEvents(callback); + } + ], + function onComplete(err) { + return cb(err); + } + ); } diff --git a/core/bbs_link.js b/core/bbs_link.js new file mode 100644 index 00000000..127f85db --- /dev/null +++ b/core/bbs_link.js @@ -0,0 +1,223 @@ +/* jslint node: true */ +'use strict'; + +const { MenuModule } = require('./menu_module.js'); +const { resetScreen } = require('./ansi_term.js'); +const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); + +// deps +const async = require('async'); +const http = require('http'); +const net = require('net'); +const crypto = require('crypto'); + +const packageJson = require('../package.json'); + +/* + Expected configuration block: + + { + module: bbs_link + ... + config: { + sysCode: XXXXX + authCode: XXXXX + schemeCode: XXXX + door: lord + + // default hoss: games.bbslink.net + host: games.bbslink.net + + // defualt port: 23 + port: 23 + } + } +*/ + +// :TODO: BUG: When a client disconnects, it's not handled very well -- the log is spammed with tons of errors +// :TODO: ENH: Support nodeMax and tooManyArt + +exports.moduleInfo = { + name : 'BBSLink', + desc : 'BBSLink Access Module', + author : 'NuSkooler', +}; + +exports.getModule = class BBSLinkModule extends MenuModule { + constructor(options) { + super(options); + + this.config = options.menuConfig.config; + this.config.host = this.config.host || 'games.bbslink.net'; + this.config.port = this.config.port || 23; + } + + initSequence() { + let token; + let randomKey; + let clientTerminated; + const self = this; + + async.series( + [ + function validateConfig(callback) { + return self.validateConfigFields( + { + host : 'string', + sysCode : 'string', + authCode : 'string', + schemeCode : 'string', + door : 'string', + port : 'number', + }, + callback + ); + }, + function acquireToken(callback) { + // + // Acquire an authentication token + // + crypto.randomBytes(16, function rand(ex, buf) { + if(ex) { + callback(ex); + } else { + randomKey = buf.toString('base64').substr(0, 6); + self.simpleHttpRequest('/token.php?key=' + randomKey, null, function resp(err, body) { + if(err) { + callback(err); + } else { + token = body.trim(); + self.client.log.trace( { token : token }, 'BBSLink token'); + callback(null); + } + }); + } + }); + }, + function authenticateToken(callback) { + // + // Authenticate the token we acquired previously + // + const headers = { + 'X-User' : self.client.user.userId.toString(), + 'X-System' : self.config.sysCode, + 'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'), + 'X-Code' : crypto.createHash('md5').update(self.config.schemeCode + token).digest('hex'), + 'X-Rows' : self.client.term.termHeight.toString(), + 'X-Key' : randomKey, + 'X-Door' : self.config.door, + 'X-Token' : token, + 'X-Type' : 'enigma-bbs', + 'X-Version' : packageJson.version, + }; + + self.simpleHttpRequest('/auth.php?key=' + randomKey, headers, function resp(err, body) { + var status = body.trim(); + + if('complete' === status) { + return callback(null); + } + return callback(Errors.AccessDenied(`Bad authentication status: ${status}`)); + }); + }, + function createTelnetBridge(callback) { + // + // Authentication with BBSLink successful. Now, we need to create a telnet + // bridge from us to them + // + const connectOpts = { + port : self.config.port, + host : self.config.host, + }; + + let dataOut; + + self.client.term.write(resetScreen()); + 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'); + + dataOut = (data) => { + return bridgeConnection.write(data); + }; + + self.client.term.output.on('data', dataOut); + + self.client.once('end', function clientEnd() { + self.client.log.info('Connection ended. Terminating BBSLink connection'); + clientTerminated = true; + bridgeConnection.end(); + }); + }); + + const restore = () => { + if(dataOut && self.client.term.output) { + self.client.term.output.removeListener('data', dataOut); + dataOut = null; + } + + trackDoorRunEnd(doorTracking); + }; + + bridgeConnection.on('data', function incomingData(data) { + // pass along + // :TODO: just pipe this as well + self.client.term.rawWrite(data); + }); + + bridgeConnection.on('end', function connectionEnd() { + 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); + restore(); + return callback(err); + }); + } + ], + function complete(err) { + if(err) { + self.client.log.warn( { error : err.toString() }, 'BBSLink connection error'); + } + + if(!clientTerminated) { + self.prevMenu(); + } + } + ); + } + + simpleHttpRequest(path, headers, cb) { + const getOpts = { + host : this.config.host, + path : path, + headers : headers, + }; + + const req = http.get(getOpts, function response(resp) { + let data = ''; + + resp.on('data', function chunk(c) { + data += c; + }); + + resp.on('end', function respEnd() { + cb(null, data); + req.end(); + }); + }); + + req.on('error', function reqErr(err) { + cb(err); + }); + } +}; diff --git a/core/bbs_list.js b/core/bbs_list.js new file mode 100644 index 00000000..82943a80 --- /dev/null +++ b/core/bbs_list.js @@ -0,0 +1,439 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; + +const { + getModDatabasePath, + getTransactionDatabase +} = require('./database.js'); + +const ViewController = require('./view_controller.js').ViewController; +const ansi = require('./ansi_term.js'); +const theme = require('./theme.js'); +const User = require('./user.js'); +const stringFormat = require('./string_format.js'); + +// deps +const async = require('async'); +const sqlite3 = require('sqlite3'); +const _ = require('lodash'); + +// :TODO: add notes field + +const moduleInfo = exports.moduleInfo = { + name : 'BBS List', + desc : 'List of other BBSes', + author : 'Andrew Pamment', + packageName : 'com.magickabbs.enigma.bbslist' +}; + +const MciViewIds = { + view : { + BBSList : 1, + SelectedBBSName : 2, + SelectedBBSSysOp : 3, + SelectedBBSTelnet : 4, + SelectedBBSWww : 5, + SelectedBBSLoc : 6, + SelectedBBSSoftware : 7, + SelectedBBSNotes : 8, + SelectedBBSSubmitter : 9, + }, + add : { + BBSName : 1, + Sysop : 2, + Telnet : 3, + Www : 4, + Location : 5, + Software : 6, + Notes : 7, + Error : 8, + } +}; + +const FormIds = { + View : 0, + Add : 1, +}; + +const SELECTED_MCI_NAME_TO_ENTRY = { + SelectedBBSName : 'bbsName', + SelectedBBSSysOp : 'sysOp', + SelectedBBSTelnet : 'telnet', + SelectedBBSWww : 'www', + SelectedBBSLoc : 'location', + SelectedBBSSoftware : 'software', + SelectedBBSSubmitter : 'submitter', + SelectedBBSSubmitterId : 'submitterUserId', + SelectedBBSNotes : 'notes', +}; + +exports.getModule = class BBSListModule extends MenuModule { + constructor(options) { + super(options); + + const self = this; + this.menuMethods = { + // + // Validators + // + viewValidationListener : function(err, cb) { + const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error); + if(errMsgView) { + if(err) { + errMsgView.setText(err.message); + } else { + errMsgView.clearText(); + } + } + + return cb(null); + }, + + // + // Key & submit handlers + // + addBBS : function(formData, extraArgs, cb) { + self.displayAddScreen(cb); + }, + deleteBBS : function(formData, extraArgs, cb) { + if(!_.isNumber(self.selectedBBS) || 0 === self.entries.length) { + return cb(null); + } + + const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList); + + if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) { + // must be owner or +op + return cb(null); + } + + const entry = self.entries[self.selectedBBS]; + if(!entry) { + return cb(null); + } + + self.database.run( + `DELETE FROM bbs_list + WHERE id=?;`, + [ entry.id ], + err => { + if (err) { + self.client.log.error( { err : err }, 'Error deleting from BBS list'); + } else { + self.entries.splice(self.selectedBBS, 1); + + self.setEntries(entriesView); + + if(self.entries.length > 0) { + entriesView.focusPrevious(); + } + + self.viewControllers.view.redrawAll(); + } + + return cb(null); + } + ); + }, + submitBBS : function(formData, extraArgs, cb) { + + let ok = true; + [ 'BBSName', 'Sysop', 'Telnet' ].forEach( mciName => { + if('' === self.viewControllers.add.getView(MciViewIds.add[mciName]).getData()) { + ok = false; + } + }); + if(!ok) { + // validators should prevent this! + return cb(null); + } + + self.database.run( + `INSERT INTO bbs_list (bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes) + VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, + [ + formData.value.name, formData.value.sysop, formData.value.telnet, formData.value.www, + formData.value.location, formData.value.software, self.client.user.userId, formData.value.notes + ], + err => { + if(err) { + self.client.log.error( { err : err }, 'Error adding to BBS list'); + } + + self.clearAddForm(); + self.displayBBSList(true, cb); + } + ); + }, + cancelSubmit : function(formData, extraArgs, cb) { + self.clearAddForm(); + self.displayBBSList(true, cb); + } + }; + } + + initSequence() { + const self = this; + async.series( + [ + function beforeDisplayArt(callback) { + self.beforeArt(callback); + }, + function display(callback) { + self.displayBBSList(false, callback); + } + ], + err => { + if(err) { + // :TODO: Handle me -- initSequence() should really take a completion callback + } + self.finishedLoading(); + } + ); + } + + drawSelectedEntry(entry) { + if(!entry) { + Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { + this.setViewText('view', MciViewIds.view[mciName], ''); + }); + } else { + const youSubmittedFormat = this.menuConfig.youSubmittedFormat || '{submitter} (You!)'; + + Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { + const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]]; + if(MciViewIds.view[mciName]) { + + if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == this.client.user.userId) { + this.setViewText('view',MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry)); + } else { + this.setViewText('view',MciViewIds.view[mciName], t); + } + } + }); + } + } + + setEntries(entriesView) { + return entriesView.setItems(this.entries); + } + + displayBBSList(clearScreen, cb) { + const self = this; + + async.waterfall( + [ + function clearAndDisplayArt(callback) { + if(self.viewControllers.add) { + self.viewControllers.add.setFocus(false); + } + if (clearScreen) { + self.client.term.rawWrite(ansi.resetScreen()); + } + theme.displayThemedAsset( + self.menuConfig.config.art.entries, + self.client, + { font : self.menuConfig.font, trailingLF : false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function initOrRedrawViewController(artData, callback) { + if(_.isUndefined(self.viewControllers.add)) { + const vc = self.addViewController( + 'view', + new ViewController( { client : self.client, formId : FormIds.View } ) + ); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds.View, + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } else { + self.viewControllers.view.setFocus(true); + self.viewControllers.view.getView(MciViewIds.view.BBSList).redraw(); + return callback(null); + } + }, + function fetchEntries(callback) { + const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList); + self.entries = []; + + self.database.each( + `SELECT id, bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes + FROM bbs_list;`, + (err, row) => { + if (!err) { + self.entries.push({ + text : row.bbs_name, // standard field + id : row.id, + bbsName : row.bbs_name, + sysOp : row.sysop, + telnet : row.telnet, + www : row.www, + location : row.location, + software : row.software, + submitterUserId : row.submitter_user_id, + notes : row.notes, + }); + } + }, + err => { + return callback(err, entriesView); + } + ); + }, + function getUserNames(entriesView, callback) { + async.each(self.entries, (entry, next) => { + User.getUserName(entry.submitterUserId, (err, username) => { + if(username) { + entry.submitter = username; + } else { + entry.submitter = 'N/A'; + } + return next(); + }); + }, () => { + return callback(null, entriesView); + }); + }, + function populateEntries(entriesView, callback) { + self.setEntries(entriesView); + + entriesView.on('index update', idx => { + const entry = self.entries[idx]; + + self.drawSelectedEntry(entry); + + if(!entry) { + self.selectedBBS = -1; + } else { + self.selectedBBS = idx; + } + }); + + if (self.selectedBBS >= 0) { + entriesView.setFocusItemIndex(self.selectedBBS); + self.drawSelectedEntry(self.entries[self.selectedBBS]); + } else if (self.entries.length > 0) { + self.selectedBBS = 0; + entriesView.setFocusItemIndex(0); + self.drawSelectedEntry(self.entries[0]); + } + + entriesView.redraw(); + + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayAddScreen(cb) { + const self = this; + + async.waterfall( + [ + function clearAndDisplayArt(callback) { + self.viewControllers.view.setFocus(false); + self.client.term.rawWrite(ansi.resetScreen()); + + theme.displayThemedAsset( + self.menuConfig.config.art.add, + self.client, + { font : self.menuConfig.font }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function initOrRedrawViewController(artData, callback) { + if(_.isUndefined(self.viewControllers.add)) { + const vc = self.addViewController( + 'add', + new ViewController( { client : self.client, formId : FormIds.Add } ) + ); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds.Add, + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } else { + self.viewControllers.add.setFocus(true); + self.viewControllers.add.redrawAll(); + self.viewControllers.add.switchFocus(MciViewIds.add.BBSName); + return callback(null); + } + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + clearAddForm() { + [ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => { + this.setViewText('add', MciViewIds.add[mciName], ''); + }); + } + + initDatabase(cb) { + const self = this; + + async.series( + [ + function openDatabase(callback) { + self.database = getTransactionDatabase(new sqlite3.Database( + getModDatabasePath(moduleInfo), + callback + )); + }, + function createTables(callback) { + self.database.serialize( () => { + self.database.run( + `CREATE TABLE IF NOT EXISTS bbs_list ( + id INTEGER PRIMARY KEY, + bbs_name VARCHAR NOT NULL, + sysop VARCHAR NOT NULL, + telnet VARCHAR NOT NULL, + www VARCHAR, + location VARCHAR, + software VARCHAR, + submitter_user_id INTEGER NOT NULL, + notes VARCHAR + );` + ); + }); + callback(null); + } + ], + err => { + return cb(err); + } + ); + } + + beforeArt(cb) { + super.beforeArt(err => { + return err ? cb(err) : this.initDatabase(cb); + }); + } +}; diff --git a/core/button_view.js b/core/button_view.js index 570adc09..edb32e12 100644 --- a/core/button_view.js +++ b/core/button_view.js @@ -1,43 +1,45 @@ /* jslint node: true */ 'use strict'; -const TextView = require('./text_view.js').TextView; -const miscUtil = require('./misc_util.js'); -const util = require('util'); +const TextView = require('./text_view.js').TextView; +const miscUtil = require('./misc_util.js'); +const util = require('util'); -exports.ButtonView = ButtonView; +exports.ButtonView = ButtonView; function ButtonView(options) { - options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); - options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); - options.justify = miscUtil.valueWithDefault(options.justify, 'center'); - options.cursor = miscUtil.valueWithDefault(options.cursor, 'hide'); + options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); + options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); + options.justify = miscUtil.valueWithDefault(options.justify, 'center'); + options.cursor = miscUtil.valueWithDefault(options.cursor, 'hide'); - TextView.call(this, options); + TextView.call(this, options); + + this.initDefaultWidth(); } util.inherits(ButtonView, TextView); ButtonView.prototype.onKeyPress = function(ch, key) { - if(this.isKeyMapped('accept', key.name) || ' ' === ch) { - this.submitData = 'accept'; - this.emit('action', 'accept'); - delete this.submitData; - } else { - ButtonView.super_.prototype.onKeyPress.call(this, ch, key); - } + if(this.isKeyMapped('accept', key.name) || ' ' === ch) { + this.submitData = 'accept'; + this.emit('action', 'accept'); + delete this.submitData; + } else { + ButtonView.super_.prototype.onKeyPress.call(this, ch, key); + } }; /* ButtonView.prototype.onKeyPress = function(ch, key) { - // allow space = submit - if(' ' === ch) { - this.emit('action', 'accept'); - } + // allow space = submit + if(' ' === ch) { + this.emit('action', 'accept'); + } - ButtonView.super_.prototype.onKeyPress.call(this, ch, key); + ButtonView.super_.prototype.onKeyPress.call(this, ch, key); }; */ ButtonView.prototype.getData = function() { - return this.submitData || null; + return this.submitData || null; }; diff --git a/core/client.js b/core/client.js index 4501396d..88c9ea0f 100644 --- a/core/client.js +++ b/core/client.js @@ -2,503 +2,605 @@ 'use strict'; /* - Portions of this code for key handling heavily inspired from the following: - https://github.com/chjj/blessed/blob/master/lib/keys.js + Portions of this code for key handling heavily inspired from the following: + https://github.com/chjj/blessed/blob/master/lib/keys.js - chji's blessed is MIT licensed: + chji's blessed is MIT licensed: - ----/snip/---------------------- - The MIT License (MIT) + ----/snip/---------------------- + The MIT License (MIT) - Copyright (c) + Copyright (c) - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. - ----/snip/---------------------- + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + ----/snip/---------------------- */ -// ENiGMA½ -const term = require('./client_term.js'); -const ansi = require('./ansi_term.js'); -const User = require('./user.js'); -const Config = require('./config.js').config; -const MenuStack = require('./menu_stack.js'); -const ACS = require('./acs.js'); +// ENiGMA½ +const term = require('./client_term.js'); +const ansi = require('./ansi_term.js'); +const User = require('./user.js'); +const Config = require('./config.js').get; +const MenuStack = require('./menu_stack.js'); +const ACS = require('./acs.js'); +const Events = require('./events.js'); +const UserInterruptQueue = require('./user_interrupt_queue.js'); +const UserProps = require('./user_property.js'); -// deps -const stream = require('stream'); -const assert = require('assert'); -const _ = require('lodash'); +// deps +const stream = require('stream'); +const assert = require('assert'); +const _ = require('lodash'); -exports.Client = Client; +exports.Client = Client; -// :TODO: Move all of the key stuff to it's own module +// :TODO: Move all of the key stuff to it's own module // -// Resources & Standards: -// * http://www.ansi-bbs.org/ansi-bbs-core-server.html +// Resources & Standards: +// * http://www.ansi-bbs.org/ansi-bbs-core-server.html // -const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9\;]+)(R)/; -const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[\=\?]([0-9a-zA-Z\;]+)(c)/; -const RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/; -const RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$'); -const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [ - '(\\d+)(?:;(\\d+))?([~^$])', - '(?:M([@ #!a`])(.)(.))', // mouse stuff - '(?:1;)?(\\d+)?([a-zA-Z@])' +/* eslint-disable no-control-regex */ +const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9;]+)(R)/; +const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[=?]([0-9a-zA-Z;]+)(c)/; +const RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/; +const RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$'); +const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [ + '(\\d+)(?:;(\\d+))?([~^$])', + '(?:M([@ #!a`])(.)(.))', // mouse stuff + '(?:1;)?(\\d+)?([a-zA-Z@])' ].join('|') + ')'); +/* eslint-enable no-control-regex */ -const RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source); -const RE_ESC_CODE_ANYWHERE = new RegExp( [ - RE_FUNCTION_KEYCODE_ANYWHERE.source, - RE_META_KEYCODE_ANYWHERE.source, - RE_DSR_RESPONSE_ANYWHERE.source, - RE_DEV_ATTR_RESPONSE_ANYWHERE.source, - /\u001b./.source +const RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source); +const RE_ESC_CODE_ANYWHERE = new RegExp( [ + RE_FUNCTION_KEYCODE_ANYWHERE.source, + RE_META_KEYCODE_ANYWHERE.source, + RE_DSR_RESPONSE_ANYWHERE.source, + RE_DEV_ATTR_RESPONSE_ANYWHERE.source, + /\u001b./.source // eslint-disable-line no-control-regex ].join('|')); -function Client(input, output) { - stream.call(this); +function Client(/*input, output*/) { + stream.call(this); - const self = this; - - this.user = new User(); - this.currentTheme = { info : { name : 'N/A', description : 'None' } }; - this.lastKeyPressMs = Date.now(); - this.menuStack = new MenuStack(this); - this.acs = new ACS(this); - this.mciCache = {}; + const self = this; - this.clearMciCache = function() { - this.mciCache = {}; - }; + this.user = new User(); + this.currentTheme = { info : { name : 'N/A', description : 'None' } }; + this.lastActivityTime = Date.now(); + this.menuStack = new MenuStack(this); + this.acs = new ACS( { client : this, user : this.user } ); + this.mciCache = {}; + this.interruptQueue = new UserInterruptQueue(this); - Object.defineProperty(this, 'node', { - get : function() { - return self.session.id + 1; - } - }); + this.clearMciCache = function() { + this.mciCache = {}; + }; - Object.defineProperty(this, 'currentMenuModule', { - get : function() { - return self.menuStack.currentModule; - } - }); + Object.defineProperty(this, 'node', { + get : function() { + return self.session.id; + } + }); + Object.defineProperty(this, 'currentMenuModule', { + get : function() { + return self.menuStack.currentModule; + } + }); - // - // Peek at incoming |data| and emit events for any special - // handling that may include: - // * Keyboard input - // * ANSI CSR's and the like - // - // References: - // * http://www.ansi-bbs.org/ansi-bbs-core-server.html - // * Christopher Jeffrey's Blessed library @ https://github.com/chjj/blessed/ - // - 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', - }[deviceAttr]; + this.setTemporaryDirectDataHandler = function(handler) { + this.dataPassthrough = true; // let implementations do with what they will here + this.input.removeAllListeners('data'); + this.input.on('data', handler); + }; - if(!termClient) { - if(_.startsWith(deviceAttr, '67;84;101;114;109')) { - // - // See https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt - // - // Known clients: - // * SyncTERM - // - termClient = 'cterm'; - } - } + this.restoreDataHandler = function() { + this.dataPassthrough = false; + this.input.removeAllListeners('data'); + this.input.on('data', this.dataHandler); + }; - return termClient; - }; + this.themeChangedListener = function( { themeId } ) { + if(_.get(self.currentTheme, 'info.themeId') === themeId) { + self.currentTheme = require('./theme.js').getAvailableThemes().get(themeId); + } + }; - this.isMouseInput = function(data) { - return /\x1b\[M/.test(data) || - /\u001b\[M([\x00\u0020-\uffff]{3})/.test(data) || - /\u001b\[(\d+;\d+;\d+)M/.test(data) || - /\u001b\[<(\d+;\d+;\d+)([mM])/.test(data) || - /\u001b\[<(\d+;\d+;\d+;\d+)&w/.test(data) || - /\u001b\[24([0135])~\[(\d+),(\d+)\]\r/.test(data) || - /\u001b\[(O|I)/.test(data); - }; + Events.on(Events.getSystemEvents().ThemeChanged, this.themeChangedListener); - this.getKeyComponentsFromCode = function(code) { - return { - // xterm/gnome - 'OP' : { name : 'f1' }, - 'OQ' : { name : 'f2' }, - 'OR' : { name : 'f3' }, - 'OS' : { name : 'f4' }, + // + // Peek at incoming |data| and emit events for any special + // handling that may include: + // * Keyboard input + // * ANSI CSR's and the like + // + // References: + // * http://www.ansi-bbs.org/ansi-bbs-core-server.html + // * Christopher Jeffrey's Blessed library @ https://github.com/chjj/blessed/ + // + this.getTermClient = function(deviceAttr) { + let termClient = { + '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]; - 'OA' : { name : 'up arrow' }, - 'OB' : { name : 'down arrow' }, - 'OC' : { name : 'right arrow' }, - 'OD' : { name : 'left arrow' }, - 'OE' : { name : 'clear' }, - 'OF' : { name : 'end' }, - 'OH' : { name : 'home' }, - - // xterm/rxvt - '[11~' : { name : 'f1' }, - '[12~' : { name : 'f2' }, - '[13~' : { name : 'f3' }, - '[14~' : { name : 'f4' }, + if(!termClient) { + if(_.startsWith(deviceAttr, '67;84;101;114;109')) { + // + // See https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt + // + // Known clients: + // * SyncTERM + // + termClient = 'cterm'; + } + } - '[1~' : { name : 'home' }, - '[2~' : { name : 'insert' }, - '[3~' : { name : 'delete' }, - '[4~' : { name : 'end' }, - '[5~' : { name : 'page up' }, - '[6~' : { name : 'page down' }, + return termClient; + }; - // Cygwin & libuv - '[[A' : { name : 'f1' }, - '[[B' : { name : 'f2' }, - '[[C' : { name : 'f3' }, - '[[D' : { name : 'f4' }, - '[[E' : { name : 'f5' }, + /* eslint-disable no-control-regex */ + this.isMouseInput = function(data) { + return /\x1b\[M/.test(data) || + /\u001b\[M([\x00\u0020-\uffff]{3})/.test(data) || + /\u001b\[(\d+;\d+;\d+)M/.test(data) || + /\u001b\[<(\d+;\d+;\d+)([mM])/.test(data) || + /\u001b\[<(\d+;\d+;\d+;\d+)&w/.test(data) || + /\u001b\[24([0135])~\[(\d+),(\d+)\]\r/.test(data) || + /\u001b\[(O|I)/.test(data); + }; + /* eslint-enable no-control-regex */ - // Common impls - '[15~' : { name : 'f5' }, - '[17~' : { name : 'f6' }, - '[18~' : { name : 'f7' }, - '[19~' : { name : 'f8' }, - '[20~' : { name : 'f9' }, - '[21~' : { name : 'f10' }, - '[23~' : { name : 'f11' }, - '[24~' : { name : 'f12' }, + this.getKeyComponentsFromCode = function(code) { + return { + // xterm/gnome + 'OP' : { name : 'f1' }, + 'OQ' : { name : 'f2' }, + 'OR' : { name : 'f3' }, + 'OS' : { name : 'f4' }, - // xterm - '[A' : { name : 'up arrow' }, - '[B' : { name : 'down arrow' }, - '[C' : { name : 'right arrow' }, - '[D' : { name : 'left arrow' }, - '[E' : { name : 'clear' }, - '[F' : { name : 'end' }, - '[H' : { name : 'home' }, + 'OA' : { name : 'up arrow' }, + 'OB' : { name : 'down arrow' }, + 'OC' : { name : 'right arrow' }, + 'OD' : { name : 'left arrow' }, + 'OE' : { name : 'clear' }, + 'OF' : { name : 'end' }, + 'OH' : { name : 'home' }, - // PuTTY - '[[5~' : { name : 'page up' }, - '[[6~' : { name : 'page down' }, + // xterm/rxvt + '[11~' : { name : 'f1' }, + '[12~' : { name : 'f2' }, + '[13~' : { name : 'f3' }, + '[14~' : { name : 'f4' }, - // rvxt - '[7~' : { name : 'home' }, - '[8~' : { name : 'end' }, + '[1~' : { name : 'home' }, + '[2~' : { name : 'insert' }, + '[3~' : { name : 'delete' }, + '[4~' : { name : 'end' }, + '[5~' : { name : 'page up' }, + '[6~' : { name : 'page down' }, - // rxvt with modifiers - '[a' : { name : 'up arrow', shift : true }, - '[b' : { name : 'down arrow', shift : true }, - '[c' : { name : 'right arrow', shift : true }, - '[d' : { name : 'left arrow', shift : true }, - '[e' : { name : 'clear', shift : true }, + // Cygwin & libuv + '[[A' : { name : 'f1' }, + '[[B' : { name : 'f2' }, + '[[C' : { name : 'f3' }, + '[[D' : { name : 'f4' }, + '[[E' : { name : 'f5' }, - '[2$' : { name : 'insert', shift : true }, - '[3$' : { name : 'delete', shift : true }, - '[5$' : { name : 'page up', shift : true }, - '[6$' : { name : 'page down', shift : true }, - '[7$' : { name : 'home', shift : true }, - '[8$' : { name : 'end', shift : true }, + // Common impls + '[15~' : { name : 'f5' }, + '[17~' : { name : 'f6' }, + '[18~' : { name : 'f7' }, + '[19~' : { name : 'f8' }, + '[20~' : { name : 'f9' }, + '[21~' : { name : 'f10' }, + '[23~' : { name : 'f11' }, + '[24~' : { name : 'f12' }, - 'Oa' : { name : 'up arrow', ctrl : true }, - 'Ob' : { name : 'down arrow', ctrl : true }, - 'Oc' : { name : 'right arrow', ctrl : true }, - 'Od' : { name : 'left arrow', ctrl : true }, - 'Oe' : { name : 'clear', ctrl : true }, + // xterm + '[A' : { name : 'up arrow' }, + '[B' : { name : 'down arrow' }, + '[C' : { name : 'right arrow' }, + '[D' : { name : 'left arrow' }, + '[E' : { name : 'clear' }, + '[F' : { name : 'end' }, + '[H' : { name : 'home' }, - '[2^' : { name : 'insert', ctrl : true }, - '[3^' : { name : 'delete', ctrl : true }, - '[5^' : { name : 'page up', ctrl : true }, - '[6^' : { name : 'page down', ctrl : true }, - '[7^' : { name : 'home', ctrl : true }, - '[8^' : { name : 'end', ctrl : true }, + // PuTTY + '[[5~' : { name : 'page up' }, + '[[6~' : { name : 'page down' }, - // SyncTERM / EtherTerm - '[K' : { name : 'end' }, - '[@' : { name : 'insert' }, - '[V' : { name : 'page up' }, - '[U' : { name : 'page down' }, + // rvxt + '[7~' : { name : 'home' }, + '[8~' : { name : 'end' }, - // other - '[Z' : { name : 'tab', shift : true }, - }[code]; - }; + // rxvt with modifiers + '[a' : { name : 'up arrow', shift : true }, + '[b' : { name : 'down arrow', shift : true }, + '[c' : { name : 'right arrow', shift : true }, + '[d' : { name : 'left arrow', shift : true }, + '[e' : { name : 'clear', shift : true }, - this.on('data', function clientData(data) { - // create a uniform format that can be parsed below - if(data[0] > 127 && undefined === data[1]) { - data[0] -= 128; - data = '\u001b' + data.toString('utf-8'); - } else { - data = data.toString('utf-8'); - } + '[2$' : { name : 'insert', shift : true }, + '[3$' : { name : 'delete', shift : true }, + '[5$' : { name : 'page up', shift : true }, + '[6$' : { name : 'page down', shift : true }, + '[7$' : { name : 'home', shift : true }, + '[8$' : { name : 'end', shift : true }, - if(self.isMouseInput(data)) { - return; - } + 'Oa' : { name : 'up arrow', ctrl : true }, + 'Ob' : { name : 'down arrow', ctrl : true }, + 'Oc' : { name : 'right arrow', ctrl : true }, + 'Od' : { name : 'left arrow', ctrl : true }, + 'Oe' : { name : 'clear', ctrl : true }, - var buf = []; - var m; - while((m = RE_ESC_CODE_ANYWHERE.exec(data))) { - buf = buf.concat(data.slice(0, m.index).split('')); - buf.push(m[0]); - data = data.slice(m.index + m[0].length); - } + '[2^' : { name : 'insert', ctrl : true }, + '[3^' : { name : 'delete', ctrl : true }, + '[5^' : { name : 'page up', ctrl : true }, + '[6^' : { name : 'page down', ctrl : true }, + '[7^' : { name : 'home', ctrl : true }, + '[8^' : { name : 'end', ctrl : true }, - buf = buf.concat(data.split('')); // remainder + // SyncTERM / EtherTerm + '[K' : { name : 'end' }, + '[@' : { name : 'insert' }, + '[V' : { name : 'page up' }, + '[U' : { name : 'page down' }, - buf.forEach(function bufPart(s) { - var key = { - seq : s, - name : undefined, - ctrl : false, - meta : false, - shift : false, - }; + // other + '[Z' : { name : 'tab', shift : true }, + }[code]; + }; - var parts; + this.on('data', function clientData(data) { + // create a uniform format that can be parsed below + if(data[0] > 127 && undefined === data[1]) { + data[0] -= 128; + data = '\u001b' + data.toString('utf-8'); + } else { + data = data.toString('utf-8'); + } - if((parts = RE_DSR_RESPONSE_ANYWHERE.exec(s))) { - if('R' === parts[2]) { - const cprArgs = parts[1].split(';').map(v => (parseInt(v, 10) || 0) ); - if(2 === cprArgs.length) { - if(self.cprOffset) { - cprArgs[0] = cprArgs[0] + self.cprOffset; - cprArgs[1] = cprArgs[1] + self.cprOffset; - } - self.emit('cursor position report', cprArgs); - } - } - } else if((parts = RE_DEV_ATTR_RESPONSE_ANYWHERE.exec(s))) { - assert('c' === parts[2]); - var termClient = self.getTermClient(parts[1]); - if(termClient) { - self.term.termClient = termClient; - } - } else if('\r' === s) { - key.name = 'return'; - } else if('\n' === s) { - key.name = 'line feed'; - } else if('\t' === s) { - key.name = 'tab'; - } else if ('\b' === s || '\x7f' === s || '\x1b\x7f' === s || '\x1b\b' === s) { - // backspace, CTRL-H - key.name = 'backspace'; - key.meta = ('\x1b' === s.charAt(0)); - } else if('\x1b' === s || '\x1b\x1b' === s) { - key.name = 'escape'; - key.meta = (2 === s.length); - } else if (' ' === s || '\x1b ' === s) { - // rather annoying that space can come in other than just " " - key.name = 'space'; - key.meta = (2 === s.length); - } else if(1 === s.length && s <= '\x1a') { - // CTRL- - key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1); - key.ctrl = true; - } else if(1 === s.length && s >= 'a' && s <= 'z') { - // normal, lowercased letter - key.name = s; - } else if(1 === s.length && s >= 'A' && s <= 'Z') { - key.name = s.toLowerCase(); - key.shift = true; - } else if ((parts = RE_META_KEYCODE.exec(s))) { - // meta with character key - key.name = parts[1].toLowerCase(); - key.meta = true; - key.shift = /^[A-Z]$/.test(parts[1]); - } else if((parts = RE_FUNCTION_KEYCODE.exec(s))) { - var code = - (parts[1] || '') + (parts[2] || '') + - (parts[4] || '') + (parts[9] || ''); - - var modifier = (parts[3] || parts[8] || 1) - 1; + if(self.isMouseInput(data)) { + return; + } - key.ctrl = !!(modifier & 4); - key.meta = !!(modifier & 10); - key.shift = !!(modifier & 1); - key.code = code; + var buf = []; + var m; + while((m = RE_ESC_CODE_ANYWHERE.exec(data))) { + buf = buf.concat(data.slice(0, m.index).split('')); + buf.push(m[0]); + data = data.slice(m.index + m[0].length); + } - _.assign(key, self.getKeyComponentsFromCode(code)); - } + buf = buf.concat(data.split('')); // remainder - var ch; - if(1 === s.length) { - ch = s; - } else if('space' === key.name) { - // stupid hack to always get space as a regular char - ch = ' '; - } + buf.forEach(function bufPart(s) { + var key = { + seq : s, + name : undefined, + ctrl : false, + meta : false, + shift : false, + }; - if(_.isUndefined(key.name)) { - key = undefined; - } else { - // - // Adjust name for CTRL/Shift/Meta modifiers - // - key.name = - (key.ctrl ? 'ctrl + ' : '') + - (key.meta ? 'meta + ' : '') + - (key.shift ? 'shift + ' : '') + - key.name; - } + var parts; - if(key || ch) { - if(Config.logging.traceUserKeyboardInput) { - self.log.trace( { key : key, ch : escape(ch) }, 'User keyboard input'); // jshint ignore:line - } + if((parts = RE_DSR_RESPONSE_ANYWHERE.exec(s))) { + if('R' === parts[2]) { + const cprArgs = parts[1].split(';').map(v => (parseInt(v, 10) || 0) ); + if(2 === cprArgs.length) { + if(self.cprOffset) { + cprArgs[0] = cprArgs[0] + self.cprOffset; + cprArgs[1] = cprArgs[1] + self.cprOffset; + } + self.emit('cursor position report', cprArgs); + } + } + } else if((parts = RE_DEV_ATTR_RESPONSE_ANYWHERE.exec(s))) { + assert('c' === parts[2]); + var termClient = self.getTermClient(parts[1]); + if(termClient) { + self.term.termClient = termClient; + } + } else if('\r' === s) { + key.name = 'return'; + } else if('\n' === s) { + key.name = 'line feed'; + } else if('\t' === s) { + key.name = 'tab'; + } else if('\x7f' === s) { + // + // Backspace vs delete is a crazy thing, especially in *nix. + // - ANSI-BBS uses 0x7f for DEL + // - xterm et. al clients send 0x7f for backspace... ugg. + // + // See http://www.hypexr.org/linux_ruboff.php + // And a great discussion @ https://lists.debian.org/debian-i18n/1998/04/msg00015.html + // + if(self.term.isNixTerm()) { + key.name = 'backspace'; + } else { + key.name = 'delete'; + } + } else if ('\b' === s || '\x1b\x7f' === s || '\x1b\b' === s) { + // backspace, CTRL-H + key.name = 'backspace'; + key.meta = ('\x1b' === s.charAt(0)); + } else if('\x1b' === s || '\x1b\x1b' === s) { + key.name = 'escape'; + key.meta = (2 === s.length); + } else if (' ' === s || '\x1b ' === s) { + // rather annoying that space can come in other than just " " + key.name = 'space'; + key.meta = (2 === s.length); + } else if(1 === s.length && s <= '\x1a') { + // CTRL- + key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1); + key.ctrl = true; + } else if(1 === s.length && s >= 'a' && s <= 'z') { + // normal, lowercased letter + key.name = s; + } else if(1 === s.length && s >= 'A' && s <= 'Z') { + key.name = s.toLowerCase(); + key.shift = true; + } else if ((parts = RE_META_KEYCODE.exec(s))) { + // meta with character key + key.name = parts[1].toLowerCase(); + key.meta = true; + key.shift = /^[A-Z]$/.test(parts[1]); + } else if((parts = RE_FUNCTION_KEYCODE.exec(s))) { + var code = + (parts[1] || '') + (parts[2] || '') + + (parts[4] || '') + (parts[9] || ''); - self.lastKeyPressMs = Date.now(); + var modifier = (parts[3] || parts[8] || 1) - 1; - if(!self.ignoreInput) { - self.emit('key press', ch, key); - } - } - }); - }); + key.ctrl = !!(modifier & 4); + key.meta = !!(modifier & 10); + key.shift = !!(modifier & 1); + key.code = code; + + _.assign(key, self.getKeyComponentsFromCode(code)); + } + + var ch; + if(1 === s.length) { + ch = s; + } else if('space' === key.name) { + // stupid hack to always get space as a regular char + ch = ' '; + } + + if(_.isUndefined(key.name)) { + key = undefined; + } else { + // + // Adjust name for CTRL/Shift/Meta modifiers + // + key.name = + (key.ctrl ? 'ctrl + ' : '') + + (key.meta ? 'meta + ' : '') + + (key.shift ? 'shift + ' : '') + + key.name; + } + + if(key || ch) { + if(Config().logging.traceUserKeyboardInput) { + self.log.trace( { key : key, ch : escape(ch) }, 'User keyboard input'); // jshint ignore:line + } + + self.lastActivityTime = Date.now(); + + if(!self.ignoreInput) { + self.emit('key press', ch, key); + } + } + }); + }); } require('util').inherits(Client, stream); Client.prototype.setInputOutput = function(input, output) { - this.input = input; - this.output = output; + this.input = input; + this.output = output; - this.term = new term.ClientTerminal(this.output); + this.term = new term.ClientTerminal(this.output); }; Client.prototype.setTermType = function(termType) { - this.term.env.TERM = termType; - this.term.termType = termType; + this.term.env.TERM = termType; + this.term.termType = termType; - this.log.debug( { termType : termType }, 'Set terminal type'); + this.log.debug( { termType : termType }, 'Set terminal type'); }; Client.prototype.startIdleMonitor = function() { - var self = this; + // clear existing, if any + if(this.idleCheck) { + this.stopIdleMonitor(); + } - self.lastKeyPressMs = Date.now(); + this.lastActivityTime = Date.now(); - // - // Every 1m, check for idle. - // - self.idleCheck = setInterval(function checkForIdle() { - const nowMs = Date.now(); + // + // Every 1m, check for idle. + // We also update minutes spent online the system here, + // if we have a authenticated user. + // + this.idleCheck = setInterval( () => { + const nowMs = Date.now(); - const idleLogoutSeconds = self.user.isAuthenticated() ? - Config.misc.idleLogoutSeconds : - Config.misc.preAuthIdleLogoutSeconds; + let idleLogoutSeconds; + if(this.user.isAuthenticated()) { + idleLogoutSeconds = Config().users.idleLogoutSeconds; - if(nowMs - self.lastKeyPressMs >= (idleLogoutSeconds * 1000)) { - self.emit('idle timeout'); - } - }, 1000 * 60); + // + // We don't really want to be firing off an event every 1m for + // every user, but want at least some updates for various things + // such as achievements. Send off every 5m. + // + const minOnline = this.user.incrementProperty(UserProps.MinutesOnlineTotalCount, 1); + if(0 === (minOnline % 5)) { + Events.emit( + Events.getSystemEvents().UserStatIncrement, + { + user : this.user, + statName : UserProps.MinutesOnlineTotalCount, + statIncrementBy : 1, + statValue : minOnline + } + ); + } + } else { + idleLogoutSeconds = Config().users.preAuthIdleLogoutSeconds; + } + + // use override value if set + idleLogoutSeconds = this.idleLogoutSecondsOverride || idleLogoutSeconds; + + if(idleLogoutSeconds > 0 && (nowMs - this.lastActivityTime >= (idleLogoutSeconds * 1000))) { + this.emit('idle timeout'); + } + }, 1000 * 60); +}; + +Client.prototype.stopIdleMonitor = function() { + if(this.idleCheck) { + clearInterval(this.idleCheck); + delete this.idleCheck; + } +}; + +Client.prototype.explicitActivityTimeUpdate = function() { + this.lastActivityTime = Date.now(); +} + +Client.prototype.overrideIdleLogoutSeconds = function(seconds) { + this.idleLogoutSecondsOverride = seconds; +}; + +Client.prototype.restoreIdleLogoutSeconds = function() { + delete this.idleLogoutSecondsOverride; }; Client.prototype.end = function () { - if(this.term) { - this.term.disconnect(); - } + if(this.term) { + this.term.disconnect(); + } - var currentModule = this.menuStack.getCurrentModule; + Events.removeListener(Events.getSystemEvents().ThemeChanged, this.themeChangedListener); - if(currentModule) { - currentModule.leave(); - } + const currentModule = this.menuStack.getCurrentModule; - clearInterval(this.idleCheck); - - try { - // - // We can end up calling 'end' before TTY/etc. is established, e.g. with SSH - // - // :TODO: is this OK? - return this.output.end.apply(this.output, arguments); - } catch(e) { - // TypeError - } + if(currentModule) { + currentModule.leave(); + } + + // persist time online for authenticated users + if(this.user.isAuthenticated()) { + this.user.persistProperty( + UserProps.MinutesOnlineTotalCount, + this.user.getProperty(UserProps.MinutesOnlineTotalCount) + ); + } + + this.stopIdleMonitor(); + + try { + // + // We can end up calling 'end' before TTY/etc. is established, e.g. with SSH + // + if(_.isFunction(this.disconnect)) { + return this.disconnect(); + } else { + // legacy fallback + return this.output.end.apply(this.output, arguments); + } + } catch(e) { + // ie TypeError + } }; Client.prototype.destroy = function () { - return this.output.destroy.apply(this.output, arguments); + return this.output.destroy.apply(this.output, arguments); }; Client.prototype.destroySoon = function () { - return this.output.destroySoon.apply(this.output, arguments); + return this.output.destroySoon.apply(this.output, arguments); }; Client.prototype.waitForKeyPress = function(cb) { - this.once('key press', function kp(ch, key) { - cb(ch, key); - }); + this.once('key press', function kp(ch, key) { + cb(ch, key); + }); }; Client.prototype.isLocal = function() { - // :TODO: return rather client is a local connection or not - return false; + // :TODO: Handle ipv6 better + return [ '127.0.0.1', '::ffff:127.0.0.1' ].includes(this.remoteAddress); }; /////////////////////////////////////////////////////////////////////////////// -// Default error handlers +// Default error handlers /////////////////////////////////////////////////////////////////////////////// -// :TODO: getDefaultHandler(name) -- handlers in default_handlers.js or something -Client.prototype.defaultHandlerMissingMod = function(err) { - var self = this; +// :TODO: getDefaultHandler(name) -- handlers in default_handlers.js or something +Client.prototype.defaultHandlerMissingMod = function() { + var self = this; - function handler(err) { - self.log.error(err); + function handler(err) { + self.log.error(err); - self.term.write(ansi.resetScreen()); - self.term.write('An unrecoverable error has been encountered!\n'); - self.term.write('This has been logged for your SysOp to review.\n'); - self.term.write('\nGoodbye!\n'); + self.term.write(ansi.resetScreen()); + self.term.write('An unrecoverable error has been encountered!\n'); + self.term.write('This has been logged for your SysOp to review.\n'); + self.term.write('\nGoodbye!\n'); - - //self.term.write(err); - //if(miscUtil.isDevelopment() && err.stack) { - // self.term.write('\n' + err.stack + '\n'); - //} + //self.term.write(err); - self.end(); - } + //if(miscUtil.isDevelopment() && err.stack) { + // self.term.write('\n' + err.stack + '\n'); + //} - return handler; + self.end(); + } + + return handler; }; Client.prototype.terminalSupports = function(query) { - switch(query) { - case 'vtx_audio' : - // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt - return this.termClient === 'vtx'; - - default : - return false; - } + const termClient = this.term.termClient; + + switch(query) { + case 'vtx_audio' : + // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt + return 'vtx' === termClient; + + case 'vtx_hyperlink' : + return 'vtx' === termClient; + + default : + return false; + } }; diff --git a/core/client_connections.js b/core/client_connections.js index 7e74e29d..d1a6be6e 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -1,106 +1,140 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const logger = require('./logger.js'); -const Events = require('./events.js'); +// ENiGMA½ +const logger = require('./logger.js'); +const Events = require('./events.js'); +const UserProps = require('./user_property.js'); -// deps -const _ = require('lodash'); -const moment = require('moment'); +// deps +const _ = require('lodash'); +const moment = require('moment'); +const hashids = require('hashids/cjs'); -exports.getActiveConnections = getActiveConnections; -exports.getActiveNodeList = getActiveNodeList; -exports.addNewClient = addNewClient; -exports.removeClient = removeClient; -exports.getConnectionByUserId = getConnectionByUserId; +exports.getActiveConnections = getActiveConnections; +exports.getActiveConnectionList = getActiveConnectionList; +exports.addNewClient = addNewClient; +exports.removeClient = removeClient; +exports.getConnectionByUserId = getConnectionByUserId; +exports.getConnectionByNodeId = getConnectionByNodeId; const clientConnections = []; -exports.clientConnections = clientConnections; +exports.clientConnections = clientConnections; -function getActiveConnections() { return clientConnections; } +function getActiveConnections(authUsersOnly = false) { + return clientConnections.filter(conn => { + return ((authUsersOnly && conn.user.isAuthenticated()) || !authUsersOnly); + }); +} -function getActiveNodeList(authUsersOnly) { +function getActiveConnectionList(authUsersOnly) { - if(!_.isBoolean(authUsersOnly)) { - authUsersOnly = true; - } + if(!_.isBoolean(authUsersOnly)) { + authUsersOnly = true; + } - const now = moment(); + const now = moment(); - const activeConnections = getActiveConnections().filter(ac => { - return ((authUsersOnly && ac.user.isAuthenticated()) || !authUsersOnly); - }); + return _.map(getActiveConnections(authUsersOnly), ac => { + const entry = { + node : ac.node, + authenticated : ac.user.isAuthenticated(), + userId : ac.user.userId, + action : _.get(ac, 'currentMenuModule.menuConfig.desc', 'Unknown'), + }; - return _.map(activeConnections, ac => { - const entry = { - node : ac.node, - authenticated : ac.user.isAuthenticated(), - userId : ac.user.userId, - action : _.has(ac, 'currentMenuModule.menuConfig.desc') ? ac.currentMenuModule.menuConfig.desc : 'Unknown', - }; + // + // There may be a connection, but not a logged in user as of yet + // + if(ac.user.isAuthenticated()) { + entry.userName = ac.user.username; + entry.realName = ac.user.properties[UserProps.RealName]; + entry.location = ac.user.properties[UserProps.Location]; + entry.affils = entry.affiliation = ac.user.properties[UserProps.Affiliations]; - // - // There may be a connection, but not a logged in user as of yet - // - if(ac.user.isAuthenticated()) { - entry.userName = ac.user.username; - entry.realName = ac.user.properties.real_name; - entry.location = ac.user.properties.location; - entry.affils = ac.user.properties.affiliation; - - const diff = now.diff(moment(ac.user.properties.last_login_timestamp), 'minutes'); - entry.timeOn = moment.duration(diff, 'minutes'); - } - return entry; - }); + const diff = now.diff(moment(ac.user.properties[UserProps.LastLoginTs]), 'minutes'); + entry.timeOn = moment.duration(diff, 'minutes'); + } + return entry; + }); } function addNewClient(client, clientSock) { - const id = client.session.id = clientConnections.push(client) - 1; - const remoteAddress = client.remoteAddress = clientSock.remoteAddress; + // + // Find a node ID "slot" + // + let nodeId; + for (nodeId = 1; nodeId < Number.MAX_SAFE_INTEGER; ++nodeId) { + const existing = clientConnections.find(client => nodeId === client.node); + if (!existing) { + break; // available slot + } + } - // Create a client specific logger - // Note that this will be updated @ login with additional information - client.log = logger.log.child( { clientId : id } ); + client.session.id = nodeId; + const remoteAddress = client.remoteAddress = clientSock.remoteAddress; + // create a unique identifier one-time ID for this session + client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ nodeId, moment().valueOf() ]); - const connInfo = { - remoteAddress : remoteAddress, - serverName : client.session.serverName, - isSecure : client.session.isSecure, - }; + clientConnections.push(client); + clientConnections.sort( (c1, c2) => c1.session.id - c2.session.id); - if(client.log.debug()) { - connInfo.port = clientSock.localPort; - connInfo.family = clientSock.localFamily; - } + // Create a client specific logger + // Note that this will be updated @ login with additional information + client.log = logger.log.child( { nodeId, sessionId : client.session.uniqueId } ); - client.log.info(connInfo, 'Client connected'); + const connInfo = { + remoteAddress : remoteAddress, + serverName : client.session.serverName, + isSecure : client.session.isSecure, + }; - Events.emit('codes.l33t.enigma.system.connected', { client : client, connectionCount : clientConnections.length } ); + if(client.log.debug()) { + connInfo.port = clientSock.localPort; + connInfo.family = clientSock.localFamily; + } - return id; + client.log.info(connInfo, 'Client connected'); + + Events.emit( + Events.getSystemEvents().ClientConnected, + { client : client, connectionCount : clientConnections.length } + ); + + return nodeId; } function removeClient(client) { - client.end(); + client.end(); - const i = clientConnections.indexOf(client); - if(i > -1) { - clientConnections.splice(i, 1); + const i = clientConnections.indexOf(client); + if(i > -1) { + clientConnections.splice(i, 1); - logger.log.info( - { - connectionCount : clientConnections.length, - clientId : client.session.id - }, - 'Client disconnected' - ); + logger.log.info( + { + connectionCount : clientConnections.length, + nodeId : client.node, + }, + 'Client disconnected' + ); - Events.emit('codes.l33t.enigma.system.disconnected', { client : client, connectionCount : clientConnections.length } ); - } + if(client.user && client.user.isValid()) { + const minutesOnline = moment().diff(moment(client.user.properties[UserProps.LastLoginTs]), 'minutes'); + Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user, minutesOnline } ); + } + + Events.emit( + Events.getSystemEvents().ClientDisconnected, + { client : client, connectionCount : clientConnections.length } + ); + } } function getConnectionByUserId(userId) { - return getActiveConnections().find( ac => userId === ac.user.userId ); + return getActiveConnections().find( ac => userId === ac.user.userId ); +} + +function getConnectionByNodeId(nodeId) { + return getActiveConnections().find( ac => nodeId == ac.node ); } diff --git a/core/client_term.js b/core/client_term.js index 9992f9f3..f537c359 100644 --- a/core/client_term.js +++ b/core/client_term.js @@ -1,188 +1,189 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -var Log = require('./logger.js').log; -var enigmaToAnsi = require('./color_codes.js').enigmaToAnsi; -var renegadeToAnsi = require('./color_codes.js').renegadeToAnsi; +// ENiGMA½ +var Log = require('./logger.js').log; +var renegadeToAnsi = require('./color_codes.js').renegadeToAnsi; -var iconv = require('iconv-lite'); -var assert = require('assert'); -var _ = require('lodash'); +var iconv = require('iconv-lite'); +var assert = require('assert'); +var _ = require('lodash'); -exports.ClientTerminal = ClientTerminal; +exports.ClientTerminal = ClientTerminal; function ClientTerminal(output) { - this.output = output; + this.output = output; - var self = this; + var outputEncoding = 'cp437'; + assert(iconv.encodingExists(outputEncoding)); - var outputEncoding = 'cp437'; - assert(iconv.encodingExists(outputEncoding)); + // convert line feeds such as \n -> \r\n + this.convertLF = true; - // convert line feeds such as \n -> \r\n - this.convertLF = true; + // + // Some terminal we handle specially + // They can also be found in this.env{} + // + var termType = 'unknown'; + var termHeight = 0; + var termWidth = 0; + var termClient = 'unknown'; - // - // Some terminal we handle specially - // They can also be found in this.env{} - // - var termType = 'unknown'; - var termHeight = 0; - var termWidth = 0; - var termClient = 'unknown'; + this.currentSyncFont = 'not_set'; - this.currentSyncFont = 'not_set'; + // Raw values set by e.g. telnet NAWS, ENVIRONMENT, etc. + this.env = {}; - // Raw values set by e.g. telnet NAWS, ENVIRONMENT, etc. - this.env = {}; + Object.defineProperty(this, 'outputEncoding', { + get : function() { + return outputEncoding; + }, + set : function(enc) { + if(iconv.encodingExists(enc)) { + outputEncoding = enc; + } else { + Log.warn({ encoding : enc }, 'Unknown encoding'); + } + } + }); - Object.defineProperty(this, 'outputEncoding', { - get : function() { - return outputEncoding; - }, - set : function(enc) { - if(iconv.encodingExists(enc)) { - outputEncoding = enc; - } else { - Log.warn({ encoding : enc }, 'Unknown encoding'); - } - } - }); + Object.defineProperty(this, 'termType', { + get : function() { + return termType; + }, + set : function(ttype) { + termType = ttype.toLowerCase(); - Object.defineProperty(this, 'termType', { - get : function() { - return termType; - }, - set : function(ttype) { - termType = ttype.toLowerCase(); - - if(this.isANSI()) { - this.outputEncoding = 'cp437'; - } else { - // :TODO: See how x84 does this -- only set if local/remote are binary - this.outputEncoding = 'utf8'; - } + if(this.isANSI()) { + this.outputEncoding = 'cp437'; + } else { + // :TODO: See how x84 does this -- only set if local/remote are binary + this.outputEncoding = 'utf8'; + } - // :TODO: according to this: http://mud-dev.wikidot.com/article:telnet-client-identification - // Windows telnet will send "VTNT". If so, set termClient='windows' - // there are some others on the page as well + // :TODO: according to this: http://mud-dev.wikidot.com/article:telnet-client-identification + // Windows telnet will send "VTNT". If so, set termClient='windows' + // there are some others on the page as well - Log.debug( { encoding : this.outputEncoding }, 'Set output encoding due to terminal type change'); - } - }); + Log.debug( { encoding : this.outputEncoding }, 'Set output encoding due to terminal type change'); + } + }); - Object.defineProperty(this, 'termWidth', { - get : function() { - return termWidth; - }, - set : function(width) { - if(width > 0) { - termWidth = width; - } - } - }); + Object.defineProperty(this, 'termWidth', { + get : function() { + return termWidth; + }, + set : function(width) { + if(width > 0) { + termWidth = width; + } + } + }); - Object.defineProperty(this, 'termHeight', { - get : function() { - return termHeight; - }, - set : function(height) { - if(height > 0) { - termHeight = height; - } - } - }); + Object.defineProperty(this, 'termHeight', { + get : function() { + return termHeight; + }, + set : function(height) { + if(height > 0) { + termHeight = height; + } + } + }); - Object.defineProperty(this, 'termClient', { - get : function() { - return termClient; - }, - set : function(tc) { - termClient = tc; + Object.defineProperty(this, 'termClient', { + get : function() { + return termClient; + }, + set : function(tc) { + termClient = tc; - Log.debug( { termClient : this.termClient }, 'Set known terminal client'); - } - }); + Log.debug( { termClient : this.termClient }, 'Set known terminal client'); + } + }); } ClientTerminal.prototype.disconnect = function() { - this.output = null; + this.output = null; +}; + +ClientTerminal.prototype.isNixTerm = function() { + // + // Standard *nix type terminals + // + if(this.termType.startsWith('xterm')) { + return true; + } + + return [ 'xterm', 'linux', 'screen', 'dumb', 'rxvt', 'konsole', 'gnome', 'x11 terminal emulator' ].includes(this.termType); }; ClientTerminal.prototype.isANSI = function() { - // - // ANSI terminals should be encoded to CP437 - // - // Some terminal types provided by Mercyful Fate / Enthral: - // ANSI-BBS - // PC-ANSI - // QANSI - // SCOANSI - // VT100 - // QNX - // - // Reports from various terminals - // - // syncterm: - // * SyncTERM - // - // xterm: - // * PuTTY - // - // ansi-bbs: - // * fTelnet - // - // pcansi: - // * ZOC - // - // screen: - // * ConnectBot (Android) - // - // linux: - // * JuiceSSH (note: TERM=linux also) - // - return [ 'ansi', 'pcansi', 'pc-ansi', 'ansi-bbs', 'qansi', 'scoansi', 'syncterm' ].indexOf(this.termType) > -1; + // + // ANSI terminals should be encoded to CP437 + // + // Some terminal types provided by Mercyful Fate / Enthral: + // ANSI-BBS + // PC-ANSI + // QANSI + // SCOANSI + // VT100 + // QNX + // + // Reports from various terminals + // + // syncterm: + // * SyncTERM + // + // xterm: + // * PuTTY + // + // ansi-bbs: + // * fTelnet + // + // pcansi: + // * ZOC + // + // screen: + // * ConnectBot (Android) + // + // linux: + // * JuiceSSH (note: TERM=linux also) + // + return [ 'ansi', 'pcansi', 'pc-ansi', 'ansi-bbs', 'qansi', 'scoansi', 'syncterm' ].includes(this.termType); }; -// :TODO: probably need to update these to convert IAC (0xff) -> IACIAC (escape it) +// :TODO: probably need to update these to convert IAC (0xff) -> IACIAC (escape it) ClientTerminal.prototype.write = function(s, convertLineFeeds, cb) { - this.rawWrite(this.encode(s, convertLineFeeds), cb); + this.rawWrite(this.encode(s, convertLineFeeds), cb); }; ClientTerminal.prototype.rawWrite = function(s, cb) { - if(this.output) { - this.output.write(s, err => { - if(cb) { - return cb(err); - } - - if(err) { - Log.warn( { error : err.message }, 'Failed writing to socket'); - } - }); - } + if(this.output) { + this.output.write(s, err => { + if(cb) { + return cb(err); + } + + if(err) { + Log.warn( { error : err.message }, 'Failed writing to socket'); + } + }); + } }; -ClientTerminal.prototype.pipeWrite = function(s, spec, cb) { - spec = spec || 'renegade'; - - var conv = { - enigma : enigmaToAnsi, - renegade : renegadeToAnsi, - }[spec] || renegadeToAnsi; - - this.write(conv(s, this), null, cb); // null = use default for |convertLineFeeds| +ClientTerminal.prototype.pipeWrite = function(s, cb) { + this.write(renegadeToAnsi(s, this), null, cb); // null = use default for |convertLineFeeds| }; ClientTerminal.prototype.encode = function(s, convertLineFeeds) { - convertLineFeeds = _.isBoolean(convertLineFeeds) ? convertLineFeeds : this.convertLF; - - if(convertLineFeeds && _.isString(s)) { - s = s.replace(/\n/g, '\r\n'); - } - return iconv.encode(s, this.outputEncoding); + convertLineFeeds = _.isBoolean(convertLineFeeds) ? convertLineFeeds : this.convertLF; + + if(convertLineFeeds && _.isString(s)) { + s = s.replace(/\n/g, '\r\n'); + } + return iconv.encode(s, this.outputEncoding); }; diff --git a/core/color_codes.js b/core/color_codes.js index 4dc8da99..ff08275e 100644 --- a/core/color_codes.js +++ b/core/color_codes.js @@ -1,150 +1,273 @@ /* jslint node: true */ 'use strict'; -var ansi = require('./ansi_term.js'); -var getPredefinedMCIValue = require('./predefined_mci.js').getPredefinedMCIValue; +const ANSI = require('./ansi_term.js'); +const { getPredefinedMCIValue } = require('./predefined_mci.js'); -var assert = require('assert'); -var _ = require('lodash'); +// deps +const _ = require('lodash'); -exports.enigmaToAnsi = enigmaToAnsi; -exports.stripPipeCodes = exports.stripEnigmaCodes = stripEnigmaCodes; -exports.pipeStrLen = exports.enigmaStrLen = enigmaStrLen; -exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi; +exports.stripMciColorCodes = stripMciColorCodes; +exports.pipeStringLength = pipeStringLength; +exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi; +exports.controlCodesToAnsi = controlCodesToAnsi; -// :TODO: Not really happy with the module name of "color_codes". Would like something better +// :TODO: Not really happy with the module name of "color_codes". Would like something better ... control_code_string? - - -// Also add: -// * fromCelerity(): | -// * fromPCBoard(): (@X) -// * fromWildcat(): (@@ (same as PCBoard without 'X' prefix and '@' suffix) -// * fromWWIV(): <0-7> -// * fromSyncronet(): -// See http://wiki.synchro.net/custom:colors - -// :TODO: rid of enigmaToAnsi() -- never really use. Instead, create bbsToAnsi() that supports renegade, PCB, WWIV, etc... -function enigmaToAnsi(s, client) { - if(-1 == s.indexOf('|')) { - return s; // no pipe codes present - } - - var result = ''; - var re = /\|([A-Z\d]{2}|\|)/g; - var m; - var lastIndex = 0; - while((m = re.exec(s))) { - var val = m[1]; - - if('|' == val) { - result += '|'; - continue; - } - - // convert to number - val = parseInt(val, 10); - if(isNaN(val)) { - // - // ENiGMA MCI code? Only available if |client| - // is supplied. - // - val = getPredefinedMCIValue(client, m[1]) || ('|' + m[1]); // value itself or literal - } - - if(_.isString(val)) { - result += s.substr(lastIndex, m.index - lastIndex) + val; - } else { - assert(val >= 0 && val <= 47); - - var attr = ''; - if(7 == val) { - attr = ansi.sgr('normal'); - } else if (val < 7 || val >= 16) { - attr = ansi.sgr(['normal', val]); - } else if (val <= 15) { - attr = ansi.sgr(['normal', val - 8, 'bold']); - } - - result += s.substr(lastIndex, m.index - lastIndex) + attr; - } - - lastIndex = re.lastIndex; - } - - result = (0 === result.length ? s : result + s.substr(lastIndex)); - - return result; +function stripMciColorCodes(s) { + return s.replace(/\|[A-Z\d]{2}/g, ''); } -function stripEnigmaCodes(s) { - return s.replace(/\|[A-Z\d]{2}/g, ''); +function pipeStringLength(s) { + return stripMciColorCodes(s).length; } -function enigmaStrLen(s) { - return stripEnigmaCodes(s).length; +function ansiSgrFromRenegadeColorCode(cc) { + return ANSI.sgr({ + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'bold', 'black' ], + 9 : [ 'bold', 'blue' ], + 10 : [ 'bold', 'green' ], + 11 : [ 'bold', 'cyan' ], + 12 : [ 'bold', 'red' ], + 13 : [ 'bold', 'magenta' ], + 14 : [ 'bold', 'yellow' ], + 15 : [ 'bold', 'white' ], + + 16 : [ 'blackBG' ], + 17 : [ 'blueBG' ], + 18 : [ 'greenBG' ], + 19 : [ 'cyanBG' ], + 20 : [ 'redBG' ], + 21 : [ 'magentaBG' ], + 22 : [ 'yellowBG' ], + 23 : [ 'whiteBG' ], + + 24 : [ 'blink', 'blackBG' ], + 25 : [ 'blink', 'blueBG' ], + 26 : [ 'blink', 'greenBG' ], + 27 : [ 'blink', 'cyanBG' ], + 28 : [ 'blink', 'redBG' ], + 29 : [ 'blink', 'magentaBG' ], + 30 : [ 'blink', 'yellowBG' ], + 31 : [ 'blink', 'whiteBG' ], + }[cc] || 'normal'); +} + +function ansiSgrFromCnetStyleColorCode(cc) { + return ANSI.sgr({ + c0 : [ 'reset', 'black' ], + c1 : [ 'reset', 'red' ], + c2 : [ 'reset', 'green' ], + c3 : [ 'reset', 'yellow' ], + c4 : [ 'reset', 'blue' ], + c5 : [ 'reset', 'magenta' ], + c6 : [ 'reset', 'cyan' ], + c7 : [ 'reset', 'white' ], + + c8 : [ 'bold', 'black' ], + c9 : [ 'bold', 'red' ], + ca : [ 'bold', 'green' ], + cb : [ 'bold', 'yellow' ], + cc : [ 'bold', 'blue' ], + cd : [ 'bold', 'magenta' ], + ce : [ 'bold', 'cyan' ], + cf : [ 'bold', 'white' ], + + z0 : [ 'blackBG' ], + z1 : [ 'redBG' ], + z2 : [ 'greenBG' ], + z3 : [ 'yellowBG' ], + z4 : [ 'blueBG' ], + z5 : [ 'magentaBG' ], + z6 : [ 'cyanBG' ], + z7 : [ 'whiteBG' ], + }[cc] || 'normal'); } function renegadeToAnsi(s, client) { - if(-1 == s.indexOf('|')) { - return s; // no pipe codes present - } + if(-1 == s.indexOf('|')) { + return s; // no pipe codes present + } - var result = ''; - var re = /\|([A-Z\d]{2}|\|)/g; - var m; - var lastIndex = 0; - while((m = re.exec(s))) { - var val = m[1]; + let result = ''; + const re = /\|(?:(C[FBUD])([0-9]{1,2})|([0-9]{2})|([A-Z]{2})|(\|))/g; + let m; + let lastIndex = 0; + while((m = re.exec(s))) { + if(m[3]) { + // |## color + const val = parseInt(m[3], 10); + const attr = ansiSgrFromRenegadeColorCode(val); + result += s.substr(lastIndex, m.index - lastIndex) + attr; + } else if(m[4] || m[1]) { + // |AA MCI code or |Cx## movement where ## is in m[1] + let val = getPredefinedMCIValue(client, m[4] || m[1], m[2]); + val = _.isString(val) ? val : m[0]; // value itself or literal + result += s.substr(lastIndex, m.index - lastIndex) + val; + } else if(m[5]) { + // || -- literal '|', that is. + result += '|'; + } - if('|' == val) { - result += '|'; - continue; - } + lastIndex = re.lastIndex; + } - // convert to number - val = parseInt(val, 10); - if(isNaN(val)) { - val = getPredefinedMCIValue(client, m[1]) || ('|' + m[1]); // value itself or literal - } - - if(_.isString(val)) { - result += s.substr(lastIndex, m.index - lastIndex) + val; - } else { - var attr = ansi.sgr({ - 0 : [ 'reset', 'black' ], - 1 : [ 'reset', 'blue' ], - 2 : [ 'reset', 'green' ], - 3 : [ 'reset', 'cyan' ], - 4 : [ 'reset', 'red' ], - 5 : [ 'reset', 'magenta' ], - 6 : [ 'reset', 'yellow' ], - 7 : [ 'reset', 'white' ], - - 8 : [ 'bold', 'black' ], - 9 : [ 'bold', 'blue' ], - 10 : [ 'bold', 'green' ], - 11 : [ 'bold', 'cyan' ], - 12 : [ 'bold', 'red' ], - 13 : [ 'bold', 'magenta' ], - 14 : [ 'bold', 'yellow' ], - 15 : [ 'bold', 'white' ], - - 16 : [ 'blackBG' ], - 17 : [ 'blueBG' ], - 18 : [ 'greenBG' ], - 19 : [ 'cyanBG' ], - 20 : [ 'redBG' ], - 21 : [ 'magentaBG' ], - 22 : [ 'yellowBG' ], - 23 : [ 'whiteBG' ], - }[val] || 'normal'); - - result += s.substr(lastIndex, m.index - lastIndex) + attr; - } - - lastIndex = re.lastIndex; - } - - return (0 === result.length ? s : result + s.substr(lastIndex)); + return (0 === result.length ? s : result + s.substr(lastIndex)); } + +// +// Converts various control codes popular in BBS packages +// to ANSI escape sequences. Additionaly supports ENiGMA style +// MCI codes. +// +// Supported control code formats: +// * Renegade : |## +// * PCBoard : @X## where the first number/char is BG color, and second is FG +// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix +// * WWIV : ^# +// * CNET Y-Style : 0x19## where ## is a specific set of codes -- this is the older format +// * CNET Q-style : 0x11##} where ## is a specific set of codes -- this is the newer format +// +// TODO: Add Synchronet and Celerity format support +// +// Resources: +// * http://wiki.synchro.net/custom:colors +// +function controlCodesToAnsi(s, client) { + const RE = /(\|([A-Z0-9]{2})|\|)|(@X([0-9A-F]{2}))|(@([0-9A-F]{2})@)|(\x03[0-9]|\x03)|(\x19(c[0-9a-f]|z[0-7]|n1|f1)|\x19)|(\x11(c[0-9a-f]|z[0-7]|n1|f1)}|\x11)/g; // eslint-disable-line no-control-regex + + let m; + let result = ''; + let lastIndex = 0; + let v; + let fg; + let bg; + + while((m = RE.exec(s))) { + switch(m[0].charAt(0)) { + case '|' : + // Renegade |## + v = parseInt(m[2], 10); + + if(isNaN(v)) { + v = getPredefinedMCIValue(client, m[2]) || m[0]; // value itself or literal + } + + if(_.isString(v)) { + result += s.substr(lastIndex, m.index - lastIndex) + v; + } else { + v = ansiSgrFromRenegadeColorCode(v); + result += s.substr(lastIndex, m.index - lastIndex) + v; + } + break; + + case '@' : + // PCBoard @X## or Wildcat! @##@ + if('@' === m[0].substr(-1)) { + // Wildcat! + v = m[6]; + } else { + v = m[4]; + } + + bg = { + 0 : [ 'blackBG' ], + 1 : [ 'blueBG' ], + 2 : [ 'greenBG' ], + 3 : [ 'cyanBG' ], + 4 : [ 'redBG' ], + 5 : [ 'magentaBG' ], + 6 : [ 'yellowBG' ], + 7 : [ 'whiteBG' ], + + 8 : [ 'bold', 'blackBG' ], + 9 : [ 'bold', 'blueBG' ], + A : [ 'bold', 'greenBG' ], + B : [ 'bold', 'cyanBG' ], + C : [ 'bold', 'redBG' ], + D : [ 'bold', 'magentaBG' ], + E : [ 'bold', 'yellowBG' ], + F : [ 'bold', 'whiteBG' ], + }[v.charAt(0)] || [ 'normal' ]; + + fg = { + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'blink', 'black' ], + 9 : [ 'blink', 'blue' ], + A : [ 'blink', 'green' ], + B : [ 'blink', 'cyan' ], + C : [ 'blink', 'red' ], + D : [ 'blink', 'magenta' ], + E : [ 'blink', 'yellow' ], + F : [ 'blink', 'white' ], + }[v.charAt(1)] || ['normal']; + + v = ANSI.sgr(fg.concat(bg)); + result += s.substr(lastIndex, m.index - lastIndex) + v; + break; + + case '\x03' : + // WWIV + v = parseInt(m[8], 10); + + if(isNaN(v)) { + v += m[0]; + } else { + v = ANSI.sgr({ + 0 : [ 'reset', 'black' ], + 1 : [ 'bold', 'cyan' ], + 2 : [ 'bold', 'yellow' ], + 3 : [ 'reset', 'magenta' ], + 4 : [ 'bold', 'white', 'blueBG' ], + 5 : [ 'reset', 'green' ], + 6 : [ 'bold', 'blink', 'red' ], + 7 : [ 'bold', 'blue' ], + 8 : [ 'reset', 'blue' ], + 9 : [ 'reset', 'cyan' ], + }[v] || 'normal'); + } + + result += s.substr(lastIndex, m.index - lastIndex) + v; + break; + + case '\x19' : + case '\0x11' : + // CNET "Y-Style" & "Q-Style" + v = m[9] || m[11]; + if(v) { + if('n1' === v) { + v = '\n'; + } else if('f1' === v) { + v = ANSI.clearScreen(); + } else { + v = ansiSgrFromCnetStyleColorCode(v); + } + } else { + v = m[0]; + } + result += s.substr(lastIndex, m.index - lastIndex) + v; + break; + } + + lastIndex = RE.lastIndex; + } + + return (0 === result.length ? s : result + s.substr(lastIndex)); +} \ No newline at end of file diff --git a/core/combatnet.js b/core/combatnet.js new file mode 100644 index 00000000..8f1a5623 --- /dev/null +++ b/core/combatnet.js @@ -0,0 +1,131 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const { MenuModule } = require('../core/menu_module.js'); +const { resetScreen } = require('../core/ansi_term.js'); +const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); + +// deps +const async = require('async'); +const RLogin = require('rlogin'); + +exports.moduleInfo = { + name : 'CombatNet', + desc : 'CombatNet Access Module', + author : 'Dave Stephens', +}; + +exports.getModule = class CombatNetModule extends MenuModule { + constructor(options) { + super(options); + + // establish defaults + this.config = options.menuConfig.config; + this.config.host = this.config.host || 'bbs.combatnet.us'; + this.config.rloginPort = this.config.rloginPort || 4513; + } + + initSequence() { + const self = this; + + async.series( + [ + function validateConfig(callback) { + return self.validateConfigFields( + { + host : 'string', + password : 'string', + bbsTag : 'string', + rloginPort : 'number', + }, + callback + ); + }, + function establishRloginConnection(callback) { + self.client.term.write(resetScreen()); + self.client.term.write('Connecting to CombatNet, please wait...\n'); + + let doorTracking; + + const restorePipeToNormal = function() { + if(self.client.term.output) { + self.client.term.output.removeListener('data', sendToRloginBuffer); + + if(doorTracking) { + trackDoorRunEnd(doorTracking); + } + } + }; + + const rlogin = new RLogin( + { + clientUsername : self.config.password, + serverUsername : `${self.config.bbsTag}${self.client.user.username}`, + host : self.config.host, + port : self.config.rloginPort, + terminalType : self.client.term.termClient, + terminalSpeed : 57600 + } + ); + + // If there was an error ... + rlogin.on('error', err => { + self.client.log.info(`CombatNet rlogin client error: ${err.message}`); + restorePipeToNormal(); + return callback(err); + }); + + // If we've been disconnected ... + rlogin.on('disconnect', () => { + self.client.log.info('Disconnected from CombatNet'); + restorePipeToNormal(); + return callback(null); + }); + + function sendToRloginBuffer(buffer) { + rlogin.send(buffer); + } + + rlogin.on('connect', + /* The 'connect' event handler will be supplied with one argument, + a boolean indicating whether or not the connection was established. */ + + function(state) { + if(state) { + self.client.log.info('Connected to CombatNet'); + self.client.term.output.on('data', sendToRloginBuffer); + + doorTracking = trackDoorRunBegin(self.client); + } else { + return callback(Errors.General('Failed to establish establish CombatNet connection')); + } + } + ); + + // If data (a Buffer) has been received from the server ... + rlogin.on('data', (data) => { + self.client.term.rawWrite(data); + }); + + // connect... + rlogin.connect(); + + // note: no explicit callback() until we're finished! + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'CombatNet error'); + } + + // if the client is still here, go to previous + self.prevMenu(); + } + ); + } +}; diff --git a/core/conf_area_util.js b/core/conf_area_util.js index 5dabfb73..1c0b65c4 100644 --- a/core/conf_area_util.js +++ b/core/conf_area_util.js @@ -1,30 +1,30 @@ /* jslint node: true */ 'use strict'; -// deps -const _ = require('lodash'); +// deps +const _ = require('lodash'); -exports.sortAreasOrConfs = sortAreasOrConfs; +exports.sortAreasOrConfs = sortAreasOrConfs; // -// Method for sorting message, file, etc. areas and confs -// If the sort key is present and is a number, sort in numerical order; -// Otherwise, use a locale comparison on the sort key or name as a fallback -// +// Method for sorting message, file, etc. areas and confs +// If the sort key is present and is a number, sort in numerical order; +// Otherwise, use a locale comparison on the sort key or name as a fallback +// function sortAreasOrConfs(areasOrConfs, type) { - let entryA; - let entryB; + let entryA; + let entryB; - areasOrConfs.sort((a, b) => { - entryA = type ? a[type] : a; - entryB = type ? b[type] : b; + areasOrConfs.sort((a, b) => { + entryA = type ? a[type] : a; + entryB = type ? b[type] : b; - if(_.isNumber(entryA.sort) && _.isNumber(entryB.sort)) { - return entryA.sort - entryB.sort; - } else { - const keyA = entryA.sort ? entryA.sort.toString() : entryA.name; - const keyB = entryB.sort ? entryB.sort.toString() : entryB.name; - return keyA.localeCompare(keyB, { sensitivity : false, numeric : true } ); // "natural" compare - } - }); + if(_.isNumber(entryA.sort) && _.isNumber(entryB.sort)) { + return entryA.sort - entryB.sort; + } else { + const keyA = entryA.sort ? entryA.sort.toString() : entryA.name; + const keyB = entryB.sort ? entryB.sort.toString() : entryB.name; + return keyA.localeCompare(keyB, { sensitivity : false, numeric : true } ); // "natural" compare + } + }); } \ No newline at end of file diff --git a/core/config.js b/core/config.js index 449a5c98..7e467709 100644 --- a/core/config.js +++ b/core/config.js @@ -1,747 +1,1093 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const miscUtil = require('./misc_util.js'); +// ENiGMA½ +const Errors = require('./enig_error.js').Errors; -// deps -const fs = require('graceful-fs'); -const paths = require('path'); -const async = require('async'); -const _ = require('lodash'); -const hjson = require('hjson'); -const assert = require('assert'); +// deps +const paths = require('path'); +const async = require('async'); +const _ = require('lodash'); +const assert = require('assert'); -exports.init = init; -exports.getDefaultPath = getDefaultPath; +exports.init = init; +exports.getDefaultPath = getDefaultPath; +exports.getDefaultConfig = getDefaultConfig; + +let currentConfiguration = {}; function hasMessageConferenceAndArea(config) { - assert(_.isObject(config.messageConferences)); // we create one ourself! + assert(_.isObject(config.messageConferences)); // we create one ourself! - const nonInternalConfs = Object.keys(config.messageConferences).filter(confTag => { - return 'system_internal' !== confTag; - }); + const nonInternalConfs = Object.keys(config.messageConferences).filter(confTag => { + return 'system_internal' !== confTag; + }); - if(0 === nonInternalConfs.length) { - return false; - } + if(0 === nonInternalConfs.length) { + return false; + } - // :TODO: there is likely a better/cleaner way of doing this + // :TODO: there is likely a better/cleaner way of doing this - let result = false; - _.forEach(nonInternalConfs, confTag => { - if(_.has(config.messageConferences[confTag], 'areas') && - Object.keys(config.messageConferences[confTag].areas) > 0) - { - result = true; - return false; // stop iteration - } - }); + let result = false; + _.forEach(nonInternalConfs, confTag => { + if(_.has(config.messageConferences[confTag], 'areas') && + Object.keys(config.messageConferences[confTag].areas) > 0) + { + result = true; + return false; // stop iteration + } + }); - return result; + return result; +} + +const ArrayReplaceKeyPaths = [ + 'loginServers.ssh.algorithms.kex', + 'loginServers.ssh.algorithms.cipher', + 'loginServers.ssh.algorithms.hmac', + 'loginServers.ssh.algorithms.compress', +]; + +const ArrayReplaceKeys = [ + 'args', + 'sendArgs', 'recvArgs', 'recvArgsNonBatch', +]; + +function mergeValidateAndFinalize(config, cb) { + const defaultConfig = getDefaultConfig(); + + const arrayReplaceKeyPathsMutable = _.clone(ArrayReplaceKeyPaths); + const shouldReplaceArray = (arr, key) => { + if(ArrayReplaceKeys.includes(key)) { + return true; + } + for(let i = 0; i < arrayReplaceKeyPathsMutable.length; ++i) { + const o = _.get(defaultConfig, arrayReplaceKeyPathsMutable[i]); + if(_.isEqual(o, arr)) { + arrayReplaceKeyPathsMutable.splice(i, 1); + return true; + } + } + return false; + }; + + async.waterfall( + [ + function mergeWithDefaultConfig(callback) { + const mergedConfig = _.mergeWith( + defaultConfig, + config, + (defConfig, userConfig, key) => { + if(Array.isArray(defConfig) && Array.isArray(userConfig)) { + // + // Arrays are special: Some we merge, while others + // we simply replace. + // + if(shouldReplaceArray(defConfig, key)) { + return userConfig; + } else { + return _.uniq(defConfig.concat(userConfig)); + } + } + } + ); + + return callback(null, mergedConfig); + }, + function validate(mergedConfig, callback) { + // + // Various sections must now exist in config + // + // :TODO: Logic is broken here: + if(hasMessageConferenceAndArea(mergedConfig)) { + return callback(Errors.MissingConfig('Please create at least one message conference and area!')); + } + return callback(null, mergedConfig); + }, + function setIt(mergedConfig, callback) { + // :TODO: .config property is to be deprecated once conversions are done + exports.config = currentConfiguration = mergedConfig; + + exports.get = () => currentConfiguration; + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); } function init(configPath, options, cb) { - if(!cb && _.isFunction(options)) { - cb = options; - options = {}; - } + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } - async.waterfall( - [ - function loadUserConfig(callback) { - if(!_.isString(configPath)) { - return callback(null, { } ); - } - - fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => { - if(err) { - return callback(err); - } - - let configJson; - try { - configJson = hjson.parse(configData, options); - } catch(e) { - return callback(e); - } + const changed = ( { fileName, fileRoot } ) => { + const reCachedPath = paths.join(fileRoot, fileName); + ConfigCache.getConfig(reCachedPath, (err, config) => { + if(!err) { + mergeValidateAndFinalize(config, err => { + if(!err) { + const Events = require('./events.js'); + Events.emit(Events.getSystemEvents().ConfigChanged); + } + }); + } else { + console.stdout(`Configuration ${reCachedPath} is invalid: ${err.message}`); // eslint-disable-line no-console + } + }); + }; - return callback(null, configJson); - }); - }, - function mergeWithDefaultConfig(configJson, callback) { - - const mergedConfig = _.mergeWith( - getDefaultConfig(), - configJson, (conf1, conf2) => { - // Arrays should always concat - if(_.isArray(conf1)) { - // :TODO: look for collisions & override dupes - return conf1.concat(conf2); - } - } - ); + const ConfigCache = require('./config_cache.js'); + const getConfigOptions = { + filePath : configPath, + noWatch : options.noWatch, + }; + if(!options.noWatch) { + getConfigOptions.callback = changed; + } + ConfigCache.getConfigWithOptions(getConfigOptions, (err, config) => { + if(err) { + return cb(err); + } - return callback(null, mergedConfig); - }, - function validate(mergedConfig, callback) { - // - // Various sections must now exist in config - // - // :TODO: Logic is broken here: - if(hasMessageConferenceAndArea(mergedConfig)) { - var msgAreasErr = new Error('Please create at least one message conference and area!'); - msgAreasErr.code = 'EBADCONFIG'; - return callback(msgAreasErr); - } else { - return callback(null, mergedConfig); - } - } - ], - function complete(err, mergedConfig) { - exports.config = mergedConfig; - - exports.config.get = function(path) { - return _.get(exports.config, path); - }; - - return cb(err); - } - ); + return mergeValidateAndFinalize(config, cb); + }); } function getDefaultPath() { - const base = miscUtil.resolvePath('~/'); - if(base) { - // e.g. /home/users/joeuser/.config/enigma-bbs/config.hjson - return paths.join(base, '.config', 'enigma-bbs', 'config.hjson'); - } + // e.g. /enigma-bbs-install-path/config/ + return './config/'; } function getDefaultConfig() { - return { - general : { - boardName : 'Another Fine ENiGMA½ BBS', + 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', - closedSystem : false, // is the system closed to new users? + // :TODO: closedSystem prob belongs under users{}? + closedSystem : false, // is the system closed to new users? - loginAttempts : 3, + menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path + promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path + achievementFile : 'achievements.hjson', + }, - menuFile : 'menu.hjson', // Override to use something else, e.g. demo.hjson. Can be a full path (defaults to ./mods) - promptFile : 'prompt.hjson', // Override to use soemthing else, e.g. myprompt.hjson. Can be a full path (defaults to ./mods) - }, + users : { + usernameMin : 2, + usernameMax : 16, // Note that FidoNet wants 36 max + usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+ .]+$', - // :TODO: see notes below about 'theme' section - move this! - preLoginTheme : 'luciano_blocktronics', + passwordMin : 6, + passwordMax : 128, - users : { - usernameMin : 2, - usernameMax : 16, // Note that FidoNet wants 36 max - usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+ ]+$', + // + // The bad password list is a text file containing a password per line. + // Entries in this list are not allowed to be used on the system as they + // are known to be too common. + // + // A great resource can be found at https://github.com/danielmiessler/SecLists + // + // Current list source: https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/probable-v2-top12000.txt + // + badPassFile : paths.join(__dirname, '../misc/bad_passwords.txt'), - passwordMin : 6, - passwordMax : 128, - badPassFile : paths.join(__dirname, '../misc/10_million_password_list_top_10000.txt'), // https://github.com/danielmiessler/SecLists + realNameMax : 32, + locationMax : 32, + affilsMax : 32, + emailMax : 255, + webMax : 255, - realNameMax : 32, - locationMax : 32, - affilsMax : 32, - emailMax : 255, - webMax : 255, + requireActivation : false, // require SysOp activation? false = auto-activate - requireActivation : false, // require SysOp activation? false = auto-activate - invalidUsernames : [], + groups : [ 'users', 'sysops' ], // built in groups + defaultGroups : [ 'users' ], // default groups new users belong to - groups : [ 'users', 'sysops' ], // built in groups - defaultGroups : [ 'users' ], // default groups new users belong to + newUserNames : [ 'new', 'apply' ], // Names reserved for applying - newUserNames : [ 'new', 'apply' ], // Names reserved for applying + badUserNames : [ + 'sysop', 'admin', 'administrator', 'root', 'all', + 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix', + 'server', 'client', 'notme' + ], - // :TODO: Mystic uses TRASHCAN.DAT for this -- is there a reason to support something like that? - badUserNames : [ 'sysop', 'admin', 'administrator', 'root', 'all' ], - }, + preAuthIdleLogoutSeconds : 60 * 3, // 3m + idleLogoutSeconds : 60 * 6, // 6m - // :TODO: better name for "defaults"... which is redundant here! - /* - Concept - "theme" : { - "default" : "defaultThemeName", // or "*" - "preLogin" : "*", - "passwordChar" : "*", - ... - } - */ - defaults : { - theme : 'luciano_blocktronics', - passwordChar : '*', // TODO: move to user ? - dateFormat : { - short : 'MM/DD/YYYY', - }, - timeFormat : { - short : 'h:mm a', - }, - dateTimeFormat : { - short : 'MM/DD/YYYY h:mm a', - } - }, + failedLogin : { + disconnect : 3, // 0=disabled + lockAccount : 9, // 0=disabled; Mark user status as "locked" if >= N + autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes. + }, + unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts - menus : { - cls : true, // Clear screen before each menu by default? - }, + twoFactorAuth : { + method : 'googleAuth', - paths : { - mods : paths.join(__dirname, './../mods/'), - loginServers : paths.join(__dirname, './servers/login/'), - contentServers : paths.join(__dirname, './servers/content/'), + 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'), + } + } + }, - scannerTossers : paths.join(__dirname, './scanner_tossers/'), - mailers : paths.join(__dirname, './mailers/') , + theme : { + default : 'luciano_blocktronics', + preLogin : 'luciano_blocktronics', - art : paths.join(__dirname, './../mods/art/'), - themes : paths.join(__dirname, './../mods/themes/'), - logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such - db : paths.join(__dirname, './../db/'), - modsDb : paths.join(__dirname, './../db/mods/'), - dropFiles : paths.join(__dirname, './../dropfiles/'), // + "/node/ - misc : paths.join(__dirname, './../misc/'), - }, - - loginServers : { - telnet : { - port : 8888, - enabled : true, - firstMenu : 'telnetConnected', - }, - ssh : { - port : 8889, - enabled : false, // defualt to false as PK/pass in config.hjson are required + passwordChar : '*', + dateFormat : { + short : 'MM/DD/YYYY', + long : 'ddd, MMMM Do, YYYY', + }, + timeFormat : { + short : 'h:mm a', + }, + dateTimeFormat : { + short : 'MM/DD/YYYY h:mm a', + long : 'ddd, MMMM Do, YYYY, h:mm a', + } + }, - // - // Private key in PEM format - // - // Generating your PK: - // > openssl genrsa -des3 -out ./misc/ssh_private_key.pem 2048 - // - // Then, set servers.ssh.privateKeyPass to the password you use above - // in your config.hjson - // - privateKeyPem : paths.join(__dirname, './../misc/ssh_private_key.pem'), - firstMenu : 'sshConnected', - firstMenuNewUser : 'sshConnectedNewUser', - }, - webSocket : { - port : 8810, // ws:// - enabled : false, - securePort : 8811, // wss:// - must provide certPem and keyPem - certPem : paths.join(__dirname, './../misc/https_cert.pem'), - keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'), - }, - }, + menus : { + cls : true, // Clear screen before each menu by default? + }, - contentServers : { - web : { - domain : 'another-fine-enigma-bbs.org', + 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/'), - staticRoot : paths.join(__dirname, './../www'), + scannerTossers : paths.join(__dirname, './scanner_tossers/'), + mailers : paths.join(__dirname, './mailers/') , - resetPassword : { - // - // The following templates have these variables available to them: - // - // * %BOARDNAME% : Name of BBS - // * %USERNAME% : Username of whom to reset password - // * %TOKEN% : Reset token - // * %RESET_URL% : In case of email, the link to follow for reset. In case of landing page, - // URL to POST submit reset form. + art : paths.join(__dirname, './../art/general/'), + themes : paths.join(__dirname, './../art/themes/'), + logs : paths.join(__dirname, './../logs/'), + db : paths.join(__dirname, './../db/'), + modsDb : paths.join(__dirname, './../db/mods/'), + dropFiles : paths.join(__dirname, './../drop/'), // + "/node/ + misc : paths.join(__dirname, './../misc/'), + }, - // templates for pw reset *email* - resetPassEmailText : paths.join(__dirname, '../misc/reset_password_email.template.txt'), // plain text version - resetPassEmailHtml : paths.join(__dirname, '../misc/reset_password_email.template.html'), // HTML version + loginServers : { + telnet : { + port : 8888, + enabled : true, + firstMenu : 'telnetConnected', + }, + ssh : { + port : 8889, + enabled : false, // default to false as PK/pass in config.hjson are required + // + // To enable SSH, perform the following steps: + // + // 1 - Generate a Private Key (PK): + // Currently ENiGMA 1/2 requires a PKCS#1 PEM formatted PK. + // To generate a secure PK, issue the following command: + // + // > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + // -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \ + // -out ./config/security/ssh_private_key.pem -aes128 + // + // (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 + // + // 3 - Finally, set 'enabled' to 'true' + // + // Additional reading: + // - https://blog.sleeplessbeastie.eu/2017/12/28/how-to-generate-private-key/ + // - https://gist.github.com/briansmith/2ee42439923d8e65a266994d0f70180b + // + privateKeyPem : paths.join(__dirname, './../config/security/ssh_private_key.pem'), + firstMenu : 'sshConnected', + firstMenuNewUser : 'sshConnectedNewUser', - // tempalte for pw reset *landing page* - // - resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'), - }, - - http : { - enabled : false, - port : 8080, - }, - https : { - enabled : false, - port : 8443, - certPem : paths.join(__dirname, './../misc/https_cert.pem'), - keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'), - } - } - }, + // + // SSH details that can affect security. Stronger ciphers are better for example, + // but terminals such as SyncTERM require KEX diffie-hellman-group14-sha1, + // cipher 3des-cbc, etc. + // + // See https://github.com/mscdex/ssh2-streams for the full list of supported + // algorithms. + // + algorithms : { + kex : [ + 'ecdh-sha2-nistp256', + 'ecdh-sha2-nistp384', + 'ecdh-sha2-nistp521', + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group-exchange-sha1', + 'diffie-hellman-group1-sha1', + ], + cipher : [ + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', + 'aes128-gcm', + 'aes128-gcm@openssh.com', + 'aes256-gcm', + 'aes256-gcm@openssh.com', + 'aes256-cbc', + 'aes192-cbc', + 'aes128-cbc', + 'blowfish-cbc', + '3des-cbc', + 'arcfour256', + 'arcfour128', + 'cast128-cbc', + 'arcfour', + ], + hmac : [ + 'hmac-sha2-256', + 'hmac-sha2-512', + 'hmac-sha1', + 'hmac-md5', + 'hmac-sha2-256-96', + 'hmac-sha2-512-96', + 'hmac-ripemd160', + 'hmac-sha1-96', + 'hmac-md5-96', + ], + // note that we disable compression by default due to issues with many clients. YMMV. + compress : [ 'none' ] + }, + }, + webSocket : { + ws : { + // non-secure ws:// + enabled : false, + port : 8810, + }, + wss : { + // secure ws:// + // must provide valid certPem and keyPem + enabled : false, + port : 8811, + certPem : paths.join(__dirname, './../config/https_cert.pem'), + keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), + }, + }, + }, - infoExtractUtils : { - Exiftool2Desc : { - cmd : `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x - }, - Exiftool : { - cmd : 'exiftool', - args : [ - '-charset', 'utf8', '{filePath}', - // exclude the following: - '--directory', '--filepermissions', '--exiftoolversion', '--filename', '--filesize', - '--filemodifydate', '--fileaccessdate', '--fileinodechangedate', '--createdate', '--modifydate', - '--metadatadate', '--xmptoolkit' - ] - } - }, + contentServers : { + web : { + domain : 'another-fine-enigma-bbs.org', - fileTypes : { - // - // File types explicitly known to the system. Here we can configure - // information extraction, archive treatment, etc. - // - // MIME types can be found in mime-db: https://github.com/jshttp/mime-db - // - // Resources for signature/magic bytes: - // * http://www.garykessler.net/library/file_sigs.html - // - // - // :TODO: text/x-ansi -> SAUCE extraction for .ans uploads - // :TODO: textual : bool -- if text, we can view. - // :TODO: asText : { cmd, args[] } -> viewable text + staticRoot : paths.join(__dirname, './../www'), - // - // Audio - // - 'audio/mpeg' : { - desc : 'MP3 Audio', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - 'application/pdf' : { - desc : 'Adobe PDF', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - // - // Video - // - 'video/mp4' : { - desc : 'MPEG Video', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - 'video/x-matroska ' : { - desc : 'Matroska Video', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - 'video/x-msvideo' : { - desc : 'Audio Video Interleave', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - // - // Images - // - 'image/jpeg' : { - desc : 'JPEG Image', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - 'image/png' : { - desc : 'Portable Network Graphic Image', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - 'image/gif' : { - desc : 'Graphics Interchange Format Image', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - 'image/webp' : { - desc : 'WebP Image', - shortDescUtil : 'Exiftool2Desc', - longDescUtil : 'Exiftool', - }, - // - // Archives - // - 'application/zip' : { - desc : 'ZIP Archive', - sig : '504b0304', - offset : 0, - archiveHandler : '7Zip', - }, - /* - 'application/x-cbr' : { - desc : 'Comic Book Archive', - sig : '504b0304', - }, - */ - 'application/x-arj' : { - desc : 'ARJ Archive', - sig : '60ea', - offset : 0, - archiveHandler : 'Arj', - }, - 'application/x-rar-compressed' : { - desc : 'RAR Archive', - sig : '526172211a0700', - offset : 0, - archiveHandler : 'Rar', - }, - 'application/gzip' : { - desc : 'Gzip Archive', - sig : '1f8b', - offset : 0, - archiveHandler : 'TarGz', - }, - // :TODO: application/x-bzip - 'application/x-bzip2' : { - desc : 'BZip2 Archive', - sig : '425a68', - offset : 0, - archiveHandler : '7Zip', - }, - 'application/x-lzh-compressed' : { - desc : 'LHArc Archive', - sig : '2d6c68', - offset : 2, - archiveHandler : 'Lha', - }, - 'application/x-7z-compressed' : { - desc : '7-Zip Archive', - sig : '377abcaf271c', - offset : 0, - archiveHandler : '7Zip', - } + resetPassword : { + // + // The following templates have these variables available to them: + // + // * %BOARDNAME% : Name of BBS + // * %USERNAME% : Username of whom to reset password + // * %TOKEN% : Reset token + // * %RESET_URL% : In case of email, the link to follow for reset. In case of landing page, + // URL to POST submit reset form. - // :TODO: update archives::formats to fall here - // * archive handler -> archiveHandler (consider archive if archiveHandler present) - // * sig, offset, ... - // * mime-db -> exts lookup - // * - }, - - archives : { - archivers : { - '7Zip' : { - compress : { - cmd : '7za', - args : [ 'a', '-tzip', '{archivePath}', '{fileList}' ], - }, - decompress : { - cmd : '7za', - args : [ 'e', '-o{extractPath}', '{archivePath}' ] // :TODO: should be 'x'? - }, - list : { - cmd : '7za', - args : [ 'l', '{archivePath}' ], - entryMatch : '^[0-9]{4}-[0-9]{2}-[0-9]{2}\\s[0-9]{2}:[0-9]{2}:[0-9]{2}\\s[A-Za-z\\.]{5}\\s+([0-9]+)\\s+[0-9]+\\s+([^\\r\\n]+)$', - }, - extract : { - cmd : '7za', - args : [ 'e', '-o{extractPath}', '{archivePath}', '{fileList}' ], - }, - }, + // templates for pw reset *email* + resetPassEmailText : paths.join(__dirname, '../misc/reset_password_email.template.txt'), // plain text version + resetPassEmailHtml : paths.join(__dirname, '../misc/reset_password_email.template.html'), // HTML version - Lha : { - // - // 'lha' command can be obtained from: - // * apt-get: lhasa - // - // (compress not currently supported) - // - decompress : { - cmd : 'lha', - args : [ '-ew={extractPath}', '{archivePath}' ], - }, - list : { - cmd : 'lha', - args : [ '-l', '{archivePath}' ], - entryMatch : '^[\\[a-z\\]]+(?:\\s+[0-9]+\\s+[0-9]+|\\s+)([0-9]+)\\s+[0-9]{2}\\.[0-9]\\%\\s+[A-Za-z]{3}\\s+[0-9]{1,2}\\s+[0-9]{4}\\s+([^\\r\\n]+)$', - }, - extract : { - cmd : 'lha', - args : [ '-ew={extractPath}', '{archivePath}', '{fileList}' ] - } - }, + // tempalte for pw reset *landing page* + // + resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'), + }, - Arj : { - // - // 'arj' command can be obtained from: - // * apt-get: arj - // - decompress : { - cmd : 'arj', - args : [ 'x', '{archivePath}', '{extractPath}' ], - }, - list : { - cmd : 'arj', - args : [ 'l', '{archivePath}' ], - entryMatch : '^([^\\s]+)\\s+([0-9]+)\\s+[0-9]+\\s[0-9\\.]+\\s+[0-9]{2}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\:[0-9]{2}\\s+(?:[^\\r\\n]+)$', - entryGroupOrder : { // defaults to { byteSize : 1, fileName : 2 } - fileName : 1, - byteSize : 2, - } - }, - extract : { - cmd : 'arj', - args : [ 'e', '{archivePath}', '{extractPath}', '{fileList}' ], - } - }, + http : { + enabled : false, + port : 8080, + }, + https : { + enabled : false, + port : 8443, + certPem : paths.join(__dirname, './../config/https_cert.pem'), + keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), + } + }, - Rar : { - decompress : { - cmd : 'unrar', - args : [ 'x', '{archivePath}', '{extractPath}' ], - }, - list : { - cmd : 'unrar', - args : [ 'l', '{archivePath}' ], - entryMatch : '^\\s+[\\.A-Z]+\\s+([\\d]+)\\s{2}[0-9]{2}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s{2}([^\\r\\n]+)$', - }, - extract : { - cmd : 'unrar', - args : [ 'e', '{archivePath}', '{extractPath}', '{fileList}' ], - } - }, + gopher : { + enabled : false, + port : 8070, + publicHostname : 'another-fine-enigma-bbs.org', + publicPort : 8070, // adjust if behind NAT/etc. + bannerFile : 'gopher_banner.asc', - TarGz : { - decompress : { - cmd : 'tar', - args : [ '-xf', '{archivePath}', '-C', '{extractPath}', '--strip-components=1' ], - }, - list : { - cmd : 'tar', - args : [ '-tvf', '{archivePath}' ], - entryMatch : '^[drwx\\-]{10}\\s[A-Za-z0-9\\/]+\\s+([0-9]+)\\s[0-9]{4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s([^\\r\\n]+)$', - }, - extract : { - cmd : 'tar', - args : [ '-xvf', '{archivePath}', '-C', '{extractPath}', '{fileList}' ], - } - } - }, - }, - - fileTransferProtocols : { - // - // See http://www.synchro.net/docs/sexyz.txt for information on SEXYZ - // - zmodem8kSexyz : { - name : 'ZModem 8k (SEXYZ)', - type : 'external', - sort : 1, - external : { - // :TODO: Look into shipping sexyz binaries or at least hosting them somewhere for common systems - sendCmd : 'sexyz', - sendArgs : [ '-telnet', '-8', 'sz', '@{fileListPath}' ], - recvCmd : 'sexyz', - recvArgs : [ '-telnet', '-8', 'rz', '{uploadDir}' ], - recvArgsNonBatch : [ '-telnet', '-8', 'rz', '{fileName}' ], - } - }, + // + // Set messageConferences{} to maps of confTag -> [ areaTag1, areaTag2, ... ] + // to export message confs/areas + // + }, - xmodemSexyz : { - name : 'XModem (SEXYZ)', - type : 'external', - sort : 3, - external : { - sendCmd : 'sexyz', - sendArgs : [ '-telnet', 'sX', '@{fileListPath}' ], - recvCmd : 'sexyz', - recvArgsNonBatch : [ '-telnet', 'rC', '{fileName}' ] - } - }, + nntp : { + // internal caching of groups, message lists, etc. + cache : { + maxItems : 200, + maxAge : 1000 * 30, // 30s + }, - ymodemSexyz : { - name : 'YModem (SEXYZ)', - type : 'external', - sort : 4, - external : { - sendCmd : 'sexyz', - sendArgs : [ '-telnet', 'sY', '@{fileListPath}' ], - recvCmd : 'sexyz', - recvArgs : [ '-telnet', 'ry', '{uploadDir}' ], - } - }, + // + // Set publicMessageConferences{} to a map of confTag -> [ areaTag1, areaTag2, ... ] + // in order to export *public* conf/areas that are available to anonymous + // NNTP users. Other conf/areas: Standard ACS rules apply. + // + publicMessageConferences: {}, - zmodem8kSz : { - name : 'ZModem 8k', - type : 'external', - sort : 2, - external : { - sendCmd : 'sz', // Avail on Debian/Ubuntu based systems as the package "lrzsz" - sendArgs : [ - // :TODO: try -q - '--zmodem', '--try-8k', '--binary', '--restricted', '{filePaths}' - ], - recvCmd : 'rz', // Avail on Debian/Ubuntu based systems as the package "lrzsz" - recvArgs : [ - '--zmodem', '--binary', '--restricted', '--keep-uppercase', // dumps to CWD which is set to {uploadDir} - ], - // :TODO: can we not just use --escape ? - escapeTelnet : true, // set to true to escape Telnet codes such as IAC - } - } - }, - - messageAreaDefaults : { - // - // The following can be override per-area as well - // - maxMessages : 1024, // 0 = unlimited - maxAgeDays : 0, // 0 = unlimited - }, + nntp : { + enabled : false, + port : 8119, + }, - messageConferences : { - system_internal : { - name : 'System Internal', - desc : 'Built in conference for private messages, bulletins, etc.', - - areas : { - private_mail : { - name : 'Private Mail', - desc : 'Private user to user mail/email', - }, + nntps : { + enabled : false, + port : 8563, + certPem : paths.join(__dirname, './../config/nntps_cert.pem'), + keyPem : paths.join(__dirname, './../config/nntps_key.pem'), + } + } + }, - local_bulletin : { - name : 'System Bulletins', - desc : 'Bulletin messages for all users', - } - } - } - }, - - scannerTossers : { - ftn_bso : { - paths : { - outbound : paths.join(__dirname, './../mail/ftn_out/'), - inbound : paths.join(__dirname, './../mail/ftn_in/'), - secInbound : paths.join(__dirname, './../mail/ftn_secin/'), - reject : paths.join(__dirname, './../mail/reject/'), // bad pkt, bundles, TIC attachments that fail any check, etc. - // set 'retain' to a valid path to keep good pkt files - }, + chatServers : { + mrc: { + enabled : false, + serverHostname : 'mrc.bottomlessabyss.net', + serverPort : 5000, + retryDelay : 10000, + multiplexerPort : 5000, + } + }, - // - // Packet and (ArcMail) bundle target sizes are just that: targets. - // Actual sizes may be slightly larger when we must place a full - // PKT contents *somewhere* - // - packetTargetByteSize : 512000, // 512k, before placing messages in a new pkt - bundleTargetByteSize : 2048000, // 2M, before creating another archive - packetMsgEncoding : 'utf8', // default packet encoding. Override per node if desired. - packetAnsiMsgEncoding : 'cp437', // packet encoding for *ANSI ART* messages + infoExtractUtils : { + Exiftool2Desc : { + cmd : `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x + }, + Exiftool : { + cmd : 'exiftool', + args : [ + '-charset', 'utf8', '{filePath}', + // exclude the following: + '--directory', '--filepermissions', '--exiftoolversion', '--filename', '--filesize', + '--filemodifydate', '--fileaccessdate', '--fileinodechangedate', '--createdate', '--modifydate', + '--metadatadate', '--xmptoolkit' + ] + }, + XDMS2Desc : { + // http://manpages.ubuntu.com/manpages/trusty/man1/xdms.1.html + cmd : 'xdms', + args : [ 'd', '{filePath}' ] + }, + XDMS2LongDesc : { + // http://manpages.ubuntu.com/manpages/trusty/man1/xdms.1.html + cmd : 'xdms', + args : [ 'f', '{filePath}' ] + }, + }, - tic : { - secureInOnly : true, // only bring in from secure inbound (|secInbound| path, password protected) - uploadBy : 'ENiGMA TIC', // default upload by username (override @ network) - allowReplace : false, // use "Replaces" TIC field - } - } - }, + fileTypes : { + // + // File types explicitly known to the system. Here we can configure + // information extraction, archive treatment, etc. + // + // MIME types can be found in mime-db: https://github.com/jshttp/mime-db + // + // Resources for signature/magic bytes: + // * http://www.garykessler.net/library/file_sigs.html + // + // + // :TODO: text/x-ansi -> SAUCE extraction for .ans uploads + // :TODO: textual : bool -- if text, we can view. + // :TODO: asText : { cmd, args[] } -> viewable text - fileBase: { - // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|: - areaStoragePrefix : paths.join(__dirname, './../file_base/'), + // + // Audio + // + 'audio/mpeg' : { + desc : 'MP3 Audio', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + 'application/pdf' : { + desc : 'Adobe PDF', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + // + // Video + // + 'video/mp4' : { + desc : 'MPEG Video', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + 'video/x-matroska ' : { + desc : 'Matroska Video', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + 'video/x-msvideo' : { + desc : 'Audio Video Interleave', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + // + // Images + // + 'image/jpeg' : { + desc : 'JPEG Image', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + 'image/png' : { + desc : 'Portable Network Graphic Image', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + 'image/gif' : { + desc : 'Graphics Interchange Format Image', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + 'image/webp' : { + desc : 'WebP Image', + shortDescUtil : 'Exiftool2Desc', + longDescUtil : 'Exiftool', + }, + // + // Archives + // + 'application/zip' : { + desc : 'ZIP Archive', + sig : '504b0304', + offset : 0, + archiveHandler : 'InfoZip', + }, + /* + 'application/x-cbr' : { + desc : 'Comic Book Archive', + sig : '504b0304', + }, + */ + 'application/x-arj' : { + desc : 'ARJ Archive', + sig : '60ea', + offset : 0, + archiveHandler : 'Arj', + }, + 'application/x-rar-compressed' : { + desc : 'RAR Archive', + sig : '526172211a07', + offset : 0, + archiveHandler : 'Rar', + }, + 'application/gzip' : { + desc : 'Gzip Archive', + sig : '1f8b', + offset : 0, + archiveHandler : 'TarGz', + }, + // :TODO: application/x-bzip + 'application/x-bzip2' : { + desc : 'BZip2 Archive', + sig : '425a68', + offset : 0, + archiveHandler : '7Zip', + }, + 'application/x-lzh-compressed' : { + desc : 'LHArc Archive', + sig : '2d6c68', + offset : 2, + archiveHandler : 'Lha', + }, + 'application/x-lzx' : { + desc : 'LZX Archive', + sig : '4c5a5800', + offset : 0, + archiveHandler : 'Lzx', + }, + 'application/x-7z-compressed' : { + desc : '7-Zip Archive', + sig : '377abcaf271c', + offset : 0, + archiveHandler : '7Zip', + }, - maxDescFileByteSize : 471859, // ~1/4 MB - maxDescLongFileByteSize : 524288, // 1/2 MB + // + // Generics that need further mapping + // + 'application/octet-stream' : [ + { + desc : 'Amiga DISKMASHER', + sig : '444d5321', // DMS! + ext : '.dms', + shortDescUtil : 'XDMS2Desc', + longDescUtil : 'XDMS2LongDesc', + }, + { + desc : 'SIO2PC Atari Disk Image', + sig : '9602', // 16bit sum of "NICKATARI" + ext : '.atr', + archiveHandler : 'Atr', + } + ] + }, - fileNamePatterns: { - // These are NOT case sensitive - // FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ - desc : [ - '^[^/\]*FILE_ID\.DIZ$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$' - ], + archives : { + archivers : { + '7Zip' : { // p7zip package + compress : { + cmd : '7za', + args : [ 'a', '-tzip', '{archivePath}', '{fileList}' ], + }, + decompress : { + cmd : '7za', + args : [ 'e', '-o{extractPath}', '{archivePath}' ] // :TODO: should be 'x'? + }, + list : { + cmd : '7za', + args : [ 'l', '{archivePath}' ], + entryMatch : '^[0-9]{4}-[0-9]{2}-[0-9]{2}\\s[0-9]{2}:[0-9]{2}:[0-9]{2}\\s[A-Za-z\\.]{5}\\s+([0-9]+)\\s+[0-9]+\\s+([^\\r\\n]+)$', + }, + extract : { + cmd : '7za', + args : [ 'e', '-o{extractPath}', '{archivePath}', '{fileList}' ], + }, + }, - // common README filename - https://en.wikipedia.org/wiki/README - descLong : [ - '^[^/\]*\.NFO$', '^[^/\]*README\.1ST$', '^[^/\]*README\.NOW$', '^[^/\]*README\.TXT$', '^[^/\]*READ\.ME$', '^[^/\]*README$', '^[^/\]*README\.md$' - ], - }, + InfoZip: { + compress : { + cmd : 'zip', + args : [ '{archivePath}', '{fileList}' ], + }, + decompress : { + cmd : 'unzip', + args : [ '-n', '{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 : [ '-n', '{archivePath}', '{fileList}', '-d', '{extractPath}' ], + } + }, - yearEstPatterns: [ - // - // Patterns should produce the year in the first submatch. - // The extracted year may be YY or YYYY - // - '\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]|[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', // yyyy-mm-dd, m/d/yyyy, mm-dd-yyyy, etc. - "\\b('[1789][0-9])\\b", // eslint-disable-line quotes - '\\b[0-3]?[0-9][\\-\\/\\.](?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)[\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', - '\\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december),?\\s[0-9]+(?:st|nd|rd|th)?,?\\s((?:[0-9]{2})?[0-9]{2})\\b', // November 29th, 1997 - '\\(((?:19|20)[0-9]{2})\\)', // (19xx) or (20xx) -- do this before 19xx 20xx such that this has priority - '\\b((?:19|20)[0-9]{2})\\b', // simple 19xx or 20xx with word boundaries - // :TODO: DD/MMM/YY, DD/MMMM/YY, DD/MMM/YYYY, etc. - ], + Lha : { + // + // 'lha' command can be obtained from: + // * apt-get: lhasa + // + // (compress not currently supported) + // + decompress : { + cmd : 'lha', + args : [ '-efw={extractPath}', '{archivePath}' ], + }, + list : { + cmd : 'lha', + args : [ '-l', '{archivePath}' ], + entryMatch : '^[\\[a-z\\]]+(?:\\s+[0-9]+\\s+[0-9]+|\\s+)([0-9]+)\\s+[0-9]{2}\\.[0-9]\\%\\s+[A-Za-z]{3}\\s+[0-9]{1,2}\\s+[0-9]{4}\\s+([^\\r\\n]+)$', + }, + extract : { + cmd : 'lha', + args : [ '-efw={extractPath}', '{archivePath}', '{fileList}' ] + } + }, - web : { - path : '/f/', - routePath : '/f/[a-zA-Z0-9]+$', - expireMinutes : 1440, // 1 day - }, + Lzx : { + // + // 'unlzx' command can be obtained from: + // * Debian based: https://launchpad.net/~rzr/+archive/ubuntu/ppa/+build/2486127 (amd64/x86_64) + // * RedHat: https://fedora.pkgs.org/28/rpm-sphere/unlzx-1.1-4.1.x86_64.rpm.html + // * Source: http://xavprods.free.fr/lzx/ + // + decompress : { + cmd : 'unlzx', + // unzlx doesn't have a output dir option, but we'll cwd to the temp output dir first + args : [ '-x', '{archivePath}' ], + }, + list : { + cmd : 'unlzx', + args : [ '-v', '{archivePath}' ], + entryMatch : '^\\s+([0-9]+)\\s+[^\\s]+\\s+[0-9]{2}:[0-9]{2}:[0-9]{2}\\s+[0-9]{1,2}-[a-z]{3}-[0-9]{4}\\s+[a-z\\-]+\\s+\\"([^"]+)\\"$', + } + }, - // - // File area storage location tag/value pairs. - // Non-absolute paths are relative to |areaStoragePrefix|. - // - storageTags : { - sys_msg_attach : 'msg_attach', - }, + Arj : { + // + // 'arj' command can be obtained from: + // * apt-get: arj + // + decompress : { + cmd : 'arj', + args : [ 'x', '{archivePath}', '{extractPath}' ], + }, + list : { + cmd : 'arj', + args : [ 'l', '{archivePath}' ], + entryMatch : '^([^\\s]+)\\s+([0-9]+)\\s+[0-9]+\\s[0-9\\.]+\\s+[0-9]{2}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\:[0-9]{2}\\s+(?:[^\\r\\n]+)$', + entryGroupOrder : { // defaults to { byteSize : 1, fileName : 2 } + fileName : 1, + byteSize : 2, + } + }, + extract : { + cmd : 'arj', + args : [ 'e', '{archivePath}', '{extractPath}', '{fileList}' ], + } + }, - areas: { - system_message_attachment : { - name : 'Message attachments', - desc : 'File attachments to messages', - storageTags : 'sys_msg_attach', // may be string or array of strings - } - } - }, - - eventScheduler : { - - events : { - trimMessageAreas : { - // may optionally use [or ]@watch:/path/to/file - schedule : 'every 24 hours', - - // action: - // - @method:path/to/module.js:theMethodName - // (path is relative to engima base dir) - // - // - @execute:/path/to/something/executable.sh - // - action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', - }, + Rar : { + decompress : { + cmd : 'unrar', + args : [ 'x', '{archivePath}', '{extractPath}' ], + }, + list : { + cmd : 'unrar', + args : [ 'l', '{archivePath}' ], + entryMatch : '^\\s+[\\.A-Z]+\\s+([\\d]+)\\s{2}[0-9]{2,4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s{2}([^\\r\\n]+)$', + }, + extract : { + cmd : 'unrar', + args : [ 'e', '{archivePath}', '{extractPath}', '{fileList}' ], + } + }, - forgotPasswordMaintenance : { - schedule : 'every 24 hours', - action : '@method:core/web_password_reset.js:performMaintenanceTask', - args : [ '24 hours' ] // items older than this will be removed - } - } - }, + TarGz : { + decompress : { + cmd : 'tar', + args : [ '-xf', '{archivePath}', '-C', '{extractPath}', '--strip-components=1' ], + }, + list : { + cmd : 'tar', + args : [ '-tvf', '{archivePath}' ], + entryMatch : '^[drwx\\-]{10}\\s[A-Za-z0-9\\/]+\\s+([0-9]+)\\s[0-9]{4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s([^\\r\\n]+)$', + }, + extract : { + cmd : 'tar', + args : [ '-xvf', '{archivePath}', '-C', '{extractPath}', '{fileList}' ], + } + }, - misc : { - preAuthIdleLogoutSeconds : 60 * 3, // 2m - idleLogoutSeconds : 60 * 6, // 6m - }, + Atr : { + decompress : { + cmd : 'atr', + args : [ '{archivePath}', 'x', '-a', '-o', '{extractPath}' ] + }, + list : { + cmd : 'atr', + args : [ '{archivePath}', 'ls', '-la1' ], + entryMatch : '^[rwxs-]{5}\\s+([0-9]+)\\s\\([0-9\\s]+\\)\\s([^\\r\\n\\s]*)(?:[^\\r\\n]+)?$', + }, + extract : { + cmd : 'atr', + // note: -l converts Atari 0x9b line feeds to 0x0a; not ideal if we're dealing with a binary of course. + args : [ '{archivePath}', 'x', '-a', '-l', '-o', '{extractPath}', '{fileList}' ] + } + } + }, + }, - logging : { - level : 'debug', + fileTransferProtocols : { + // + // See http://www.synchro.net/docs/sexyz.txt for information on SEXYZ + // + zmodem8kSexyz : { + name : 'ZModem 8k (SEXYZ)', + type : 'external', + sort : 1, + external : { + // :TODO: Look into shipping sexyz binaries or at least hosting them somewhere for common systems + // Linux x86_64 binary: https://l33t.codes/outgoing/sexyz + sendCmd : 'sexyz', + sendArgs : [ '-telnet', '-8', 'sz', '@{fileListPath}' ], + recvCmd : 'sexyz', + recvArgs : [ '-telnet', '-8', 'rz', '{uploadDir}' ], + recvArgsNonBatch : [ '-telnet', '-8', 'rz', '{fileName}' ], + } + }, - rotatingFile : { // set to 'disabled' or false to disable - type : 'rotating-file', - fileName : 'enigma-bbs.log', - period : '1d', - count : 3, - level : 'debug', - } + xmodemSexyz : { + name : 'XModem (SEXYZ)', + type : 'external', + sort : 3, + external : { + sendCmd : 'sexyz', + sendArgs : [ '-telnet', 'sX', '@{fileListPath}' ], + recvCmd : 'sexyz', + recvArgsNonBatch : [ '-telnet', 'rC', '{fileName}' ] + } + }, - // :TODO: syslog - https://github.com/mcavage/node-bunyan-syslog - }, + ymodemSexyz : { + name : 'YModem (SEXYZ)', + type : 'external', + sort : 4, + external : { + sendCmd : 'sexyz', + sendArgs : [ '-telnet', 'sY', '@{fileListPath}' ], + recvCmd : 'sexyz', + recvArgs : [ '-telnet', 'ry', '{uploadDir}' ], + } + }, - debug : { - assertsEnabled : false, - } - }; + zmodem8kSz : { + name : 'ZModem 8k', + type : 'external', + sort : 2, + external : { + sendCmd : 'sz', // Avail on Debian/Ubuntu based systems as the package "lrzsz" + sendArgs : [ + // :TODO: try -q + '--zmodem', '--try-8k', '--binary', '--restricted', '{filePaths}' + ], + recvCmd : 'rz', // Avail on Debian/Ubuntu based systems as the package "lrzsz" + recvArgs : [ + '--zmodem', '--binary', '--restricted', '--keep-uppercase', // dumps to CWD which is set to {uploadDir} + ], + processIACs : true, // escape/de-escape IACs (0xff) + } + } + }, + + messageAreaDefaults : { + // + // The following can be override per-area as well + // + maxMessages : 1024, // 0 = unlimited + maxAgeDays : 0, // 0 = unlimited + }, + + messageConferences : { + system_internal : { + name : 'System Internal', + desc : 'Built in conference for private messages, bulletins, etc.', + + areas : { + private_mail : { + name : 'Private Mail', + desc : 'Private user to user mail/email', + maxExternalSentAgeDays : 30, // max external "outbox" item age + }, + + local_bulletin : { + name : 'System Bulletins', + desc : 'Bulletin messages for all users', + } + } + } + }, + + scannerTossers : { + ftn_bso : { + paths : { + outbound : paths.join(__dirname, './../mail/ftn_out/'), + inbound : paths.join(__dirname, './../mail/ftn_in/'), + secInbound : paths.join(__dirname, './../mail/ftn_secin/'), + reject : paths.join(__dirname, './../mail/reject/'), // bad pkt, bundles, TIC attachments that fail any check, etc. + //outboundNetMail : paths.join(__dirname, './../mail/ftn_netmail_out/'), + // set 'retain' to a valid path to keep good pkt files + }, + + // + // Packet and (ArcMail) bundle target sizes are just that: targets. + // Actual sizes may be slightly larger when we must place a full + // PKT contents *somewhere* + // + packetTargetByteSize : 512000, // 512k, before placing messages in a new pkt + bundleTargetByteSize : 2048000, // 2M, before creating another archive + packetMsgEncoding : 'utf8', // default packet encoding. Override per node if desired. + packetAnsiMsgEncoding : 'cp437', // packet encoding for *ANSI ART* messages + + tic : { + secureInOnly : true, // only bring in from secure inbound (|secInbound| path, password protected) + uploadBy : 'ENiGMA TIC', // default upload by username (override @ network) + allowReplace : false, // use "Replaces" TIC field + descPriority : 'diz', // May be diz=.DIZ/etc., or tic=from TIC Ldesc + } + } + }, + + fileBase: { + // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|: + areaStoragePrefix : paths.join(__dirname, './../file_base/'), + + maxDescFileByteSize : 471859, // ~1/4 MB + maxDescLongFileByteSize : 524288, // 1/2 MB + + fileNamePatterns: { + // These are NOT case sensitive + // FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ + // Some groups include a FILE_ID.ANS. We try to use that over FILE_ID.DIZ if available. + desc : [ + '^.*FILE_ID\.ANS$', '^.*FILE_ID\.DIZ$', // eslint-disable-line no-useless-escape + '^.*DESC\.SDI$', // eslint-disable-line no-useless-escape + '^.*DESCRIPT\.ION$', // eslint-disable-line no-useless-escape + '^.*FILE\.DES$', // eslint-disable-line no-useless-escape + '^.*FILE\.SDI$', // eslint-disable-line no-useless-escape + '^.*DISK\.ID$' // eslint-disable-line no-useless-escape + ], + + // common README filename - https://en.wikipedia.org/wiki/README + descLong : [ + '^[^/\]*\.NFO$', // eslint-disable-line no-useless-escape + '^.*README\.1ST$', // eslint-disable-line no-useless-escape + '^.*README\.NOW$', // eslint-disable-line no-useless-escape + '^.*README\.TXT$', // eslint-disable-line no-useless-escape + '^.*READ\.ME$', // eslint-disable-line no-useless-escape + '^.*README$', // eslint-disable-line no-useless-escape + '^.*README\.md$', // eslint-disable-line no-useless-escape + '^RELEASE-INFO.ASC$' // eslint-disable-line no-useless-escape + ], + }, + + yearEstPatterns: [ + // + // Patterns should produce the year in the first submatch. + // The extracted year may be YY or YYYY + // + '\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]\\b', // yyyy-mm-dd, yyyy/mm/dd, ... + '\\b[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[1-2][0-9][0-9]{2}))\\b', // mm/dd/yyyy, mm.dd.yyyy, ... + '\\b((?:[1789][0-9]))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]\\b', // yy-mm-dd, yy-mm-dd, ... + '\\b[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[1789][0-9]))\\b', // mm-dd-yy, mm/dd/yy, ... + //'\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]|[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', // yyyy-mm-dd, m/d/yyyy, mm-dd-yyyy, etc. + //"\\b('[1789][0-9])\\b", // eslint-disable-line quotes + '\\b[0-3]?[0-9][\\-\\/\\.](?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)[\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', + '\\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december),?\\s[0-9]+(?:st|nd|rd|th)?,?\\s((?:[0-9]{2})?[0-9]{2})\\b', // November 29th, 1997 + '\\(((?:19|20)[0-9]{2})\\)', // (19xx) or (20xx) -- with parens -- do this before 19xx 20xx such that this has priority + '\\b((?:19|20)[0-9]{2})\\b', // simple 19xx or 20xx with word boundaries + '\\b\'([17-9][0-9])\\b', // '95, '17, ... + // :TODO: DD/MMM/YY, DD/MMMM/YY, DD/MMM/YYYY, etc. + ], + + web : { + path : '/f/', + routePath : '/f/[a-zA-Z0-9]+$', + expireMinutes : 1440, // 1 day + }, + + // + // File area storage location tag/value pairs. + // Non-absolute paths are relative to |areaStoragePrefix|. + // + storageTags : { + sys_msg_attach : 'sys_msg_attach', + sys_temp_download : 'sys_temp_download', + }, + + areas: { + system_message_attachment : { + name : 'System Message Attachments', + desc : 'File attachments to messages', + storageTags : [ 'sys_msg_attach' ], + }, + + system_temporary_download : { + name : 'System Temporary Downloads', + desc : 'Temporary downloadables', + storageTags : [ 'sys_temp_download' ], + } + } + }, + + eventScheduler : { + + events : { + dailyMaintenance : { + schedule : 'at 11:59pm', + action : '@method:core/misc_scheduled_events.js:dailyMaintenanceScheduledEvent', + }, + trimMessageAreas : { + // may optionally use [or ]@watch:/path/to/file + schedule : 'every 24 hours', + + // action: + // - @method:path/to/module.js:theMethodName + // (path is relative to ENiGMA base dir) + // + // - @execute:/path/to/something/executable.sh + // + action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', + }, + + nntpMaintenance : { + schedule : 'every 12 hours', // should generally be < trimMessageAreas interval + action : '@method:core/servers/content/nntp.js:performMaintenanceTask', + }, + + updateFileAreaStats : { + schedule : 'every 1 hours', + action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent', + }, + + forgotPasswordMaintenance : { + schedule : 'every 24 hours', + action : '@method:core/web_password_reset.js:performMaintenanceTask', + 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 + // + /* + updateDescriptIonFiles : { + schedule : 'on the last day of the week', + action : '@method:core/file_base_list_export.js:updateFileBaseDescFilesScheduledEvent', + } + */ + } + }, + + logging : { + rotatingFile : { // set to 'disabled' or false to disable + type : 'rotating-file', + fileName : 'enigma-bbs.log', + period : '1d', + count : 3, + level : 'debug', + } + + // :TODO: syslog - https://github.com/mcavage/node-bunyan-syslog + }, + + debug : { + assertsEnabled : false, + }, + + statLog : { + systemEvents : { + loginHistoryMax: -1, // set to -1 for forever + } + }, + }; } diff --git a/core/config_cache.js b/core/config_cache.js index 9df08316..62c6bb55 100644 --- a/core/config_cache.js +++ b/core/config_cache.js @@ -1,85 +1,76 @@ /* jslint node: true */ 'use strict'; -var Config = require('./config.js').config; -var Log = require('./logger.js').log; +// deps +const paths = require('path'); +const fs = require('graceful-fs'); +const hjson = require('hjson'); +const sane = require('sane'); -var paths = require('path'); -var fs = require('graceful-fs'); -var Gaze = require('gaze').Gaze; -var events = require('events'); -var util = require('util'); -var assert = require('assert'); -var hjson = require('hjson'); -var _ = require('lodash'); +module.exports = new class ConfigCache +{ + constructor() { + this.cache = new Map(); // path->parsed config + } -function ConfigCache() { - events.EventEmitter.call(this); + getConfigWithOptions(options, cb) { + const cached = this.cache.has(options.filePath); - var self = this; - this.cache = {}; // filePath -> HJSON - this.gaze = new Gaze(); + if(options.forceReCache || !cached) { + this.recacheConfigFromFile(options.filePath, (err, config) => { + if(!err && !cached) { + if(!options.noWatch) { + const watcher = sane( + paths.dirname(options.filePath), + { + glob : `**/${paths.basename(options.filePath)}` + } + ); - this.reCacheConfigFromFile = function(filePath, cb) { - fs.readFile(filePath, { encoding : 'utf-8' }, function fileRead(err, data) { - try { - self.cache[filePath] = hjson.parse(data); - cb(null, self.cache[filePath]); - } catch(e) { - Log.error( { filePath : filePath, error : e.toString() }, 'Failed recaching'); - cb(e); - } - }); - }; + watcher.on('change', (fileName, fileRoot) => { + require('./logger.js').log.info( { fileName, fileRoot }, 'Configuration file changed; re-caching'); + this.recacheConfigFromFile(paths.join(fileRoot, fileName), err => { + if(!err) { + if(options.callback) { + options.callback( { fileName, fileRoot } ); + } + } + }); + }); + } + } + return cb(err, config, true); + }); + } else { + return cb(null, this.cache.get(options.filePath), false); + } + } - this.gaze.on('error', function gazeErr(err) { + getConfig(filePath, cb) { + return this.getConfigWithOptions( { filePath }, cb); + } - }); + recacheConfigFromFile(path, cb) { + fs.readFile(path, { encoding : 'utf-8' }, (err, data) => { + if(err) { + return cb(err); + } - this.gaze.on('changed', function fileChanged(filePath) { - assert(filePath in self.cache); + let parsed; + try { + parsed = hjson.parse(data); + this.cache.set(path, parsed); + } catch(e) { + try { + require('./logger.js').log.error( { filePath : path, error : e.message }, 'Failed to re-cache' ); + } catch(ignored) { + // nothing - we may be failing to parse the config in which we can't log here! + } + return cb(e); + } - Log.info( { path : filePath }, 'Configuration file changed; re-caching'); - - self.reCacheConfigFromFile(filePath, function reCached(err) { - if(err) { - Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration'); - } else { - self.emit('recached', filePath); - } - }); - }); - -} - -util.inherits(ConfigCache, events.EventEmitter); - -ConfigCache.prototype.getConfigWithOptions = function(options, cb) { - assert(_.isString(options.filePath)); - - var self = this; - var isCached = (options.filePath in this.cache); - - if(options.forceReCache || !isCached) { - this.reCacheConfigFromFile(options.filePath, function fileCached(err, config) { - if(!err && !isCached) { - self.gaze.add(options.filePath); - } - cb(err, config, true); - }); - } else { - cb(null, this.cache[options.filePath], false); - } + return cb(null, parsed); + }); + } }; - - -ConfigCache.prototype.getConfig = function(filePath, cb) { - this.getConfigWithOptions( { filePath : filePath }, cb); -}; - -ConfigCache.prototype.getModConfig = function(fileName, cb) { - this.getConfig(paths.join(Config.paths.mods, fileName), cb); -}; - -module.exports = exports = new ConfigCache(); \ No newline at end of file diff --git a/core/config_util.js b/core/config_util.js index f078f758..d64c7a24 100644 --- a/core/config_util.js +++ b/core/config_util.js @@ -1,19 +1,67 @@ /* jslint node: true */ 'use strict'; -var configCache = require('./config_cache.js'); +const Config = require('./config.js').get; +const ConfigCache = require('./config_cache.js'); +const Events = require('./events.js'); -var paths = require('path'); +// deps +const paths = require('path'); +const async = require('async'); -exports.getFullConfig = getFullConfig; +exports.init = init; +exports.getConfigPath = getConfigPath; +exports.getFullConfig = getFullConfig; + +function getConfigPath(filePath) { + // |filePath| is assumed to be in the config path if it's only a file name + if('.' === paths.dirname(filePath)) { + filePath = paths.join(Config().paths.config, filePath); + } + return filePath; +} + +function init(cb) { + // pre-cache menu.hjson and prompt.hjson + establish events + const changed = ( { fileName, fileRoot } ) => { + const reCachedPath = paths.join(fileRoot, fileName); + if(reCachedPath === getConfigPath(Config().general.menuFile)) { + Events.emit(Events.getSystemEvents().MenusChanged); + } else if(reCachedPath === getConfigPath(Config().general.promptFile)) { + Events.emit(Events.getSystemEvents().PromptsChanged); + } + }; + + const config = Config(); + async.series( + [ + function menu(callback) { + return ConfigCache.getConfigWithOptions( + { + filePath : getConfigPath(config.general.menuFile), + callback : changed, + }, + callback + ); + }, + function prompt(callback) { + return ConfigCache.getConfigWithOptions( + { + filePath : getConfigPath(config.general.promptFile), + callback : changed, + }, + callback + ); + } + ], + err => { + return cb(err); + } + ); +} function getFullConfig(filePath, cb) { - // |filePath| is assumed to be in 'mods' if it's only a file name - if('.' === paths.dirname(filePath)) { - filePath = paths.join(__dirname, '../mods', filePath); - } - - configCache.getConfig(filePath, function loaded(err, configJson) { - cb(err, configJson); - }); -} \ No newline at end of file + ConfigCache.getConfig(getConfigPath(filePath), (err, config) => { + return cb(err, config); + }); +} diff --git a/core/connect.js b/core/connect.js index b94fa586..64c4ea3e 100644 --- a/core/connect.js +++ b/core/connect.js @@ -1,187 +1,265 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const ansi = require('./ansi_term.js'); -const Events = require('./events.js'); +// ENiGMA½ +const ansi = require('./ansi_term.js'); +const Events = require('./events.js'); +const { Errors } = require('./enig_error.js'); -// deps -const async = require('async'); +// deps +const async = require('async'); -exports.connectEntry = connectEntry; +exports.connectEntry = connectEntry; function ansiDiscoverHomePosition(client, cb) { - // - // We want to find the home position. ANSI-BBS and most terminals - // utilize 1,1 as home. However, some terminals such as ConnectBot - // think of home as 0,0. If this is the case, we need to offset - // our positioning to accomodate for such. - // - const done = function(err) { - client.removeListener('cursor position report', cprListener); - clearTimeout(giveUpTimer); - return cb(err); - }; + // + // We want to find the home position. ANSI-BBS and most terminals + // utilize 1,1 as home. However, some terminals such as ConnectBot + // think of home as 0,0. If this is the case, we need to offset + // our positioning to accommodate for such. + // + const done = (err) => { + client.removeListener('cursor position report', cprListener); + clearTimeout(giveUpTimer); + return cb(err); + }; - const cprListener = function(pos) { - const h = pos[0]; - const w = pos[1]; + const cprListener = function(pos) { + const h = pos[0]; + const w = pos[1]; - // - // We expect either 0,0, or 1,1. Anything else will be filed as bad data - // - if(h > 1 || w > 1) { - client.log.warn( { height : h, width : w }, 'Ignoring ANSI home position CPR due to unexpected values'); - return done(new Error('Home position CPR expected to be 0,0, or 1,1')); - } + // + // We expect either 0,0, or 1,1. Anything else will be filed as bad data + // + if(h > 1 || w > 1) { + client.log.warn( { height : h, width : w }, 'Ignoring ANSI home position CPR due to unexpected values'); + return done(Errors.UnexpectedState('Home position CPR expected to be 0,0, or 1,1')); + } - if(0 === h & 0 === w) { - // - // Store a CPR offset in the client. All CPR's from this point on will offset by this amount - // - client.log.info('Setting CPR offset to 1'); - client.cprOffset = 1; - } + if(0 === h & 0 === w) { + // + // Store a CPR offset in the client. All CPR's from this point on will offset by this amount + // + client.log.info('Setting CPR offset to 1'); + client.cprOffset = 1; + } - return done(null); - }; + return done(null); + }; - client.once('cursor position report', cprListener); + client.once('cursor position report', cprListener); - const giveUpTimer = setTimeout( () => { - return done(new Error('Giving up on home position CPR')); - }, 3000); // 3s + const giveUpTimer = setTimeout( () => { + return done(Errors.General('Giving up on home position CPR')); + }, 3000); // 3s - client.term.write(`${ansi.goHome()}${ansi.queryPos()}`); // go home, query pos + client.term.write(`${ansi.goHome()}${ansi.queryPos()}`); // go home, query pos +} + +function ansiAttemptDetectUTF8(client, cb) { + // + // Trick to attempt and detect UTF-8. While there is a lot more than + // just UTF-8 and CP437, many those are the main concerns, when it comes + // terminals that for example tell us they are "xterm" but still want CP437. + // + // Try to detect UTF-8 by discovering the cursor position, writing some + // multi-byte UTF-8, and checking the position again. If the term is really + // UTF-8, we should get a proper position, otherwise we'll be further out. + // + // We currently only do this if the term hasn't already been ID'd as a + // "*nix" terminal -- that is, xterm, etc. + // + if(!client.term.isNixTerm()) { + return cb(null); + } + + let posStage = 1; + let initialPosition; + let giveUpTimer; + + const giveUp = () => { + client.removeListener('cursor position report', cprListener); + clearTimeout(giveUpTimer); + return cb(null); + }; + + const ASCIIPortion = ' Character encoding detection '; + + const cprListener = (pos) => { + switch(posStage) { + case 1 : + posStage = 2; + + initialPosition = pos; + clearTimeout(giveUpTimer); + + giveUpTimer = setTimeout( () => { + return giveUp(); + }, 2000); + + client.once('cursor position report', cprListener); + client.term.rawWrite(`\u9760${ASCIIPortion}\u9760`); // Unicode skulls on each side + client.term.rawWrite(ansi.queryPos()); + break; + + case 2 : + { + clearTimeout(giveUpTimer); + const len = pos[1] - initialPosition[1]; + if(!isNaN(len) && len >= ASCIIPortion.length + 6) { // CP437 displays 3 chars each Unicode skull + client.log.info('Terminal identified as UTF-8 but does not appear to be. Overriding to "ansi".'); + client.setTermType('ansi'); + } + } + return cb(null); + } + }; + + giveUpTimer = setTimeout( () => { + return giveUp(); + }, 2000); + + client.once('cursor position report', cprListener); + client.term.rawWrite(ansi.goHome() + ansi.queryPos()); } function ansiQueryTermSizeIfNeeded(client, cb) { - if(client.term.termHeight > 0 || client.term.termWidth > 0) { - return cb(null); - } + if(client.term.termHeight > 0 || client.term.termWidth > 0) { + return cb(null); + } - const done = function(err) { - client.removeListener('cursor position report', cprListener); - clearTimeout(giveUpTimer); - return cb(err); - }; + const done = function(err) { + client.removeListener('cursor position report', cprListener); + clearTimeout(giveUpTimer); + return cb(err); + }; - const cprListener = function(pos) { - // - // If we've already found out, disregard - // - if(client.term.termHeight > 0 || client.term.termWidth > 0) { - return done(null); - } + const cprListener = function(pos) { + // + // If we've already found out, disregard + // + if(client.term.termHeight > 0 || client.term.termWidth > 0) { + return done(null); + } - const h = pos[0]; - const w = pos[1]; + const h = pos[0]; + const w = pos[1]; - // - // Netrunner for example gives us 1x1 here. Not really useful. Ignore - // values that seem obviously bad. - // - if(h < 10 || w < 10) { - client.log.warn( - { height : h, width : w }, - 'Ignoring ANSI CPR screen size query response due to very small values'); - return done(new Error('Term size <= 10 considered invalid')); - } + // + // NetRunner for example gives us 1x1 here. Not really useful. Ignore + // values that seem obviously bad. Included in the set is the explicit + // 999x999 values we asked to move to. + // + if(h < 10 || h === 999 || w < 10 || w === 999) { + client.log.warn( + { height : h, width : w }, + 'Ignoring ANSI CPR screen size query response due to non-sane values'); + return done(Errors.Invalid('Term size <= 10 considered invalid')); + } - client.term.termHeight = h; - client.term.termWidth = w; + client.term.termHeight = h; + client.term.termWidth = w; - client.log.debug( - { - termWidth : client.term.termWidth, - termHeight : client.term.termHeight, - source : 'ANSI CPR' - }, - 'Window size updated' - ); + client.log.debug( + { + termWidth : client.term.termWidth, + termHeight : client.term.termHeight, + source : 'ANSI CPR' + }, + 'Window size updated' + ); - return done(null); - }; + return done(null); + }; - client.once('cursor position report', cprListener); + client.once('cursor position report', cprListener); - // give up after 2s - const giveUpTimer = setTimeout( () => { - return done(new Error('No term size established by CPR within timeout')); - }, 2000); + // give up after 2s + const giveUpTimer = setTimeout( () => { + return done(Errors.General('No term size established by CPR within timeout')); + }, 2000); - // Start the process: Query for CPR - client.term.rawWrite(ansi.queryScreenSize()); + // Start the process: + // 1 - Ask to goto 999,999 -- a very much "bottom right" (generally 80x25 for example + // is the real size) + // 2 - Query for screen size with bansi.txt style specialized Device Status Report (DSR) + // request. We expect a CPR of: + // a - Terms that support bansi.txt style: Screen size + // b - Terms that do not support bansi.txt style: Since we moved to the bottom right + // we should still be able to determine a screen size. + // + client.term.rawWrite(`${ansi.goto(999, 999)}${ansi.queryScreenSize()}`); } function prepareTerminal(term) { - term.rawWrite(ansi.normal()); - //term.rawWrite(ansi.disableVT100LineWrapping()); - // :TODO: set xterm stuff -- see x84/others + term.rawWrite(`${ansi.normal()}${ansi.clearScreen()}`); } function displayBanner(term) { - // note: intentional formatting: - term.pipeWrite(` + // note: intentional formatting: + term.pipeWrite(` |06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN -|06Copyright (c) 2014-2017 Bryan Ashby |14- |12http://l33t.codes/ +|06Copyright (c) 2014-2020 Bryan Ashby |14- |12http://l33t.codes/ |06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/ |00` - ); + ); } function connectEntry(client, nextMenu) { - const term = client.term; + const term = client.term; - async.series( - [ - function basicPrepWork(callback) { - term.rawWrite(ansi.queryDeviceAttributes(0)); - return callback(null); - }, - function discoverHomePosition(callback) { - ansiDiscoverHomePosition(client, () => { - // :TODO: If CPR for home fully fails, we should bail out on the connection with an error, e.g. ANSI support required - return callback(null); // we try to continue anyway - }); - }, - function queryTermSizeByNonStandardAnsi(callback) { - ansiQueryTermSizeIfNeeded(client, err => { - if(err) { - // - // Check again; We may have got via NAWS/similar before CPR completed. - // - if(0 === term.termHeight || 0 === term.termWidth) { - // - // We still don't have something good for term height/width. - // Default to DOS size 80x25. - // - // :TODO: Netrunner is currenting hitting this and it feels wrong. Why is NAWS/ENV/CPR all failing??? - client.log.warn( { reason : err.message }, 'Failed to negotiate term size; Defaulting to 80x25!'); + async.series( + [ + function basicPrepWork(callback) { + term.rawWrite(ansi.queryDeviceAttributes(0)); + return callback(null); + }, + function discoverHomePosition(callback) { + ansiDiscoverHomePosition(client, () => { + // :TODO: If CPR for home fully fails, we should bail out on the connection with an error, e.g. ANSI support required + return callback(null); // we try to continue anyway + }); + }, + function queryTermSizeByNonStandardAnsi(callback) { + ansiQueryTermSizeIfNeeded(client, err => { + if(err) { + // + // Check again; We may have got via NAWS/similar before CPR completed. + // + if(0 === term.termHeight || 0 === term.termWidth) { + // + // We still don't have something good for term height/width. + // Default to DOS size 80x25. + // + // :TODO: Netrunner is currently hitting this and it feels wrong. Why is NAWS/ENV/CPR all failing??? + client.log.warn( { reason : err.message }, 'Failed to negotiate term size; Defaulting to 80x25!'); - term.termHeight = 25; - term.termWidth = 80; - } - } + term.termHeight = 25; + term.termWidth = 80; + } + } - return callback(null); - }); - }, - ], - () => { - prepareTerminal(term); + return callback(null); + }); + }, + function checkUtf8IfNeeded(callback) { + return ansiAttemptDetectUTF8(client, callback); + } + ], + () => { + prepareTerminal(term); - // - // Always show an ENiGMA½ banner - // - displayBanner(term); + // + // Always show an ENiGMA½ banner + // + displayBanner(term); - // fire event - Events.emit('codes.l33t.enigma.system.term_detected', { client : client } ); + // fire event + Events.emit(Events.getSystemEvents().TermDetected, { client : client } ); - setTimeout( () => { - return client.menuStack.goto(nextMenu); - }, 500); - } - ); + setTimeout( () => { + return client.menuStack.goto(nextMenu); + }, 500); + } + ); } diff --git a/core/cp437util.js b/core/cp437util.js new file mode 100644 index 00000000..32425d3a --- /dev/null +++ b/core/cp437util.js @@ -0,0 +1,55 @@ + + +const CP437UnicodeTable = [ + '\u0000', '\u0001', '\u0002', '\u0003', '\u0004', '\u0005', '\u0006', + '\u0007', '\u0008', '\u0009', '\u000A', '\u000B', '\u000C', '\u000D', + '\u000E', '\u000F', '\u0010', '\u0011', '\u0012', '\u0013', '\u0014', + '\u0015', '\u0016', '\u0017', '\u0018', '\u0019', '\u001A', '\u001B', + '\u001C', '\u001D', '\u001E', '\u001F', '\u0020', '\u0021', '\u0022', + '\u0023', '\u0024', '\u0025', '\u0026', '\u0027', '\u0028', '\u0029', + '\u002A', '\u002B', '\u002C', '\u002D', '\u002E', '\u002F', '\u0030', + '\u0031', '\u0032', '\u0033', '\u0034', '\u0035', '\u0036', '\u0037', + '\u0038', '\u0039', '\u003A', '\u003B', '\u003C', '\u003D', '\u003E', + '\u003F', '\u0040', '\u0041', '\u0042', '\u0043', '\u0044', '\u0045', + '\u0046', '\u0047', '\u0048', '\u0049', '\u004A', '\u004B', '\u004C', + '\u004D', '\u004E', '\u004F', '\u0050', '\u0051', '\u0052', '\u0053', + '\u0054', '\u0055', '\u0056', '\u0057', '\u0058', '\u0059', '\u005A', + '\u005B', '\u005C', '\u005D', '\u005E', '\u005F', '\u0060', '\u0061', + '\u0062', '\u0063', '\u0064', '\u0065', '\u0066', '\u0067', '\u0068', + '\u0069', '\u006A', '\u006B', '\u006C', '\u006D', '\u006E', '\u006F', + '\u0070', '\u0071', '\u0072', '\u0073', '\u0074', '\u0075', '\u0076', + '\u0077', '\u0078', '\u0079', '\u007A', '\u007B', '\u007C', '\u007D', + '\u007E', '\u007F', '\u00C7', '\u00FC', '\u00E9', '\u00E2', '\u00E4', + '\u00E0', '\u00E5', '\u00E7', '\u00EA', '\u00EB', '\u00E8', '\u00EF', + '\u00EE', '\u00EC', '\u00C4', '\u00C5', '\u00C9', '\u00E6', '\u00C6', + '\u00F4', '\u00F6', '\u00F2', '\u00FB', '\u00F9', '\u00FF', '\u00D6', + '\u00DC', '\u00A2', '\u00A3', '\u00A5', '\u20A7', '\u0192', '\u00E1', + '\u00ED', '\u00F3', '\u00FA', '\u00F1', '\u00D1', '\u00AA', '\u00BA', + '\u00BF', '\u2310', '\u00AC', '\u00BD', '\u00BC', '\u00A1', '\u00AB', + '\u00BB', '\u2591', '\u2592', '\u2593', '\u2502', '\u2524', '\u2561', + '\u2562', '\u2556', '\u2555', '\u2563', '\u2551', '\u2557', '\u255D', + '\u255C', '\u255B', '\u2510', '\u2514', '\u2534', '\u252C', '\u251C', + '\u2500', '\u253C', '\u255E', '\u255F', '\u255A', '\u2554', '\u2569', + '\u2566', '\u2560', '\u2550', '\u256C', '\u2567', '\u2568', '\u2564', + '\u2565', '\u2559', '\u2558', '\u2552', '\u2553', '\u256B', '\u256A', + '\u2518', '\u250C', '\u2588', '\u2584', '\u258C', '\u2590', '\u2580', + '\u03B1', '\u00DF', '\u0393', '\u03C0', '\u03A3', '\u03C3', '\u00B5', + '\u03C4', '\u03A6', '\u0398', '\u03A9', '\u03B4', '\u221E', '\u03C6', + '\u03B5', '\u2229', '\u2261', '\u00B1', '\u2265', '\u2264', '\u2320', + '\u2321', '\u00F7', '\u2248', '\u00B0', '\u2219', '\u00B7', '\u221A', + '\u207F', '\u00B2', '\u25A0', '\u00A0' +]; + +const NonCP437EncodableRegExp = /[^\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000A\u000B\u000C\u000D\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F\u0020\u0021\u0022\u0023\u0024\u0025\u0026\u0027\u0028\u0029\u002A\u002B\u002C\u002D\u002E\u002F\u0030\u0031\u0032\u0033\u0034\u0035\u0036\u0037\u0038\u0039\u003A\u003B\u003C\u003D\u003E\u003F\u0040\u0041\u0042\u0043\u0044\u0045\u0046\u0047\u0048\u0049\u004A\u004B\u004C\u004D\u004E\u004F\u0050\u0051\u0052\u0053\u0054\u0055\u0056\u0057\u0058\u0059\u005A\u005B\u005C\u005D\u005E\u005F\u0060\u0061\u0062\u0063\u0064\u0065\u0066\u0067\u0068\u0069\u006A\u006B\u006C\u006D\u006E\u006F\u0070\u0071\u0072\u0073\u0074\u0075\u0076\u0077\u0078\u0079\u007A\u007B\u007C\u007D\u007E\u007F\u00C7\u00FC\u00E9\u00E2\u00E4\u00E0\u00E5\u00E7\u00EA\u00EB\u00E8\u00EF\u00EE\u00EC\u00C4\u00C5\u00C9\u00E6\u00C6\u00F4\u00F6\u00F2\u00FB\u00F9\u00FF\u00D6\u00DC\u00A2\u00A3\u00A5\u20A7\u0192\u00E1\u00ED\u00F3\u00FA\u00F1\u00D1\u00AA\u00BA\u00BF\u2310\u00AC\u00BD\u00BC\u00A1\u00AB\u00BB\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255D\u255C\u255B\u2510\u2514\u2534\u252C\u251C\u2500\u253C\u255E\u255F\u255A\u2554\u2569\u2566\u2560\u2550\u256C\u2567\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256B\u256A\u2518\u250C\u2588\u2584\u258C\u2590\u2580\u03B1\u00DF\u0393\u03C0\u03A3\u03C3\u00B5\u03C4\u03A6\u0398\u03A9\u03B4\u221E\u03C6\u03B5\u2229\u2261\u00B1\u2265\u2264\u2320\u2321\u00F7\u2248\u00B0\u2219\u00B7\u221A\u207F\u00B2\u25A0\u00A0]/; +const isCP437Encodable = (s) => { + if (!s.length) { + return true; + } + + return !NonCP437EncodableRegExp.test(s); +} + +module.exports = { + CP437UnicodeTable, + isCP437Encodable, +} \ No newline at end of file diff --git a/core/crc.js b/core/crc.js index 886dad1d..f90ac961 100644 --- a/core/crc.js +++ b/core/crc.js @@ -1,54 +1,91 @@ /* jslint node: true */ 'use strict'; -const CRC32_TABLE = new Int32Array( - '00000000 77073096 EE0E612C 990951BA 076DC419 706AF48F E963A535 9E6495A3 0EDB8832 79DCB8A4 E0D5E91E 97D2D988 09B64C2B 7EB17CBD E7B82D07 90BF1D91 1DB71064 6AB020F2 F3B97148 84BE41DE 1ADAD47D 6DDDE4EB F4D4B551 83D385C7 136C9856 646BA8C0 FD62F97A 8A65C9EC 14015C4F 63066CD9 FA0F3D63 8D080DF5 3B6E20C8 4C69105E D56041E4 A2677172 3C03E4D1 4B04D447 D20D85FD A50AB56B 35B5A8FA 42B2986C DBBBC9D6 ACBCF940 32D86CE3 45DF5C75 DCD60DCF ABD13D59 26D930AC 51DE003A C8D75180 BFD06116 21B4F4B5 56B3C423 CFBA9599 B8BDA50F 2802B89E 5F058808 C60CD9B2 B10BE924 2F6F7C87 58684C11 C1611DAB B6662D3D 76DC4190 01DB7106 98D220BC EFD5102A 71B18589 06B6B51F 9FBFE4A5 E8B8D433 7807C9A2 0F00F934 9609A88E E10E9818 7F6A0DBB 086D3D2D 91646C97 E6635C01 6B6B51F4 1C6C6162 856530D8 F262004E 6C0695ED 1B01A57B 8208F4C1 F50FC457 65B0D9C6 12B7E950 8BBEB8EA FCB9887C 62DD1DDF 15DA2D49 8CD37CF3 FBD44C65 4DB26158 3AB551CE A3BC0074 D4BB30E2 4ADFA541 3DD895D7 A4D1C46D D3D6F4FB 4369E96A 346ED9FC AD678846 DA60B8D0 44042D73 33031DE5 AA0A4C5F DD0D7CC9 5005713C 270241AA BE0B1010 C90C2086 5768B525 206F85B3 B966D409 CE61E49F 5EDEF90E 29D9C998 B0D09822 C7D7A8B4 59B33D17 2EB40D81 B7BD5C3B C0BA6CAD EDB88320 9ABFB3B6 03B6E20C 74B1D29A EAD54739 9DD277AF 04DB2615 73DC1683 E3630B12 94643B84 0D6D6A3E 7A6A5AA8 E40ECF0B 9309FF9D 0A00AE27 7D079EB1 F00F9344 8708A3D2 1E01F268 6906C2FE F762575D 806567CB 196C3671 6E6B06E7 FED41B76 89D32BE0 10DA7A5A 67DD4ACC F9B9DF6F 8EBEEFF9 17B7BE43 60B08ED5 D6D6A3E8 A1D1937E 38D8C2C4 4FDFF252 D1BB67F1 A6BC5767 3FB506DD 48B2364B D80D2BDA AF0A1B4C 36034AF6 41047A60 DF60EFC3 A867DF55 316E8EEF 4669BE79 CB61B38C BC66831A 256FD2A0 5268E236 CC0C7795 BB0B4703 220216B9 5505262F C5BA3BBE B2BD0B28 2BB45A92 5CB36A04 C2D7FFA7 B5D0CF31 2CD99E8B 5BDEAE1D 9B64C2B0 EC63F226 756AA39C 026D930A 9C0906A9 EB0E363F 72076785 05005713 95BF4A82 E2B87A14 7BB12BAE 0CB61B38 92D28E9B E5D5BE0D 7CDCEFB7 0BDBDF21 86D3D2D4 F1D4E242 68DDB3F8 1FDA836E 81BE16CD F6B9265B 6FB077E1 18B74777 88085AE6 FF0F6A70 66063BCA 11010B5C 8F659EFF F862AE69 616BFFD3 166CCF45 A00AE278 D70DD2EE 4E048354 3903B3C2 A7672661 D06016F7 4969474D 3E6E77DB AED16A4A D9D65ADC 40DF0B66 37D83BF0 A9BCAE53 DEBB9EC5 47B2CF7F 30B5FFE9 BDBDF21C CABAC28A 53B39330 24B4A3A6 BAD03605 CDD70693 54DE5729 23D967BF B3667A2E C4614AB8 5D681B02 2A6F2B94 B40BBE37 C30C8EA1 5A05DF1B 2D02EF8D'.split(' ').map(s => parseInt(s, 16))); +const CRC32_TABLE = new Int32Array([ + 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, + 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, + 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, + 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, + 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, + 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, + 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, + 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, + 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, + 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, + 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, + 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, + 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, + 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, + 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, + 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, + 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, + 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, + 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, + 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, + 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, + 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, + 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, + 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, + 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, + 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, + 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, + 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, + 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, + 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, + 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, + 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, + 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, + 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, + 0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, + 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, + 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d +]); exports.CRC32 = class CRC32 { - constructor() { - this.crc = -1; - } + constructor() { + this.crc = -1; + } - update(input) { - input = Buffer.isBuffer(input) ? input : Buffer.from(input, 'binary'); - return input.length > 10240 ? this.update_8(input) : this.update_4(input); - } + update(input) { + input = Buffer.isBuffer(input) ? input : Buffer.from(input, 'binary'); + return input.length > 10240 ? this.update_8(input) : this.update_4(input); + } - update_4(input) { - const len = input.length - 3; - let i = 0; + update_4(input) { + const len = input.length - 3; + let i = 0; - for(i = 0; i < len;) { - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - } - while(i < len + 3) { - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ]; - } - } + for(i = 0; i < len;) { + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + } + while(i < len + 3) { + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ]; + } + } - update_8(input) { - const len = input.length - 7; - let i = 0; + update_8(input) { + const len = input.length - 7; + let i = 0; - for(i = 0; i < len;) { - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; - } - while(i < len + 7) { - this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ]; - } - } - - finalize() { - return (this.crc ^ (-1)) >>> 0; - } + for(i = 0; i < len;) { + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ]; + } + while(i < len + 7) { + this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ]; + } + } + + finalize() { + return (this.crc ^ (-1)) >>> 0; + } }; diff --git a/core/database.js b/core/database.js index d4fb4795..e0e58168 100644 --- a/core/database.js +++ b/core/database.js @@ -1,373 +1,449 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const conf = require('./config.js'); +// ENiGMA½ +const conf = require('./config.js'); -// deps -const sqlite3 = require('sqlite3'); -const paths = require('path'); -const async = require('async'); -const _ = require('lodash'); -const assert = require('assert'); -const moment = require('moment'); +// deps +const sqlite3 = require('sqlite3'); +const sqlite3Trans = require('sqlite3-trans'); +const paths = require('path'); +const async = require('async'); +const _ = require('lodash'); +const assert = require('assert'); +const moment = require('moment'); -// database handles -let dbs = {}; +// database handles +const dbs = {}; -exports.getModDatabasePath = getModDatabasePath; -exports.getISOTimestampString = getISOTimestampString; -exports.initializeDatabases = initializeDatabases; +exports.getTransactionDatabase = getTransactionDatabase; +exports.getModDatabasePath = getModDatabasePath; +exports.loadDatabaseForMod = loadDatabaseForMod; +exports.getISOTimestampString = getISOTimestampString; +exports.sanitizeString = sanitizeString; +exports.initializeDatabases = initializeDatabases; -exports.dbs = dbs; +exports.dbs = dbs; + +function getTransactionDatabase(db) { + return sqlite3Trans.wrap(db); +} function getDatabasePath(name) { - return paths.join(conf.config.paths.db, `${name}.sqlite3`); + return paths.join(conf.config.paths.db, `${name}.sqlite3`); } function getModDatabasePath(moduleInfo, suffix) { - // - // Mods that use a database are stored in Config.paths.modsDb (e.g. enigma-bbs/db/mods) - // We expect that moduleInfo defines packageName which will be the base of the modules - // filename. An optional suffix may be supplied as well. - // - const HOST_RE = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; + // + // Mods that use a database are stored in Config.paths.modsDb (e.g. enigma-bbs/db/mods) + // We expect that moduleInfo defines packageName which will be the base of the modules + // filename. An optional suffix may be supplied as well. + // + const HOST_RE = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; - assert(_.isObject(moduleInfo)); - assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!'); - - let full = moduleInfo.packageName; - if(suffix) { - full += `.${suffix}`; - } + assert(_.isObject(moduleInfo)); + assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!'); - assert( - (full.split('.').length > 1 && HOST_RE.test(full)), - 'packageName must follow Reverse Domain Name Notation - https://en.wikipedia.org/wiki/Reverse_domain_name_notation'); + let full = moduleInfo.packageName; + if(suffix) { + full += `.${suffix}`; + } - return paths.join(conf.config.paths.modsDb, `${full}.sqlite3`); + assert( + (full.split('.').length > 1 && HOST_RE.test(full)), + 'packageName must follow Reverse Domain Name Notation - https://en.wikipedia.org/wiki/Reverse_domain_name_notation'); + + return paths.join(conf.config.paths.modsDb, `${full}.sqlite3`); +} + +function loadDatabaseForMod(modInfo, cb) { + const db = getTransactionDatabase(new sqlite3.Database( + getModDatabasePath(modInfo), + err => { + return cb(err, db); + } + )); } function getISOTimestampString(ts) { - ts = ts || moment(); - return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + ts = ts || moment(); + if(!moment.isMoment(ts)) { + if(_.isString(ts)) { + ts = ts.replace(/\//g, '-'); + } + ts = moment(ts); + } + return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); +} + +function sanitizeString(s) { + return s.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, c => { // eslint-disable-line no-control-regex + switch (c) { + case '\0' : return '\\0'; + case '\x08' : return '\\b'; + case '\x09' : return '\\t'; + case '\x1a' : return '\\z'; + case '\n' : return '\\n'; + case '\r' : return '\\r'; + + case '"' : + case '\'' : + return `${c}${c}`; + + case '\\' : + case '%' : + return `\\${c}`; + } + }); } function initializeDatabases(cb) { - async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { - dbs[dbName] = new sqlite3.Database(getDatabasePath(dbName), err => { - if(err) { - return cb(err); - } + async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { + dbs[dbName] = sqlite3Trans.wrap(new sqlite3.Database(getDatabasePath(dbName), err => { + if(err) { + return cb(err); + } - dbs[dbName].serialize( () => { - DB_INIT_TABLE[dbName]( () => { - return next(null); - }); - }); - }); - }, err => { - return cb(err); - }); + dbs[dbName].serialize( () => { + DB_INIT_TABLE[dbName]( () => { + return next(null); + }); + }); + })); + }, err => { + return cb(err); + }); } function enableForeignKeys(db) { - db.run('PRAGMA foreign_keys = ON;'); + db.run('PRAGMA foreign_keys = ON;'); } const DB_INIT_TABLE = { - system : (cb) => { - enableForeignKeys(dbs.system); + system : (cb) => { + enableForeignKeys(dbs.system); - // Various stat/event logging - see stat_log.js - dbs.system.run( - `CREATE TABLE IF NOT EXISTS system_stat ( - stat_name VARCHAR PRIMARY KEY NOT NULL, - stat_value VARCHAR NOT NULL - );` - ); + // Various stat/event logging - see stat_log.js + dbs.system.run( + `CREATE TABLE IF NOT EXISTS system_stat ( + stat_name VARCHAR PRIMARY KEY NOT NULL, + stat_value VARCHAR NOT NULL + );` + ); - dbs.system.run( - `CREATE TABLE IF NOT EXISTS system_event_log ( - id INTEGER PRIMARY KEY, - timestamp DATETIME NOT NULL, - log_name VARCHAR NOT NULL, - log_value VARCHAR NOT NULL, + dbs.system.run( + `CREATE TABLE IF NOT EXISTS system_event_log ( + id INTEGER PRIMARY KEY, + timestamp DATETIME NOT NULL, + log_name VARCHAR NOT NULL, + log_value VARCHAR NOT NULL, - UNIQUE(timestamp, log_name) - );` - ); + UNIQUE(timestamp, log_name) + );` + ); - dbs.system.run( - `CREATE TABLE IF NOT EXISTS user_event_log ( - id INTEGER PRIMARY KEY, - timestamp DATETIME NOT NULL, - user_id INTEGER NOT NULL, - log_name VARCHAR NOT NULL, - log_value VARCHAR NOT NULL, + dbs.system.run( + `CREATE TABLE IF NOT EXISTS user_event_log ( + id INTEGER PRIMARY KEY, + timestamp DATETIME NOT NULL, + user_id INTEGER NOT NULL, + session_id VARCHAR NOT NULL, + log_name VARCHAR NOT NULL, + log_value VARCHAR NOT NULL, - UNIQUE(timestamp, user_id, log_name) - );` - ); + UNIQUE(timestamp, user_id, session_id, log_name) + );` + ); - return cb(null); - }, + return cb(null); + }, - user : (cb) => { - enableForeignKeys(dbs.user); + user : (cb) => { + enableForeignKeys(dbs.user); - dbs.user.run( - `CREATE TABLE IF NOT EXISTS user ( - id INTEGER PRIMARY KEY, - user_name VARCHAR NOT NULL, - UNIQUE(user_name) - );` - ); + dbs.user.run( + `CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY, + user_name VARCHAR NOT NULL, + UNIQUE(user_name) + );` + ); - // :TODO: create FK on delete/etc. + // :TODO: create FK on delete/etc. - dbs.user.run( - `CREATE TABLE IF NOT EXISTS user_property ( - user_id INTEGER NOT NULL, - prop_name VARCHAR NOT NULL, - prop_value VARCHAR, - UNIQUE(user_id, prop_name), - FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE - );` - ); + dbs.user.run( + `CREATE TABLE IF NOT EXISTS user_property ( + user_id INTEGER NOT NULL, + prop_name VARCHAR NOT NULL, + prop_value VARCHAR, + UNIQUE(user_id, prop_name), + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE + );` + ); - dbs.user.run( - `CREATE TABLE IF NOT EXISTS user_group_member ( - group_name VARCHAR NOT NULL, - user_id INTEGER NOT NULL, - UNIQUE(group_name, user_id) - );` - ); + dbs.user.run( + `CREATE TABLE IF NOT EXISTS user_group_member ( + group_name VARCHAR NOT NULL, + user_id INTEGER NOT NULL, + UNIQUE(group_name, user_id) + );` + ); - dbs.user.run( - `CREATE TABLE IF NOT EXISTS user_login_history ( - user_id INTEGER NOT NULL, - user_name VARCHAR NOT NULL, - timestamp DATETIME NOT NULL - );` - ); + dbs.user.run( + `CREATE TABLE IF NOT EXISTS user_achievement ( + user_id INTEGER NOT NULL, + achievement_tag VARCHAR NOT NULL, + timestamp DATETIME NOT NULL, + match VARCHAR NOT NULL, + title VARCHAR NOT NULL, + text VARCHAR NOT NULL, + points INTEGER NOT NULL, + UNIQUE(user_id, achievement_tag, match), + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE + );` + ); - return cb(null); - }, + // + // 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 + );` + ); - message : (cb) => { - enableForeignKeys(dbs.message); + return cb(null); + }, - dbs.message.run( - `CREATE TABLE IF NOT EXISTS message ( - message_id INTEGER PRIMARY KEY, - area_tag VARCHAR NOT NULL, - message_uuid VARCHAR(36) NOT NULL, - reply_to_message_id INTEGER, - to_user_name VARCHAR NOT NULL, - from_user_name VARCHAR NOT NULL, - subject, /* FTS @ message_fts */ - message, /* FTS @ message_fts */ - modified_timestamp DATETIME NOT NULL, - view_count INTEGER NOT NULL DEFAULT 0, - UNIQUE(message_uuid) - );` - ); + message : (cb) => { + enableForeignKeys(dbs.message); - dbs.message.run( - `CREATE INDEX IF NOT EXISTS message_by_area_tag_index - ON message (area_tag);` - ); + dbs.message.run( + `CREATE TABLE IF NOT EXISTS message ( + message_id INTEGER PRIMARY KEY, + area_tag VARCHAR NOT NULL, + message_uuid VARCHAR(36) NOT NULL, + reply_to_message_id INTEGER, + to_user_name VARCHAR NOT NULL, + from_user_name VARCHAR NOT NULL, + subject, /* FTS @ message_fts */ + message, /* FTS @ message_fts */ + modified_timestamp DATETIME NOT NULL, + view_count INTEGER NOT NULL DEFAULT 0, + UNIQUE(message_uuid) + );` + ); - dbs.message.run( - `CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4 ( - content="message", - subject, - message - );` - ); + dbs.message.run( + `CREATE INDEX IF NOT EXISTS message_by_area_tag_index + ON message (area_tag);` + ); - dbs.message.run( - `CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN - DELETE FROM message_fts WHERE docid=old.rowid; - END;` - ); - - dbs.message.run( - `CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN - DELETE FROM message_fts WHERE docid=old.rowid; - END;` - ); + dbs.message.run( + `CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4 ( + content="message", + subject, + message + );` + ); - dbs.message.run( - `CREATE TRIGGER IF NOT EXISTS message_after_update AFTER UPDATE ON message BEGIN - INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); - END;` - ); + dbs.message.run( + `CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN + DELETE FROM message_fts WHERE docid=old.rowid; + END;` + ); - dbs.message.run( - `CREATE TRIGGER IF NOT EXISTS message_after_insert AFTER INSERT ON message BEGIN - INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); - END;` - ); + dbs.message.run( + `CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN + DELETE FROM message_fts WHERE docid=old.rowid; + END;` + ); - dbs.message.run( - `CREATE TABLE IF NOT EXISTS message_meta ( - message_id INTEGER NOT NULL, - meta_category INTEGER NOT NULL, - meta_name VARCHAR NOT NULL, - meta_value VARCHAR NOT NULL, - UNIQUE(message_id, meta_category, meta_name, meta_value), - FOREIGN KEY(message_id) REFERENCES message(message_id) ON DELETE CASCADE - );` - ); + dbs.message.run( + `CREATE TRIGGER IF NOT EXISTS message_after_update AFTER UPDATE ON message BEGIN + INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); + END;` + ); + + dbs.message.run( + `CREATE TRIGGER IF NOT EXISTS message_after_insert AFTER INSERT ON message BEGIN + INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); + END;` + ); + + dbs.message.run( + `CREATE TABLE IF NOT EXISTS message_meta ( + message_id INTEGER NOT NULL, + meta_category INTEGER NOT NULL, + meta_name VARCHAR NOT NULL, + meta_value VARCHAR NOT NULL, + UNIQUE(message_id, meta_category, meta_name, meta_value), + FOREIGN KEY(message_id) REFERENCES message(message_id) ON DELETE CASCADE + );` + ); - // :TODO: need SQL to ensure cleaned up if delete from message? - /* - dbs.message.run( - `CREATE TABLE IF NOT EXISTS hash_tag ( - hash_tag_id INTEGER PRIMARY KEY, - hash_tag_name VARCHAR NOT NULL, - UNIQUE(hash_tag_name) - );` - ); + // :TODO: need SQL to ensure cleaned up if delete from message? + /* + dbs.message.run( + `CREATE TABLE IF NOT EXISTS hash_tag ( + hash_tag_id INTEGER PRIMARY KEY, + hash_tag_name VARCHAR NOT NULL, + UNIQUE(hash_tag_name) + );` + ); - // :TODO: need SQL to ensure cleaned up if delete from message? - dbs.message.run( - `CREATE TABLE IF NOT EXISTS message_hash_tag ( - hash_tag_id INTEGER NOT NULL, - message_id INTEGER NOT NULL, - );` - ); - */ + // :TODO: need SQL to ensure cleaned up if delete from message? + dbs.message.run( + `CREATE TABLE IF NOT EXISTS message_hash_tag ( + hash_tag_id INTEGER NOT NULL, + message_id INTEGER NOT NULL, + );` + ); + */ - dbs.message.run( - `CREATE TABLE IF NOT EXISTS user_message_area_last_read ( - user_id INTEGER NOT NULL, - area_tag VARCHAR NOT NULL, - message_id INTEGER NOT NULL, - UNIQUE(user_id, area_tag) - );` - ); - - dbs.message.run( - `CREATE TABLE IF NOT EXISTS message_area_last_scan ( - scan_toss VARCHAR NOT NULL, - area_tag VARCHAR NOT NULL, - message_id INTEGER NOT NULL, - UNIQUE(scan_toss, area_tag) - );` - ); + dbs.message.run( + `CREATE TABLE IF NOT EXISTS user_message_area_last_read ( + user_id INTEGER NOT NULL, + area_tag VARCHAR NOT NULL, + message_id INTEGER NOT NULL, + UNIQUE(user_id, area_tag) + );` + ); - return cb(null); - }, + dbs.message.run( + `CREATE TABLE IF NOT EXISTS message_area_last_scan ( + scan_toss VARCHAR NOT NULL, + area_tag VARCHAR NOT NULL, + message_id INTEGER NOT NULL, + UNIQUE(scan_toss, area_tag) + );` + ); - file : (cb) => { - enableForeignKeys(dbs.file); + return cb(null); + }, - dbs.file.run( - // :TODO: should any of this be unique -- file_sha256 unless dupes are allowed on the system - `CREATE TABLE IF NOT EXISTS file ( - file_id INTEGER PRIMARY KEY, - area_tag VARCHAR NOT NULL, - file_sha256 VARCHAR NOT NULL, - file_name, /* FTS @ file_fts */ - storage_tag VARCHAR NOT NULL, - desc, /* FTS @ file_fts */ - desc_long, /* FTS @ file_fts */ - upload_timestamp DATETIME NOT NULL - );` - ); + file : (cb) => { + enableForeignKeys(dbs.file); - dbs.file.run( - `CREATE INDEX IF NOT EXISTS file_by_area_tag_index - ON file (area_tag);` - ); + dbs.file.run( + // :TODO: should any of this be unique -- file_sha256 unless dupes are allowed on the system + `CREATE TABLE IF NOT EXISTS file ( + file_id INTEGER PRIMARY KEY, + area_tag VARCHAR NOT NULL, + file_sha256 VARCHAR NOT NULL, + file_name, /* FTS @ file_fts */ + storage_tag VARCHAR NOT NULL, + desc, /* FTS @ file_fts */ + desc_long, /* FTS @ file_fts */ + upload_timestamp DATETIME NOT NULL + );` + ); - dbs.file.run( - `CREATE INDEX IF NOT EXISTS file_by_sha256_index - ON file (file_sha256);` - ); + dbs.file.run( + `CREATE INDEX IF NOT EXISTS file_by_area_tag_index + ON file (area_tag);` + ); - dbs.file.run( - `CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts4 ( - content="file", - file_name, - desc, - desc_long - );` - ); + dbs.file.run( + `CREATE INDEX IF NOT EXISTS file_by_sha256_index + ON file (file_sha256);` + ); - dbs.file.run( - `CREATE TRIGGER IF NOT EXISTS file_before_update BEFORE UPDATE ON file BEGIN - DELETE FROM file_fts WHERE docid=old.rowid; - END;` - ); + dbs.file.run( + `CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts4 ( + content="file", + file_name, + desc, + desc_long + );` + ); - dbs.file.run( - `CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN - DELETE FROM file_fts WHERE docid=old.rowid; - END;` - ); + dbs.file.run( + `CREATE TRIGGER IF NOT EXISTS file_before_update BEFORE UPDATE ON file BEGIN + DELETE FROM file_fts WHERE docid=old.rowid; + END;` + ); - dbs.file.run( - `CREATE TRIGGER IF NOT EXISTS file_after_update AFTER UPDATE ON file BEGIN - INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long); - END;` - ); + dbs.file.run( + `CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN + DELETE FROM file_fts WHERE docid=old.rowid; + END;` + ); - dbs.file.run( - `CREATE TRIGGER IF NOT EXISTS file_after_insert AFTER INSERT ON file BEGIN - INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long); - END;` - ); + dbs.file.run( + `CREATE TRIGGER IF NOT EXISTS file_after_update AFTER UPDATE ON file BEGIN + INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long); + END;` + ); - dbs.file.run( - `CREATE TABLE IF NOT EXISTS file_meta ( - file_id INTEGER NOT NULL, - meta_name VARCHAR NOT NULL, - meta_value VARCHAR NOT NULL, - UNIQUE(file_id, meta_name, meta_value), - FOREIGN KEY(file_id) REFERENCES file(file_id) ON DELETE CASCADE - );` - ); + dbs.file.run( + `CREATE TRIGGER IF NOT EXISTS file_after_insert AFTER INSERT ON file BEGIN + INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long); + END;` + ); - dbs.file.run( - `CREATE TABLE IF NOT EXISTS hash_tag ( - hash_tag_id INTEGER PRIMARY KEY, - hash_tag VARCHAR NOT NULL, - - UNIQUE(hash_tag) - );` - ); + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_meta ( + file_id INTEGER NOT NULL, + meta_name VARCHAR NOT NULL, + meta_value VARCHAR NOT NULL, + UNIQUE(file_id, meta_name, meta_value), + FOREIGN KEY(file_id) REFERENCES file(file_id) ON DELETE CASCADE + );` + ); - dbs.file.run( - `CREATE TABLE IF NOT EXISTS file_hash_tag ( - hash_tag_id INTEGER NOT NULL, - file_id INTEGER NOT NULL, - - UNIQUE(hash_tag_id, file_id) - );` - ); + dbs.file.run( + `CREATE TABLE IF NOT EXISTS hash_tag ( + hash_tag_id INTEGER PRIMARY KEY, + hash_tag VARCHAR NOT NULL, + + UNIQUE(hash_tag) + );` + ); - dbs.file.run( - `CREATE TABLE IF NOT EXISTS file_user_rating ( - file_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - rating INTEGER NOT NULL, + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_hash_tag ( + hash_tag_id INTEGER NOT NULL, + file_id INTEGER NOT NULL, + + UNIQUE(hash_tag_id, file_id) + );` + ); - UNIQUE(file_id, user_id) - );` - ); + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_user_rating ( + file_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + rating INTEGER NOT NULL, - dbs.file.run( - `CREATE TABLE IF NOT EXISTS file_web_serve ( - hash_id VARCHAR NOT NULL PRIMARY KEY, - expire_timestamp DATETIME NOT NULL - );` - ); + UNIQUE(file_id, user_id) + );` + ); - return cb(null); - } + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_web_serve ( + hash_id VARCHAR NOT NULL PRIMARY KEY, + expire_timestamp DATETIME NOT NULL + );` + ); + + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_web_serve_batch ( + hash_id VARCHAR NOT NULL, + file_id INTEGER NOT NULL, + + UNIQUE(hash_id, file_id) + );` + ); + + return cb(null); + } }; \ No newline at end of file diff --git a/core/descript_ion_file.js b/core/descript_ion_file.js index 8f2bc1b3..d34551ba 100644 --- a/core/descript_ion_file.js +++ b/core/descript_ion_file.js @@ -1,72 +1,77 @@ /* jslint node: true */ 'use strict'; -// deps -const fs = require('graceful-fs'); -const iconv = require('iconv-lite'); -const async = require('async'); +const { Errors } = require('./enig_error.js'); + +// deps +const fs = require('graceful-fs'); +const iconv = require('iconv-lite'); +const async = require('async'); module.exports = class DescriptIonFile { - constructor() { - this.entries = new Map(); - } + constructor() { + this.entries = new Map(); + } - get(fileName) { - return this.entries.get(fileName); - } + get(fileName) { + return this.entries.get(fileName); + } - getDescription(fileName) { - const entry = this.get(fileName); - if(entry) { - return entry.desc; - } - } + getDescription(fileName) { + const entry = this.get(fileName); + if(entry) { + return entry.desc; + } + } - static createFromFile(path, cb) { - fs.readFile(path, (err, descData) => { - if(err) { - return cb(err); - } + static createFromFile(path, cb) { + fs.readFile(path, (err, descData) => { + if(err) { + return cb(err); + } - const descIonFile = new DescriptIonFile(); + const descIonFile = new DescriptIonFile(); - // DESCRIPT.ION entries are terminated with a CR and/or LF - const lines = iconv.decode(descData, 'cp437').split(/\r?\n/g); + // DESCRIPT.ION entries are terminated with a CR and/or LF + const lines = iconv.decode(descData, 'cp437').split(/\r?\n/g); - async.each(lines, (entryData, nextLine) => { - // - // We allow quoted (long) filenames or non-quoted filenames. - // FILENAMEDESC<0x04> - // - const parts = entryData.match(/^(?:(?:"([^"]+)" )|(?:([^ ]+) ))([^\x04]+)\x04(.)[^\r\n]*$/); // eslint-disable-line no-control-regex - if(!parts) { - return nextLine(null); - } + async.each(lines, (entryData, nextLine) => { + // + // We allow quoted (long) filenames or non-quoted filenames. + // FILENAMEDESC<0x04> + // + const parts = entryData.match(/^(?:(?:"([^"]+)" )|(?:([^ ]+) ))([^\x04]+)\x04(.)[^\r\n]*$/); // eslint-disable-line no-control-regex + if(!parts) { + return nextLine(null); + } - const fileName = parts[1] || parts[2]; + const fileName = parts[1] || parts[2]; - // - // Un-escape CR/LF's - // - escapped \r and/or \n - // - BBBS style @n - See https://www.bbbs.net/sysop.html - // - const desc = parts[3].replace(/\\r\\n|\\n|[^@]@n/g, '\r\n'); + // + // Un-escape CR/LF's + // - escapped \r and/or \n + // - BBBS style @n - See https://www.bbbs.net/sysop.html + // + const desc = parts[3].replace(/\\r\\n|\\n|[^@]@n/g, '\r\n'); - descIonFile.entries.set( - fileName, - { - desc : desc, - programId : parts[4], - programData : parts[5], - } - ); + descIonFile.entries.set( + fileName, + { + desc : desc, + programId : parts[4], + programData : parts[5], + } + ); - return nextLine(null); - }, - () => { - return cb(null, descIonFile); - }); - }); - } + return nextLine(null); + }, + () => { + return cb( + descIonFile.entries.size > 0 ? null : Errors.Invalid('Invalid or unrecognized DESCRIPT.ION format'), + descIonFile + ); + }); + }); + } }; diff --git a/core/door.js b/core/door.js index 5670db1e..c8dd3796 100644 --- a/core/door.js +++ b/core/door.js @@ -1,153 +1,147 @@ /* jslint node: true */ 'use strict'; +const stringFormat = require('./string_format.js'); +const { Errors } = require('./enig_error.js'); -const stringFormat = require('./string_format.js'); +// deps +const pty = require('node-pty'); +const decode = require('iconv-lite').decode; +const createServer = require('net').createServer; +const paths = require('path'); -const events = require('events'); -const _ = require('lodash'); -const pty = require('ptyw.js'); -const decode = require('iconv-lite').decode; -const createServer = require('net').createServer; +module.exports = class Door { + constructor(client) { + this.client = client; + this.restored = false; + } -exports.Door = Door; + prepare(ioType, cb) { + this.io = ioType; -function Door(client, exeInfo) { - events.EventEmitter.call(this); + // we currently only have to do any real setup for 'socket' + if('socket' !== ioType) { + return cb(null); + } - const self = this; - this.client = client; - this.exeInfo = exeInfo; - this.exeInfo.encoding = this.exeInfo.encoding || 'cp437'; - this.exeInfo.encoding = this.exeInfo.encoding.toLowerCase(); - let restored = false; + this.sockServer = createServer(conn => { + conn.once('end', () => { + return this.restoreIo(conn); + }); - // - // Members of exeInfo: - // cmd - // args[] - // env{} - // cwd - // io - // encoding - // dropFile - // node - // inhSocket - // + conn.once('error', err => { + this.client.log.info( { error : err.message }, 'Door socket server connection'); + return this.restoreIo(conn); + }); - this.doorDataHandler = function(data) { - if(self.client.term.outputEncoding === self.exeInfo.encoding) { - self.client.term.rawWrite(data); - } else { - self.client.term.write(decode(data, self.exeInfo.encoding)); - } - }; + this.sockServer.getConnections( (err, count) => { + // We expect only one connection from our DOOR/emulator/etc. + if(!err && count <= 1) { + this.client.term.output.pipe(conn); + conn.on('data', this.doorDataHandler.bind(this)); + } + }); + }); - this.restoreIo = function(piped) { - if(!restored && self.client.term.output) { - self.client.term.output.unpipe(piped); - self.client.term.output.resume(); - restored = true; - } - }; + this.sockServer.listen(0, () => { + return cb(null); + }); + } - this.prepareSocketIoServer = function(cb) { - if('socket' === self.exeInfo.io) { - const sockServer = createServer(conn => { + run(exeInfo, cb) { + this.encoding = (exeInfo.encoding || 'cp437').toLowerCase(); - sockServer.getConnections( (err, count) => { + if('socket' === this.io && !this.sockServer) { + return cb(Errors.UnexpectedState('Socket server is not running')); + } - // We expect only one connection from our DOOR/emulator/etc. - if(!err && count <= 1) { - self.client.term.output.pipe(conn); - - conn.on('data', self.doorDataHandler); + const cwd = exeInfo.cwd || paths.dirname(exeInfo.cmd); - conn.once('end', () => { - return self.restoreIo(conn); - }); + const formatObj = { + dropFile : exeInfo.dropFile, + dropFilePath : exeInfo.dropFilePath, + node : exeInfo.node.toString(), + srvPort : this.sockServer ? this.sockServer.address().port.toString() : '-1', + userId : this.client.user.userId.toString(), + userName : this.client.user.getSanitizedName(), + userNameRaw : this.client.user.username, + cwd : cwd, + }; - conn.once('error', err => { - self.client.log.info( { error : err.toString() }, 'Door socket server connection'); - return self.restoreIo(conn); - }); - } - }); - }); + const args = exeInfo.args.map( arg => stringFormat(arg, formatObj) ); - sockServer.listen(0, () => { - return cb(null, sockServer); - }); - } else { - return cb(null); - } - }; + this.client.log.info( + { cmd : exeInfo.cmd, args, io : this.io }, + 'Executing external door process' + ); - this.doorExited = function() { - self.emit('finished'); - }; -} + try { + this.doorPty = pty.spawn(exeInfo.cmd, args, { + cols : this.client.term.termWidth, + rows : this.client.term.termHeight, + cwd : cwd, + env : exeInfo.env, + encoding : null, // we want to handle all encoding ourself + }); + } catch(e) { + return cb(e); + } -require('util').inherits(Door, events.EventEmitter); + this.client.log.debug( + { processId : this.doorPty.pid }, 'External door process spawned' + ); -Door.prototype.run = function() { - const self = this; + if('stdio' === this.io) { + this.client.log.debug('Using stdio for door I/O'); - this.prepareSocketIoServer( (err, sockServer) => { - if(err) { - this.client.log.warn( { error : err.toString() }, 'Failed executing door'); - return self.doorExited(); - } + this.client.term.output.pipe(this.doorPty); - // Expand arg strings, e.g. {dropFile} -> DOOR32.SYS - // :TODO: Use .map() here - let args = _.clone(self.exeInfo.args); // we need a copy so the original is not modified + this.doorPty.on('data', this.doorDataHandler.bind(this)); - for(let i = 0; i < args.length; ++i) { - args[i] = stringFormat(self.exeInfo.args[i], { - dropFile : self.exeInfo.dropFile, - node : self.exeInfo.node.toString(), - srvPort : sockServer ? sockServer.address().port.toString() : '-1', - userId : self.client.user.userId.toString(), - }); - } + this.doorPty.once('close', () => { + return this.restoreIo(this.doorPty); + }); + } else if('socket' === this.io) { + this.client.log.debug( + { srvPort : this.sockServer.address().port, srvSocket : this.sockServerSocket }, + 'Using temporary socket server for door I/O' + ); + } - const door = pty.spawn(self.exeInfo.cmd, args, { - cols : self.client.term.termWidth, - rows : self.client.term.termHeight, - // :TODO: cwd - env : self.exeInfo.env, - }); + this.doorPty.once('exit', exitCode => { + this.client.log.info( { exitCode : exitCode }, 'Door exited'); - if('stdio' === self.exeInfo.io) { - self.client.log.debug('Using stdio for door I/O'); + if(this.sockServer) { + this.sockServer.close(); + } - self.client.term.output.pipe(door); + // we may not get a close + if('stdio' === this.io) { + this.restoreIo(this.doorPty); + } - door.on('data', self.doorDataHandler); + this.doorPty.removeAllListeners(); + delete this.doorPty; - door.once('close', () => { - return self.restoreIo(door); - }); - } else if('socket' === self.exeInfo.io) { - self.client.log.debug( { port : sockServer.address().port }, 'Using temporary socket server for door I/O'); - } + return cb(null); + }); + } - door.once('exit', exitCode => { - self.client.log.info( { exitCode : exitCode }, 'Door exited'); + doorDataHandler(data) { + this.client.term.write(decode(data, this.encoding)); + } - if(sockServer) { - sockServer.close(); - } + restoreIo(piped) { + if(!this.restored) { + if(this.doorPty) { + this.doorPty.kill(); + } - // we may not get a close - if('stdio' === self.exeInfo.io) { - self.restoreIo(door); - } - - door.removeAllListeners(); - - return self.doorExited(); - }); - }); + if(this.client.term.output) { + this.client.term.output.unpipe(piped); + this.client.term.output.resume(); + } + this.restored = true; + } + } }; diff --git a/core/door_party.js b/core/door_party.js index 762f626b..184416f7 100644 --- a/core/door_party.js +++ b/core/door_party.js @@ -1,131 +1,145 @@ /* jslint node: true */ 'use strict'; -// enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; +// enigma-bbs +const { MenuModule } = require('./menu_module.js'); +const { resetScreen } = require('./ansi_term.js'); +const { Errors } = require('./enig_error.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); -// deps -const async = require('async'); -const _ = require('lodash'); -const SSHClient = require('ssh2').Client; +// deps +const async = require('async'); +const SSHClient = require('ssh2').Client; exports.moduleInfo = { - name : 'DoorParty', - desc : 'DoorParty Access Module', - author : 'NuSkooler', + name : 'DoorParty', + desc : 'DoorParty Access Module', + author : 'NuSkooler', }; exports.getModule = class DoorPartyModule extends MenuModule { - constructor(options) { - super(options); + constructor(options) { + super(options); - // establish defaults - this.config = options.menuConfig.config; - this.config.host = this.config.host || 'dp.throwbackbbs.com'; - this.config.sshPort = this.config.sshPort || 2022; - this.config.rloginPort = this.config.rloginPort || 513; - } - - initSequence() { - let clientTerminated; - const self = this; - - async.series( - [ - function validateConfig(callback) { - if(!_.isString(self.config.username)) { - return callback(new Error('Config requires "username"!')); - } - if(!_.isString(self.config.password)) { - return callback(new Error('Config requires "password"!')); - } - if(!_.isString(self.config.bbsTag)) { - return callback(new Error('Config requires "bbsTag"!')); - } - return callback(null); - }, - function establishSecureConnection(callback) { - self.client.term.write(resetScreen()); - self.client.term.write('Connecting to DoorParty, please wait...\n'); - - const sshClient = new SSHClient(); - - let pipeRestored = false; - let pipedStream; - const restorePipe = function() { - if(pipedStream && !pipeRestored && !clientTerminated) { - self.client.term.output.unpipe(pipedStream); - self.client.term.output.resume(); - } - }; - - sshClient.on('ready', () => { - // track client termination so we can clean up early - self.client.once('end', () => { - self.client.log.info('Connection ended. Terminating DoorParty connection'); - clientTerminated = true; - sshClient.end(); - }); - - // establish tunnel for rlogin - sshClient.forwardOut('127.0.0.1', self.config.sshPort, self.config.host, self.config.rloginPort, (err, stream) => { - if(err) { - return callback(new Error('Failed to establish tunnel')); - } + // establish defaults + this.config = options.menuConfig.config; + this.config.host = this.config.host || 'dp.throwbackbbs.com'; + this.config.sshPort = this.config.sshPort || 2022; + this.config.rloginPort = this.config.rloginPort || 513; + } - // - // Send rlogin - // DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g. - // [XA]nuskooler - // - const rlogin = `\x00${self.client.user.username}\x00[${self.config.bbsTag}]${self.client.user.username}\x00${self.client.term.termType}\x00`; - stream.write(rlogin); - - pipedStream = stream; // :TODO: this is hacky... - self.client.term.output.pipe(stream); - - stream.on('data', d => { - // :TODO: we should just pipe this... - self.client.term.rawWrite(d); - }); - - stream.on('close', () => { - restorePipe(); - sshClient.end(); - }); - }); - }); + initSequence() { + let clientTerminated; + const self = this; - sshClient.on('error', err => { - self.client.log.info(`DoorParty SSH client error: ${err.message}`); - }); - - sshClient.on('close', () => { - restorePipe(); - callback(null); - }); - - sshClient.connect( { - host : self.config.host, - port : self.config.sshPort, - username : self.config.username, - password : self.config.password, - }); - - // note: no explicit callback() until we're finished! - } - ], - err => { - if(err) { - self.client.log.warn( { error : err.message }, 'DoorParty error'); - } - - // if the client is stil here, go to previous - if(!clientTerminated) { - self.prevMenu(); - } - } - ); - } + async.series( + [ + function validateConfig(callback) { + return self.validateConfigFields( + { + host : 'string', + username : 'string', + password : 'string', + bbsTag : 'string', + sshPort : 'number', + rloginPort : 'number', + }, + callback + ); + }, + function establishSecureConnection(callback) { + self.client.term.write(resetScreen()); + self.client.term.write('Connecting to DoorParty, please wait...\n'); + + const sshClient = new SSHClient(); + + let pipeRestored = false; + let pipedStream; + let doorTracking; + + const restorePipe = function() { + if(pipedStream && !pipeRestored && !clientTerminated) { + self.client.term.output.unpipe(pipedStream); + self.client.term.output.resume(); + + if(doorTracking) { + trackDoorRunEnd(doorTracking); + } + } + }; + + sshClient.on('ready', () => { + // track client termination so we can clean up early + self.client.once('end', () => { + self.client.log.info('Connection ended. Terminating DoorParty connection'); + clientTerminated = true; + sshClient.end(); + }); + + // establish tunnel for rlogin + sshClient.forwardOut('127.0.0.1', self.config.sshPort, self.config.host, self.config.rloginPort, (err, stream) => { + if(err) { + return callback(Errors.General('Failed to establish tunnel')); + } + + doorTracking = trackDoorRunBegin(self.client); + + // + // Send rlogin + // DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g. + // [XA]nuskooler + // + const rlogin = `\x00${self.client.user.username}\x00[${self.config.bbsTag}]${self.client.user.username}\x00${self.client.term.termType}\x00`; + stream.write(rlogin); + + pipedStream = stream; // :TODO: this is hacky... + self.client.term.output.pipe(stream); + + stream.on('data', d => { + // :TODO: we should just pipe this... + self.client.term.rawWrite(d); + }); + + stream.on('close', () => { + restorePipe(); + sshClient.end(); + }); + }); + }); + + sshClient.on('error', err => { + self.client.log.info(`DoorParty SSH client error: ${err.message}`); + trackDoorRunEnd(doorTracking); + }); + + sshClient.on('close', () => { + restorePipe(); + callback(null); + }); + + sshClient.connect( { + host : self.config.host, + port : self.config.sshPort, + username : self.config.username, + password : self.config.password, + }); + + // note: no explicit callback() until we're finished! + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'DoorParty error'); + } + + // if the client is still here, go to previous + if(!clientTerminated) { + self.prevMenu(); + } + } + ); + } }; diff --git a/core/door_util.js b/core/door_util.js new file mode 100644 index 00000000..24c2cd80 --- /dev/null +++ b/core/door_util.js @@ -0,0 +1,42 @@ +/* jslint node: true */ +'use strict'; + +const UserProps = require('./user_property.js'); +const Events = require('./events.js'); +const StatLog = require('./stat_log.js'); + +const moment = require('moment'); + +exports.trackDoorRunBegin = trackDoorRunBegin; +exports.trackDoorRunEnd = trackDoorRunEnd; + +function trackDoorRunBegin(client, doorTag) { + const startTime = moment(); + return { startTime, client, doorTag }; +} + +function trackDoorRunEnd(trackInfo) { + if (!trackInfo) { + return; + } + + const { startTime, client, doorTag } = trackInfo; + + const diff = moment.duration(moment().diff(startTime)); + if(diff.asSeconds() >= 45) { + StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalCount, 1); + } + + const runTimeMinutes = Math.floor(diff.asMinutes()); + if(runTimeMinutes > 0) { + StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes); + + const eventInfo = { + runTimeMinutes, + user : client.user, + doorTag : doorTag || 'unknown', + }; + + Events.emit(Events.getSystemEvents().UserRunDoor, eventInfo); + } +} \ No newline at end of file diff --git a/core/download_queue.js b/core/download_queue.js index 6bfbd47f..4380ded1 100644 --- a/core/download_queue.js +++ b/core/download_queue.js @@ -1,72 +1,103 @@ /* jslint node: true */ 'use strict'; -const FileEntry = require('./file_entry.js'); +const FileEntry = require('./file_entry'); +const UserProps = require('./user_property'); +const Events = require('./events'); + +// deps +const _ = require('lodash'); module.exports = class DownloadQueue { - constructor(client) { - this.client = client; + constructor(client) { + this.client = client; - if(!Array.isArray(this.client.user.downloadQueue)) { - if(this.client.user.properties.dl_queue) { - this.loadFromProperty(this.client.user.properties.dl_queue); - } else { - this.client.user.downloadQueue = []; - } - } - } + if(!Array.isArray(this.client.user.downloadQueue)) { + if(this.client.user.properties[UserProps.DownloadQueue]) { + this.loadFromProperty(this.client.user.properties[UserProps.DownloadQueue]); + } else { + this.client.user.downloadQueue = []; + } + } + } - get items() { - return this.client.user.downloadQueue; - } + static get(client) { + return new DownloadQueue(client); + } - clear() { - this.client.user.downloadQueue = []; - } + get items() { + return this.client.user.downloadQueue; + } - toggle(fileEntry) { - if(this.isQueued(fileEntry)) { - this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId); - } else { - this.add(fileEntry); - } - } + clear() { + this.client.user.downloadQueue = []; + } - add(fileEntry) { - this.client.user.downloadQueue.push({ - fileId : fileEntry.fileId, - areaTag : fileEntry.areaTag, - fileName : fileEntry.fileName, - path : fileEntry.filePath, - byteSize : fileEntry.meta.byte_size || 0, - }); - } + toggle(fileEntry, systemFile=false) { + if(this.isQueued(fileEntry)) { + this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId); + } else { + this.add(fileEntry, systemFile); + } + } - removeItems(fileIds) { - if(!Array.isArray(fileIds)) { - fileIds = [ fileIds ]; - } + add(fileEntry, systemFile=false) { + this.client.user.downloadQueue.push({ + fileId : fileEntry.fileId, + areaTag : fileEntry.areaTag, + fileName : fileEntry.fileName, + path : fileEntry.filePath, + byteSize : fileEntry.meta.byte_size || 0, + systemFile : systemFile, + }); + } - this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => ( -1 === fileIds.indexOf(e.fileId) ) ); - } + removeItems(fileIds) { + if(!Array.isArray(fileIds)) { + fileIds = [ fileIds ]; + } - isQueued(entryOrId) { - if(entryOrId instanceof FileEntry) { - entryOrId = entryOrId.fileId; - } + const [ remain, removed ] = _.partition(this.client.user.downloadQueue, e => ( -1 === fileIds.indexOf(e.fileId) )); + this.client.user.downloadQueue = remain; + return removed; + } - return this.client.user.downloadQueue.find(e => entryOrId === e.fileId) ? true : false; - } + isQueued(entryOrId) { + if(entryOrId instanceof FileEntry) { + entryOrId = entryOrId.fileId; + } - toProperty() { return JSON.stringify(this.client.user.downloadQueue); } - - loadFromProperty(prop) { - try { - this.client.user.downloadQueue = JSON.parse(prop); - } catch(e) { - this.client.user.downloadQueue = []; + return this.client.user.downloadQueue.find(e => entryOrId === e.fileId) ? true : false; + } - this.client.log.error( { error : e.message, property : prop }, 'Failed parsing download queue property'); - } - } + toProperty() { return JSON.stringify(this.client.user.downloadQueue); } + + loadFromProperty(prop) { + try { + this.client.user.downloadQueue = JSON.parse(prop); + } catch(e) { + this.client.user.downloadQueue = []; + + this.client.log.error( { error : e.message, property : prop }, 'Failed parsing download queue property'); + } + } + + addTemporaryDownload(entry) { + this.add(entry, true); // true=systemFile + + // clean up after ourselves when the session ends + const thisUniqueId = this.client.session.uniqueId; + Events.once(Events.getSystemEvents().ClientDisconnected, evt => { + if(thisUniqueId === _.get(evt, 'client.session.uniqueId')) { + FileEntry.removeEntry(entry, { removePhysFile : true }, err => { + const Log = require('./logger').log; + if(err) { + Log.warn( { fileId : entry.fileId, path : entry.filePath }, 'Failed removing temporary session download' ); + } else { + Log.debug( { fileId : entry.fileId, path : entry.filePath }, 'Removed temporary session download item' ); + } + }); + } + }); + } }; diff --git a/core/dropfile.js b/core/dropfile.js index 20c027e3..0c66d38a 100644 --- a/core/dropfile.js +++ b/core/dropfile.js @@ -1,211 +1,227 @@ /* jslint node: true */ 'use strict'; -var Config = require('./config.js').config; -const StatLog = require('./stat_log.js'); +// ENiGMA½ +const Config = require('./config.js').get; +const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); +const SysProps = require('./system_property.js'); -var fs = require('graceful-fs'); -var paths = require('path'); -var _ = require('lodash'); -var moment = require('moment'); -var iconv = require('iconv-lite'); - -exports.DropFile = DropFile; +// deps +const fs = require('graceful-fs'); +const paths = require('path'); +const _ = require('lodash'); +const moment = require('moment'); +const iconv = require('iconv-lite'); +const { mkdirs } = require('fs-extra'); // -// Resources -// * http://goldfndr.home.mindspring.com/dropfile/ -// * https://en.wikipedia.org/wiki/Talk%3ADropfile -// * http://thoughtproject.com/libraries/bbs/Sysop/Doors/DropFiles/index.htm -// * http://thebbs.org/bbsfaq/ch06.02.htm +// Resources +// * https://github.com/NuSkooler/ansi-bbs/tree/master/docs/dropfile_formats +// * http://goldfndr.home.mindspring.com/dropfile/ +// * https://en.wikipedia.org/wiki/Talk%3ADropfile +// * http://thoughtproject.com/libraries/bbs/Sysop/Doors/DropFiles/index.htm +// * http://thebbs.org/bbsfaq/ch06.02.htm +// * http://lord.lordlegacy.com/dosemu/ +// +module.exports = class DropFile { + constructor(client, { fileType = 'DORINFO', baseDir = Config().paths.dropFiles } = {} ) { + this.client = client; + this.fileType = fileType.toUpperCase(); + this.baseDir = baseDir; + } -// http://lord.lordlegacy.com/dosemu/ + get fullPath() { + return paths.join(this.baseDir, ('node' + this.client.node), this.fileName); + } -function DropFile(client, fileType) { + get fileName() { + return { + DOOR : 'DOOR.SYS', // GAP BBS, many others + DOOR32 : 'door32.sys', // Mystic, EleBBS, Syncronet, Maximus, Telegard, AdeptXBBS (lowercase name as per spec) + CALLINFO : 'CALLINFO.BBS', // Citadel? + DORINFO : this.getDoorInfoFileName(), // RBBS, RemoteAccess, QBBS, ... + CHAIN : 'CHAIN.TXT', // WWIV + CURRUSER : 'CURRUSER.BBS', // RyBBS + SFDOORS : 'SFDOORS.DAT', // Spitfire + PCBOARD : 'PCBOARD.SYS', // PCBoard + TRIBBS : 'TRIBBS.SYS', // TriBBS + USERINFO : 'USERINFO.DAT', // Wildcat! 3.0+ + JUMPER : 'JUMPER.DAT', // 2AM BBS + SXDOOR : 'SXDOOR.' + _.pad(this.client.node.toString(), 3, '0'), // System/X, dESiRE + INFO : 'INFO.BBS', // Phoenix BBS + }[this.fileType]; + } - var self = this; - this.client = client; - this.fileType = (fileType || 'DORINFO').toUpperCase(); + isSupported() { + return this.getHandler() ? true : false; + } - Object.defineProperty(this, 'fullPath', { - get : function() { - return paths.join(Config.paths.dropFiles, ('node' + self.client.node), self.fileName); - } - }); + getHandler() { + return { + DOOR : this.getDoorSysBuffer, + DOOR32 : this.getDoor32Buffer, + DORINFO : this.getDoorInfoDefBuffer, + }[this.fileType]; + } - Object.defineProperty(this, 'fileName', { - get : function() { - return { - DOOR : 'DOOR.SYS', // GAP BBS, many others - DOOR32 : 'DOOR32.SYS', // EleBBS / Mystic, Syncronet, Maximus, Telegard, AdeptXBBS, ... - CALLINFO : 'CALLINFO.BBS', // Citadel? - DORINFO : self.getDoorInfoFileName(), // RBBS, RemoteAccess, QBBS, ... - CHAIN : 'CHAIN.TXT', // WWIV - CURRUSER : 'CURRUSER.BBS', // RyBBS - SFDOORS : 'SFDOORS.DAT', // Spitfire - PCBOARD : 'PCBOARD.SYS', // PCBoard - TRIBBS : 'TRIBBS.SYS', // TriBBS - USERINFO : 'USERINFO.DAT', // Wildcat! 3.0+ - JUMPER : 'JUMPER.DAT', // 2AM BBS - SXDOOR : // System/X, dESiRE - 'SXDOOR.' + _.pad(self.client.node.toString(), 3, '0'), - INFO : 'INFO.BBS', // Phoenix BBS - }[self.fileType]; - } - }); + getContents() { + const handler = this.getHandler().bind(this); + return handler(); + } - Object.defineProperty(this, 'dropFileContents', { - get : function() { - return { - DOOR : self.getDoorSysBuffer(), - DOOR32 : self.getDoor32Buffer(), - DORINFO : self.getDoorInfoDefBuffer(), - }[self.fileType]; - } - }); + getDoorInfoFileName() { + let x; + const node = this.client.node; + if(10 === node) { + x = 0; + } else if(node < 10) { + x = node; + } else { + x = String.fromCharCode('a'.charCodeAt(0) + (node - 11)); + } + return 'DORINFO' + x + '.DEF'; + } - this.getDoorInfoFileName = function() { - var x; - var node = self.client.node; - if(10 === node) { - x = 0; - } else if(node < 10) { - x = node; - } else { - x = String.fromCharCode('a'.charCodeAt(0) + (node - 11)); - } - return 'DORINFO' + x + '.DEF'; - }; + getDoorSysBuffer() { + const prop = this.client.user.properties; + const now = moment(); + const secLevel = this.client.user.getLegacySecurityLevel().toString(); + const fullName = this.client.user.getSanitizedName('real'); + const bd = moment(prop[UserProps.Birthdate]).format('MM/DD/YY'); - this.getDoorSysBuffer = function() { - var up = self.client.user.properties; - var now = moment(); - var secLevel = self.client.user.getLegacySecurityLevel().toString(); + const upK = Math.floor((parseInt(prop[UserProps.FileUlTotalBytes]) || 0) / 1024); + const downK = Math.floor((parseInt(prop[UserProps.FileDlTotalBytes]) || 0) / 1024); - // :TODO: fix time remaining - // :TODO: fix default protocol -- user prop: transfer_protocol + const timeOfCall = moment(prop[UserProps.LastLoginTs] || moment()).format('hh:mm'); - return iconv.encode( [ - 'COM1:', // "Comm Port - COM0: = LOCAL MODE" - '57600', // "Baud Rate - 300 to 38400" (Note: set as 57600 instead!) - '8', // "Parity - 7 or 8" - self.client.node.toString(), // "Node Number - 1 to 99" - '57600', // "DTE Rate. Actual BPS rate to use. (kg)" - 'Y', // "Screen Display - Y=On N=Off (Default to Y)" - 'Y', // "Printer Toggle - Y=On N=Off (Default to Y)" - 'Y', // "Page Bell - Y=On N=Off (Default to Y)" - 'Y', // "Caller Alarm - Y=On N=Off (Default to Y)" - up.real_name || self.client.user.username, // "User Full Name" - up.location || 'Anywhere', // "Calling From" - '123-456-7890', // "Home Phone" - '123-456-7890', // "Work/Data Phone" - 'NOPE', // "Password" (Note: this is never given out or even stored plaintext) - secLevel, // "Security Level" - up.login_count.toString(), // "Total Times On" - now.format('MM/DD/YY'), // "Last Date Called" - '15360', // "Seconds Remaining THIS call (for those that particular)" - '256', // "Minutes Remaining THIS call" - 'GR', // "Graphics Mode - GR=Graph, NG=Non-Graph, 7E=7,E Caller" - self.client.term.termHeight.toString(), // "Page Length" - 'N', // "User Mode - Y = Expert, N = Novice" - '1,2,3,4,5,6,7', // "Conferences/Forums Registered In (ABCDEFG)" - '1', // "Conference Exited To DOOR From (G)" - '01/01/99', // "User Expiration Date (mm/dd/yy)" - self.client.user.userId.toString(), // "User File's Record Number" - 'Z', // "Default Protocol - X, C, Y, G, I, N, Etc." - // :TODO: fix up, down, etc. form user properties - '0', // "Total Uploads" - '0', // "Total Downloads" - '0', // "Daily Download "K" Total" - '999999', // "Daily Download Max. "K" Limit" - moment(up.birthdate).format('MM/DD/YY'), // "Caller's Birthdate" - 'X:\\MAIN\\', // "Path to the MAIN directory (where User File is)" - 'X:\\GEN\\', // "Path to the GEN directory" - StatLog.getSystemStat('sysop_username'), // "Sysop's Name (name BBS refers to Sysop as)" - self.client.user.username, // "Alias name" - '00:05', // "Event time (hh:mm)" (note: wat?) - 'Y', // "If its an error correcting connection (Y/N)" - 'Y', // "ANSI supported & caller using NG mode (Y/N)" - 'Y', // "Use Record Locking (Y/N)" - '7', // "BBS Default Color (Standard IBM color code, ie, 1-15)" - // :TODO: fix minutes here also: - '256', // "Time Credits In Minutes (positive/negative)" - '07/07/90', // "Last New Files Scan Date (mm/dd/yy)" - // :TODO: fix last vs now times: - now.format('hh:mm'), // "Time of This Call" - now.format('hh:mm'), // "Time of Last Call (hh:mm)" - '9999', // "Maximum daily files available" - // :TODO: fix these stats: - '0', // "Files d/led so far today" - '0', // "Total "K" Bytes Uploaded" - '0', // "Total "K" Bytes Downloaded" - up.user_comment || 'None', // "User Comment" - '0', // "Total Doors Opened" - '0', // "Total Messages Left" + // :TODO: fix time remaining + // :TODO: fix default protocol -- user prop: transfer_protocol + return iconv.encode( [ + 'COM1:', // "Comm Port - COM0: = LOCAL MODE" + '57600', // "Baud Rate - 300 to 38400" (Note: set as 57600 instead!) + '8', // "Parity - 7 or 8" + this.client.node.toString(), // "Node Number - 1 to 99" + '57600', // "DTE Rate. Actual BPS rate to use. (kg)" + 'Y', // "Screen Display - Y=On N=Off (Default to Y)" + 'Y', // "Printer Toggle - Y=On N=Off (Default to Y)" + 'Y', // "Page Bell - Y=On N=Off (Default to Y)" + 'Y', // "Caller Alarm - Y=On N=Off (Default to Y)" + fullName, // "User Full Name" + prop[UserProps.Location]|| 'Anywhere', // "Calling From" + '123-456-7890', // "Home Phone" + '123-456-7890', // "Work/Data Phone" + 'NOPE', // "Password" (Note: this is never given out or even stored plaintext) + secLevel, // "Security Level" + prop[UserProps.LoginCount].toString(), // "Total Times On" + now.format('MM/DD/YY'), // "Last Date Called" + '15360', // "Seconds Remaining THIS call (for those that particular)" + '256', // "Minutes Remaining THIS call" + 'GR', // "Graphics Mode - GR=Graph, NG=Non-Graph, 7E=7,E Caller" + this.client.term.termHeight.toString(), // "Page Length" + 'N', // "User Mode - Y = Expert, N = Novice" + '1,2,3,4,5,6,7', // "Conferences/Forums Registered In (ABCDEFG)" + '1', // "Conference Exited To DOOR From (G)" + '01/01/99', // "User Expiration Date (mm/dd/yy)" + this.client.user.userId.toString(), // "User File's Record Number" + 'Z', // "Default Protocol - X, C, Y, G, I, N, Etc." + // :TODO: fix up, down, etc. form user properties + '0', // "Total Uploads" + '0', // "Total Downloads" + '0', // "Daily Download "K" Total" + '999999', // "Daily Download Max. "K" Limit" + bd, // "Caller's Birthdate" + 'X:\\MAIN\\', // "Path to the MAIN directory (where User File is)" + 'X:\\GEN\\', // "Path to the GEN directory" + StatLog.getSystemStat(SysProps.SysOpUsername), // "Sysop's Name (name BBS refers to Sysop as)" + this.client.user.getSanitizedName(), // "Alias name" + '00:05', // "Event time (hh:mm)" (note: wat?) + 'Y', // "If its an error correcting connection (Y/N)" + 'Y', // "ANSI supported & caller using NG mode (Y/N)" + 'Y', // "Use Record Locking (Y/N)" + '7', // "BBS Default Color (Standard IBM color code, ie, 1-15)" + // :TODO: fix minutes here also: + '256', // "Time Credits In Minutes (positive/negative)" + '07/07/90', // "Last New Files Scan Date (mm/dd/yy)" + timeOfCall, // "Time of This Call" + timeOfCall, // "Time of Last Call (hh:mm)" + '9999', // "Maximum daily files available" + '0', // "Files d/led so far today" + upK.toString(), // "Total "K" Bytes Uploaded" + downK.toString(), // "Total "K" Bytes Downloaded" + prop[UserProps.UserComment] || 'None', // "User Comment" + '0', // "Total Doors Opened" + '0', // "Total Messages Left" + ].join('\r\n') + '\r\n', 'cp437'); + } - ].join('\r\n') + '\r\n', 'cp437'); - }; + getDoor32Buffer() { + // + // Resources: + // * http://wiki.bbses.info/index.php/DOOR32.SYS + // * https://github.com/NuSkooler/ansi-bbs/blob/master/docs/dropfile_formats/door32_sys.txt + // + // :TODO: local/serial/telnet need to be configurable -- which also changes socket handle! + const Door32CommTypes = { + Local : 0, + Serial : 1, + Telnet : 2, + }; - this.getDoor32Buffer = function() { - // - // Resources: - // * http://wiki.bbses.info/index.php/DOOR32.SYS - // - // :TODO: local/serial/telnet need to be configurable -- which also changes socket handle! - return iconv.encode([ - '2', // :TODO: This needs to be configurable! - // :TODO: Completely broken right now -- This need to be configurable & come from temp socket server most likely - '-1', // self.client.output._handle.fd.toString(), // :TODO: ALWAYS -1 on Windows! - '57600', - Config.general.boardName, - self.client.user.userId.toString(), - self.client.user.properties.real_name || self.client.user.username, - self.client.user.username, - self.client.user.getLegacySecurityLevel().toString(), - '546', // :TODO: Minutes left! - '1', // ANSI - self.client.node.toString(), - ].join('\r\n') + '\r\n', 'cp437'); + const commType = Door32CommTypes.Telnet; - }; + return iconv.encode([ + commType.toString(), + '-1', + '115200', + Config().general.boardName, + this.client.user.userId.toString(), + this.client.user.getSanitizedName('real'), + this.client.user.getSanitizedName(), + this.client.user.getLegacySecurityLevel().toString(), + '546', // :TODO: Minutes left! + '1', // ANSI + this.client.node.toString(), + ].join('\r\n') + '\r\n', 'cp437'); + } - this.getDoorInfoDefBuffer = function() { - // :TODO: fix time remaining + getDoorInfoDefBuffer() { + // :TODO: fix time remaining - // - // Resources: - // * http://goldfndr.home.mindspring.com/dropfile/dorinfo.htm - // - // Note that usernames are just used for first/last names here - // - var opUn = /[^\s]*/.exec(StatLog.getSystemStat('sysop_username'))[0]; - var un = /[^\s]*/.exec(self.client.user.username)[0]; - var secLevel = self.client.user.getLegacySecurityLevel().toString(); + // + // Resources: + // * http://goldfndr.home.mindspring.com/dropfile/dorinfo.htm + // + // Note that usernames are just used for first/last names here + // + const opUserName = /[^\s]*/.exec(StatLog.getSystemStat(SysProps.SysOpUsername))[0]; + const userName = /[^\s]*/.exec(this.client.user.getSanitizedName())[0]; + const secLevel = this.client.user.getLegacySecurityLevel().toString(); + const location = this.client.user.properties[UserProps.Location]; - return iconv.encode( [ - Config.general.boardName, // "The name of the system." - opUn, // "The sysop's name up to the first space." - opUn, // "The sysop's name following the first space." - 'COM1', // "The serial port the modem is connected to, or 0 if logged in on console." - '57600', // "The current port (DTE) rate." - '0', // "The number "0"" - un, // "The current user's name, up to the first space." - un, // "The current user's name, following the first space." - self.client.user.properties.location || '', // "Where the user lives, or a blank line if unknown." - '1', // "The number "0" if TTY, or "1" if ANSI." - secLevel, // "The number 5 for problem users, 30 for regular users, 80 for Aides, and 100 for Sysops." - '546', // "The number of minutes left in the current user's account, limited to 546 to keep from overflowing other software." - '-1' // "The number "-1" if using an external serial driver or "0" if using internal serial routines." - ].join('\r\n') + '\r\n', 'cp437'); - }; + return iconv.encode( [ + Config().general.boardName, // "The name of the system." + opUserName, // "The sysop's name up to the first space." + opUserName, // "The sysop's name following the first space." + 'COM1', // "The serial port the modem is connected to, or 0 if logged in on console." + '57600', // "The current port (DTE) rate." + '0', // "The number "0"" + userName, // "The current user's name, up to the first space." + userName, // "The current user's name, following the first space." + location || '', // "Where the user lives, or a blank line if unknown." + '1', // "The number "0" if TTY, or "1" if ANSI." + secLevel, // "The number 5 for problem users, 30 for regular users, 80 for Aides, and 100 for Sysops." + '546', // "The number of minutes left in the current user's account, limited to 546 to keep from overflowing other software." + '-1' // "The number "-1" if using an external serial driver or "0" if using internal serial routines." + ].join('\r\n') + '\r\n', 'cp437'); + } -} - -DropFile.fileTypes = [ 'DORINFO' ]; - -DropFile.prototype.createFile = function(cb) { - fs.writeFile(this.fullPath, this.dropFileContents, function written(err) { - cb(err); - }); + createFile(cb) { + mkdirs(paths.dirname(this.fullPath), err => { + if(err) { + return cb(err); + } + return fs.writeFile(this.fullPath, this.getContents(), cb); + }); + } }; - diff --git a/core/edit_text_view.js b/core/edit_text_view.js index 8e55ae53..db01b9f5 100644 --- a/core/edit_text_view.js +++ b/core/edit_text_view.js @@ -1,90 +1,92 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const TextView = require('./text_view.js').TextView; -const miscUtil = require('./misc_util.js'); -const strUtil = require('./string_util.js'); +// ENiGMA½ +const TextView = require('./text_view.js').TextView; +const miscUtil = require('./misc_util.js'); +const strUtil = require('./string_util.js'); -// deps -const _ = require('lodash'); +// deps +const _ = require('lodash'); -exports.EditTextView = EditTextView; +exports.EditTextView = EditTextView; function EditTextView(options) { - options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); - options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); - options.cursorStyle = miscUtil.valueWithDefault(options.cursorStyle, 'steady block'); - options.resizable = false; - - TextView.call(this, options); + options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); + options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); + options.cursorStyle = miscUtil.valueWithDefault(options.cursorStyle, 'steady block'); + options.resizable = false; - this.cursorPos = { row : 0, col : 0 }; + TextView.call(this, options); - this.clientBackspace = function() { - const fillCharSGR = this.getStyleSGR(1) || this.getSGR(); - this.client.term.write(`\b${fillCharSGR}${this.fillChar}\b${this.getFocusSGR()}`); - }; + this.initDefaultWidth(); + + this.cursorPos = { row : 0, col : 0 }; + + this.clientBackspace = function() { + const fillCharSGR = this.getStyleSGR(1) || this.getSGR(); + this.client.term.write(`\b${fillCharSGR}${this.fillChar}\b${this.getFocusSGR()}`); + }; } require('util').inherits(EditTextView, TextView); EditTextView.prototype.onKeyPress = function(ch, key) { - if(key) { - if(this.isKeyMapped('backspace', key.name)) { - if(this.text.length > 0) { - this.text = this.text.substr(0, this.text.length - 1); + if(key) { + if(this.isKeyMapped('backspace', key.name)) { + if(this.text.length > 0) { + this.text = this.text.substr(0, this.text.length - 1); - if(this.text.length >= this.dimens.width) { - this.redraw(); - } else { - this.cursorPos.col -= 1; - if(this.cursorPos.col >= 0) { - this.clientBackspace(); - } - } - } - - return EditTextView.super_.prototype.onKeyPress.call(this, ch, key); - } else if(this.isKeyMapped('clearLine', key.name)) { - this.text = ''; - this.cursorPos.col = 0; - this.setFocus(true); // resetting focus will redraw & adjust cursor + if(this.text.length >= this.dimens.width) { + this.redraw(); + } else { + this.cursorPos.col -= 1; + if(this.cursorPos.col >= 0) { + this.clientBackspace(); + } + } + } - return EditTextView.super_.prototype.onKeyPress.call(this, ch, key); - } - } + return EditTextView.super_.prototype.onKeyPress.call(this, ch, key); + } else if(this.isKeyMapped('clearLine', key.name)) { + this.text = ''; + this.cursorPos.col = 0; + this.setFocus(true); // resetting focus will redraw & adjust cursor - if(ch && strUtil.isPrintable(ch)) { - if(this.text.length < this.maxLength) { - ch = strUtil.stylizeString(ch, this.textStyle); + return EditTextView.super_.prototype.onKeyPress.call(this, ch, key); + } + } - this.text += ch; + if(ch && strUtil.isPrintable(ch)) { + if(this.text.length < this.maxLength) { + ch = strUtil.stylizeString(ch, this.textStyle); - if(this.text.length > this.dimens.width) { - // no shortcuts - redraw the view - this.redraw(); - } else { - this.cursorPos.col += 1; + this.text += ch; - if(_.isString(this.textMaskChar)) { - if(this.textMaskChar.length > 0) { - this.client.term.write(this.textMaskChar); - } - } else { - this.client.term.write(ch); - } - } - } - } + if(this.text.length > this.dimens.width) { + // no shortcuts - redraw the view + this.redraw(); + } else { + this.cursorPos.col += 1; - EditTextView.super_.prototype.onKeyPress.call(this, ch, key); + if(_.isString(this.textMaskChar)) { + if(this.textMaskChar.length > 0) { + this.client.term.write(this.textMaskChar); + } + } else { + this.client.term.write(ch); + } + } + } + } + + EditTextView.super_.prototype.onKeyPress.call(this, ch, key); }; EditTextView.prototype.setText = function(text) { - // draw & set |text| - EditTextView.super_.prototype.setText.call(this, text); + // draw & set |text| + EditTextView.super_.prototype.setText.call(this, text); - // adjust local cursor tracking - this.cursorPos = { row : 0, col : text.length }; + // adjust local cursor tracking + this.cursorPos = { row : 0, col : text.length }; }; diff --git a/core/email.js b/core/email.js index 0daf06b2..4a41106a 100644 --- a/core/email.js +++ b/core/email.js @@ -1,31 +1,32 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const Errors = require('./enig_error.js').Errors; -const Log = require('./logger.js').log; +// ENiGMA½ +const Config = require('./config.js').get; +const Errors = require('./enig_error.js').Errors; +const Log = require('./logger.js').log; -// deps -const _ = require('lodash'); -const nodeMailer = require('nodemailer'); +// deps +const _ = require('lodash'); +const nodeMailer = require('nodemailer'); -exports.sendMail = sendMail; +exports.sendMail = sendMail; function sendMail(message, cb) { - if(!_.has(Config, 'email.transport')) { - return cb(Errors.MissingConfig('Email "email::transport" configuration missing')); - } + const config = Config(); + if(!_.has(config, 'email.transport')) { + return cb(Errors.MissingConfig('Email "email.transport" configuration missing')); + } - message.from = message.from || Config.email.defaultFrom; + message.from = message.from || config.email.defaultFrom; - const transportOptions = Object.assign( {}, Config.email.transport, { - logger : Log, - }); + const transportOptions = Object.assign( {}, config.email.transport, { + logger : Log, + }); - const transport = nodeMailer.createTransport(transportOptions); + const transport = nodeMailer.createTransport(transportOptions); - transport.sendMail(message, (err, info) => { - return cb(err, info); - }); + transport.sendMail(message, (err, info) => { + return cb(err, info); + }); } diff --git a/core/enig_error.js b/core/enig_error.js index 49627b9c..4d88cfa1 100644 --- a/core/enig_error.js +++ b/core/enig_error.js @@ -2,41 +2,56 @@ 'use strict'; class EnigError extends Error { - constructor(message, code, reason, reasonCode) { - super(message); + constructor(message, code, reason, reasonCode) { + super(message); - this.name = this.constructor.name; - this.message = message; - this.code = code; - this.reason = reason; - this.reasonCode = reasonCode; + this.name = this.constructor.name; + this.message = message; + this.code = code; + this.reason = reason; + this.reasonCode = reasonCode; - if(typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, this.constructor); - } else { - this.stack = (new Error(message)).stack; - } - } + if(this.reason) { + this.message += `: ${this.reason}`; + } + + if(typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, this.constructor); + } else { + this.stack = (new Error(message)).stack; + } + } } -exports.EnigError = EnigError; +exports.EnigError = EnigError; exports.Errors = { - General : (reason, reasonCode) => new EnigError('An error occurred', -33000, reason, reasonCode), - MenuStack : (reason, reasonCode) => new EnigError('Menu stack error', -33001, reason, reasonCode), - DoesNotExist : (reason, reasonCode) => new EnigError('Object does not exist', -33002, reason, reasonCode), - AccessDenied : (reason, reasonCode) => new EnigError('Access denied', -32003, reason, reasonCode), - Invalid : (reason, reasonCode) => new EnigError('Invalid', -32004, reason, reasonCode), - ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode), - MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode), - UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode), - MissingParam : (reason, reasonCode) => new EnigError('Missing paramater(s)', -32008, reason, reasonCode), + General : (reason, reasonCode) => new EnigError('An error occurred', -33000, reason, reasonCode), + MenuStack : (reason, reasonCode) => new EnigError('Menu stack error', -33001, reason, reasonCode), + DoesNotExist : (reason, reasonCode) => new EnigError('Object does not exist', -33002, reason, reasonCode), + AccessDenied : (reason, reasonCode) => new EnigError('Access denied', -32003, reason, reasonCode), + Invalid : (reason, reasonCode) => new EnigError('Invalid', -32004, reason, reasonCode), + ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode), + MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode), + UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode), + MissingParam : (reason, reasonCode) => new EnigError('Missing paramter(s)', -32008, reason, reasonCode), + MissingMci : (reason, reasonCode) => new EnigError('Missing required MCI code(s)', -32009, reason, reasonCode), + BadLogin : (reason, reasonCode) => new EnigError('Bad login attempt', -32010, reason, reasonCode), + UserInterrupt : (reason, reasonCode) => new EnigError('User interrupted', -32011, reason, reasonCode), + NothingToDo : (reason, reasonCode) => new EnigError('Nothing to do', -32012, reason, reasonCode), }; exports.ErrorReasons = { - AlreadyThere : 'ALREADYTHERE', - InvalidNextMenu : 'BADNEXT', - NoPreviousMenu : 'NOPREV', - NoConditionMatch : 'NOCONDMATCH', - NotEnabled : 'NOTENABLED', -}; \ No newline at end of file + AlreadyThere : 'ALREADYTHERE', + InvalidNextMenu : 'BADNEXT', + NoPreviousMenu : 'NOPREV', + NoConditionMatch : 'NOCONDMATCH', + NotEnabled : 'NOTENABLED', + AlreadyLoggedIn : 'ALREADYLOGGEDIN', + TooMany : 'TOOMANY', + Disabled : 'DISABLED', + Inactive : 'INACTIVE', + Locked : 'LOCKED', + NotAllowed : 'NOTALLOWED', + Invalid2FA : 'INVALID2FA', +}; diff --git a/core/enigma_assert.js b/core/enigma_assert.js index 2001825d..34f9beed 100644 --- a/core/enigma_assert.js +++ b/core/enigma_assert.js @@ -1,18 +1,18 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const Log = require('./logger.js').log; +// ENiGMA½ +const Config = require('./config.js').get; +const Log = require('./logger.js').log; -// deps -const assert = require('assert'); +// deps +const assert = require('assert'); module.exports = function(condition, message) { - if(Config.debug.assertsEnabled) { - assert.apply(this, arguments); - } else if(!(condition)) { - const stack = new Error().stack; - Log.error( { condition : condition, stack : stack }, message || 'Assertion failed' ); - } + if(Config().debug.assertsEnabled) { + assert.apply(this, arguments); + } else if(!(condition)) { + const stack = new Error().stack; + Log.error( { condition : condition, stack : stack }, message || 'Assertion failed' ); + } }; diff --git a/core/event_scheduler.js b/core/event_scheduler.js index a1c2f6ef..4b7da062 100644 --- a/core/event_scheduler.js +++ b/core/event_scheduler.js @@ -1,251 +1,285 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const PluginModule = require('./plugin_module.js').PluginModule; -const Config = require('./config.js').config; -const Log = require('./logger.js').log; +// ENiGMA½ +const PluginModule = require('./plugin_module.js').PluginModule; +const Config = require('./config.js').get; +const Log = require('./logger.js').log; +const { Errors } = require('./enig_error.js'); -const _ = require('lodash'); -const later = require('later'); -const path = require('path'); -const pty = require('ptyw.js'); -const gaze = require('gaze'); -const moment = require('moment'); +const _ = require('lodash'); +const later = require('later'); +const path = require('path'); +const pty = require('node-pty'); +const sane = require('sane'); +const moment = require('moment'); +const paths = require('path'); +const fse = require('fs-extra'); -exports.getModule = EventSchedulerModule; -exports.EventSchedulerModule = EventSchedulerModule; // allow for loadAndStart +exports.getModule = EventSchedulerModule; +exports.EventSchedulerModule = EventSchedulerModule; // allow for loadAndStart exports.moduleInfo = { - name : 'Event Scheduler', - desc : 'Support for scheduling arbritary events', - author : 'NuSkooler', + name : 'Event Scheduler', + desc : 'Support for scheduling arbritary events', + author : 'NuSkooler', }; -const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:)([^\0]+)?$/; -const ACTION_REGEXP = /\@(method|execute)\:([^\0]+)?$/; +const SCHEDULE_REGEXP = /(?:^|or )?(@watch:)([^\0]+)?$/; +const ACTION_REGEXP = /@(method|execute):([^\0]+)?$/; class ScheduledEvent { - constructor(events, name) { - this.name = name; - this.schedule = this.parseScheduleString(events[name].schedule); - this.action = this.parseActionSpec(events[name].action); - if(this.action) { - this.action.args = events[name].args || []; - } - } - - get isValid() { - if((!this.schedule || (!this.schedule.sched && !this.schedule.watchFile)) || !this.action) { - return false; - } - - if('method' === this.action.type && !this.action.location) { - return false; - } - - return true; - } - - parseScheduleString(schedStr) { - if(!schedStr) { - return false; - } - - let schedule = {}; - - const m = SCHEDULE_REGEXP.exec(schedStr); - if(m) { - schedStr = schedStr.substr(0, m.index).trim(); - - if('@watch:' === m[1]) { - schedule.watchFile = m[2]; - } - } + constructor(events, name) { + this.name = name; + this.schedule = this.parseScheduleString(events[name].schedule); + this.action = this.parseActionSpec(events[name].action); + if(this.action) { + this.action.args = events[name].args || []; + } + } - if(schedStr.length > 0) { - const sched = later.parse.text(schedStr); - if(-1 === sched.error) { - schedule.sched = sched; - } - } - - // return undefined if we couldn't parse out anything useful - if(!_.isEmpty(schedule)) { - return schedule; - } - } - - parseActionSpec(actionSpec) { - if(actionSpec) { - if('@' === actionSpec[0]) { - const m = ACTION_REGEXP.exec(actionSpec); - if(m) { - if(m[2].indexOf(':') > -1) { - const parts = m[2].split(':'); - return { - type : m[1], - location : parts[0], - what : parts[1], - }; - } else { - return { - type : m[1], - what : m[2], - }; - } - } - } else { - return { - type : 'execute', - what : actionSpec, - }; - } - } - } + get isValid() { + if((!this.schedule || (!this.schedule.sched && !this.schedule.watchFile)) || !this.action) { + return false; + } - executeAction(reason, cb) { - Log.info( { eventName : this.name, action : this.action, reason : reason }, 'Executing scheduled event action...'); + if('method' === this.action.type && !this.action.location) { + return false; + } - if('method' === this.action.type) { - const modulePath = path.join(__dirname, '../', this.action.location); // enigma-bbs base + supplied location (path/file.js') - try { - const methodModule = require(modulePath); - methodModule[this.action.what](this.action.args, err => { - if(err) { - Log.debug( - { error : err.toString(), eventName : this.name, action : this.action }, - 'Error performing scheduled event action'); - } - - return cb(err); - }); - } catch(e) { - Log.warn( - { error : e.toString(), eventName : this.name, action : this.action }, - 'Failed to perform scheduled event action'); - - return cb(e); - } - } else if('execute' === this.action.type) { - const opts = { - // :TODO: cwd - name : this.name, - cols : 80, - rows : 24, - env : process.env, - }; + return true; + } - const proc = pty.spawn(this.action.what, this.action.args, opts); + parseScheduleString(schedStr) { + if(!schedStr) { + return false; + } - proc.once('exit', exitCode => { - if(exitCode) { - Log.warn( - { eventName : this.name, action : this.action, exitCode : exitCode }, - 'Bad exit code while performing scheduled event action'); - } - return cb(exitCode ? new Error(`Bad exit code while performing scheduled event action: ${exitCode}`) : null); - }); - } - } + let schedule = {}; + + const m = SCHEDULE_REGEXP.exec(schedStr); + if(m) { + schedStr = schedStr.substr(0, m.index).trim(); + + if('@watch:' === m[1]) { + schedule.watchFile = m[2]; + } + } + + if(schedStr.length > 0) { + const sched = later.parse.text(schedStr); + if(-1 === sched.error) { + schedule.sched = sched; + } + } + + // return undefined if we couldn't parse out anything useful + if(!_.isEmpty(schedule)) { + return schedule; + } + } + + parseActionSpec(actionSpec) { + if(actionSpec) { + if('@' === actionSpec[0]) { + const m = ACTION_REGEXP.exec(actionSpec); + if(m) { + if(m[2].indexOf(':') > -1) { + const parts = m[2].split(':'); + return { + type : m[1], + location : parts[0], + what : parts[1], + }; + } else { + return { + type : m[1], + what : m[2], + }; + } + } + } else { + return { + type : 'execute', + what : actionSpec, + }; + } + } + } + + executeAction(reason, cb) { + Log.info( { eventName : this.name, action : this.action, reason : reason }, 'Executing scheduled event action...'); + + if('method' === this.action.type) { + const modulePath = path.join(__dirname, '../', this.action.location); // enigma-bbs base + supplied location (path/file.js') + try { + const methodModule = require(modulePath); + methodModule[this.action.what](this.action.args, err => { + if(err) { + Log.debug( + { error : err.message, eventName : this.name, action : this.action }, + 'Error performing scheduled event action'); + } + + return cb(err); + }); + } catch(e) { + Log.warn( + { error : e.message, eventName : this.name, action : this.action }, + 'Failed to perform scheduled event action'); + + return cb(e); + } + } else if('execute' === this.action.type) { + const opts = { + // :TODO: cwd + name : this.name, + cols : 80, + rows : 24, + env : process.env, + }; + + let proc; + try { + proc = pty.spawn(this.action.what, this.action.args, opts); + } catch(e) { + Log.warn( + { + error : 'Failed to spawn @execute process', + reason : e.message, + eventName : this.name, + action : this.action, + what : this.action.what, + args : this.action.args + } + ); + return cb(e); + } + + proc.once('exit', exitCode => { + if(exitCode) { + Log.warn( + { eventName : this.name, action : this.action, exitCode : exitCode }, + 'Bad exit code while performing scheduled event action'); + } + return cb(exitCode ? Errors.ExternalProcess(`Bad exit code while performing scheduled event action: ${exitCode}`) : null); + }); + } + } } function EventSchedulerModule(options) { - PluginModule.call(this, options); - - if(_.has(Config, 'eventScheduler')) { - this.moduleConfig = Config.eventScheduler; - } - - const self = this; - this.runningActions = new Set(); - - this.performAction = function(schedEvent, reason) { - if(self.runningActions.has(schedEvent.name)) { - return; // already running - } - - self.runningActions.add(schedEvent.name); + PluginModule.call(this, options); - schedEvent.executeAction(reason, () => { - self.runningActions.delete(schedEvent.name); - }); - }; + const config = Config(); + if(_.has(config, 'eventScheduler')) { + this.moduleConfig = config.eventScheduler; + } + + const self = this; + this.runningActions = new Set(); + + this.performAction = function(schedEvent, reason) { + if(self.runningActions.has(schedEvent.name)) { + return; // already running + } + + self.runningActions.add(schedEvent.name); + + schedEvent.executeAction(reason, () => { + self.runningActions.delete(schedEvent.name); + }); + }; } -// convienence static method for direct load + start +// convienence static method for direct load + start EventSchedulerModule.loadAndStart = function(cb) { - const loadModuleEx = require('./module_util.js').loadModuleEx; - - const loadOpts = { - name : path.basename(__filename, '.js'), - path : __dirname, - }; - - loadModuleEx(loadOpts, (err, mod) => { - if(err) { - return cb(err); - } - - const modInst = new mod.getModule(); - modInst.startup( err => { - return cb(err, modInst); - }); - }); + const loadModuleEx = require('./module_util.js').loadModuleEx; + + const loadOpts = { + name : path.basename(__filename, '.js'), + path : __dirname, + }; + + loadModuleEx(loadOpts, (err, mod) => { + if(err) { + return cb(err); + } + + const modInst = new mod.getModule(); + modInst.startup( err => { + return cb(err, modInst); + }); + }); }; EventSchedulerModule.prototype.startup = function(cb) { - - this.eventTimers = []; - const self = this; - - if(this.moduleConfig && _.has(this.moduleConfig, 'events')) { - const events = Object.keys(this.moduleConfig.events).map( name => { - return new ScheduledEvent(this.moduleConfig.events, name); - }); - - events.forEach( schedEvent => { - if(!schedEvent.isValid) { - Log.warn( { eventName : schedEvent.name }, 'Invalid scheduled event entry'); - return; - } - - Log.debug( - { - eventName : schedEvent.name, - schedule : this.moduleConfig.events[schedEvent.name].schedule, - action : schedEvent.action, - next : moment(later.schedule(schedEvent.schedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a') - }, - 'Scheduled event loaded' - ); - if(schedEvent.schedule.sched) { - this.eventTimers.push(later.setInterval( () => { - self.performAction(schedEvent, 'Schedule'); - }, schedEvent.schedule.sched)); - } + this.eventTimers = []; + const self = this; - if(schedEvent.schedule.watchFile) { - gaze(schedEvent.schedule.watchFile, (err, watcher) => { - // :TODO: should track watched files & stop watching @ shutdown - watcher.on('all', (watchEvent, watchedPath) => { - if(schedEvent.schedule.watchFile === watchedPath) { - self.performAction(schedEvent, `Watch file: ${watchedPath}`); - } - }); - }); - } - }); - } - - cb(null); + if(this.moduleConfig && _.has(this.moduleConfig, 'events')) { + const events = Object.keys(this.moduleConfig.events).map( name => { + return new ScheduledEvent(this.moduleConfig.events, name); + }); + + events.forEach( schedEvent => { + if(!schedEvent.isValid) { + Log.warn( { eventName : schedEvent.name }, 'Invalid scheduled event entry'); + return; + } + + Log.debug( + { + eventName : schedEvent.name, + schedule : this.moduleConfig.events[schedEvent.name].schedule, + action : schedEvent.action, + next : schedEvent.schedule.sched ? moment(later.schedule(schedEvent.schedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a') : 'N/A', + }, + 'Scheduled event loaded' + ); + + if(schedEvent.schedule.sched) { + this.eventTimers.push(later.setInterval( () => { + self.performAction(schedEvent, 'Schedule'); + }, schedEvent.schedule.sched)); + } + + if(schedEvent.schedule.watchFile) { + const watcher = sane( + paths.dirname(schedEvent.schedule.watchFile), + { + glob : `**/${paths.basename(schedEvent.schedule.watchFile)}` + } + ); + + // :TODO: should track watched files & stop watching @ shutdown? + + [ 'change', 'add', 'delete' ].forEach(event => { + watcher.on(event, (fileName, fileRoot) => { + const eventPath = paths.join(fileRoot, fileName); + if(schedEvent.schedule.watchFile === eventPath) { + self.performAction(schedEvent, `Watch file: ${eventPath}`); + } + }); + }); + + fse.exists(schedEvent.schedule.watchFile, exists => { + if(exists) { + self.performAction(schedEvent, `Watch file: ${schedEvent.schedule.watchFile}`); + } + }); + } + }); + } + + cb(null); }; EventSchedulerModule.prototype.shutdown = function(cb) { - if(this.eventTimers) { - this.eventTimers.forEach( et => et.clear() ); - } - - cb(null); + if(this.eventTimers) { + this.eventTimers.forEach( et => et.clear() ); + } + + cb(null); }; diff --git a/core/events.js b/core/events.js index 8e16a374..541a5cae 100644 --- a/core/events.js +++ b/core/events.js @@ -1,73 +1,76 @@ /* jslint node: true */ 'use strict'; -const paths = require('path'); -const events = require('events'); -const Log = require('./logger.js').log; +const events = require('events'); +const Log = require('./logger.js').log; +const SystemEvents = require('./system_events.js'); -// deps -const _ = require('lodash'); -const async = require('async'); -const glob = require('glob'); +// deps +const _ = require('lodash'); module.exports = new class Events extends events.EventEmitter { - constructor() { - super(); - } + constructor() { + super(); + this.setMaxListeners(64); // :TODO: play with this... + } - addListener(event, listener) { - Log.trace( { event : event }, 'Registering event listener'); - return super.addListener(event, listener); - } + getSystemEvents() { + return SystemEvents; + } - emit(event, ...args) { - Log.trace( { event : event }, 'Emitting event'); - return super.emit(event, args); - } + addListener(event, listener) { + Log.trace( { event : event }, 'Registering event listener'); + return super.addListener(event, listener); + } - on(event, listener) { - Log.trace( { event : event }, 'Registering event listener'); - return super.on(event, listener); - } + emit(event, ...args) { + Log.trace( { event : event }, 'Emitting event'); + return super.emit(event, ...args); + } - once(event, listener) { - Log.trace( { event : event }, 'Registering single use event listener'); - return super.once(event, listener); - } + on(event, listener) { + Log.trace( { event : event }, 'Registering event listener'); + return super.on(event, listener); + } - removeListener(event, listener) { - Log.trace( { event : event }, 'Removing listener'); - return super.removeListener(event, listener); - } + once(event, listener) { + Log.trace( { event : event }, 'Registering single use event listener'); + return super.once(event, listener); + } - startup(cb) { - async.each(require('./module_util.js').getModulePaths(), (modulePath, nextPath) => { - glob('*{.js,/*.js}', { cwd : modulePath }, (err, files) => { - if(err) { - return nextPath(err); - } + // + // Listen to multiple events for a single listener. + // Called with: listener(event, eventName) + // + // The returned object must be used with removeMultipleEventListener() + // + addMultipleEventListener(events, listener) { + Log.trace( { events }, 'Registering event listeners'); - async.each(files, (moduleName, nextModule) => { - modulePath = paths.join(modulePath, moduleName); + const listeners = []; - try { - const mod = require(modulePath); - - if(_.isFunction(mod.registerEvents)) { - // :TODO: ... or just systemInit() / systemShutdown() & mods could call Events.on() / Events.removeListener() ? - mod.registerEvents(this); - } - } catch(e) { + events.forEach(eventName => { + const listenWrapper = _.partial(listener, _, eventName); + this.on(eventName, listenWrapper); + listeners.push( { eventName, listenWrapper } ); + }); - } + return listeners; + } - return nextModule(null); - }, err => { - return nextPath(err); - }); - }); - }, err => { - return cb(err); - }); - } + removeMultipleEventListener(listeners) { + Log.trace( { events }, 'Removing listeners'); + listeners.forEach(listener => { + this.removeListener(listener.eventName, listener.listenWrapper); + }); + } + + removeListener(event, listener) { + Log.trace( { event : event }, 'Removing listener'); + return super.removeListener(event, listener); + } + + startup(cb) { + return cb(null); + } }; diff --git a/core/exodus.js b/core/exodus.js index e77183ee..5ed29a4e 100644 --- a/core/exodus.js +++ b/core/exodus.js @@ -1,231 +1,244 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const Config = require('./config.js').config; -const Errors = require('./enig_error.js').Errors; -const Log = require('./logger.js').log; -const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent; +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { resetScreen } = require('./ansi_term.js'); +const Config = require('./config.js').get; +const { Errors } = require('./enig_error.js'); +const Log = require('./logger.js').log; +const { + getEnigmaUserAgent +} = require('./misc_util.js'); +const { + trackDoorRunBegin, + trackDoorRunEnd +} = require('./door_util.js'); -// deps -const async = require('async'); -const _ = require('lodash'); -const joinPath = require('path').join; -const crypto = require('crypto'); -const moment = require('moment'); -const https = require('https'); -const querystring = require('querystring'); -const fs = require('fs'); -const SSHClient = require('ssh2').Client; +// deps +const async = require('async'); +const _ = require('lodash'); +const joinPath = require('path').join; +const crypto = require('crypto'); +const moment = require('moment'); +const https = require('https'); +const querystring = require('querystring'); +const fs = require('fs-extra'); +const SSHClient = require('ssh2').Client; /* - Configuration block: + Configuration block: - - someDoor: { - module: exodus - config: { - // defaults - ticketHost: oddnetwork.org - ticketPort: 1984 - ticketPath: /exodus - rejectUnauthorized: false // set to true to allow untrusted CA's (dangerous!) - sshHost: oddnetwork.org - sshPort: 22 - sshUser: exodus - sshKeyPem: /path/to/enigma-bbs/misc/exodus.id_rsa - // optional - caPem: /path/to/cacerts.pem // see https://curl.haxx.se/docs/caextract.html + someDoor: { + module: exodus + config: { + // defaults + ticketHost: oddnetwork.org + ticketPort: 1984 + ticketPath: /exodus + rejectUnauthorized: false // set to true to allow untrusted CA's (dangerous!) + sshHost: oddnetwork.org + sshPort: 22 + sshUser: exodus + sshKeyPem: /path/to/enigma-bbs/misc/exodus.id_rsa - // required - board: XXXX - key: XXXX - door: some_door - } - } + // optional + caPem: /path/to/cacerts.pem // see https://curl.haxx.se/docs/caextract.html + + // required + board: XXXX + key: XXXX + door: some_door + } + } */ exports.moduleInfo = { - name : 'Exodus', - desc : 'Exodus Door Server Access Module - https://oddnetwork.org/exodus/', - author : 'NuSkooler', + name : 'Exodus', + desc : 'Exodus Door Server Access Module - https://oddnetwork.org/exodus/', + author : 'NuSkooler', }; exports.getModule = class ExodusModule extends MenuModule { - constructor(options) { - super(options); + constructor(options) { + super(options); - this.config = options.menuConfig.config || {}; - this.config.ticketHost = this.config.ticketHost || 'oddnetwork.org'; - this.config.ticketPort = this.config.ticketPort || 1984, - this.config.ticketPath = this.config.ticketPath || '/exodus'; - this.config.rejectUnauthorized = _.get(this.config, 'rejectUnauthorized', true); - this.config.sshHost = this.config.sshHost || this.config.ticketHost; - this.config.sshPort = this.config.sshPort || 22; - this.config.sshUser = this.config.sshUser || 'exodus_server'; - this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config.paths.misc, 'exodus.id_rsa'); - } + this.config = options.menuConfig.config || {}; + this.config.ticketHost = this.config.ticketHost || 'oddnetwork.org'; + this.config.ticketPort = this.config.ticketPort || 1984, + this.config.ticketPath = this.config.ticketPath || '/exodus'; + this.config.rejectUnauthorized = _.get(this.config, 'rejectUnauthorized', true); + this.config.sshHost = this.config.sshHost || this.config.ticketHost; + this.config.sshPort = this.config.sshPort || 22; + this.config.sshUser = this.config.sshUser || 'exodus_server'; + this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config().paths.misc, 'exodus.id_rsa'); + } - initSequence() { + initSequence() { - const self = this; - let clientTerminated = false; + const self = this; + let clientTerminated = false; - async.waterfall( - [ - function validateConfig(callback) { - // very basic validation on optionals - async.each( [ 'board', 'key', 'door' ], (key, next) => { - return _.isString(self.config[key]) ? next(null) : next(Errors.MissingConfig(`Config requires "${key}"!`)); - }, callback); - }, - function loadCertAuthorities(callback) { - if(!_.isString(self.config.caPem)) { - return callback(null, null); - } + async.waterfall( + [ + function validateConfig(callback) { + // very basic validation on optionals + async.each( [ 'board', 'key', 'door' ], (key, next) => { + return _.isString(self.config[key]) ? next(null) : next(Errors.MissingConfig(`Config requires "${key}"!`)); + }, callback); + }, + function loadCertAuthorities(callback) { + if(!_.isString(self.config.caPem)) { + return callback(null, null); + } - fs.readFile(self.config.caPem, (err, certAuthorities) => { - return callback(err, certAuthorities); - }); - }, - function getTicket(certAuthorities, callback) { - const now = moment.utc().unix(); - const sha256 = crypto.createHash('sha256').update(`${self.config.key}${now}`).digest('hex'); - const token = `${sha256}|${now}`; + fs.readFile(self.config.caPem, (err, certAuthorities) => { + return callback(err, certAuthorities); + }); + }, + function getTicket(certAuthorities, callback) { + const now = moment.utc().unix(); + const sha256 = crypto.createHash('sha256').update(`${self.config.key}${now}`).digest('hex'); + const token = `${sha256}|${now}`; - const postData = querystring.stringify({ - token : token, - board : self.config.board, - user : self.client.user.username, - door : self.config.door, - }); + const postData = querystring.stringify({ + token : token, + board : self.config.board, + user : self.client.user.username, + door : self.config.door, + }); - const reqOptions = { - hostname : self.config.ticketHost, - port : self.config.ticketPort, - path : self.config.ticketPath, - rejectUnauthorized : self.config.rejectUnauthorized, - method : 'POST', - headers : { - 'Content-Type' : 'application/x-www-form-urlencoded', - 'Content-Length' : postData.length, - 'User-Agent' : getEnigmaUserAgent(), - } - }; + const reqOptions = { + hostname : self.config.ticketHost, + port : self.config.ticketPort, + path : self.config.ticketPath, + rejectUnauthorized : self.config.rejectUnauthorized, + method : 'POST', + headers : { + 'Content-Type' : 'application/x-www-form-urlencoded', + 'Content-Length' : postData.length, + 'User-Agent' : getEnigmaUserAgent(), + } + }; - if(certAuthorities) { - reqOptions.ca = certAuthorities; - } + if(certAuthorities) { + reqOptions.ca = certAuthorities; + } - let ticket = ''; - const req = https.request(reqOptions, res => { - res.on('data', data => { - ticket += data; - }); + let ticket = ''; + const req = https.request(reqOptions, res => { + res.on('data', data => { + ticket += data; + }); - res.on('end', () => { - if(ticket.length !== 36) { - return callback(Errors.Invalid(`Invalid Exodus ticket: ${ticket}`)); - } + res.on('end', () => { + if(ticket.length !== 36) { + return callback(Errors.Invalid(`Invalid Exodus ticket: ${ticket}`)); + } - return callback(null, ticket); - }); - }); + return callback(null, ticket); + }); + }); - req.on('error', err => { - return callback(Errors.General(`Exodus error: ${err.message}`)); - }); + req.on('error', err => { + return callback(Errors.General(`Exodus error: ${err.message}`)); + }); - req.write(postData); - req.end(); - }, - function loadPrivateKey(ticket, callback) { - fs.readFile(self.config.sshKeyPem, (err, privateKey) => { - return callback(err, ticket, privateKey); - }); - }, - function establishSecureConnection(ticket, privateKey, callback) { + req.write(postData); + req.end(); + }, + function loadPrivateKey(ticket, callback) { + fs.readFile(self.config.sshKeyPem, (err, privateKey) => { + return callback(err, ticket, privateKey); + }); + }, + function establishSecureConnection(ticket, privateKey, callback) { - let pipeRestored = false; - let pipedStream; + let pipeRestored = false; + let pipedStream; + let doorTracking; - function restorePipe() { - if(pipedStream && !pipeRestored && !clientTerminated) { - self.client.term.output.unpipe(pipedStream); - self.client.term.output.resume(); - } - } + function restorePipe() { + if(pipedStream && !pipeRestored && !clientTerminated) { + self.client.term.output.unpipe(pipedStream); + self.client.term.output.resume(); - self.client.term.write(resetScreen()); - self.client.term.write('Connecting to Exodus server, please wait...\n'); + if(doorTracking) { + trackDoorRunEnd(doorTracking); + } + } + } - const sshClient = new SSHClient(); + self.client.term.write(resetScreen()); + self.client.term.write('Connecting to Exodus server, please wait...\n'); - const window = { - rows : self.client.term.termHeight, - cols : self.client.term.termWidth, - width : 0, - height : 0, - term : 'vt100', // Want to pass |self.client.term.termClient| here, but we end up getting hung up on :( - }; + const sshClient = new SSHClient(); - const options = { - env : { - exodus : ticket, - }, - }; + const window = { + rows : self.client.term.termHeight, + cols : self.client.term.termWidth, + width : 0, + height : 0, + term : 'vt100', // Want to pass |self.client.term.termClient| here, but we end up getting hung up on :( + }; - sshClient.on('ready', () => { - self.client.once('end', () => { - self.client.log.info('Connection ended. Terminating Exodus connection'); - clientTerminated = true; - return sshClient.end(); - }); + const options = { + env : { + exodus : ticket, + }, + }; - sshClient.shell(window, options, (err, stream) => { - pipedStream = stream; // :TODO: ewwwwwwwww hack - self.client.term.output.pipe(stream); + sshClient.on('ready', () => { + self.client.once('end', () => { + self.client.log.info('Connection ended. Terminating Exodus connection'); + clientTerminated = true; + return sshClient.end(); + }); - stream.on('data', d => { - return self.client.term.rawWrite(d); - }); + sshClient.shell(window, options, (err, stream) => { + doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`); - stream.on('close', () => { - restorePipe(); - return sshClient.end(); - }); + pipedStream = stream; // :TODO: ewwwwwwwww hack + self.client.term.output.pipe(stream); - stream.on('error', err => { - Log.warn( { error : err.message }, 'Exodus SSH client stream error'); - }); - }); - }); + stream.on('data', d => { + return self.client.term.rawWrite(d); + }); - sshClient.on('close', () => { - restorePipe(); - return callback(null); - }); + stream.on('close', () => { + restorePipe(); + return sshClient.end(); + }); - sshClient.connect({ - host : self.config.sshHost, - port : self.config.sshPort, - username : self.config.sshUser, - privateKey : privateKey, - }); - } - ], - err => { - if(err) { - self.client.log.warn( { error : err.message }, 'Exodus error'); - } + stream.on('error', err => { + Log.warn( { error : err.message }, 'Exodus SSH client stream error'); + }); + }); + }); - if(!clientTerminated) { - self.prevMenu(); - } - } - ); - } + sshClient.on('close', () => { + restorePipe(); + return callback(null); + }); + + sshClient.connect({ + host : self.config.sshHost, + port : self.config.sshPort, + username : self.config.sshUser, + privateKey : privateKey, + }); + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'Exodus error'); + } + + if(!clientTerminated) { + self.prevMenu(); + } + } + ); + } }; diff --git a/core/file_area_filter_edit.js b/core/file_area_filter_edit.js new file mode 100644 index 00000000..e20d766b --- /dev/null +++ b/core/file_area_filter_edit.js @@ -0,0 +1,340 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('./file_base_filter.js'); +const stringFormat = require('./string_format.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); + +exports.moduleInfo = { + name : 'File Area Filter Editor', + desc : 'Module for adding, deleting, and modifying file base filters', + author : 'NuSkooler', +}; + +const MciViewIds = { + editor : { + searchTerms : 1, + tags : 2, + area : 3, + sort : 4, + order : 5, + filterName : 6, + navMenu : 7, + + // :TODO: use the customs new standard thing - filter obj can have active/selected, etc. + selectedFilterInfo : 10, // { ...filter object ... } + activeFilterInfo : 11, // { ...filter object ... } + error : 12, // validation errors + } +}; + +exports.getModule = class FileAreaFilterEdit extends MenuModule { + constructor(options) { + super(options); + + this.filtersArray = new FileBaseFilters(this.client).toArray(); // ordered, such that we can index into them + this.currentFilterIndex = 0; // into |filtersArray| + + // + // Lexical sort + keep currently active filter (if any) as the first item in |filtersArray| + // + const activeFilter = FileBaseFilters.getActiveFilter(this.client); + this.filtersArray.sort( (filterA, filterB) => { + if(activeFilter) { + if(filterA.uuid === activeFilter.uuid) { + return -1; + } + if(filterB.uuid === activeFilter.uuid) { + return 1; + } + } + + return filterA.name.localeCompare(filterB.name, { sensitivity : false, numeric : true } ); + }); + + this.menuMethods = { + saveFilter : (formData, extraArgs, cb) => { + return this.saveCurrentFilter(formData, cb); + }, + prevFilter : (formData, extraArgs, cb) => { + this.currentFilterIndex -= 1; + if(this.currentFilterIndex < 0) { + this.currentFilterIndex = this.filtersArray.length - 1; + } + this.loadDataForFilter(this.currentFilterIndex); + return cb(null); + }, + nextFilter : (formData, extraArgs, cb) => { + this.currentFilterIndex += 1; + if(this.currentFilterIndex >= this.filtersArray.length) { + this.currentFilterIndex = 0; + } + this.loadDataForFilter(this.currentFilterIndex); + return cb(null); + }, + makeFilterActive : (formData, extraArgs, cb) => { + const filters = new FileBaseFilters(this.client); + filters.setActive(this.filtersArray[this.currentFilterIndex].uuid); + + this.updateActiveLabel(); + + return cb(null); + }, + newFilter : (formData, extraArgs, cb) => { + this.currentFilterIndex = this.filtersArray.length; // next avail slot + this.clearForm(MciViewIds.editor.searchTerms); + return cb(null); + }, + deleteFilter : (formData, extraArgs, cb) => { + const selectedFilter = this.filtersArray[this.currentFilterIndex]; + const filterUuid = selectedFilter.uuid; + + // cannot delete built-in/system filters + if(true === selectedFilter.system) { + this.showError('Cannot delete built in filters!'); + return cb(null); + } + + this.filtersArray.splice(this.currentFilterIndex, 1); // remove selected entry + + // remove from stored properties + const filters = new FileBaseFilters(this.client); + filters.remove(filterUuid); + filters.persist( () => { + + // + // If the item was also the active filter, we need to make a new one active + // + if(filterUuid === this.client.user.properties[UserProps.FileBaseFilterActiveUuid]) { + const newActive = this.filtersArray[this.currentFilterIndex]; + if(newActive) { + filters.setActive(newActive.uuid); + } else { + // nothing to set active to + this.client.user.removeProperty('file_base_filter_active_uuid'); + } + } + + // update UI + this.updateActiveLabel(); + + if(this.filtersArray.length > 0) { + this.loadDataForFilter(this.currentFilterIndex); + } else { + this.clearForm(); + } + return cb(null); + }); + }, + + viewValidationListener : (err, cb) => { + const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error); + let newFocusId; + + if(errorView) { + if(err) { + errorView.setText(err.message); + err.view.clearText(); // clear out the invalid data + } else { + errorView.clearText(); + } + } + + return cb(newFocusId); + }, + }; + } + + showError(errMsg) { + const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error); + if(errorView) { + if(errMsg) { + errorView.setText(errMsg); + } else { + errorView.clearText(); + } + } + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.addViewController( 'editor', new ViewController( { client : this.client } ) ); + + async.series( + [ + function loadFromConfig(callback) { + return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function populateAreas(callback) { + self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); + + const areasView = vc.getView(MciViewIds.editor.area); + if(areasView) { + areasView.setItems( self.availAreas.map( a => a.name ) ); + } + + self.updateActiveLabel(); + self.loadDataForFilter(self.currentFilterIndex); + self.viewControllers.editor.resetInitialFocus(); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + getCurrentFilter() { + return this.filtersArray[this.currentFilterIndex]; + } + + setText(mciId, text) { + const view = this.viewControllers.editor.getView(mciId); + if(view) { + view.setText(text); + } + } + + updateActiveLabel() { + const activeFilter = FileBaseFilters.getActiveFilter(this.client); + if(activeFilter) { + const activeFormat = this.menuConfig.config.activeFormat || '{name}'; + this.setText(MciViewIds.editor.activeFilterInfo, stringFormat(activeFormat, activeFilter)); + } + } + + setFocusItemIndex(mciId, index) { + const view = this.viewControllers.editor.getView(mciId); + if(view) { + view.setFocusItemIndex(index); + } + } + + clearForm(newFocusId) { + [ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => { + this.setText(mciId, ''); + }); + + [ MciViewIds.editor.area, MciViewIds.editor.order, MciViewIds.editor.sort ].forEach(mciId => { + this.setFocusItemIndex(mciId, 0); + }); + + if(newFocusId) { + this.viewControllers.editor.switchFocus(newFocusId); + } else { + this.viewControllers.editor.resetInitialFocus(); + } + } + + getSelectedAreaTag(index) { + if(0 === index) { + return ''; // -ALL- + } + const area = this.availAreas[index]; + if(!area) { + return ''; + } + return area.areaTag; + } + + getOrderBy(index) { + return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; + } + + setAreaIndexFromCurrentFilter() { + let index; + const filter = this.getCurrentFilter(); + if(filter) { + // special treatment: areaTag saved as blank ("") if -ALL- + index = (filter.areaTag && this.availAreas.findIndex(area => filter.areaTag === area.areaTag)) || 0; + } else { + index = 0; + } + this.setFocusItemIndex(MciViewIds.editor.area, index); + } + + setOrderByFromCurrentFilter() { + let index; + const filter = this.getCurrentFilter(); + if(filter) { + index = FileBaseFilters.OrderByValues.findIndex( ob => filter.order === ob ) || 0; + } else { + index = 0; + } + this.setFocusItemIndex(MciViewIds.editor.order, index); + } + + setSortByFromCurrentFilter() { + let index; + const filter = this.getCurrentFilter(); + if(filter) { + index = FileBaseFilters.SortByValues.findIndex( sb => filter.sort === sb ) || 0; + } else { + index = 0; + } + this.setFocusItemIndex(MciViewIds.editor.sort, index); + } + + getSortBy(index) { + return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; + } + + setFilterValuesFromFormData(filter, formData) { + filter.name = formData.value.name; + filter.areaTag = this.getSelectedAreaTag(formData.value.areaIndex); + filter.terms = formData.value.searchTerms; + filter.tags = formData.value.tags; + filter.order = this.getOrderBy(formData.value.orderByIndex); + filter.sort = this.getSortBy(formData.value.sortByIndex); + } + + saveCurrentFilter(formData, cb) { + const filters = new FileBaseFilters(this.client); + const selectedFilter = this.filtersArray[this.currentFilterIndex]; + + if(selectedFilter) { + // *update* currently selected filter + this.setFilterValuesFromFormData(selectedFilter, formData); + filters.replace(selectedFilter.uuid, selectedFilter); + } else { + // add a new entry; note that UUID will be generated + const newFilter = {}; + this.setFilterValuesFromFormData(newFilter, formData); + + // set current to what we just saved + newFilter.uuid = filters.add(newFilter); + + // add to our array (at current index position) + this.filtersArray[this.currentFilterIndex] = newFilter; + } + + return filters.persist(cb); + } + + loadDataForFilter(filterIndex) { + const filter = this.filtersArray[filterIndex]; + if(filter) { + this.setText(MciViewIds.editor.searchTerms, filter.terms); + this.setText(MciViewIds.editor.tags, filter.tags); + this.setText(MciViewIds.editor.filterName, filter.name); + + this.setAreaIndexFromCurrentFilter(); + this.setSortByFromCurrentFilter(); + this.setOrderByFromCurrentFilter(); + } + } +}; diff --git a/core/file_area_list.js b/core/file_area_list.js new file mode 100644 index 00000000..a62271ca --- /dev/null +++ b/core/file_area_list.js @@ -0,0 +1,728 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const ansi = require('./ansi_term.js'); +const theme = require('./theme.js'); +const FileEntry = require('./file_entry.js'); +const stringFormat = require('./string_format.js'); +const FileArea = require('./file_base_area.js'); +const Errors = require('./enig_error.js').Errors; +const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; +const ArchiveUtil = require('./archive_util.js'); +const Config = require('./config.js').get; +const DownloadQueue = require('./download_queue.js'); +const FileAreaWeb = require('./file_area_web.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const resolveMimeType = require('./mime_util.js').resolveMimeType; +const isAnsi = require('./string_util.js').isAnsi; +const controlCodesToAnsi = require('./color_codes.js').controlCodesToAnsi; + +// deps +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); +const paths = require('path'); + +exports.moduleInfo = { + name : 'File Area List', + desc : 'Lists contents of file an file area', + author : 'NuSkooler', +}; + +const FormIds = { + browse : 0, + details : 1, + detailsGeneral : 2, + detailsNfo : 3, + detailsFileList : 4, +}; + +const MciViewIds = { + browse : { + desc : 1, + navMenu : 2, + + customRangeStart : 10, // 10+ = customs + }, + details : { + navMenu : 1, + infoXyTop : 2, // %XY starting position for info area + infoXyBottom : 3, + + customRangeStart : 10, // 10+ = customs + }, + detailsGeneral : { + customRangeStart : 10, // 10+ = customs + }, + detailsNfo : { + nfo : 1, + + customRangeStart : 10, // 10+ = customs + }, + detailsFileList : { + fileList : 1, + + customRangeStart : 10, // 10+ = customs + }, +}; + +exports.getModule = class FileAreaList extends MenuModule { + + constructor(options) { + super(options); + + this.filterCriteria = _.get(options, 'extraArgs.filterCriteria'); + this.fileList = _.get(options, 'extraArgs.fileList'); + this.lastFileNextExit = _.get(options, 'extraArgs.lastFileNextExit', true); + + if(this.fileList) { + // we'll need to adjust position as well! + this.fileListPosition = 0; + } + + this.dlQueue = new DownloadQueue(this.client); + + if(!this.filterCriteria) { + this.filterCriteria = FileBaseFilters.getActiveFilter(this.client); + } + + if(_.isString(this.filterCriteria)) { + this.filterCriteria = JSON.parse(this.filterCriteria); + } + + if(_.has(options, 'lastMenuResult.value')) { + this.lastMenuResultValue = options.lastMenuResult.value; + } + + this.menuMethods = { + nextFile : (formData, extraArgs, cb) => { + if(this.fileListPosition + 1 < this.fileList.length) { + this.fileListPosition += 1; + + return this.displayBrowsePage(true, cb); // true=clerarScreen + } + + if(this.lastFileNextExit) { + return this.prevMenu(cb); + } + + return cb(null); + }, + prevFile : (formData, extraArgs, cb) => { + if(this.fileListPosition > 0) { + --this.fileListPosition; + + return this.displayBrowsePage(true, cb); // true=clearScreen + } + + return cb(null); + }, + viewDetails : (formData, extraArgs, cb) => { + this.viewControllers.browse.setFocus(false); + return this.displayDetailsPage(cb); + }, + detailsQuit : (formData, extraArgs, cb) => { + [ 'detailsNfo', 'detailsFileList', 'details' ].forEach(n => { + const vc = this.viewControllers[n]; + if(vc) { + vc.detachClientEvents(); + } + }); + + return this.displayBrowsePage(true, cb); // true=clearScreen + }, + toggleQueue : (formData, extraArgs, cb) => { + this.dlQueue.toggle(this.currentFileEntry); + this.updateQueueIndicator(); + return cb(null); + }, + showWebDownloadLink : (formData, extraArgs, cb) => { + return this.fetchAndDisplayWebDownloadLink(cb); + }, + displayHelp : (formData, extraArgs, cb) => { + return this.displayHelpPage(cb); + } + }; + } + + enter() { + super.enter(); + } + + leave() { + super.leave(); + } + + getSaveState() { + return { + fileList : this.fileList, + fileListPosition : this.fileListPosition, + }; + } + + restoreSavedState(savedState) { + if(savedState) { + this.fileList = savedState.fileList; + this.fileListPosition = savedState.fileListPosition; + } + } + + updateFileEntryWithMenuResult(cb) { + if(!this.lastMenuResultValue) { + return cb(null); + } + + if(_.isNumber(this.lastMenuResultValue.rating)) { + const fileId = this.fileList[this.fileListPosition]; + FileEntry.persistUserRating(fileId, this.client.user.userId, this.lastMenuResultValue.rating, err => { + if(err) { + this.client.log.warn( { error : err.message, fileId : fileId }, 'Failed to persist file rating' ); + } + return cb(null); + }); + } else { + return cb(null); + } + } + + initSequence() { + const self = this; + + async.series( + [ + function preInit(callback) { + return self.updateFileEntryWithMenuResult(callback); + }, + function beforeArt(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + return self.displayBrowsePage(false, err => { + if(err && 'NORESULTS' === err.reasonCode) { + self.gotoMenu(self.menuConfig.config.noResultsMenu || 'fileBaseListEntriesNoResults'); + } + return callback(err); + }); + } + ], + () => { + self.finishedLoading(); + } + ); + } + + populateCurrentEntryInfo(cb) { + const config = this.menuConfig.config; + const currEntry = this.currentFileEntry; + + const uploadTimestampFormat = config.uploadTimestampFormat || this.client.currentTheme.helpers.getDateFormat('short'); + const area = FileArea.getFileAreaByTag(currEntry.areaTag); + const hashTagsSep = config.hashTagsSep || ', '; + const isQueuedIndicator = config.isQueuedIndicator || 'Y'; + const isNotQueuedIndicator = config.isNotQueuedIndicator || 'N'; + + const entryInfo = currEntry.entryInfo = { + fileId : currEntry.fileId, + areaTag : currEntry.areaTag, + areaName : _.get(area, 'name') || 'N/A', + areaDesc : _.get(area, 'desc') || 'N/A', + fileSha256 : currEntry.fileSha256, + fileName : currEntry.fileName, + desc : currEntry.desc || '', + descLong : currEntry.descLong || '', + userRating : currEntry.userRating, + uploadTimestamp : moment(currEntry.uploadTimestamp).format(uploadTimestampFormat), + hashTags : Array.from(currEntry.hashTags).join(hashTagsSep), + isQueued : this.dlQueue.isQueued(currEntry) ? isQueuedIndicator : isNotQueuedIndicator, + webDlLink : '', // :TODO: fetch web any existing web d/l link + webDlExpire : '', // :TODO: fetch web d/l link expire time + }; + + // + // We need the entry object to contain meta keys even if they are empty as + // consumers may very likely attempt to use them + // + const metaValues = FileEntry.WellKnownMetaValues; + metaValues.forEach(name => { + const value = !_.isUndefined(currEntry.meta[name]) ? currEntry.meta[name] : 'N/A'; + entryInfo[_.camelCase(name)] = value; + }); + + if(entryInfo.archiveType) { + const mimeType = resolveMimeType(entryInfo.archiveType); + let desc; + if(mimeType) { + let fileType = _.get(Config(), [ 'fileTypes', mimeType ] ); + + if(Array.isArray(fileType)) { + // further refine by extention + fileType = fileType.find(ft => paths.extname(currEntry.fileName) === ft.ext); + } + desc = fileType && fileType.desc; + } + entryInfo.archiveTypeDesc = desc || mimeType || entryInfo.archiveType; + } else { + entryInfo.archiveTypeDesc = 'N/A'; + } + + entryInfo.uploadByUsername = entryInfo.uploadByUserName = entryInfo.uploadByUsername || 'N/A'; // may be imported + entryInfo.hashTags = entryInfo.hashTags || '(none)'; + + // create a rating string, e.g. "**---" + const userRatingTicked = config.userRatingTicked || '*'; + const userRatingUnticked = config.userRatingUnticked || ''; + entryInfo.userRating = ~~Math.round(entryInfo.userRating) || 0; // be safe! + entryInfo.userRatingString = userRatingTicked.repeat(entryInfo.userRating); + if(entryInfo.userRating < 5) { + entryInfo.userRatingString += userRatingUnticked.repeat( (5 - entryInfo.userRating) ); + } + + FileAreaWeb.getExistingTempDownloadServeItem(this.client, this.currentFileEntry, (err, serveItem) => { + if(err) { + entryInfo.webDlExpire = ''; + if(ErrNotEnabled === err.reasonCode) { + entryInfo.webDlExpire = config.webDlLinkNoWebserver || 'Web server is not enabled'; + } else { + entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated'; + } + } else { + const webDlExpireTimeFormat = config.webDlExpireTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat('short'); + + entryInfo.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; + entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + } + + return cb(null); + }); + } + + populateCustomLabels(category, startId) { + return this.updateCustomViewTextsWithFilter(category, startId, this.currentFileEntry.entryInfo); + } + + displayArtAndPrepViewController(name, options, cb) { + const self = this; + const config = this.menuConfig.config; + + async.waterfall( + [ + function readyAndDisplayArt(callback) { + if(options.clearScreen) { + self.client.term.rawWrite(ansi.resetScreen()); + } + + theme.displayThemedAsset( + config.art[name], + self.client, + { font : self.menuConfig.font, trailingLF : false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function prepeareViewController(artData, callback) { + if(_.isUndefined(self.viewControllers[name])) { + const vcOpts = { + client : self.client, + formId : FormIds[name], + }; + + if(!_.isUndefined(options.noInput)) { + vcOpts.noInput = options.noInput; + } + + const vc = self.addViewController(name, new ViewController(vcOpts)); + + if('details' === name) { + try { + self.detailsInfoArea = { + top : artData.mciMap.XY2.position, + bottom : artData.mciMap.XY3.position, + }; + } catch(e) { + return callback(Errors.DoesNotExist('Missing XY2 and XY3 position indicators!')); + } + } + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds[name], + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } + + self.viewControllers[name].setFocus(true); + return callback(null); + + }, + ], + err => { + return cb(err); + } + ); + } + + displayBrowsePage(clearScreen, cb) { + const self = this; + + async.series( + [ + function fetchEntryData(callback) { + if(self.fileList) { + return callback(null); + } + return self.loadFileIds(false, callback); // false=do not force + }, + function checkEmptyResults(callback) { + if(0 === self.fileList.length) { + return callback(Errors.General('No results for criteria', 'NORESULTS')); + } + return callback(null); + }, + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('browse', { clearScreen : clearScreen }, callback); + }, + function loadCurrentFileInfo(callback) { + self.currentFileEntry = new FileEntry(); + + self.currentFileEntry.load( self.fileList[ self.fileListPosition ], err => { + if(err) { + return callback(err); + } + + return self.populateCurrentEntryInfo(callback); + }); + }, + function populateDesc(callback) { + if(_.isString(self.currentFileEntry.desc)) { + const descView = self.viewControllers.browse.getView(MciViewIds.browse.desc); + if(descView) { + // + // For descriptions we want to support as many color code systems + // as we can for coverage of what is found in the while (e.g. Renegade + // pipes, PCB @X##, etc.) + // + // MLTEV doesn't support all of this, so convert. If we produced ANSI + // esc sequences, we'll proceed with specialization, else just treat + // it as text. + // + const desc = controlCodesToAnsi(self.currentFileEntry.desc); + if(desc.length != self.currentFileEntry.desc.length || isAnsi(desc)) { + const opts = { + prepped : false, + forceLineTerm : true + }; + + // + // if SAUCE states a term width, honor it else we may see + // display corruption + // + const sauceTermWidth = _.get(self.currentFileEntry.meta, 'desc_sauce.Character.characterWidth'); + if(_.isNumber(sauceTermWidth)) { + opts.termWidth = sauceTermWidth; + } + + descView.setAnsi(desc, opts, () => { + return callback(null); + }); + } else { + descView.setText(self.currentFileEntry.desc); + return callback(null); + } + } + } else { + return callback(null); + } + }, + function populateAdditionalViews(callback) { + self.updateQueueIndicator(); + self.populateCustomLabels('browse', MciViewIds.browse.customRangeStart); + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayDetailsPage(cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('details', { clearScreen : true }, callback); + }, + function populateViews(callback) { + self.populateCustomLabels('details', MciViewIds.details.customRangeStart); + return callback(null); + }, + function prepSection(callback) { + return self.displayDetailsSection('general', false, callback); + }, + function listenNavChanges(callback) { + const navMenu = self.viewControllers.details.getView(MciViewIds.details.navMenu); + navMenu.setFocusItemIndex(0); + + navMenu.on('index update', index => { + const sectionName = { + 0 : 'general', + 1 : 'nfo', + 2 : 'fileList', + }[index]; + + if(sectionName) { + self.displayDetailsSection(sectionName, true); + } + }); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + } + + displayHelpPage(cb) { + this.displayAsset( + this.menuConfig.config.art.help, + { clearScreen : true }, + () => { + this.client.waitForKeyPress( () => { + return this.displayBrowsePage(true, cb); + }); + } + ); + } + + fetchAndDisplayWebDownloadLink(cb) { + const self = this; + + async.series( + [ + function generateLinkIfNeeded(callback) { + + if(self.currentFileEntry.webDlExpireTime < moment()) { + return callback(null); + } + + const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempDownload( + self.client, + self.currentFileEntry, + { expireTime : expireTime }, + (err, url) => { + if(err) { + return callback(err); + } + + self.currentFileEntry.webDlExpireTime = expireTime; + + const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + self.currentFileEntry.entryInfo.webDlLink = ansi.vtxHyperlink(self.client, url) + url; + self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat); + + return callback(null); + } + ); + }, + function updateActiveViews(callback) { + self.updateCustomViewTextsWithFilter( + 'browse', + MciViewIds.browse.customRangeStart, self.currentFileEntry.entryInfo, + { filter : [ '{webDlLink}', '{webDlExpire}' ] } + ); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + } + + updateQueueIndicator() { + const isQueuedIndicator = this.menuConfig.config.isQueuedIndicator || 'Y'; + const isNotQueuedIndicator = this.menuConfig.config.isNotQueuedIndicator || 'N'; + + this.currentFileEntry.entryInfo.isQueued = stringFormat( + this.dlQueue.isQueued(this.currentFileEntry) ? + isQueuedIndicator : + isNotQueuedIndicator + ); + + this.updateCustomViewTextsWithFilter( + 'browse', + MciViewIds.browse.customRangeStart, + this.currentFileEntry.entryInfo, + { filter : [ '{isQueued}' ] } + ); + } + + cacheArchiveEntries(cb) { + // check cache + if(this.currentFileEntry.archiveEntries) { + return cb(null, 'cache'); + } + + const areaInfo = FileArea.getFileAreaByTag(this.currentFileEntry.areaTag); + if(!areaInfo) { + return cb(Errors.Invalid('Invalid area tag')); + } + + const filePath = this.currentFileEntry.filePath; + const archiveUtil = ArchiveUtil.getInstance(); + + archiveUtil.listEntries(filePath, this.currentFileEntry.entryInfo.archiveType, (err, entries) => { + if(err) { + return cb(err); + } + + // assign and add standard "text" member for itemFormat + this.currentFileEntry.archiveEntries = entries.map(e => Object.assign(e, { text : `${e.fileName} (${e.byteSize})` } )); + return cb(null, 're-cached'); + }); + } + + setFileListNoListing(text) { + const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList); + if(fileListView) { + fileListView.complexItems = false; + fileListView.setItems( [ text ] ); + fileListView.redraw(); + } + } + + populateFileListing() { + const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList); + + if(this.currentFileEntry.entryInfo.archiveType) { + this.cacheArchiveEntries( (err, cacheStatus) => { + if(err) { + return this.setFileListNoListing('Failed to get file listing'); + } + + if('re-cached' === cacheStatus) { + fileListView.setItems(this.currentFileEntry.archiveEntries); + fileListView.redraw(); + } + }); + } else { + const notAnArchiveFileName = stringFormat(this.menuConfig.config.notAnArchiveFormat || 'Not an archive', { fileName : this.currentFileEntry.fileName } ); + this.setFileListNoListing(notAnArchiveFileName); + } + } + + displayDetailsSection(sectionName, clearArea, cb) { + const self = this; + const name = `details${_.upperFirst(sectionName)}`; + + async.series( + [ + function detachPrevious(callback) { + if(self.lastDetailsViewController) { + self.lastDetailsViewController.detachClientEvents(); + } + return callback(null); + }, + function prepArtAndViewController(callback) { + + function gotoTopPos() { + self.client.term.rawWrite(ansi.goto(self.detailsInfoArea.top[0], 1)); + } + + gotoTopPos(); + + if(clearArea) { + self.client.term.rawWrite(ansi.reset()); + + let pos = self.detailsInfoArea.top[0]; + const bottom = self.detailsInfoArea.bottom[0]; + + while(pos++ <= bottom) { + self.client.term.rawWrite(ansi.eraseLine() + ansi.down()); + } + + gotoTopPos(); + } + + return self.displayArtAndPrepViewController(name, { clearScreen : false, noInput : true }, callback); + }, + function populateViews(callback) { + self.lastDetailsViewController = self.viewControllers[name]; + + switch(sectionName) { + case 'nfo' : + { + const nfoView = self.viewControllers.detailsNfo.getView(MciViewIds.detailsNfo.nfo); + if(!nfoView) { + return callback(null); + } + + if(isAnsi(self.currentFileEntry.entryInfo.descLong)) { + nfoView.setAnsi( + self.currentFileEntry.entryInfo.descLong, + { + prepped : false, + forceLineTerm : true, + }, + () => { + return callback(null); + } + ); + } else { + nfoView.setText(self.currentFileEntry.entryInfo.descLong); + return callback(null); + } + } + break; + + case 'fileList' : + self.populateFileListing(); + return callback(null); + + default : + return callback(null); + } + }, + function setLabels(callback) { + self.populateCustomLabels(name, MciViewIds[name].customRangeStart); + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + loadFileIds(force, cb) { + if(force || (_.isUndefined(this.fileList) || _.isUndefined(this.fileListPosition))) { + this.fileListPosition = 0; + + const filterCriteria = Object.assign({}, this.filterCriteria); + if(!filterCriteria.areaTag) { + filterCriteria.areaTag = FileArea.getAvailableFileAreaTags(this.client); + } + + FileEntry.findFiles(filterCriteria, (err, fileIds) => { + this.fileList = fileIds; + return cb(err); + }); + } + } +}; diff --git a/core/file_area_web.js b/core/file_area_web.js index bac85de7..b6a3d8ae 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -1,336 +1,498 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const FileDb = require('./database.js').dbs.file; -const getISOTimestampString = require('./database.js').getISOTimestampString; -const FileEntry = require('./file_entry.js'); -const getServer = require('./listening_server.js').getServer; -const Errors = require('./enig_error.js').Errors; -const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; -const StatLog = require('./stat_log.js'); -const User = require('./user.js'); -const Log = require('./logger.js').log; -const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId; -const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; +// ENiGMA½ +const Config = require('./config.js').get; +const FileDb = require('./database.js').dbs.file; +const getISOTimestampString = require('./database.js').getISOTimestampString; +const FileEntry = require('./file_entry.js'); +const getServer = require('./listening_server.js').getServer; +const Errors = require('./enig_error.js').Errors; +const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; +const StatLog = require('./stat_log.js'); +const User = require('./user.js'); +const Log = require('./logger.js').log; +const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId; +const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; +const Events = require('./events.js'); +const UserProps = require('./user_property.js'); +const SysProps = require('./system_menu_method.js'); -// deps -const hashids = require('hashids'); -const moment = require('moment'); -const paths = require('path'); -const async = require('async'); -const fs = require('graceful-fs'); -const mimeTypes = require('mime-types'); -const _ = require('lodash'); - - /* - :TODO: - * Load temp download URLs @ startup & set expire timers via scheduler. - * At creation, set expire timer via scheduler - * - */ +// deps +const hashids = require('hashids/cjs'); +const moment = require('moment'); +const paths = require('path'); +const async = require('async'); +const fs = require('graceful-fs'); +const mimeTypes = require('mime-types'); +const yazl = require('yazl'); function notEnabledError() { - return Errors.General('Web server is not enabled', ErrNotEnabled); + return Errors.General('Web server is not enabled', ErrNotEnabled); } class FileAreaWebAccess { - constructor() { - this.hashids = new hashids(Config.general.boardName); - this.expireTimers = {}; // hashId->timer - } + constructor() { + this.hashids = new hashids(Config().general.boardName); + this.expireTimers = {}; // hashId->timer + } - startup(cb) { - const self = this; + startup(cb) { + const self = this; - async.series( - [ - function initFromDb(callback) { - return self.load(callback); - }, - function addWebRoute(callback) { - self.webServer = getServer(webServerPackageName); - if(!self.webServer) { - return callback(Errors.DoesNotExist(`Server with package name "${webServerPackageName}" does not exist`)); - } + async.series( + [ + function initFromDb(callback) { + return self.load(callback); + }, + function addWebRoute(callback) { + self.webServer = getServer(webServerPackageName); + if(!self.webServer) { + return callback(Errors.DoesNotExist(`Server with package name "${webServerPackageName}" does not exist`)); + } - if(self.isEnabled()) { - const routeAdded = self.webServer.instance.addRoute({ - method : 'GET', - path : Config.fileBase.web.routePath, - handler : self.routeWebRequestForFile.bind(self), - }); - return callback(routeAdded ? null : Errors.General('Failed adding route')); - } else { - return callback(null); // not enabled, but no error - } - } - ], - err => { - return cb(err); - } - ); - } + if(self.isEnabled()) { + const routeAdded = self.webServer.instance.addRoute({ + method : 'GET', + path : Config().fileBase.web.routePath, + handler : self.routeWebRequest.bind(self), + }); + return callback(routeAdded ? null : Errors.General('Failed adding route')); + } else { + return callback(null); // not enabled, but no error + } + } + ], + err => { + return cb(err); + } + ); + } - shutdown(cb) { - return cb(null); - } + shutdown(cb) { + return cb(null); + } - isEnabled() { - return this.webServer.instance.isEnabled(); - } + isEnabled() { + return this.webServer.instance.isEnabled(); + } - load(cb) { - // - // Load entries, register expiration timers - // - FileDb.each( - `SELECT hash_id, expire_timestamp - FROM file_web_serve;`, - (err, row) => { - if(row) { - this.scheduleExpire(row.hash_id, moment(row.expire_timestamp)); - } - }, - err => { - return cb(err); - } - ); - } + static getHashIdTypes() { + return { + SingleFile : 0, + BatchArchive : 1, + }; + } - removeEntry(hashId) { - // - // Delete record from DB, and our timer - // - FileDb.run( - `DELETE FROM file_web_serve - WHERE hash_id = ?;`, - [ hashId ] - ); + load(cb) { + // + // Load entries, register expiration timers + // + FileDb.each( + `SELECT hash_id, expire_timestamp + FROM file_web_serve;`, + (err, row) => { + if(row) { + this.scheduleExpire(row.hash_id, moment(row.expire_timestamp)); + } + }, + err => { + return cb(err); + } + ); + } - delete this.expireTimers[hashId]; - } + removeEntry(hashId) { + // + // Delete record from DB, and our timer + // + FileDb.run( + `DELETE FROM file_web_serve + WHERE hash_id = ?;`, + [ hashId ] + ); - scheduleExpire(hashId, expireTime) { + delete this.expireTimers[hashId]; + } - // remove any previous entry for this hashId - const previous = this.expireTimers[hashId]; - if(previous) { - clearTimeout(previous); - delete this.expireTimers[hashId]; - } + scheduleExpire(hashId, expireTime) { - const timeoutMs = expireTime.diff(moment()); + // remove any previous entry for this hashId + const previous = this.expireTimers[hashId]; + if(previous) { + clearTimeout(previous); + delete this.expireTimers[hashId]; + } - if(timeoutMs <= 0) { - setImmediate( () => { - this.removeEntry(hashId); - }); - } else { - this.expireTimers[hashId] = setTimeout( () => { - this.removeEntry(hashId); - }, timeoutMs); - } - } + const timeoutMs = expireTime.diff(moment()); - loadServedHashId(hashId, cb) { - FileDb.get( - `SELECT expire_timestamp FROM - file_web_serve - WHERE hash_id = ?`, - [ hashId ], - (err, result) => { - if(err) { - return cb(err); - } + if(timeoutMs <= 0) { + setImmediate( () => { + this.removeEntry(hashId); + }); + } else { + this.expireTimers[hashId] = setTimeout( () => { + this.removeEntry(hashId); + }, timeoutMs); + } + } - const decoded = this.hashids.decode(hashId); - if(!result || 2 !== decoded.length) { - return cb(Errors.Invalid('Invalid or unknown hash ID')); - } + loadServedHashId(hashId, cb) { + FileDb.get( + `SELECT expire_timestamp FROM + file_web_serve + WHERE hash_id = ?`, + [ hashId ], + (err, result) => { + if(err || !result) { + return cb(err ? err : Errors.DoesNotExist('Invalid or missing hash ID')); + } - return cb( - null, - { - hashId : hashId, - userId : decoded[0], - fileId : decoded[1], - expireTimestamp : moment(result.expire_timestamp), - } - ); - } - ); - } + const decoded = this.hashids.decode(hashId); - getHashId(client, fileEntry) { - // - // Hashid is a unique combination of userId & fileId - // - return this.hashids.encode(client.user.userId, fileEntry.fileId); - } + // decode() should provide an array of [ userId, hashIdType, id, ... ] + if(!Array.isArray(decoded) || decoded.length < 3) { + return cb(Errors.Invalid('Invalid or unknown hash ID')); + } - buildTempDownloadLink(client, fileEntry, hashId) { - hashId = hashId || this.getHashId(client, fileEntry); + const servedItem = { + hashId : hashId, + userId : decoded[0], + hashIdType : decoded[1], + expireTimestamp : moment(result.expire_timestamp), + }; - return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); - /* - - // - // Create a URL such as - // https://l33t.codes:44512/f/qFdxyZr - // - // Prefer HTTPS over HTTP. Be explicit about the port - // only if non-standard. - // - let schema; - let port; - if(_.isString(Config.contentServers.web.overrideUrlPrefix)) { - return `${Config.contentServers.web.overrideUrlPrefix}${Config.fileBase.web.path}${hashId}`; - } else { - if(Config.contentServers.web.https.enabled) { - schema = 'https://'; - port = (443 === Config.contentServers.web.https.port) ? - '' : - `:${Config.contentServers.web.https.port}`; - } else { - schema = 'http://'; - port = (80 === Config.contentServers.web.http.port) ? - '' : - `:${Config.contentServers.web.http.port}`; - } - - return `${schema}${Config.contentServers.web.domain}${port}${Config.fileBase.web.path}${hashId}`; - } - */ - } + if(FileAreaWebAccess.getHashIdTypes().SingleFile === servedItem.hashIdType) { + servedItem.fileIds = decoded.slice(2); + } - getExistingTempDownloadServeItem(client, fileEntry, cb) { - if(!this.isEnabled()) { - return cb(notEnabledError()); - } + return cb(null, servedItem); + } + ); + } - const hashId = this.getHashId(client, fileEntry); - this.loadServedHashId(hashId, (err, servedItem) => { - if(err) { - return cb(err); - } + getSingleFileHashId(client, fileEntry) { + return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().SingleFile, [ fileEntry.fileId ] ); + } - servedItem.url = this.buildTempDownloadLink(client, fileEntry); + getBatchArchiveHashId(client, batchId) { + return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().BatchArchive, batchId); + } - return cb(null, servedItem); - }); - } + getHashId(client, hashIdType, identifier) { + return this.hashids.encode(client.user.userId, hashIdType, identifier); + } - createAndServeTempDownload(client, fileEntry, options, cb) { - if(!this.isEnabled()) { - return cb(notEnabledError()); - } + buildSingleFileTempDownloadLink(client, fileEntry, hashId) { + hashId = hashId || this.getSingleFileHashId(client, fileEntry); - const hashId = this.getHashId(client, fileEntry); - const url = this.buildTempDownloadLink(client, fileEntry, hashId); - options.expireTime = options.expireTime || moment().add(2, 'days'); + return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`); + } - // add/update rec with hash id and (latest) timestamp - FileDb.run( - `REPLACE INTO file_web_serve (hash_id, expire_timestamp) - VALUES (?, ?);`, - [ hashId, getISOTimestampString(options.expireTime) ], - err => { - if(err) { - return cb(err); - } + buildBatchArchiveTempDownloadLink(client, hashId) { + return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`); + } - this.scheduleExpire(hashId, options.expireTime); - - return cb(null, url); - } - ); - } + getExistingTempDownloadServeItem(client, fileEntry, cb) { + if(!this.isEnabled()) { + return cb(notEnabledError()); + } - fileNotFound(resp) { - return this.webServer.instance.fileNotFound(resp); - } + const hashId = this.getSingleFileHashId(client, fileEntry); + this.loadServedHashId(hashId, (err, servedItem) => { + if(err) { + return cb(err); + } - routeWebRequestForFile(req, resp) { - const hashId = paths.basename(req.url); + servedItem.url = this.buildSingleFileTempDownloadLink(client, fileEntry); - this.loadServedHashId(hashId, (err, servedItem) => { + return cb(null, servedItem); + }); + } - if(err) { - return this.fileNotFound(resp); - } + _addOrUpdateHashIdRecord(dbOrTrans, hashId, expireTime, cb) { + // add/update rec with hash id and (latest) timestamp + dbOrTrans.run( + `REPLACE INTO file_web_serve (hash_id, expire_timestamp) + VALUES (?, ?);`, + [ hashId, getISOTimestampString(expireTime) ], + err => { + if(err) { + return cb(err); + } - const fileEntry = new FileEntry(); - fileEntry.load(servedItem.fileId, err => { - if(err) { - return this.fileNotFound(resp); - } + this.scheduleExpire(hashId, expireTime); - const filePath = fileEntry.filePath; - if(!filePath) { - return this.fileNotFound(resp); - } + return cb(null); + } + ); + } - fs.stat(filePath, (err, stats) => { - if(err) { - return this.fileNotFound(resp); - } + createAndServeTempDownload(client, fileEntry, options, cb) { + if(!this.isEnabled()) { + return cb(notEnabledError()); + } - resp.on('close', () => { - // connection closed *before* the response was fully sent - // :TODO: Log and such - }); + const hashId = this.getSingleFileHashId(client, fileEntry); + const url = this.buildSingleFileTempDownloadLink(client, fileEntry, hashId); + options.expireTime = options.expireTime || moment().add(2, 'days'); - resp.on('finish', () => { - // transfer completed fully - this.updateDownloadStatsForUserIdAndSystemAndSystem(servedItem.userId, stats.size); - }); + this._addOrUpdateHashIdRecord(FileDb, hashId, options.expireTime, err => { + return cb(err, url); + }); + } - const headers = { - 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), - 'Content-Length' : stats.size, - 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, - }; + createAndServeTempBatchDownload(client, fileEntries, options, cb) { + if(!this.isEnabled()) { + return cb(notEnabledError()); + } - const readStream = fs.createReadStream(filePath); - resp.writeHead(200, headers); - return readStream.pipe(resp); - }); - }); - }); - } + const batchId = moment().utc().unix(); + const hashId = this.getBatchArchiveHashId(client, batchId); + const url = this.buildBatchArchiveTempDownloadLink(client, hashId); + options.expireTime = options.expireTime || moment().add(2, 'days'); - updateDownloadStatsForUserIdAndSystem(userId, dlBytes, cb) { - async.waterfall( - [ - function fetchActiveUser(callback) { - const clientForUserId = getConnectionByUserId(userId); - if(clientForUserId) { - return callback(null, clientForUserId.user); - } + FileDb.beginTransaction( (err, trans) => { + if(err) { + return cb(err); + } - // not online now - look 'em up - User.getUser(userId, (err, assocUser) => { - return callback(err, assocUser); - }); - }, - function updateStats(user, callback) { - StatLog.incrementUserStat(user, 'dl_total_count', 1); - StatLog.incrementUserStat(user, 'dl_total_bytes', dlBytes); - StatLog.incrementSystemStat('dl_total_count', 1); - StatLog.incrementSystemStat('dl_total_bytes', dlBytes); - - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } + this._addOrUpdateHashIdRecord(trans, hashId, options.expireTime, err => { + if(err) { + return trans.rollback( () => { + return cb(err); + }); + } + + async.eachSeries(fileEntries, (entry, nextEntry) => { + trans.run( + `INSERT INTO file_web_serve_batch (hash_id, file_id) + VALUES (?, ?);`, + [ hashId, entry.fileId ], + err => { + return nextEntry(err); + } + ); + }, err => { + trans[err ? 'rollback' : 'commit']( () => { + return cb(err, url); + }); + }); + }); + }); + } + + fileNotFound(resp) { + return this.webServer.instance.fileNotFound(resp); + } + + routeWebRequest(req, resp) { + const hashId = paths.basename(req.url); + + Log.debug( { hashId : hashId, url : req.url }, 'File area web request'); + + this.loadServedHashId(hashId, (err, servedItem) => { + + if(err) { + return this.fileNotFound(resp); + } + + const hashIdTypes = FileAreaWebAccess.getHashIdTypes(); + switch(servedItem.hashIdType) { + case hashIdTypes.SingleFile : + return this.routeWebRequestForSingleFile(servedItem, req, resp); + + case hashIdTypes.BatchArchive : + return this.routeWebRequestForBatchArchive(servedItem, req, resp); + + default : + return this.fileNotFound(resp); + } + }); + } + + routeWebRequestForSingleFile(servedItem, req, resp) { + Log.debug( { servedItem : servedItem }, 'Single file web request'); + + const fileEntry = new FileEntry(); + + servedItem.fileId = servedItem.fileIds[0]; + + fileEntry.load(servedItem.fileId, err => { + if(err) { + return this.fileNotFound(resp); + } + + const filePath = fileEntry.filePath; + if(!filePath) { + return this.fileNotFound(resp); + } + + fs.stat(filePath, (err, stats) => { + if(err) { + return this.fileNotFound(resp); + } + + resp.on('close', () => { + // connection closed *before* the response was fully sent + // :TODO: Log and such + }); + + resp.on('finish', () => { + // transfer completed fully + this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size, [ fileEntry ]); + }); + + const headers = { + 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), + 'Content-Length' : stats.size, + 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, + }; + + const readStream = fs.createReadStream(filePath); + resp.writeHead(200, headers); + return readStream.pipe(resp); + }); + }); + } + + routeWebRequestForBatchArchive(servedItem, req, resp) { + Log.debug( { servedItem : servedItem }, 'Batch file web request'); + + // + // We are going to build an on-the-fly zip file stream of 1:n + // files in the batch. + // + // First, collect all file IDs + // + const self = this; + + async.waterfall( + [ + function fetchFileIds(callback) { + FileDb.all( + `SELECT file_id + FROM file_web_serve_batch + WHERE hash_id = ?;`, + [ servedItem.hashId ], + (err, fileIdRows) => { + if(err || !Array.isArray(fileIdRows) || 0 === fileIdRows.length) { + return callback(Errors.DoesNotExist('Could not get file IDs for batch')); + } + + return callback(null, fileIdRows.map(r => r.file_id)); + } + ); + }, + function loadFileEntries(fileIds, callback) { + async.map(fileIds, (fileId, nextFileId) => { + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + return nextFileId(err, fileEntry); + }); + }, (err, fileEntries) => { + if(err) { + return callback(Errors.DoesNotExist('Could not load file IDs for batch')); + } + + return callback(null, fileEntries); + }); + }, + function createAndServeStream(fileEntries, callback) { + const filePaths = fileEntries.map(fe => fe.filePath); + Log.trace( { filePaths : filePaths }, 'Creating zip archive for batch web request'); + + const zipFile = new yazl.ZipFile(); + + zipFile.on('error', err => { + Log.warn( { error : err.message }, 'Error adding file to batch web request archive'); + }); + + filePaths.forEach(fp => { + zipFile.addFile( + fp, // path to physical file + paths.basename(fp), // filename/path *stored in archive* + { + compress : false, // :TODO: do this smartly - if ext is in set = false, else true via isArchive() or such... mimeDB has this for us. + } + ); + }); + + zipFile.end( finalZipSize => { + if(-1 === finalZipSize) { + return callback(Errors.UnexpectedState('Unable to acquire final zip size')); + } + + resp.on('close', () => { + // connection closed *before* the response was fully sent + // :TODO: Log and such + }); + + resp.on('finish', () => { + // transfer completed fully + self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize, fileEntries); + }); + + const batchFileName = `batch_${servedItem.hashId}.zip`; + + const headers = { + 'Content-Type' : mimeTypes.contentType(batchFileName) || mimeTypes.contentType('.bin'), + 'Content-Length' : finalZipSize, + 'Content-Disposition' : `attachment; filename="${batchFileName}"`, + }; + + resp.writeHead(200, headers); + return zipFile.outputStream.pipe(resp); + }); + } + ], + err => { + if(err) { + // :TODO: Log me! + return this.fileNotFound(resp); + } + + // ...otherwise, we would have called resp() already. + } + ); + } + + updateDownloadStatsForUserIdAndSystem(userId, dlBytes, fileEntries) { + async.waterfall( + [ + function fetchActiveUser(callback) { + const clientForUserId = getConnectionByUserId(userId); + if(clientForUserId) { + return callback(null, clientForUserId.user); + } + + // not online now - look 'em up + User.getUser(userId, (err, assocUser) => { + return callback(err, assocUser); + }); + }, + function updateStats(user, callback) { + StatLog.incrementUserStat(user, UserProps.FileDlTotalCount, 1); + StatLog.incrementUserStat(user, UserProps.FileDlTotalBytes, dlBytes); + + StatLog.incrementSystemStat(SysProps.FileDlTotalCount, 1); + StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, dlBytes); + + return callback(null, user); + }, + function sendEvent(user, callback) { + Events.emit( + Events.getSystemEvents().UserDownload, + { + user : user, + files : fileEntries, + } + ); + return callback(null); + } + ] + ); + } } module.exports = new FileAreaWebAccess(); \ No newline at end of file diff --git a/core/file_base_area.js b/core/file_base_area.js index 79b91ce8..e9926111 100644 --- a/core/file_base_area.js +++ b/core/file_base_area.js @@ -1,859 +1,1100 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const Errors = require('./enig_error.js').Errors; -const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; -const FileEntry = require('./file_entry.js'); -const FileDb = require('./database.js').dbs.file; -const ArchiveUtil = require('./archive_util.js'); -const CRC32 = require('./crc.js').CRC32; -const Log = require('./logger.js').log; -const resolveMimeType = require('./mime_util.js').resolveMimeType; -const stringFormat = require('./string_format.js'); -const wordWrapText = require('./word_wrap.js').wordWrapText; +// ENiGMA½ +const Config = require('./config.js').get; +const Errors = require('./enig_error.js').Errors; +const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +const FileEntry = require('./file_entry.js'); +const FileDb = require('./database.js').dbs.file; +const ArchiveUtil = require('./archive_util.js'); +const CRC32 = require('./crc.js').CRC32; +const Log = require('./logger.js').log; +const resolveMimeType = require('./mime_util.js').resolveMimeType; +const stringFormat = require('./string_format.js'); +const wordWrapText = require('./word_wrap.js').wordWrapText; +const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); +const SysProps = require('./system_property.js'); +const SAUCE = require('./sauce.js'); +const { wildcardMatch } = require('./string_util'); -// deps -const _ = require('lodash'); -const async = require('async'); -const fs = require('graceful-fs'); -const crypto = require('crypto'); -const paths = require('path'); -const temptmp = require('temptmp').createTrackedSession('file_area'); -const iconv = require('iconv-lite'); -const execFile = require('child_process').execFile; -const moment = require('moment'); +// deps +const _ = require('lodash'); +const async = require('async'); +const fs = require('graceful-fs'); +const crypto = require('crypto'); +const paths = require('path'); +const temptmp = require('temptmp').createTrackedSession('file_area'); +const iconv = require('iconv-lite'); +const execFile = require('child_process').execFile; +const moment = require('moment'); -exports.isInternalArea = isInternalArea; -exports.getAvailableFileAreas = getAvailableFileAreas; -exports.getSortedAvailableFileAreas = getSortedAvailableFileAreas; -exports.isValidStorageTag = isValidStorageTag; -exports.getAreaStorageDirectoryByTag = getAreaStorageDirectoryByTag; -exports.getAreaDefaultStorageDirectory = getAreaDefaultStorageDirectory; -exports.getAreaStorageLocations = getAreaStorageLocations; -exports.getDefaultFileAreaTag = getDefaultFileAreaTag; -exports.getFileAreaByTag = getFileAreaByTag; -exports.getFileEntryPath = getFileEntryPath; -exports.changeFileAreaWithOptions = changeFileAreaWithOptions; -exports.scanFile = scanFile; -exports.scanFileAreaForChanges = scanFileAreaForChanges; -exports.getDescFromFileName = getDescFromFileName; +exports.startup = startup; +exports.isInternalArea = isInternalArea; +exports.getAvailableFileAreas = getAvailableFileAreas; +exports.getAvailableFileAreaTags = getAvailableFileAreaTags; +exports.getSortedAvailableFileAreas = getSortedAvailableFileAreas; +exports.isValidStorageTag = isValidStorageTag; +exports.getAreaStorageDirectoryByTag = getAreaStorageDirectoryByTag; +exports.getAreaDefaultStorageDirectory = getAreaDefaultStorageDirectory; +exports.getAreaStorageLocations = getAreaStorageLocations; +exports.getDefaultFileAreaTag = getDefaultFileAreaTag; +exports.getFileAreaByTag = getFileAreaByTag; +exports.getFileAreasByTagWildcardRule = getFileAreasByTagWildcardRule; +exports.getFileEntryPath = getFileEntryPath; +exports.changeFileAreaWithOptions = changeFileAreaWithOptions; +exports.scanFile = scanFile; +exports.scanFileAreaForChanges = scanFileAreaForChanges; +exports.getDescFromFileName = getDescFromFileName; +exports.getAreaStats = getAreaStats; +exports.cleanUpTempSessionItems = cleanUpTempSessionItems; -const WellKnownAreaTags = exports.WellKnownAreaTags = { - Invalid : '', - MessageAreaAttach : 'system_message_attachment', +// for scheduler: +exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent; + +const WellKnownAreaTags = exports.WellKnownAreaTags = { + Invalid : '', + MessageAreaAttach : 'system_message_attachment', + TempDownloads : 'system_temporary_download', }; +function startup(cb) { + async.series( + [ + (callback) => { + return cleanUpTempSessionItems(callback); + }, + (callback) => { + getAreaStats( (err, stats) => { + if(!err) { + StatLog.setNonPersistentSystemStat(SysProps.FileBaseAreaStats, stats); + } + + return callback(null); + }); + } + ], + err => { + return cb(err); + } + ); +} + function isInternalArea(areaTag) { - return areaTag === WellKnownAreaTags.MessageAreaAttach; + return [ WellKnownAreaTags.MessageAreaAttach, WellKnownAreaTags.TempDownloads ].includes(areaTag); } function getAvailableFileAreas(client, options) { - options = options || { }; + options = options || { }; - // perform ACS check per conf & omit internal if desired - const allAreas = _.map(Config.fileBase.areas, (areaInfo, areaTag) => Object.assign(areaInfo, { areaTag : areaTag } )); - - return _.omitBy(allAreas, areaInfo => { - if(!options.includeSystemInternal && isInternalArea(areaInfo.areaTag)) { - return true; - } + // perform ACS check per conf & omit internal if desired + const allAreas = _.map(Config().fileBase.areas, (areaInfo, areaTag) => Object.assign(areaInfo, { areaTag : areaTag } )); - if(options.writeAcs && !client.acs.hasFileAreaWrite(areaInfo)) { - return true; // omit - } + return _.omitBy(allAreas, areaInfo => { + if(!options.includeSystemInternal && isInternalArea(areaInfo.areaTag)) { + return true; + } - return !client.acs.hasFileAreaRead(areaInfo); - }); + if(options.skipAcsCheck) { + return false; // no ACS checks (below) + } + + if(options.writeAcs && !client.acs.hasFileAreaWrite(areaInfo)) { + return true; // omit + } + + return !client.acs.hasFileAreaRead(areaInfo); + }); +} + +function getAvailableFileAreaTags(client, options) { + return _.map(getAvailableFileAreas(client, options), area => area.areaTag); } function getSortedAvailableFileAreas(client, options) { - const areas = _.map(getAvailableFileAreas(client, options), v => v); - sortAreasOrConfs(areas); - return areas; + const areas = _.map(getAvailableFileAreas(client, options), v => v); + sortAreasOrConfs(areas); + return areas; } function getDefaultFileAreaTag(client, disableAcsCheck) { - let defaultArea = _.findKey(Config.fileBase, o => o.default); - if(defaultArea) { - const area = Config.fileBase.areas[defaultArea]; - if(true === disableAcsCheck || client.acs.hasFileAreaRead(area)) { - return defaultArea; - } - } + const config = Config(); + let defaultArea = _.findKey(config.fileBase, o => o.default); + if(defaultArea) { + const area = config.fileBase.areas[defaultArea]; + if(true === disableAcsCheck || client.acs.hasFileAreaRead(area)) { + return defaultArea; + } + } - // just use anything we can - defaultArea = _.findKey(Config.fileBase.areas, (area, areaTag) => { - return WellKnownAreaTags.MessageAreaAttach !== areaTag && (true === disableAcsCheck || client.acs.hasFileAreaRead(area)); - }); - - return defaultArea; + // just use anything we can + defaultArea = _.findKey(config.fileBase.areas, (area, areaTag) => { + return WellKnownAreaTags.MessageAreaAttach !== areaTag && (true === disableAcsCheck || client.acs.hasFileAreaRead(area)); + }); + + return defaultArea; } function getFileAreaByTag(areaTag) { - const areaInfo = Config.fileBase.areas[areaTag]; - if(areaInfo) { - areaInfo.areaTag = areaTag; // convienence! - areaInfo.storage = getAreaStorageLocations(areaInfo); - return areaInfo; - } + const areaInfo = Config().fileBase.areas[areaTag]; + if(areaInfo) { + areaInfo.areaTag = areaTag; // convienence! + areaInfo.storage = getAreaStorageLocations(areaInfo); + return areaInfo; + } +} + +function getFileAreasByTagWildcardRule(rule) { + const areaTags = Object.keys(Config().fileBase.areas) + .filter(areaTag => { + return !isInternalArea(areaTag) && wildcardMatch(areaTag, rule); + }); + + return areaTags.map(areaTag => getFileAreaByTag(areaTag)); } function changeFileAreaWithOptions(client, areaTag, options, cb) { - async.waterfall( - [ - function getArea(callback) { - const area = getFileAreaByTag(areaTag); - return callback(area ? null : Errors.Invalid('Invalid file areaTag'), area); - }, - function validateAccess(area, callback) { - if(!client.acs.hasFileAreaRead(area)) { - return callback(Errors.AccessDenied('No access to this area')); - } - }, - function changeArea(area, callback) { - if(true === options.persist) { - client.user.persistProperty('file_area_tag', areaTag, err => { - return callback(err, area); - }); - } else { - client.user.properties['file_area_tag'] = areaTag; - return callback(null, area); - } - } - ], - (err, area) => { - if(!err) { - client.log.info( { areaTag : areaTag, area : area }, 'Current file area changed'); - } else { - client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change file area'); - } + async.waterfall( + [ + function getArea(callback) { + const area = getFileAreaByTag(areaTag); + return callback(area ? null : Errors.Invalid('Invalid file areaTag'), area); + }, + function validateAccess(area, callback) { + if(!client.acs.hasFileAreaRead(area)) { + return callback(Errors.AccessDenied('No access to this area')); + } + }, + function changeArea(area, callback) { + if(true === options.persist) { + client.user.persistProperty(UserProps.FileAreaTag, areaTag, err => { + return callback(err, area); + }); + } else { + client.user.properties[UserProps.FileAreaTag] = areaTag; + return callback(null, area); + } + } + ], + (err, area) => { + if(!err) { + client.log.info( { areaTag : areaTag, area : area }, 'Current file area changed'); + } else { + client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change file area'); + } - return cb(err); - } - ); + return cb(err); + } + ); } function isValidStorageTag(storageTag) { - return storageTag in Config.fileBase.storageTags; + return storageTag in Config().fileBase.storageTags; } function getAreaStorageDirectoryByTag(storageTag) { - const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]); + const config = Config(); + const storageLocation = (storageTag && config.fileBase.storageTags[storageTag]); - return paths.resolve(Config.fileBase.areaStoragePrefix, storageLocation || ''); + return paths.resolve(config.fileBase.areaStoragePrefix, storageLocation || ''); } function getAreaDefaultStorageDirectory(areaInfo) { - return getAreaStorageDirectoryByTag(areaInfo.storageTags[0]); + return getAreaStorageDirectoryByTag(areaInfo.storageTags[0]); } function getAreaStorageLocations(areaInfo) { - - const storageTags = Array.isArray(areaInfo.storageTags) ? - areaInfo.storageTags : - [ areaInfo.storageTags || '' ]; - const avail = Config.fileBase.storageTags; - - return _.compact(storageTags.map(storageTag => { - if(avail[storageTag]) { - return { - storageTag : storageTag, - dir : getAreaStorageDirectoryByTag(storageTag), - }; - } - })); + const storageTags = Array.isArray(areaInfo.storageTags) ? + areaInfo.storageTags : + [ areaInfo.storageTags || '' ]; + + const avail = Config().fileBase.storageTags; + + return _.compact(storageTags.map(storageTag => { + if(avail[storageTag]) { + return { + storageTag : storageTag, + dir : getAreaStorageDirectoryByTag(storageTag), + }; + } + })); } function getFileEntryPath(fileEntry) { - const areaInfo = getFileAreaByTag(fileEntry.areaTag); - if(areaInfo) { - return paths.join(areaInfo.storageDirectory, fileEntry.fileName); - } + const areaInfo = getFileAreaByTag(fileEntry.areaTag); + if(areaInfo) { + return paths.join(areaInfo.storageDirectory, fileEntry.fileName); + } } function getExistingFileEntriesBySha256(sha256, cb) { - const entries = []; + const entries = []; - FileDb.each( - `SELECT file_id, area_tag - FROM file - WHERE file_sha256=?;`, - [ sha256 ], - (err, fileRow) => { - if(fileRow) { - entries.push({ - fileId : fileRow.file_id, - areaTag : fileRow.area_tag, - }); - } - }, - err => { - return cb(err, entries); - } - ); + FileDb.each( + `SELECT file_id, area_tag + FROM file + WHERE file_sha256=?;`, + [ sha256 ], + (err, fileRow) => { + if(fileRow) { + entries.push({ + fileId : fileRow.file_id, + areaTag : fileRow.area_tag, + }); + } + }, + err => { + return cb(err, entries); + } + ); } -// :TODO: This is bascially sliceAtEOF() from art.js .... DRY! +// :TODO: This is basically sliceAtEOF() from art.js .... DRY! function sliceAtSauceMarker(data) { - let eof = data.length; - const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE) + let eof = data.length; + const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE) - for(let i = eof - 1; i > stopPos; i--) { - if(0x1a === data[i]) { - eof = i; - break; - } - } - return data.slice(0, eof); + for(let i = eof - 1; i > stopPos; i--) { + if(0x1a === data[i]) { + eof = i; + break; + } + } + return data.slice(0, eof); } function attemptSetEstimatedReleaseDate(fileEntry) { - // :TODO: yearEstPatterns RegExp's should be cached - we can do this @ Config (re)load time - const patterns = Config.fileBase.yearEstPatterns.map( p => new RegExp(p, 'gmi')); + // :TODO: yearEstPatterns RegExp's should be cached - we can do this @ Config (re)load time + const patterns = Config().fileBase.yearEstPatterns.map( p => new RegExp(p, 'gmi')); - function getMatch(input) { - if(input) { - let m; - for(let i = 0; i < patterns.length; ++i) { - m = patterns[i].exec(input); - if(m) { - return m; - } - } - } - } + function getMatch(input) { + if(input) { + let m; + for(let i = 0; i < patterns.length; ++i) { + m = patterns[i].exec(input); + if(m) { + return m; + } + } + } + } - // - // We attempt detection in short -> long order - // - // Throw out anything that is current_year + 2 (we give some leway) - // with the assumption that must be wrong. - // - const maxYear = moment().add(2, 'year').year(); - const match = getMatch(fileEntry.desc) || getMatch(fileEntry.descLong); - - if(match && match[1]) { - let year; - if(2 === match[1].length) { - year = parseInt(match[1]); - if(year) { - if(year > 70) { - year += 1900; - } else { - year += 2000; - } - } - } else { - year = parseInt(match[1]); - } + // + // We attempt detection in short -> long order + // + // Throw out anything that is current_year + 2 (we give some leway) + // with the assumption that must be wrong. + // + const maxYear = moment().add(2, 'year').year(); + const match = getMatch(fileEntry.desc) || getMatch(fileEntry.descLong); - if(year && year <= maxYear) { - fileEntry.meta.est_release_year = year; - } - } + if(match && match[1]) { + let year; + if(2 === match[1].length) { + year = parseInt(match[1]); + if(year) { + if(year > 70) { + year += 1900; + } else { + year += 2000; + } + } + } else { + year = parseInt(match[1]); + } + + if(year && year <= maxYear) { + fileEntry.meta.est_release_year = year; + } + } } -// a simple log proxy for when we call from oputil.js -function logDebug(obj, msg) { - if(Log) { - Log.debug(obj, msg); - } -} +// a simple log proxy for when we call from oputil.js +const maybeLog = (obj, msg, level) => { + if(Log) { + Log[level](obj, msg); + } else if ('error' === level) { + console.error(`${msg}: ${JSON.stringify(obj)}`); // eslint-disable-line no-console + } +}; + +const logDebug = (obj, msg) => maybeLog(obj, msg, 'debug'); +const logTrace = (obj, msg) => maybeLog(obj, msg, 'trace'); +const logError = (obj, msg) => maybeLog(obj, msg, 'error'); function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) { - async.waterfall( - [ - function extractDescFiles(callback) { - // :TODO: would be nice if these RegExp's were cached - // :TODO: this is long winded... + async.waterfall( + [ + function extractDescFiles(callback) { + // :TODO: would be nice if these RegExp's were cached + // :TODO: this is long winded... + const config = Config(); + const extractList = []; - const extractList = []; + const shortDescFile = archiveEntries.find( e => { + return config.fileBase.fileNamePatterns.desc.find( pat => new RegExp(pat, 'i').test(e.fileName) ); + }); - const shortDescFile = archiveEntries.find( e => { - return Config.fileBase.fileNamePatterns.desc.find( pat => new RegExp(pat, 'i').test(e.fileName) ); - }); + if(shortDescFile) { + extractList.push(shortDescFile.fileName); + } - if(shortDescFile) { - extractList.push(shortDescFile.fileName); - } + const longDescFile = archiveEntries.find( e => { + return config.fileBase.fileNamePatterns.descLong.find( pat => new RegExp(pat, 'i').test(e.fileName) ); + }); - const longDescFile = archiveEntries.find( e => { - return Config.fileBase.fileNamePatterns.descLong.find( pat => new RegExp(pat, 'i').test(e.fileName) ); - }); + if(longDescFile) { + extractList.push(longDescFile.fileName); + } - if(longDescFile) { - extractList.push(longDescFile.fileName); - } + if(0 === extractList.length) { + return callback(null, [] ); + } - if(0 === extractList.length) { - return callback(null, [] ); - } + temptmp.mkdir( { prefix : 'enigextract-' }, (err, tempDir) => { + if(err) { + return callback(err); + } - temptmp.mkdir( { prefix : 'enigextract-' }, (err, tempDir) => { - if(err) { - return callback(err); - } + const archiveUtil = ArchiveUtil.getInstance(); + archiveUtil.extractTo(filePath, tempDir, fileEntry.meta.archive_type, extractList, err => { + if(err) { + return callback(err); + } - const archiveUtil = ArchiveUtil.getInstance(); - archiveUtil.extractTo(filePath, tempDir, fileEntry.meta.archive_type, extractList, err => { - if(err) { - return callback(err); - } + const descFiles = { + desc : shortDescFile ? paths.join(tempDir, paths.basename(shortDescFile.fileName)) : null, + descLong : longDescFile ? paths.join(tempDir, paths.basename(longDescFile.fileName)) : null, + }; - const descFiles = { - desc : shortDescFile ? paths.join(tempDir, shortDescFile.fileName) : null, - descLong : longDescFile ? paths.join(tempDir, longDescFile.fileName) : null, - }; + return callback(null, descFiles); + }); + }); + }, + function readDescFiles(descFiles, callback) { + const config = Config(); + async.each(Object.keys(descFiles), (descType, next) => { + const path = descFiles[descType]; + if(!path) { + return next(null); + } - return callback(null, descFiles); - }); - }); - }, - function readDescFiles(descFiles, callback) { - async.each(Object.keys(descFiles), (descType, next) => { - const path = descFiles[descType]; - if(!path) { - return next(null); - } + fs.stat(path, (err, stats) => { + if(err) { + return next(null); + } - fs.stat(path, (err, stats) => { - if(err) { - return next(null); - } + // skip entries that are too large + const maxFileSizeKey = `max${_.upperFirst(descType)}FileByteSize`; + if(config.fileBase[maxFileSizeKey] && stats.size > config.fileBase[maxFileSizeKey]) { + logDebug( { byteSize : stats.size, maxByteSize : config.fileBase[maxFileSizeKey] }, `Skipping "${descType}"; Too large` ); + return next(null); + } - // skip entries that are too large - const maxFileSizeKey = `max${_.upperFirst(descType)}FileByteSize`; - - if(Config.fileBase[maxFileSizeKey] && stats.size > Config.fileBase[maxFileSizeKey]) { - logDebug( { byteSize : stats.size, maxByteSize : Config.fileBase[maxFileSizeKey] }, `Skipping "${descType}"; Too large` ); - return next(null); - } + fs.readFile(path, (err, data) => { + if(err || !data) { + return next(null); + } - fs.readFile(path, (err, data) => { - if(err || !data) { - return next(null); - } + SAUCE.readSAUCE(data, (err, sauce) => { + if(sauce) { + // if we have SAUCE, this information will be kept as well, + // but separate/pre-parsed. + const metaKey = `desc${'descLong' === descType ? '_long' : ''}_sauce`; + fileEntry.meta[metaKey] = JSON.stringify(sauce); + } - // - // Assume FILE_ID.DIZ, NFO files, etc. are CP437. - // - // :TODO: This isn't really always the case - how to handle this? We could do a quick detection... - fileEntry[descType] = iconv.decode(sliceAtSauceMarker(data, 0x1a), 'cp437'); - return next(null); - }); - }); - }, () => { - // cleanup but don't wait - temptmp.cleanup( paths => { - // note: don't use client logger here - may not be avail - logDebug( { paths : paths, sessionId : temptmp.sessionId }, 'Cleaned up temporary files' ); - }); - return callback(null); - }); - }, - ], - err => { - return cb(err); - } - ); + // + // Assume FILE_ID.DIZ, NFO files, etc. are CP437; we need + // to decode to a native format for storage + // + // :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); + fileEntry[`${descType}Src`] = 'descFile'; + return next(null); + }); + }); + }); + }, () => { + // cleanup but don't wait + temptmp.cleanup( paths => { + // note: don't use client logger here - may not be avail + logTrace( { paths : paths, sessionId : temptmp.sessionId }, 'Cleaned up temporary files' ); + }); + return callback(null); + }); + }, + ], + err => { + return cb(err); + } + ); } function extractAndProcessSingleArchiveEntry(fileEntry, filePath, archiveEntries, cb) { - async.waterfall( - [ - function extractToTemp(callback) { - // :TODO: we may want to skip this if the compressed file is too large... - temptmp.mkdir( { prefix : 'enigextract-' }, (err, tempDir) => { - if(err) { - return callback(err); - } + async.waterfall( + [ + function extractToTemp(callback) { + // :TODO: we may want to skip this if the compressed file is too large... + temptmp.mkdir( { prefix : 'enigextract-' }, (err, tempDir) => { + if(err) { + return callback(err); + } - const archiveUtil = ArchiveUtil.getInstance(); - - // ensure we only extract one - there should only be one anyway -- we also just need the fileName - const extractList = archiveEntries.slice(0, 1).map(entry => entry.fileName); - - archiveUtil.extractTo(filePath, tempDir, fileEntry.meta.archive_type, extractList, err => { - if(err) { - return callback(err); - } + const archiveUtil = ArchiveUtil.getInstance(); - return callback(null, paths.join(tempDir, extractList[0])); - }); - }); - }, - function processSingleExtractedFile(extractedFile, callback) { - populateFileEntryInfoFromFile(fileEntry, extractedFile, err => { - if(!fileEntry.desc) { - fileEntry.desc = getDescFromFileName(filePath); - } - return callback(err); - }); - } - ], - err => { - return cb(err); - } - ); + // ensure we only extract one - there should only be one anyway -- we also just need the fileName + const extractList = archiveEntries.slice(0, 1).map(entry => entry.fileName); + + archiveUtil.extractTo(filePath, tempDir, fileEntry.meta.archive_type, extractList, err => { + if(err) { + return callback(err); + } + + return callback(null, paths.join(tempDir, extractList[0])); + }); + }); + }, + function processSingleExtractedFile(extractedFile, callback) { + populateFileEntryInfoFromFile(fileEntry, extractedFile, err => { + if(!fileEntry.desc) { + fileEntry.desc = getDescFromFileName(filePath); + fileEntry.descSrc = 'fileName'; + } + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); } function populateFileEntryWithArchive(fileEntry, filePath, stepInfo, iterator, cb) { - const archiveUtil = ArchiveUtil.getInstance(); - const archiveType = fileEntry.meta.archive_type; // we set this previous to populateFileEntryWithArchive() + const archiveUtil = ArchiveUtil.getInstance(); + const archiveType = fileEntry.meta.archive_type; // we set this previous to populateFileEntryWithArchive() - async.waterfall( - [ - function getArchiveFileList(callback) { - stepInfo.step = 'archive_list_start'; + async.waterfall( + [ + function getArchiveFileList(callback) { + stepInfo.step = 'archive_list_start'; - iterator(err => { - if(err) { - return callback(err); - } + iterator(err => { + if(err) { + return callback(err); + } - archiveUtil.listEntries(filePath, archiveType, (err, entries) => { - if(err) { - stepInfo.step = 'archive_list_failed'; - } else { - stepInfo.step = 'archive_list_finish'; - stepInfo.archiveEntries = entries || []; - } + archiveUtil.listEntries(filePath, archiveType, (err, entries) => { + if(err) { + stepInfo.step = 'archive_list_failed'; + } else { + stepInfo.step = 'archive_list_finish'; + stepInfo.archiveEntries = entries || []; + } - iterator(iterErr => { - return callback( iterErr, entries || [] ); // ignore original |err| here - }); - }); - }); - }, - function processDescFilesStart(entries, callback) { - stepInfo.step = 'desc_files_start'; - iterator(err => { - return callback(err, entries); - }); - }, - function extractDescFromArchive(entries, callback) { - // - // If we have a -single- entry in the archive, extract that file - // and try retrieving info in the non-archive manor. This should - // work for things like zipped up .pdf files. - // - // Otherwise, try to find particular desc files such as FILE_ID.DIZ - // and README.1ST - // - const archDescHandler = (1 === entries.length) ? extractAndProcessSingleArchiveEntry : extractAndProcessDescFiles; - archDescHandler(fileEntry, filePath, entries, err => { - return callback(err); - }); - }, - function attemptReleaseYearEstimation(callback) { - attemptSetEstimatedReleaseDate(fileEntry); - return callback(null); - }, - function processDescFilesFinish(callback) { - stepInfo.step = 'desc_files_finish'; - return iterator(callback); - }, - ], - err => { - return cb(err); - } - ); + iterator(iterErr => { + return callback( iterErr, entries || [] ); // ignore original |err| here + }); + }); + }); + }, + function processDescFilesStart(entries, callback) { + stepInfo.step = 'desc_files_start'; + iterator(err => { + return callback(err, entries); + }); + }, + function extractDescFromArchive(entries, callback) { + // + // If we have a -single- entry in the archive, extract that file + // and try retrieving info in the non-archive manor. This should + // work for things like zipped up .pdf files. + // + // Otherwise, try to find particular desc files such as FILE_ID.DIZ + // and README.1ST + // + const archDescHandler = (1 === entries.length) ? extractAndProcessSingleArchiveEntry : extractAndProcessDescFiles; + archDescHandler(fileEntry, filePath, entries, err => { + return callback(err); + }); + }, + function attemptReleaseYearEstimation(callback) { + attemptSetEstimatedReleaseDate(fileEntry); + return callback(null); + }, + function processDescFilesFinish(callback) { + stepInfo.step = 'desc_files_finish'; + return iterator(callback); + }, + ], + err => { + return cb(err); + } + ); } -function getInfoExtractUtilForDesc(mimeType, descType) { - let util = _.get(Config, [ 'fileTypes', mimeType, `${descType}DescUtil` ]); - if(!_.isString(util)) { - return; - } +function getInfoExtractUtilForDesc(mimeType, filePath, descType) { + const config = Config(); + let fileType = _.get(config, [ 'fileTypes', mimeType ] ); - util = _.get(Config, [ 'infoExtractUtils', util ]); - if(!util || !_.isString(util.cmd)) { - return; - } + if(Array.isArray(fileType)) { + // further refine by extention + fileType = fileType.find(ft => paths.extname(filePath) === ft.ext); + } - return util; + if(!_.isObject(fileType)) { + return; + } + + let util = _.get(fileType, `${descType}DescUtil`); + if(!_.isString(util)) { + return; + } + + util = _.get(config, [ 'infoExtractUtils', util ]); + if(!util || !_.isString(util.cmd)) { + return; + } + + return util; } function populateFileEntryInfoFromFile(fileEntry, filePath, cb) { - const mimeType = resolveMimeType(filePath); - if(!mimeType) { - return cb(null); - } + const mimeType = resolveMimeType(filePath); + if(!mimeType) { + return cb(null); + } - async.eachSeries( [ 'short', 'long' ], (descType, nextDesc) => { - const util = getInfoExtractUtilForDesc(mimeType, descType); - if(!util) { - return nextDesc(null); - } + async.eachSeries( [ 'short', 'long' ], (descType, nextDesc) => { + const util = getInfoExtractUtilForDesc(mimeType, filePath, descType); + if(!util) { + return nextDesc(null); + } - const args = (util.args || [ '{filePath}'] ).map( arg => stringFormat(arg, { filePath : filePath } ) ); + const args = (util.args || [ '{filePath}'] ).map( arg => stringFormat(arg, { filePath : filePath } ) ); - execFile(util.cmd, args, { timeout : 1000 * 30 }, (err, stdout) => { - if(err || !stdout) { - const reason = err ? err.message : 'No description produced'; - logDebug( - { reason : reason, cmd : util.cmd, args : args }, - `${_.upperFirst(descType)} description command failed` - ); - } else { - stdout = (stdout || '').trim(); - if(stdout.length > 0) { - const key = 'short' === descType ? 'desc' : 'descLong'; - if('desc' === key) { - // - // Word wrap short descriptions to FILE_ID.DIZ spec - // - // "...no more than 45 characters long" - // - // See http://www.textfiles.com/computers/fileid.txt - // - stdout = (wordWrapText( stdout, { width : 45 } ).wrapped || []).join('\n'); - } + execFile(util.cmd, args, { timeout : 1000 * 30 }, (err, stdout) => { + if(err || !stdout) { + const reason = err ? err.message : 'No description produced'; + logDebug( + { reason : reason, cmd : util.cmd, args : args }, + `${_.upperFirst(descType)} description command failed` + ); + } else { + stdout = stdout.trim(); + if(stdout.length > 0) { + const key = 'short' === descType ? 'desc' : 'descLong'; + if('desc' === key) { + // + // Word wrap short descriptions to FILE_ID.DIZ spec + // + // "...no more than 45 characters long" + // + // See http://www.textfiles.com/computers/fileid.txt + // + stdout = (wordWrapText( stdout, { width : 45 } ).wrapped || []).join('\n'); + } - fileEntry[key] = stdout; - } - } + fileEntry[key] = stdout; + fileEntry[`${key}Src`] = 'infoTool'; + } + } - return nextDesc(null); - }); - }, () => { - return cb(null); - }); + return nextDesc(null); + }); + }, () => { + return cb(null); + }); } function populateFileEntryNonArchive(fileEntry, filePath, stepInfo, iterator, cb) { - async.series( - [ - function processDescFilesStart(callback) { - stepInfo.step = 'desc_files_start'; - return iterator(callback); - }, - function getDescriptions(callback) { - populateFileEntryInfoFromFile(fileEntry, filePath, err => { - if(!fileEntry.desc) { - fileEntry.desc = getDescFromFileName(filePath); - } - return callback(err); - }); - }, - function processDescFilesFinish(callback) { - stepInfo.step = 'desc_files_finish'; - return iterator(callback); - }, - ], - err => { - return cb(err); - } - ); + async.series( + [ + function processDescFilesStart(callback) { + stepInfo.step = 'desc_files_start'; + return iterator(callback); + }, + function getDescriptions(callback) { + populateFileEntryInfoFromFile(fileEntry, filePath, err => { + if(!fileEntry.desc) { + fileEntry.desc = getDescFromFileName(filePath); + fileEntry.descSrc = 'fileName'; + } + return callback(err); + }); + }, + function processDescFilesFinish(callback) { + stepInfo.step = 'desc_files_finish'; + return iterator(callback); + }, + ], + err => { + return cb(err); + } + ); } function addNewFileEntry(fileEntry, filePath, cb) { - // :TODO: Use detectTypeWithBuf() once avail - we *just* read some file data - - async.series( - [ - function addNewDbRecord(callback) { - return fileEntry.persist(callback); - } - ], - err => { - return cb(err); - } - ); -} - -function updateFileEntry(fileEntry, filePath, cb) { + // :TODO: Use detectTypeWithBuf() once avail - we *just* read some file data + async.series( + [ + function addNewDbRecord(callback) { + return fileEntry.persist(callback); + } + ], + err => { + return cb(err); + } + ); } const HASH_NAMES = [ 'sha1', 'sha256', 'md5', 'crc32' ]; function scanFile(filePath, options, iterator, cb) { - if(3 === arguments.length && _.isFunction(iterator)) { - cb = iterator; - iterator = null; - } else if(2 === arguments.length && _.isFunction(options)) { - cb = options; - iterator = null; - options = {}; - } + if(3 === arguments.length && _.isFunction(iterator)) { + cb = iterator; + iterator = null; + } else if(2 === arguments.length && _.isFunction(options)) { + cb = options; + iterator = null; + options = {}; + } - const fileEntry = new FileEntry({ - areaTag : options.areaTag, - meta : options.meta, - hashTags : options.hashTags, // Set() or Array - fileName : paths.basename(filePath), - storageTag : options.storageTag, - fileSha256 : options.sha256, // caller may know this already - }); + const fileEntry = new FileEntry({ + areaTag : options.areaTag, + meta : options.meta, + hashTags : options.hashTags, // Set() or Array + fileName : paths.basename(filePath), + storageTag : options.storageTag, + fileSha256 : options.sha256, // caller may know this already + }); - const stepInfo = { - filePath : filePath, - fileName : paths.basename(filePath), - }; + const stepInfo = { + filePath : filePath, + fileName : paths.basename(filePath), + }; - function callIter(next) { - if(iterator) { - return iterator(stepInfo, next); - } else { - return next(null); - } - } + const callIter = (next) => { + return iterator ? iterator(stepInfo, next) : next(null); + }; - function readErrorCallIter(origError, next) { - stepInfo.step = 'read_error'; - stepInfo.error = origError.message; + const readErrorCallIter = (origError, next) => { + stepInfo.step = 'read_error'; + stepInfo.error = origError.message; - callIter( () => { - return next(origError); - }); - } + callIter( () => { + return next(origError); + }); + }; + let lastCalcHashPercent; - let lastCalcHashPercent; + // don't re-calc hashes for any we already have in |options| + const hashesToCalc = HASH_NAMES.filter(hn => { + if('sha256' === hn && fileEntry.fileSha256) { + return false; + } - // don't re-calc hashes for any we already have in |options| - const hashesToCalc = HASH_NAMES.filter(hn => { - if('sha256' === hn && fileEntry.fileSha256) { - return false; - } + if(`file_${hn}` in fileEntry.meta) { + return false; + } - if(`file_${hn}` in fileEntry.meta) { - return false; - } + return true; + }); - return true; - }); + async.waterfall( + [ + function startScan(callback) { + fs.stat(filePath, (err, stats) => { + if(err) { + return readErrorCallIter(err, callback); + } - async.waterfall( - [ - function startScan(callback) { - fs.stat(filePath, (err, stats) => { - if(err) { - return readErrorCallIter(err, callback); - } + stepInfo.step = 'start'; + stepInfo.byteSize = fileEntry.meta.byte_size = stats.size; - stepInfo.step = 'start'; - stepInfo.byteSize = fileEntry.meta.byte_size = stats.size; + return callIter(callback); + }); + }, + function processPhysicalFileGeneric(callback) { + stepInfo.bytesProcessed = 0; - return callIter(callback); - }); - }, - function processPhysicalFileGeneric(callback) { - stepInfo.bytesProcessed = 0; + const hashes = {}; + hashesToCalc.forEach(hashName => { + if('crc32' === hashName) { + hashes.crc32 = new CRC32; + } else { + hashes[hashName] = crypto.createHash(hashName); + } + }); - const hashes = {}; - hashesToCalc.forEach(hashName => { - if('crc32' === hashName) { - hashes.crc32 = new CRC32; - } else { - hashes[hashName] = crypto.createHash(hashName); - } - }); + const updateHashes = (data) => { + for(let i = 0; i < hashesToCalc.length; ++i) { + hashes[hashesToCalc[i]].update(data); + } + }; - const stream = fs.createReadStream(filePath); + // + // Note that we are not using fs.createReadStream() here: + // While convenient, it is quite a bit slower -- which adds + // up to many seconds in time for larger files. + // + const chunkSize = 1024 * 64; + const buffer = Buffer.allocUnsafe(chunkSize); - function updateHashes(data) { - async.each(hashesToCalc, (hashName, nextHash) => { - hashes[hashName].update(data); - return nextHash(null); - }, () => { - return stream.resume(); - }); - } + fs.open(filePath, 'r', (err, fd) => { + if(err) { + return readErrorCallIter(err, callback); + } - stream.on('data', data => { - stream.pause(); // until iterator compeltes + const nextChunk = () => { + fs.read(fd, buffer, 0, chunkSize, null, (err, bytesRead) => { + if(err) { + return fs.close(fd, closeErr => { + if(closeErr) { + logError( { filePath, error : err.message }, 'Failed to close file'); + } + return readErrorCallIter(err, callback); + }); + } - stepInfo.bytesProcessed += data.length; - stepInfo.calcHashPercent = Math.round(((stepInfo.bytesProcessed / stepInfo.byteSize) * 100)); + if(0 === bytesRead) { + // done - finalize + fileEntry.meta.byte_size = stepInfo.bytesProcessed; - // - // Only send 'hash_update' step update if we have a noticable percentage change in progress - // - if(stepInfo.calcHashPercent === lastCalcHashPercent) { - updateHashes(data); - } else { - lastCalcHashPercent = stepInfo.calcHashPercent; - stepInfo.step = 'hash_update'; + for(let i = 0; i < hashesToCalc.length; ++i) { + const hashName = hashesToCalc[i]; + if('sha256' === hashName) { + stepInfo.sha256 = fileEntry.fileSha256 = hashes.sha256.digest('hex'); + } else if('sha1' === hashName || 'md5' === hashName) { + stepInfo[hashName] = fileEntry.meta[`file_${hashName}`] = hashes[hashName].digest('hex'); + } else if('crc32' === hashName) { + stepInfo.crc32 = fileEntry.meta.file_crc32 = hashes.crc32.finalize().toString(16); + } + } - callIter(err => { - if(err) { - stream.destroy(); // cancel read - return callback(err); - } + stepInfo.step = 'hash_finish'; + return fs.close(fd, closeErr => { + if(closeErr) { + logError( { filePath, error : err.message }, 'Failed to close file'); + } + return callIter(callback); + }); + } - updateHashes(data); - }); - } - }); + stepInfo.bytesProcessed += bytesRead; + stepInfo.calcHashPercent = Math.round(((stepInfo.bytesProcessed / stepInfo.byteSize) * 100)); - stream.on('end', () => { - fileEntry.meta.byte_size = stepInfo.bytesProcessed; + // + // Only send 'hash_update' step update if we have a noticable percentage change in progress + // + const data = bytesRead < chunkSize ? buffer.slice(0, bytesRead) : buffer; + if(!iterator || stepInfo.calcHashPercent === lastCalcHashPercent) { + updateHashes(data); + return nextChunk(); + } else { + lastCalcHashPercent = stepInfo.calcHashPercent; + stepInfo.step = 'hash_update'; - async.each(hashesToCalc, (hashName, nextHash) => { - if('sha256' === hashName) { - stepInfo.sha256 = fileEntry.fileSha256 = hashes.sha256.digest('hex'); - } else if('sha1' === hashName || 'md5' === hashName) { - stepInfo[hashName] = fileEntry.meta[`file_${hashName}`] = hashes[hashName].digest('hex'); - } else if('crc32' === hashName) { - stepInfo.crc32 = fileEntry.meta.file_crc32 = hashes.crc32.finalize().toString(16); - } + callIter(err => { + if(err) { + return callback(err); + } - return nextHash(null); - }, () => { - stepInfo.step = 'hash_finish'; - return callIter(callback); - }); - }); + updateHashes(data); + return nextChunk(); + }); + } + }); + }; - stream.on('error', err => { - return readErrorCallIter(err, callback); - }); - }, - function processPhysicalFileByType(callback) { - const archiveUtil = ArchiveUtil.getInstance(); + nextChunk(); + }); + }, + function processPhysicalFileByType(callback) { + const archiveUtil = ArchiveUtil.getInstance(); - archiveUtil.detectType(filePath, (err, archiveType) => { - if(archiveType) { - // save this off - fileEntry.meta.archive_type = archiveType; + archiveUtil.detectType(filePath, (err, archiveType) => { + if(archiveType) { + // save this off + fileEntry.meta.archive_type = archiveType; - populateFileEntryWithArchive(fileEntry, filePath, stepInfo, callIter, err => { - if(err) { - populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => { - // :TODO: log err - return callback(null); // ignore err - }); - } else { - return callback(null); - } - }); - } else { - populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => { - // :TODO: log err - return callback(null); // ignore err - }); - } - }); - }, - function fetchExistingEntry(callback) { - getExistingFileEntriesBySha256(fileEntry.fileSha256, (err, dupeEntries) => { - return callback(err, dupeEntries); - }); - }, - function finished(dupeEntries, callback) { - stepInfo.step = 'finished'; - callIter( () => { - return callback(null, dupeEntries); - }); - } - ], - (err, dupeEntries) => { - if(err) { - return cb(err); - } + populateFileEntryWithArchive(fileEntry, filePath, stepInfo, callIter, err => { + if(err) { + populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => { + if(err) { + logDebug( { error : err.message }, 'Non-archive file entry population failed'); + } + return callback(null); // ignore err + }); + } else { + return callback(null); + } + }); + } else { + populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => { + if(err) { + logDebug( { error : err.message }, 'Non-archive file entry population failed'); + } + return callback(null); // ignore err + }); + } + }); + }, + function fetchExistingEntry(callback) { + getExistingFileEntriesBySha256(fileEntry.fileSha256, (err, dupeEntries) => { + return callback(err, dupeEntries); + }); + }, + function finished(dupeEntries, callback) { + stepInfo.step = 'finished'; + callIter( () => { + return callback(null, dupeEntries); + }); + } + ], + (err, dupeEntries) => { + if(err) { + return cb(err); + } - return cb(null, fileEntry, dupeEntries); - } - ); + return cb(null, fileEntry, dupeEntries); + } + ); } function scanFileAreaForChanges(areaInfo, options, iterator, cb) { - if(3 === arguments.length && _.isFunction(iterator)) { - cb = iterator; - iterator = null; - } else if(2 === arguments.length && _.isFunction(options)) { - cb = options; - iterator = null; - options = {}; - } + if(3 === arguments.length && _.isFunction(iterator)) { + cb = iterator; + iterator = null; + } else if(2 === arguments.length && _.isFunction(options)) { + cb = options; + iterator = null; + options = {}; + } - const storageLocations = getAreaStorageLocations(areaInfo); + const storageLocations = getAreaStorageLocations(areaInfo); - async.eachSeries(storageLocations, (storageLoc, nextLocation) => { - async.series( - [ - function scanPhysFiles(callback) { - const physDir = storageLoc.dir; + async.eachSeries(storageLocations, (storageLoc, nextLocation) => { + async.series( + [ + function scanPhysFiles(callback) { + const physDir = storageLoc.dir; - fs.readdir(physDir, (err, files) => { - if(err) { - return callback(err); - } + fs.readdir(physDir, (err, files) => { + if(err) { + return callback(err); + } - async.eachSeries(files, (fileName, nextFile) => { - const fullPath = paths.join(physDir, fileName); + async.eachSeries(files, (fileName, nextFile) => { + const fullPath = paths.join(physDir, fileName); - fs.stat(fullPath, (err, stats) => { - if(err) { - // :TODO: Log me! - return nextFile(null); // always try next file - } + fs.stat(fullPath, (err, stats) => { + if(err) { + // :TODO: Log me! + return nextFile(null); // always try next file + } - if(!stats.isFile()) { - return nextFile(null); - } + if(!stats.isFile()) { + return nextFile(null); + } - scanFile( - fullPath, - { - areaTag : areaInfo.areaTag, - storageTag : storageLoc.storageTag - }, - iterator, - (err, fileEntry, dupeEntries) => { - if(err) { - // :TODO: Log me!!! - return nextFile(null); // try next anyway - } + scanFile( + fullPath, + { + areaTag : areaInfo.areaTag, + storageTag : storageLoc.storageTag + }, + iterator, + (err, fileEntry, dupeEntries) => { + if(err) { + // :TODO: Log me!!! + return nextFile(null); // try next anyway + } - if(dupeEntries.length > 0) { - // :TODO: Handle duplidates -- what to do here??? - } else { - if(Array.isArray(options.tags)) { - options.tags.forEach(tag => { - fileEntry.hashTags.add(tag); - }); - } - addNewFileEntry(fileEntry, fullPath, err => { - // pass along error; we failed to insert a record in our DB or something else bad - return nextFile(err); - }); - } - } - ); - }); - }, err => { - return callback(err); - }); - }); - }, - function scanDbEntries(callback) { - // :TODO: Look @ db entries for area that were *not* processed above - return callback(null); - } - ], - err => { - return nextLocation(err); - } - ); - }, - err => { - return cb(err); - }); + if(dupeEntries.length > 0) { + // :TODO: Handle duplidates -- what to do here??? + } else { + if(Array.isArray(options.tags)) { + options.tags.forEach(tag => { + fileEntry.hashTags.add(tag); + }); + } + addNewFileEntry(fileEntry, fullPath, err => { + // pass along error; we failed to insert a record in our DB or something else bad + return nextFile(err); + }); + } + } + ); + }); + }, err => { + return callback(err); + }); + }); + }, + function scanDbEntries(callback) { + // :TODO: Look @ db entries for area that were *not* processed above + return callback(null); + } + ], + err => { + return nextLocation(err); + } + ); + }, + err => { + return cb(err); + }); } function getDescFromFileName(fileName) { - // :TODO: this method could use some more logic to really be nice. - const ext = paths.extname(fileName); - const name = paths.basename(fileName, ext); + // + // Example filenames: + // + // input desired output + // ----------------------------------------------------------------------------------------- + // Nintendo_Power_Issue_011_March-April_1990.cbr Nintendo Power Issue 011 March-April 1990 + // Atari User Issue 3 (July 1985).pdf Atari User Issue 3 (July 1985) + // Out_Of_The_Shadows_010__1953_.cbz Out Of The Shadows 010 1953 + // ABC A Basic Compiler 1.03 [pro].atr ABC A Basic Compiler 1.03 [pro] + // 221B Baker Street v1.0 (1987)(Datasoft)(Side B)[cr The Bounty].zip 221B Baker Street v1.0 (1987)(Datasoft)(Side B)[cr the Bounty] + // + // See also: + // * https://scenerules.org/ + // - return _.upperFirst(name.replace(/[\-_.+]/g, ' ').replace(/\s+/g, ' ')); + const ext = paths.extname(fileName); + const name = paths.basename(fileName, ext); + const asIsRe = /([vV]?(?:[0-9]{1,4})(?:\.[0-9]{1,4})+[-+]?(?:[a-z]{1,4})?)|(Incl\.)|(READ\.NFO)/g; + + const normalize = (s) => { + return _.upperFirst(s.replace(/[-_.+]/g, ' ').replace(/\s+/g, ' ')); + }; + + let out = ''; + let m; + let pos; + do { + pos = asIsRe.lastIndex; + m = asIsRe.exec(name); + if(m) { + if(m.index > pos) { + out += normalize(name.slice(pos, m.index)); + } + out += m[0]; // as-is + } + } while(0 != asIsRe.lastIndex); + + if(pos < name.length) { + out += normalize(name.slice(pos)); + } + + return out; +} + +// +// Return an object of stats about an area(s) +// +// { +// +// totalFiles : , +// totalBytes : , +// areas : { +// : { +// files : , +// bytes : +// } +// } +// } +// +function getAreaStats(cb) { + FileDb.all( + `SELECT DISTINCT f.area_tag, COUNT(f.file_id) AS total_files, SUM(m.meta_value) AS total_byte_size + FROM file f, file_meta m + WHERE f.file_id = m.file_id AND m.meta_name='byte_size' + GROUP BY f.area_tag;`, + (err, statRows) => { + if(err) { + return cb(err); + } + + if(!statRows || 0 === statRows.length) { + return cb(Errors.DoesNotExist('No file areas to acquire stats from')); + } + + return cb( + null, + statRows.reduce( (stats, v) => { + stats.totalFiles = (stats.totalFiles || 0) + v.total_files; + stats.totalBytes = (stats.totalBytes || 0) + v.total_byte_size; + + stats.areas = stats.areas || {}; + + stats.areas[v.area_tag] = { + files : v.total_files, + bytes : v.total_byte_size, + }; + return stats; + }, {}) + ); + } + ); +} + +// method exposed for event scheduler +function updateAreaStatsScheduledEvent(args, cb) { + getAreaStats( (err, stats) => { + if(!err) { + StatLog.setNonPersistentSystemStat(SysProps.FileBaseAreaStats, stats); + } + + return cb(err); + }); +} + +function cleanUpTempSessionItems(cb) { + // find (old) temporary session items and nuke 'em + const filter = { + areaTag : WellKnownAreaTags.TempDownloads, + metaPairs : [ + { + name : 'session_temp_dl', + value : 1 + } + ] + }; + + FileEntry.findFiles(filter, (err, fileIds) => { + if(err) { + return cb(err); + } + + async.each(fileIds, (fileId, nextFileId) => { + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + if(err) { + Log.warn( { fileId }, 'Failed loading temporary session download item for cleanup'); + return nextFileId(null); + } + + FileEntry.removeEntry(fileEntry, { removePhysFile : true }, err => { + if(err) { + Log.warn( { fileId : fileEntry.fileId, filePath : fileEntry.filePath }, 'Failed to clean up temporary session download item'); + } + return nextFileId(null); + }); + }); + }, () => { + return cb(null); + }); + }); } \ No newline at end of file diff --git a/core/file_base_area_select.js b/core/file_base_area_select.js new file mode 100644 index 00000000..a8f74322 --- /dev/null +++ b/core/file_base_area_select.js @@ -0,0 +1,88 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const MenuModule = require('./menu_module.js').MenuModule; +const { getSortedAvailableFileAreas } = require('./file_base_area.js'); +const StatLog = require('./stat_log.js'); +const SysProps = require('./system_property.js'); + +// deps +const async = require('async'); + +exports.moduleInfo = { + name : 'File Area Selector', + desc : 'Select from available file areas', + author : 'NuSkooler', +}; + +const MciViewIds = { + areaList : 1, +}; + +exports.getModule = class FileAreaSelectModule extends MenuModule { + constructor(options) { + super(options); + + this.menuMethods = { + selectArea : (formData, extraArgs, cb) => { + const filterCriteria = { + areaTag : formData.value.areaTag, + }; + + const menuOpts = { + extraArgs : { + filterCriteria : filterCriteria, + }, + menuFlags : [ 'popParent', 'mergeFlags' ], + }; + + return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + + async.waterfall( + [ + function mergeAreaStats(callback) { + const areaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats) || { areas : {} }; + + // we could use 'sort' alone, but area/conf sorting has some special properties; user can still override + const availAreas = getSortedAvailableFileAreas(self.client); + availAreas.forEach(area => { + const stats = areaStats.areas[area.areaTag]; + area.totalFiles = stats ? stats.files : 0; + area.totalBytes = stats ? stats.bytes : 0; + }); + + return callback(null, availAreas); + }, + function prepView(availAreas, callback) { + self.prepViewController('allViews', 0, mciData.menu, (err, vc) => { + if(err) { + return callback(err); + } + + const areaListView = vc.getView(MciViewIds.areaList); + areaListView.setItems(availAreas.map(area => Object.assign(area, { text : area.name, data : area.areaTag } ))); + areaListView.redraw(); + + return callback(null); + }); + } + ], + err => { + return cb(err); + } + ); + }); + } +}; diff --git a/core/file_base_download_manager.js b/core/file_base_download_manager.js new file mode 100644 index 00000000..8487697f --- /dev/null +++ b/core/file_base_download_manager.js @@ -0,0 +1,237 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const DownloadQueue = require('./download_queue.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const Errors = require('./enig_error.js').Errors; +const FileAreaWeb = require('./file_area_web.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); + +exports.moduleInfo = { + name : 'File Base Download Queue Manager', + desc : 'Module for interacting with download queue/batch', + author : 'NuSkooler', +}; + +const FormIds = { + queueManager : 0, +}; + +const MciViewIds = { + queueManager : { + queue : 1, + navMenu : 2, + + customRangeStart : 10, + }, +}; + +exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { + + constructor(options) { + super(options); + + this.dlQueue = new DownloadQueue(this.client); + + if(_.has(options, 'lastMenuResult.sentFileIds')) { + this.sentFileIds = options.lastMenuResult.sentFileIds; + } + + this.fallbackOnly = options.lastMenuResult ? true : false; + + this.menuMethods = { + downloadAll : (formData, extraArgs, cb) => { + const modOpts = { + extraArgs : { + sendQueue : this.dlQueue.items, + direction : 'send', + } + }; + + return this.gotoMenu(this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', modOpts, cb); + }, + removeItem : (formData, extraArgs, cb) => { + const selectedItem = this.dlQueue.items[formData.value.queueItem]; + if(!selectedItem) { + return cb(null); + } + + this.dlQueue.removeItems(selectedItem.fileId); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb); + }, + clearQueue : (formData, extraArgs, cb) => { + this.dlQueue.clear(); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView('all', cb); + } + }; + } + + initSequence() { + if(0 === this.dlQueue.items.length) { + if(this.sendFileIds) { + // we've finished everything up - just fall back + return this.prevMenu(); + } + + // Simply an empty D/L queue: Present a specialized "empty queue" page + return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); + } + + const self = this; + + async.series( + [ + function beforeArt(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + return self.displayQueueManagerPage(false, callback); + } + ], + () => { + return self.finishedLoading(); + } + ); + } + + removeItemsFromDownloadQueueView(itemIndex, cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + return cb(Errors.DoesNotExist('Queue view does not exist')); + } + + if('all' === itemIndex) { + queueView.setItems([]); + queueView.setFocusItems([]); + } else { + queueView.removeItem(itemIndex); + } + + queueView.redraw(); + return cb(null); + } + + displayWebDownloadLinkForFileEntry(fileEntry) { + FileAreaWeb.getExistingTempDownloadServeItem(this.client, fileEntry, (err, serveItem) => { + if(serveItem && serveItem.url) { + const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + fileEntry.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; + fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + } else { + fileEntry.webDlLink = ''; + fileEntry.webDlExpire = ''; + } + + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, fileEntry, + { filter : [ '{webDlLink}', '{webDlExpire}' ] } + ); + }); + } + + updateDownloadQueueView(cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + return cb(Errors.DoesNotExist('Queue view does not exist')); + } + + queueView.setItems(this.dlQueue.items); + + queueView.on('index update', idx => { + const fileEntry = this.dlQueue.items[idx]; + this.displayWebDownloadLinkForFileEntry(fileEntry); + }); + + queueView.redraw(); + this.displayWebDownloadLinkForFileEntry(this.dlQueue.items[0]); + + return cb(null); + } + + displayQueueManagerPage(clearScreen, cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback); + }, + function populateViews(callback) { + return self.updateDownloadQueueView(callback); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayArtAndPrepViewController(name, options, cb) { + const self = this; + const config = this.menuConfig.config; + + async.waterfall( + [ + function readyAndDisplayArt(callback) { + if(options.clearScreen) { + self.client.term.rawWrite(ansi.resetScreen()); + } + + theme.displayThemedAsset( + config.art[name], + self.client, + { font : self.menuConfig.font, trailingLF : false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function prepeareViewController(artData, callback) { + if(_.isUndefined(self.viewControllers[name])) { + const vcOpts = { + client : self.client, + formId : FormIds[name], + }; + + if(!_.isUndefined(options.noInput)) { + vcOpts.noInput = options.noInput; + } + + const vc = self.addViewController(name, new ViewController(vcOpts)); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds[name], + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } + + self.viewControllers[name].setFocus(true); + return callback(null); + + }, + ], + err => { + return cb(err); + } + ); + } +}; diff --git a/core/file_base_filter.js b/core/file_base_filter.js index fadd41fd..ecf857fa 100644 --- a/core/file_base_filter.js +++ b/core/file_base_filter.js @@ -1,149 +1,157 @@ /* jslint node: true */ 'use strict'; -// deps -const _ = require('lodash'); -const uuidV4 = require('uuid/v4'); +const UserProps = require('./user_property.js'); + +// deps +const _ = require('lodash'); +const { v4 : UUIDv4 } = require('uuid'); module.exports = class FileBaseFilters { - constructor(client) { - this.client = client; - - this.load(); - } + constructor(client) { + this.client = client; - static get OrderByValues() { - return [ 'descending', 'ascending' ]; - } + this.load(); + } - static get SortByValues() { - return [ - 'upload_timestamp', - 'upload_by_username', - 'dl_count', - 'user_rating', - 'est_release_year', - 'byte_size', - ]; - } + static get OrderByValues() { + return [ 'descending', 'ascending' ]; + } - toArray() { - return _.map(this.filters, (filter, uuid) => { - return Object.assign( { uuid : uuid }, filter ); - }); - } + static get SortByValues() { + return [ + 'upload_timestamp', + 'upload_by_username', + 'dl_count', + 'user_rating', + 'est_release_year', + 'byte_size', + 'file_name', + ]; + } - get(filterUuid) { - return this.filters[filterUuid]; - } + toArray() { + return _.map(this.filters, (filter, uuid) => { + return Object.assign( { uuid : uuid }, filter ); + }); + } - add(filterInfo) { - const filterUuid = uuidV4(); - - filterInfo.tags = this.cleanTags(filterInfo.tags); - - this.filters[filterUuid] = filterInfo; - - return filterUuid; - } + get(filterUuid) { + return this.filters[filterUuid]; + } - replace(filterUuid, filterInfo) { - const filter = this.get(filterUuid); - if(!filter) { - return false; - } + add(filterInfo) { + const filterUuid = UUIDv4(); - filterInfo.tags = this.cleanTags(filterInfo.tags); - this.filters[filterUuid] = filterInfo; - return true; - } + filterInfo.tags = this.cleanTags(filterInfo.tags); - remove(filterUuid) { - delete this.filters[filterUuid]; - } + this.filters[filterUuid] = filterInfo; - load() { - let filtersProperty = this.client.user.properties.file_base_filters; - let defaulted; - if(!filtersProperty) { - filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters()); - defaulted = true; - } + return filterUuid; + } - try { - this.filters = JSON.parse(filtersProperty); - } catch(e) { - this.filters = FileBaseFilters.getBuiltInSystemFilters(); // something bad happened; reset everything back to defaults :( - defaulted = true; - this.client.log.error( { error : e.message, property : filtersProperty }, 'Failed parsing file base filters property' ); - } + replace(filterUuid, filterInfo) { + const filter = this.get(filterUuid); + if(!filter) { + return false; + } - if(defaulted) { - this.persist( err => { - if(!err) { - const defaultActiveUuid = this.toArray()[0].uuid; - this.setActive(defaultActiveUuid); - } - }); - } - } + filterInfo.tags = this.cleanTags(filterInfo.tags); + this.filters[filterUuid] = filterInfo; + return true; + } - persist(cb) { - return this.client.user.persistProperty('file_base_filters', JSON.stringify(this.filters), cb); - } + remove(filterUuid) { + delete this.filters[filterUuid]; + } - cleanTags(tags) { - return tags.toLowerCase().replace(/,?\s+|\,/g, ' ').trim(); - } + load() { + let filtersProperty = this.client.user.properties[UserProps.FileBaseFilters]; + let defaulted; + if(!filtersProperty) { + filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters()); + defaulted = true; + } - setActive(filterUuid) { - const activeFilter = this.get(filterUuid); - - if(activeFilter) { - this.activeFilter = activeFilter; - this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid); - return true; - } - - return false; - } + try { + this.filters = JSON.parse(filtersProperty); + } catch(e) { + this.filters = FileBaseFilters.getBuiltInSystemFilters(); // something bad happened; reset everything back to defaults :( + defaulted = true; + this.client.log.error( { error : e.message, property : filtersProperty }, 'Failed parsing file base filters property' ); + } - static getBuiltInSystemFilters() { - const U_LATEST = '7458b09d-40ab-4f9b-a0d7-0cf866646329'; + if(defaulted) { + this.persist( err => { + if(!err) { + const defaultActiveUuid = this.toArray()[0].uuid; + this.setActive(defaultActiveUuid); + } + }); + } + } - const filters = { - [ U_LATEST ] : { - name : 'By Date Added', - areaTag : '', // all - terms : '', // * - tags : '', // * - order : 'descending', - sort : 'upload_timestamp', - uuid : U_LATEST, - system : true, - } - }; + persist(cb) { + return this.client.user.persistProperty(UserProps.FileBaseFilters, JSON.stringify(this.filters), cb); + } - return filters; - } + cleanTags(tags) { + return tags.toLowerCase().replace(/,?\s+|,/g, ' ').trim(); + } - static getActiveFilter(client) { - return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid); - } + setActive(filterUuid) { + const activeFilter = this.get(filterUuid); - static getFileBaseLastViewedFileIdByUser(user) { - return parseInt((user.properties.user_file_base_last_viewed || 0)); - } + if(activeFilter) { + this.activeFilter = activeFilter; + this.client.user.persistProperty(UserProps.FileBaseFilterActiveUuid, filterUuid); + return true; + } - static setFileBaseLastViewedFileIdForUser(user, fileId, cb) { - const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user); - if(fileId < current) { - if(cb) { - cb(null); - } - return; - } + return false; + } - return user.persistProperty('user_file_base_last_viewed', fileId, cb); - } + static getBuiltInSystemFilters() { + const U_LATEST = '7458b09d-40ab-4f9b-a0d7-0cf866646329'; + + const filters = { + [ U_LATEST ] : { + name : 'By Date Added', + areaTag : '', // all + terms : '', // * + tags : '', // * + order : 'descending', + sort : 'upload_timestamp', + uuid : U_LATEST, + system : true, + } + }; + + return filters; + } + + static getActiveFilter(client) { + return new FileBaseFilters(client).get(client.user.properties[UserProps.FileBaseFilterActiveUuid]); + } + + static getFileBaseLastViewedFileIdByUser(user) { + return parseInt((user.properties[UserProps.FileBaseLastViewedId] || 0)); + } + + static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) { + if(!cb && _.isFunction(allowOlder)) { + cb = allowOlder; + allowOlder = false; + } + + const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user); + if(!allowOlder && fileId < current) { + if(cb) { + cb(null); + } + return; + } + + return user.persistProperty(UserProps.FileBaseLastViewedId, fileId, cb); + } }; diff --git a/core/file_base_list_export.js b/core/file_base_list_export.js new file mode 100644 index 00000000..5c4991f3 --- /dev/null +++ b/core/file_base_list_export.js @@ -0,0 +1,301 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const stringFormat = require('./string_format.js'); +const FileEntry = require('./file_entry.js'); +const FileArea = require('./file_base_area.js'); +const Config = require('./config.js').get; +const { Errors } = require('./enig_error.js'); +const { + splitTextAtTerms, + isAnsi, +} = require('./string_util.js'); +const AnsiPrep = require('./ansi_prep.js'); +const Log = require('./logger.js').log; + +// deps +const _ = require('lodash'); +const async = require('async'); +const fs = require('graceful-fs'); +const paths = require('path'); +const iconv = require('iconv-lite'); +const moment = require('moment'); + +exports.exportFileList = exportFileList; +exports.updateFileBaseDescFilesScheduledEvent = updateFileBaseDescFilesScheduledEvent; + +function exportFileList(filterCriteria, options, cb) { + options.templateEncoding = options.templateEncoding || 'utf8'; + options.entryTemplate = options.entryTemplate || 'descript_ion_export_entry_template.asc'; + options.tsFormat = options.tsFormat || 'YYYY-MM-DD'; + options.descWidth = options.descWidth || 45; // FILE_ID.DIZ spec + options.escapeDesc = _.get(options, 'escapeDesc', false); // escape \r and \n in desc? + + if(true === options.escapeDesc) { + options.escapeDesc = '\\n'; + } + + const state = { + total : 0, + current : 0, + step : 'preparing', + status : 'Preparing', + }; + + const updateProgress = _.isFunction(options.progress) ? + progCb => { + return options.progress(state, progCb); + } : + progCb => { + return progCb(null); + } + ; + + async.waterfall( + [ + function readTemplateFiles(callback) { + updateProgress(err => { + if(err) { + return callback(err); + } + + const templateFiles = [ + { name : options.headerTemplate, req : false }, + { name : options.entryTemplate, req : true } + ]; + + const config = Config(); + async.map(templateFiles, (template, nextTemplate) => { + if(!template.name && !template.req) { + return nextTemplate(null, Buffer.from([])); + } + + template.name = paths.isAbsolute(template.name) ? template.name : paths.join(config.paths.misc, template.name); + fs.readFile(template.name, (err, data) => { + return nextTemplate(err, data); + }); + }, (err, templates) => { + if(err) { + return callback(Errors.General(err.message)); + } + + // decode + ensure DOS style CRLF + templates = templates.map(tmp => iconv.decode(tmp, options.templateEncoding).replace(/\r?\n/g, '\r\n') ); + + // Look for the first {fileDesc} (if any) in 'entry' template & find indentation requirements + let descIndent = 0; + if(!options.escapeDesc) { + splitTextAtTerms(templates[1]).some(line => { + const pos = line.indexOf('{fileDesc}'); + if(pos > -1) { + descIndent = pos; + return true; // found it! + } + return false; // keep looking + }); + } + + return callback(null, templates[0], templates[1], descIndent); + }); + }); + }, + function findFiles(headerTemplate, entryTemplate, descIndent, callback) { + state.step = 'gathering'; + state.status = 'Gathering files for supplied criteria'; + updateProgress(err => { + if(err) { + return callback(err); + } + + FileEntry.findFiles(filterCriteria, (err, fileIds) => { + if(0 === fileIds.length) { + return callback(Errors.General('No results for criteria', 'NORESULTS')); + } + + return callback(err, headerTemplate, entryTemplate, descIndent, fileIds); + }); + }); + }, + function buildListEntries(headerTemplate, entryTemplate, descIndent, fileIds, callback) { + const formatObj = { + totalFileCount : fileIds.length, + }; + + let current = 0; + let listBody = ''; + const totals = { fileCount : fileIds.length, bytes : 0 }; + state.total = fileIds.length; + + state.step = 'file'; + + async.eachSeries(fileIds, (fileId, nextFileId) => { + const fileInfo = new FileEntry(); + current += 1; + + fileInfo.load(fileId, err => { + if(err) { + return nextFileId(null); // failed, but try the next + } + + totals.bytes += fileInfo.meta.byte_size; + + const appendFileInfo = () => { + if(options.escapeDesc) { + formatObj.fileDesc = formatObj.fileDesc.replace(/\r?\n/g, options.escapeDesc); + } + + if(options.maxDescLen) { + formatObj.fileDesc = formatObj.fileDesc.slice(0, options.maxDescLen); + } + + listBody += stringFormat(entryTemplate, formatObj); + + state.current = current; + state.status = `Processing ${fileInfo.fileName}`; + state.fileInfo = formatObj; + + updateProgress(err => { + return nextFileId(err); + }); + }; + + const area = FileArea.getFileAreaByTag(fileInfo.areaTag); + + formatObj.fileId = fileId; + formatObj.areaName = _.get(area, 'name') || 'N/A'; + formatObj.areaDesc = _.get(area, 'desc') || 'N/A'; + formatObj.userRating = fileInfo.userRating || 0; + formatObj.fileName = fileInfo.fileName; + formatObj.fileSize = fileInfo.meta.byte_size; + formatObj.fileDesc = fileInfo.desc || ''; + formatObj.fileDescShort = formatObj.fileDesc.slice(0, options.descWidth); + formatObj.fileSha256 = fileInfo.fileSha256; + formatObj.fileCrc32 = fileInfo.meta.file_crc32; + formatObj.fileMd5 = fileInfo.meta.file_md5; + formatObj.fileSha1 = fileInfo.meta.file_sha1; + formatObj.uploadBy = fileInfo.meta.upload_by_username || 'N/A'; + formatObj.fileUploadTs = moment(fileInfo.uploadTimestamp).format(options.tsFormat); + formatObj.fileHashTags = fileInfo.hashTags.size > 0 ? Array.from(fileInfo.hashTags).join(', ') : 'N/A'; + formatObj.currentFile = current; + formatObj.progress = Math.floor( (current / fileIds.length) * 100 ); + + if(isAnsi(fileInfo.desc)) { + AnsiPrep( + fileInfo.desc, + { + cols : Math.min(options.descWidth, 79 - descIndent), + forceLineTerm : true, // ensure each line is term'd + asciiMode : true, // export to ASCII + fillLines : false, // don't fill up to |cols| + indent : descIndent, + }, + (err, desc) => { + if(desc) { + formatObj.fileDesc = desc; + } + return appendFileInfo(); + } + ); + } else { + const indentSpc = descIndent > 0 ? ' '.repeat(descIndent) : ''; + formatObj.fileDesc = splitTextAtTerms(formatObj.fileDesc).join(`\r\n${indentSpc}`) + '\r\n'; + return appendFileInfo(); + } + }); + }, err => { + return callback(err, listBody, headerTemplate, totals); + }); + }, + function buildHeader(listBody, headerTemplate, totals, callback) { + // header is built last such that we can have totals/etc. + + let filterAreaName; + let filterAreaDesc; + if(filterCriteria.areaTag) { + const area = FileArea.getFileAreaByTag(filterCriteria.areaTag); + filterAreaName = _.get(area, 'name') || 'N/A'; + filterAreaDesc = _.get(area, 'desc') || 'N/A'; + } else { + filterAreaName = '-ALL-'; + filterAreaDesc = 'All areas'; + } + + const headerFormatObj = { + nowTs : moment().format(options.tsFormat), + boardName : Config().general.boardName, + totalFileCount : totals.fileCount, + totalFileSize : totals.bytes, + filterAreaTag : filterCriteria.areaTag || '-ALL-', + filterAreaName : filterAreaName, + filterAreaDesc : filterAreaDesc, + filterTerms : filterCriteria.terms || '(none)', + filterHashTags : filterCriteria.tags || '(none)', + }; + + listBody = stringFormat(headerTemplate, headerFormatObj) + listBody; + return callback(null, listBody); + }, + function done(listBody, callback) { + delete state.fileInfo; + state.step = 'finished'; + state.status = 'Finished processing'; + updateProgress( () => { + return callback(null, listBody); + }); + } + ], (err, listBody) => { + return cb(err, listBody); + } + ); +} + +function updateFileBaseDescFilesScheduledEvent(args, cb) { + // + // For each area, loop over storage locations and build + // DESCRIPT.ION file to store in the same directory. + // + // Standard-ish 4DOS spec is as such: + // * Entry: [0x04]\r\n + // * Multi line descriptions are stored with *escaped* \r\n pairs + // * Default template uses 0x2c for as per https://stackoverflow.com/questions/1810398/descript-ion-file-spec + // + const entryTemplate = args[0]; + const headerTemplate = args[1]; + + const areas = FileArea.getAvailableFileAreas(null, { skipAcsCheck : true }); + async.each(areas, (area, nextArea) => { + const storageLocations = FileArea.getAreaStorageLocations(area); + + async.each(storageLocations, (storageLoc, nextStorageLoc) => { + const filterCriteria = { + areaTag : area.areaTag, + storageTag : storageLoc.storageTag, + }; + + const exportOpts = { + headerTemplate : headerTemplate, + entryTemplate : entryTemplate, + escapeDesc : true, // escape CRLF's + maxDescLen : 4096, // DESCRIPT.ION: "The line length limit is 4096 bytes" + }; + + exportFileList(filterCriteria, exportOpts, (err, listBody) => { + + const descIonPath = paths.join(storageLoc.dir, 'DESCRIPT.ION'); + fs.writeFile(descIonPath, iconv.encode(listBody, 'cp437'), err => { + if(err) { + Log.warn( { error : err.message, path : descIonPath }, 'Failed (re)creating DESCRIPT.ION'); + } else { + Log.debug( { path : descIonPath }, '(Re)generated DESCRIPT.ION'); + } + return nextStorageLoc(null); + }); + }); + }, () => { + return nextArea(null); + }); + }, () => { + return cb(null); + }); +} diff --git a/core/file_base_search.js b/core/file_base_search.js new file mode 100644 index 00000000..168ed39a --- /dev/null +++ b/core/file_base_search.js @@ -0,0 +1,120 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('./file_base_filter.js'); + +// deps +const async = require('async'); + +exports.moduleInfo = { + name : 'File Base Search', + desc : 'Module for quickly searching the file base', + author : 'NuSkooler', +}; + +const MciViewIds = { + search : { + searchTerms : 1, + search : 2, + tags : 3, + area : 4, + orderBy : 5, + sort : 6, + advSearch : 7, + } +}; + +exports.getModule = class FileBaseSearch extends MenuModule { + constructor(options) { + super(options); + + this.menuMethods = { + search : (formData, extraArgs, cb) => { + const isAdvanced = formData.submitId === MciViewIds.search.advSearch; + return this.searchNow(formData, isAdvanced, cb); + }, + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.addViewController( 'search', new ViewController( { client : this.client } ) ); + + async.series( + [ + function loadFromConfig(callback) { + return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function populateAreas(callback) { + self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); + + const areasView = vc.getView(MciViewIds.search.area); + areasView.setItems( self.availAreas.map( a => a.name ) ); + areasView.redraw(); + vc.switchFocus(MciViewIds.search.searchTerms); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + getSelectedAreaTag(index) { + if(0 === index) { + return ''; // -ALL- + } + const area = this.availAreas[index]; + if(!area) { + return ''; + } + return area.areaTag; + } + + getOrderBy(index) { + return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; + } + + getSortBy(index) { + return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; + } + + getFilterValuesFromFormData(formData, isAdvanced) { + const areaIndex = isAdvanced ? formData.value.areaIndex : 0; + const orderByIndex = isAdvanced ? formData.value.orderByIndex : 0; + const sortByIndex = isAdvanced ? formData.value.sortByIndex : 0; + + return { + areaTag : this.getSelectedAreaTag(areaIndex), + terms : formData.value.searchTerms, + tags : isAdvanced ? formData.value.tags : '', + order : this.getOrderBy(orderByIndex), + sort : this.getSortBy(sortByIndex), + }; + } + + searchNow(formData, isAdvanced, cb) { + const filterCriteria = this.getFilterValuesFromFormData(formData, isAdvanced); + + const menuOpts = { + extraArgs : { + filterCriteria : filterCriteria, + }, + menuFlags : [ 'popParent' ], + }; + + return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); + } +}; diff --git a/core/file_base_user_list_export.js b/core/file_base_user_list_export.js new file mode 100644 index 00000000..c3f3b6f8 --- /dev/null +++ b/core/file_base_user_list_export.js @@ -0,0 +1,278 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const FileEntry = require('./file_entry.js'); +const FileArea = require('./file_base_area.js'); +const { renderSubstr } = require('./string_util.js'); +const { Errors } = require('./enig_error.js'); +const DownloadQueue = require('./download_queue.js'); +const { exportFileList } = require('./file_base_list_export.js'); + +// deps +const _ = require('lodash'); +const async = require('async'); +const fs = require('graceful-fs'); +const fse = require('fs-extra'); +const paths = require('path'); +const moment = require('moment'); +const { v4 : UUIDv4 } = require('uuid'); +const yazl = require('yazl'); + +/* + Module config block can contain the following: + templateEncoding - encoding of template files (utf8) + tsFormat - timestamp format (theme 'short') + descWidth - max desc width (45) + progBarChar - progress bar character (▒) + compressThreshold - threshold to kick in compression for lists (1.44 MiB) + templates - object containing: + header - filename of header template (misc/file_list_header.asc) + entry - filename of entry template (misc/file_list_entry.asc) + + Header template variables: + nowTs, boardName, totalFileCount, totalFileSize, + filterAreaTag, filterAreaName, filterAreaDesc, + filterTerms, filterHashTags + + Entry template variables: + fileId, areaName, areaDesc, userRating, fileName, + fileSize, fileDesc, fileDescShort, fileSha256, fileCrc32, + fileMd5, fileSha1, uploadBy, fileUploadTs, fileHashTags, + currentFile, progress, +*/ + +exports.moduleInfo = { + name : 'File Base List Export', + desc : 'Exports file base listings for download', + author : 'NuSkooler', +}; + +const FormIds = { + main : 0, +}; + +const MciViewIds = { + main : { + status : 1, + progressBar : 2, + + customRangeStart : 10, + } +}; + +exports.getModule = class FileBaseListExport extends MenuModule { + + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), options.extraArgs); + + this.config.templateEncoding = this.config.templateEncoding || 'utf8'; + this.config.tsFormat = this.config.tsFormat || this.client.currentTheme.helpers.getDateTimeFormat('short'); + this.config.descWidth = this.config.descWidth || 45; // ie FILE_ID.DIZ + this.config.progBarChar = renderSubstr( (this.config.progBarChar || '▒'), 0, 1); + this.config.compressThreshold = this.config.compressThreshold || (1440000); // >= 1.44M by default :) + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => this.prepViewController('main', FormIds.main, mciData.menu, callback), + (callback) => this.prepareList(callback), + ], + err => { + if(err) { + if('NORESULTS' === err.reasonCode) { + return this.gotoMenu(this.menuConfig.config.noResultsMenu || 'fileBaseExportListNoResults'); + } + + return this.prevMenu(); + } + return cb(err); + } + ); + }); + } + + finishedLoading() { + this.prevMenu(); + } + + prepareList(cb) { + const self = this; + + const statusView = self.viewControllers.main.getView(MciViewIds.main.status); + const updateStatus = (status) => { + if(statusView) { + statusView.setText(status); + } + }; + + const progBarView = self.viewControllers.main.getView(MciViewIds.main.progressBar); + const updateProgressBar = (curr, total) => { + if(progBarView) { + const prog = Math.floor( (curr / total) * progBarView.dimens.width ); + progBarView.setText(self.config.progBarChar.repeat(prog)); + } + }; + + let cancel = false; + + const exportListProgress = (state, progNext) => { + switch(state.step) { + case 'preparing' : + case 'gathering' : + updateStatus(state.status); + break; + case 'file' : + updateStatus(state.status); + updateProgressBar(state.current, state.total); + self.updateCustomViewTextsWithFilter('main', MciViewIds.main.customRangeStart, state.fileInfo); + break; + default : + break; + } + + return progNext(cancel ? Errors.General('User canceled') : null); + }; + + const keyPressHandler = (ch, key) => { + if('escape' === key.name) { + cancel = true; + self.client.removeListener('key press', keyPressHandler); + } + }; + + async.waterfall( + [ + function buildList(callback) { + // this may take quite a while; temp disable of idle monitor + self.client.stopIdleMonitor(); + + self.client.on('key press', keyPressHandler); + + const filterCriteria = Object.assign({}, self.config.filterCriteria); + if(!filterCriteria.areaTag) { + filterCriteria.areaTag = FileArea.getAvailableFileAreaTags(self.client); + } + + const opts = { + templateEncoding : self.config.templateEncoding, + headerTemplate : _.get(self.config, 'templates.header', 'file_list_header.asc'), + entryTemplate : _.get(self.config, 'templates.entry', 'file_list_entry.asc'), + tsFormat : self.config.tsFormat, + descWidth : self.config.descWidth, + progress : exportListProgress, + }; + + exportFileList(filterCriteria, opts, (err, listBody) => { + return callback(err, listBody); + }); + }, + function persistList(listBody, callback) { + updateStatus('Persisting list'); + + const sysTempDownloadArea = FileArea.getFileAreaByTag(FileArea.WellKnownAreaTags.TempDownloads); + const sysTempDownloadDir = FileArea.getAreaDefaultStorageDirectory(sysTempDownloadArea); + + fse.mkdirs(sysTempDownloadDir, err => { + if(err) { + return callback(err); + } + + const outputFileName = paths.join( + sysTempDownloadDir, + `file_list_${UUIDv4().substr(-8)}_${moment().format('YYYY-MM-DD')}.txt` + ); + + fs.writeFile(outputFileName, listBody, 'utf8', err => { + if(err) { + return callback(err); + } + + self.getSizeAndCompressIfMeetsSizeThreshold(outputFileName, (err, finalOutputFileName, fileSize) => { + return callback(err, finalOutputFileName, fileSize, sysTempDownloadArea); + }); + }); + }); + }, + function persistFileEntry(outputFileName, fileSize, sysTempDownloadArea, callback) { + const newEntry = new FileEntry({ + areaTag : sysTempDownloadArea.areaTag, + fileName : paths.basename(outputFileName), + storageTag : sysTempDownloadArea.storageTags[0], + meta : { + upload_by_username : self.client.user.username, + upload_by_user_id : self.client.user.userId, + byte_size : fileSize, + session_temp_dl : 1, // download is valid until session is over + } + }); + + newEntry.desc = 'File List Export'; + + newEntry.persist(err => { + if(!err) { + // queue it! + DownloadQueue.get(self.client).addTemporaryDownload(newEntry); + } + return callback(err); + }); + }, + function done(callback) { + // re-enable idle monitor + // :TODO: this should probably be moved down below at the end of the full waterfall + self.client.startIdleMonitor(); + + updateStatus('Exported list has been added to your download queue'); + return callback(null); + } + ], + err => { + self.client.removeListener('key press', keyPressHandler); + return cb(err); + } + ); + } + + getSizeAndCompressIfMeetsSizeThreshold(filePath, cb) { + fse.stat(filePath, (err, stats) => { + if(err) { + return cb(err); + } + + if(stats.size < this.config.compressThreshold) { + // small enough, keep orig + return cb(null, filePath, stats.size); + } + + const zipFilePath = `${filePath}.zip`; + + const zipFile = new yazl.ZipFile(); + zipFile.addFile(filePath, paths.basename(filePath)); + zipFile.end( () => { + const outZipFile = fs.createWriteStream(zipFilePath); + zipFile.outputStream.pipe(outZipFile); + zipFile.outputStream.on('finish', () => { + // delete the original + fse.unlink(filePath, err => { + if(err) { + return cb(err); + } + + // finally stat the new output + fse.stat(zipFilePath, (err, stats) => { + return cb(err, zipFilePath, stats ? stats.size : 0); + }); + }); + }); + }); + }); + } +}; \ No newline at end of file diff --git a/core/file_base_web_download_manager.js b/core/file_base_web_download_manager.js new file mode 100644 index 00000000..cf509cd9 --- /dev/null +++ b/core/file_base_web_download_manager.js @@ -0,0 +1,282 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const DownloadQueue = require('./download_queue.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const Errors = require('./enig_error.js').Errors; +const FileAreaWeb = require('./file_area_web.js'); +const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; +const Config = require('./config.js').get; + +// deps +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); + +exports.moduleInfo = { + name : 'File Base Download Web Queue Manager', + desc : 'Module for interacting with web backed download queue/batch', + author : 'NuSkooler', +}; + +const FormIds = { + queueManager : 0 +}; + +const MciViewIds = { + queueManager : { + queue : 1, + navMenu : 2, + + customRangeStart : 10, + } +}; + +exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule { + + constructor(options) { + super(options); + + this.dlQueue = new DownloadQueue(this.client); + + this.menuMethods = { + removeItem : (formData, extraArgs, cb) => { + const selectedItem = this.dlQueue.items[formData.value.queueItem]; + if(!selectedItem) { + return cb(null); + } + + this.dlQueue.removeItems(selectedItem.fileId); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb); + }, + clearQueue : (formData, extraArgs, cb) => { + this.dlQueue.clear(); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView('all', cb); + }, + getBatchLink : (formData, extraArgs, cb) => { + return this.generateAndDisplayBatchLink(cb); + } + }; + } + + initSequence() { + if(0 === this.dlQueue.items.length) { + return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); + } + + const self = this; + + async.series( + [ + function beforeArt(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + return self.displayQueueManagerPage(false, callback); + } + ], + () => { + return self.finishedLoading(); + } + ); + } + + removeItemsFromDownloadQueueView(itemIndex, cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + return cb(Errors.DoesNotExist('Queue view does not exist')); + } + + if('all' === itemIndex) { + queueView.setItems([]); + queueView.setFocusItems([]); + } else { + queueView.removeItem(itemIndex); + } + + queueView.redraw(); + return cb(null); + } + + displayFileInfoForFileEntry(fileEntry) { + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, fileEntry, + { filter : [ '{webDlLink}', '{webDlExpire}', '{fileName}' ] } // :TODO: Others.... + ); + } + + updateDownloadQueueView(cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + return cb(Errors.DoesNotExist('Queue view does not exist')); + } + + queueView.setItems(this.dlQueue.items); + + queueView.on('index update', idx => { + const fileEntry = this.dlQueue.items[idx]; + this.displayFileInfoForFileEntry(fileEntry); + }); + + queueView.redraw(); + this.displayFileInfoForFileEntry(this.dlQueue.items[0]); + + return cb(null); + } + + generateAndDisplayBatchLink(cb) { + const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempBatchDownload( + this.client, + this.dlQueue.items, + { + expireTime : expireTime + }, + (err, webBatchDlLink) => { + // :TODO: handle not enabled -> display such + if(err) { + return cb(err); + } + + const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + const formatObj = { + webBatchDlLink : ansi.vtxHyperlink(this.client, webBatchDlLink) + webBatchDlLink, + webBatchDlExpire : expireTime.format(webDlExpireTimeFormat), + }; + + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, + formatObj, + { filter : Object.keys(formatObj).map(k => '{' + k + '}' ) } + ); + + return cb(null); + } + ); + } + + displayQueueManagerPage(clearScreen, cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback); + }, + function prepareQueueDownloadLinks(callback) { + const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + const config = Config(); + async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => { + FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => { + if(err) { + if(ErrNotEnabled === err.reasonCode) { + return nextFileEntry(err); // we should have caught this prior + } + + const expireTime = moment().add(config.fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempDownload( + self.client, + fileEntry, + { expireTime : expireTime }, + (err, url) => { + if(err) { + return nextFileEntry(err); + } + + fileEntry.webDlLinkRaw = url; + fileEntry.webDlLink = ansi.vtxHyperlink(self.client, url) + url; + fileEntry.webDlExpire = expireTime.format(webDlExpireTimeFormat); + + return nextFileEntry(null); + } + ); + } else { + fileEntry.webDlLinkRaw = serveItem.url; + fileEntry.webDlLink = ansi.vtxHyperlink(self.client, serveItem.url) + serveItem.url; + fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + return nextFileEntry(null); + } + }); + }, err => { + return callback(err); + }); + }, + function populateViews(callback) { + return self.updateDownloadQueueView(callback); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayArtAndPrepViewController(name, options, cb) { + const self = this; + const config = this.menuConfig.config; + + async.waterfall( + [ + function readyAndDisplayArt(callback) { + if(options.clearScreen) { + self.client.term.rawWrite(ansi.resetScreen()); + } + + theme.displayThemedAsset( + config.art[name], + self.client, + { font : self.menuConfig.font, trailingLF : false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function prepeareViewController(artData, callback) { + if(_.isUndefined(self.viewControllers[name])) { + const vcOpts = { + client : self.client, + formId : FormIds[name], + }; + + if(!_.isUndefined(options.noInput)) { + vcOpts.noInput = options.noInput; + } + + const vc = self.addViewController(name, new ViewController(vcOpts)); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds[name], + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } + + self.viewControllers[name].setFocus(true); + return callback(null); + + }, + ], + err => { + return cb(err); + } + ); + } +}; diff --git a/core/file_entry.js b/core/file_entry.js index b88d41a0..0539f137 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -1,577 +1,696 @@ /* jslint node: true */ 'use strict'; -const fileDb = require('./database.js').dbs.file; -const Errors = require('./enig_error.js').Errors; -const getISOTimestampString = require('./database.js').getISOTimestampString; -const Config = require('./config.js').config; +const fileDb = require('./database.js').dbs.file; +const Errors = require('./enig_error.js').Errors; +const { + getISOTimestampString, + sanitizeString +} = require('./database.js'); +const Config = require('./config.js').get; -// deps -const async = require('async'); -const _ = require('lodash'); -const paths = require('path'); -const fse = require('fs-extra'); +// deps +const async = require('async'); +const _ = require('lodash'); +const paths = require('path'); +const fse = require('fs-extra'); +const { unlink, readFile } = require('graceful-fs'); +const crypto = require('crypto'); +const moment = require('moment'); -const FILE_TABLE_MEMBERS = [ - 'file_id', 'area_tag', 'file_sha256', 'file_name', 'storage_tag', - 'desc', 'desc_long', 'upload_timestamp' +const FILE_TABLE_MEMBERS = [ + 'file_id', 'area_tag', 'file_sha256', 'file_name', 'storage_tag', + 'desc', 'desc_long', 'upload_timestamp' ]; const FILE_WELL_KNOWN_META = { - // name -> *read* converter, if any - upload_by_username : null, - upload_by_user_id : (u) => parseInt(u) || 0, - file_md5 : null, - file_sha1 : null, - file_crc32 : null, - est_release_year : (y) => parseInt(y) || new Date().getFullYear(), - dl_count : (d) => parseInt(d) || 0, - byte_size : (b) => parseInt(b) || 0, - archive_type : null, - short_file_name : null, // e.g. DOS 8.3 filename, avail in some scenarios such as TIC import - tic_origin : null, // TIC "Origin" - tic_desc : null, // TIC "Desc" - tic_ldesc : null, // TIC "Ldesc" joined by '\n' + // name -> *read* converter, if any + upload_by_username : null, + upload_by_user_id : (u) => parseInt(u) || 0, + file_md5 : null, + file_sha1 : null, + file_crc32 : null, + est_release_year : (y) => parseInt(y) || new Date().getFullYear(), + dl_count : (d) => parseInt(d) || 0, + byte_size : (b) => parseInt(b) || 0, + archive_type : null, + short_file_name : null, // e.g. DOS 8.3 filename, avail in some scenarios such as TIC import + tic_origin : null, // TIC "Origin" + tic_desc : null, // TIC "Desc" + tic_ldesc : null, // TIC "Ldesc" joined by '\n' + session_temp_dl : (v) => parseInt(v) ? true : false, + desc_sauce : (s) => JSON.parse(s) || {}, + desc_long_sauce : (s) => JSON.parse(s) || {}, }; module.exports = class FileEntry { - constructor(options) { - options = options || {}; + constructor(options) { + options = options || {}; - this.fileId = options.fileId || 0; - this.areaTag = options.areaTag || ''; - this.meta = options.meta || { - // values we always want - dl_count : 0, - }; - - this.hashTags = options.hashTags || new Set(); - this.fileName = options.fileName; - this.storageTag = options.storageTag; - this.fileSha256 = options.fileSha256; - } + this.fileId = options.fileId || 0; + this.areaTag = options.areaTag || ''; + this.meta = Object.assign( { dl_count : 0 }, options.meta); + this.hashTags = options.hashTags || new Set(); + this.fileName = options.fileName; + this.storageTag = options.storageTag; + this.fileSha256 = options.fileSha256; + } - static loadBasicEntry(fileId, dest, cb) { - dest = dest || {}; + static loadBasicEntry(fileId, dest, cb) { + dest = dest || {}; - fileDb.get( - `SELECT ${FILE_TABLE_MEMBERS.join(', ')} - FROM file - WHERE file_id=? - LIMIT 1;`, - [ fileId ], - (err, file) => { - if(err) { - return cb(err); - } + fileDb.get( + `SELECT ${FILE_TABLE_MEMBERS.join(', ')} + FROM file + WHERE file_id=? + LIMIT 1;`, + [ fileId ], + (err, file) => { + if(err) { + return cb(err); + } - if(!file) { - return cb(Errors.DoesNotExist('No file is available by that ID')); - } + if(!file) { + return cb(Errors.DoesNotExist('No file is available by that ID')); + } - // assign props from |file| - FILE_TABLE_MEMBERS.forEach(prop => { - dest[_.camelCase(prop)] = file[prop]; - }); + // assign props from |file| + FILE_TABLE_MEMBERS.forEach(prop => { + dest[_.camelCase(prop)] = file[prop]; + }); - return cb(null, dest); - } - ); - } + return cb(null, dest); + } + ); + } - load(fileId, cb) { - const self = this; + load(fileId, cb) { + const self = this; - async.series( - [ - function loadBasicEntry(callback) { - FileEntry.loadBasicEntry(fileId, self, callback); - }, - function loadMeta(callback) { - return self.loadMeta(callback); - }, - function loadHashTags(callback) { - return self.loadHashTags(callback); - }, - function loadUserRating(callback) { - return self.loadRating(callback); - } - ], - err => { - return cb(err); - } - ); - } + async.series( + [ + function loadBasicEntry(callback) { + FileEntry.loadBasicEntry(fileId, self, callback); + }, + function loadMeta(callback) { + return self.loadMeta(callback); + }, + function loadHashTags(callback) { + return self.loadHashTags(callback); + }, + function loadUserRating(callback) { + return self.loadRating(callback); + } + ], + err => { + return cb(err); + } + ); + } - persist(isUpdate, cb) { - if(!cb && _.isFunction(isUpdate)) { - cb = isUpdate; - isUpdate = false; - } + persist(isUpdate, cb) { + if(!cb && _.isFunction(isUpdate)) { + cb = isUpdate; + isUpdate = false; + } - const self = this; - let inTransaction = false; + const self = this; - async.series( - [ - function check(callback) { - if(isUpdate && !self.fileId) { - return callback(Errors.Invalid('Cannot update file entry without an existing "fileId" member')); - } - return callback(null); - }, - function startTrans(callback) { - return fileDb.run('BEGIN;', callback); - }, - function storeEntry(callback) { - inTransaction = true; + async.waterfall( + [ + function check(callback) { + if(isUpdate && !self.fileId) { + return callback(Errors.Invalid('Cannot update file entry without an existing "fileId" member')); + } + return callback(null); + }, + function calcSha256IfNeeded(callback) { + if(self.fileSha256) { + return callback(null); + } - if(isUpdate) { - fileDb.run( - `REPLACE INTO file (file_id, area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) - VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, - [ self.fileId, self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], - err => { - return callback(err); - } - ); - } else { - fileDb.run( - `REPLACE INTO file (area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) - VALUES(?, ?, ?, ?, ?, ?, ?);`, - [ self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], - function inserted(err) { // use non-arrow func for 'this' scope / lastID - if(!err) { - self.fileId = this.lastID; - } - return callback(err); - } - ); - } - }, - function storeMeta(callback) { - async.each(Object.keys(self.meta), (n, next) => { - const v = self.meta[n]; - return FileEntry.persistMetaValue(self.fileId, n, v, next); - }, - err => { - return callback(err); - }); - }, - function storeHashTags(callback) { - const hashTagsArray = Array.from(self.hashTags); - async.each(hashTagsArray, (hashTag, next) => { - return FileEntry.persistHashTag(self.fileId, hashTag, next); - }, - err => { - return callback(err); - }); - } - ], - err => { - // :TODO: Log orig err - if(inTransaction) { - fileDb.run(err ? 'ROLLBACK;' : 'COMMIT;', err => { - return cb(err); - }); - } else { - return cb(err); - } - } - ); - } + if(isUpdate) { + return callback(Errors.MissingParam('fileSha256 property must be set for updates!')); + } - static getAreaStorageDirectoryByTag(storageTag) { - const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]); - - // absolute paths as-is - if(storageLocation && '/' === storageLocation.charAt(0)) { - return storageLocation; - } + readFile(self.filePath, (err, data) => { + if(err) { + return callback(err); + } - // relative to |areaStoragePrefix| - return paths.join(Config.fileBase.areaStoragePrefix, storageLocation || ''); - } + const sha256 = crypto.createHash('sha256'); + sha256.update(data); + self.fileSha256 = sha256.digest('hex'); + return callback(null); + }); + }, + function startTrans(callback) { + return fileDb.beginTransaction(callback); + }, + function storeEntry(trans, callback) { + if(isUpdate) { + trans.run( + `REPLACE INTO file (file_id, area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) + VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, + [ self.fileId, self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], + err => { + return callback(err, trans); + } + ); + } else { + trans.run( + `REPLACE INTO file (area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) + VALUES(?, ?, ?, ?, ?, ?, ?);`, + [ self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], + function inserted(err) { // use non-arrow func for 'this' scope / lastID + if(!err) { + self.fileId = this.lastID; + } + return callback(err, trans); + } + ); + } + }, + function storeMeta(trans, callback) { + async.each(Object.keys(self.meta), (n, next) => { + const v = self.meta[n]; + return FileEntry.persistMetaValue(self.fileId, n, v, trans, next); + }, + err => { + return callback(err, trans); + }); + }, + function storeHashTags(trans, callback) { + const hashTagsArray = Array.from(self.hashTags); + async.each(hashTagsArray, (hashTag, next) => { + return FileEntry.persistHashTag(self.fileId, hashTag, trans, next); + }, + err => { + return callback(err, trans); + }); + } + ], + (err, trans) => { + // :TODO: Log orig err + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(transErr ? transErr : err); + }); + } else { + return cb(err); + } + } + ); + } - get filePath() { - const storageDir = FileEntry.getAreaStorageDirectoryByTag(this.storageTag); - return paths.join(storageDir, this.fileName); - } + static getAreaStorageDirectoryByTag(storageTag) { + const config = Config(); + const storageLocation = (storageTag && config.fileBase.storageTags[storageTag]); - static persistUserRating(fileId, userId, rating, cb) { - return fileDb.run( - `REPLACE INTO file_user_rating (file_id, user_id, rating) - VALUES (?, ?, ?);`, - [ fileId, userId, rating ], - cb - ); - } + // absolute paths as-is + if(storageLocation && '/' === storageLocation.charAt(0)) { + return storageLocation; + } - static persistMetaValue(fileId, name, value, cb) { - return fileDb.run( - `REPLACE INTO file_meta (file_id, meta_name, meta_value) - VALUES (?, ?, ?);`, - [ fileId, name, value ], - cb - ); - } + // relative to |areaStoragePrefix| + return paths.join(config.fileBase.areaStoragePrefix, storageLocation || ''); + } - static incrementAndPersistMetaValue(fileId, name, incrementBy, cb) { - incrementBy = incrementBy || 1; - fileDb.run( - `UPDATE file_meta - SET meta_value = meta_value + ? - WHERE file_id = ? AND meta_name = ?;`, - [ incrementBy, fileId, name ], - err => { - if(cb) { - return cb(err); - } - } - ); - } + get filePath() { + const storageDir = FileEntry.getAreaStorageDirectoryByTag(this.storageTag); + return paths.join(storageDir, this.fileName); + } - loadMeta(cb) { - fileDb.each( - `SELECT meta_name, meta_value - FROM file_meta - WHERE file_id=?;`, - [ this.fileId ], - (err, meta) => { - if(meta) { - const conv = FILE_WELL_KNOWN_META[meta.meta_name]; - this.meta[meta.meta_name] = conv ? conv(meta.meta_value) : meta.meta_value; - } - }, - err => { - return cb(err); - } - ); - } + static quickCheckExistsByPath(fullPath, cb) { + fileDb.get( + `SELECT COUNT() AS count + FROM file + WHERE file_name = ? + LIMIT 1;`, + [ paths.basename(fullPath) ], + (err, rows) => { + return err ? cb(err) : cb(null, rows.count > 0 ? true : false); + } + ); + } - static persistHashTag(fileId, hashTag, cb) { - fileDb.serialize( () => { - fileDb.run( - `INSERT OR IGNORE INTO hash_tag (hash_tag) - VALUES (?);`, - [ hashTag ] - ); + static persistUserRating(fileId, userId, rating, cb) { + return fileDb.run( + `REPLACE INTO file_user_rating (file_id, user_id, rating) + VALUES (?, ?, ?);`, + [ fileId, userId, rating ], + cb + ); + } - fileDb.run( - `REPLACE INTO file_hash_tag (hash_tag_id, file_id) - VALUES ( - (SELECT hash_tag_id - FROM hash_tag - WHERE hash_tag = ?), - ? - );`, - [ hashTag, fileId ], - err => { - return cb(err); - } - ); - }); - } + static persistMetaValue(fileId, name, value, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = fileDb; + } - loadHashTags(cb) { - fileDb.each( - `SELECT ht.hash_tag_id, ht.hash_tag - FROM hash_tag ht - WHERE ht.hash_tag_id IN ( - SELECT hash_tag_id - FROM file_hash_tag - WHERE file_id=? - );`, - [ this.fileId ], - (err, hashTag) => { - if(hashTag) { - this.hashTags.add(hashTag.hash_tag); - } - }, - err => { - return cb(err); - } - ); - } + return transOrDb.run( + `REPLACE INTO file_meta (file_id, meta_name, meta_value) + VALUES (?, ?, ?);`, + [ fileId, name, value ], + cb + ); + } - loadRating(cb) { - fileDb.get( - `SELECT AVG(fur.rating) AS avg_rating - FROM file_user_rating fur - INNER JOIN file f - ON f.file_id = fur.file_id - AND f.file_id = ?`, - [ this.fileId ], - (err, result) => { - if(result) { - this.userRating = result.avg_rating; - } - return cb(err); - } - ); - } + static incrementAndPersistMetaValue(fileId, name, incrementBy, cb) { + incrementBy = incrementBy || 1; + fileDb.run( + `UPDATE file_meta + SET meta_value = meta_value + ? + WHERE file_id = ? AND meta_name = ?;`, + [ incrementBy, fileId, name ], + err => { + if(cb) { + return cb(err); + } + } + ); + } - setHashTags(hashTags) { - if(_.isString(hashTags)) { - this.hashTags = new Set(hashTags.split(/[\s,]+/)); - } else if(Array.isArray(hashTags)) { - this.hashTags = new Set(hashTags); - } else if(hashTags instanceof Set) { - this.hashTags = hashTags; - } - } + loadMeta(cb) { + fileDb.each( + `SELECT meta_name, meta_value + FROM file_meta + WHERE file_id=?;`, + [ this.fileId ], + (err, meta) => { + if(meta) { + const conv = FILE_WELL_KNOWN_META[meta.meta_name]; + this.meta[meta.meta_name] = conv ? conv(meta.meta_value) : meta.meta_value; + } + }, + err => { + return cb(err); + } + ); + } - static get WellKnownMetaValues() { - return Object.keys(FILE_WELL_KNOWN_META); - } + static persistHashTag(fileId, hashTag, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = fileDb; + } - static findFileBySha(sha, cb) { - // full or partial SHA-256 - fileDb.all( - `SELECT file_id - FROM file - WHERE file_sha256 LIKE "${sha}%" - LIMIT 2;`, // limit 2 such that we can find if there are dupes - (err, fileIdRows) => { - if(err) { - return cb(err); - } + transOrDb.serialize( () => { + transOrDb.run( + `INSERT OR IGNORE INTO hash_tag (hash_tag) + VALUES (?);`, + [ hashTag ] + ); - if(!fileIdRows || 0 === fileIdRows.length) { - return cb(Errors.DoesNotExist('No matches')); - } + transOrDb.run( + `REPLACE INTO file_hash_tag (hash_tag_id, file_id) + VALUES ( + (SELECT hash_tag_id + FROM hash_tag + WHERE hash_tag = ?), + ? + );`, + [ hashTag, fileId ], + err => { + return cb(err); + } + ); + }); + } - if(fileIdRows.length > 1) { - return cb(Errors.Invalid('SHA is ambiguous')); - } + loadHashTags(cb) { + fileDb.each( + `SELECT ht.hash_tag_id, ht.hash_tag + FROM hash_tag ht + WHERE ht.hash_tag_id IN ( + SELECT hash_tag_id + FROM file_hash_tag + WHERE file_id=? + );`, + [ this.fileId ], + (err, hashTag) => { + if(hashTag) { + this.hashTags.add(hashTag.hash_tag); + } + }, + err => { + return cb(err); + } + ); + } - const fileEntry = new FileEntry(); - return fileEntry.load(fileIdRows[0].file_id, err => { - return cb(err, fileEntry); - }); - } - ); - } + loadRating(cb) { + fileDb.get( + `SELECT AVG(fur.rating) AS avg_rating + FROM file_user_rating fur + INNER JOIN file f + ON f.file_id = fur.file_id + AND f.file_id = ?`, + [ this.fileId ], + (err, result) => { + if(result) { + this.userRating = result.avg_rating; + } + return cb(err); + } + ); + } - static findByFileNameWildcard(wc, cb) { - // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html - wc = wc.replace(/\*/g, '%').replace(/\?/g, '_'); + setHashTags(hashTags) { + if(_.isString(hashTags)) { + this.hashTags = new Set(hashTags.split(/[\s,]+/)); + } else if(Array.isArray(hashTags)) { + this.hashTags = new Set(hashTags); + } else if(hashTags instanceof Set) { + this.hashTags = hashTags; + } + } - fileDb.all( - `SELECT file_id - FROM file - WHERE file_name LIKE "${wc}" - `, - (err, fileIdRows) => { - if(err) { - return cb(err); - } + static get WellKnownMetaValues() { + return Object.keys(FILE_WELL_KNOWN_META); + } - if(!fileIdRows || 0 === fileIdRows.length) { - return cb(Errors.DoesNotExist('No matches')); - } + static findBySha(sha, cb) { + // full or partial SHA-256 + fileDb.all( + `SELECT file_id + FROM file + WHERE file_sha256 LIKE "${sha}%" + LIMIT 2;`, // limit 2 such that we can find if there are dupes + (err, fileIdRows) => { + if(err) { + return cb(err); + } - const entries = []; - async.each(fileIdRows, (row, nextRow) => { - const fileEntry = new FileEntry(); - fileEntry.load(row.file_id, err => { - if(!err) { - entries.push(fileEntry); - } - return nextRow(err); - }); - }, - err => { - return cb(err, entries); - }); - } - ); - } + if(!fileIdRows || 0 === fileIdRows.length) { + return cb(Errors.DoesNotExist('No matches')); + } - static findFiles(filter, cb) { - filter = filter || {}; + if(fileIdRows.length > 1) { + return cb(Errors.Invalid('SHA is ambiguous')); + } - let sql; - let sqlWhere = ''; - let sqlOrderBy; - const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC'; - - function getOrderByWithCast(ob) { - if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) { - return `ORDER BY CAST(${ob} AS INTEGER)`; - } + const fileEntry = new FileEntry(); + return fileEntry.load(fileIdRows[0].file_id, err => { + return cb(err, fileEntry); + }); + } + ); + } - return `ORDER BY ${ob}`; - } + // Attempt to fine a file by an *existing* full path. + // Checkums may have changed and are not validated here. + static findByFullPath(fullPath, cb) { + // first, basic by-filename lookup. + FileEntry.findByFileNameWildcard(paths.basename(fullPath), (err, entries) => { + if(err) { + return cb(err); + } + if(!entries || !entries.length || entries.length > 1) { + return cb(Errors.DoesNotExist('No matches')); + } - function appendWhereClause(clause) { - if(sqlWhere) { - sqlWhere += ' AND '; - } else { - sqlWhere += ' WHERE '; - } - sqlWhere += clause; - } + // ensure the *full* path has not changed + // :TODO: if FS is case-insensitive, we probably want a better check here + const possibleMatch = entries[0]; + if(possibleMatch.fullPath === fullPath) { + return cb(null, possibleMatch); + } - if(filter.sort && filter.sort.length > 0) { - if(Object.keys(FILE_WELL_KNOWN_META).indexOf(filter.sort) > -1) { // sorting via a meta value? - sql = - `SELECT DISTINCT f.file_id - FROM file f, file_meta m`; + return cb(Errors.DoesNotExist('No matches')); + }); + } - appendWhereClause(`f.file_id = m.file_id AND m.meta_name = "${filter.sort}"`); + static findByFileNameWildcard(wc, cb) { + // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html + wc = wc.replace(/\*/g, '%').replace(/\?/g, '_'); - sqlOrderBy = `${getOrderByWithCast('m.meta_value')} ${sqlOrderDir}`; - } else { - // additional special treatment for user ratings: we need to average them - if('user_rating' === filter.sort) { - sql = - `SELECT DISTINCT f.file_id, - (SELECT IFNULL(AVG(rating), 0) rating - FROM file_user_rating - WHERE file_id = f.file_id) - AS avg_rating - FROM file f`; - - sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`; - } else { - sql = - `SELECT DISTINCT f.file_id, f.${filter.sort} - FROM file f`; + fileDb.all( + `SELECT file_id + FROM file + WHERE file_name LIKE "${wc}" + `, + (err, fileIdRows) => { + if(err) { + return cb(err); + } - sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir; - } - } - } else { - sql = - `SELECT DISTINCT f.file_id - FROM file f`; + if(!fileIdRows || 0 === fileIdRows.length) { + return cb(Errors.DoesNotExist('No matches')); + } - sqlOrderBy = `${getOrderByWithCast('f.file_id')} ${sqlOrderDir}`; - } + const entries = []; + async.each(fileIdRows, (row, nextRow) => { + const fileEntry = new FileEntry(); + fileEntry.load(row.file_id, err => { + if(!err) { + entries.push(fileEntry); + } + return nextRow(err); + }); + }, + err => { + return cb(err, entries); + }); + } + ); + } - if(filter.areaTag && filter.areaTag.length > 0) { - appendWhereClause(`f.area_tag = "${filter.areaTag}"`); - } + static findFiles(filter, cb) { + filter = filter || {}; - if(filter.metaPairs && filter.metaPairs.length > 0) { + let sql; + let sqlWhere = ''; + let sqlOrderBy; + const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC'; - filter.metaPairs.forEach(mp => { - if(mp.wcValue) { - // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html - mp.value = mp.value.replace(/\*/g, '%').replace(/\?/g, '_'); - appendWhereClause( - `f.file_id IN ( - SELECT file_id - FROM file_meta - WHERE meta_name = "${mp.name}" AND meta_value LIKE "${mp.value}" - )` - ); - } else { - appendWhereClause( - `f.file_id IN ( - SELECT file_id - FROM file_meta - WHERE meta_name = "${mp.name}" AND meta_value = "${mp.value}" - )` - ); - } - }); - } + if(moment.isMoment(filter.newerThanTimestamp)) { + filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp); + } - if(filter.storageTag && filter.storageTag.length > 0) { - appendWhereClause(`f.storage_tag="${filter.storageTag}"`); - } + function getOrderByWithCast(ob) { + if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) { + return `ORDER BY CAST(${ob} AS INTEGER)`; + } - if(filter.terms && filter.terms.length > 0) { - appendWhereClause( - `f.file_id IN ( - SELECT rowid - FROM file_fts - WHERE file_fts MATCH "${filter.terms.replace(/"/g,'""')}" - )` - ); - } - - if(filter.tags && filter.tags.length > 0) { - // build list of quoted tags; filter.tags comes in as a space separated values - const tags = filter.tags.split(' ').map( tag => `"${tag}"` ).join(','); + return `ORDER BY ${ob}`; + } - appendWhereClause( - `f.file_id IN ( - SELECT file_id - FROM file_hash_tag - WHERE hash_tag_id IN ( - SELECT hash_tag_id - FROM hash_tag - WHERE hash_tag IN (${tags}) - ) - )` - ); - } + function appendWhereClause(clause) { + if(sqlWhere) { + sqlWhere += ' AND '; + } else { + sqlWhere += ' WHERE '; + } + sqlWhere += clause; + } - if(_.isString(filter.newerThanTimestamp) && filter.newerThanTimestamp.length > 0) { - appendWhereClause(`DATETIME(f.upload_timestamp) > DATETIME("${filter.newerThanTimestamp}", "+1 seconds")`); - } + if(filter.sort && filter.sort.length > 0) { + if(Object.keys(FILE_WELL_KNOWN_META).indexOf(filter.sort) > -1) { // sorting via a meta value? + sql = + `SELECT DISTINCT f.file_id + FROM file f, file_meta m`; - if(_.isNumber(filter.newerThanFileId)) { - appendWhereClause(`f.file_id > ${filter.newerThanFileId}`); - } + appendWhereClause(`f.file_id = m.file_id AND m.meta_name = "${filter.sort}"`); - sql += `${sqlWhere} ${sqlOrderBy};`; + sqlOrderBy = `${getOrderByWithCast('m.meta_value')} ${sqlOrderDir}`; + } else { + // additional special treatment for user ratings: we need to average them + if('user_rating' === filter.sort) { + sql = + `SELECT DISTINCT f.file_id, + (SELECT IFNULL(AVG(rating), 0) rating + FROM file_user_rating + WHERE file_id = f.file_id) + AS avg_rating + FROM file f`; - const matchingFileIds = []; - fileDb.each(sql, (err, fileId) => { - if(fileId) { - matchingFileIds.push(fileId.file_id); - } - }, err => { - return cb(err, matchingFileIds); - }); - } + sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`; + } else { + sql = + `SELECT DISTINCT f.file_id + FROM file f`; - static moveEntry(srcFileEntry, destAreaTag, destStorageTag, destFileName, cb) { - if(!cb && _.isFunction(destFileName)) { - cb = destFileName; - destFileName = srcFileEntry.fileName; - } + sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir; + } + } + } else { + sql = + `SELECT DISTINCT f.file_id + FROM file f`; - const srcPath = srcFileEntry.filePath; - const dstDir = FileEntry.getAreaStorageDirectoryByTag(destStorageTag); - - - if(!dstDir) { - return cb(Errors.Invalid('Invalid storage tag')); - } + sqlOrderBy = `${getOrderByWithCast('f.file_id')} ${sqlOrderDir}`; + } - const dstPath = paths.join(dstDir, destFileName); + if(filter.areaTag && filter.areaTag.length > 0) { + if(Array.isArray(filter.areaTag)) { + const areaList = filter.areaTag.map(t => `"${t}"`).join(', '); + appendWhereClause(`f.area_tag IN(${areaList})`); + } else { + appendWhereClause(`f.area_tag = "${filter.areaTag}"`); + } + } - async.series( - [ - function movePhysFile(callback) { - if(srcPath === dstPath) { - return callback(null); // don't need to move file, but may change areas - } + if(filter.metaPairs && filter.metaPairs.length > 0) { - fse.move(srcPath, dstPath, err => { - return callback(err); - }); - }, - function updateDatabase(callback) { - fileDb.run( - `UPDATE file - SET area_tag = ?, file_name = ?, storage_tag = ? - WHERE file_id = ?;`, - [ destAreaTag, destFileName, destStorageTag, srcFileEntry.fileId ], - err => { - return callback(err); - } - ); - } - ], - err => { - return cb(err); - } - ); - } + filter.metaPairs.forEach(mp => { + if(mp.wildcards) { + // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html + mp.value = mp.value.replace(/\*/g, '%').replace(/\?/g, '_'); + appendWhereClause( + `f.file_id IN ( + SELECT file_id + FROM file_meta + WHERE meta_name = "${mp.name}" AND meta_value LIKE "${mp.value}" + )` + ); + } else { + appendWhereClause( + `f.file_id IN ( + SELECT file_id + FROM file_meta + WHERE meta_name = "${mp.name}" AND meta_value = "${mp.value}" + )` + ); + } + }); + } + + if(filter.storageTag && filter.storageTag.length > 0) { + appendWhereClause(`f.storage_tag="${filter.storageTag}"`); + } + + if(filter.terms && filter.terms.length > 0) { + // note the ':' in MATCH expr., see https://www.sqlite.org/cvstrac/wiki?p=FullTextIndex + appendWhereClause( + `f.file_id IN ( + SELECT rowid + FROM file_fts + WHERE file_fts MATCH ":${sanitizeString(filter.terms)}" + )` + ); + } + + if(filter.tags && filter.tags.length > 0) { + // build list of quoted tags; filter.tags comes in as a space and/or comma separated values + const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${sanitizeString(tag)}"` ).join(','); + + appendWhereClause( + `f.file_id IN ( + SELECT file_id + FROM file_hash_tag + WHERE hash_tag_id IN ( + SELECT hash_tag_id + FROM hash_tag + WHERE hash_tag IN (${tags}) + ) + )` + ); + } + + if(_.isString(filter.newerThanTimestamp) && filter.newerThanTimestamp.length > 0) { + appendWhereClause(`DATETIME(f.upload_timestamp) > DATETIME("${filter.newerThanTimestamp}", "+1 seconds")`); + } + + if(_.isNumber(filter.newerThanFileId)) { + appendWhereClause(`f.file_id > ${filter.newerThanFileId}`); + } + + sql += `${sqlWhere} ${sqlOrderBy}`; + + if(_.isNumber(filter.limit)) { + sql += ` LIMIT ${filter.limit}`; + } + + sql += ';'; + + fileDb.all(sql, (err, rows) => { + if(err) { + return cb(err); + } + if(!rows || 0 === rows.length) { + return cb(null, []); // no matches + } + return cb(null, rows.map(r => r.file_id)); + }); + } + + static removeEntry(srcFileEntry, options, cb) { + if(!_.isFunction(cb) && _.isFunction(options)) { + cb = options; + options = {}; + } + + async.series( + [ + function removeFromDatabase(callback) { + fileDb.run( + `DELETE FROM file + WHERE file_id = ?;`, + [ srcFileEntry.fileId ], + err => { + return callback(err); + } + ); + }, + function optionallyRemovePhysicalFile(callback) { + if(true !== options.removePhysFile) { + return callback(null); + } + + unlink(srcFileEntry.filePath, err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + + static moveEntry(srcFileEntry, destAreaTag, destStorageTag, destFileName, cb) { + if(!cb && _.isFunction(destFileName)) { + cb = destFileName; + destFileName = srcFileEntry.fileName; + } + + const srcPath = srcFileEntry.filePath; + const dstDir = FileEntry.getAreaStorageDirectoryByTag(destStorageTag); + + if(!dstDir) { + return cb(Errors.Invalid('Invalid storage tag')); + } + + const dstPath = paths.join(dstDir, destFileName); + + async.series( + [ + function movePhysFile(callback) { + if(srcPath === dstPath) { + return callback(null); // don't need to move file, but may change areas + } + + fse.move(srcPath, dstPath, err => { + return callback(err); + }); + }, + function updateDatabase(callback) { + fileDb.run( + `UPDATE file + SET area_tag = ?, file_name = ?, storage_tag = ? + WHERE file_id = ?;`, + [ destAreaTag, destFileName, destStorageTag, srcFileEntry.fileId ], + err => { + return callback(err); + } + ); + } + ], + err => { + return cb(err); + } + ); + } }; diff --git a/core/file_transfer.js b/core/file_transfer.js index 4e81bf72..68daf2db 100644 --- a/core/file_transfer.js +++ b/core/file_transfer.js @@ -1,582 +1,661 @@ /* jslint node: true */ 'use strict'; -// enigma-bbs -const MenuModule = require('./menu_module.js').MenuModule; -const Config = require('./config.js').config; -const stringFormat = require('./string_format.js'); -const Errors = require('./enig_error.js').Errors; -const DownloadQueue = require('./download_queue.js'); -const StatLog = require('./stat_log.js'); -const FileEntry = require('./file_entry.js'); -const Log = require('./logger.js').log; +// enigma-bbs +const MenuModule = require('./menu_module.js').MenuModule; +const Config = require('./config.js').get; +const stringFormat = require('./string_format.js'); +const Errors = require('./enig_error.js').Errors; +const DownloadQueue = require('./download_queue.js'); +const StatLog = require('./stat_log.js'); +const FileEntry = require('./file_entry.js'); +const Log = require('./logger.js').log; +const Events = require('./events.js'); +const UserProps = require('./user_property.js'); +const SysProps = require('./system_property.js'); -// deps -const async = require('async'); -const _ = require('lodash'); -const pty = require('ptyw.js'); -const temptmp = require('temptmp').createTrackedSession('transfer_file'); -const paths = require('path'); -const fs = require('graceful-fs'); -const fse = require('fs-extra'); +// deps +const async = require('async'); +const _ = require('lodash'); +const pty = require('node-pty'); +const temptmp = require('temptmp').createTrackedSession('transfer_file'); +const paths = require('path'); +const fs = require('graceful-fs'); +const fse = require('fs-extra'); -// some consts -const SYSTEM_EOL = require('os').EOL; -const TEMP_SUFFIX = 'enigtf-'; // temp CWD/etc. +// some consts +const SYSTEM_EOL = require('os').EOL; +const TEMP_SUFFIX = 'enigtf-'; // temp CWD/etc. /* - Notes - ----------------------------------------------------------------------------- + Notes + ----------------------------------------------------------------------------- - See core/config.js for external protocol configuration + See core/config.js for external protocol configuration - Resources - ----------------------------------------------------------------------------- + Resources + ----------------------------------------------------------------------------- - ZModem - * http://gallium.inria.fr/~doligez/zmodem/zmodem.txt - * https://github.com/protomouse/synchronet/blob/master/src/sbbs3/zmodem.c + ZModem + * http://gallium.inria.fr/~doligez/zmodem/zmodem.txt + * https://github.com/protomouse/synchronet/blob/master/src/sbbs3/zmodem.c */ exports.moduleInfo = { - name : 'Transfer file', - desc : 'Sends or receives a file(s)', - author : 'NuSkooler', + name : 'Transfer file', + desc : 'Sends or receives a file(s)', + author : 'NuSkooler', }; exports.getModule = class TransferFileModule extends MenuModule { - constructor(options) { - super(options); - - this.config = this.menuConfig.config || {}; - - // - // Most options can be set via extraArgs or config block - // - if(options.extraArgs) { - if(options.extraArgs.protocol) { - this.protocolConfig = Config.fileTransferProtocols[options.extraArgs.protocol]; - } - - if(options.extraArgs.direction) { - this.direction = options.extraArgs.direction; - } - - if(options.extraArgs.sendQueue) { - this.sendQueue = options.extraArgs.sendQueue; - } - - if(options.extraArgs.recvFileName) { - this.recvFileName = options.extraArgs.recvFileName; - } - - if(options.extraArgs.recvDirectory) { - this.recvDirectory = options.extraArgs.recvDirectory; - } - } else { - if(this.config.protocol) { - this.protocolConfig = Config.fileTransferProtocols[this.config.protocol]; - } - - if(this.config.direction) { - this.direction = this.config.direction; - } - - if(this.config.sendQueue) { - this.sendQueue = this.config.sendQueue; - } - - if(this.config.recvFileName) { - this.recvFileName = this.config.recvFileName; - } - - if(this.config.recvDirectory) { - this.recvDirectory = this.config.recvDirectory; - } - } - - this.protocolConfig = this.protocolConfig || Config.fileTransferProtocols.zmodem8kSz; // try for *something* - this.direction = this.direction || 'send'; - this.sendQueue = this.sendQueue || []; - - // Ensure sendQueue is an array of objects that contain at least a 'path' member - this.sendQueue = this.sendQueue.map(item => { - if(_.isString(item)) { - return { path : item }; - } else { - return item; - } - }); - - this.sentFileIds = []; - } - - isSending() { - return ('send' === this.direction); - } - - restorePipeAfterExternalProc() { - if(!this.pipeRestored) { - this.pipeRestored = true; - - this.client.restoreDataHandler(); - } - } - - sendFiles(cb) { - // assume *sending* can always batch - // :TODO: Look into this further - const allFiles = this.sendQueue.map(f => f.path); - this.executeExternalProtocolHandlerForSend(allFiles, err => { - if(err) { - this.client.log.warn( { files : allFiles, error : err.message }, 'Error sending file(s)' ); - } else { - const sentFiles = []; - this.sendQueue.forEach(f => { - f.sent = true; - sentFiles.push(f.path); - - }); - - this.client.log.info( { sentFiles : sentFiles }, `Successfully sent ${sentFiles.length} file(s)` ); - } - return cb(err); - }); - } - - /* - sendFiles(cb) { - // :TODO: built in/native protocol support - - if(this.protocolConfig.external.supportsBatch) { - const allFiles = this.sendQueue.map(f => f.path); - this.executeExternalProtocolHandlerForSend(allFiles, err => { - if(err) { - this.client.log.warn( { files : allFiles, error : err.message }, 'Error sending file(s)' ); - } else { - const sentFiles = []; - this.sendQueue.forEach(f => { - f.sent = true; - sentFiles.push(f.path); - - }); - - this.client.log.info( { sentFiles : sentFiles }, `Successfully sent ${sentFiles.length} file(s)` ); - } - return cb(err); - }); - } else { - // :TODO: we need to prompt between entries such that users can prepare their clients - async.eachSeries(this.sendQueue, (queueItem, next) => { - this.executeExternalProtocolHandlerForSend(queueItem.path, err => { - if(err) { - this.client.log.warn( { file : queueItem.path, error : err.message }, 'Error sending file' ); - } else { - queueItem.sent = true; - - this.client.log.info( { sentFile : queueItem.path }, 'Successfully sent file' ); - } - return next(err); - }); - }, err => { - return cb(err); - }); - } - } - */ - - moveFileWithCollisionHandling(src, dst, cb) { - // - // Move |src| -> |dst| renaming to file(1).ext, file(2).ext, etc. - // in the case of collisions. - // - const dstPath = paths.dirname(dst); - const dstFileExt = paths.extname(dst); - const dstFileSuffix = paths.basename(dst, dstFileExt); - - let renameIndex = 0; - let movedOk = false; - let tryDstPath; - - async.until( - () => movedOk, // until moved OK - (cb) => { - if(0 === renameIndex) { - // try originally supplied path first - tryDstPath = dst; - } else { - tryDstPath = paths.join(dstPath, `${dstFileSuffix}(${renameIndex})${dstFileExt}`); - } - - fse.move(src, tryDstPath, err => { - if(err) { - if('EEXIST' === err.code) { - renameIndex += 1; - return cb(null); // keep trying - } - - return cb(err); - } - - movedOk = true; - return cb(null, tryDstPath); - }); - }, - (err, finalPath) => { - return cb(err, finalPath); - } - ); - } - - recvFiles(cb) { - this.executeExternalProtocolHandlerForRecv(err => { - if(err) { - return cb(err); - } - - this.recvFilePaths = []; - - if(this.recvFileName) { - // - // file name specified - we expect a single file in |this.recvDirectory| - // by the name of |this.recvFileName| - // - const recvFullPath = paths.join(this.recvDirectory, this.recvFileName); - fs.stat(recvFullPath, (err, stats) => { - if(err) { - return cb(err); - } - - if(!stats.isFile()) { - return cb(Errors.Invalid('Expected file entry in recv directory')); - } - - this.recvFilePaths.push(recvFullPath); - return cb(null); - }); - } else { - // - // Blind Upload (recv): files in |this.recvDirectory| should be named appropriately already - // - fs.readdir(this.recvDirectory, (err, files) => { - if(err) { - return cb(err); - } - - // stat each to grab files only - async.each(files, (fileName, nextFile) => { - const recvFullPath = paths.join(this.recvDirectory, fileName); - - fs.stat(recvFullPath, (err, stats) => { - if(err) { - this.client.log.warn('Failed to stat file', { path : recvFullPath } ); - return nextFile(null); // just try the next one - } - - if(stats.isFile()) { - this.recvFilePaths.push(recvFullPath); - } - - return nextFile(null); - }); - }, () => { - return cb(null); - }); - }); - } - }); - } - - pathWithTerminatingSeparator(path) { - if(path && paths.sep !== path.charAt(path.length - 1)) { - path = path + paths.sep; - } - return path; - } - - prepAndBuildSendArgs(filePaths, cb) { - const externalArgs = this.protocolConfig.external['sendArgs']; - - async.waterfall( - [ - function getTempFileListPath(callback) { - const hasFileList = externalArgs.find(ea => (ea.indexOf('{fileListPath}') > -1) ); - if(!hasFileList) { - return callback(null, null); - } - - temptmp.open( { prefix : TEMP_SUFFIX, suffix : '.txt' }, (err, tempFileInfo) => { - if(err) { - return callback(err); // failed to create it - } - - fs.write(tempFileInfo.fd, filePaths.join(SYSTEM_EOL)); - fs.close(tempFileInfo.fd, err => { - return callback(err, tempFileInfo.path); - }); - }); - }, - function createArgs(tempFileListPath, callback) { - // initial args: ignore {filePaths} as we must break that into it's own sep array items - const args = externalArgs.map(arg => { - return '{filePaths}' === arg ? arg : stringFormat(arg, { - fileListPath : tempFileListPath || '', - }); - }); - - const filePathsPos = args.indexOf('{filePaths}'); - if(filePathsPos > -1) { - // replace {filePaths} with 0:n individual entries in |args| - args.splice.apply( args, [ filePathsPos, 1 ].concat(filePaths) ); - } - - return callback(null, args); - } - ], - (err, args) => { - return cb(err, args); - } - ); - } - - prepAndBuildRecvArgs(cb) { - const argsKey = this.recvFileName ? 'recvArgsNonBatch' : 'recvArgs'; - const externalArgs = this.protocolConfig.external[argsKey]; - const args = externalArgs.map(arg => stringFormat(arg, { - uploadDir : this.recvDirectory, - fileName : this.recvFileName || '', - })); - - return cb(null, args); - } - - executeExternalProtocolHandler(args, cb) { - const external = this.protocolConfig.external; - const cmd = external[`${this.direction}Cmd`]; - - this.client.log.debug( - { cmd : cmd, args : args, tempDir : this.recvDirectory, direction : this.direction }, - 'Executing external protocol' - ); - - const externalProc = pty.spawn(cmd, args, { - cols : this.client.term.termWidth, - rows : this.client.term.termHeight, - cwd : this.recvDirectory, - }); - - this.client.setTemporaryDirectDataHandler(data => { - // needed for things like sz/rz - if(external.escapeTelnet) { - const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape - externalProc.write(new Buffer(tmp, 'binary')); - } else { - externalProc.write(data); - } - }); - - externalProc.on('data', data => { - // needed for things like sz/rz - if(external.escapeTelnet) { - const tmp = data.toString('binary').replace(/\xff/g, '\xff\xff'); // escape - this.client.term.rawWrite(new Buffer(tmp, 'binary')); - } else { - this.client.term.rawWrite(data); - } - }); - - externalProc.once('close', () => { - return this.restorePipeAfterExternalProc(); - }); - - externalProc.once('exit', (exitCode) => { - this.client.log.debug( { cmd : cmd, args : args, exitCode : exitCode }, 'Process exited' ); - - this.restorePipeAfterExternalProc(); - externalProc.removeAllListeners(); - - return cb(exitCode ? Errors.ExternalProcess(`Process exited with exit code ${exitCode}`, 'EBADEXIT') : null); - }); - } - - executeExternalProtocolHandlerForSend(filePaths, cb) { - if(!Array.isArray(filePaths)) { - filePaths = [ filePaths ]; - } - - this.prepAndBuildSendArgs(filePaths, (err, args) => { - if(err) { - return cb(err); - } - - this.executeExternalProtocolHandler(args, err => { - return cb(err); - }); - }); - } - - executeExternalProtocolHandlerForRecv(cb) { - this.prepAndBuildRecvArgs( (err, args) => { - if(err) { - return cb(err); - } - - this.executeExternalProtocolHandler(args, err => { - return cb(err); - }); - }); - } - - getMenuResult() { - if(this.isSending()) { - return { sentFileIds : this.sentFileIds }; - } else { - return { recvFilePaths : this.recvFilePaths }; - } - } - - updateSendStats(cb) { - let downloadBytes = 0; - let downloadCount = 0; - let fileIds = []; - - async.each(this.sendQueue, (queueItem, next) => { - if(!queueItem.sent) { - return next(null); - } - - if(queueItem.fileId) { - fileIds.push(queueItem.fileId); - } - - if(_.isNumber(queueItem.byteSize)) { - downloadCount += 1; - downloadBytes += queueItem.byteSize; - return next(null); - } - - // we just have a path - figure it out - fs.stat(queueItem.path, (err, stats) => { - if(err) { - this.client.log.warn( { error : err.message, path : queueItem.path }, 'File stat failed' ); - } else { - downloadCount += 1; - downloadBytes += stats.size; - } - - return next(null); - }); - }, () => { - // All stats/meta currently updated via fire & forget - if this is ever a issue, we can wait for callbacks - StatLog.incrementUserStat(this.client.user, 'dl_total_count', downloadCount); - StatLog.incrementUserStat(this.client.user, 'dl_total_bytes', downloadBytes); - StatLog.incrementSystemStat('dl_total_count', downloadCount); - StatLog.incrementSystemStat('dl_total_bytes', downloadBytes); - - fileIds.forEach(fileId => { - FileEntry.incrementAndPersistMetaValue(fileId, 'dl_count', 1); - }); - - return cb(null); - }); - } - - updateRecvStats(cb) { - let uploadBytes = 0; - let uploadCount = 0; - - async.each(this.recvFilePaths, (filePath, next) => { - // we just have a path - figure it out - fs.stat(filePath, (err, stats) => { - if(err) { - this.client.log.warn( { error : err.message, path : filePath }, 'File stat failed' ); - } else { - uploadCount += 1; - uploadBytes += stats.size; - } - - return next(null); - }); - }, () => { - StatLog.incrementUserStat(this.client.user, 'ul_total_count', uploadCount); - StatLog.incrementUserStat(this.client.user, 'ul_total_bytes', uploadBytes); - StatLog.incrementSystemStat('ul_total_count', uploadCount); - StatLog.incrementSystemStat('ul_total_bytes', uploadBytes); - - return cb(null); - }); - } - - initSequence() { - const self = this; - - // :TODO: break this up to send|recv - - async.series( - [ - function validateConfig(callback) { - if(self.isSending()) { - if(!Array.isArray(self.sendQueue)) { - self.sendQueue = [ self.sendQueue ]; - } - } - - return callback(null); - }, - function transferFiles(callback) { - if(self.isSending()) { - self.sendFiles( err => { - if(err) { - return callback(err); - } - - const sentFileIds = []; - self.sendQueue.forEach(queueItem => { - if(queueItem.sent && queueItem.fileId) { - sentFileIds.push(queueItem.fileId); - } - }); - - if(sentFileIds.length > 0) { - // remove items we sent from the D/L queue - const dlQueue = new DownloadQueue(self.client); - dlQueue.removeItems(sentFileIds); - - self.sentFileIds = sentFileIds; - } - - return callback(null); - }); - } else { - self.recvFiles( err => { - return callback(err); - }); - } - }, - function cleanupTempFiles(callback) { - temptmp.cleanup( paths => { - Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' ); - }); - - return callback(null); - }, - function updateUserAndSystemStats(callback) { - if(self.isSending()) { - return self.updateSendStats(callback); - } else { - return self.updateRecvStats(callback); - } - } - ], - err => { - if(err) { - self.client.log.warn( { error : err.message }, 'File transfer error'); - } - - return self.prevMenu(); - } - ); - } + constructor(options) { + super(options); + + this.config = this.menuConfig.config || {}; + + // + // Most options can be set via extraArgs or config block + // + const config = Config(); + if(options.extraArgs) { + if(options.extraArgs.protocol) { + this.protocolConfig = config.fileTransferProtocols[options.extraArgs.protocol]; + } + + if(options.extraArgs.direction) { + this.direction = options.extraArgs.direction; + } + + if(options.extraArgs.sendQueue) { + this.sendQueue = options.extraArgs.sendQueue; + } + + if(options.extraArgs.recvFileName) { + this.recvFileName = options.extraArgs.recvFileName; + } + + if(options.extraArgs.recvDirectory) { + this.recvDirectory = options.extraArgs.recvDirectory; + } + } else { + if(this.config.protocol) { + this.protocolConfig = config.fileTransferProtocols[this.config.protocol]; + } + + if(this.config.direction) { + this.direction = this.config.direction; + } + + if(this.config.sendQueue) { + this.sendQueue = this.config.sendQueue; + } + + if(this.config.recvFileName) { + this.recvFileName = this.config.recvFileName; + } + + if(this.config.recvDirectory) { + this.recvDirectory = this.config.recvDirectory; + } + } + + this.protocolConfig = this.protocolConfig || config.fileTransferProtocols.zmodem8kSz; // try for *something* + this.direction = this.direction || 'send'; + this.sendQueue = this.sendQueue || []; + + // Ensure sendQueue is an array of objects that contain at least a 'path' member + this.sendQueue = this.sendQueue.map(item => { + if(_.isString(item)) { + return { path : item }; + } else { + return item; + } + }); + + this.sentFileIds = []; + } + + isSending() { + return ('send' === this.direction); + } + + restorePipeAfterExternalProc() { + if(!this.pipeRestored) { + this.pipeRestored = true; + + this.client.restoreDataHandler(); + } + } + + sendFiles(cb) { + // assume *sending* can always batch + // :TODO: Look into this further + const allFiles = this.sendQueue.map(f => f.path); + this.executeExternalProtocolHandlerForSend(allFiles, err => { + if(err) { + this.client.log.warn( { files : allFiles, error : err.message }, 'Error sending file(s)' ); + } else { + const sentFiles = []; + this.sendQueue.forEach(f => { + f.sent = true; + sentFiles.push(f.path); + + }); + + this.client.log.info( { sentFiles : sentFiles }, `Successfully sent ${sentFiles.length} file(s)` ); + } + return cb(err); + }); + } + + /* + sendFiles(cb) { + // :TODO: built in/native protocol support + + if(this.protocolConfig.external.supportsBatch) { + const allFiles = this.sendQueue.map(f => f.path); + this.executeExternalProtocolHandlerForSend(allFiles, err => { + if(err) { + this.client.log.warn( { files : allFiles, error : err.message }, 'Error sending file(s)' ); + } else { + const sentFiles = []; + this.sendQueue.forEach(f => { + f.sent = true; + sentFiles.push(f.path); + + }); + + this.client.log.info( { sentFiles : sentFiles }, `Successfully sent ${sentFiles.length} file(s)` ); + } + return cb(err); + }); + } else { + // :TODO: we need to prompt between entries such that users can prepare their clients + async.eachSeries(this.sendQueue, (queueItem, next) => { + this.executeExternalProtocolHandlerForSend(queueItem.path, err => { + if(err) { + this.client.log.warn( { file : queueItem.path, error : err.message }, 'Error sending file' ); + } else { + queueItem.sent = true; + + this.client.log.info( { sentFile : queueItem.path }, 'Successfully sent file' ); + } + return next(err); + }); + }, err => { + return cb(err); + }); + } + } + */ + + moveFileWithCollisionHandling(src, dst, cb) { + // + // Move |src| -> |dst| renaming to file(1).ext, file(2).ext, etc. + // in the case of collisions. + // + const dstPath = paths.dirname(dst); + const dstFileExt = paths.extname(dst); + const dstFileSuffix = paths.basename(dst, dstFileExt); + + let renameIndex = 0; + let movedOk = false; + let tryDstPath; + + async.until( + (callback) => callback(null, movedOk), // until moved OK + (cb) => { + if(0 === renameIndex) { + // try originally supplied path first + tryDstPath = dst; + } else { + tryDstPath = paths.join(dstPath, `${dstFileSuffix}(${renameIndex})${dstFileExt}`); + } + + fse.move(src, tryDstPath, err => { + if(err) { + if('EEXIST' === err.code) { + renameIndex += 1; + return cb(null); // keep trying + } + + return cb(err); + } + + movedOk = true; + return cb(null, tryDstPath); + }); + }, + (err, finalPath) => { + return cb(err, finalPath); + } + ); + } + + recvFiles(cb) { + this.executeExternalProtocolHandlerForRecv(err => { + if(err) { + return cb(err); + } + + this.recvFilePaths = []; + + if(this.recvFileName) { + // + // file name specified - we expect a single file in |this.recvDirectory| + // by the name of |this.recvFileName| + // + const recvFullPath = paths.join(this.recvDirectory, this.recvFileName); + fs.stat(recvFullPath, (err, stats) => { + if(err) { + return cb(err); + } + + if(!stats.isFile()) { + return cb(Errors.Invalid('Expected file entry in recv directory')); + } + + this.recvFilePaths.push(recvFullPath); + return cb(null); + }); + } else { + // + // Blind Upload (recv): files in |this.recvDirectory| should be named appropriately already + // + fs.readdir(this.recvDirectory, (err, files) => { + if(err) { + return cb(err); + } + + // stat each to grab files only + async.each(files, (fileName, nextFile) => { + const recvFullPath = paths.join(this.recvDirectory, fileName); + + fs.stat(recvFullPath, (err, stats) => { + if(err) { + this.client.log.warn('Failed to stat file', { path : recvFullPath } ); + return nextFile(null); // just try the next one + } + + if(stats.isFile()) { + this.recvFilePaths.push(recvFullPath); + } + + return nextFile(null); + }); + }, () => { + return cb(null); + }); + }); + } + }); + } + + pathWithTerminatingSeparator(path) { + if(path && paths.sep !== path.charAt(path.length - 1)) { + path = path + paths.sep; + } + return path; + } + + prepAndBuildSendArgs(filePaths, cb) { + const externalArgs = this.protocolConfig.external['sendArgs']; + + async.waterfall( + [ + function getTempFileListPath(callback) { + const hasFileList = externalArgs.find(ea => (ea.indexOf('{fileListPath}') > -1) ); + if(!hasFileList) { + return callback(null, null); + } + + temptmp.open( { prefix : TEMP_SUFFIX, suffix : '.txt' }, (err, tempFileInfo) => { + if(err) { + return callback(err); // failed to create it + } + + fs.write(tempFileInfo.fd, filePaths.join(SYSTEM_EOL), err => { + if(err) { + return callback(err); + } + fs.close(tempFileInfo.fd, err => { + return callback(err, tempFileInfo.path); + }); + }); + }); + }, + function createArgs(tempFileListPath, callback) { + // initial args: ignore {filePaths} as we must break that into it's own sep array items + const args = externalArgs.map(arg => { + return '{filePaths}' === arg ? arg : stringFormat(arg, { + fileListPath : tempFileListPath || '', + }); + }); + + const filePathsPos = args.indexOf('{filePaths}'); + if(filePathsPos > -1) { + // replace {filePaths} with 0:n individual entries in |args| + args.splice.apply( args, [ filePathsPos, 1 ].concat(filePaths) ); + } + + return callback(null, args); + } + ], + (err, args) => { + return cb(err, args); + } + ); + } + + prepAndBuildRecvArgs(cb) { + const argsKey = this.recvFileName ? 'recvArgsNonBatch' : 'recvArgs'; + const externalArgs = this.protocolConfig.external[argsKey]; + const args = externalArgs.map(arg => stringFormat(arg, { + uploadDir : this.recvDirectory, + fileName : this.recvFileName || '', + })); + + return cb(null, args); + } + + executeExternalProtocolHandler(args, cb) { + const external = this.protocolConfig.external; + const cmd = external[`${this.direction}Cmd`]; + + // support for handlers that need IACs taken care of over Telnet/etc. + const processIACs = + external.processIACs || + external.escapeTelnet; // deprecated name + + // :TODO: we should only do this when over Telnet (or derived, such as WebSockets)? + + const IAC = Buffer.from([255]); + const EscapedIAC = Buffer.from([255, 255]); + + this.client.log.debug( + { cmd : cmd, args : args, tempDir : this.recvDirectory, direction : this.direction }, + 'Executing external protocol' + ); + + const spawnOpts = { + cols : this.client.term.termWidth, + rows : this.client.term.termHeight, + cwd : this.recvDirectory, + encoding : null, // don't bork our data! + }; + + const externalProc = pty.spawn(cmd, args, spawnOpts); + + let dataHits = 0; + const updateActivity = () => { + if (0 === (dataHits++ % 4)) { + this.client.explicitActivityTimeUpdate(); + } + }; + + this.client.setTemporaryDirectDataHandler(data => { + updateActivity(); + + // needed for things like sz/rz + if(processIACs) { + let iacPos = data.indexOf(EscapedIAC); + if (-1 === iacPos) { + return externalProc.write(data); + } + + // at least one double (escaped) IAC + let lastPos = 0; + while (iacPos > -1) { + let rem = iacPos - lastPos; + if (rem >= 0) { + externalProc.write(data.slice(lastPos, iacPos + 1)); + } + lastPos = iacPos + 2; + iacPos = data.indexOf(EscapedIAC, lastPos); + } + + if (lastPos < data.length) { + externalProc.write(data.slice(lastPos)); + } + // const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape + // externalProc.write(Buffer.from(tmp, 'binary')); + } else { + externalProc.write(data); + } + }); + + externalProc.on('data', data => { + updateActivity(); + + // needed for things like sz/rz + if(processIACs) { + let iacPos = data.indexOf(IAC); + if (-1 === iacPos) { + return this.client.term.rawWrite(data); + } + + // Has at least a single IAC + let lastPos = 0; + while (iacPos !== -1) { + if (iacPos - lastPos > 0) { + this.client.term.rawWrite(data.slice(lastPos, iacPos)); + } + this.client.term.rawWrite(EscapedIAC); + lastPos = iacPos + 1; + iacPos = data.indexOf(IAC, lastPos); + } + + if (lastPos < data.length) { + this.client.term.rawWrite(data.slice(lastPos)); + } + } else { + this.client.term.rawWrite(data); + } + }); + + externalProc.once('close', () => { + return this.restorePipeAfterExternalProc(); + }); + + externalProc.once('exit', (exitCode) => { + this.client.log.debug( { cmd : cmd, args : args, exitCode : exitCode }, 'Process exited' ); + + this.restorePipeAfterExternalProc(); + externalProc.removeAllListeners(); + + return cb(exitCode ? Errors.ExternalProcess(`Process exited with exit code ${exitCode}`, 'EBADEXIT') : null); + }); + } + + executeExternalProtocolHandlerForSend(filePaths, cb) { + if(!Array.isArray(filePaths)) { + filePaths = [ filePaths ]; + } + + this.prepAndBuildSendArgs(filePaths, (err, args) => { + if(err) { + return cb(err); + } + + this.executeExternalProtocolHandler(args, err => { + return cb(err); + }); + }); + } + + executeExternalProtocolHandlerForRecv(cb) { + this.prepAndBuildRecvArgs( (err, args) => { + if(err) { + return cb(err); + } + + this.executeExternalProtocolHandler(args, err => { + return cb(err); + }); + }); + } + + getMenuResult() { + if(this.isSending()) { + return { sentFileIds : this.sentFileIds }; + } else { + return { recvFilePaths : this.recvFilePaths }; + } + } + + updateSendStats(cb) { + let downloadBytes = 0; + let downloadCount = 0; + let fileIds = []; + + async.each(this.sendQueue, (queueItem, next) => { + if(!queueItem.sent) { + return next(null); + } + + if(queueItem.fileId) { + fileIds.push(queueItem.fileId); + } + + if(_.isNumber(queueItem.byteSize)) { + downloadCount += 1; + downloadBytes += queueItem.byteSize; + return next(null); + } + + // we just have a path - figure it out + fs.stat(queueItem.path, (err, stats) => { + if(err) { + this.client.log.warn( { error : err.message, path : queueItem.path }, 'File stat failed' ); + } else { + downloadCount += 1; + downloadBytes += stats.size; + } + + return next(null); + }); + }, () => { + // All stats/meta currently updated via fire & forget - if this is ever a issue, we can wait for callbacks + StatLog.incrementUserStat(this.client.user, UserProps.FileDlTotalCount, downloadCount); + StatLog.incrementUserStat(this.client.user, UserProps.FileDlTotalBytes, downloadBytes); + + StatLog.incrementSystemStat(SysProps.FileDlTotalCount, downloadCount); + StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, downloadBytes); + + fileIds.forEach(fileId => { + FileEntry.incrementAndPersistMetaValue(fileId, 'dl_count', 1); + }); + + return cb(null); + }); + } + + updateRecvStats(cb) { + let uploadBytes = 0; + let uploadCount = 0; + + async.each(this.recvFilePaths, (filePath, next) => { + // we just have a path - figure it out + fs.stat(filePath, (err, stats) => { + if(err) { + this.client.log.warn( { error : err.message, path : filePath }, 'File stat failed' ); + } else { + uploadCount += 1; + uploadBytes += stats.size; + } + + return next(null); + }); + }, () => { + StatLog.incrementUserStat(this.client.user, UserProps.FileUlTotalCount, uploadCount); + StatLog.incrementUserStat(this.client.user, UserProps.FileUlTotalBytes, uploadBytes); + + StatLog.incrementSystemStat(SysProps.FileUlTotalCount, uploadCount); + StatLog.incrementSystemStat(SysProps.FileUlTotalBytes, uploadBytes); + + return cb(null); + }); + } + + initSequence() { + const self = this; + + // :TODO: break this up to send|recv + + async.series( + [ + function validateConfig(callback) { + if(self.isSending()) { + if(!Array.isArray(self.sendQueue)) { + self.sendQueue = [ self.sendQueue ]; + } + } + + return callback(null); + }, + function transferFiles(callback) { + if(self.isSending()) { + self.sendFiles( err => { + if(err) { + return callback(err); + } + + const sentFileIds = []; + self.sendQueue.forEach(queueItem => { + if(queueItem.sent && queueItem.fileId) { + sentFileIds.push(queueItem.fileId); + } + }); + + if(sentFileIds.length > 0) { + // remove items we sent from the D/L queue + const dlQueue = new DownloadQueue(self.client); + const dlFileEntries = dlQueue.removeItems(sentFileIds); + + // fire event for downloaded entries + Events.emit( + Events.getSystemEvents().UserDownload, + { + user : self.client.user, + files : dlFileEntries + } + ); + + self.sentFileIds = sentFileIds; + } + + return callback(null); + }); + } else { + self.recvFiles( err => { + return callback(err); + }); + } + }, + function cleanupTempFiles(callback) { + temptmp.cleanup( paths => { + Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' ); + }); + + return callback(null); + }, + function updateUserAndSystemStats(callback) { + if(self.isSending()) { + return self.updateSendStats(callback); + } else { + return self.updateRecvStats(callback); + } + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'File transfer error'); + } + + return self.prevMenu(); + } + ); + } }; diff --git a/core/file_transfer_protocol_select.js b/core/file_transfer_protocol_select.js new file mode 100644 index 00000000..13e30a74 --- /dev/null +++ b/core/file_transfer_protocol_select.js @@ -0,0 +1,153 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const MenuModule = require('./menu_module.js').MenuModule; +const Config = require('./config.js').get; +const ViewController = require('./view_controller.js').ViewController; + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'File transfer protocol selection', + desc : 'Select protocol / method for file transfer', + author : 'NuSkooler', +}; + +const MciViewIds = { + protList : 1, +}; + +exports.getModule = class FileTransferProtocolSelectModule extends MenuModule { + + constructor(options) { + super(options); + + this.config = this.menuConfig.config || {}; + + if(options.extraArgs) { + if(options.extraArgs.direction) { + this.config.direction = options.extraArgs.direction; + } + } + + this.config.direction = this.config.direction || 'send'; + + this.extraArgs = options.extraArgs; + + if(_.has(options, 'lastMenuResult.sentFileIds')) { + this.sentFileIds = options.lastMenuResult.sentFileIds; + } + + if(_.has(options, 'lastMenuResult.recvFilePaths')) { + this.recvFilePaths = options.lastMenuResult.recvFilePaths; + } + + this.fallbackOnly = options.lastMenuResult ? true : false; + + this.loadAvailProtocols(); + + this.menuMethods = { + selectProtocol : (formData, extraArgs, cb) => { + const protocol = this.protocols[formData.value.protocol]; + const finalExtraArgs = this.extraArgs || {}; + Object.assign(finalExtraArgs, { protocol : protocol.protocol, direction : this.config.direction }, extraArgs ); + + const modOpts = { + extraArgs : finalExtraArgs, + }; + + if('send' === this.config.direction) { + return this.gotoMenu(this.config.downloadFilesMenu || 'sendFilesToUser', modOpts, cb); + } else { + return this.gotoMenu(this.config.uploadFilesMenu || 'recvFilesFromUser', modOpts, cb); + } + }, + }; + } + + getMenuResult() { + if(this.sentFileIds) { + return { sentFileIds : this.sentFileIds }; + } + + if(this.recvFilePaths) { + return { recvFilePaths : this.recvFilePaths }; + } + } + + initSequence() { + if(this.sentFileIds || this.recvFilePaths) { + // nothing to do here; move along (we're just falling through) + this.prevMenu(); + } else { + super.initSequence(); + } + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); + + async.series( + [ + function loadFromConfig(callback) { + const loadOpts = { + callingMenu : self, + mciMap : mciData.menu + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + }, + function populateList(callback) { + const protListView = vc.getView(MciViewIds.protList); + + protListView.setItems(self.protocols); + protListView.redraw(); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + loadAvailProtocols() { + this.protocols = _.map(Config().fileTransferProtocols, (protInfo, protocol) => { + return { + text : protInfo.name, // standard + protocol : protocol, + name : protInfo.name, + hasBatch : _.has(protInfo, 'external.recvArgs'), + hasNonBatch : _.has(protInfo, 'external.recvArgsNonBatch'), + sort : protInfo.sort, + }; + }); + + // Filter out batch vs non-batch only protocols + if(this.extraArgs.recvFileName) { // non-batch aka non-blind + this.protocols = this.protocols.filter( prot => prot.hasNonBatch ); + } else { + this.protocols = this.protocols.filter( prot => prot.hasBatch ); + } + + // natural sort taking explicit orders into consideration + this.protocols.sort( (a, b) => { + if(_.isNumber(a.sort) && _.isNumber(b.sort)) { + return a.sort - b.sort; + } else { + return a.name.localeCompare(b.name, { sensitivity : false, numeric : true } ); + } + }); + } +}; diff --git a/core/file_util.js b/core/file_util.js index 9452b23f..56d8d5ac 100644 --- a/core/file_util.js +++ b/core/file_util.js @@ -1,89 +1,89 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const EnigAssert = require('./enigma_assert.js'); +// ENiGMA½ +const EnigAssert = require('./enigma_assert.js'); -// deps -const fse = require('fs-extra'); -const paths = require('path'); -const async = require('async'); +// deps +const fse = require('fs-extra'); +const paths = require('path'); +const async = require('async'); -exports.moveFileWithCollisionHandling = moveFileWithCollisionHandling; -exports.copyFileWithCollisionHandling = copyFileWithCollisionHandling; -exports.pathWithTerminatingSeparator = pathWithTerminatingSeparator; +exports.moveFileWithCollisionHandling = moveFileWithCollisionHandling; +exports.copyFileWithCollisionHandling = copyFileWithCollisionHandling; +exports.pathWithTerminatingSeparator = pathWithTerminatingSeparator; function moveOrCopyFileWithCollisionHandling(src, dst, operation, cb) { - operation = operation || 'copy'; - const dstPath = paths.dirname(dst); - const dstFileExt = paths.extname(dst); - const dstFileSuffix = paths.basename(dst, dstFileExt); + operation = operation || 'copy'; + const dstPath = paths.dirname(dst); + const dstFileExt = paths.extname(dst); + const dstFileSuffix = paths.basename(dst, dstFileExt); - EnigAssert('move' === operation || 'copy' === operation); + EnigAssert('move' === operation || 'copy' === operation); - let renameIndex = 0; - let opOk = false; - let tryDstPath; + let renameIndex = 0; + let opOk = false; + let tryDstPath; - function tryOperation(src, dst, callback) { - if('move' === operation) { - fse.move(src, tryDstPath, err => { - return callback(err); - }); - } else if('copy' === operation) { - fse.copy(src, tryDstPath, { overwrite : false, errorOnExist : true }, err => { - return callback(err); - }); - } - } + function tryOperation(src, dst, callback) { + if('move' === operation) { + fse.move(src, tryDstPath, err => { + return callback(err); + }); + } else if('copy' === operation) { + fse.copy(src, tryDstPath, { overwrite : false, errorOnExist : true }, err => { + return callback(err); + }); + } + } - async.until( - () => opOk, // until moved OK - (cb) => { - if(0 === renameIndex) { - // try originally supplied path first - tryDstPath = dst; - } else { - tryDstPath = paths.join(dstPath, `${dstFileSuffix}(${renameIndex})${dstFileExt}`); - } + async.until( + (callback) => callback(null, opOk), // until moved OK + (cb) => { + if(0 === renameIndex) { + // try originally supplied path first + tryDstPath = dst; + } else { + tryDstPath = paths.join(dstPath, `${dstFileSuffix}(${renameIndex})${dstFileExt}`); + } - tryOperation(src, tryDstPath, err => { - if(err) { - // for some reason fs-extra copy doesn't pass err.code - // :TODO: this is dangerous: submit a PR to fs-extra to set EEXIST - if('EEXIST' === err.code || 'copy' === operation) { - renameIndex += 1; - return cb(null); // keep trying - } + tryOperation(src, tryDstPath, err => { + if(err) { + // for some reason fs-extra copy doesn't pass err.code + // :TODO: this is dangerous: submit a PR to fs-extra to set EEXIST + if('EEXIST' === err.code || 'dest already exists.' === err.message) { + renameIndex += 1; + return cb(null); // keep trying + } - return cb(err); - } + return cb(err); + } - opOk = true; - return cb(null, tryDstPath); - }); - }, - (err, finalPath) => { - return cb(err, finalPath); - } - ); + opOk = true; + return cb(null, tryDstPath); + }); + }, + (err, finalPath) => { + return cb(err, finalPath); + } + ); } // -// Move |src| -> |dst| renaming to file(1).ext, file(2).ext, etc. -// in the case of collisions. +// Move |src| -> |dst| renaming to file(1).ext, file(2).ext, etc. +// in the case of collisions. // function moveFileWithCollisionHandling(src, dst, cb) { - return moveOrCopyFileWithCollisionHandling(src, dst, 'move', cb); + return moveOrCopyFileWithCollisionHandling(src, dst, 'move', cb); } function copyFileWithCollisionHandling(src, dst, cb) { - return moveOrCopyFileWithCollisionHandling(src, dst, 'copy', cb); + return moveOrCopyFileWithCollisionHandling(src, dst, 'copy', cb); } function pathWithTerminatingSeparator(path) { - if(path && paths.sep !== path.charAt(path.length - 1)) { - path = path + paths.sep; - } - return path; + if(path && paths.sep !== path.charAt(path.length - 1)) { + path = path + paths.sep; + } + return path; } diff --git a/core/files_bbs_file.js b/core/files_bbs_file.js new file mode 100644 index 00000000..6e186ab4 --- /dev/null +++ b/core/files_bbs_file.js @@ -0,0 +1,311 @@ +/* jslint node: true */ +'use strict'; + +const { Errors } = require('./enig_error.js'); + +// deps +const fs = require('graceful-fs'); +const iconv = require('iconv-lite'); +const moment = require('moment'); + +// Descriptions found in the wild that mean "no description" /facepalm. +const IgnoredDescriptions = [ + 'No description available', + 'No ID File Found For This Archive File.', +]; + +module.exports = class FilesBBSFile { + constructor() { + this.entries = new Map(); + } + + get(fileName) { + return this.entries.get(fileName); + } + + getDescription(fileName) { + const entry = this.get(fileName); + if(entry) { + return entry.desc; + } + } + + static createFromFile(path, cb) { + fs.readFile(path, (err, descData) => { + if(err) { + return cb(err); + } + + // :TODO: encoding should be default to CP437, but allowed to change - ie for Amiga/etc. + const lines = iconv.decode(descData, 'cp437').split(/\r?\n/g); + const filesBbs = new FilesBBSFile(); + + const isBadDescription = (desc) => { + return IgnoredDescriptions.find(d => desc.startsWith(d)) ? true : false; + }; + + // + // Contrary to popular belief, there is not a FILES.BBS standard. Instead, + // many formats have been used over the years. We'll try to support as much + // as we can within reason. + // + // Resources: + // - Great info from Mystic @ http://wiki.mysticbbs.com/doku.php?id=mutil_import_files.bbs + // - https://alt.bbs.synchronet.narkive.com/I6Vrxq6q/format-of-files-bbs + // + // Example files: + // - https://github.com/NuSkooler/ansi-bbs/tree/master/ancient_formats/files_bbs + // + const detectDecoder = () => { + // helpers + const regExpTestUpTo = (n, re) => { + return lines + .slice(0, n) + .some(l => re.test(l)); + }; + + // + // Try to figure out which decoder to use + // + const decoders = [ + { + // I've been told this is what Syncrhonet uses + lineRegExp : /^([^ ]{1,12})\s{1,11}([0-3][0-9]\/[0-3][0-9]\/[1789][0-9]) ([^\r\n]+)$/, + detect : function() { + return regExpTestUpTo(10, this.lineRegExp); + }, + extract : function() { + for(let i = 0; i < lines.length; ++i) { + let line = lines[i]; + const hdr = line.match(this.lineRegExp); + if(!hdr) { + continue; + } + const long = []; + for(let j = i + 1; j < lines.length; ++j) { + line = lines[j]; + if(!line.startsWith(' ')) { + break; + } + long.push(line.trim()); + ++i; + } + const desc = long.join('\r\n') || hdr[3] || ''; + const fileName = hdr[1]; + const timestamp = moment(hdr[2], 'MM/DD/YY'); + + if(isBadDescription(desc) || !timestamp.isValid()) { + continue; + } + filesBbs.entries.set(fileName, { timestamp, desc } ); + } + } + }, + + { + // + // Examples: + // - Night Owl CD #7, 1992 + // + lineRegExp : /^([^\s]{1,12})\s{2,14}\[0\]\s\s([^\r\n]+)$/, + detect : function() { + return regExpTestUpTo(10, this.lineRegExp); + }, + extract : function() { + for(let i = 0; i < lines.length; ++i) { + let line = lines[i]; + const hdr = line.match(this.lineRegExp); + if(!hdr) { + continue; + } + const long = [ hdr[2].trim() ]; + for(let j = i + 1; j < lines.length; ++j) { + line = lines[j]; + // -------------------------------------------------v 32 + if(!line.startsWith(' | ')) { + break; + } + long.push(line.substr(33)); + ++i; + } + const desc = long.join('\r\n'); + const fileName = hdr[1]; + + if(isBadDescription(desc)) { + continue; + } + + filesBbs.entries.set(fileName, { desc } ); + } + } + }, + + { + // + // Simple first line with partial description, + // secondary description lines tabbed out. + // + // Examples + // - GUS archive @ dk.toastednet.org + // + lineRegExp : /^([^\s]{1,12})\s+\[00\]\s([^\r\n]+)$/, + detect : function() { + return regExpTestUpTo(10, this.lineRegExp); + }, + extract : function() { + for(let i = 0; i < lines.length; ++i) { + let line = lines[i]; + const hdr = line.match(this.lineRegExp); + if(!hdr) { + continue; + } + const long = [ hdr[2].trimRight() ]; + for(let j = i + 1; j < lines.length; ++j) { + line = lines[j]; + if(!line.startsWith('\t\t ')) { + break; + } + long.push(line.substr(4)); + ++i; + } + const desc = long.join('\r\n'); + const fileName = hdr[1]; + + if(isBadDescription(desc)) { + continue; + } + + filesBbs.entries.set(fileName, { desc } ); + } + } + }, + + { + // + // <8.3FileName> + // + // Examples: + // - Expanding Your BBS CD by David Wolfe, 1995 + // + lineRegExp : /^([^ ]{1,12})\s{1,20}([0-9]+)\s\s([0-3][0-9]-[0-3][0-9]-[1789][0-9])\s\s([^\r\n]+)$/, + detect : function() { + return regExpTestUpTo(10, this.lineRegExp); + }, + extract : function() { + for(let i = 0; i < lines.length; ++i) { + let line = lines[i]; + const hdr = line.match(this.lineRegExp); + if(!hdr) { + continue; + } + + const firstDescLine = hdr[4].trimRight(); + const long = [ firstDescLine ]; + for(let j = i + 1; j < lines.length; ++j) { + line = lines[j]; + if(!line.startsWith(' '.repeat(34))) { + break; + } + long.push(line.substr(34).trimRight()); + ++i; + } + + const desc = long.join('\r\n'); + const fileName = hdr[1]; + const size = parseInt(hdr[2]); + const timestamp = moment(hdr[3], 'MM-DD-YY'); + + if(isBadDescription(desc) || isNaN(size) || !timestamp.isValid()) { + continue; + } + + filesBbs.entries.set(fileName, { desc, size, timestamp }); + } + } + }, + + { + // + // Examples: + // - Aminet Amiga CDROM, March 1994. Walnut Creek CDROM. + // - CP/M CDROM, Sep. 1994. Walnut Creek CDROM. + // - ...and many others. + // + // Basically: <8.3 filename> + // + // May contain headers, but we'll just skip 'em. + // + lineRegExp : /^([^ ]{1,12})\s{1,11}([^\r\n]+)$/, + detect : function() { + return regExpTestUpTo(10, this.lineRegExp); + }, + extract : function() { + lines.forEach(line => { + const hdr = line.match(this.lineRegExp); + if(!hdr) { + return; // forEach + } + + const fileName = hdr[1].trim(); + const desc = hdr[2].trim(); + + if(desc && !isBadDescription(desc)) { + filesBbs.entries.set(fileName, { desc } ); + } + }); + } + }, + + { + // + // Examples: + // - AMINET CD's & similar + // + lineRegExp : /^(.{1,22}) ([0-9]+)K ([^\r\n]+)$/, + detect : function() { + return regExpTestUpTo(10, this.lineRegExp); + }, + extract : function() { + lines.forEach(line => { + const hdr = line.match(this.tester); + if(!hdr) { + return; // forEach + } + + const fileName = hdr[1].trim(); + let size = parseInt(hdr[2]); + const desc = hdr[3].trim(); + + if(isNaN(size)) { + return; // forEach + } + size *= 1024; // K->bytes. + + if(desc) { // omit empty entries + filesBbs.entries.set(fileName, { size, desc } ); + } + }); + } + }, + ]; + + const decoder = decoders.find(d => d.detect()); + return decoder; + }; + + const decoder = detectDecoder(); + if(!decoder) { + return cb(Errors.Invalid('Invalid or unrecognized FILES.BBS format')); + } + + decoder.extract(decoder); + + return cb( + filesBbs.entries.size > 0 ? null : Errors.Invalid('Invalid or unrecognized FILES.BBS format'), + filesBbs + ); + }); + } + + +}; diff --git a/core/fnv1a.js b/core/fnv1a.js index f7714936..b85e4241 100644 --- a/core/fnv1a.js +++ b/core/fnv1a.js @@ -1,50 +1,52 @@ /* jslint node: true */ 'use strict'; -let _ = require('lodash'); +const { Errors } = require('./enig_error.js'); -// FNV-1a based on work here: https://github.com/wiedi/node-fnv +const _ = require('lodash'); + +// FNV-1a based on work here: https://github.com/wiedi/node-fnv module.exports = class FNV1a { - constructor(data) { - this.hash = 0x811c9dc5; - - if(!_.isUndefined(data)) { - this.update(data); - } - } + constructor(data) { + this.hash = 0x811c9dc5; - update(data) { - if(_.isNumber(data)) { - data = data.toString(); - } - - if(_.isString(data)) { - data = new Buffer(data); - } + if(!_.isUndefined(data)) { + this.update(data); + } + } - if(!Buffer.isBuffer(data)) { - throw new Error('data must be String or Buffer!'); - } + update(data) { + if(_.isNumber(data)) { + data = data.toString(); + } - for(let b of data) { - this.hash = this.hash ^ b; - this.hash += - (this.hash << 24) + (this.hash << 8) + (this.hash << 7) + - (this.hash << 4) + (this.hash << 1); - } + if(_.isString(data)) { + data = Buffer.from(data); + } - return this; - } + if(!Buffer.isBuffer(data)) { + throw Errors.Invalid('data must be String or Buffer!'); + } - digest(encoding) { - encoding = encoding || 'binary'; - let buf = new Buffer(4); - buf.writeInt32BE(this.hash & 0xffffffff, 0); - return buf.toString(encoding); - } + for(let b of data) { + this.hash = this.hash ^ b; + this.hash += + (this.hash << 24) + (this.hash << 8) + (this.hash << 7) + + (this.hash << 4) + (this.hash << 1); + } - get value() { - return this.hash & 0xffffffff; - } -} + return this; + } + + digest(encoding) { + encoding = encoding || 'binary'; + const buf = Buffer.alloc(4); + buf.writeInt32BE(this.hash & 0xffffffff, 0); + return buf.toString(encoding); + } + + get value() { + return this.hash & 0xffffffff; + } +}; diff --git a/core/fse.js b/core/fse.js index ed9d3b13..5e018df7 100644 --- a/core/fse.js +++ b/core/fse.js @@ -1,1049 +1,1092 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const MenuModule = require('./menu_module.js').MenuModule; -const ViewController = require('./view_controller.js').ViewController; -const ansi = require('./ansi_term.js'); -const theme = require('./theme.js'); -const Message = require('./message.js'); -const updateMessageAreaLastReadId = require('./message_area.js').updateMessageAreaLastReadId; -const getMessageAreaByTag = require('./message_area.js').getMessageAreaByTag; -const User = require('./user.js'); -const StatLog = require('./stat_log.js'); -const stringFormat = require('./string_format.js'); -const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher; -const { isAnsi, cleanControlCodes, insert } = require('./string_util.js'); -const Config = require('./config.js').config; +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { ViewController } = require('./view_controller.js'); +const ansi = require('./ansi_term.js'); +const theme = require('./theme.js'); +const Message = require('./message.js'); +const { + updateMessageAreaLastReadId +} = require('./message_area.js'); +const { getMessageAreaByTag } = require('./message_area.js'); +const User = require('./user.js'); +const StatLog = require('./stat_log.js'); +const stringFormat = require('./string_format.js'); +const { + MessageAreaConfTempSwitcher +} = require('./mod_mixins.js'); +const { + isAnsi, stripAnsiControlCodes, + insert +} = require('./string_util.js'); +const Config = require('./config.js').get; +const { getAddressedToInfo } = require('./mail_util.js'); +const Events = require('./events.js'); +const UserProps = require('./user_property.js'); +const SysProps = require('./system_property.js'); -// deps -const async = require('async'); -const assert = require('assert'); -const _ = require('lodash'); -const moment = require('moment'); +// deps +const async = require('async'); +const assert = require('assert'); +const _ = require('lodash'); +const moment = require('moment'); exports.moduleInfo = { - name : 'Full Screen Editor (FSE)', - desc : 'A full screen editor/viewer', - author : 'NuSkooler', + name : 'Full Screen Editor (FSE)', + desc : 'A full screen editor/viewer', + author : 'NuSkooler', +}; + +const MciViewIds = { + header : { + from : 1, + to : 2, + subject : 3, + errorMsg : 4, + modTimestamp : 5, + msgNum : 6, + msgTotal : 7, + + customRangeStart : 10, // 10+ = customs + }, + + body : { + message : 1, + }, + + // :TODO: quote builder MCIs - remove all magic #'s + + // :TODO: consolidate all footer MCI's - remove all magic #'s + ViewModeFooter : { + MsgNum : 6, + MsgTotal : 7, + // :TODO: Just use custom ranges + }, + + quoteBuilder : { + quotedMsg : 1, + // 2 NYI + quoteLines : 3, + } }; /* - MCI Codes - General - MA - Message Area Desc + Custom formatting: + header + fromUserName + toUserName - MCI Codes - View Mode - Header - TL1 - From - TL2 - To - TL3 - Subject - TL4 - Area name - - TL5 - Date/Time (TODO: format) - TL6 - Message number - TL7 - Mesage total (in area) - TL8 - View Count - TL9 - Hash tags - TL10 - Message ID - TL11 - Reply to message ID + fromRealName (may be fromUserName) NYI + toRealName (may be toUserName) NYI - TL12 - User1 - TL13 - User2 - - - Footer - Viewing - HM1 - Menu (prev/next/etc.) - - TL6 - Message number - TL7 - Message total (in area) - - TL12 - User1 (fmt message object) - TL13 - User2 - - + fromRemoteUser (may be "N/A") + toRemoteUser (may be "N/A") + subject + modTimestamp + msgNum + msgTotal (in area) + messageId */ -const MciCodeIds = { - ViewModeHeader : { - From : 1, - To : 2, - Subject : 3, - - DateTime : 5, - MsgNum : 6, - MsgTotal : 7, - ViewCount : 8, - HashTags : 9, - MessageID : 10, - ReplyToMsgID : 11, - // :TODO: ConfName - - }, - - ViewModeFooter : { - MsgNum : 6, - MsgTotal : 7, - }, - - ReplyEditModeHeader : { - From : 1, - To : 2, - Subject : 3, - - ErrorMsg : 13, - }, -}; - -// :TODO: convert code in this class to newer styles, conventions, etc. There is a lot of experimental stuff here that has better (DRY) alternatives +// :TODO: convert code in this class to newer styles, conventions, etc. There is a lot of experimental stuff here that has better (DRY) alternatives exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModule extends MessageAreaConfTempSwitcher(MenuModule) { - constructor(options) { - super(options); - - const self = this; - const config = this.menuConfig.config; - - // - // menuConfig.config: - // editorType : email | area - // editorMode : view | edit | quote - // - // menuConfig.config or extraArgs - // messageAreaTag - // messageIndex / messageTotal - // toUserId - // - this.editorType = config.editorType; - this.editorMode = config.editorMode; - - if(config.messageAreaTag) { - this.messageAreaTag = config.messageAreaTag; - } - - this.messageIndex = config.messageIndex || 0; - this.messageTotal = config.messageTotal || 0; - this.toUserId = config.toUserId || 0; - - // extraArgs can override some config - if(_.isObject(options.extraArgs)) { - if(options.extraArgs.messageAreaTag) { - this.messageAreaTag = options.extraArgs.messageAreaTag; - } - if(options.extraArgs.messageIndex) { - this.messageIndex = options.extraArgs.messageIndex; - } - if(options.extraArgs.messageTotal) { - this.messageTotal = options.extraArgs.messageTotal; - } - if(options.extraArgs.toUserId) { - this.toUserId = options.extraArgs.toUserId; - } - } - - this.isReady = false; - - if(_.has(options, 'extraArgs.message')) { - this.setMessage(options.extraArgs.message); - } else if(_.has(options, 'extraArgs.replyToMessage')) { - this.replyToMessage = options.extraArgs.replyToMessage; - } - - this.menuMethods = { - // - // Validation stuff - // - viewValidationListener : function(err, cb) { - var errMsgView = self.viewControllers.header.getView(MciCodeIds.ReplyEditModeHeader.ErrorMsg); - var newFocusViewId; - if(errMsgView) { - if(err) { - errMsgView.setText(err.message); - - if(MciCodeIds.ViewModeHeader.Subject === err.view.getId()) { - // :TODO: for "area" mode, should probably just bail if this is emtpy (e.g. cancel) - } - } else { - errMsgView.clearText(); - } - } - cb(newFocusViewId); - }, - headerSubmit : function(formData, extraArgs, cb) { - self.switchToBody(); - return cb(null); - }, - editModeEscPressed : function(formData, extraArgs, cb) { - self.footerMode = 'editor' === self.footerMode ? 'editorMenu' : 'editor'; - - self.switchFooter(function next(err) { - if(err) { - // :TODO:... what now? - console.log(err) - } else { - switch(self.footerMode) { - case 'editor' : - if(!_.isUndefined(self.viewControllers.footerEditorMenu)) { - //self.viewControllers.footerEditorMenu.setFocus(false); - self.viewControllers.footerEditorMenu.detachClientEvents(); - } - self.viewControllers.body.switchFocus(1); - self.observeEditorEvents(); - break; - - case 'editorMenu' : - self.viewControllers.body.setFocus(false); - self.viewControllers.footerEditorMenu.switchFocus(1); - break; - - default : throw new Error('Unexpected mode'); - } - } - - return cb(null); - }); - }, - editModeMenuQuote : function(formData, extraArgs, cb) { - self.viewControllers.footerEditorMenu.setFocus(false); - self.displayQuoteBuilder(); - return cb(null); - }, - appendQuoteEntry: function(formData, extraArgs, cb) { - // :TODO: Dont' use magic # ID's here - const quoteMsgView = self.viewControllers.quoteBuilder.getView(1); - - if(self.newQuoteBlock) { - self.newQuoteBlock = false; - - // :TODO: If replying to ANSI, add a blank sepration line here - - quoteMsgView.addText(self.getQuoteByHeader()); - } - - const quoteText = self.viewControllers.quoteBuilder.getView(3).getItem(formData.value.quote); - quoteMsgView.addText(quoteText); - - // - // If this is *not* the last item, advance. Otherwise, do nothing as we - // don't want to jump back to the top and repeat already quoted lines - // - const quoteListView = self.viewControllers.quoteBuilder.getView(3); - if(quoteListView.getData() !== quoteListView.getCount() - 1) { - quoteListView.focusNext(); - } else { - self.quoteBuilderFinalize(); - } - - return cb(null); - }, - quoteBuilderEscPressed : function(formData, extraArgs, cb) { - self.quoteBuilderFinalize(); - return cb(null); - }, - /* - replyDiscard : function(formData, extraArgs) { - // :TODO: need to prompt yes/no - // :TODO: @method for fallback would be better - self.prevMenu(); - }, - */ - editModeMenuHelp : function(formData, extraArgs, cb) { - self.viewControllers.footerEditorMenu.setFocus(false); - return self.displayHelp(cb); - }, - /////////////////////////////////////////////////////////////////////// - // View Mode - /////////////////////////////////////////////////////////////////////// - viewModeMenuHelp : function(formData, extraArgs, cb) { - self.viewControllers.footerView.setFocus(false); - return self.displayHelp(cb); - } - }; - } - - isEditMode() { - return 'edit' === this.editorMode; - } - - isViewMode() { - return 'view' === this.editorMode; - } - - isLocalEmail() { - return Message.WellKnownAreaTags.Private === this.messageAreaTag; - } - - isReply() { - return !_.isUndefined(this.replyToMessage); - } - - getFooterName() { - return 'footer' + _.upperFirst(this.footerMode); // e.g. 'footerEditor', 'footerEditorMenu', ... - } - - getFormId(name) { - return { - header : 0, - body : 1, - footerEditor : 2, - footerEditorMenu : 3, - footerView : 4, - quoteBuilder : 5, - - help : 50, - }[name]; - } - - // :TODO: convert to something like this for all view acces: - getHeaderViews() { - var vc = this.viewControllers.header; - - if(this.isViewMode()) { - return { - from : vc.getView(1), - to : vc.getView(2), - subject : vc.getView(3), - - dateTime : vc.getView(5), - msgNum : vc.getView(7), - // ... - - }; - } - } - - setInitialFooterMode() { - switch(this.editorMode) { - case 'edit' : this.footerMode = 'editor'; break; - case 'view' : this.footerMode = 'view'; break; - } - } - - buildMessage(cb) { - const headerValues = this.viewControllers.header.getFormData().value; - - const msgOpts = { - areaTag : this.messageAreaTag, - toUserName : headerValues.to, - fromUserName : this.client.user.username, - subject : headerValues.subject, - // :TODO: don't hard code 1 here: - message : this.viewControllers.body.getView(1).getData( { forceLineTerms : this.replyIsAnsi } ), - }; - - if(this.isReply()) { - msgOpts.replyToMsgId = this.replyToMessage.messageId; - - if(this.replyIsAnsi) { - // - // Ensure first characters indicate ANSI for detection down - // the line (other boards/etc.). We also set explicit_encoding - // to packetAnsiMsgEncoding (generally cp437) as various boards - // really don't like ANSI messages in UTF-8 encoding (they should!) - // - msgOpts.meta = { System : { 'explicit_encoding' : Config.scannerTossers.ftn_bso.packetAnsiMsgEncoding || 'cp437' } }; - // :TODO: change to \r\nESC[A - //msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}${msgOpts.message}`; - msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}\r\n${ansi.up()}${msgOpts.message}`; - } - } - - this.message = new Message(msgOpts); - - return cb(null); - } - - setMessage(message) { - this.message = message; - - updateMessageAreaLastReadId( - this.client.user.userId, this.messageAreaTag, this.message.messageId, () => { - - if(this.isReady) { - this.initHeaderViewMode(); - this.initFooterViewMode(); - - const bodyMessageView = this.viewControllers.body.getView(1); - let msg = this.message.message; - - if(bodyMessageView && _.has(this, 'message.message')) { - // - // We handle ANSI messages differently than standard messages -- this is required as - // we don't want to do things like word wrap ANSI, but instead, trust that it's formatted - // how the author wanted it - // - if(isAnsi(msg)) { - // - // Find tearline - we want to color it differently. - // - const tearLinePos = this.message.getTearLinePosition(msg); - - if(tearLinePos > -1) { - msg = insert(msg, tearLinePos, bodyMessageView.getSGRFor('text')); - } - - bodyMessageView.setAnsi( - msg.replace(/\r?\n/g, '\r\n'), // messages are stored with CRLF -> LF - { - prepped : false, - forceLineTerm : true, - } - ); - } else { - bodyMessageView.setText(cleanControlCodes(msg)); - } - } - } - } - ); - } - - getMessage(cb) { - const self = this; - - async.series( - [ - function buildIfNecessary(callback) { - if(self.isEditMode()) { - return self.buildMessage(callback); // creates initial self.message - } - - return callback(null); - }, - function populateLocalUserInfo(callback) { - if(self.isLocalEmail()) { - self.message.setLocalFromUserId(self.client.user.userId); - - if(self.toUserId > 0) { - self.message.setLocalToUserId(self.toUserId); - callback(null); - } else { - // we need to look it up - User.getUserIdAndName(self.message.toUserName, function userInfo(err, toUserId) { - if(err) { - callback(err); - } else { - self.message.setLocalToUserId(toUserId); - callback(null); - } - }); - } - } else { - callback(null); - } - } - ], - function complete(err) { - cb(err, self.message); - } - ); - } - - updateUserStats(cb) { - if(Message.isPrivateAreaTag(this.message.areaTag)) { - if(cb) { - cb(null); - } - return; // don't inc stats for private messages - } - - return StatLog.incrementUserStat(this.client.user, 'post_count', 1, cb); - } - - redrawFooter(options, cb) { - const self = this; - - async.waterfall( - [ - function moveToFooterPosition(callback) { - // - // Calculate footer starting position - // - // row = (header height + body height) - // - var footerRow = self.header.height + self.body.height; - self.client.term.rawWrite(ansi.goto(footerRow, 1)); - callback(null); - }, - function clearFooterArea(callback) { - if(options.clear) { - // footer up to 3 rows in height - - // :TODO: We'd like to delete up to N rows, but this does not work - // in NetRunner: - self.client.term.rawWrite(ansi.reset() + ansi.deleteLine(3)); - - self.client.term.rawWrite(ansi.reset() + ansi.eraseLine(2)); - } - callback(null); - }, - function displayFooterArt(callback) { - const footerArt = self.menuConfig.config.art[options.footerName]; - - theme.displayThemedAsset( - footerArt, - self.client, - { font : self.menuConfig.font }, - function displayed(err, artData) { - callback(err, artData); - } - ); - } - ], - function complete(err, artData) { - cb(err, artData); - } - ); - } - - redrawScreen(cb) { - var comps = [ 'header', 'body' ]; - const self = this; - var art = self.menuConfig.config.art; - - self.client.term.rawWrite(ansi.resetScreen()); - - async.series( - [ - function displayHeaderAndBody(callback) { - async.eachSeries( comps, function dispArt(n, next) { - theme.displayThemedAsset( - art[n], - self.client, - { font : self.menuConfig.font }, - function displayed(err, artData) { - next(err); - } - ); - }, function complete(err) { - callback(err); - }); - }, - function displayFooter(callback) { - // we have to treat the footer special - self.redrawFooter( { clear : false, footerName : self.getFooterName() }, function footerDisplayed(err) { - callback(err); - }); - }, - function refreshViews(callback) { - comps.push(self.getFooterName()); - - comps.forEach(function artComp(n) { - self.viewControllers[n].redrawAll(); - }); - - callback(null); - } - ], - function complete(err) { - cb(err); - } - ); - } - - switchFooter(cb) { - var footerName = this.getFooterName(); - - this.redrawFooter( { footerName : footerName, clear : true }, (err, artData) => { - if(err) { - cb(err); - return; - } - - var formId = this.getFormId(footerName); - - if(_.isUndefined(this.viewControllers[footerName])) { - var menuLoadOpts = { - callingMenu : this, - formId : formId, - mciMap : artData.mciMap - }; - - this.addViewController( - footerName, - new ViewController( { client : this.client, formId : formId } ) - ).loadFromMenuConfig(menuLoadOpts, err => { - cb(err); - }); - } else { - this.viewControllers[footerName].redrawAll(); - cb(null); - } - }); - } - - initSequence() { - var mciData = { }; - const self = this; - var art = self.menuConfig.config.art; - - assert(_.isObject(art)); - - async.series( - [ - function beforeDisplayArt(callback) { - self.beforeArt(callback); - }, - function displayHeaderAndBodyArt(callback) { - assert(_.isString(art.header)); - assert(_.isString(art.body)); - - async.eachSeries( [ 'header', 'body' ], function dispArt(n, next) { - theme.displayThemedAsset( - art[n], - self.client, - { font : self.menuConfig.font }, - function displayed(err, artData) { - if(artData) { - mciData[n] = artData; - self[n] = { height : artData.height }; - } - - next(err); - } - ); - }, function complete(err) { - callback(err); - }); - }, - function displayFooter(callback) { - self.setInitialFooterMode(); - - var footerName = self.getFooterName(); - - self.redrawFooter( { footerName : footerName }, function artDisplayed(err, artData) { - mciData[footerName] = artData; - callback(err); - }); - }, - function afterArtDisplayed(callback) { - self.mciReady(mciData, callback); - } - ], - function complete(err) { - if(err) { - // :TODO: This needs properly handled! - console.log(err) - } else { - self.isReady = true; - self.finishedLoading(); - } - } - ); - } - - createInitialViews(mciData, cb) { - const self = this; - var menuLoadOpts = { callingMenu : self }; - - async.series( - [ - function header(callback) { - menuLoadOpts.formId = self.getFormId('header'); - menuLoadOpts.mciMap = mciData.header.mciMap; - - self.addViewController( - 'header', - new ViewController( { client : self.client, formId : menuLoadOpts.formId } ) - ).loadFromMenuConfig(menuLoadOpts, function headerReady(err) { - callback(err); - }); - }, - function body(callback) { - menuLoadOpts.formId = self.getFormId('body'); - menuLoadOpts.mciMap = mciData.body.mciMap; - - self.addViewController( - 'body', - new ViewController( { client : self.client, formId : menuLoadOpts.formId } ) - ).loadFromMenuConfig(menuLoadOpts, function bodyReady(err) { - callback(err); - }); - }, - function footer(callback) { - var footerName = self.getFooterName(); - - menuLoadOpts.formId = self.getFormId(footerName); - menuLoadOpts.mciMap = mciData[footerName].mciMap; - - self.addViewController( - footerName, - new ViewController( { client : self.client, formId : menuLoadOpts.formId } ) - ).loadFromMenuConfig(menuLoadOpts, function footerReady(err) { - callback(err); - }); - }, - function prepareViewStates(callback) { - var header = self.viewControllers.header; - var from = header.getView(1); - from.acceptsFocus = false; - //from.setText(self.client.user.username); - - // :TODO: make this a method - var body = self.viewControllers.body.getView(1); - self.updateTextEditMode(body.getTextEditMode()); - self.updateEditModePosition(body.getEditPosition()); - - // :TODO: If view mode, set body to read only... which needs an impl... - - callback(null); - }, - function setInitialData(callback) { - - switch(self.editorMode) { - case 'view' : - if(self.message) { - self.initHeaderViewMode(); - self.initFooterViewMode(); - - var bodyMessageView = self.viewControllers.body.getView(1); - if(bodyMessageView && _.has(self, 'message.message')) { - //self.setBodyMessageViewText(); - bodyMessageView.setText(cleanControlCodes(self.message.message)); - } - } - break; - - case 'edit' : - { - const fromView = self.viewControllers.header.getView(1); - const area = getMessageAreaByTag(self.messageAreaTag); - if(area && area.realNames) { - fromView.setText(self.client.user.properties.real_name || self.client.user.username); - } else { - fromView.setText(self.client.user.username); - } - - if(self.replyToMessage) { - self.initHeaderReplyEditMode(); - } - } - break; - } - - callback(null); - }, - function setInitialFocus(callback) { - - switch(self.editorMode) { - case 'edit' : - self.switchToHeader(); - break; - - case 'view' : - self.switchToFooter(); - //self.observeViewPosition(); - break; - } - - callback(null); - } - ], - function complete(err) { - if(err) { - console.error(err) - } - cb(err); - } - ); - } - - mciReadyHandler(mciData, cb) { - - this.createInitialViews(mciData, err => { - // :TODO: Can probably be replaced with @systemMethod:validateUserNameExists when the framework is in - // place - if this is for existing usernames else validate spec - - /* - self.viewControllers.header.on('leave', function headerViewLeave(view) { - - if(2 === view.id) { // "to" field - self.validateToUserName(view.getData(), function result(err) { - if(err) { - // :TODO: display a error in a %TL area or such - view.clearText(); - self.viewControllers.headers.switchFocus(2); - } - }); - } - });*/ - - cb(err); - }); - } - - updateEditModePosition(pos) { - if(this.isEditMode()) { - var posView = this.viewControllers.footerEditor.getView(1); - if(posView) { - this.client.term.rawWrite(ansi.savePos()); - // :TODO: Use new formatting techniques here, e.g. state.cursorPositionRow, cursorPositionCol and cursorPositionFormat - posView.setText(_.padStart(String(pos.row + 1), 2, '0') + ',' + _.padEnd(String(pos.col + 1), 2, '0')); - this.client.term.rawWrite(ansi.restorePos()); - } - } - } - - updateTextEditMode(mode) { - if(this.isEditMode()) { - var modeView = this.viewControllers.footerEditor.getView(2); - if(modeView) { - this.client.term.rawWrite(ansi.savePos()); - modeView.setText('insert' === mode ? 'INS' : 'OVR'); - this.client.term.rawWrite(ansi.restorePos()); - } - } - } - - setHeaderText(id, text) { - this.setViewText('header', id, text); - } - - initHeaderViewMode() { - assert(_.isObject(this.message)); - - this.setHeaderText(MciCodeIds.ViewModeHeader.From, this.message.fromUserName); - this.setHeaderText(MciCodeIds.ViewModeHeader.To, this.message.toUserName); - this.setHeaderText(MciCodeIds.ViewModeHeader.Subject, this.message.subject); - this.setHeaderText(MciCodeIds.ViewModeHeader.DateTime, moment(this.message.modTimestamp).format(this.client.currentTheme.helpers.getDateTimeFormat())); - this.setHeaderText(MciCodeIds.ViewModeHeader.MsgNum, (this.messageIndex + 1).toString()); - this.setHeaderText(MciCodeIds.ViewModeHeader.MsgTotal, this.messageTotal.toString()); - this.setHeaderText(MciCodeIds.ViewModeHeader.ViewCount, this.message.viewCount); - this.setHeaderText(MciCodeIds.ViewModeHeader.HashTags, 'TODO hash tags'); - this.setHeaderText(MciCodeIds.ViewModeHeader.MessageID, this.message.messageId); - this.setHeaderText(MciCodeIds.ViewModeHeader.ReplyToMsgID, this.message.replyToMessageId); - } - - initHeaderReplyEditMode() { - assert(_.isObject(this.replyToMessage)); - - this.setHeaderText(MciCodeIds.ReplyEditModeHeader.To, this.replyToMessage.fromUserName); - - // - // We want to prefix the subject with "RE: " only if it's not already - // that way -- avoid RE: RE: RE: RE: ... - // - let newSubj = this.replyToMessage.subject; - if(false === /^RE:\s+/i.test(newSubj)) { - newSubj = `RE: ${newSubj}`; - } - - this.setHeaderText(MciCodeIds.ReplyEditModeHeader.Subject, newSubj); - } - - initFooterViewMode() { - this.setViewText('footerView', MciCodeIds.ViewModeFooter.MsgNum, (this.messageIndex + 1).toString() ); - this.setViewText('footerView', MciCodeIds.ViewModeFooter.MsgTotal, this.messageTotal.toString() ); - } - - displayHelp(cb) { - this.client.term.rawWrite(ansi.resetScreen()); - - theme.displayThemeArt( - { name : this.menuConfig.config.art.help, client : this.client }, - () => { - this.client.waitForKeyPress( () => { - this.redrawScreen( () => { - this.viewControllers[this.getFooterName()].setFocus(true); - return cb(null); - }); - }); - } - ); - } - - displayQuoteBuilder() { - // - // Clear body area - // - this.newQuoteBlock = true; - const self = this; - - async.waterfall( - [ - function clearAndDisplayArt(callback) { - - // :TODO: use termHeight, not hard coded 24 here: - - // :TODO: NetRunner does NOT support delete line, so this does not work: - self.client.term.rawWrite( - ansi.goto(self.header.height + 1, 1) + - ansi.deleteLine(24 - self.header.height)); - - theme.displayThemeArt( { name : self.menuConfig.config.art.quote, client : self.client }, function displayed(err, artData) { - callback(err, artData); - }); - }, - function createViewsIfNecessary(artData, callback) { - var formId = self.getFormId('quoteBuilder'); - - if(_.isUndefined(self.viewControllers.quoteBuilder)) { - var menuLoadOpts = { - callingMenu : self, - formId : formId, - mciMap : artData.mciMap, - }; - - self.addViewController( - 'quoteBuilder', - new ViewController( { client : self.client, formId : formId } ) - ).loadFromMenuConfig(menuLoadOpts, function quoteViewsReady(err) { - callback(err); - }); - } else { - self.viewControllers.quoteBuilder.redrawAll(); - callback(null); - } - }, - function loadQuoteLines(callback) { - const quoteView = self.viewControllers.quoteBuilder.getView(3); - const bodyView = self.viewControllers.body.getView(1); - - self.replyToMessage.getQuoteLines( - { - termWidth : self.client.term.termWidth, - termHeight : self.client.term.termHeight, - cols : quoteView.dimens.width, - startCol : quoteView.position.col, - ansiResetSgr : bodyView.styleSGR1, - ansiFocusPrefixSgr : quoteView.styleSGR2, - }, - (err, quoteLines, focusQuoteLines, replyIsAnsi) => { - if(err) { - return callback(err); - } - - self.replyIsAnsi = replyIsAnsi; - - quoteView.setItems(quoteLines); - quoteView.setFocusItems(focusQuoteLines); - - return callback(null); - } - ); - }, - function setViewFocus(callback) { - self.viewControllers.quoteBuilder.getView(1).setFocus(false); - self.viewControllers.quoteBuilder.switchFocus(3); - - callback(null); - } - ], - function complete(err) { - if(err) { - console.log(err) // :TODO: needs real impl. - } - } - ); - } - - observeEditorEvents() { - const bodyView = this.viewControllers.body.getView(1); - - bodyView.on('edit position', pos => { - this.updateEditModePosition(pos); - }); - - bodyView.on('text edit mode', mode => { - this.updateTextEditMode(mode); - }); - } - - /* - this.observeViewPosition = function() { - self.viewControllers.body.getView(1).on('edit position', function positionUpdate(pos) { - console.log(pos.percent + ' / ' + pos.below) - }); - }; - */ - - switchToHeader() { - this.viewControllers.body.setFocus(false); - this.viewControllers.header.switchFocus(2); // to - } - - switchToBody() { - this.viewControllers.header.setFocus(false); - this.viewControllers.body.switchFocus(1); - - this.observeEditorEvents(); - }; - - switchToFooter() { - this.viewControllers.header.setFocus(false); - this.viewControllers.body.setFocus(false); - - this.viewControllers[this.getFooterName()].switchFocus(1); // HM1 - } - - switchFromQuoteBuilderToBody() { - this.viewControllers.quoteBuilder.setFocus(false); - var body = this.viewControllers.body.getView(1); - body.redraw(); - this.viewControllers.body.switchFocus(1); - - // :TODO: create method (DRY) - - this.updateTextEditMode(body.getTextEditMode()); - this.updateEditModePosition(body.getEditPosition()); - - this.observeEditorEvents(); - } - - quoteBuilderFinalize() { - // :TODO: fix magic #'s - const quoteMsgView = this.viewControllers.quoteBuilder.getView(1); - const msgView = this.viewControllers.body.getView(1); - - let quoteLines = quoteMsgView.getData(); - - if(quoteLines.trim().length > 0) { - if(this.replyIsAnsi) { - const bodyMessageView = this.viewControllers.body.getView(1); - quoteLines += `${ansi.normal()}${bodyMessageView.getSGRFor('text')}`; - } - msgView.addText(`${quoteLines}\n`); - } - - quoteMsgView.setText(''); - - this.footerMode = 'editor'; - - this.switchFooter( () => { - this.switchFromQuoteBuilderToBody(); - }); - } - - getQuoteByHeader() { - let quoteFormat = this.menuConfig.config.quoteFormats; - - if(Array.isArray(quoteFormat)) { - quoteFormat = quoteFormat[ Math.floor(Math.random() * quoteFormat.length) ]; - } else if(!_.isString(quoteFormat)) { - quoteFormat = 'On {dateTime} {userName} said...'; - } - - const dtFormat = this.menuConfig.config.quoteDateTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat(); - return stringFormat(quoteFormat, { - dateTime : moment(this.replyToMessage.modTimestamp).format(dtFormat), - userName : this.replyToMessage.fromUserName, - }); - } - - enter() { - if(this.messageAreaTag) { - this.tempMessageConfAndAreaSwitch(this.messageAreaTag); - } - - super.enter(); - } - - leave() { - this.tempMessageConfAndAreaRestore(); - super.leave(); - } - - mciReady(mciData, cb) { - return this.mciReadyHandler(mciData, cb); - } + constructor(options) { + super(options); + + const self = this; + const config = this.menuConfig.config; + + // + // menuConfig.config: + // editorType : email | area + // editorMode : view | edit | quote + // + // menuConfig.config or extraArgs + // messageAreaTag + // messageIndex / messageTotal + // toUserId + // + this.editorType = config.editorType; + this.editorMode = config.editorMode; + + if(config.messageAreaTag) { + // :TODO: swtich to this.config.messageAreaTag so we can follow Object.assign pattern for config/extraArgs + this.messageAreaTag = config.messageAreaTag; + } + + this.messageIndex = config.messageIndex || 0; + this.messageTotal = config.messageTotal || 0; + this.toUserId = config.toUserId || 0; + + // extraArgs can override some config + if(_.isObject(options.extraArgs)) { + if(options.extraArgs.messageAreaTag) { + this.messageAreaTag = options.extraArgs.messageAreaTag; + } + if(options.extraArgs.messageIndex) { + this.messageIndex = options.extraArgs.messageIndex; + } + if(options.extraArgs.messageTotal) { + this.messageTotal = options.extraArgs.messageTotal; + } + if(options.extraArgs.toUserId) { + this.toUserId = options.extraArgs.toUserId; + } + } + + this.noUpdateLastReadId = _.get(options, 'extraArgs.noUpdateLastReadId', config.noUpdateLastReadId) || false; + + this.isReady = false; + + if(_.has(options, 'extraArgs.message')) { + this.setMessage(options.extraArgs.message); + } else if(_.has(options, 'extraArgs.replyToMessage')) { + this.replyToMessage = options.extraArgs.replyToMessage; + } + + this.menuMethods = { + // + // Validation stuff + // + viewValidationListener : function(err, cb) { + var errMsgView = self.viewControllers.header.getView(MciViewIds.header.errorMsg); + var newFocusViewId; + if(errMsgView) { + if(err) { + errMsgView.setText(err.message); + + if(MciViewIds.header.subject === err.view.getId()) { + // :TODO: for "area" mode, should probably just bail if this is emtpy (e.g. cancel) + } + } else { + errMsgView.clearText(); + } + } + cb(newFocusViewId); + }, + headerSubmit : function(formData, extraArgs, cb) { + self.switchToBody(); + return cb(null); + }, + editModeEscPressed : function(formData, extraArgs, cb) { + self.footerMode = 'editor' === self.footerMode ? 'editorMenu' : 'editor'; + + self.switchFooter(function next(err) { + if(err) { + return cb(err); + } + + switch(self.footerMode) { + case 'editor' : + if(!_.isUndefined(self.viewControllers.footerEditorMenu)) { + self.viewControllers.footerEditorMenu.detachClientEvents(); + } + self.viewControllers.body.switchFocus(1); + self.observeEditorEvents(); + break; + + case 'editorMenu' : + self.viewControllers.body.setFocus(false); + self.viewControllers.footerEditorMenu.switchFocus(1); + break; + + default : throw new Error('Unexpected mode'); + } + + return cb(null); + }); + }, + editModeMenuQuote : function(formData, extraArgs, cb) { + self.viewControllers.footerEditorMenu.setFocus(false); + self.displayQuoteBuilder(); + return cb(null); + }, + appendQuoteEntry: function(formData, extraArgs, cb) { + const quoteMsgView = self.viewControllers.quoteBuilder.getView(MciViewIds.quoteBuilder.quotedMsg); + + if(self.newQuoteBlock) { + self.newQuoteBlock = false; + + // :TODO: If replying to ANSI, add a blank sepration line here + + quoteMsgView.addText(self.getQuoteByHeader()); + } + + const quoteListView = self.viewControllers.quoteBuilder.getView(MciViewIds.quoteBuilder.quoteLines); + const quoteText = quoteListView.getItem(formData.value.quote); + + quoteMsgView.addText(quoteText); + + // + // If this is *not* the last item, advance. Otherwise, do nothing as we + // don't want to jump back to the top and repeat already quoted lines + // + + if(quoteListView.getData() !== quoteListView.getCount() - 1) { + quoteListView.focusNext(); + } else { + self.quoteBuilderFinalize(); + } + + return cb(null); + }, + quoteBuilderEscPressed : function(formData, extraArgs, cb) { + self.quoteBuilderFinalize(); + return cb(null); + }, + /* + replyDiscard : function(formData, extraArgs) { + // :TODO: need to prompt yes/no + // :TODO: @method for fallback would be better + self.prevMenu(); + }, + */ + editModeMenuHelp : function(formData, extraArgs, cb) { + self.viewControllers.footerEditorMenu.setFocus(false); + return self.displayHelp(cb); + }, + /////////////////////////////////////////////////////////////////////// + // View Mode + /////////////////////////////////////////////////////////////////////// + viewModeMenuHelp : function(formData, extraArgs, cb) { + self.viewControllers.footerView.setFocus(false); + return self.displayHelp(cb); + } + }; + } + + isEditMode() { + return 'edit' === this.editorMode; + } + + isViewMode() { + return 'view' === this.editorMode; + } + + isPrivateMail() { + return Message.WellKnownAreaTags.Private === this.messageAreaTag; + } + + isReply() { + return !_.isUndefined(this.replyToMessage); + } + + getFooterName() { + return 'footer' + _.upperFirst(this.footerMode); // e.g. 'footerEditor', 'footerEditorMenu', ... + } + + getFormId(name) { + return { + header : 0, + body : 1, + footerEditor : 2, + footerEditorMenu : 3, + footerView : 4, + quoteBuilder : 5, + + help : 50, + }[name]; + } + + getHeaderFormatObj() { + const remoteUserNotAvail = this.menuConfig.config.remoteUserNotAvail || 'N/A'; + const localUserIdNotAvail = this.menuConfig.config.localUserIdNotAvail || 'N/A'; + const modTimestampFormat = this.menuConfig.config.modTimestampFormat || this.client.currentTheme.helpers.getDateTimeFormat(); + + return { + // :TODO: ensure we show real names for form/to if they are enforced in the area + fromUserName : this.message.fromUserName, + toUserName : this.message.toUserName, + // :TODO: + //fromRealName + //toRealName + 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.message, 'meta.System.remote_to_user', remoteUserNotAvail), + subject : this.message.subject, + modTimestamp : this.message.modTimestamp.format(modTimestampFormat), + msgNum : this.messageIndex + 1, + msgTotal : this.messageTotal, + messageId : this.message.messageId, + }; + } + + setInitialFooterMode() { + switch(this.editorMode) { + case 'edit' : this.footerMode = 'editor'; break; + case 'view' : this.footerMode = 'view'; break; + } + } + + 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 : getFromUserName(), + subject : headerValues.subject, + }; + + if(this.isReply()) { + msgOpts.replyToMsgId = this.replyToMessage.messageId; + + if(this.replyIsAnsi) { + // + // Ensure first characters indicate ANSI for detection down + // the line (other boards/etc.). We also set explicit_encoding + // 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') } }; + 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); + } + + updateLastReadId(cb) { + if(this.noUpdateLastReadId) { + return cb(null); + } + + return updateMessageAreaLastReadId( + this.client.user.userId, this.messageAreaTag, this.message.messageId, cb + ); + } + + setMessage(message) { + this.message = message; + + this.updateLastReadId( () => { + if(this.isReady) { + this.initHeaderViewMode(); + this.initFooterViewMode(); + + const bodyMessageView = this.viewControllers.body.getView(MciViewIds.body.message); + let msg = this.message.message; + + if(bodyMessageView && _.has(this, 'message.message')) { + // + // We handle ANSI messages differently than standard messages -- this is required as + // we don't want to do things like word wrap ANSI, but instead, trust that it's formatted + // how the author wanted it + // + if(isAnsi(msg)) { + // + // Find tearline - we want to color it differently. + // + const tearLinePos = this.message.getTearLinePosition(msg); + + if(tearLinePos > -1) { + msg = insert(msg, tearLinePos, bodyMessageView.getSGRFor('text')); + } + + bodyMessageView.setAnsi( + msg.replace(/\r?\n/g, '\r\n'), // messages are stored with CRLF -> LF + { + prepped : false, + forceLineTerm : true, + } + ); + } else { + bodyMessageView.setText(stripAnsiControlCodes(msg)); + } + } + } + }); + } + + getMessage(cb) { + const self = this; + + async.series( + [ + function buildIfNecessary(callback) { + if(self.isEditMode()) { + return self.buildMessage(callback); // creates initial self.message + } + + return callback(null); + }, + function populateLocalUserInfo(callback) { + self.message.setLocalFromUserId(self.client.user.userId); + + if(!self.isPrivateMail()) { + return callback(null); + } + + if(self.toUserId > 0) { + self.message.setLocalToUserId(self.toUserId); + return callback(null); + } + + // + // If the message we're replying to is from a remote user + // don't try to look up the local user ID. Instead, mark the mail + // for export with the remote to address. + // + if(self.replyToMessage && self.replyToMessage.isFromRemoteUser()) { + self.message.setRemoteToUser(self.replyToMessage.meta.System[Message.SystemMetaNames.RemoteFromUser]); + self.message.setExternalFlavor(self.replyToMessage.meta.System[Message.SystemMetaNames.ExternalFlavor]); + return callback(null); + } + + // + // Detect if the user is attempting to send to a remote mail type that we support + // + // :TODO: how to plug in support without tying to various types here? isSupportedExteranlType() or such + const addressedToInfo = getAddressedToInfo(self.message.toUserName); + if(addressedToInfo.name && Message.AddressFlavor.FTN === addressedToInfo.flavor) { + self.message.setRemoteToUser(addressedToInfo.remote); + self.message.setExternalFlavor(addressedToInfo.flavor); + self.message.toUserName = addressedToInfo.name; + return callback(null); + } + + // we need to look it up + User.getUserIdAndNameByLookup(self.message.toUserName, (err, toUserId) => { + if(err) { + return callback(err); + } + + self.message.setLocalToUserId(toUserId); + return callback(null); + }); + } + ], + err => { + return cb(err, self.message); + } + ); + } + + updateUserAndSystemStats(cb) { + if(Message.isPrivateAreaTag(this.message.areaTag)) { + Events.emit(Events.getSystemEvents().UserSendMail, { user : this.client.user }); + if(cb) { + cb(null); + } + return; // don't inc stats for private messages + } + + Events.emit(Events.getSystemEvents().UserPostMessage, { user : this.client.user, areaTag : this.message.areaTag }); + + StatLog.incrementNonPersistentSystemStat(SysProps.MessageTotalCount, 1); + StatLog.incrementNonPersistentSystemStat(SysProps.MessagesToday, 1); + return StatLog.incrementUserStat(this.client.user, UserProps.MessagePostCount, 1, cb); + } + + redrawFooter(options, cb) { + const self = this; + + async.waterfall( + [ + function moveToFooterPosition(callback) { + // + // Calculate footer starting position + // + // row = (header height + body height) + // + var footerRow = self.header.height + self.body.height; + self.client.term.rawWrite(ansi.goto(footerRow, 1)); + callback(null); + }, + function clearFooterArea(callback) { + if(options.clear) { + // footer up to 3 rows in height + + // :TODO: We'd like to delete up to N rows, but this does not work + // in NetRunner: + self.client.term.rawWrite(ansi.reset() + ansi.deleteLine(3)); + + self.client.term.rawWrite(ansi.reset() + ansi.eraseLine(2)); + } + callback(null); + }, + function displayFooterArt(callback) { + const footerArt = self.menuConfig.config.art[options.footerName]; + + theme.displayThemedAsset( + footerArt, + self.client, + { font : self.menuConfig.font }, + function displayed(err, artData) { + callback(err, artData); + } + ); + } + ], + function complete(err, artData) { + cb(err, artData); + } + ); + } + + redrawScreen(cb) { + var comps = [ 'header', 'body' ]; + const self = this; + var art = self.menuConfig.config.art; + + self.client.term.rawWrite(ansi.resetScreen()); + + async.series( + [ + function displayHeaderAndBody(callback) { + async.eachSeries( comps, function dispArt(n, next) { + theme.displayThemedAsset( + art[n], + self.client, + { font : self.menuConfig.font }, + function displayed(err) { + next(err); + } + ); + }, function complete(err) { + //self.body.height = self.client.term.termHeight - self.header.height - 1; + callback(err); + }); + }, + function displayFooter(callback) { + // we have to treat the footer special + self.redrawFooter( { clear : false, footerName : self.getFooterName() }, function footerDisplayed(err) { + callback(err); + }); + }, + function refreshViews(callback) { + comps.push(self.getFooterName()); + + comps.forEach(function artComp(n) { + self.viewControllers[n].redrawAll(); + }); + + callback(null); + } + ], + function complete(err) { + cb(err); + } + ); + } + + switchFooter(cb) { + var footerName = this.getFooterName(); + + this.redrawFooter( { footerName : footerName, clear : true }, (err, artData) => { + if(err) { + cb(err); + return; + } + + var formId = this.getFormId(footerName); + + if(_.isUndefined(this.viewControllers[footerName])) { + var menuLoadOpts = { + callingMenu : this, + formId : formId, + mciMap : artData.mciMap + }; + + this.addViewController( + footerName, + new ViewController( { client : this.client, formId : formId } ) + ).loadFromMenuConfig(menuLoadOpts, err => { + cb(err); + }); + } else { + this.viewControllers[footerName].redrawAll(); + cb(null); + } + }); + } + + initSequence() { + var mciData = { }; + const self = this; + var art = self.menuConfig.config.art; + + assert(_.isObject(art)); + + async.series( + [ + function beforeDisplayArt(callback) { + self.beforeArt(callback); + }, + function displayHeaderAndBodyArt(callback) { + async.eachSeries( [ 'header', 'body' ], function dispArt(n, next) { + theme.displayThemedAsset( + art[n], + self.client, + { font : self.menuConfig.font }, + function displayed(err, artData) { + if(artData) { + mciData[n] = artData; + self[n] = { height : artData.height }; + } + + next(err); + } + ); + }, function complete(err) { + callback(err); + }); + }, + function displayFooter(callback) { + self.setInitialFooterMode(); + + var footerName = self.getFooterName(); + + self.redrawFooter( { footerName : footerName }, function artDisplayed(err, artData) { + mciData[footerName] = artData; + callback(err); + }); + }, + function afterArtDisplayed(callback) { + self.mciReady(mciData, callback); + } + ], + function complete(err) { + if(err) { + self.client.log.warn( { error : err.message }, 'FSE init error'); + } else { + self.isReady = true; + self.finishedLoading(); + } + } + ); + } + + createInitialViews(mciData, cb) { + const self = this; + var menuLoadOpts = { callingMenu : self }; + + async.series( + [ + function header(callback) { + menuLoadOpts.formId = self.getFormId('header'); + menuLoadOpts.mciMap = mciData.header.mciMap; + + self.addViewController( + 'header', + new ViewController( { client : self.client, formId : menuLoadOpts.formId } ) + ).loadFromMenuConfig(menuLoadOpts, function headerReady(err) { + callback(err); + }); + }, + function body(callback) { + menuLoadOpts.formId = self.getFormId('body'); + menuLoadOpts.mciMap = mciData.body.mciMap; + + self.addViewController( + 'body', + new ViewController( { client : self.client, formId : menuLoadOpts.formId } ) + ).loadFromMenuConfig(menuLoadOpts, function bodyReady(err) { + callback(err); + }); + }, + function footer(callback) { + var footerName = self.getFooterName(); + + menuLoadOpts.formId = self.getFormId(footerName); + menuLoadOpts.mciMap = mciData[footerName].mciMap; + + self.addViewController( + footerName, + new ViewController( { client : self.client, formId : menuLoadOpts.formId } ) + ).loadFromMenuConfig(menuLoadOpts, function footerReady(err) { + callback(err); + }); + }, + function prepareViewStates(callback) { + var header = self.viewControllers.header; + var from = header.getView(MciViewIds.header.from); + from.acceptsFocus = false; + //from.setText(self.client.user.username); + + // :TODO: make this a method + var body = self.viewControllers.body.getView(MciViewIds.body.message); + self.updateTextEditMode(body.getTextEditMode()); + self.updateEditModePosition(body.getEditPosition()); + + // :TODO: If view mode, set body to read only... which needs an impl... + + callback(null); + }, + function setInitialData(callback) { + + switch(self.editorMode) { + case 'view' : + if(self.message) { + self.initHeaderViewMode(); + self.initFooterViewMode(); + + var bodyMessageView = self.viewControllers.body.getView(MciViewIds.body.message); + if(bodyMessageView && _.has(self, 'message.message')) { + //self.setBodyMessageViewText(); + bodyMessageView.setText(stripAnsiControlCodes(self.message.message)); + } + } + break; + + case 'edit' : + { + const fromView = self.viewControllers.header.getView(MciViewIds.header.from); + const area = getMessageAreaByTag(self.messageAreaTag); + if(area && area.realNames) { + fromView.setText(self.client.user.properties[UserProps.RealName] || self.client.user.username); + } else { + fromView.setText(self.client.user.username); + } + + if(self.replyToMessage) { + self.initHeaderReplyEditMode(); + } + } + break; + } + + callback(null); + }, + function setInitialFocus(callback) { + + switch(self.editorMode) { + case 'edit' : + self.switchToHeader(); + break; + + case 'view' : + self.switchToFooter(); + //self.observeViewPosition(); + break; + } + + callback(null); + } + ], + function complete(err) { + return cb(err); + } + ); + } + + mciReadyHandler(mciData, cb) { + + this.createInitialViews(mciData, err => { + // :TODO: Can probably be replaced with @systemMethod:validateUserNameExists when the framework is in + // place - if this is for existing usernames else validate spec + + /* + self.viewControllers.header.on('leave', function headerViewLeave(view) { + + if(2 === view.id) { // "to" field + self.validateToUserName(view.getData(), function result(err) { + if(err) { + // :TODO: display a error in a %TL area or such + view.clearText(); + self.viewControllers.headers.switchFocus(2); + } + }); + } + });*/ + + cb(err); + }); + } + + updateEditModePosition(pos) { + if(this.isEditMode()) { + var posView = this.viewControllers.footerEditor.getView(1); + if(posView) { + this.client.term.rawWrite(ansi.savePos()); + // :TODO: Use new formatting techniques here, e.g. state.cursorPositionRow, cursorPositionCol and cursorPositionFormat + posView.setText(_.padStart(String(pos.row + 1), 2, '0') + ',' + _.padEnd(String(pos.col + 1), 2, '0')); + this.client.term.rawWrite(ansi.restorePos()); + } + } + } + + updateTextEditMode(mode) { + if(this.isEditMode()) { + var modeView = this.viewControllers.footerEditor.getView(2); + if(modeView) { + this.client.term.rawWrite(ansi.savePos()); + modeView.setText('insert' === mode ? 'INS' : 'OVR'); + this.client.term.rawWrite(ansi.restorePos()); + } + } + } + + setHeaderText(id, text) { + this.setViewText('header', id, text); + } + + initHeaderViewMode() { + this.setHeaderText(MciViewIds.header.from, this.message.fromUserName); + this.setHeaderText(MciViewIds.header.to, this.message.toUserName); + this.setHeaderText(MciViewIds.header.subject, this.message.subject); + this.setHeaderText(MciViewIds.header.modTimestamp, moment(this.message.modTimestamp).format(this.client.currentTheme.helpers.getDateTimeFormat())); + this.setHeaderText(MciViewIds.header.msgNum, (this.messageIndex + 1).toString()); + this.setHeaderText(MciViewIds.header.msgTotal, this.messageTotal.toString()); + + this.updateCustomViewTextsWithFilter('header', MciViewIds.header.customRangeStart, this.getHeaderFormatObj()); + + // if we changed conf/area we need to update any related standard MCI view + this.refreshPredefinedMciViewsByCode('header', [ 'MA', 'MC', 'ML', 'CM' ] ); + } + + initHeaderReplyEditMode() { + assert(_.isObject(this.replyToMessage)); + + this.setHeaderText(MciViewIds.header.to, this.replyToMessage.fromUserName); + + // + // We want to prefix the subject with "RE: " only if it's not already + // that way -- avoid RE: RE: RE: RE: ... + // + let newSubj = this.replyToMessage.subject; + if(false === /^RE:\s+/i.test(newSubj)) { + newSubj = `RE: ${newSubj}`; + } + + this.setHeaderText(MciViewIds.header.subject, newSubj); + } + + initFooterViewMode() { + this.setViewText('footerView', MciViewIds.ViewModeFooter.msgNum, (this.messageIndex + 1).toString() ); + this.setViewText('footerView', MciViewIds.ViewModeFooter.msgTotal, this.messageTotal.toString() ); + } + + displayHelp(cb) { + this.client.term.rawWrite(ansi.resetScreen()); + + theme.displayThemeArt( + { name : this.menuConfig.config.art.help, client : this.client }, + () => { + this.client.waitForKeyPress( () => { + this.redrawScreen( () => { + this.viewControllers[this.getFooterName()].setFocus(true); + return cb(null); + }); + }); + } + ); + } + + displayQuoteBuilder() { + // + // Clear body area + // + this.newQuoteBlock = true; + const self = this; + + async.waterfall( + [ + function clearAndDisplayArt(callback) { + // :TODO: NetRunner does NOT support delete line, so this does not work: + self.client.term.rawWrite( + ansi.goto(self.header.height + 1, 1) + + ansi.deleteLine((self.client.term.termHeight - self.header.height) - 1)); + + theme.displayThemeArt( { name : self.menuConfig.config.art.quote, client : self.client }, function displayed(err, artData) { + callback(err, artData); + }); + }, + function createViewsIfNecessary(artData, callback) { + var formId = self.getFormId('quoteBuilder'); + + if(_.isUndefined(self.viewControllers.quoteBuilder)) { + var menuLoadOpts = { + callingMenu : self, + formId : formId, + mciMap : artData.mciMap, + }; + + self.addViewController( + 'quoteBuilder', + new ViewController( { client : self.client, formId : formId } ) + ).loadFromMenuConfig(menuLoadOpts, function quoteViewsReady(err) { + callback(err); + }); + } else { + self.viewControllers.quoteBuilder.redrawAll(); + callback(null); + } + }, + function loadQuoteLines(callback) { + const quoteView = self.viewControllers.quoteBuilder.getView(MciViewIds.quoteBuilder.quoteLines); + const bodyView = self.viewControllers.body.getView(MciViewIds.body.message); + + self.replyToMessage.getQuoteLines( + { + termWidth : self.client.term.termWidth, + termHeight : self.client.term.termHeight, + cols : quoteView.dimens.width, + startCol : quoteView.position.col, + ansiResetSgr : bodyView.styleSGR1, + ansiFocusPrefixSgr : quoteView.styleSGR2, + }, + (err, quoteLines, focusQuoteLines, replyIsAnsi) => { + if(err) { + return callback(err); + } + + self.replyIsAnsi = replyIsAnsi; + + quoteView.setItems(quoteLines); + quoteView.setFocusItems(focusQuoteLines); + + self.viewControllers.quoteBuilder.getView(MciViewIds.quoteBuilder.quotedMsg).setFocus(false); + self.viewControllers.quoteBuilder.switchFocus(MciViewIds.quoteBuilder.quoteLines); + + return callback(null); + } + ); + }, + ], + function complete(err) { + if(err) { + self.client.log.warn( { error : err.message }, 'Error displaying quote builder'); + } + } + ); + } + + observeEditorEvents() { + const bodyView = this.viewControllers.body.getView(MciViewIds.body.message); + + bodyView.on('edit position', pos => { + this.updateEditModePosition(pos); + }); + + bodyView.on('text edit mode', mode => { + this.updateTextEditMode(mode); + }); + } + + /* + this.observeViewPosition = function() { + self.viewControllers.body.getView(MciViewIds.body.message).on('edit position', function positionUpdate(pos) { + console.log(pos.percent + ' / ' + pos.below) + }); + }; + */ + + switchToHeader() { + this.viewControllers.body.setFocus(false); + this.viewControllers.header.switchFocus(2); // to + } + + switchToBody() { + this.viewControllers.header.setFocus(false); + this.viewControllers.body.switchFocus(1); + + this.observeEditorEvents(); + } + + switchToFooter() { + this.viewControllers.header.setFocus(false); + this.viewControllers.body.setFocus(false); + + this.viewControllers[this.getFooterName()].switchFocus(1); // HM1 + } + + switchFromQuoteBuilderToBody() { + this.viewControllers.quoteBuilder.setFocus(false); + var body = this.viewControllers.body.getView(MciViewIds.body.message); + body.redraw(); + this.viewControllers.body.switchFocus(1); + + // :TODO: create method (DRY) + + this.updateTextEditMode(body.getTextEditMode()); + this.updateEditModePosition(body.getEditPosition()); + + this.observeEditorEvents(); + } + + quoteBuilderFinalize() { + // :TODO: fix magic #'s + const quoteMsgView = this.viewControllers.quoteBuilder.getView(MciViewIds.quoteBuilder.quotedMsg); + const msgView = this.viewControllers.body.getView(MciViewIds.body.message); + + let quoteLines = quoteMsgView.getData().trim(); + + if(quoteLines.length > 0) { + if(this.replyIsAnsi) { + const bodyMessageView = this.viewControllers.body.getView(MciViewIds.body.message); + quoteLines += `${ansi.normal()}${bodyMessageView.getSGRFor('text')}`; + } + msgView.addText(`${quoteLines}\n\n`); + } + + quoteMsgView.setText(''); + + this.footerMode = 'editor'; + + this.switchFooter( () => { + this.switchFromQuoteBuilderToBody(); + }); + } + + getQuoteByHeader() { + let quoteFormat = this.menuConfig.config.quoteFormats; + + if(Array.isArray(quoteFormat)) { + quoteFormat = quoteFormat[ Math.floor(Math.random() * quoteFormat.length) ]; + } else if(!_.isString(quoteFormat)) { + quoteFormat = 'On {dateTime} {userName} said...'; + } + + const dtFormat = this.menuConfig.config.quoteDateTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat(); + return stringFormat(quoteFormat, { + dateTime : moment(this.replyToMessage.modTimestamp).format(dtFormat), + userName : this.replyToMessage.fromUserName, + }); + } + + enter() { + if(this.messageAreaTag) { + this.tempMessageConfAndAreaSwitch(this.messageAreaTag); + } + + super.enter(); + } + + leave() { + this.tempMessageConfAndAreaRestore(); + super.leave(); + } + + mciReady(mciData, cb) { + return this.mciReadyHandler(mciData, cb); + } }; diff --git a/core/ftn_address.js b/core/ftn_address.js index 9edb3819..6751adb8 100644 --- a/core/ftn_address.js +++ b/core/ftn_address.js @@ -1,198 +1,207 @@ /* jslint node: true */ 'use strict'; -const _ = require('lodash'); +const _ = require('lodash'); -const FTN_ADDRESS_REGEXP = /^([0-9]+:)?([0-9]+)(\/[0-9]+)?(\.[0-9]+)?(@[a-z0-9\-\.]+)?$/i; -const FTN_PATTERN_REGEXP = /^([0-9\*]+:)?([0-9\*]+)(\/[0-9\*]+)?(\.[0-9\*]+)?(@[a-z0-9\-\.\*]+)?$/i; +const FTN_ADDRESS_REGEXP = /^([0-9]+:)?([0-9]+)(\/[0-9]+)?(\.[0-9]+)?(@[a-z0-9\-.]+)?$/i; +const FTN_PATTERN_REGEXP = /^([0-9*]+:)?([0-9*]+)(\/[0-9*]+)?(\.[0-9*]+)?(@[a-z0-9\-.*]+)?$/i; module.exports = class Address { - constructor(addr) { - if(addr) { - if(_.isObject(addr)) { - Object.assign(this, addr); - } else if(_.isString(addr)) { - const temp = Address.fromString(addr); - if(temp) { - Object.assign(this, temp); - } - } - } - } + constructor(addr) { + if(addr) { + if(_.isObject(addr)) { + Object.assign(this, addr); + } else if(_.isString(addr)) { + const temp = Address.fromString(addr); + if(temp) { + Object.assign(this, temp); + } + } + } + } - isEqual(other) { - if(_.isString(other)) { - other = Address.fromString(other); - } + static isValidAddress(addr) { + return addr && addr.isValid(); + } - return ( - this.net === other.net && - this.node === other.node && - this.zone === other.zone && - this.point === other.point && - this.domain === other.domain - ); - } + isValid() { + // FTN address is valid if we have at least a net/node + return _.isNumber(this.net) && _.isNumber(this.node); + } - getMatchAddr(pattern) { - const m = FTN_PATTERN_REGEXP.exec(pattern); - if(m) { - let addr = { }; + isEqual(other) { + if(_.isString(other)) { + other = Address.fromString(other); + } - if(m[1]) { - addr.zone = m[1].slice(0, -1); - if('*' !== addr.zone) { - addr.zone = parseInt(addr.zone); - } - } else { - addr.zone = '*'; - } + return ( + this.net === other.net && + this.node === other.node && + this.zone === other.zone && + this.point === other.point && + this.domain === other.domain + ); + } - if(m[2]) { - addr.net = m[2]; - if('*' !== addr.net) { - addr.net = parseInt(addr.net); - } - } else { - addr.net = '*'; - } + getMatchAddr(pattern) { + const m = FTN_PATTERN_REGEXP.exec(pattern); + if(m) { + let addr = { }; - if(m[3]) { - addr.node = m[3].substr(1); - if('*' !== addr.node) { - addr.node = parseInt(addr.node); - } - } else { - addr.node = '*'; - } + if(m[1]) { + addr.zone = m[1].slice(0, -1); + if('*' !== addr.zone) { + addr.zone = parseInt(addr.zone); + } + } else { + addr.zone = '*'; + } - if(m[4]) { - addr.point = m[4].substr(1); - if('*' !== addr.point) { - addr.point = parseInt(addr.point); - } - } else { - addr.point = '*'; - } + if(m[2]) { + addr.net = m[2]; + if('*' !== addr.net) { + addr.net = parseInt(addr.net); + } + } else { + addr.net = '*'; + } - if(m[5]) { - addr.domain = m[5].substr(1); - } else { - addr.domain = '*'; - } + if(m[3]) { + addr.node = m[3].substr(1); + if('*' !== addr.node) { + addr.node = parseInt(addr.node); + } + } else { + addr.node = '*'; + } - return addr; - } - } + if(m[4]) { + addr.point = m[4].substr(1); + if('*' !== addr.point) { + addr.point = parseInt(addr.point); + } + } else { + addr.point = '*'; + } - /* - getMatchScore(pattern) { - let score = 0; - const addr = this.getMatchAddr(pattern); - if(addr) { - const PARTS = [ 'net', 'node', 'zone', 'point', 'domain' ]; - for(let i = 0; i < PARTS.length; ++i) { - const member = PARTS[i]; - if(this[member] === addr[member]) { - score += 2; - } else if('*' === addr[member]) { - score += 1; - } else { - break; - } - } - } + if(m[5]) { + addr.domain = m[5].substr(1); + } else { + addr.domain = '*'; + } - return score; - } - */ + return addr; + } + } - isPatternMatch(pattern) { - const addr = this.getMatchAddr(pattern); - if(addr) { - return ( - ('*' === addr.net || this.net === addr.net) && - ('*' === addr.node || this.node === addr.node) && - ('*' === addr.zone || this.zone === addr.zone) && - ('*' === addr.point || this.point === addr.point) && - ('*' === addr.domain || this.domain === addr.domain) - ); - } + /* + getMatchScore(pattern) { + let score = 0; + const addr = this.getMatchAddr(pattern); + if(addr) { + const PARTS = [ 'net', 'node', 'zone', 'point', 'domain' ]; + for(let i = 0; i < PARTS.length; ++i) { + const member = PARTS[i]; + if(this[member] === addr[member]) { + score += 2; + } else if('*' === addr[member]) { + score += 1; + } else { + break; + } + } + } - return false; - } + return score; + } + */ - static fromString(addrStr) { - const m = FTN_ADDRESS_REGEXP.exec(addrStr); - - if(m) { - // start with a 2D - let addr = { - net : parseInt(m[2]), - node : parseInt(m[3].substr(1)), - }; + isPatternMatch(pattern) { + const addr = this.getMatchAddr(pattern); + if(addr) { + return ( + ('*' === addr.net || this.net === addr.net) && + ('*' === addr.node || this.node === addr.node) && + ('*' === addr.zone || this.zone === addr.zone) && + ('*' === addr.point || this.point === addr.point) && + ('*' === addr.domain || this.domain === addr.domain) + ); + } - // 3D: Addition of zone if present - if(m[1]) { - addr.zone = parseInt(m[1].slice(0, -1)); - } + return false; + } - // 4D if optional point is present - if(m[4]) { - addr.point = parseInt(m[4].substr(1)); - } + static fromString(addrStr) { + const m = FTN_ADDRESS_REGEXP.exec(addrStr); - // 5D with @domain - if(m[5]) { - addr.domain = m[5].substr(1); - } + if(m) { + // start with a 2D + let addr = { + net : parseInt(m[2]), + node : parseInt(m[3].substr(1)), + }; - return new Address(addr); - } - } + // 3D: Addition of zone if present + if(m[1]) { + addr.zone = parseInt(m[1].slice(0, -1)); + } - toString(dimensions) { - dimensions = dimensions || '5D'; + // 4D if optional point is present + if(m[4]) { + addr.point = parseInt(m[4].substr(1)); + } - let addrStr = `${this.zone}:${this.net}`; + // 5D with @domain + if(m[5]) { + addr.domain = m[5].substr(1); + } - // allow for e.g. '4D' or 5 - const dim = parseInt(dimensions.toString()[0]); + return new Address(addr); + } + } - if(dim >= 3) { - addrStr += `/${this.node}`; - } + toString(dimensions) { + dimensions = dimensions || '5D'; - // missing & .0 are equiv for point - if(dim >= 4 && this.point) { - addrStr += `.${this.point}`; - } + let addrStr = `${this.zone}:${this.net}`; - if(5 === dim && this.domain) { - addrStr += `@${this.domain.toLowerCase()}`; - } + // allow for e.g. '4D' or 5 + const dim = parseInt(dimensions.toString()[0]); - return addrStr; - } + if(dim >= 3) { + addrStr += `/${this.node}`; + } - static getComparator() { - return function(left, right) { - let c = (left.zone || 0) - (right.zone || 0); - if(0 !== c) { - return c; - } + // missing & .0 are equiv for point + if(dim >= 4 && this.point) { + addrStr += `.${this.point}`; + } - c = (left.net || 0) - (right.net || 0); - if(0 !== c) { - return c; - } + if(5 === dim && this.domain) { + addrStr += `@${this.domain.toLowerCase()}`; + } - c = (left.node || 0) - (right.node || 0); - if(0 !== c) { - return c; - } + return addrStr; + } - return (left.domain || '').localeCompare(right.domain || ''); - }; - } + static getComparator() { + return function(left, right) { + let c = (left.zone || 0) - (right.zone || 0); + if(0 !== c) { + return c; + } + + c = (left.net || 0) - (right.net || 0); + if(0 !== c) { + return c; + } + + c = (left.node || 0) - (right.node || 0); + if(0 !== c) { + return c; + } + + return (left.domain || '').localeCompare(right.domain || ''); + }; + } }; diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index 095628ea..781ed929 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -1,1003 +1,1119 @@ /* jslint node: true */ 'use strict'; -const ftn = require('./ftn_util.js'); -const Message = require('./message.js'); -const sauce = require('./sauce.js'); -const Address = require('./ftn_address.js'); -const strUtil = require('./string_util.js'); -const Log = require('./logger.js').log; -const ansiPrep = require('./ansi_prep.js'); +const ftn = require('./ftn_util.js'); +const Message = require('./message.js'); +const sauce = require('./sauce.js'); +const Address = require('./ftn_address.js'); +const strUtil = require('./string_util.js'); +const Log = require('./logger.js').log; +const ansiPrep = require('./ansi_prep.js'); +const Errors = require('./enig_error.js').Errors; -const _ = require('lodash'); -const assert = require('assert'); -const binary = require('binary'); -const fs = require('graceful-fs'); -const async = require('async'); -const iconv = require('iconv-lite'); -const moment = require('moment'); +const _ = require('lodash'); +const assert = require('assert'); +const { Parser } = require('binary-parser'); +const fs = require('graceful-fs'); +const async = require('async'); +const iconv = require('iconv-lite'); +const moment = require('moment'); -exports.Packet = Packet; +exports.Packet = Packet; -/* - :TODO: things - * Test SAUCE ignore/extraction - * FSP-1010 for netmail (see SBBS) - * Syncronet apparently uses odd origin lines - * Origin lines starting with "#" instead of "*" ? +const FTN_PACKET_HEADER_SIZE = 58; // fixed header size +const FTN_PACKET_HEADER_TYPE = 2; +const FTN_PACKET_MESSAGE_TYPE = 2; +const FTN_PACKET_BAUD_TYPE_2_2 = 2; -*/ +// SAUCE magic header + version ("00") +const FTN_MESSAGE_SAUCE_HEADER = Buffer.from('SAUCE00'); -const FTN_PACKET_HEADER_SIZE = 58; // fixed header size -const FTN_PACKET_HEADER_TYPE = 2; -const FTN_PACKET_MESSAGE_TYPE = 2; -const FTN_PACKET_BAUD_TYPE_2_2 = 2; -const NULL_TERM_BUFFER = new Buffer( [ 0x00 ] ); - -// SAUCE magic header + version ("00") -const FTN_MESSAGE_SAUCE_HEADER = new Buffer('SAUCE00'); - -const FTN_MESSAGE_KLUDGE_PREFIX = '\x01'; +const FTN_MESSAGE_KLUDGE_PREFIX = '\x01'; class PacketHeader { - constructor(origAddr, destAddr, version, createdMoment) { - const EMPTY_ADDRESS = { - node : 0, - net : 0, - zone : 0, - point : 0, - }; + constructor(origAddr, destAddr, version, createdMoment) { + const EMPTY_ADDRESS = { + node : 0, + net : 0, + zone : 0, + point : 0, + }; - this.version = version || '2+'; - this.origAddress = origAddr || EMPTY_ADDRESS; - this.destAddress = destAddr || EMPTY_ADDRESS; - this.created = createdMoment || moment(); + this.version = version || '2+'; + this.origAddress = origAddr || EMPTY_ADDRESS; + this.destAddress = destAddr || EMPTY_ADDRESS; + this.created = createdMoment || moment(); - // uncommon to set the following explicitly - this.prodCodeLo = 0xfe; // http://ftsc.org/docs/fta-1005.003 - this.prodRevLo = 0; - this.baud = 0; - this.packetType = FTN_PACKET_HEADER_TYPE; - this.password = ''; - this.prodData = 0x47694e45; // "ENiG" + // uncommon to set the following explicitly + this.prodCodeLo = 0xfe; // http://ftsc.org/docs/fta-1005.003 + this.prodRevLo = 0; + this.baud = 0; + this.packetType = FTN_PACKET_HEADER_TYPE; + this.password = ''; + this.prodData = 0x47694e45; // "ENiG" - this.capWord = 0x0001; - this.capWordValidate = ((this.capWord & 0xff) << 8) | ((this.capWord >> 8) & 0xff); // swap - - this.prodCodeHi = 0xfe; // see above - this.prodRevHi = 0; - } + this.capWord = 0x0001; + this.capWordValidate = ((this.capWord & 0xff) << 8) | ((this.capWord >> 8) & 0xff); // swap - get origAddress() { - let addr = new Address({ - node : this.origNode, - zone : this.origZone, - }); + this.prodCodeHi = 0xfe; // see above + this.prodRevHi = 0; + } - if(this.origPoint) { - addr.point = this.origPoint; - addr.net = this.auxNet; - } else { - addr.net = this.origNet; - } + get origAddress() { + let addr = new Address({ + node : this.origNode, + zone : this.origZone, + }); - return addr; - } + if(this.origPoint) { + addr.point = this.origPoint; + addr.net = this.auxNet; + } else { + addr.net = this.origNet; + } - set origAddress(address) { - if(_.isString(address)) { - address = Address.fromString(address); - } + return addr; + } - this.origNode = address.node; + set origAddress(address) { + if(_.isString(address)) { + address = Address.fromString(address); + } - // See FSC-48 - // :TODO: disabled for now until we have separate packet writers for 2, 2+, 2+48, and 2.2 - /*if(address.point) { - this.auxNet = address.origNet; - this.origNet = -1; - } else { - this.origNet = address.net; - this.auxNet = 0; - } - */ - this.origNet = address.net; - this.auxNet = 0; + this.origNode = address.node; - this.origZone = address.zone; - this.origZone2 = address.zone; - this.origPoint = address.point || 0; - } + // See FSC-48 + // :TODO: disabled for now until we have separate packet writers for 2, 2+, 2+48, and 2.2 + /*if(address.point) { + this.auxNet = address.origNet; + this.origNet = -1; + } else { + this.origNet = address.net; + this.auxNet = 0; + } + */ + this.origNet = address.net; + this.auxNet = 0; - get destAddress() { - let addr = new Address({ - node : this.destNode, - net : this.destNet, - zone : this.destZone, - }); + this.origZone = address.zone; + this.origZone2 = address.zone; + this.origPoint = address.point || 0; + } - if(this.destPoint) { - addr.point = this.destPoint; - } + get destAddress() { + let addr = new Address({ + node : this.destNode, + net : this.destNet, + zone : this.destZone, + }); - return addr; - } + if(this.destPoint) { + addr.point = this.destPoint; + } - set destAddress(address) { - if(_.isString(address)) { - address = Address.fromString(address); - } + return addr; + } - this.destNode = address.node; - this.destNet = address.net; - this.destZone = address.zone; - this.destZone2 = address.zone; - this.destPoint = address.point || 0; - } + set destAddress(address) { + if(_.isString(address)) { + address = Address.fromString(address); + } - get created() { - return moment({ - year : this.year, - month : this.month - 1, // moment uses 0 indexed months - date : this.day, - hour : this.hour, - minute : this.minute, - second : this.second - }); - } + this.destNode = address.node; + this.destNet = address.net; + this.destZone = address.zone; + this.destZone2 = address.zone; + this.destPoint = address.point || 0; + } - set created(momentCreated) { - if(!moment.isMoment(momentCreated)) { - momentCreated = moment(momentCreated); - } + get created() { + return moment({ + year : this.year, + month : this.month - 1, // moment uses 0 indexed months + date : this.day, + hour : this.hour, + minute : this.minute, + second : this.second + }); + } - this.year = momentCreated.year(); - this.month = momentCreated.month() + 1; // moment uses 0 indexed months - this.day = momentCreated.date(); // day of month - this.hour = momentCreated.hour(); - this.minute = momentCreated.minute(); - this.second = momentCreated.second(); - } + set created(momentCreated) { + if(!moment.isMoment(momentCreated)) { + momentCreated = moment(momentCreated); + } + + this.year = momentCreated.year(); + this.month = momentCreated.month() + 1; // moment uses 0 indexed months + this.day = momentCreated.date(); // day of month + this.hour = momentCreated.hour(); + this.minute = momentCreated.minute(); + this.second = momentCreated.second(); + } } exports.PacketHeader = PacketHeader; // -// Read/Write FTN packets with support for the following formats: +// Read/Write FTN packets with support for the following formats: // -// * Type 2 FTS-0001 @ http://ftsc.org/docs/fts-0001.016 (Obsolete) -// * Type 2.2 FSC-0045 @ http://ftsc.org/docs/fsc-0045.001 -// * Type 2+ FSC-0039 and FSC-0048 @ http://ftsc.org/docs/fsc-0039.004 -// and http://ftsc.org/docs/fsc-0048.002 -// -// Additional resources: -// * Writeup on differences between type 2, 2.2, and 2+: -// http://walon.org/pub/fidonet/FTSC-nodelists-etc./pkt-types.txt +// * Type 2 FTS-0001 @ http://ftsc.org/docs/fts-0001.016 (Obsolete) +// * Type 2.2 FSC-0045 @ http://ftsc.org/docs/fsc-0045.001 +// * Type 2+ FSC-0039 and FSC-0048 @ http://ftsc.org/docs/fsc-0039.004 +// and http://ftsc.org/docs/fsc-0048.002 // +// Additional resources: +// * Writeup on differences between type 2, 2.2, and 2+: +// http://walon.org/pub/fidonet/FTSC-nodelists-etc./pkt-types.txt +// +const PacketHeaderParser = new Parser() + .uint16le('origNode') + .uint16le('destNode') + .uint16le('year') + .uint16le('month') + .uint16le('day') + .uint16le('hour') + .uint16le('minute') + .uint16le('second') + .uint16le('baud') + .uint16le('packetType') + .uint16le('origNet') + .uint16le('destNet') + .int8('prodCodeLo') + .int8('prodRevLo') // aka serialNo + .buffer('password', { length : 8 }) // can't use string; need CP437 - see https://github.com/keichi/binary-parser/issues/33 + .uint16le('origZone') + .uint16le('destZone') + // + // The following is "filler" in FTS-0001, specifics in + // FSC-0045 and FSC-0048 + // + .uint16le('auxNet') + .uint16le('capWordValidate') + .int8('prodCodeHi') + .int8('prodRevHi') + .uint16le('capWord') + .uint16le('origZone2') + .uint16le('destZone2') + .uint16le('origPoint') + .uint16le('destPoint') + .uint32le('prodData'); + +const MessageHeaderParser = new Parser() + .uint16le('messageType') + .uint16le('ftn_msg_orig_node') + .uint16le('ftn_msg_dest_node') + .uint16le('ftn_msg_orig_net') + .uint16le('ftn_msg_dest_net') + .uint16le('ftn_attr_flags') + .uint16le('ftn_cost') + // + // It would be nice to just string() these, but we want CP437 which requires + // iconv. Another option would be to use a formatter, but until issue 33 + // (https://github.com/keichi/binary-parser/issues/33) is fixed, this is cumbersome. + // + .array('modDateTime', { + type : 'uint8', + length : 20, // FTS-0001.016: 20 bytes + }) + .array('toUserName', { + type : 'uint8', + // :TODO: array needs some soft of 'limit' field + readUntil : b => 0x00 === b, + }) + .array('fromUserName', { + type : 'uint8', + readUntil : b => 0x00 === b, + }) + .array('subject', { + type : 'uint8', + readUntil : b => 0x00 === b, + }) + .array('message', { + type : 'uint8', + readUntil : b => 0x00 === b, + }); + function Packet(options) { - var self = this; - - this.options = options || {}; + var self = this; - this.parsePacketHeader = function(packetBuffer, cb) { - assert(Buffer.isBuffer(packetBuffer)); + this.options = options || {}; - if(packetBuffer.length < FTN_PACKET_HEADER_SIZE) { - cb(new Error('Buffer too small')); - return; - } + this.parsePacketHeader = function(packetBuffer, cb) { + assert(Buffer.isBuffer(packetBuffer)); - // - // Start out reading as if this is a FSC-0048 2+ packet - // - binary.parse(packetBuffer) - .word16lu('origNode') - .word16lu('destNode') - .word16lu('year') - .word16lu('month') - .word16lu('day') - .word16lu('hour') - .word16lu('minute') - .word16lu('second') - .word16lu('baud') - .word16lu('packetType') - .word16lu('origNet') - .word16lu('destNet') - .word8('prodCodeLo') - .word8('prodRevLo') // aka serialNo - .buffer('password', 8) // null padded C style string - .word16lu('origZone') - .word16lu('destZone') - // - // The following is "filler" in FTS-0001, specifics in - // FSC-0045 and FSC-0048 - // - .word16lu('auxNet') - .word16lu('capWordValidate') - .word8('prodCodeHi') - .word8('prodRevHi') - .word16lu('capWord') - .word16lu('origZone2') - .word16lu('destZone2') - .word16lu('origPoint') - .word16lu('destPoint') - .word32lu('prodData') - .tap(packetHeader => { - // Convert password from NULL padded array to string - //packetHeader.password = ftn.stringFromFTN(packetHeader.password); - packetHeader.password = strUtil.stringFromNullTermBuffer(packetHeader.password, 'CP437'); + let packetHeader; + try { + packetHeader = PacketHeaderParser.parse(packetBuffer); + } catch(e) { + return Errors.Invalid(`Unable to parse FTN packet header: ${e.message}`); + } - if(FTN_PACKET_HEADER_TYPE !== packetHeader.packetType) { - cb(new Error('Unsupported header type: ' + packetHeader.packetType)); - return; - } + // Convert password from NULL padded array to string + packetHeader.password = strUtil.stringFromNullTermBuffer(packetHeader.password, 'CP437'); - // - // What kind of packet do we really have here? - // - // :TODO: adjust values based on version discovered - if(FTN_PACKET_BAUD_TYPE_2_2 === packetHeader.baud) { - packetHeader.version = '2.2'; + if(FTN_PACKET_HEADER_TYPE !== packetHeader.packetType) { + return cb(Errors.Invalid(`Unsupported FTN packet header type: ${packetHeader.packetType}`)); + } - // See FSC-0045 - packetHeader.origPoint = packetHeader.year; - packetHeader.destPoint = packetHeader.month; + // + // What kind of packet do we really have here? + // + // :TODO: adjust values based on version discovered + if(FTN_PACKET_BAUD_TYPE_2_2 === packetHeader.baud) { + packetHeader.version = '2.2'; - packetHeader.destDomain = packetHeader.origZone2; - packetHeader.origDomain = packetHeader.auxNet; - } else { - // - // See heuristics described in FSC-0048, "Receiving Type-2+ bundles" - // - const capWordValidateSwapped = - ((packetHeader.capWordValidate & 0xff) << 8) | - ((packetHeader.capWordValidate >> 8) & 0xff); + // See FSC-0045 + packetHeader.origPoint = packetHeader.year; + packetHeader.destPoint = packetHeader.month; - if(capWordValidateSwapped === packetHeader.capWord && - 0 != packetHeader.capWord && - packetHeader.capWord & 0x0001) - { - packetHeader.version = '2+'; + packetHeader.destDomain = packetHeader.origZone2; + packetHeader.origDomain = packetHeader.auxNet; + } else { + // + // See heuristics described in FSC-0048, "Receiving Type-2+ bundles" + // + const capWordValidateSwapped = + ((packetHeader.capWordValidate & 0xff) << 8) | + ((packetHeader.capWordValidate >> 8) & 0xff); - // See FSC-0048 - if(-1 === packetHeader.origNet) { - packetHeader.origNet = packetHeader.auxNet; - } - } else { - packetHeader.version = '2'; + if(capWordValidateSwapped === packetHeader.capWord && + 0 != packetHeader.capWord && + packetHeader.capWord & 0x0001) + { + packetHeader.version = '2+'; - // :TODO: should fill bytes be 0? - } - } - - packetHeader.created = moment({ - year : packetHeader.year, - month : packetHeader.month - 1, // moment uses 0 indexed months - date : packetHeader.day, - hour : packetHeader.hour, - minute : packetHeader.minute, - second : packetHeader.second - }); - - let ph = new PacketHeader(); - _.assign(ph, packetHeader); + // See FSC-0048 + if(-1 === packetHeader.origNet) { + packetHeader.origNet = packetHeader.auxNet; + } + } else { + packetHeader.version = '2'; - cb(null, ph); - }); - }; - - this.getPacketHeaderBuffer = function(packetHeader) { - let buffer = new Buffer(FTN_PACKET_HEADER_SIZE); + // :TODO: should fill bytes be 0? + } + } - buffer.writeUInt16LE(packetHeader.origNode, 0); - buffer.writeUInt16LE(packetHeader.destNode, 2); - buffer.writeUInt16LE(packetHeader.year, 4); - buffer.writeUInt16LE(packetHeader.month, 6); - buffer.writeUInt16LE(packetHeader.day, 8); - buffer.writeUInt16LE(packetHeader.hour, 10); - buffer.writeUInt16LE(packetHeader.minute, 12); - buffer.writeUInt16LE(packetHeader.second, 14); - - buffer.writeUInt16LE(packetHeader.baud, 16); - buffer.writeUInt16LE(FTN_PACKET_HEADER_TYPE, 18); - buffer.writeUInt16LE(-1 === packetHeader.origNet ? 0xffff : packetHeader.origNet, 20); - buffer.writeUInt16LE(packetHeader.destNet, 22); - buffer.writeUInt8(packetHeader.prodCodeLo, 24); - buffer.writeUInt8(packetHeader.prodRevHi, 25); - - const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8); - pass.copy(buffer, 26); - - buffer.writeUInt16LE(packetHeader.origZone, 34); - buffer.writeUInt16LE(packetHeader.destZone, 36); - buffer.writeUInt16LE(packetHeader.auxNet, 38); - buffer.writeUInt16LE(packetHeader.capWordValidate, 40); - buffer.writeUInt8(packetHeader.prodCodeHi, 42); - buffer.writeUInt8(packetHeader.prodRevLo, 43); - buffer.writeUInt16LE(packetHeader.capWord, 44); - buffer.writeUInt16LE(packetHeader.origZone2, 46); - buffer.writeUInt16LE(packetHeader.destZone2, 48); - buffer.writeUInt16LE(packetHeader.origPoint, 50); - buffer.writeUInt16LE(packetHeader.destPoint, 52); - buffer.writeUInt32LE(packetHeader.prodData, 54); - - return buffer; - }; + packetHeader.created = moment({ + year : packetHeader.year, + month : packetHeader.month - 1, // moment uses 0 indexed months + date : packetHeader.day, + hour : packetHeader.hour, + minute : packetHeader.minute, + second : packetHeader.second + }); - this.writePacketHeader = function(packetHeader, ws) { - let buffer = new Buffer(FTN_PACKET_HEADER_SIZE); + const ph = new PacketHeader(); + _.assign(ph, packetHeader); - buffer.writeUInt16LE(packetHeader.origNode, 0); - buffer.writeUInt16LE(packetHeader.destNode, 2); - buffer.writeUInt16LE(packetHeader.year, 4); - buffer.writeUInt16LE(packetHeader.month, 6); - buffer.writeUInt16LE(packetHeader.day, 8); - buffer.writeUInt16LE(packetHeader.hour, 10); - buffer.writeUInt16LE(packetHeader.minute, 12); - buffer.writeUInt16LE(packetHeader.second, 14); - - buffer.writeUInt16LE(packetHeader.baud, 16); - buffer.writeUInt16LE(FTN_PACKET_HEADER_TYPE, 18); - buffer.writeUInt16LE(-1 === packetHeader.origNet ? 0xffff : packetHeader.origNet, 20); - buffer.writeUInt16LE(packetHeader.destNet, 22); - buffer.writeUInt8(packetHeader.prodCodeLo, 24); - buffer.writeUInt8(packetHeader.prodRevHi, 25); - - const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8); - pass.copy(buffer, 26); - - buffer.writeUInt16LE(packetHeader.origZone, 34); - buffer.writeUInt16LE(packetHeader.destZone, 36); - buffer.writeUInt16LE(packetHeader.auxNet, 38); - buffer.writeUInt16LE(packetHeader.capWordValidate, 40); - buffer.writeUInt8(packetHeader.prodCodeHi, 42); - buffer.writeUInt8(packetHeader.prodRevLo, 43); - buffer.writeUInt16LE(packetHeader.capWord, 44); - buffer.writeUInt16LE(packetHeader.origZone2, 46); - buffer.writeUInt16LE(packetHeader.destZone2, 48); - buffer.writeUInt16LE(packetHeader.origPoint, 50); - buffer.writeUInt16LE(packetHeader.destPoint, 52); - buffer.writeUInt32LE(packetHeader.prodData, 54); - - ws.write(buffer); - - return buffer.length; - }; + return cb(null, ph); + }; - this.processMessageBody = function(messageBodyBuffer, cb) { - // - // From FTS-0001.16: - // "Message text is unbounded and null terminated (note exception below). - // - // A 'hard' carriage return, 0DH, marks the end of a paragraph, and must - // be preserved. - // - // So called 'soft' carriage returns, 8DH, may mark a previous - // processor's automatic line wrap, and should be ignored. Beware that - // they may be followed by linefeeds, or may not. - // - // All linefeeds, 0AH, should be ignored. Systems which display message - // text should wrap long lines to suit their application." - // - // This can be a bit tricky: - // * Decoding as CP437 converts 0x8d -> 0xec, so we'll need to correct for that - // * Many kludge lines specify an encoding. If we find one of such lines, we'll - // likely need to re-decode as the specified encoding - // * SAUCE is binary-ish data, so we need to inspect for it before any - // decoding occurs - // - let messageBodyData = { - message : [], - kludgeLines : {}, // KLUDGE:[value1, value2, ...] map - seenBy : [], - }; + this.getPacketHeaderBuffer = function(packetHeader) { + let buffer = Buffer.alloc(FTN_PACKET_HEADER_SIZE); - function addKludgeLine(line) { - const sepIndex = line.indexOf(':'); - const key = line.substr(0, sepIndex).toUpperCase(); - const value = line.substr(sepIndex + 1).trim(); + buffer.writeUInt16LE(packetHeader.origNode, 0); + buffer.writeUInt16LE(packetHeader.destNode, 2); + buffer.writeUInt16LE(packetHeader.year, 4); + buffer.writeUInt16LE(packetHeader.month, 6); + buffer.writeUInt16LE(packetHeader.day, 8); + buffer.writeUInt16LE(packetHeader.hour, 10); + buffer.writeUInt16LE(packetHeader.minute, 12); + buffer.writeUInt16LE(packetHeader.second, 14); - // - // Allow mapped value to be either a key:value if there is only - // one entry, or key:[value1, value2,...] if there are more - // - if(messageBodyData.kludgeLines[key]) { - if(!_.isArray(messageBodyData.kludgeLines[key])) { - messageBodyData.kludgeLines[key] = [ messageBodyData.kludgeLines[key] ]; - } - messageBodyData.kludgeLines[key].push(value); - } else { - messageBodyData.kludgeLines[key] = value; - } - } - - let encoding = 'cp437'; + buffer.writeUInt16LE(packetHeader.baud, 16); + buffer.writeUInt16LE(FTN_PACKET_HEADER_TYPE, 18); + buffer.writeUInt16LE(-1 === packetHeader.origNet ? 0xffff : packetHeader.origNet, 20); + buffer.writeUInt16LE(packetHeader.destNet, 22); + buffer.writeUInt8(packetHeader.prodCodeLo, 24); + buffer.writeUInt8(packetHeader.prodRevHi, 25); - async.series( - [ - function extractSauce(callback) { - // :TODO: This is wrong: SAUCE may not have EOF marker for one, also if it's - // present, we need to extract it but keep the rest of hte message intact as it likely - // has SEEN-BY, PATH, and other kludge information *appended* - const sauceHeaderPosition = messageBodyBuffer.indexOf(FTN_MESSAGE_SAUCE_HEADER); - if(sauceHeaderPosition > -1) { - sauce.readSAUCE(messageBodyBuffer.slice(sauceHeaderPosition, sauceHeaderPosition + sauce.SAUCE_SIZE), (err, theSauce) => { - if(!err) { - // we read some SAUCE - don't re-process that portion into the body - messageBodyBuffer = messageBodyBuffer.slice(0, sauceHeaderPosition) + messageBodyBuffer.slice(sauceHeaderPosition + sauce.SAUCE_SIZE); -// messageBodyBuffer = messageBodyBuffer.slice(0, sauceHeaderPosition); - messageBodyData.sauce = theSauce; - } else { - console.log(err) - } - callback(null); // failure to read SAUCE is OK - }); - } else { - callback(null); - } - }, - function extractChrsAndDetermineEncoding(callback) { - // - // From FTS-5003.001: - // "The CHRS control line is formatted as follows: - // - // ^ACHRS: - // - // Where is a character string of no more than eight (8) - // ASCII characters identifying the character set or character encoding - // scheme used, and level is a positive integer value describing what - // level of CHRS the message is written in." - // - // Also according to the spec, the deprecated "CHARSET" value may be used - // :TODO: Look into CHARSET more - should we bother supporting it? - // :TODO: See encodingFromHeader() for CHRS/CHARSET support @ https://github.com/Mithgol/node-fidonet-jam - const FTN_CHRS_PREFIX = new Buffer( [ 0x01, 0x43, 0x48, 0x52, 0x53, 0x3a, 0x20 ] ); // "\x01CHRS:" - const FTN_CHRS_SUFFIX = new Buffer( [ 0x0d ] ); - binary.parse(messageBodyBuffer) - .scan('prefix', FTN_CHRS_PREFIX) - .scan('content', FTN_CHRS_SUFFIX) - .tap(chrsData => { - if(chrsData.prefix && chrsData.content && chrsData.content.length > 0) { - const chrs = iconv.decode(chrsData.content, 'CP437'); - const chrsEncoding = ftn.getEncodingFromCharacterSetIdentifier(chrs); - if(chrsEncoding) { - encoding = chrsEncoding; - } - callback(null); - } else { - callback(null); - } - }); - }, - function extractMessageData(callback) { - // - // Decode |messageBodyBuffer| using |encoding| defaulted or detected above - // - // :TODO: Look into \xec thing more - document - let decoded; - try { - decoded = iconv.decode(messageBodyBuffer, encoding); - } catch(e) { - Log.debug( { encoding : encoding, error : e.toString() }, 'Error decoding. Falling back to ASCII'); - decoded = iconv.decode(messageBodyBuffer, 'ascii'); - } - - const messageLines = strUtil.splitTextAtTerms(decoded.replace(/\xec/g, '')); - let endOfMessage = false; + const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8); + pass.copy(buffer, 26); - messageLines.forEach(line => { - if(0 === line.length) { - messageBodyData.message.push(''); - return; - } - - if(line.startsWith('AREA:')) { - messageBodyData.area = line.substring(line.indexOf(':') + 1).trim(); - } else if(line.startsWith('--- ')) { - // Tear Lines are tracked allowing for specialized display/etc. - messageBodyData.tearLine = line; - } else if(/^[ ]{1,2}\* Origin\: /.test(line)) { // To spec is " * Origin: ..." - messageBodyData.originLine = line; - endOfMessage = true; // Anything past origin is not part of the message body - } else if(line.startsWith('SEEN-BY:')) { - endOfMessage = true; // Anything past the first SEEN-BY is not part of the message body - messageBodyData.seenBy.push(line.substring(line.indexOf(':') + 1).trim()); - } else if(FTN_MESSAGE_KLUDGE_PREFIX === line.charAt(0)) { - if('PATH:' === line.slice(1, 6)) { - endOfMessage = true; // Anything pats the first PATH is not part of the message body - } - addKludgeLine(line.slice(1)); - } else if(!endOfMessage) { - // regular ol' message line - messageBodyData.message.push(line); - } - }); + buffer.writeUInt16LE(packetHeader.origZone, 34); + buffer.writeUInt16LE(packetHeader.destZone, 36); + buffer.writeUInt16LE(packetHeader.auxNet, 38); + buffer.writeUInt16LE(packetHeader.capWordValidate, 40); + buffer.writeUInt8(packetHeader.prodCodeHi, 42); + buffer.writeUInt8(packetHeader.prodRevLo, 43); + buffer.writeUInt16LE(packetHeader.capWord, 44); + buffer.writeUInt16LE(packetHeader.origZone2, 46); + buffer.writeUInt16LE(packetHeader.destZone2, 48); + buffer.writeUInt16LE(packetHeader.origPoint, 50); + buffer.writeUInt16LE(packetHeader.destPoint, 52); + buffer.writeUInt32LE(packetHeader.prodData, 54); - return callback(null); - } - ], - () => { - messageBodyData.message = messageBodyData.message.join('\n'); - return cb(messageBodyData); - } - ); - }; - - this.parsePacketMessages = function(packetBuffer, iterator, cb) { - binary.parse(packetBuffer) - .word16lu('messageType') - .word16lu('ftn_orig_node') - .word16lu('ftn_dest_node') - .word16lu('ftn_orig_network') - .word16lu('ftn_dest_network') - .word16lu('ftn_attr_flags') - .word16lu('ftn_cost') - .scan('modDateTime', NULL_TERM_BUFFER) // :TODO: 20 bytes max - .scan('toUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max - .scan('fromUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max - .scan('subject', NULL_TERM_BUFFER) // :TODO: 72 bytes max6 - .scan('message', NULL_TERM_BUFFER) - .tap(function tapped(msgData) { // no arrow function; want classic this - if(!msgData.messageType) { - // end marker -- no more messages - return cb(null); - } - - if(FTN_PACKET_MESSAGE_TYPE != msgData.messageType) { - return cb(new Error('Unsupported message type: ' + msgData.messageType)); - } - - const read = - 14 + // fixed header size - msgData.modDateTime.length + 1 + - msgData.toUserName.length + 1 + - msgData.fromUserName.length + 1 + - msgData.subject.length + 1 + - msgData.message.length + 1; - - // - // Convert null terminated arrays to strings - // - let convMsgData = {}; - [ 'modDateTime', 'toUserName', 'fromUserName', 'subject' ].forEach(k => { - convMsgData[k] = iconv.decode(msgData[k], 'CP437'); - }); + return buffer; + }; - // - // The message body itself is a special beast as it may - // contain an origin line, kludges, SAUCE in the case - // of ANSI files, etc. - // - let msg = new Message( { - toUserName : convMsgData.toUserName, - fromUserName : convMsgData.fromUserName, - subject : convMsgData.subject, - modTimestamp : ftn.getDateFromFtnDateTime(convMsgData.modDateTime), - }); - - msg.meta.FtnProperty = {}; - msg.meta.FtnProperty.ftn_orig_node = msgData.ftn_orig_node; - msg.meta.FtnProperty.ftn_dest_node = msgData.ftn_dest_node; - msg.meta.FtnProperty.ftn_orig_network = msgData.ftn_orig_network; - msg.meta.FtnProperty.ftn_dest_network = msgData.ftn_dest_network; - msg.meta.FtnProperty.ftn_attr_flags = msgData.ftn_attr_flags; - msg.meta.FtnProperty.ftn_cost = msgData.ftn_cost; + this.writePacketHeader = function(packetHeader, ws) { + let buffer = Buffer.alloc(FTN_PACKET_HEADER_SIZE); - self.processMessageBody(msgData.message, messageBodyData => { - msg.message = messageBodyData.message; - msg.meta.FtnKludge = messageBodyData.kludgeLines; - - if(messageBodyData.tearLine) { - msg.meta.FtnProperty.ftn_tear_line = messageBodyData.tearLine; - - if(self.options.keepTearAndOrigin) { - msg.message += `\r\n${messageBodyData.tearLine}\r\n`; - } - } - - if(messageBodyData.seenBy.length > 0) { - msg.meta.FtnProperty.ftn_seen_by = messageBodyData.seenBy; - } - - if(messageBodyData.area) { - msg.meta.FtnProperty.ftn_area = messageBodyData.area; - } - - if(messageBodyData.originLine) { - msg.meta.FtnProperty.ftn_origin = messageBodyData.originLine; - - if(self.options.keepTearAndOrigin) { - msg.message += `${messageBodyData.originLine}\r\n`; - } - } - - // - // If we have a UTC offset kludge (e.g. TZUTC) then update - // modDateTime with it - // - if(_.isString(msg.meta.FtnKludge.TZUTC) && msg.meta.FtnKludge.TZUTC.length > 0) { - msg.modDateTime = msg.modTimestamp.utcOffset(msg.meta.FtnKludge.TZUTC); - } - - const nextBuf = packetBuffer.slice(read); - if(nextBuf.length > 0) { - let next = function(e) { - if(e) { - cb(e); - } else { - self.parsePacketMessages(nextBuf, iterator, cb); - } - }; - - iterator('message', msg, next); - } else { - cb(null); - } - }); - }); - }; - - this.getMessageEntryBuffer = function(message, options, cb) { - - function getAppendMeta(k, m) { - let append = ''; - if(m) { - let a = m; - if(!_.isArray(a)) { - a = [ a ]; - } - a.forEach(v => { - append += `${k}: ${v}\r`; - }); - } - return append; - } + buffer.writeUInt16LE(packetHeader.origNode, 0); + buffer.writeUInt16LE(packetHeader.destNode, 2); + buffer.writeUInt16LE(packetHeader.year, 4); + buffer.writeUInt16LE(packetHeader.month, 6); + buffer.writeUInt16LE(packetHeader.day, 8); + buffer.writeUInt16LE(packetHeader.hour, 10); + buffer.writeUInt16LE(packetHeader.minute, 12); + buffer.writeUInt16LE(packetHeader.second, 14); - async.waterfall( - [ - function prepareHeaderAndKludges(callback) { - const basicHeader = new Buffer(34); - - basicHeader.writeUInt16LE(FTN_PACKET_MESSAGE_TYPE, 0); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_node, 2); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_node, 4); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_network, 6); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_network, 8); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_attr_flags, 10); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12); + buffer.writeUInt16LE(packetHeader.baud, 16); + buffer.writeUInt16LE(FTN_PACKET_HEADER_TYPE, 18); + buffer.writeUInt16LE(-1 === packetHeader.origNet ? 0xffff : packetHeader.origNet, 20); + buffer.writeUInt16LE(packetHeader.destNet, 22); + buffer.writeUInt8(packetHeader.prodCodeLo, 24); + buffer.writeUInt8(packetHeader.prodRevHi, 25); - const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0'); - dateTimeBuffer.copy(basicHeader, 14); + const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8); + pass.copy(buffer, 26); - // - // To, from, and subject must be NULL term'd and have max lengths as per spec. - // - const toUserNameBuf = strUtil.stringToNullTermBuffer(message.toUserName, { encoding : 'cp437', maxBufLen : 36 } ); - const fromUserNameBuf = strUtil.stringToNullTermBuffer(message.fromUserName, { encoding : 'cp437', maxBufLen : 36 } ); - const subjectBuf = strUtil.stringToNullTermBuffer(message.subject, { encoding : 'cp437', maxBufLen : 72 } ); + buffer.writeUInt16LE(packetHeader.origZone, 34); + buffer.writeUInt16LE(packetHeader.destZone, 36); + buffer.writeUInt16LE(packetHeader.auxNet, 38); + buffer.writeUInt16LE(packetHeader.capWordValidate, 40); + buffer.writeUInt8(packetHeader.prodCodeHi, 42); + buffer.writeUInt8(packetHeader.prodRevLo, 43); + buffer.writeUInt16LE(packetHeader.capWord, 44); + buffer.writeUInt16LE(packetHeader.origZone2, 46); + buffer.writeUInt16LE(packetHeader.destZone2, 48); + buffer.writeUInt16LE(packetHeader.origPoint, 50); + buffer.writeUInt16LE(packetHeader.destPoint, 52); + buffer.writeUInt32LE(packetHeader.prodData, 54); - // - // message: unbound length, NULL term'd - // - // We need to build in various special lines - kludges, area, - // seen-by, etc. - // - let msgBody = ''; + ws.write(buffer); - // - // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 - // AREA:CONFERENCE - // Should be first line in a message - // - if(message.meta.FtnProperty.ftn_area) { - msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) - } - - Object.keys(message.meta.FtnKludge).forEach(k => { - // we want PATH to be last - if('PATH' !== k) { - msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]); - } - }); + return buffer.length; + }; - return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody); - }, - function prepareAnsiMessageBody(basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, callback) { - if(!strUtil.isAnsi(message.message)) { - return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, message.message); - } + this.processMessageBody = function(messageBodyBuffer, cb) { + // + // From FTS-0001.16: + // "Message text is unbounded and null terminated (note exception below). + // + // A 'hard' carriage return, 0DH, marks the end of a paragraph, and must + // be preserved. + // + // So called 'soft' carriage returns, 8DH, may mark a previous + // processor's automatic line wrap, and should be ignored. Beware that + // they may be followed by linefeeds, or may not. + // + // All linefeeds, 0AH, should be ignored. Systems which display message + // text should wrap long lines to suit their application." + // + // This can be a bit tricky: + // * Decoding as CP437 converts 0x8d -> 0xec, so we'll need to correct for that + // * Many kludge lines specify an encoding. If we find one of such lines, we'll + // likely need to re-decode as the specified encoding + // * SAUCE is binary-ish data, so we need to inspect for it before any + // decoding occurs + // + let messageBodyData = { + message : [], + kludgeLines : {}, // KLUDGE:[value1, value2, ...] map + seenBy : [], + }; - ansiPrep( - message.message, - { - cols : 80, - rows : 'auto', - forceLineTerm : true, - exportMode : true, - }, - (err, preppedMsg) => { - return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, preppedMsg || message.message); - } - ); - }, - function addMessageBody(basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, preppedMsg, callback) { - msgBody += preppedMsg + '\r'; + function addKludgeLine(line) { + // + // We have to special case INTL/TOPT/FMPT as they don't contain + // a ':' name/value separator like the rest of the kludge lines... because stupdity. + // + let key = line.substr(0, 4).trim(); + let value; + if( ['INTL', 'TOPT', 'FMPT', 'Via' ].includes(key)) { + value = line.substr(key.length).trim(); + } else { + const sepIndex = line.indexOf(':'); + key = line.substr(0, sepIndex).toUpperCase(); + value = line.substr(sepIndex + 1).trim(); + } - // - // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 - // Tear line should be near the bottom of a message - // - if(message.meta.FtnProperty.ftn_tear_line) { - msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`; - } + // + // Allow mapped value to be either a key:value if there is only + // one entry, or key:[value1, value2,...] if there are more + // + if(messageBodyData.kludgeLines[key]) { + if(!_.isArray(messageBodyData.kludgeLines[key])) { + messageBodyData.kludgeLines[key] = [ messageBodyData.kludgeLines[key] ]; + } + messageBodyData.kludgeLines[key].push(value); + } else { + messageBodyData.kludgeLines[key] = value; + } + } - // - // Origin line should be near the bottom of a message - // - if(message.meta.FtnProperty.ftn_origin) { - msgBody += `${message.meta.FtnProperty.ftn_origin}\r`; - } + let encoding = 'cp437'; - // - // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 - // SEEN-BY and PATH should be the last lines of a message - // - msgBody += getAppendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01) - msgBody += getAppendMeta('\x01PATH', message.meta.FtnKludge['PATH']); - - let msgBodyEncoded; - try { - msgBodyEncoded = iconv.encode(msgBody + '\0', options.encoding); - } catch(e) { - msgBodyEncoded = iconv.encode(msgBody + '\0', 'ascii'); - } - - return callback( - null, - Buffer.concat( [ - basicHeader, - toUserNameBuf, - fromUserNameBuf, - subjectBuf, - msgBodyEncoded - ]) - ); - } - ], - (err, msgEntryBuffer) => { - return cb(err, msgEntryBuffer); - } - ); - }; + async.series( + [ + function extractSauce(callback) { + // :TODO: This is wrong: SAUCE may not have EOF marker for one, also if it's + // present, we need to extract it but keep the rest of hte message intact as it likely + // has SEEN-BY, PATH, and other kludge information *appended* + const sauceHeaderPosition = messageBodyBuffer.indexOf(FTN_MESSAGE_SAUCE_HEADER); + if(sauceHeaderPosition > -1) { + sauce.readSAUCE(messageBodyBuffer.slice(sauceHeaderPosition, sauceHeaderPosition + sauce.SAUCE_SIZE), (err, theSauce) => { + if(!err) { + // we read some SAUCE - don't re-process that portion into the body + messageBodyBuffer = messageBodyBuffer.slice(0, sauceHeaderPosition) + messageBodyBuffer.slice(sauceHeaderPosition + sauce.SAUCE_SIZE); + // messageBodyBuffer = messageBodyBuffer.slice(0, sauceHeaderPosition); + messageBodyData.sauce = theSauce; + } else { + Log.warn( { error : err.message }, 'Found what looks like to be a SAUCE record, but failed to read'); + } + return callback(null); // failure to read SAUCE is OK + }); + } else { + callback(null); + } + }, + function extractChrsAndDetermineEncoding(callback) { + // + // From FTS-5003.001: + // "The CHRS control line is formatted as follows: + // + // ^ACHRS: + // + // Where is a character string of no more than eight (8) + // ASCII characters identifying the character set or character encoding + // scheme used, and level is a positive integer value describing what + // level of CHRS the message is written in." + // + // Also according to the spec, the deprecated "CHARSET" value may be used + // :TODO: Look into CHARSET more - should we bother supporting it? + // :TODO: See encodingFromHeader() for CHRS/CHARSET support @ https://github.com/Mithgol/node-fidonet-jam + const FTN_CHRS_PREFIX = Buffer.from( [ 0x01, 0x43, 0x48, 0x52, 0x53, 0x3a, 0x20 ] ); // "\x01CHRS:" + const FTN_CHRS_SUFFIX = Buffer.from( [ 0x0d ] ); - this.writeMessage = function(message, ws, options) { - let basicHeader = new Buffer(34); - - basicHeader.writeUInt16LE(FTN_PACKET_MESSAGE_TYPE, 0); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_node, 2); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_node, 4); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_network, 6); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_network, 8); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_attr_flags, 10); - basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12); + let chrsPrefixIndex = messageBodyBuffer.indexOf(FTN_CHRS_PREFIX); + if(chrsPrefixIndex < 0) { + return callback(null); + } - const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0'); - dateTimeBuffer.copy(basicHeader, 14); + chrsPrefixIndex += FTN_CHRS_PREFIX.length; - ws.write(basicHeader); + const chrsEndIndex = messageBodyBuffer.indexOf(FTN_CHRS_SUFFIX, chrsPrefixIndex); + if(chrsEndIndex < 0) { + return callback(null); + } - // toUserName & fromUserName: up to 36 bytes in length, NULL term'd - // :TODO: DRY... - let encBuf = iconv.encode(message.toUserName + '\0', 'CP437').slice(0, 36); - encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd - ws.write(encBuf); - - encBuf = iconv.encode(message.fromUserName + '\0', 'CP437').slice(0, 36); - encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd - ws.write(encBuf); + let chrsContent = messageBodyBuffer.slice(chrsPrefixIndex, chrsEndIndex); + if(0 === chrsContent.length) { + return callback(null); + } - // subject: up to 72 bytes in length, NULL term'd - encBuf = iconv.encode(message.subject + '\0', 'CP437').slice(0, 72); - encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd - ws.write(encBuf); + chrsContent = iconv.decode(chrsContent, 'CP437'); + const chrsEncoding = ftn.getEncodingFromCharacterSetIdentifier(chrsContent); + if(chrsEncoding) { + encoding = chrsEncoding; + } + return callback(null); + }, + function extractMessageData(callback) { + // + // Decode |messageBodyBuffer| using |encoding| defaulted or detected above + // + // :TODO: Look into \xec thing more - document + let decoded; + try { + decoded = iconv.decode(messageBodyBuffer, encoding); + } catch(e) { + Log.debug( { encoding : encoding, error : e.toString() }, 'Error decoding. Falling back to ASCII'); + decoded = iconv.decode(messageBodyBuffer, 'ascii'); + } - // - // message: unbound length, NULL term'd - // - // We need to build in various special lines - kludges, area, - // seen-by, etc. - // - // :TODO: Put this in it's own method - let msgBody = ''; + const messageLines = strUtil.splitTextAtTerms(decoded.replace(/\xec/g, '')); + let endOfMessage = false; - function appendMeta(k, m) { - if(m) { - let a = m; - if(!_.isArray(a)) { - a = [ a ]; - } - a.forEach(v => { - msgBody += `${k}: ${v}\r`; - }); - } - } + messageLines.forEach(line => { + if(0 === line.length) { + messageBodyData.message.push(''); + return; + } - // - // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 - // AREA:CONFERENCE - // Should be first line in a message - // - if(message.meta.FtnProperty.ftn_area) { - msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) - } - - Object.keys(message.meta.FtnKludge).forEach(k => { - // we want PATH to be last - if('PATH' !== k) { - appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); - } - }); + if(line.startsWith('AREA:')) { + messageBodyData.area = line.substring(line.indexOf(':') + 1).trim(); + } else if(line.startsWith('--- ')) { + // Tear Lines are tracked allowing for specialized display/etc. + messageBodyData.tearLine = line; + } else if(/^[ ]{1,2}\* Origin: /.test(line)) { // To spec is " * Origin: ..." + messageBodyData.originLine = line; + endOfMessage = true; // Anything past origin is not part of the message body + } else if(line.startsWith('SEEN-BY:')) { + endOfMessage = true; // Anything past the first SEEN-BY is not part of the message body + messageBodyData.seenBy.push(line.substring(line.indexOf(':') + 1).trim()); + } else if(FTN_MESSAGE_KLUDGE_PREFIX === line.charAt(0)) { + if('PATH:' === line.slice(1, 6)) { + endOfMessage = true; // Anything pats the first PATH is not part of the message body + } + addKludgeLine(line.slice(1)); + } else if(!endOfMessage) { + // regular ol' message line + messageBodyData.message.push(line); + } + }); - msgBody += message.message + '\r'; + return callback(null); + } + ], + () => { + messageBodyData.message = messageBodyData.message.join('\n'); + return cb(messageBodyData); + } + ); + }; - // - // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 - // Tear line should be near the bottom of a message - // - if(message.meta.FtnProperty.ftn_tear_line) { - msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`; - } - - // - // Origin line should be near the bottom of a message - // - if(message.meta.FtnProperty.ftn_origin) { - msgBody += `${message.meta.FtnProperty.ftn_origin}\r`; - } + this.parsePacketMessages = function(header, packetBuffer, iterator, cb) { + // + // Check for end-of-messages marker up front before parse so we can easily + // tell the difference between end and bad header + // + if(packetBuffer.length < 3) { + const peek = packetBuffer.slice(0, 2); + if(peek.equals(Buffer.from([ 0x00 ])) || peek.equals(Buffer.from( [ 0x00, 0x00 ]))) { + // end marker - no more messages + return cb(null); + } + // else fall through & hit exception below to log error + } - // - // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 - // SEEN-BY and PATH should be the last lines of a message - // - appendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01) + let msgData; + try { + msgData = MessageHeaderParser.parse(packetBuffer); + } catch(e) { + return cb(Errors.Invalid(`Failed to parse FTN message header: ${e.message}`)); + } - appendMeta('\x01PATH', message.meta.FtnKludge['PATH']); + if(FTN_PACKET_MESSAGE_TYPE != msgData.messageType) { + return cb(Errors.Invalid(`Unsupported FTN message type: ${msgData.messageType}`)); + } - // - // :TODO: We should encode based on config and add the proper kludge here! - ws.write(iconv.encode(msgBody + '\0', options.encoding)); - }; + // + // Convert null terminated arrays to strings + // + // From FTS-0001.016: + // * modDateTime: 20 bytes exactly (see above) + // * toUserName and fromUserName: *max* 36 bytes, aka "up to"; null terminated + // * subject: *max* 72 bytes, aka "up to"; null terminated + // * message: Unbounded & null terminated + // + // For everything above but message, we can get away with assuming CP437 + // and probably even just "ascii" for most cases. The message field is + // much more complex so we'll look for encoding kludges, detection, etc. + // later on. + // + if(msgData.modDateTime.length != 20) { + return cb(Errors.Invalid(`FTN packet DateTime field must be 20 bytes (got ${msgData.modDateTime.length})`)); + } + if(msgData.toUserName.length > 36) { + return cb(Errors.Invalid(`FTN packet toUserName field must be 36 bytes max (got ${msgData.toUserName.length})`)); + } + if(msgData.fromUserName.length > 36) { + return cb(Errors.Invalid(`FTN packet fromUserName field must be 36 bytes max (got ${msgData.fromUserName.length})`)); + } + if(msgData.subject.length > 72) { + return cb(Errors.Invalid(`FTN packet subject field must be 72 bytes max (got ${msgData.subject.length})`)); + } - this.parsePacketBuffer = function(packetBuffer, iterator, cb) { - async.series( - [ - function processHeader(callback) { - self.parsePacketHeader(packetBuffer, (err, header) => { - if(err) { - return callback(err); - } - - let next = function(e) { - callback(e); - }; - - iterator('header', header, next); - }); - }, - function processMessages(callback) { - self.parsePacketMessages( - packetBuffer.slice(FTN_PACKET_HEADER_SIZE), - iterator, - callback); - } - ], - cb // complete - ); - }; + // Arrays of CP437 bytes -> String + [ 'modDateTime', 'toUserName', 'fromUserName', 'subject' ].forEach(k => { + msgData[k] = strUtil.stringFromNullTermBuffer(msgData[k], 'CP437'); + }); + + // + // The message body itself is a special beast as it may + // contain an origin line, kludges, SAUCE in the case + // of ANSI files, etc. + // + const msg = new Message( { + toUserName : msgData.toUserName, + fromUserName : msgData.fromUserName, + subject : msgData.subject, + modTimestamp : ftn.getDateFromFtnDateTime(msgData.modDateTime), + }); + + // :TODO: When non-private (e.g. EchoMail), attempt to extract SRC from MSGID vs headers, when avail (or Orgin line? research further) + msg.meta.FtnProperty = { + ftn_orig_node : header.origNode, + ftn_dest_node : header.destNode, + ftn_orig_network : header.origNet, + ftn_dest_network : header.destNet, + + ftn_attr_flags : msgData.ftn_attr_flags, + ftn_cost : msgData.ftn_cost, + + ftn_msg_orig_node : msgData.ftn_msg_orig_node, + ftn_msg_dest_node : msgData.ftn_msg_dest_node, + ftn_msg_orig_net : msgData.ftn_msg_orig_net, + ftn_msg_dest_net : msgData.ftn_msg_dest_net, + }; + + self.processMessageBody(msgData.message, messageBodyData => { + msg.message = messageBodyData.message; + msg.meta.FtnKludge = messageBodyData.kludgeLines; + + if(messageBodyData.tearLine) { + msg.meta.FtnProperty.ftn_tear_line = messageBodyData.tearLine; + + if(self.options.keepTearAndOrigin) { + msg.message += `\r\n${messageBodyData.tearLine}\r\n`; + } + } + + if(messageBodyData.seenBy.length > 0) { + msg.meta.FtnProperty.ftn_seen_by = messageBodyData.seenBy; + } + + if(messageBodyData.area) { + msg.meta.FtnProperty.ftn_area = messageBodyData.area; + } + + if(messageBodyData.originLine) { + msg.meta.FtnProperty.ftn_origin = messageBodyData.originLine; + + if(self.options.keepTearAndOrigin) { + msg.message += `${messageBodyData.originLine}\r\n`; + } + } + + // + // If we have a UTC offset kludge (e.g. TZUTC) then update + // modDateTime with it + // + if(_.isString(msg.meta.FtnKludge.TZUTC) && msg.meta.FtnKludge.TZUTC.length > 0) { + msg.modDateTime = msg.modTimestamp.utcOffset(msg.meta.FtnKludge.TZUTC); + } + + // :TODO: Parser should give is this info: + const bytesRead = + 14 + // fixed header size + msgData.modDateTime.length + 1 + // +1 = NULL + msgData.toUserName.length + 1 + // +1 = NULL + msgData.fromUserName.length + 1 + // +1 = NULL + msgData.subject.length + 1 + // +1 = NULL + msgData.message.length; // includes NULL + + const nextBuf = packetBuffer.slice(bytesRead); + if(nextBuf.length > 0) { + const next = function(e) { + if(e) { + cb(e); + } else { + self.parsePacketMessages(header, nextBuf, iterator, cb); + } + }; + + iterator('message', msg, next); + } else { + cb(null); + } + }); + }; + + this.sanatizeFtnProperties = function(message) { + [ + Message.FtnPropertyNames.FtnOrigNode, + Message.FtnPropertyNames.FtnDestNode, + Message.FtnPropertyNames.FtnOrigNetwork, + Message.FtnPropertyNames.FtnDestNetwork, + Message.FtnPropertyNames.FtnAttrFlags, + Message.FtnPropertyNames.FtnCost, + Message.FtnPropertyNames.FtnOrigZone, + Message.FtnPropertyNames.FtnDestZone, + Message.FtnPropertyNames.FtnOrigPoint, + Message.FtnPropertyNames.FtnDestPoint, + Message.FtnPropertyNames.FtnAttribute, + Message.FtnPropertyNames.FtnMsgOrigNode, + Message.FtnPropertyNames.FtnMsgDestNode, + Message.FtnPropertyNames.FtnMsgOrigNet, + Message.FtnPropertyNames.FtnMsgDestNet, + ].forEach( propName => { + if(message.meta.FtnProperty[propName]) { + message.meta.FtnProperty[propName] = parseInt(message.meta.FtnProperty[propName]) || 0; + } + }); + }; + + this.writeMessageHeader = function(message, buf) { + // ensure address FtnProperties are numbers + self.sanatizeFtnProperties(message); + + const destNode = message.meta.FtnProperty.ftn_msg_dest_node || message.meta.FtnProperty.ftn_dest_node; + const destNet = message.meta.FtnProperty.ftn_msg_dest_net || message.meta.FtnProperty.ftn_dest_network; + + buf.writeUInt16LE(FTN_PACKET_MESSAGE_TYPE, 0); + buf.writeUInt16LE(message.meta.FtnProperty.ftn_orig_node, 2); + buf.writeUInt16LE(destNode, 4); + buf.writeUInt16LE(message.meta.FtnProperty.ftn_orig_network, 6); + buf.writeUInt16LE(destNet, 8); + buf.writeUInt16LE(message.meta.FtnProperty.ftn_attr_flags, 10); + buf.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12); + + const dateTimeBuffer = Buffer.from(ftn.getDateTimeString(message.modTimestamp) + '\0'); + dateTimeBuffer.copy(buf, 14); + }; + + this.getMessageEntryBuffer = function(message, options, cb) { + + function getAppendMeta(k, m, sepChar=':') { + let append = ''; + if(m) { + let a = m; + if(!_.isArray(a)) { + a = [ a ]; + } + a.forEach(v => { + append += `${k}${sepChar} ${v}\r`; + }); + } + return append; + } + + async.waterfall( + [ + function prepareHeaderAndKludges(callback) { + const basicHeader = Buffer.alloc(34); + self.writeMessageHeader(message, basicHeader); + + // + // To, from, and subject must be NULL term'd and have max lengths as per spec. + // + const toUserNameBuf = strUtil.stringToNullTermBuffer(message.toUserName, { encoding : 'cp437', maxBufLen : 36 } ); + const fromUserNameBuf = strUtil.stringToNullTermBuffer(message.fromUserName, { encoding : 'cp437', maxBufLen : 36 } ); + const subjectBuf = strUtil.stringToNullTermBuffer(message.subject, { encoding : 'cp437', maxBufLen : 72 } ); + + // + // message: unbound length, NULL term'd + // + // We need to build in various special lines - kludges, area, + // seen-by, etc. + // + let msgBody = ''; + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // AREA:CONFERENCE + // Should be first line in a message + // + if(message.meta.FtnProperty.ftn_area) { + msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) + } + + // :TODO: DRY with similar function in this file! + Object.keys(message.meta.FtnKludge).forEach(k => { + switch(k) { + case 'PATH' : + break; // skip & save for last + + case 'Via' : + case 'FMPT' : + case 'TOPT' : + case 'INTL' : + msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); // no sepChar + break; + + default : + msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + break; + } + }); + + return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody); + }, + function prepareAnsiMessageBody(basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, callback) { + if(!strUtil.isAnsi(message.message)) { + return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, message.message); + } + + ansiPrep( + message.message, + { + cols : 80, + rows : 'auto', + forceLineTerm : true, + exportMode : true, + }, + (err, preppedMsg) => { + return callback(null, basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, preppedMsg || message.message); + } + ); + }, + function addMessageBody(basicHeader, toUserNameBuf, fromUserNameBuf, subjectBuf, msgBody, preppedMsg, callback) { + msgBody += preppedMsg + '\r'; + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // Tear line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_tear_line) { + msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`; + } + + // + // Origin line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_origin) { + msgBody += `${message.meta.FtnProperty.ftn_origin}\r`; + } + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // SEEN-BY and PATH should be the last lines of a message + // + msgBody += getAppendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01) + msgBody += getAppendMeta('\x01PATH', message.meta.FtnKludge['PATH']); + + let msgBodyEncoded; + try { + msgBodyEncoded = iconv.encode(msgBody + '\0', options.encoding); + } catch(e) { + msgBodyEncoded = iconv.encode(msgBody + '\0', 'ascii'); + } + + return callback( + null, + Buffer.concat( [ + basicHeader, + toUserNameBuf, + fromUserNameBuf, + subjectBuf, + msgBodyEncoded + ]) + ); + } + ], + (err, msgEntryBuffer) => { + return cb(err, msgEntryBuffer); + } + ); + }; + + this.writeMessage = function(message, ws, options) { + const basicHeader = Buffer.alloc(34); + self.writeMessageHeader(message, basicHeader); + + ws.write(basicHeader); + + // toUserName & fromUserName: up to 36 bytes in length, NULL term'd + // :TODO: DRY... + let encBuf = iconv.encode(message.toUserName + '\0', 'CP437').slice(0, 36); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + encBuf = iconv.encode(message.fromUserName + '\0', 'CP437').slice(0, 36); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + // subject: up to 72 bytes in length, NULL term'd + encBuf = iconv.encode(message.subject + '\0', 'CP437').slice(0, 72); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + // + // message: unbound length, NULL term'd + // + // We need to build in various special lines - kludges, area, + // seen-by, etc. + // + // :TODO: Put this in it's own method + let msgBody = ''; + + function appendMeta(k, m, sepChar=':') { + if(m) { + let a = m; + if(!_.isArray(a)) { + a = [ a ]; + } + a.forEach(v => { + msgBody += `${k}${sepChar} ${v}\r`; + }); + } + } + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // AREA:CONFERENCE + // Should be first line in a message + // + if(message.meta.FtnProperty.ftn_area) { + msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) + } + + Object.keys(message.meta.FtnKludge).forEach(k => { + switch(k) { + case 'PATH' : break; // skip & save for last + + case 'Via' : + case 'FMPT' : + case 'TOPT' : + case 'INTL' : appendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); break; // no sepChar + + default : appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); break; + } + }); + + msgBody += message.message + '\r'; + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // Tear line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_tear_line) { + msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`; + } + + // + // Origin line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_origin) { + msgBody += `${message.meta.FtnProperty.ftn_origin}\r`; + } + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // SEEN-BY and PATH should be the last lines of a message + // + appendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01) + + appendMeta('\x01PATH', message.meta.FtnKludge['PATH']); + + // + // :TODO: We should encode based on config and add the proper kludge here! + ws.write(iconv.encode(msgBody + '\0', options.encoding)); + }; + + this.parsePacketBuffer = function(packetBuffer, iterator, cb) { + async.waterfall( + [ + function processHeader(callback) { + self.parsePacketHeader(packetBuffer, (err, header) => { + if(err) { + return callback(err); + } + + const next = function(e) { + return callback(e, header); + }; + + iterator('header', header, next); + }); + }, + function processMessages(header, callback) { + self.parsePacketMessages( + header, + packetBuffer.slice(FTN_PACKET_HEADER_SIZE), + iterator, + callback); + } + ], + cb // complete + ); + }; } // -// Message attributes defined in FTS-0001.016 -// http://ftsc.org/docs/fts-0001.016 +// Message attributes defined in FTS-0001.016 +// http://ftsc.org/docs/fts-0001.016 // -// See also: -// * http://www.skepticfiles.org/aj/basics03.htm +// See also: +// * http://www.skepticfiles.org/aj/basics03.htm // Packet.Attribute = { - Private : 0x0001, // Private message / NetMail - Crash : 0x0002, - Received : 0x0004, - Sent : 0x0008, - FileAttached : 0x0010, - InTransit : 0x0020, - Orphan : 0x0040, - KillSent : 0x0080, - Local : 0x0100, // Message is from *this* system - Hold : 0x0200, - Reserved0 : 0x0400, - FileRequest : 0x0800, - ReturnReceiptRequest : 0x1000, - ReturnReceipt : 0x2000, - AuditRequest : 0x4000, - FileUpdateRequest : 0x8000, + Private : 0x0001, // Private message / NetMail + Crash : 0x0002, + Received : 0x0004, + Sent : 0x0008, + FileAttached : 0x0010, + InTransit : 0x0020, + Orphan : 0x0040, + KillSent : 0x0080, + Local : 0x0100, // Message is from *this* system + Hold : 0x0200, + Reserved0 : 0x0400, + FileRequest : 0x0800, + ReturnReceiptRequest : 0x1000, + ReturnReceipt : 0x2000, + AuditRequest : 0x4000, + FileUpdateRequest : 0x8000, }; Object.freeze(Packet.Attribute); Packet.prototype.read = function(pathOrBuffer, iterator, cb) { - var self = this; + var self = this; - async.series( - [ - function getBufferIfPath(callback) { - if(_.isString(pathOrBuffer)) { - fs.readFile(pathOrBuffer, (err, data) => { - pathOrBuffer = data; - callback(err); - }); - } else { - callback(null); - } - }, - function parseBuffer(callback) { - self.parsePacketBuffer(pathOrBuffer, iterator, err => { - callback(err); - }); - } - ], - err => { - cb(err); - } - ); + async.series( + [ + function getBufferIfPath(callback) { + if(_.isString(pathOrBuffer)) { + fs.readFile(pathOrBuffer, (err, data) => { + pathOrBuffer = data; + callback(err); + }); + } else { + callback(null); + } + }, + function parseBuffer(callback) { + self.parsePacketBuffer(pathOrBuffer, iterator, err => { + callback(err); + }); + } + ], + err => { + cb(err); + } + ); }; Packet.prototype.writeHeader = function(ws, packetHeader) { - return this.writePacketHeader(packetHeader, ws); + return this.writePacketHeader(packetHeader, ws); }; Packet.prototype.writeMessageEntry = function(ws, msgEntry) { - ws.write(msgEntry); - return msgEntry.length; + ws.write(msgEntry); + return msgEntry.length; }; Packet.prototype.writeTerminator = function(ws) { - // - // From FTS-0001.016: - // "A pseudo-message beginning with the word 0000H signifies the end of the packet." - // - ws.write(new Buffer( [ 0x00, 0x00 ] )); // final extra null term - return 2; + // + // From FTS-0001.016: + // "A pseudo-message beginning with the word 0000H signifies the end of the packet." + // + ws.write(Buffer.from( [ 0x00, 0x00 ] )); // final extra null term + return 2; }; Packet.prototype.writeStream = function(ws, messages, options) { - if(!_.isBoolean(options.terminatePacket)) { - options.terminatePacket = true; - } - - if(_.isObject(options.packetHeader)) { - this.writePacketHeader(options.packetHeader, ws); - } - - options.encoding = options.encoding || 'utf8'; + if(!_.isBoolean(options.terminatePacket)) { + options.terminatePacket = true; + } - messages.forEach(msg => { - this.writeMessage(msg, ws, options); - }); + if(_.isObject(options.packetHeader)) { + this.writePacketHeader(options.packetHeader, ws); + } - if(true === options.terminatePacket) { - ws.write(new Buffer( [ 0 ] )); // final extra null term - } + options.encoding = options.encoding || 'utf8'; + + messages.forEach(msg => { + this.writeMessage(msg, ws, options); + }); + + if(true === options.terminatePacket) { + ws.write(Buffer.from( [ 0 ] )); // final extra null term + } }; Packet.prototype.write = function(path, packetHeader, messages, options) { - if(!_.isArray(messages)) { - messages = [ messages ]; - } - - options = options || { encoding : 'utf8' }; // utf-8 = 'CHRS UTF-8 4' + if(!_.isArray(messages)) { + messages = [ messages ]; + } - this.writeStream( - fs.createWriteStream(path), // :TODO: specify mode/etc. - messages, - { packetHeader : packetHeader, terminatePacket : true } - ); + options = options || { encoding : 'utf8' }; // utf-8 = 'CHRS UTF-8 4' + + this.writeStream( + fs.createWriteStream(path), // :TODO: specify mode/etc. + messages, + Object.assign( { packetHeader : packetHeader, terminatePacket : true }, options) + ); }; diff --git a/core/ftn_util.js b/core/ftn_util.js index 4d779c4a..9d53c9a3 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -1,402 +1,426 @@ /* jslint node: true */ 'use strict'; -let Config = require('./config.js').config; -let Address = require('./ftn_address.js'); -let FNV1a = require('./fnv1a.js'); -const getCleanEnigmaVersion = require('./misc_util.js').getCleanEnigmaVersion; +const Config = require('./config.js').get; +const Address = require('./ftn_address.js'); +const FNV1a = require('./fnv1a.js'); +const getCleanEnigmaVersion = require('./misc_util.js').getCleanEnigmaVersion; -let _ = require('lodash'); -let iconv = require('iconv-lite'); -let moment = require('moment'); -//let uuid = require('node-uuid'); -let os = require('os'); +const _ = require('lodash'); +const iconv = require('iconv-lite'); +const moment = require('moment'); +const os = require('os'); -let packageJson = require('../package.json'); +const packageJson = require('../package.json'); -// :TODO: Remove "Ftn" from most of these -- it's implied in the module -exports.stringToNullPaddedBuffer = stringToNullPaddedBuffer; -exports.getMessageSerialNumber = getMessageSerialNumber; -exports.getDateFromFtnDateTime = getDateFromFtnDateTime; -exports.getDateTimeString = getDateTimeString; +// :TODO: Remove "Ftn" from most of these -- it's implied in the module +exports.stringToNullPaddedBuffer = stringToNullPaddedBuffer; +exports.getMessageSerialNumber = getMessageSerialNumber; +exports.getDateFromFtnDateTime = getDateFromFtnDateTime; +exports.getDateTimeString = getDateTimeString; -exports.getMessageIdentifier = getMessageIdentifier; -exports.getProductIdentifier = getProductIdentifier; -exports.getUTCTimeZoneOffset = getUTCTimeZoneOffset; -exports.getOrigin = getOrigin; -exports.getTearLine = getTearLine; -exports.getVia = getVia; -exports.getAbbreviatedNetNodeList = getAbbreviatedNetNodeList; -exports.parseAbbreviatedNetNodeList = parseAbbreviatedNetNodeList; -exports.getUpdatedSeenByEntries = getUpdatedSeenByEntries; -exports.getUpdatedPathEntries = getUpdatedPathEntries; +exports.getMessageIdentifier = getMessageIdentifier; +exports.getProductIdentifier = getProductIdentifier; +exports.getUTCTimeZoneOffset = getUTCTimeZoneOffset; +exports.getOrigin = getOrigin; +exports.getTearLine = getTearLine; +exports.getVia = getVia; +exports.getIntl = getIntl; +exports.getAbbreviatedNetNodeList = getAbbreviatedNetNodeList; +exports.parseAbbreviatedNetNodeList = parseAbbreviatedNetNodeList; +exports.getUpdatedSeenByEntries = getUpdatedSeenByEntries; +exports.getUpdatedPathEntries = getUpdatedPathEntries; -exports.getCharacterSetIdentifierByEncoding = getCharacterSetIdentifierByEncoding; -exports.getEncodingFromCharacterSetIdentifier = getEncodingFromCharacterSetIdentifier; +exports.getCharacterSetIdentifierByEncoding = getCharacterSetIdentifierByEncoding; +exports.getEncodingFromCharacterSetIdentifier = getEncodingFromCharacterSetIdentifier; -exports.getQuotePrefix = getQuotePrefix; +exports.getQuotePrefix = getQuotePrefix; // -// Namespace for RFC-4122 name based UUIDs generated from -// FTN kludges MSGID + AREA +// Namespace for RFC-4122 name based UUIDs generated from +// FTN kludges MSGID + AREA // -//const ENIGMA_FTN_MSGID_NAMESPACE = uuid.parse('a5c7ae11-420c-4469-a116-0e9a6d8d2654'); +//const ENIGMA_FTN_MSGID_NAMESPACE = uuid.parse('a5c7ae11-420c-4469-a116-0e9a6d8d2654'); -// See list here: https://github.com/Mithgol/node-fidonet-jam +// See list here: https://github.com/Mithgol/node-fidonet-jam -function stringToNullPaddedBuffer(s, bufLen) { - let buffer = new Buffer(bufLen).fill(0x00); - let enc = iconv.encode(s, 'CP437').slice(0, bufLen); - for(let i = 0; i < enc.length; ++i) { - buffer[i] = enc[i]; - } - return buffer; +function stringToNullPaddedBuffer(s, bufLen) { + let buffer = Buffer.alloc(bufLen); + let enc = iconv.encode(s, 'CP437').slice(0, bufLen); + for(let i = 0; i < enc.length; ++i) { + buffer[i] = enc[i]; + } + return buffer; } // -// Convert a FTN style DateTime string to a Date object -// -// :TODO: Name the next couple methods better - for FTN *packets* +// Convert a FTN style DateTime string to a Date object +// +// :TODO: Name the next couple methods better - for FTN *packets* function getDateFromFtnDateTime(dateTime) { - // - // Examples seen in the wild (Working): - // "12 Sep 88 18:17:59" - // "Tue 01 Jan 80 00:00" - // "27 Feb 15 00:00:03" - // - // :TODO: Use moment.js here - return moment(Date.parse(dateTime)); // Date.parse() allows funky formats -// return (new Date(Date.parse(dateTime))).toISOString(); + // + // Examples seen in the wild (Working): + // "12 Sep 88 18:17:59" + // "Tue 01 Jan 80 00:00" + // "27 Feb 15 00:00:03" + // + // :TODO: Use moment.js here + return moment(Date.parse(dateTime)); // Date.parse() allows funky formats +// return (new Date(Date.parse(dateTime))).toISOString(); } function getDateTimeString(m) { - // - // From http://ftsc.org/docs/fts-0001.016: - // DateTime = (* a character string 20 characters long *) - // (* 01 Jan 86 02:34:56 *) - // DayOfMonth " " Month " " Year " " - // " " HH ":" MM ":" SS - // Null - // - // DayOfMonth = "01" | "02" | "03" | ... | "31" (* Fido 0 fills *) - // Month = "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | - // "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" - // Year = "01" | "02" | .. | "85" | "86" | ... | "99" | "00" - // HH = "00" | .. | "23" - // MM = "00" | .. | "59" - // SS = "00" | .. | "59" - // - if(!moment.isMoment(m)) { - m = moment(m); - } + // + // From http://ftsc.org/docs/fts-0001.016: + // DateTime = (* a character string 20 characters long *) + // (* 01 Jan 86 02:34:56 *) + // DayOfMonth " " Month " " Year " " + // " " HH ":" MM ":" SS + // Null + // + // DayOfMonth = "01" | "02" | "03" | ... | "31" (* Fido 0 fills *) + // Month = "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | + // "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" + // Year = "01" | "02" | .. | "85" | "86" | ... | "99" | "00" + // HH = "00" | .. | "23" + // MM = "00" | .. | "59" + // SS = "00" | .. | "59" + // + if(!moment.isMoment(m)) { + m = moment(m); + } - return m.format('DD MMM YY HH:mm:ss'); + return m.format('DD MMM YY HH:mm:ss'); } function getMessageSerialNumber(messageId) { - const msSinceEnigmaEpoc = (Date.now() - Date.UTC(2016, 1, 1)); - const hash = Math.abs(new FNV1a(msSinceEnigmaEpoc + messageId).value).toString(16); - return `00000000${hash}`.substr(-8); + const msSinceEnigmaEpoc = (Date.now() - Date.UTC(2016, 1, 1)); + const hash = Math.abs(new FNV1a(msSinceEnigmaEpoc + messageId).value).toString(16); + return `00000000${hash}`.substr(-8); } // -// Return a FTS-0009.001 compliant MSGID value given a message -// See http://ftsc.org/docs/fts-0009.001 -// -// "A MSGID line consists of the string "^AMSGID:" (where ^A is a -// control-A (hex 01) and the double-quotes are not part of the -// string), followed by a space, the address of the originating -// system, and a serial number unique to that message on the -// originating system, i.e.: +// Return a FTS-0009.001 compliant MSGID value given a message +// See http://ftsc.org/docs/fts-0009.001 // -// ^AMSGID: origaddr serialno +// "A MSGID line consists of the string "^AMSGID:" (where ^A is a +// control-A (hex 01) and the double-quotes are not part of the +// string), followed by a space, the address of the originating +// system, and a serial number unique to that message on the +// originating system, i.e.: // -// The originating address should be specified in a form that -// constitutes a valid return address for the originating network. -// If the originating address is enclosed in double-quotes, the -// entire string between the beginning and ending double-quotes is -// considered to be the orginating address. A double-quote character -// within a quoted address is represented by by two consecutive -// double-quote characters. The serial number may be any eight -// character hexadecimal number, as long as it is unique - no two -// messages from a given system may have the same serial number -// within a three years. The manner in which this serial number is -// generated is left to the implementor." -// +// ^AMSGID: origaddr serialno // -// Examples & Implementations +// The originating address should be specified in a form that +// constitutes a valid return address for the originating network. +// If the originating address is enclosed in double-quotes, the +// entire string between the beginning and ending double-quotes is +// considered to be the orginating address. A double-quote character +// within a quoted address is represented by by two consecutive +// double-quote characters. The serial number may be any eight +// character hexadecimal number, as long as it is unique - no two +// messages from a given system may have the same serial number +// within a three years. The manner in which this serial number is +// generated is left to the implementor." // -// Synchronet: .@ -// 2606.agora-agn_tst@46:1/142 19609217 -// -// Mystic: -// 46:3/102 46686263 // -// ENiGMA½: .@<5dFtnAddress> +// Examples & Implementations // -function getMessageIdentifier(message, address) { - const addrStr = new Address(address).toString('5D'); - return `${message.messageId}.${message.areaTag.toLowerCase()}@${addrStr} ${getMessageSerialNumber(message.messageId)}`; +// Synchronet: .@ +// 2606.agora-agn_tst@46:1/142 19609217 +// +// Mystic: +// 46:3/102 46686263 +// +// ENiGMA½: .@<5dFtnAddress> +// +// 0.0.8-alpha: +// Made compliant with FTN spec *when exporting NetMail* due to +// Mystic rejecting messages with the true-unique version. +// Strangely, Synchronet uses the unique format and Mystic does +// OK with it. Will need to research further. Note also that +// g00r00 was kind enough to fix Mystic to allow for the Sync/Enig +// format, but that will only help when using newer Mystic versions. +// +function getMessageIdentifier(message, address, isNetMail = false) { + const addrStr = new Address(address).toString('5D'); + return isNetMail ? + `${addrStr} ${getMessageSerialNumber(message.messageId)}` : + `${message.messageId}.${message.areaTag.toLowerCase()}@${addrStr} ${getMessageSerialNumber(message.messageId)}` + ; } // -// Return a FSC-0046.005 Product Identifier or "PID" -// http://ftsc.org/docs/fsc-0046.005 +// Return a FSC-0046.005 Product Identifier or "PID" +// http://ftsc.org/docs/fsc-0046.005 // -// Note that we use a variant on the spec for -// in which (; ; ) is used instead +// Note that we use a variant on the spec for +// in which (; ; ) is used instead // function getProductIdentifier() { - const version = getCleanEnigmaVersion(); - const nodeVer = process.version.substr(1); // remove 'v' prefix + const version = getCleanEnigmaVersion(); + const nodeVer = process.version.substr(1); // remove 'v' prefix - return `ENiGMA1/2 ${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; + return `ENiGMA1/2 ${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; } // -// Return a FRL-1004 style time zone offset for a -// 'TZUTC' kludge line +// Return a FRL-1004 style time zone offset for a +// 'TZUTC' kludge line // -// http://ftsc.org/docs/frl-1004.002 +// http://ftsc.org/docs/frl-1004.002 // function getUTCTimeZoneOffset() { - return moment().format('ZZ').replace(/\+/, ''); + return moment().format('ZZ').replace(/\+/, ''); } // -// Get a FSC-0032 style quote prefix -// http://ftsc.org/docs/fsc-0032.001 -// +// Get a FSC-0032 style quote prefix +// http://ftsc.org/docs/fsc-0032.001 +// function getQuotePrefix(name) { - let initials; - - const parts = name.split(' '); - if(parts.length > 1) { - // First & Last initials - (Bryan Ashby -> BA) - initials = `${parts[0].slice(0, 1)}${parts[parts.length - 1].slice(0, 1)}`.toUpperCase(); - } else { - // Just use the first two - (NuSkooler -> Nu) - initials = _.capitalize(name.slice(0, 2)); - } + let initials; - return ` ${initials}> `; + const parts = name.split(' '); + if(parts.length > 1) { + // First & Last initials - (Bryan Ashby -> BA) + initials = `${parts[0].slice(0, 1)}${parts[parts.length - 1].slice(0, 1)}`.toUpperCase(); + } else { + // Just use the first two - (NuSkooler -> Nu) + initials = _.capitalize(name.slice(0, 2)); + } + + return ` ${initials}> `; } // -// Return a FTS-0004 Origin line -// http://ftsc.org/docs/fts-0004.001 +// Return a FTS-0004 Origin line +// http://ftsc.org/docs/fts-0004.001 // function getOrigin(address) { - const origin = _.has(Config, 'messageNetworks.originLine') ? - Config.messageNetworks.originLine : - Config.general.boardName; + const config = Config(); + const origin = _.has(config, 'messageNetworks.originLine') ? + config.messageNetworks.originLine : + config.general.boardName; - const addrStr = new Address(address).toString('5D'); - return ` * Origin: ${origin} (${addrStr})`; + const addrStr = new Address(address).toString('5D'); + return ` * Origin: ${origin} (${addrStr})`; } function getTearLine() { - const nodeVer = process.version.substr(1); // remove 'v' prefix - return `--- ENiGMA 1/2 v${packageJson.version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; + const nodeVer = process.version.substr(1); // remove 'v' prefix + return `--- ENiGMA 1/2 v${packageJson.version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; } // -// Return a FRL-1005.001 "Via" line -// http://ftsc.org/docs/frl-1005.001 +// Return a FRL-1005.001 "Via" line +// http://ftsc.org/docs/frl-1005.001 // function getVia(address) { - /* - FRL-1005.001 states teh following format: + /* + FRL-1005.001 states teh following format: - ^AVia: @YYYYMMDD.HHMMSS[.Precise][.Time Zone] - [Serial Number] - */ - const addrStr = new Address(address).toString('5D'); - const dateTime = moment().utc().format('YYYYMMDD.HHmmSS.SSSS.UTC'); + ^AVia: @YYYYMMDD.HHMMSS[.Precise][.Time Zone] + [Serial Number] + */ + const addrStr = new Address(address).toString('5D'); + const dateTime = moment().utc().format('YYYYMMDD.HHmmSS.SSSS.UTC'); + const version = getCleanEnigmaVersion(); - const version = packageJson.version - .replace(/\-/g, '.') - .replace(/alpha/,'a') - .replace(/beta/,'b'); + return `${addrStr} @${dateTime} ENiGMA1/2 ${version}`; +} - return `${addrStr} @${dateTime} ENiGMA1/2 ${version}`; +// +// Creates a INTL kludge value as per FTS-4001 +// http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac +// +function getIntl(toAddress, fromAddress) { + // + // INTL differs from 'standard' kludges in that there is no ':' after "INTL" + // + // ""INTL "" "" + // "...These addresses shall be given on the form :/" + // + return `${toAddress.toString('3D')} ${fromAddress.toString('3D')}`; } function getAbbreviatedNetNodeList(netNodes) { - let abbrList = ''; - let currNet; - netNodes.forEach(netNode => { - if(_.isString(netNode)) { - netNode = Address.fromString(netNode); - } - if(currNet !== netNode.net) { - abbrList += `${netNode.net}/`; - currNet = netNode.net; - } - abbrList += `${netNode.node} `; - }); + let abbrList = ''; + let currNet; + netNodes.forEach(netNode => { + if(_.isString(netNode)) { + netNode = Address.fromString(netNode); + } + if(currNet !== netNode.net) { + abbrList += `${netNode.net}/`; + currNet = netNode.net; + } + abbrList += `${netNode.node} `; + }); - return abbrList.trim(); // remove trailing space + return abbrList.trim(); // remove trailing space } // -// Parse an abbreviated net/node list commonly used for SEEN-BY and PATH +// Parse an abbreviated net/node list commonly used for SEEN-BY and PATH // function parseAbbreviatedNetNodeList(netNodes) { - const re = /([0-9]+)\/([0-9]+)\s?|([0-9]+)\s?/g; - let net; - let m; - let results = []; - while(null !== (m = re.exec(netNodes))) { - if(m[1] && m[2]) { - net = parseInt(m[1]); - results.push(new Address( { net : net, node : parseInt(m[2]) } )); - } else if(net) { - results.push(new Address( { net : net, node : parseInt(m[3]) } )); - } - } + const re = /([0-9]+)\/([0-9]+)\s?|([0-9]+)\s?/g; + let net; + let m; + let results = []; + while(null !== (m = re.exec(netNodes))) { + if(m[1] && m[2]) { + net = parseInt(m[1]); + results.push(new Address( { net : net, node : parseInt(m[2]) } )); + } else if(net) { + results.push(new Address( { net : net, node : parseInt(m[3]) } )); + } + } - return results; + return results; } // -// Return a FTS-0004.001 SEEN-BY entry(s) that include -// all pre-existing SEEN-BY entries with the addition -// of |additions|. +// Return a FTS-0004.001 SEEN-BY entry(s) that include +// all pre-existing SEEN-BY entries with the addition +// of |additions|. // -// See http://ftsc.org/docs/fts-0004.001 -// and notes at http://ftsc.org/docs/fsc-0043.002. +// See http://ftsc.org/docs/fts-0004.001 +// and notes at http://ftsc.org/docs/fsc-0043.002. // -// For a great write up, see http://www.skepticfiles.org/aj/basics03.htm +// For a great write up, see http://www.skepticfiles.org/aj/basics03.htm // -// This method returns an sorted array of values, but -// not the "SEEN-BY" prefix itself +// This method returns an sorted array of values, but +// not the "SEEN-BY" prefix itself // function getUpdatedSeenByEntries(existingEntries, additions) { - /* - From FTS-0004: + /* + From FTS-0004: - "There can be many seen-by lines at the end of Conference - Mail messages, and they are the real "meat" of the control - information. They are used to determine the systems to - receive the exported messages. The format of the line is: + "There can be many seen-by lines at the end of Conference + Mail messages, and they are the real "meat" of the control + information. They are used to determine the systems to + receive the exported messages. The format of the line is: - SEEN-BY: 132/101 113 136/601 1014/1 + SEEN-BY: 132/101 113 136/601 1014/1 - The net/node numbers correspond to the net/node numbers of - the systems having already received the message. In this way - a message is never sent to a system twice. In a conference - with many participants the number of seen-by lines can be - very large. This line is added if it is not already a part - of the message, or added to if it already exists, each time - a message is exported to other systems. This is a REQUIRED - field, and Conference Mail will not function correctly if - this field is not put in place by other Echomail compatible - programs." + The net/node numbers correspond to the net/node numbers of + the systems having already received the message. In this way + a message is never sent to a system twice. In a conference + with many participants the number of seen-by lines can be + very large. This line is added if it is not already a part + of the message, or added to if it already exists, each time + a message is exported to other systems. This is a REQUIRED + field, and Conference Mail will not function correctly if + this field is not put in place by other Echomail compatible + programs." */ - existingEntries = existingEntries || []; - if(!_.isArray(existingEntries)) { - existingEntries = [ existingEntries ]; - } - - if(!_.isString(additions)) { - additions = parseAbbreviatedNetNodeList(getAbbreviatedNetNodeList(additions)); - } + existingEntries = existingEntries || []; + if(!_.isArray(existingEntries)) { + existingEntries = [ existingEntries ]; + } - additions = additions.sort(Address.getComparator()); + if(!_.isString(additions)) { + additions = parseAbbreviatedNetNodeList(getAbbreviatedNetNodeList(additions)); + } - // - // For now, we'll just append a new SEEN-BY entry - // - // :TODO: we should at least try and update what is already there in a smart way - existingEntries.push(getAbbreviatedNetNodeList(additions)); - return existingEntries; + additions = additions.sort(Address.getComparator()); + + // + // For now, we'll just append a new SEEN-BY entry + // + // :TODO: we should at least try and update what is already there in a smart way + existingEntries.push(getAbbreviatedNetNodeList(additions)); + return existingEntries; } function getUpdatedPathEntries(existingEntries, localAddress) { - // :TODO: append to PATH in a smart way! We shoudl try to fit at least the last existing line + // :TODO: append to PATH in a smart way! We shoudl try to fit at least the last existing line - existingEntries = existingEntries || []; - if(!_.isArray(existingEntries)) { - existingEntries = [ existingEntries ]; - } + existingEntries = existingEntries || []; + if(!_.isArray(existingEntries)) { + existingEntries = [ existingEntries ]; + } - existingEntries.push(getAbbreviatedNetNodeList( - parseAbbreviatedNetNodeList(localAddress))); + existingEntries.push(getAbbreviatedNetNodeList( + parseAbbreviatedNetNodeList(localAddress))); - return existingEntries; + return existingEntries; } // -// Return FTS-5000.001 "CHRS" value -// http://ftsc.org/docs/fts-5003.001 +// Return FTS-5000.001 "CHRS" value +// http://ftsc.org/docs/fts-5003.001 // const ENCODING_TO_FTS_5003_001_CHARS = { - // level 1 - generally should not be used - ascii : [ 'ASCII', 1 ], - 'us-ascii' : [ 'ASCII', 1 ], - - // level 2 - 8 bit, ASCII based - cp437 : [ 'CP437', 2 ], - cp850 : [ 'CP850', 2 ], - - // level 3 - reserved - - // level 4 - utf8 : [ 'UTF-8', 4 ], - 'utf-8' : [ 'UTF-8', 4 ], + // level 1 - generally should not be used + ascii : [ 'ASCII', 1 ], + 'us-ascii' : [ 'ASCII', 1 ], + + // level 2 - 8 bit, ASCII based + cp437 : [ 'CP437', 2 ], + cp850 : [ 'CP850', 2 ], + + // level 3 - reserved + + // level 4 + utf8 : [ 'UTF-8', 4 ], + 'utf-8' : [ 'UTF-8', 4 ], }; function getCharacterSetIdentifierByEncoding(encodingName) { - const value = ENCODING_TO_FTS_5003_001_CHARS[encodingName.toLowerCase()]; - return value ? `${value[0]} ${value[1]}` : encodingName.toUpperCase(); + const value = ENCODING_TO_FTS_5003_001_CHARS[encodingName.toLowerCase()]; + return value ? `${value[0]} ${value[1]}` : encodingName.toUpperCase(); } +// http://ftsc.org/docs/fts-5003.001 +// http://www.unicode.org/L2/L1999/99325-N.htm function getEncodingFromCharacterSetIdentifier(chrs) { - const ident = chrs.split(' ')[0].toUpperCase(); - - // :TODO: fill in the rest!!! - return { - // level 1 - 'ASCII' : 'iso-646-1', - 'DUTCH' : 'iso-646', - 'FINNISH' : 'iso-646-10', - 'FRENCH' : 'iso-646', - 'CANADIAN' : 'iso-646', - 'GERMAN' : 'iso-646', - 'ITALIAN' : 'iso-646', - 'NORWEIG' : 'iso-646', - 'PORTU' : 'iso-646', - 'SPANISH' : 'iso-656', - 'SWEDISH' : 'iso-646-10', - 'SWISS' : 'iso-646', - 'UK' : 'iso-646', - 'ISO-10' : 'iso-646-10', - - // level 2 - 'CP437' : 'cp437', - 'CP850' : 'cp850', - 'CP852' : 'cp852', - 'CP866' : 'cp866', - 'CP848' : 'cp848', - 'CP1250' : 'cp1250', - 'CP1251' : 'cp1251', - 'CP1252' : 'cp1252', - 'CP10000' : 'macroman', - 'LATIN-1' : 'iso-8859-1', - 'LATIN-2' : 'iso-8859-2', - 'LATIN-5' : 'iso-8859-9', - 'LATIN-9' : 'iso-8859-15', - - // level 4 - 'UTF-8' : 'utf8', - - // deprecated stuff - 'IBMPC' : 'cp1250', // :TODO: validate - '+7_FIDO' : 'cp866', - '+7' : 'cp866', - 'MAC' : 'macroman', // :TODO: validate - - }[ident]; + const ident = chrs.split(' ')[0].toUpperCase(); + + // :TODO: fill in the rest!!! + return { + // level 1 + 'ASCII' : 'ascii', // ISO-646-1 + 'DUTCH' : 'ascii', // ISO-646 + 'FINNISH' : 'ascii', // ISO-646-10 + 'FRENCH' : 'ascii', // ISO-646 + 'CANADIAN' : 'ascii', // ISO-646 + 'GERMAN' : 'ascii', // ISO-646 + 'ITALIAN' : 'ascii', // ISO-646 + 'NORWEIG' : 'ascii', // ISO-646 + 'PORTU' : 'ascii', // ISO-646 + 'SPANISH' : 'iso-656', + 'SWEDISH' : 'ascii', // ISO-646-10 + 'SWISS' : 'ascii', // ISO-646 + 'UK' : 'ascii', // ISO-646 + 'ISO-10' : 'ascii', // ISO-646-10 + + // level 2 + 'CP437' : 'cp437', + 'CP850' : 'cp850', + 'CP852' : 'cp852', + 'CP866' : 'cp866', + 'CP848' : 'cp848', + 'CP1250' : 'cp1250', + 'CP1251' : 'cp1251', + 'CP1252' : 'cp1252', + 'CP10000' : 'macroman', + 'LATIN-1' : 'iso-8859-1', + 'LATIN-2' : 'iso-8859-2', + 'LATIN-5' : 'iso-8859-9', + 'LATIN-9' : 'iso-8859-15', + + // level 4 + 'UTF-8' : 'utf8', + + // deprecated stuff + 'IBMPC' : 'cp1250', // :TODO: validate + '+7_FIDO' : 'cp866', + '+7' : 'cp866', + 'MAC' : 'macroman', // :TODO: validate + + }[ident]; } \ No newline at end of file diff --git a/core/horizontal_menu_view.js b/core/horizontal_menu_view.js index 28f4c29d..5c83eb16 100644 --- a/core/horizontal_menu_view.js +++ b/core/horizontal_menu_view.js @@ -1,157 +1,157 @@ /* jslint node: true */ 'use strict'; -var MenuView = require('./menu_view.js').MenuView; -var ansi = require('./ansi_term.js'); -var strUtil = require('./string_util.js'); +const MenuView = require('./menu_view.js').MenuView; +const strUtil = require('./string_util.js'); +const formatString = require('./string_format'); +const { pipeToAnsi } = require('./color_codes.js'); +const { goto } = require('./ansi_term.js'); -var assert = require('assert'); -var _ = require('lodash'); +const assert = require('assert'); +const _ = require('lodash'); -exports.HorizontalMenuView = HorizontalMenuView; +exports.HorizontalMenuView = HorizontalMenuView; -// :TODO: Update this to allow scrolling if number of items cannot fit in width (similar to VerticalMenuView) +// :TODO: Update this to allow scrolling if number of items cannot fit in width (similar to VerticalMenuView) function HorizontalMenuView(options) { - options.cursor = options.cursor || 'hide'; + options.cursor = options.cursor || 'hide'; - if(!_.isNumber(options.itemSpacing)) { - options.itemSpacing = 1; - } + if(!_.isNumber(options.itemSpacing)) { + options.itemSpacing = 1; + } - MenuView.call(this, options); + MenuView.call(this, options); - this.dimens.height = 1; // always the case + this.dimens.height = 1; // always the case - var self = this; + var self = this; - this.getSpacer = function() { - return new Array(self.itemSpacing + 1).join(' '); - }; + this.getSpacer = function() { + return new Array(self.itemSpacing + 1).join(' '); + }; - this.performAutoScale = function() { - if(self.autoScale.width) { - var spacer = self.getSpacer(); - var width = self.items.join(spacer).length + (spacer.length * 2); - assert(width <= self.client.term.termWidth - self.position.col); - self.dimens.width = width; - } - }; + this.cachePositions = function() { + if(this.positionCacheExpired) { + var col = self.position.col; + var spacer = self.getSpacer(); - this.performAutoScale(); + for(var i = 0; i < self.items.length; ++i) { + self.items[i].col = col; + col += spacer.length + self.items[i].text.length + spacer.length; + } + } - this.cachePositions = function() { - if(this.positionCacheExpired) { - var col = self.position.col; - var spacer = self.getSpacer(); + this.positionCacheExpired = false; + }; - for(var i = 0; i < self.items.length; ++i) { - self.items[i].col = col; - col += spacer.length + self.items[i].text.length + spacer.length; - } - } + this.drawItem = function(index) { + assert(!this.positionCacheExpired); - this.positionCacheExpired = false; - }; + const item = self.items[index]; + if(!item) { + return; + } - this.drawItem = function(index) { - assert(!this.positionCacheExpired); + let text; + let sgr; + if(item.focused && self.hasFocusItems()) { + const focusItem = self.focusItems[index]; + text = focusItem ? focusItem.text : item.text; + sgr = ''; + } else if(this.complexItems) { + text = pipeToAnsi(formatString(item.focused && this.focusItemFormat ? this.focusItemFormat : this.itemFormat, item)); + sgr = this.focusItemFormat ? '' : (index === self.focusedItemIndex ? self.getFocusSGR() : self.getSGR()); + } else { + text = strUtil.stylizeString(item.text, item.focused ? self.focusTextStyle : self.textStyle); + sgr = (index === self.focusedItemIndex ? self.getFocusSGR() : self.getSGR()); + } - var item = self.items[index]; - if(!item) { - return; - } + const drawWidth = strUtil.renderStringLength(text) + (self.getSpacer().length * 2); - var text = strUtil.stylizeString( - item.text, - this.hasFocus && item.focused ? self.focusTextStyle : self.textStyle); - - var drawWidth = text.length + self.getSpacer().length * 2; // * 2 = sides - - self.client.term.write( - ansi.goto(self.position.row, item.col) + - (index === self.focusedItemIndex ? self.getFocusSGR() : self.getSGR()) + - strUtil.pad(text, drawWidth, self.fillChar, 'center') - ); - }; + self.client.term.write( + `${goto(self.position.row, item.col)}${sgr}${strUtil.pad(text, drawWidth, self.fillChar, 'center')}` + ); + }; } require('util').inherits(HorizontalMenuView, MenuView); HorizontalMenuView.prototype.setHeight = function(height) { - height = parseInt(height, 10); - assert(1 === height); // nothing else allowed here - HorizontalMenuView.super_.prototype.setHeight(this, height); + height = parseInt(height, 10); + assert(1 === height); // nothing else allowed here + HorizontalMenuView.super_.prototype.setHeight(this, height); }; HorizontalMenuView.prototype.redraw = function() { - HorizontalMenuView.super_.prototype.redraw.call(this); + HorizontalMenuView.super_.prototype.redraw.call(this); - this.cachePositions(); + this.cachePositions(); - for(var i = 0; i < this.items.length; ++i) { - this.items[i].focused = this.focusedItemIndex === i; - this.drawItem(i); - } + for(var i = 0; i < this.items.length; ++i) { + this.items[i].focused = this.focusedItemIndex === i; + this.drawItem(i); + } }; HorizontalMenuView.prototype.setPosition = function(pos) { - HorizontalMenuView.super_.prototype.setPosition.call(this, pos); + HorizontalMenuView.super_.prototype.setPosition.call(this, pos); - this.positionCacheExpired = true; + this.positionCacheExpired = true; }; HorizontalMenuView.prototype.setFocus = function(focused) { - HorizontalMenuView.super_.prototype.setFocus.call(this, focused); + HorizontalMenuView.super_.prototype.setFocus.call(this, focused); - this.redraw(); + this.redraw(); }; HorizontalMenuView.prototype.setItems = function(items) { - HorizontalMenuView.super_.prototype.setItems.call(this, items); + HorizontalMenuView.super_.prototype.setItems.call(this, items); - this.positionCacheExpired = true; + this.positionCacheExpired = true; }; HorizontalMenuView.prototype.focusNext = function() { - if(this.items.length - 1 === this.focusedItemIndex) { - this.focusedItemIndex = 0; - } else { - this.focusedItemIndex++; - } + if(this.items.length - 1 === this.focusedItemIndex) { + this.focusedItemIndex = 0; + } else { + this.focusedItemIndex++; + } - // :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes - this.redraw(); + // :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes + this.redraw(); - HorizontalMenuView.super_.prototype.focusNext.call(this); + HorizontalMenuView.super_.prototype.focusNext.call(this); }; HorizontalMenuView.prototype.focusPrevious = function() { - if(0 === this.focusedItemIndex) { - this.focusedItemIndex = this.items.length - 1; - } else { - this.focusedItemIndex--; - } + if(0 === this.focusedItemIndex) { + this.focusedItemIndex = this.items.length - 1; + } else { + this.focusedItemIndex--; + } - // :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes - this.redraw(); + // :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes + this.redraw(); - HorizontalMenuView.super_.prototype.focusPrevious.call(this); + HorizontalMenuView.super_.prototype.focusPrevious.call(this); }; HorizontalMenuView.prototype.onKeyPress = function(ch, key) { - if(key) { - if(this.isKeyMapped('left', key.name)) { - this.focusPrevious(); - } else if(this.isKeyMapped('right', key.name)) { - this.focusNext(); - } - } + if(key) { + if(this.isKeyMapped('left', key.name)) { + this.focusPrevious(); + } else if(this.isKeyMapped('right', key.name)) { + this.focusNext(); + } + } - HorizontalMenuView.super_.prototype.onKeyPress.call(this, ch, key); + HorizontalMenuView.super_.prototype.onKeyPress.call(this, ch, key); }; HorizontalMenuView.prototype.getData = function() { - return this.focusedItemIndex; + const item = this.getItem(this.focusedItemIndex); + return _.isString(item.data) ? item.data : this.focusedItemIndex; }; \ No newline at end of file diff --git a/core/key_entry_view.js b/core/key_entry_view.js index cf1ba008..0bab0ad5 100644 --- a/core/key_entry_view.js +++ b/core/key_entry_view.js @@ -1,77 +1,77 @@ /* jslint node: true */ 'use strict'; -const View = require('./view.js').View; -const valueWithDefault = require('./misc_util.js').valueWithDefault; -const isPrintable = require('./string_util.js').isPrintable; -const stylizeString = require('./string_util.js').stylizeString; +const View = require('./view.js').View; +const valueWithDefault = require('./misc_util.js').valueWithDefault; +const isPrintable = require('./string_util.js').isPrintable; +const stylizeString = require('./string_util.js').stylizeString; -const _ = require('lodash'); +const _ = require('lodash'); module.exports = class KeyEntryView extends View { - constructor(options) { - options.acceptsFocus = valueWithDefault(options.acceptsFocus, true); - options.acceptsInput = valueWithDefault(options.acceptsInput, true); + constructor(options) { + options.acceptsFocus = valueWithDefault(options.acceptsFocus, true); + options.acceptsInput = valueWithDefault(options.acceptsInput, true); - super(options); + super(options); - this.eatTabKey = options.eatTabKey || true; - this.caseInsensitive = options.caseInsensitive || true; + this.eatTabKey = options.eatTabKey || true; + this.caseInsensitive = options.caseInsensitive || true; - if(Array.isArray(options.keys)) { - if(this.caseInsensitive) { - this.keys = options.keys.map( k => k.toUpperCase() ); - } else { - this.keys = options.keys; - } - } - } + if(Array.isArray(options.keys)) { + if(this.caseInsensitive) { + this.keys = options.keys.map( k => k.toUpperCase() ); + } else { + this.keys = options.keys; + } + } + } - onKeyPress(ch, key) { - const drawKey = ch; + onKeyPress(ch, key) { + const drawKey = ch; - if(ch && this.caseInsensitive) { - ch = ch.toUpperCase(); - } + if(ch && this.caseInsensitive) { + ch = ch.toUpperCase(); + } - if(drawKey && isPrintable(drawKey) && (!this.keys || this.keys.indexOf(ch) > -1)) { - this.redraw(); // sets position - this.client.term.write(stylizeString(ch, this.textStyle)); - } + if(drawKey && isPrintable(drawKey) && (!this.keys || this.keys.indexOf(ch) > -1)) { + this.redraw(); // sets position + this.client.term.write(stylizeString(ch, this.textStyle)); + } - this.keyEntered = ch || key.name; + this.keyEntered = ch || key.name; - if(key && 'tab' === key.name && !this.eatTabKey) { - return this.emit('action', 'next', key); - } - - this.emit('action', 'accept'); - // NOTE: we don't call super here. KeyEntryView is a special snowflake. - } + if(key && 'tab' === key.name && !this.eatTabKey) { + return this.emit('action', 'next', key); + } - setPropertyValue(propName, propValue) { - switch(propName) { - case 'eatTabKey' : - if(_.isBoolean(propValue)) { - this.eatTabKey = propValue; - } - break; + this.emit('action', 'accept'); + // NOTE: we don't call super here. KeyEntryView is a special snowflake. + } - case 'caseInsensitive' : - if(_.isBoolean(propValue)) { - this.caseInsensitive = propValue; - } - break; + setPropertyValue(propName, propValue) { + switch(propName) { + case 'eatTabKey' : + if(_.isBoolean(propValue)) { + this.eatTabKey = propValue; + } + break; - case 'keys' : - if(Array.isArray(propValue)) { - this.keys = propValue; - } - break; - } - - super.setPropertyValue(propName, propValue); - } + case 'caseInsensitive' : + if(_.isBoolean(propValue)) { + this.caseInsensitive = propValue; + } + break; - getData() { return this.keyEntered; } + case 'keys' : + if(Array.isArray(propValue)) { + this.keys = propValue; + } + break; + } + + super.setPropertyValue(propName, propValue); + } + + getData() { return this.keyEntered; } }; \ No newline at end of file diff --git a/core/last_callers.js b/core/last_callers.js new file mode 100644 index 00000000..9d875b2e --- /dev/null +++ b/core/last_callers.js @@ -0,0 +1,223 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const StatLog = require('./stat_log.js'); +const User = require('./user.js'); +const sysDb = require('./database.js').dbs.system; +const { Errors } = require('./enig_error.js'); +const UserProps = require('./user_property.js'); +const SysLogKeys = require('./system_log.js'); + +// deps +const moment = require('moment'); +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Last Callers', + desc : 'Last callers to the system', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.lastcallers' +}; + +const MciViewIds = { + callerList : 1, +}; + +exports.getModule = class LastCallersModule extends MenuModule { + constructor(options) { + super(options); + + this.actionIndicators = _.get(options, 'menuConfig.config.actionIndicators', {}); + this.actionIndicatorDefault = _.get(options, 'menuConfig.config.actionIndicatorDefault', '-'); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.waterfall( + [ + (callback) => { + this.prepViewController('callers', 0, mciData.menu, err => { + return callback(err); + }); + }, + (callback) => { + this.fetchHistory( (err, loginHistory) => { + return callback(err, loginHistory); + }); + }, + (loginHistory, callback) => { + this.loadUserForHistoryItems(loginHistory, (err, updatedHistory) => { + return callback(err, updatedHistory); + }); + }, + (loginHistory, callback) => { + const callersView = this.viewControllers.callers.getView(MciViewIds.callerList); + if(!callersView) { + return cb(Errors.MissingMci(`Missing caller list MCI ${MciViewIds.callerList}`)); + } + callersView.setItems(loginHistory); + callersView.redraw(); + return callback(null); + } + ], + err => { + if(err) { + this.client.log.warn( { error : err.message }, 'Error loading last callers'); + } + return cb(err); + } + ); + }); + } + + getCollapse(conf) { + let collapse = _.get(this, conf); + collapse = collapse && collapse.match(/^([0-9]+)\s*(minutes?|seconds?|hours?|days?|months?)$/); + if(collapse) { + return moment.duration(parseInt(collapse[1]), collapse[2]); + } + } + + fetchHistory(cb) { + const callersView = this.viewControllers.callers.getView(MciViewIds.callerList); + if(!callersView || 0 === callersView.dimens.height) { + return cb(null); + } + + StatLog.getSystemLogEntries( + SysLogKeys.UserLoginHistory, + StatLog.Order.TimestampDesc, + 200, // max items to fetch - we need more than max displayed for filtering/etc. + (err, loginHistory) => { + if(err) { + return cb(err); + } + + const dateTimeFormat = _.get( + this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateFormat('short')); + + loginHistory = loginHistory.map(item => { + try { + const historyItem = JSON.parse(item.log_value); + if(_.isObject(historyItem)) { + item.userId = historyItem.userId; + item.sessionId = historyItem.sessionId; + } else { + item.userId = historyItem; // older format + item.sessionId = '-none-'; + } + } catch(e) { + return null; // we'll filter this out + } + + item.timestamp = moment(item.timestamp); + + return Object.assign( + item, + { + ts : moment(item.timestamp).format(dateTimeFormat) + } + ); + }); + + const hideSysOp = _.get(this, 'menuConfig.config.sysop.hide'); + const sysOpCollapse = this.getCollapse('menuConfig.config.sysop.collapse'); + + const collapseList = (withUserId, minAge) => { + let lastUserId; + let lastTimestamp; + loginHistory = loginHistory.filter(item => { + const secApart = lastTimestamp ? moment.duration(lastTimestamp.diff(item.timestamp)).asSeconds() : 0; + const collapse = (null === withUserId ? true : withUserId === item.userId) && + (lastUserId === item.userId) && + (secApart < minAge); + + lastUserId = item.userId; + lastTimestamp = item.timestamp; + + return !collapse; + }); + }; + + if(hideSysOp) { + loginHistory = loginHistory.filter(item => false === User.isRootUserId(item.userId)); + } else if(sysOpCollapse) { + collapseList(User.RootUserID, sysOpCollapse.asSeconds()); + } + + const userCollapse = this.getCollapse('menuConfig.config.user.collapse'); + if(userCollapse) { + collapseList(null, userCollapse.asSeconds()); + } + + return cb( + null, + loginHistory.slice(0, callersView.dimens.height) // trim the fat + ); + } + ); + } + + loadUserForHistoryItems(loginHistory, cb) { + const getPropOpts = { + names : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations ] + }; + + const actionIndicatorNames = _.map(this.actionIndicators, (v, k) => k); + let indicatorSumsSql; + if(actionIndicatorNames.length > 0) { + indicatorSumsSql = actionIndicatorNames.map(i => { + return `SUM(CASE WHEN log_name='${_.snakeCase(i)}' THEN 1 ELSE 0 END) AS ${i}`; + }); + } + + async.map(loginHistory, (item, nextHistoryItem) => { + User.getUserName(item.userId, (err, userName) => { + if(err) { + return nextHistoryItem(null, null); + } + + item.userName = item.text = userName; + + User.loadProperties(item.userId, getPropOpts, (err, props) => { + item.location = (props && props[UserProps.Location]) || ''; + item.affiliation = item.affils = (props && props[UserProps.Affiliations]) || ''; + item.realName = (props && props[UserProps.RealName]) || ''; + + if(!indicatorSumsSql) { + return nextHistoryItem(null, item); + } + + sysDb.get( + `SELECT ${indicatorSumsSql.join(', ')} + FROM user_event_log + WHERE user_id=? AND session_id=? + LIMIT 1;`, + [ item.userId, item.sessionId ], + (err, results) => { + if(_.isObject(results)) { + item.actions = ''; + Object.keys(results).forEach(n => { + const indicator = results[n] > 0 ? this.actionIndicators[n] || this.actionIndicatorDefault : this.actionIndicatorDefault; + item[n] = indicator; + item.actions += indicator; + }); + } + return nextHistoryItem(null, item); + } + ); + }); + }); + }, + (err, mapped) => { + return cb(err, mapped.filter(item => item)); // remove deleted + }); + } +}; diff --git a/core/listening_server.js b/core/listening_server.js index 94efd475..7cb7405e 100644 --- a/core/listening_server.js +++ b/core/listening_server.js @@ -1,64 +1,63 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const logger = require('./logger.js'); +// ENiGMA½ +const logger = require('./logger.js'); -// deps -const async = require('async'); +// deps +const async = require('async'); -const listeningServers = {}; // packageName -> info +const listeningServers = {}; // packageName -> info -exports.startup = startup; -exports.shutdown = shutdown; -exports.getServer = getServer; +exports.startup = startup; +exports.shutdown = shutdown; +exports.getServer = getServer; function startup(cb) { - return startListening(cb); + return startListening(cb); } function shutdown(cb) { - return cb(null); + return cb(null); } function getServer(packageName) { - return listeningServers[packageName]; + return listeningServers[packageName]; } function startListening(cb) { - const moduleUtil = require('./module_util.js'); // late load so we get Config + const moduleUtil = require('./module_util.js'); // late load so we get Config - async.each( [ 'login', 'content' ], (category, next) => { - moduleUtil.loadModulesForCategory(`${category}Servers`, (err, module) => { - // :TODO: use enig error here! - if(err) { - if('EENIGMODDISABLED' === err.code) { - logger.log.debug(err.message); - } else { - logger.log.info( { err : err }, 'Failed loading module'); - } - return; - } + async.each( [ 'login', 'content', 'chat' ], (category, next) => { + moduleUtil.loadModulesForCategory(`${category}Servers`, (module, nextModule) => { + const moduleInst = new module.getModule(); + try { + moduleInst.createServer(err => { + if(err) { + return nextModule(err); + } - const moduleInst = new module.getModule(); - try { - moduleInst.createServer(); - if(!moduleInst.listen()) { - throw new Error('Failed listening'); - } + moduleInst.listen( err => { + if(err) { + return nextModule(err); + } - listeningServers[module.moduleInfo.packageName] = { - instance : moduleInst, - info : module.moduleInfo, - }; + listeningServers[module.moduleInfo.packageName] = { + instance : moduleInst, + info : module.moduleInfo, + }; - } catch(e) { - logger.log.error(e, 'Exception caught creating server!'); - } - }, err => { - return next(err); - }); - }, err => { - return cb(err); - }); + return nextModule(null); + }); + }); + } catch(e) { + logger.log.error(e, 'Exception caught creating server!'); + return nextModule(e); + } + }, err => { + return next(err); + }); + }, err => { + return cb(err); + }); } diff --git a/core/logger.js b/core/logger.js index 3b0b47e2..9a4e8711 100644 --- a/core/logger.js +++ b/core/logger.js @@ -1,74 +1,74 @@ /* jslint node: true */ 'use strict'; -// deps -const bunyan = require('bunyan'); -const paths = require('path'); -const fs = require('graceful-fs'); -const _ = require('lodash'); +// deps +const bunyan = require('bunyan'); +const paths = require('path'); +const fs = require('graceful-fs'); +const _ = require('lodash'); module.exports = class Log { - static init() { - const Config = require('./config.js').config; - const logPath = Config.paths.logs; - - const err = this.checkLogPath(logPath); - if(err) { - console.error(err.message); // eslint-disable-line no-console - return process.exit(); - } + static init() { + const Config = require('./config.js').get(); + const logPath = Config.paths.logs; - const logStreams = []; - if(_.isObject(Config.logging.rotatingFile)) { - Config.logging.rotatingFile.path = paths.join(logPath, Config.logging.rotatingFile.fileName); - logStreams.push(Config.logging.rotatingFile); - } + const err = this.checkLogPath(logPath); + if(err) { + console.error(err.message); // eslint-disable-line no-console + return process.exit(); + } - const serializers = { - err : bunyan.stdSerializers.err, // handle 'err' fields with stack/etc. - }; + const logStreams = []; + if(_.isObject(Config.logging.rotatingFile)) { + Config.logging.rotatingFile.path = paths.join(logPath, Config.logging.rotatingFile.fileName); + logStreams.push(Config.logging.rotatingFile); + } - // try to remove sensitive info by default, e.g. 'password' fields - [ 'formData', 'formValue' ].forEach(keyName => { - serializers[keyName] = (fd) => Log.hideSensitive(fd); - }); + const serializers = { + err : bunyan.stdSerializers.err, // handle 'err' fields with stack/etc. + }; - this.log = bunyan.createLogger({ - name : 'ENiGMA½ BBS', - streams : logStreams, - serializers : serializers, - }); - } + // try to remove sensitive info by default, e.g. 'password' fields + [ 'formData', 'formValue' ].forEach(keyName => { + serializers[keyName] = (fd) => Log.hideSensitive(fd); + }); - static checkLogPath(logPath) { - try { - if(!fs.statSync(logPath).isDirectory()) { - return new Error(`${logPath} is not a directory`); - } - - return null; - } catch(e) { - if('ENOENT' === e.code) { - return new Error(`${logPath} does not exist`); - } - return e; - } - } + this.log = bunyan.createLogger({ + name : 'ENiGMA½ BBS', + streams : logStreams, + serializers : serializers, + }); + } - static hideSensitive(obj) { - try { - // - // Use a regexp -- we don't know how nested fields we want to seek and destroy may be - // - return JSON.parse( - JSON.stringify(obj).replace(/"(password|key|authCode)"\s?:\s?"([^"]+)"/, (match, valueName) => { - return `"${valueName}":"********"`; - }) - ); - } catch(e) { - // be safe and return empty obj! - return {}; - } - } + static checkLogPath(logPath) { + try { + if(!fs.statSync(logPath).isDirectory()) { + return new Error(`${logPath} is not a directory`); + } + + return null; + } catch(e) { + if('ENOENT' === e.code) { + return new Error(`${logPath} does not exist`); + } + return e; + } + } + + static hideSensitive(obj) { + try { + // + // Use a regexp -- we don't know how nested fields we want to seek and destroy may be + // + return JSON.parse( + JSON.stringify(obj).replace(/"(password|passwordConfirm|key|authCode)"\s?:\s?"([^"]+)"/, (match, valueName) => { + return `"${valueName}":"********"`; + }) + ); + } catch(e) { + // be safe and return empty obj! + return {}; + } + } }; diff --git a/core/login_server_module.js b/core/login_server_module.js index 212d2e27..da3e06de 100644 --- a/core/login_server_module.js +++ b/core/login_server_module.js @@ -1,87 +1,97 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const conf = require('./config.js'); -const logger = require('./logger.js'); -const ServerModule = require('./server_module.js').ServerModule; -const clientConns = require('./client_connections.js'); +// ENiGMA½ +const conf = require('./config.js'); +const logger = require('./logger.js'); +const ServerModule = require('./server_module.js').ServerModule; +const clientConns = require('./client_connections.js'); +const UserProps = require('./user_property.js'); -// deps -const _ = require('lodash'); +// deps +const _ = require('lodash'); module.exports = class LoginServerModule extends ServerModule { - constructor() { - super(); - } + constructor() { + super(); + } - // :TODO: we need to max connections -- e.g. from config 'maxConnections' + // :TODO: we need to max connections -- e.g. from config 'maxConnections' - prepareClient(client, cb) { - const theme = require('./theme.js'); + prepareClient(client, cb) { + if(client.user.isAuthenticated()) { + return cb(null); + } - // - // Choose initial theme before we have user context - // - if('*' === conf.config.preLoginTheme) { - client.user.properties.theme_id = theme.getRandomTheme() || ''; - } else { - client.user.properties.theme_id = conf.config.preLoginTheme; - } - - theme.setClientTheme(client, client.user.properties.theme_id); - return cb(null); // note: currently useless to use cb here - but this may change...again... - } + const theme = require('./theme.js'); - handleNewClient(client, clientSock, modInfo) { - // - // Start tracking the client. We'll assign it an ID which is - // just the index in our connections array. - // - if(_.isUndefined(client.session)) { - client.session = {}; - } + // + // Choose initial theme before we have user context + // + const preLoginTheme = _.get(conf.config, 'theme.preLogin'); + if('*' === preLoginTheme) { + client.user.properties[UserProps.ThemeId] = theme.getRandomTheme() || ''; + } else { + client.user.properties[UserProps.ThemeId] = preLoginTheme; + } - client.session.serverName = modInfo.name; - client.session.isSecure = _.isBoolean(client.isSecure) ? client.isSecure : (modInfo.isSecure || false); + theme.setClientTheme(client, client.user.properties[UserProps.ThemeId]); + return cb(null); + } - clientConns.addNewClient(client, clientSock); + handleNewClient(client, clientSock, modInfo) { + clientSock.on('error', err => { + logger.log.warn({ modInfo, error : err.message }, 'Client socket error'); + }); - client.on('ready', readyOptions => { + // + // Start tracking the client. A session ID aka client ID + // will be established in addNewClient() below. + // + if(_.isUndefined(client.session)) { + client.session = {}; + } - client.startIdleMonitor(); + client.session.serverName = modInfo.name; + client.session.isSecure = _.isBoolean(client.isSecure) ? client.isSecure : (modInfo.isSecure || false); - // Go to module -- use default error handler - this.prepareClient(client, () => { - require('./connect.js').connectEntry(client, readyOptions.firstMenu); - }); - }); + clientConns.addNewClient(client, clientSock); - client.on('end', () => { - clientConns.removeClient(client); - }); + client.on('ready', readyOptions => { - client.on('error', err => { - logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message); - }); + client.startIdleMonitor(); - client.on('close', err => { - const logFunc = err ? logger.log.info : logger.log.debug; - logFunc( { clientId : client.session.id }, 'Connection closed'); - - clientConns.removeClient(client); - }); + // Go to module -- use default error handler + this.prepareClient(client, () => { + require('./connect.js').connectEntry(client, readyOptions.firstMenu); + }); + }); - client.on('idle timeout', () => { - client.log.info('User idle timeout expired'); + client.on('end', () => { + clientConns.removeClient(client); + }); - client.menuStack.goto('idleLogoff', err => { - if(err) { - // likely just doesn't exist - client.term.write('\nIdle timeout expired. Goodbye!\n'); - client.end(); - } - }); - }); - } + client.on('error', err => { + logger.log.info({ nodeId : client.node, error : err.message }, 'Connection error'); + }); + + client.on('close', err => { + const logFunc = err ? logger.log.info : logger.log.debug; + logFunc( { nodeId : client.node }, 'Connection closed'); + + clientConns.removeClient(client); + }); + + client.on('idle timeout', () => { + client.log.info('User idle timeout expired'); + + client.menuStack.goto('idleLogoff', err => { + if(err) { + // likely just doesn't exist + client.term.write('\nIdle timeout expired. Goodbye!\n'); + client.end(); + } + }); + }); + } }; diff --git a/core/mail_packet.js b/core/mail_packet.js index 3fb8b2d2..ce5b160a 100644 --- a/core/mail_packet.js +++ b/core/mail_packet.js @@ -1,36 +1,36 @@ /* jslint node: true */ 'use strict'; -var events = require('events'); -var assert = require('assert'); -var _ = require('lodash'); +var events = require('events'); +var assert = require('assert'); +var _ = require('lodash'); module.exports = MailPacket; function MailPacket(options) { - events.EventEmitter.call(this); + events.EventEmitter.call(this); - // map of network name -> address obj ( { zone, net, node, point, domain } ) - this.nodeAddresses = options.nodeAddresses || {}; + // map of network name -> address obj ( { zone, net, node, point, domain } ) + this.nodeAddresses = options.nodeAddresses || {}; } require('util').inherits(MailPacket, events.EventEmitter); MailPacket.prototype.read = function(options) { - // - // options.packetPath | opts.packetBuffer: supplies a path-to-file - // or a buffer containing packet data - // - // emits 'message' event per message read - // - assert(_.isString(options.packetPath) || Buffer.isBuffer(options.packetBuffer)); + // + // options.packetPath | opts.packetBuffer: supplies a path-to-file + // or a buffer containing packet data + // + // emits 'message' event per message read + // + assert(_.isString(options.packetPath) || Buffer.isBuffer(options.packetBuffer)); }; MailPacket.prototype.write = function(options) { - // - // options.messages[]: array of message(s) to create packets from - // - // emits 'packet' event per packet constructed - // - assert(_.isArray(options.messages)); -} \ No newline at end of file + // + // options.messages[]: array of message(s) to create packets from + // + // emits 'packet' event per packet constructed + // + assert(_.isArray(options.messages)); +}; \ No newline at end of file diff --git a/core/mail_util.js b/core/mail_util.js new file mode 100644 index 00000000..6bd433d3 --- /dev/null +++ b/core/mail_util.js @@ -0,0 +1,81 @@ +/* jslint node: true */ +'use strict'; + +const Address = require('./ftn_address.js'); +const Message = require('./message.js'); + +exports.getAddressedToInfo = getAddressedToInfo; + +const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +/* + Input Output + ---------------------------------------------------------------------------------------------------- + User { name : 'User', flavor : 'local' } + Some User { name : 'Some User', flavor : 'local' } + JoeUser @ 1:103/75 { name : 'JoeUser', flavor : 'ftn', remote : '1:103/75' } + Bob@1:103/705@fidonet.org { name : 'Bob', flavor : 'ftn', remote : '1:103/705@fidonet.org' } + 1:103/705@fidonet.org { flavor : 'ftn', remote : '1:103/705@fidonet.org' } + Jane <23:4/100> { name : 'Jane', flavor : 'ftn', remote : '23:4/100' } + 43:20/100.2 { flavor : 'ftn', remote : '43:20/100.2' } + foo@host.com { name : 'foo', flavor : 'email', remote : 'foo@host.com' } + Bar { name : 'Bar', flavor : 'email', remote : 'baz@foobar.com' } +*/ +function getAddressedToInfo(input) { + input = input.trim(); + + const firstAtPos = input.indexOf('@'); + + if(firstAtPos < 0) { + let addr = Address.fromString(input); + if(Address.isValidAddress(addr)) { + return { flavor : Message.AddressFlavor.FTN, remote : input }; + } + + const lessThanPos = input.indexOf('<'); + if(lessThanPos < 0) { + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + const greaterThanPos = input.indexOf('>'); + if(greaterThanPos < lessThanPos) { + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + addr = Address.fromString(input.slice(lessThanPos + 1, greaterThanPos)); + if(Address.isValidAddress(addr)) { + return { name : input.slice(0, lessThanPos).trim(), flavor : Message.AddressFlavor.FTN, remote : addr.toString() }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + const lessThanPos = input.indexOf('<'); + const greaterThanPos = input.indexOf('>'); + if(lessThanPos > 0 && greaterThanPos > lessThanPos) { + const addr = input.slice(lessThanPos + 1, greaterThanPos); + const m = addr.match(EMAIL_REGEX); + if(m) { + return { name : input.slice(0, lessThanPos).trim(), flavor : Message.AddressFlavor.Email, remote : addr }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + let m = input.match(EMAIL_REGEX); + if(m) { + return { name : input.slice(0, firstAtPos), flavor : Message.AddressFlavor.Email, remote : input }; + } + + let addr = Address.fromString(input); // 5D? + if(Address.isValidAddress(addr)) { + return { flavor : Message.AddressFlavor.FTN, remote : addr.toString() } ; + } + + addr = Address.fromString(input.slice(firstAtPos + 1).trim()); + if(Address.isValidAddress(addr)) { + return { name : input.slice(0, firstAtPos).trim(), flavor : Message.AddressFlavor.FTN, remote : addr.toString() }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; +} diff --git a/core/mask_edit_text_view.js b/core/mask_edit_text_view.js index f99774e6..abd04cb1 100644 --- a/core/mask_edit_text_view.js +++ b/core/mask_edit_text_view.js @@ -1,208 +1,211 @@ /* jslint node: true */ 'use strict'; -var TextView = require('./text_view.js').TextView; -var miscUtil = require('./misc_util.js'); -var strUtil = require('./string_util.js'); -var ansi = require('./ansi_term.js'); +var TextView = require('./text_view.js').TextView; +var miscUtil = require('./misc_util.js'); +var strUtil = require('./string_util.js'); +var ansi = require('./ansi_term.js'); -//var util = require('util'); -var assert = require('assert'); -var _ = require('lodash'); +//var util = require('util'); +var assert = require('assert'); +var _ = require('lodash'); -exports.MaskEditTextView = MaskEditTextView; +exports.MaskEditTextView = MaskEditTextView; -// ##/##/#### <--styleSGR2 if fillChar -// ^- styleSGR1 -// buildPattern -> [ RE, RE, '/', RE, RE, '/', RE, RE, RE, RE ] -// patternIndex -----^ +// ##/##/#### <--styleSGR2 if fillChar +// ^- styleSGR1 +// buildPattern -> [ RE, RE, '/', RE, RE, '/', RE, RE, RE, RE ] +// patternIndex -----^ -// styleSGR1: Literal's (non-focus) -// styleSGR2: Literals (focused) -// styleSGR3: fillChar +// styleSGR1: Literal's (non-focus) +// styleSGR2: Literals (focused) +// styleSGR3: fillChar // -// :TODO: -// * Hint, e.g. YYYY/MM/DD -// * Return values with literals in place -// +// :TODO: +// * Hint, e.g. YYYY/MM/DD +// * Return values with literals in place +// * Tab in/out results in oddities such as cursor placement & ability to type in non-pattern chars +// * There exists some sort of condition that allows pattern position to get out of sync function MaskEditTextView(options) { - options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); - options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); - options.cursorStyle = miscUtil.valueWithDefault(options.cursorStyle, 'steady block'); - options.resizable = false; + options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); + options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); + options.cursorStyle = miscUtil.valueWithDefault(options.cursorStyle, 'steady block'); + options.resizable = false; - TextView.call(this, options); + TextView.call(this, options); - this.cursorPos = { x : 0 }; - this.patternArrayPos = 0; + this.initDefaultWidth(); - var self = this; + this.cursorPos = { x : 0 }; + this.patternArrayPos = 0; - this.maskPattern = options.maskPattern || ''; + var self = this; - this.clientBackspace = function() { - var fillCharSGR = this.getStyleSGR(3) || this.getSGR(); - this.client.term.write('\b' + fillCharSGR + this.fillChar + '\b' + this.getFocusSGR()); - }; + this.maskPattern = options.maskPattern || ''; - this.drawText = function(s) { - var textToDraw = strUtil.stylizeString(s, this.hasFocus ? this.focusTextStyle : this.textStyle); - - assert(textToDraw.length <= self.patternArray.length); + this.clientBackspace = function() { + var fillCharSGR = this.getStyleSGR(3) || this.getSGR(); + this.client.term.write('\b' + fillCharSGR + this.fillChar + '\b' + this.getFocusSGR()); + }; - // draw out the text we have so far - var i = 0; - var t = 0; - while(i < self.patternArray.length) { - if(_.isRegExp(self.patternArray[i])) { - if(t < textToDraw.length) { - self.client.term.write((self.hasFocus ? self.getFocusSGR() : self.getSGR()) + textToDraw[t]); - t++; - } else { - self.client.term.write((self.getStyleSGR(3) || '') + self.fillChar); - } - } else { - var styleSgr = this.hasFocus ? (self.getStyleSGR(2) || '') : (self.getStyleSGR(1) || ''); - self.client.term.write(styleSgr + self.maskPattern[i]); - } - i++; - } - }; + this.drawText = function(s) { + var textToDraw = strUtil.stylizeString(s, this.hasFocus ? this.focusTextStyle : this.textStyle); - this.buildPattern = function() { - self.patternArray = []; - self.maxLength = 0; + assert(textToDraw.length <= self.patternArray.length); - for(var i = 0; i < self.maskPattern.length; i++) { - // :TODO: support escaped characters, e.g. \#. Also allow \\ for a '\' mark! - if(self.maskPattern[i] in MaskEditTextView.maskPatternCharacterRegEx) { - self.patternArray.push(MaskEditTextView.maskPatternCharacterRegEx[self.maskPattern[i]]); - ++self.maxLength; - } else { - self.patternArray.push(self.maskPattern[i]); - } - } - }; + // draw out the text we have so far + var i = 0; + var t = 0; + while(i < self.patternArray.length) { + if(_.isRegExp(self.patternArray[i])) { + if(t < textToDraw.length) { + self.client.term.write((self.hasFocus ? self.getFocusSGR() : self.getSGR()) + textToDraw[t]); + t++; + } else { + self.client.term.write((self.getStyleSGR(3) || '') + self.fillChar); + } + } else { + var styleSgr = this.hasFocus ? (self.getStyleSGR(2) || '') : (self.getStyleSGR(1) || ''); + self.client.term.write(styleSgr + self.maskPattern[i]); + } + i++; + } + }; - this.getEndOfTextColumn = function() { - return this.position.col + this.patternArrayPos; - }; + this.buildPattern = function() { + self.patternArray = []; + self.maxLength = 0; - this.buildPattern(); + for(var i = 0; i < self.maskPattern.length; i++) { + // :TODO: support escaped characters, e.g. \#. Also allow \\ for a '\' mark! + if(self.maskPattern[i] in MaskEditTextView.maskPatternCharacterRegEx) { + self.patternArray.push(MaskEditTextView.maskPatternCharacterRegEx[self.maskPattern[i]]); + ++self.maxLength; + } else { + self.patternArray.push(self.maskPattern[i]); + } + } + }; + + this.getEndOfTextColumn = function() { + return this.position.col + this.patternArrayPos; + }; + + this.buildPattern(); } require('util').inherits(MaskEditTextView, TextView); MaskEditTextView.maskPatternCharacterRegEx = { - '#' : /[0-9]/, // Numeric - 'A' : /[a-zA-Z]/, // Alpha - '@' : /[0-9a-zA-Z]/, // Alphanumeric - '&' : /[\w\d\s]/, // Any "printable" 32-126, 128-255 + '#' : /[0-9]/, // Numeric + 'A' : /[a-zA-Z]/, // Alpha + '@' : /[0-9a-zA-Z]/, // Alphanumeric + '&' : /[\w\d\s]/, // Any "printable" 32-126, 128-255 }; MaskEditTextView.prototype.setText = function(text) { - MaskEditTextView.super_.prototype.setText.call(this, text); - - if(this.patternArray) { // :TODO: This is a hack - see TextView ctor note about setText() - this.patternArrayPos = this.patternArray.length; - } + MaskEditTextView.super_.prototype.setText.call(this, text); + + if(this.patternArray) { // :TODO: This is a hack - see TextView ctor note about setText() + this.patternArrayPos = this.patternArray.length; + } }; MaskEditTextView.prototype.setMaskPattern = function(pattern) { - this.dimens.width = pattern.length; + this.dimens.width = pattern.length; - this.maskPattern = pattern; - this.buildPattern(); + this.maskPattern = pattern; + this.buildPattern(); }; MaskEditTextView.prototype.onKeyPress = function(ch, key) { - if(key) { - if(this.isKeyMapped('backspace', key.name)) { - if(this.text.length > 0) { - this.patternArrayPos--; - assert(this.patternArrayPos >= 0); + if(key) { + if(this.isKeyMapped('backspace', key.name)) { + if(this.text.length > 0) { + this.patternArrayPos--; + assert(this.patternArrayPos >= 0); - if(_.isRegExp(this.patternArray[this.patternArrayPos])) { - this.text = this.text.substr(0, this.text.length - 1); - this.clientBackspace(); - } else { - while(this.patternArrayPos > 0) { - if(_.isRegExp(this.patternArray[this.patternArrayPos])) { - this.text = this.text.substr(0, this.text.length - 1); - this.client.term.write(ansi.goto(this.position.row, this.getEndOfTextColumn() + 1)); - this.clientBackspace(); - break; - } - this.patternArrayPos--; - } - } - } + if(_.isRegExp(this.patternArray[this.patternArrayPos])) { + this.text = this.text.substr(0, this.text.length - 1); + this.clientBackspace(); + } else { + while(this.patternArrayPos > 0) { + if(_.isRegExp(this.patternArray[this.patternArrayPos])) { + this.text = this.text.substr(0, this.text.length - 1); + this.client.term.write(ansi.goto(this.position.row, this.getEndOfTextColumn() + 1)); + this.clientBackspace(); + break; + } + this.patternArrayPos--; + } + } + } - return; - } else if(this.isKeyMapped('clearLine', key.name)) { - this.text = ''; - this.patternArrayPos = 0; - this.setFocus(true); // redraw + adjust cursor + return; + } else if(this.isKeyMapped('clearLine', key.name)) { + this.text = ''; + this.patternArrayPos = 0; + this.setFocus(true); // redraw + adjust cursor - return; - } - } + return; + } + } - if(ch && strUtil.isPrintable(ch)) { - if(this.text.length < this.maxLength) { - ch = strUtil.stylizeString(ch, this.textStyle); + if(ch && strUtil.isPrintable(ch)) { + if(this.text.length < this.maxLength) { + ch = strUtil.stylizeString(ch, this.textStyle); - if(!ch.match(this.patternArray[this.patternArrayPos])) { - return; - } + if(!ch.match(this.patternArray[this.patternArrayPos])) { + return; + } - this.text += ch; - this.patternArrayPos++; + this.text += ch; + this.patternArrayPos++; - while(this.patternArrayPos < this.patternArray.length && - !_.isRegExp(this.patternArray[this.patternArrayPos])) - { - this.patternArrayPos++; - } + while(this.patternArrayPos < this.patternArray.length && + !_.isRegExp(this.patternArray[this.patternArrayPos])) + { + this.patternArrayPos++; + } - this.redraw(); - this.client.term.write(ansi.goto(this.position.row, this.getEndOfTextColumn())); - } - } + this.redraw(); + this.client.term.write(ansi.goto(this.position.row, this.getEndOfTextColumn())); + } + } - MaskEditTextView.super_.prototype.onKeyPress.call(this, ch, key); + MaskEditTextView.super_.prototype.onKeyPress.call(this, ch, key); }; MaskEditTextView.prototype.setPropertyValue = function(propName, value) { - switch(propName) { - case 'maskPattern' : this.setMaskPattern(value); break; - } + switch(propName) { + case 'maskPattern' : this.setMaskPattern(value); break; + } - MaskEditTextView.super_.prototype.setPropertyValue.call(this, propName, value); + MaskEditTextView.super_.prototype.setPropertyValue.call(this, propName, value); }; MaskEditTextView.prototype.getData = function() { - var rawData = MaskEditTextView.super_.prototype.getData.call(this); - - if(!rawData || 0 === rawData.length) { - return rawData; - } - - var data = ''; + var rawData = MaskEditTextView.super_.prototype.getData.call(this); - assert(rawData.length <= this.patternArray.length); + if(!rawData || 0 === rawData.length) { + return rawData; + } - var p = 0; - for(var i = 0; i < this.patternArray.length; ++i) { - if(_.isRegExp(this.patternArray[i])) { - data += rawData[p++]; - } else { - data += this.patternArray[i]; - } - } + var data = ''; - return data; + assert(rawData.length <= this.patternArray.length); + + var p = 0; + for(var i = 0; i < this.patternArray.length; ++i) { + if(_.isRegExp(this.patternArray[i])) { + data += rawData[p++]; + } else { + data += this.patternArray[i]; + } + } + + return data; }; diff --git a/core/mbf.js b/core/mbf.js new file mode 100644 index 00000000..9c3b2f6d --- /dev/null +++ b/core/mbf.js @@ -0,0 +1,59 @@ +const { Errors } = require('./enig_error'); + +// +// Utils for dealing with Microsoft Binary Format (MBF) used +// in various BASIC languages, etc. +// +// - https://en.wikipedia.org/wiki/Microsoft_Binary_Format +// - https://stackoverflow.com/questions/2268191/how-to-convert-from-ieee-python-float-to-microsoft-basic-float +// + +// Number to 32bit MBF +numToMbf32 = (v) => { + const mbf = Buffer.alloc(4); + + if (0 === v) { + return mbf; + } + + const ieee = Buffer.alloc(4); + ieee.writeFloatLE(v, 0); + + const sign = ieee[3] & 0x80; + let exp = (ieee[3] << 1) | (ieee[2] >> 7); + + if (exp === 0xfe) { + throw Errors.Invalid(`${v} cannot be converted to mbf`); + } + + exp += 2; + + mbf[3] = exp; + mbf[2] = sign | (ieee[2] & 0x7f); + mbf[1] = ieee[1]; + mbf[0] = ieee[0]; + + return mbf; +} + +mbf32ToNum = (mbf) => { + if (0 === mbf[3]) { + return 0.0; + } + + const ieee = Buffer.alloc(4); + const sign = mbf[2] & 0x80; + const exp = mbf[3] - 2; + + ieee[3] = sign | (exp >> 1); + ieee[2] = (exp << 7) | (mbf[2] & 0x7f); + ieee[1] = mbf[1]; + ieee[0] = mbf[0]; + + return ieee.readFloatLE(0); +} + +module.exports = { + numToMbf32, + mbf32ToNum, +} \ No newline at end of file diff --git a/core/mci_view_factory.js b/core/mci_view_factory.js index c5b95bb3..037121e5 100644 --- a/core/mci_view_factory.js +++ b/core/mci_view_factory.js @@ -1,204 +1,218 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const TextView = require('./text_view.js').TextView; -const EditTextView = require('./edit_text_view.js').EditTextView; -const ButtonView = require('./button_view.js').ButtonView; -const VerticalMenuView = require('./vertical_menu_view.js').VerticalMenuView; -const HorizontalMenuView = require('./horizontal_menu_view.js').HorizontalMenuView; -const SpinnerMenuView = require('./spinner_menu_view.js').SpinnerMenuView; -const ToggleMenuView = require('./toggle_menu_view.js').ToggleMenuView; -const MaskEditTextView = require('./mask_edit_text_view.js').MaskEditTextView; -//const StatusBarView = require('./status_bar_view.js').StatusBarView; -const KeyEntryView = require('./key_entry_view.js'); -const MultiLineEditTextView = require('./multi_line_edit_text_view.js').MultiLineEditTextView; -const getPredefinedMCIValue = require('./predefined_mci.js').getPredefinedMCIValue; -const ansi = require('./ansi_term.js'); +// ENiGMA½ +const TextView = require('./text_view.js').TextView; +const View = require('./view.js').View; +const EditTextView = require('./edit_text_view.js').EditTextView; +const ButtonView = require('./button_view.js').ButtonView; +const VerticalMenuView = require('./vertical_menu_view.js').VerticalMenuView; +const HorizontalMenuView = require('./horizontal_menu_view.js').HorizontalMenuView; +const SpinnerMenuView = require('./spinner_menu_view.js').SpinnerMenuView; +const ToggleMenuView = require('./toggle_menu_view.js').ToggleMenuView; +const MaskEditTextView = require('./mask_edit_text_view.js').MaskEditTextView; +const KeyEntryView = require('./key_entry_view.js'); +const MultiLineEditTextView = require('./multi_line_edit_text_view.js').MultiLineEditTextView; +const getPredefinedMCIValue = require('./predefined_mci.js').getPredefinedMCIValue; +const ansi = require('./ansi_term.js'); -// deps -const assert = require('assert'); -const _ = require('lodash'); +// deps +const assert = require('assert'); +const _ = require('lodash'); -exports.MCIViewFactory = MCIViewFactory; +exports.MCIViewFactory = MCIViewFactory; function MCIViewFactory(client) { - this.client = client; + this.client = client; } MCIViewFactory.UserViewCodes = [ - 'TL', 'ET', 'ME', 'MT', 'PL', 'BT', 'VM', 'HM', 'SM', 'TM', 'KE', + 'TL', 'ET', 'ME', 'MT', 'PL', 'BT', 'VM', 'HM', 'SM', 'TM', 'KE', - // - // XY is a special MCI code that allows finding positions - // and counts for key lookup, but does not explicitly - // represent a visible View on it's own - // - 'XY', + // + // XY is a special MCI code that allows finding positions + // and counts for key lookup, but does not explicitly + // represent a visible View on it's own + // + 'XY', ]; -MCIViewFactory.prototype.createFromMCI = function(mci, cb) { - assert(mci.code); - assert(mci.id > 0); - assert(mci.position); +MCIViewFactory.MovementCodes = [ + 'CF', 'CB', 'CU', 'CD', +]; - var view; - var options = { - client : this.client, - id : mci.id, - ansiSGR : mci.SGR, - ansiFocusSGR : mci.focusSGR, - position : { row : mci.position[0], col : mci.position[1] }, - }; +MCIViewFactory.prototype.createFromMCI = function(mci) { + assert(mci.code); + assert(mci.id > 0); + assert(mci.position); - // :TODO: These should use setPropertyValue()! - function setOption(pos, name) { - if(mci.args.length > pos && mci.args[pos].length > 0) { - options[name] = mci.args[pos]; - } - } + var view; + var options = { + client : this.client, + id : mci.id, + ansiSGR : mci.SGR, + ansiFocusSGR : mci.focusSGR, + position : { row : mci.position[0], col : mci.position[1] }, + }; - function setWidth(pos) { - if(mci.args.length > pos && mci.args[pos].length > 0) { - if(!_.isObject(options.dimens)) { - options.dimens = {}; - } - options.dimens.width = parseInt(mci.args[pos], 10); - } - } + // :TODO: These should use setPropertyValue()! + function setOption(pos, name) { + if(mci.args.length > pos && mci.args[pos].length > 0) { + options[name] = mci.args[pos]; + } + } - function setFocusOption(pos, name) { - if(mci.focusArgs && mci.focusArgs.length > pos && mci.focusArgs[pos].length > 0) { - options[name] = mci.focusArgs[pos]; - } - } + function setWidth(pos) { + if(mci.args.length > pos && mci.args[pos].length > 0) { + if(!_.isObject(options.dimens)) { + options.dimens = {}; + } + options.dimens.width = parseInt(mci.args[pos], 10); + } + } - // - // Note: Keep this in sync with UserViewCodes above! - // - switch(mci.code) { - // Text Label (Text View) - case 'TL' : - setOption(0, 'textStyle'); - setOption(1, 'justify'); - setWidth(2); + function setFocusOption(pos, name) { + if(mci.focusArgs && mci.focusArgs.length > pos && mci.focusArgs[pos].length > 0) { + options[name] = mci.focusArgs[pos]; + } + } - view = new TextView(options); - break; + // + // Note: Keep this in sync with UserViewCodes above! + // + switch(mci.code) { + // Text Label (Text View) + case 'TL' : + setOption(0, 'textStyle'); + setOption(1, 'justify'); + setWidth(2); - // Edit Text - case 'ET' : - setWidth(0); + view = new TextView(options); + break; - setOption(1, 'textStyle'); - setFocusOption(0, 'focusTextStyle'); + // Edit Text + case 'ET' : + setWidth(0); - view = new EditTextView(options); - break; + setOption(1, 'textStyle'); + setFocusOption(0, 'focusTextStyle'); - // Masked Edit Text - case 'ME' : - setOption(0, 'textStyle'); - setFocusOption(0, 'focusTextStyle'); + view = new EditTextView(options); + break; - view = new MaskEditTextView(options); - break; + // Masked Edit Text + case 'ME' : + setOption(0, 'textStyle'); + setFocusOption(0, 'focusTextStyle'); - // Multi Line Edit Text - case 'MT' : - // :TODO: apply params - view = new MultiLineEditTextView(options); - break; + view = new MaskEditTextView(options); + break; - // Pre-defined Label (Text View) - // :TODO: Currently no real point of PL -- @method replaces this pretty much... probably remove - case 'PL' : - if(mci.args.length > 0) { - options.text = getPredefinedMCIValue(this.client, mci.args[0]); - if(options.text) { - setOption(1, 'textStyle'); - setOption(2, 'justify'); - setWidth(3); + // Multi Line Edit Text + case 'MT' : + // :TODO: apply params + view = new MultiLineEditTextView(options); + break; - view = new TextView(options); - } - } - break; + // Pre-defined Label (Text View) + // :TODO: Currently no real point of PL -- @method replaces this pretty much... probably remove + case 'PL' : + if(mci.args.length > 0) { + options.text = getPredefinedMCIValue(this.client, mci.args[0]); + if(options.text) { + setOption(1, 'textStyle'); + setOption(2, 'justify'); + setWidth(3); - // Button - case 'BT' : - if(mci.args.length > 0) { - options.dimens = { width : parseInt(mci.args[0], 10) }; - } + view = new TextView(options); + } + } + break; - setOption(1, 'textStyle'); - setOption(2, 'justify'); + // Button + case 'BT' : + if(mci.args.length > 0) { + options.dimens = { width : parseInt(mci.args[0], 10) }; + } - setFocusOption(0, 'focusTextStyle'); + setOption(1, 'textStyle'); + setOption(2, 'justify'); - view = new ButtonView(options); - break; + setFocusOption(0, 'focusTextStyle'); - // Vertial Menu - case 'VM' : - setOption(0, 'itemSpacing'); - setOption(1, 'justify'); - setOption(2, 'textStyle'); - - setFocusOption(0, 'focusTextStyle'); + view = new ButtonView(options); + break; - view = new VerticalMenuView(options); - break; + // Vertial Menu + case 'VM' : + setOption(0, 'itemSpacing'); + setOption(1, 'justify'); + setOption(2, 'textStyle'); - // Horizontal Menu - case 'HM' : - setOption(0, 'itemSpacing'); - setOption(1, 'textStyle'); + setFocusOption(0, 'focusTextStyle'); - setFocusOption(0, 'focusTextStyle'); + view = new VerticalMenuView(options); + break; - view = new HorizontalMenuView(options); - break; + // Horizontal Menu + case 'HM' : + setOption(0, 'itemSpacing'); + setOption(1, 'textStyle'); - case 'SM' : - setOption(0, 'textStyle'); - setOption(1, 'justify'); + setFocusOption(0, 'focusTextStyle'); - setFocusOption(0, 'focusTextStyle'); - - view = new SpinnerMenuView(options); - break; + view = new HorizontalMenuView(options); + break; - case 'TM' : - if(mci.args.length > 0) { - var styleSG1 = { fg : parseInt(mci.args[0], 10) }; - if(mci.args.length > 1) { - styleSG1.bg = parseInt(mci.args[1], 10); - } - options.styleSG1 = ansi.getSGRFromGraphicRendition(styleSG1, true); - } + case 'SM' : + setOption(0, 'textStyle'); + setOption(1, 'justify'); - setFocusOption(0, 'focusTextStyle'); + setFocusOption(0, 'focusTextStyle'); - view = new ToggleMenuView(options); - break; + view = new SpinnerMenuView(options); + break; - case 'KE' : - view = new KeyEntryView(options); - break; + case 'TM' : + if(mci.args.length > 0) { + var styleSG1 = { fg : parseInt(mci.args[0], 10) }; + if(mci.args.length > 1) { + styleSG1.bg = parseInt(mci.args[1], 10); + } + options.styleSG1 = ansi.getSGRFromGraphicRendition(styleSG1, true); + } - default : - options.text = getPredefinedMCIValue(this.client, mci.code); - if(_.isString(options.text)) { - setWidth(0); + setFocusOption(0, 'focusTextStyle'); - setOption(1, 'textStyle'); - setOption(2, 'justify'); + view = new ToggleMenuView(options); + break; - view = new TextView(options); - } - break; - } + case 'KE' : + view = new KeyEntryView(options); + break; - return view; + case 'XY' : + view = new View(options); + break; + + default : + if(!MCIViewFactory.MovementCodes.includes(mci.code)) { + options.text = getPredefinedMCIValue(this.client, mci.code); + if(_.isString(options.text)) { + setWidth(0); + + setOption(1, 'textStyle'); + setOption(2, 'justify'); + + view = new TextView(options); + } + } + break; + } + + if(view) { + view.mciCode = mci.code; + } + + return view; }; diff --git a/core/menu_module.js b/core/menu_module.js index 884389ca..804065a7 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -1,426 +1,692 @@ /* jslint node: true */ 'use strict'; -const PluginModule = require('./plugin_module.js').PluginModule; -const theme = require('./theme.js'); -const ansi = require('./ansi_term.js'); -const ViewController = require('./view_controller.js').ViewController; -const menuUtil = require('./menu_util.js'); -const Config = require('./config.js').config; -const stringFormat = require('../core/string_format.js'); -const MultiLineEditTextView = require('../core/multi_line_edit_text_view.js').MultiLineEditTextView; -const Errors = require('../core/enig_error.js').Errors; +const PluginModule = require('./plugin_module.js').PluginModule; +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const ViewController = require('./view_controller.js').ViewController; +const menuUtil = require('./menu_util.js'); +const Config = require('./config.js').get; +const stringFormat = require('../core/string_format.js'); +const MultiLineEditTextView = require('../core/multi_line_edit_text_view.js').MultiLineEditTextView; +const Errors = require('../core/enig_error.js').Errors; +const { getPredefinedMCIValue } = require('../core/predefined_mci.js'); -// deps -const async = require('async'); -const assert = require('assert'); -const _ = require('lodash'); +// deps +const async = require('async'); +const assert = require('assert'); +const _ = require('lodash'); +const iconvDecode = require('iconv-lite').decode; exports.MenuModule = class MenuModule extends PluginModule { - - constructor(options) { - super(options); - - this.menuName = options.menuName; - this.menuConfig = options.menuConfig; - this.client = options.client; - this.menuConfig.options = options.menuConfig.options || {}; - this.menuMethods = {}; // methods called from @method's - this.menuConfig.config = this.menuConfig.config || {}; - - this.cls = _.isBoolean(this.menuConfig.options.cls) ? this.menuConfig.options.cls : Config.menus.cls; - - this.viewControllers = {}; - } - - enter() { - this.initSequence(); - } - - leave() { - this.detachViewControllers(); - } - - initSequence() { - const self = this; - const mciData = {}; - let pausePosition; - - async.series( - [ - function beforeDisplayArt(callback) { - self.beforeArt(callback); - }, - function displayMenuArt(callback) { - if(!_.isString(self.menuConfig.art)) { - return callback(null); - } - - self.displayAsset( - self.menuConfig.art, - self.menuConfig.options, - (err, artData) => { - if(err) { - self.client.log.trace('Could not display art', { art : self.menuConfig.art, reason : err.message } ); - } else { - mciData.menu = artData.mciMap; - } - - return callback(null); // any errors are non-fatal - } - ); - }, - function moveToPromptLocation(callback) { - if(self.menuConfig.prompt) { - // :TODO: fetch and move cursor to prompt location, if supplied. See notes/etc. on placements - } - - return callback(null); - }, - function displayPromptArt(callback) { - if(!_.isString(self.menuConfig.prompt)) { - return callback(null); - } - - if(!_.isObject(self.menuConfig.promptConfig)) { - return callback(Errors.MissingConfig('Prompt specified but no "promptConfig" block found')); - } - - self.displayAsset( - self.menuConfig.promptConfig.art, - self.menuConfig.options, - (err, artData) => { - if(artData) { - mciData.prompt = artData.mciMap; - } - return callback(err); // pass err here; prompts *must* have art - } - ); - }, - function recordCursorPosition(callback) { - if(!self.shouldPause()) { - return callback(null); // cursor position not needed - } - - self.client.once('cursor position report', pos => { - pausePosition = { row : pos[0], col : 1 }; - self.client.log.trace('After art position recorded', pausePosition ); - return callback(null); - }); - - self.client.term.rawWrite(ansi.queryPos()); - }, - function afterArtDisplayed(callback) { - return self.mciReady(mciData, callback); - }, - function displayPauseIfRequested(callback) { - if(!self.shouldPause()) { - return callback(null); - } - - return self.pausePrompt(pausePosition, callback); - }, - function finishAndNext(callback) { - self.finishedLoading(); - return self.autoNextMenu(callback); - } - ], - err => { - if(err) { - self.client.log.warn('Error during init sequence', { error : err.message } ); - - return self.prevMenu( () => { /* dummy */ } ); - } - } - ); - } - - beforeArt(cb) { - if(_.isNumber(this.menuConfig.options.baudRate)) { - // :TODO: some terminals not supporting cterm style emulated baud rate end up displaying a broken ESC sequence or a single "r" here - this.client.term.rawWrite(ansi.setEmulatedBaudRate(this.menuConfig.options.baudRate)); - } - - if(this.cls) { - this.client.term.rawWrite(ansi.resetScreen()); - } - - return cb(null); - } - - mciReady(mciData, cb) { - // available for sub-classes - return cb(null); - } - - finishedLoading() { - // nothing in base - } - - getSaveState() { - // nothing in base - } - - restoreSavedState(/*savedState*/) { - // nothing in base - } - - getMenuResult() { - // default to the formData that was provided @ a submit, if any - return this.submitFormData; - } - - nextMenu(cb) { - if(!this.haveNext()) { - return this.prevMenu(cb); // no next, go to prev - } - - return this.client.menuStack.next(cb); - } - - prevMenu(cb) { - return this.client.menuStack.prev(cb); - } - - gotoMenu(name, options, cb) { - return this.client.menuStack.goto(name, options, cb); - } - - addViewController(name, vc) { - assert(!this.viewControllers[name], `ViewController by the name of "${name}" already exists!`); - - this.viewControllers[name] = vc; - return vc; - } - - detachViewControllers() { - Object.keys(this.viewControllers).forEach( name => { - this.viewControllers[name].detachClientEvents(); - }); - } - - shouldPause() { - return ('end' === this.menuConfig.options.pause || true === this.menuConfig.options.pause); - } - - hasNextTimeout() { - return _.isNumber(this.menuConfig.options.nextTimeout); - } - - haveNext() { - return (_.isString(this.menuConfig.next) || _.isArray(this.menuConfig.next)); - } - - autoNextMenu(cb) { - const self = this; - - function gotoNextMenu() { - if(self.haveNext()) { - return menuUtil.handleNext(self.client, self.menuConfig.next, {}, cb); - } else { - return self.prevMenu(cb); - } - } - - if(_.has(this.menuConfig, 'runtime.autoNext') && true === this.menuConfig.runtime.autoNext) { - if(this.hasNextTimeout()) { - setTimeout( () => { - return gotoNextMenu(); - }, this.menuConfig.options.nextTimeout); - } else { - return gotoNextMenu(); - } - } - } - - standardMCIReadyHandler(mciData, cb) { - // - // A quick rundown: - // * We may have mciData.menu, mciData.prompt, or both. - // * Prompt form is favored over menu form if both are present. - // * Standard/prefdefined MCI entries must load both (e.g. %BN is expected to resolve) - // - const self = this; - - async.series( - [ - function addViewControllers(callback) { - _.forEach(mciData, (mciMap, name) => { - assert('menu' === name || 'prompt' === name); - self.addViewController(name, new ViewController( { client : self.client } ) ); - }); - - return callback(null); - }, - function createMenu(callback) { - if(!self.viewControllers.menu) { - return callback(null); - } - - const menuLoadOpts = { - mciMap : mciData.menu, - callingMenu : self, - withoutForm : _.isObject(mciData.prompt), - }; - - self.viewControllers.menu.loadFromMenuConfig(menuLoadOpts, err => { - return callback(err); - }); - }, - function createPrompt(callback) { - if(!self.viewControllers.prompt) { - return callback(null); - } - - const promptLoadOpts = { - callingMenu : self, - mciMap : mciData.prompt, - }; - - self.viewControllers.prompt.loadFromPromptConfig(promptLoadOpts, err => { - return callback(err); - }); - } - ], - err => { - return cb(err); - } - ); - } - - displayAsset(name, options, cb) { - if(_.isFunction(options)) { - cb = options; - options = {}; - } - - if(options.clearScreen) { - this.client.term.rawWrite(ansi.resetScreen()); - } - - return theme.displayThemedAsset( - name, - this.client, - Object.assign( { font : this.menuConfig.config.font }, options ), - (err, artData) => { - if(cb) { - return cb(err, artData); - } - } - ); - } - - prepViewController(name, formId, artData, cb) { - if(_.isUndefined(this.viewControllers[name])) { - const vcOpts = { - client : this.client, - formId : formId, - }; - - const vc = this.addViewController(name, new ViewController(vcOpts)); - - const loadOpts = { - callingMenu : this, - mciMap : artData.mciMap, - formId : formId, - }; - - return vc.loadFromMenuConfig(loadOpts, err => { - return cb(err, vc); - }); - } - - this.viewControllers[name].setFocus(true); - - return cb(null, this.viewControllers[name]); - } - - prepViewControllerWithArt(name, formId, options, cb) { - this.displayAsset( - this.menuConfig.config.art[name], - options, - (err, artData) => { - if(err) { - return cb(err); - } - - return this.prepViewController(name, formId, artData, cb); - } - ); - } - - optionalMoveToPosition(position) { - if(position) { - position.x = position.row || position.x || 1; - position.y = position.col || position.y || 1; - - this.client.term.rawWrite(ansi.goto(position.x, position.y)); - } - } - - pausePrompt(position, cb) { - if(!cb && _.isFunction(position)) { - cb = position; - position = null; - } - - this.optionalMoveToPosition(position); - - return theme.displayThemedPause(this.client, cb); - } - - /* - :TODO: this needs quite a bit of work - but would be nice: promptForInput(..., (err, formData) => ... ) - promptForInput(formName, name, options, cb) { - if(!cb && _.isFunction(options)) { - cb = options; - options = {}; - } - - options.viewController = this.viewControllers[formName]; - - this.optionalMoveToPosition(options.position); - - return theme.displayThemedPrompt(name, this.client, options, cb); - } - */ - - setViewText(formName, mciId, text, appendMultiLine) { - const view = this.viewControllers[formName].getView(mciId); - if(!view) { - return; - } - - if(appendMultiLine && (view instanceof MultiLineEditTextView)) { - view.addText(text); - } else { - view.setText(text); - } - } - - updateCustomViewTextsWithFilter(formName, startId, fmtObj, options) { - options = options || {}; - - let textView; - let customMciId = startId; - const config = this.menuConfig.config; - const endId = options.endId || 99; // we'll fail to get a view before 99 - - while(customMciId <= endId && (textView = this.viewControllers[formName].getView(customMciId)) ) { - const key = `${formName}InfoFormat${customMciId}`; // e.g. "mainInfoFormat10" - const format = config[key]; - - if(format && (!options.filter || options.filter.find(f => format.indexOf(f) > - 1))) { - const text = stringFormat(format, fmtObj); - - if(options.appendMultiLine && (textView instanceof MultiLineEditTextView)) { - textView.addText(text); - } else { - textView.setText(text); - } - } - - ++customMciId; - } - } + + constructor(options) { + super(options); + + this.menuName = options.menuName; + this.menuConfig = options.menuConfig; + this.client = options.client; + this.menuMethods = {}; // methods called from @method's + this.menuConfig.config = this.menuConfig.config || {}; + this.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls); + this.viewControllers = {}; + this.interrupt = (_.get(this.menuConfig.config, 'interrupt', MenuModule.InterruptTypes.Queued)).toLowerCase(); + + if(MenuModule.InterruptTypes.Realtime === this.interrupt) { + this.realTimeInterrupt = 'blocked'; + } + } + + static get InterruptTypes() { + return { + Never : 'never', + Queued : 'queued', + Realtime : 'realtime', + }; + } + + enter() { + this.initSequence(); + } + + leave() { + this.detachViewControllers(); + } + + initSequence() { + const self = this; + const mciData = {}; + let pausePosition; + + const hasArt = () => { + return _.isString(self.menuConfig.art) || + (Array.isArray(self.menuConfig.art) && _.has(self.menuConfig.art[0], 'acs')); + }; + + async.series( + [ + function beforeArtInterrupt(callback) { + return self.displayQueuedInterruptions(callback); + }, + function beforeDisplayArt(callback) { + return self.beforeArt(callback); + }, + function displayMenuArt(callback) { + if(!hasArt()) { + return callback(null); + } + + self.displayAsset( + self.menuConfig.art, + self.menuConfig.config, + (err, artData) => { + if(err) { + self.client.log.trace('Could not display art', { art : self.menuConfig.art, reason : err.message } ); + } else { + mciData.menu = artData.mciMap; + } + + return callback(null); // any errors are non-fatal + } + ); + }, + function moveToPromptLocation(callback) { + if(self.menuConfig.prompt) { + // :TODO: fetch and move cursor to prompt location, if supplied. See notes/etc. on placements + } + + return callback(null); + }, + function displayPromptArt(callback) { + if(!_.isString(self.menuConfig.prompt)) { + return callback(null); + } + + if(!_.isObject(self.menuConfig.promptConfig)) { + return callback(Errors.MissingConfig('Prompt specified but no "promptConfig" block found')); + } + + self.displayAsset( + self.menuConfig.promptConfig.art, + self.menuConfig.config, + (err, artData) => { + if(artData) { + mciData.prompt = artData.mciMap; + } + return callback(err); // pass err here; prompts *must* have art + } + ); + }, + function recordCursorPosition(callback) { + if(!self.shouldPause()) { + return callback(null); // cursor position not needed + } + + self.client.once('cursor position report', pos => { + pausePosition = { row : pos[0], col : 1 }; + self.client.log.trace('After art position recorded', pausePosition ); + return callback(null); + }); + + self.client.term.rawWrite(ansi.queryPos()); + }, + function afterArtDisplayed(callback) { + return self.mciReady(mciData, callback); + }, + function displayPauseIfRequested(callback) { + if(!self.shouldPause()) { + return callback(null); + } + + return self.pausePrompt(pausePosition, callback); + }, + function finishAndNext(callback) { + self.finishedLoading(); + self.realTimeInterrupt = 'allowed'; + return self.autoNextMenu(callback); + } + ], + err => { + if(err) { + self.client.log.warn('Error during init sequence', { error : err.message } ); + + return self.prevMenu( () => { /* dummy */ } ); + } + } + ); + } + + beforeArt(cb) { + if(_.isNumber(this.menuConfig.config.baudRate)) { + // :TODO: some terminals not supporting cterm style emulated baud rate end up displaying a broken ESC sequence or a single "r" here + this.client.term.rawWrite(ansi.setEmulatedBaudRate(this.menuConfig.config.baudRate)); + } + + if(this.cls) { + this.client.term.rawWrite(ansi.resetScreen()); + } + + return cb(null); + } + + mciReady(mciData, cb) { + // available for sub-classes + return cb(null); + } + + finishedLoading() { + // nothing in base + } + + displayQueuedInterruptions(cb) { + if(MenuModule.InterruptTypes.Never === this.interrupt) { + return cb(null); + } + + let opts = { cls : true }; // clear screen for first message + + async.whilst( + (callback) => callback(null, this.client.interruptQueue.hasItems()), + next => { + this.client.interruptQueue.displayNext(opts, err => { + opts = {}; + return next(err); + }); + }, + err => { + return cb(err); + } + ); + } + + attemptInterruptNow(interruptItem, cb) { + if(this.realTimeInterrupt !== 'allowed' || MenuModule.InterruptTypes.Realtime !== this.interrupt) { + return cb(null, false); // don't eat up the item; queue for later + } + + this.realTimeInterrupt = 'blocked'; + + // + // Default impl: clear screen -> standard display -> reload menu + // + const done = (err, removeFromQueue) => { + this.realTimeInterrupt = 'allowed'; + return cb(err, removeFromQueue); + }; + + this.client.interruptQueue.displayWithItem( + Object.assign({}, interruptItem, { cls : true }), + err => { + if(err) { + return done(err, false); + } + this.reload(err => { + return done(err, err ? false : true); + }); + }); + } + + getSaveState() { + // nothing in base + } + + restoreSavedState(/*savedState*/) { + // nothing in base + } + + getMenuResult() { + // default to the formData that was provided @ a submit, if any + return this.submitFormData; + } + + nextMenu(cb) { + if(!this.haveNext()) { + return this.prevMenu(cb); // no next, go to prev + } + + this.displayQueuedInterruptions( () => { + return this.client.menuStack.next(cb); + }); + } + + prevMenu(cb) { + this.displayQueuedInterruptions( () => { + return this.client.menuStack.prev(cb); + }); + } + + gotoMenu(name, options, cb) { + 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(); + return this.client.menuStack.goto(prevMenu.name, cb); + } + + prevMenuOnTimeout(timeout, cb) { + setTimeout( () => { + return this.prevMenu(cb); + }, timeout); + } + + addViewController(name, vc) { + assert(!this.viewControllers[name], `ViewController by the name of "${name}" already exists!`); + + this.viewControllers[name] = vc; + return vc; + } + + removeViewController(name) { + if(this.viewControllers[name]) { + this.viewControllers[name].detachClientEvents(); + delete this.viewControllers[name]; + } + } + + detachViewControllers() { + Object.keys(this.viewControllers).forEach( name => { + this.viewControllers[name].detachClientEvents(); + }); + } + + shouldPause() { + return ('end' === this.menuConfig.config.pause || true === this.menuConfig.config.pause); + } + + hasNextTimeout() { + return _.isNumber(this.menuConfig.config.nextTimeout); + } + + haveNext() { + return (_.isString(this.menuConfig.next) || _.isArray(this.menuConfig.next)); + } + + autoNextMenu(cb) { + const gotoNextMenu = () => { + if(this.haveNext()) { + this.displayQueuedInterruptions( () => { + return menuUtil.handleNext(this.client, this.menuConfig.next, {}, cb); + }); + } else { + return this.prevMenu(cb); + } + }; + + if(_.has(this.menuConfig, 'runtime.autoNext') && true === this.menuConfig.runtime.autoNext) { + if(this.hasNextTimeout()) { + setTimeout( () => { + return gotoNextMenu(); + }, this.menuConfig.config.nextTimeout); + } else { + return gotoNextMenu(); + } + } + } + + standardMCIReadyHandler(mciData, cb) { + // + // A quick rundown: + // * We may have mciData.menu, mciData.prompt, or both. + // * Prompt form is favored over menu form if both are present. + // * Standard/predefined MCI entries must load both (e.g. %BN is expected to resolve) + // + const self = this; + + async.series( + [ + function addViewControllers(callback) { + _.forEach(mciData, (mciMap, name) => { + assert('menu' === name || 'prompt' === name); + self.addViewController(name, new ViewController( { client : self.client } ) ); + }); + + return callback(null); + }, + function createMenu(callback) { + if(!self.viewControllers.menu) { + return callback(null); + } + + const menuLoadOpts = { + mciMap : mciData.menu, + callingMenu : self, + withoutForm : _.isObject(mciData.prompt), + }; + + self.viewControllers.menu.loadFromMenuConfig(menuLoadOpts, err => { + return callback(err); + }); + }, + function createPrompt(callback) { + if(!self.viewControllers.prompt) { + return callback(null); + } + + const promptLoadOpts = { + callingMenu : self, + mciMap : mciData.prompt, + }; + + self.viewControllers.prompt.loadFromPromptConfig(promptLoadOpts, err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + + displayAsset(nameOrData, options, cb) { + if(_.isFunction(options)) { + cb = options; + options = {}; + } + + if(options.clearScreen) { + 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( + nameOrData, + this.client, + options, + (err, artData) => { + if(cb) { + return cb(err, artData); + } + } + ); + } + + prepViewController(name, formId, mciMap, cb) { + const needsCreated = _.isUndefined(this.viewControllers[name]); + if(needsCreated) { + const vcOpts = { + client : this.client, + formId : formId, + }; + + const vc = this.addViewController(name, new ViewController(vcOpts)); + + const loadOpts = { + callingMenu : this, + mciMap : mciMap, + formId : formId, + }; + + return vc.loadFromMenuConfig(loadOpts, err => { + return cb(err, vc, true); + }); + } + + this.viewControllers[name].setFocus(true); + + return cb(null, this.viewControllers[name], false); + } + + prepViewControllerWithArt(name, formId, options, cb) { + this.displayAsset( + this.menuConfig.config.art[name], + options, + (err, artData) => { + if(err) { + return cb(err); + } + + return this.prepViewController(name, formId, artData.mciMap, cb); + } + ); + } + + optionalMoveToPosition(position) { + if(position) { + position.x = position.row || position.x || 1; + position.y = position.col || position.y || 1; + + this.client.term.rawWrite(ansi.goto(position.x, position.y)); + } + } + + pausePrompt(position, cb) { + if(!cb && _.isFunction(position)) { + cb = position; + position = null; + } + + this.optionalMoveToPosition(position); + + return theme.displayThemedPause(this.client, cb); + } + + promptForInput( { formName, formId, promptName, prevFormName, position } = {}, options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + + options.viewController = this.addViewController( + formName, + new ViewController( { client : this.client, formId } ) + ); + + options.trailingLF = _.get(options, 'trailingLF', false); + + let prevVc; + if(prevFormName) { + prevVc = this.viewControllers[prevFormName]; + if(prevVc) { + prevVc.setFocus(false); + } + } + + //let artHeight; + options.submitNotify = () => { + if(prevVc) { + prevVc.setFocus(true); + } + this.removeViewController(formName); + if(options.clearAtSubmit) { + this.optionalMoveToPosition(position); + if(options.clearWidth) { + this.client.term.rawWrite(`${ansi.reset()}${' '.repeat(options.clearWidth)}`); + } else { + // :TODO: handle multi-rows via artHeight + this.client.term.rawWrite(ansi.eraseLine()); + } + } + }; + + options.viewController.setFocus(true); + + this.optionalMoveToPosition(position); + theme.displayThemedPrompt(promptName, this.client, options, (err, artInfo) => { + /* + if(artInfo) { + artHeight = artInfo.height; + } + */ + return cb(err, artInfo); + }); + } + + setViewText(formName, mciId, text, appendMultiLine) { + const view = this.getView(formName, mciId); + if(!view) { + return; + } + + if(appendMultiLine && (view instanceof MultiLineEditTextView)) { + view.addText(text); + } else { + view.setText(text); + } + } + + getView(formName, id) { + const form = this.viewControllers[formName]; + return form && form.getView(id); + } + + updateCustomViewTextsWithFilter(formName, startId, fmtObj, options) { + options = options || {}; + + let textView; + let customMciId = startId; + const config = this.menuConfig.config; + const endId = options.endId || 99; // we'll fail to get a view before 99 + + while(customMciId <= endId && (textView = this.viewControllers[formName].getView(customMciId)) ) { + const key = `${formName}InfoFormat${customMciId}`; // e.g. "mainInfoFormat10" + const format = config[key]; + + if(format && (!options.filter || options.filter.find(f => format.indexOf(f) > - 1))) { + const text = stringFormat(format, fmtObj); + + if(options.appendMultiLine && (textView instanceof MultiLineEditTextView)) { + textView.addText(text); + } else { + textView.setText(text); + } + } + + ++customMciId; + } + } + + refreshPredefinedMciViewsByCode(formName, mciCodes) { + const form = _.get(this, [ 'viewControllers', formName] ); + if(form) { + form.getViewsByMciCode(mciCodes).forEach(v => { + if(!v.setText) { + return; + } + + v.setText(getPredefinedMCIValue(this.client, v.mciCode)); + }); + } + } + + validateMCIByViewIds(formName, viewIds, cb) { + if(!Array.isArray(viewIds)) { + viewIds = [ viewIds ]; + } + const form = _.get(this, [ 'viewControllers', formName ] ); + if(!form) { + return cb(Errors.DoesNotExist(`Form does not exist: ${formName}`)); + } + for(let i = 0; i < viewIds.length; ++i) { + if(!form.hasView(viewIds[i])) { + return cb(Errors.MissingMci(`Missing MCI ${viewIds[i]}`)); + } + } + return cb(null); + } + + validateConfigFields(fields, cb) { + // + // fields is expected to be { key : type || validator(key, config) } + // where |type| is 'string', 'array', object', 'number' + // + if(!_.isObject(fields)) { + return cb(Errors.Invalid('Invalid validator!')); + } + + const config = this.config || this.menuConfig.config; + let firstBadKey; + let badReason; + const good = _.every(fields, (type, key) => { + if(_.isFunction(type)) { + if(!type(key, config)) { + firstBadKey = key; + badReason = 'Validate failure'; + return false; + } + return true; + } + + const c = config[key]; + let typeOk; + if(_.isUndefined(c)) { + typeOk = false; + badReason = `Missing "${key}", expected ${type}`; + } else { + switch(type) { + case 'string' : typeOk = _.isString(c); break; + case 'object' : typeOk = _.isObject(c); break; + case 'array' : typeOk = Array.isArray(c); break; + case 'number' : typeOk = !isNaN(parseInt(c)); break; + default : + typeOk = false; + badReason = `Don't know how to validate ${type}`; + break; + } + } + if(!typeOk) { + firstBadKey = key; + if(!badReason) { + badReason = `Expected ${type}`; + } + } + return typeOk; + }); + + return cb(good ? null : Errors.Invalid(`Invalid or missing config option "${firstBadKey}" (${badReason})`)); + } }; diff --git a/core/menu_stack.js b/core/menu_stack.js index f4b29460..42cd6987 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -1,174 +1,214 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const loadMenu = require('./menu_util.js').loadMenu; -const Errors = require('./enig_error.js').Errors; +// ENiGMA½ +const loadMenu = require('./menu_util.js').loadMenu; +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); +const { + getResolvedSpec +} = require('./menu_util.js'); -// deps -const _ = require('lodash'); -const assert = require('assert'); +// deps +const _ = require('lodash'); +const assert = require('assert'); -// :TODO: Stack is backwards.... top should be most recent! :) +// :TODO: Stack is backwards.... top should be most recent! :) module.exports = class MenuStack { - constructor(client) { - this.client = client; - this.stack = []; - } + constructor(client) { + this.client = client; + this.stack = []; + } - push(moduleInfo) { - return this.stack.push(moduleInfo); - } + push(moduleInfo) { + return this.stack.push(moduleInfo); + } - pop() { - return this.stack.pop(); - } + pop() { + return this.stack.pop(); + } - peekPrev() { - if(this.stackSize > 1) { - return this.stack[this.stack.length - 2]; - } - } + peekPrev() { + if(this.stackSize > 1) { + return this.stack[this.stack.length - 2]; + } + } - top() { - if(this.stackSize > 0) { - return this.stack[this.stack.length - 1]; - } - } + top() { + if(this.stackSize > 0) { + return this.stack[this.stack.length - 1]; + } + } - get stackSize() { - return this.stack.length; - } + get stackSize() { + return this.stack.length; + } - get currentModule() { - const top = this.top(); - if(top) { - return top.instance; - } - } + get currentModule() { + const top = this.top(); + assert(top, 'Empty menu stack!'); + return top.instance; + } - next(cb) { - const currentModuleInfo = this.top(); - assert(currentModuleInfo, 'Empty menu stack!'); + next(cb) { + const currentModuleInfo = this.top(); + const menuConfig = currentModuleInfo.instance.menuConfig; + 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) : + Errors.MenuStack('Invalid or missing "next" member in menu config', ErrorReasons.InvalidNextMenu) + ); + } - const menuConfig = currentModuleInfo.instance.menuConfig; - let nextMenu; + if(nextMenu === currentModuleInfo.name) { + return cb(Errors.MenuStack('Menu config "next" specifies current menu', ErrorReasons.AlreadyThere)); + } - if(_.isArray(menuConfig.next)) { - nextMenu = this.client.acs.getConditionalValue(menuConfig.next, 'next'); - if(!nextMenu) { - return cb(Errors.MenuStack('No matching condition for "next"', 'NOCONDMATCH')); - } - } else if(_.isString(menuConfig.next)) { - nextMenu = menuConfig.next; - } else { - return cb(Errors.MenuStack('Invalid or missing "next" member in menu config', 'BADNEXT')); - } + this.goto(nextMenu, { }, cb); + } - if(nextMenu === currentModuleInfo.name) { - return cb(Errors.MenuStack('Menu config "next" specifies current menu', 'ALREADYTHERE')); - } + prev(cb) { + const menuResult = this.top().instance.getMenuResult(); - this.goto(nextMenu, { }, cb); - } + // :TODO: leave() should really take a cb... + this.pop().instance.leave(); // leave & remove current - prev(cb) { - const menuResult = this.top().instance.getMenuResult(); + const previousModuleInfo = this.pop(); // get previous - // :TODO: leave() should really take a cb... - this.pop().instance.leave(); // leave & remove current - - const previousModuleInfo = this.pop(); // get previous + if(previousModuleInfo) { + const opts = { + extraArgs : previousModuleInfo.extraArgs, + savedState : previousModuleInfo.savedState, + lastMenuResult : menuResult, + }; - if(previousModuleInfo) { - const opts = { - extraArgs : previousModuleInfo.extraArgs, - savedState : previousModuleInfo.savedState, - lastMenuResult : menuResult, - }; + return this.goto(previousModuleInfo.name, opts, cb); + } - return this.goto(previousModuleInfo.name, opts, cb); - } - - return cb(Errors.MenuStack('No previous menu available', 'NOPREV')); - } + return cb(Errors.MenuStack('No previous menu available', ErrorReasons.NoPreviousMenu)); + } - goto(name, options, cb) { - const currentModuleInfo = this.top(); + goto(name, options, cb) { + const currentModuleInfo = this.top(); - if(!cb && _.isFunction(options)) { - cb = options; - options = {}; - } + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } - const self = this; + options = options || {}; + const self = this; - if(currentModuleInfo && name === currentModuleInfo.name) { - if(cb) { - cb(Errors.MenuStack('Already at supplied menu', 'ALREADYTHERE')); - } - return; - } + if(currentModuleInfo && name === currentModuleInfo.name) { + if(cb) { + cb(Errors.MenuStack('Already at supplied menu', ErrorReasons.AlreadyThere)); + } + return; + } - const loadOpts = { - name : name, - client : self.client, - }; + const loadOpts = { + name : name, + client : self.client, + }; - if(_.isObject(options)) { - loadOpts.extraArgs = options.extraArgs; - loadOpts.lastMenuResult = options.lastMenuResult; - } + if(currentModuleInfo && currentModuleInfo.menuFlags.includes('forwardArgs')) { + loadOpts.extraArgs = currentModuleInfo.extraArgs; + } else { + loadOpts.extraArgs = options.extraArgs || _.get(options, 'formData.value'); + } + loadOpts.lastMenuResult = options.lastMenuResult; - loadMenu(loadOpts, (err, modInst) => { - if(err) { - // :TODO: probably should just require a cb... - const errCb = cb || self.client.defaultHandlerMissingMod(); - errCb(err); - } else { - self.client.log.debug( { menuName : name }, 'Goto menu module'); + loadMenu(loadOpts, (err, modInst) => { + if(err) { + // :TODO: probably should just require a cb... + const errCb = cb || self.client.defaultHandlerMissingMod(); + errCb(err); + } else { + self.client.log.debug( { menuName : name }, 'Goto menu module'); - if(currentModuleInfo) { - // save stack state - currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState(); + if(!this.client.acs.hasMenuModuleAccess(modInst)) { + if(cb) { + return cb(Errors.AccessDenied('No access to this menu')); + } + return; + } - currentModuleInfo.instance.leave(); + // + // Handle deprecated 'options' block by merging to config and warning user. + // :TODO: Remove in 0.0.10+ + // + if(modInst.menuConfig.options) { + self.client.log.warn( + { options : modInst.menuConfig.options }, + 'Use of "options" is deprecated. Move relevant members to "config" block! Support will be fully removed in future versions' + ); + Object.assign(modInst.menuConfig.config || {}, modInst.menuConfig.options); + delete modInst.menuConfig.options; + } - const menuFlags = (options && Array.isArray(options.menuFlags)) ? options.menuFlags : modInst.menuConfig.options.menuFlags; + // + // If menuFlags were supplied in menu.hjson, they should win over + // anything supplied in code. + // + let menuFlags; + if(0 === modInst.menuConfig.config.menuFlags.length) { + menuFlags = Array.isArray(options.menuFlags) ? options.menuFlags : []; + } else { + menuFlags = modInst.menuConfig.config.menuFlags; - if(menuFlags.includes('noHistory')) { - this.pop().instance.leave(); // leave & remove current - } - } + // in code we can ask to merge in + if(Array.isArray(options.menuFlags) && options.menuFlags.includes('mergeFlags')) { + menuFlags = _.uniq(menuFlags.concat(options.menuFlags)); + } + } - self.push({ - name : name, - instance : modInst, - extraArgs : loadOpts.extraArgs, - }); + if(currentModuleInfo) { + // save stack state + currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState(); - // restore previous state if requested - if(options && options.savedState) { - modInst.restoreSavedState(options.savedState); - } + currentModuleInfo.instance.leave(); - const stackEntries = self.stack.map(stackEntry => { - let name = stackEntry.name; - if(stackEntry.instance.menuConfig.options.menuFlags.length > 0) { - name += ` (${stackEntry.instance.menuConfig.options.menuFlags.join(', ')})`; - } - return name; - }); + if(currentModuleInfo.menuFlags.includes('noHistory')) { + this.pop(); + } - self.client.log.trace( { stack : stackEntries }, 'Updated menu stack' ); + if(menuFlags.includes('popParent')) { + this.pop().instance.leave(); // leave & remove current + } + } - modInst.enter(); + self.push({ + name : name, + instance : modInst, + extraArgs : loadOpts.extraArgs, + menuFlags : menuFlags, + }); - if(cb) { - cb(null); - } - } - }); - } + // restore previous state if requested + if(options.savedState) { + modInst.restoreSavedState(options.savedState); + } + + const stackEntries = self.stack.map(stackEntry => { + let name = stackEntry.name; + if(stackEntry.instance.menuConfig.config.menuFlags.length > 0) { + name += ` (${stackEntry.instance.menuConfig.config.menuFlags.join(', ')})`; + } + return name; + }); + + self.client.log.trace( { stack : stackEntries }, 'Updated menu stack' ); + + modInst.enter(); + + if(cb) { + cb(null); + } + } + }); + } }; diff --git a/core/menu_util.js b/core/menu_util.js index d9e5a1a6..a429415b 100644 --- a/core/menu_util.js +++ b/core/menu_util.js @@ -1,265 +1,287 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -var moduleUtil = require('./module_util.js'); -var Log = require('./logger.js').log; -var Config = require('./config.js').config; -var asset = require('./asset.js'); -var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory; +// ENiGMA½ +const moduleUtil = require('./module_util.js'); +const Log = require('./logger.js').log; +const Config = require('./config.js').get; +const asset = require('./asset.js'); +const { MCIViewFactory } = require('./mci_view_factory.js'); +const { Errors } = require('./enig_error.js'); -var paths = require('path'); -var async = require('async'); -var assert = require('assert'); -var _ = require('lodash'); +// deps +const paths = require('path'); +const async = require('async'); +const _ = require('lodash'); -exports.loadMenu = loadMenu; -exports.getFormConfigByIDAndMap = getFormConfigByIDAndMap; -exports.handleAction = handleAction; -exports.handleNext = handleNext; +exports.loadMenu = loadMenu; +exports.getFormConfigByIDAndMap = getFormConfigByIDAndMap; +exports.handleAction = handleAction; +exports.getResolvedSpec = getResolvedSpec; +exports.handleNext = handleNext; function getMenuConfig(client, name, cb) { - var menuConfig; - - async.waterfall( - [ - function locateMenuConfig(callback) { - if(_.has(client.currentTheme, [ 'menus', name ])) { - menuConfig = client.currentTheme.menus[name]; - callback(null); - } else { - callback(new Error('No menu entry for \'' + name + '\'')); - } - }, - function locatePromptConfig(callback) { - if(_.isString(menuConfig.prompt)) { - if(_.has(client.currentTheme, [ 'prompts', menuConfig.prompt ])) { - menuConfig.promptConfig = client.currentTheme.prompts[menuConfig.prompt]; - callback(null); - } else { - callback(new Error('No prompt entry for \'' + menuConfig.prompt + '\'')); - } - } else { - callback(null); - } - } - ], - function complete(err) { - cb(err, menuConfig); - } - ); + async.waterfall( + [ + function locateMenuConfig(callback) { + if(_.has(client.currentTheme, [ 'menus', name ])) { + const menuConfig = client.currentTheme.menus[name]; + return callback(null, menuConfig); + } + return callback(Errors.DoesNotExist(`No menu entry for "${name}"`)); + }, + function locatePromptConfig(menuConfig, callback) { + if(_.isString(menuConfig.prompt)) { + if(_.has(client.currentTheme, [ 'prompts', menuConfig.prompt ])) { + menuConfig.promptConfig = client.currentTheme.prompts[menuConfig.prompt]; + return callback(null, menuConfig); + } + return callback(Error.DoesNotExist(`No prompt entry for "${menuConfig.prompt}"`)); + } + return callback(null, menuConfig); + } + ], + (err, menuConfig) => { + return cb(err, menuConfig); + } + ); } +// :TODO: name/client should not be part of options - they are required always function loadMenu(options, cb) { - assert(_.isObject(options)); - assert(_.isString(options.name)); - assert(_.isObject(options.client)); + if(!_.isString(options.name) || !_.isObject(options.client)) { + return cb(Errors.MissingParam('Missing required options')); + } - async.waterfall( - [ - function getMenuConfiguration(callback) { - getMenuConfig(options.client, options.name, (err, menuConfig) => { - return callback(err, menuConfig); - }); - }, - function loadMenuModule(menuConfig, callback) { + async.waterfall( + [ + function getMenuConfiguration(callback) { + getMenuConfig(options.client, options.name, (err, menuConfig) => { + return callback(err, menuConfig); + }); + }, + function loadMenuModule(menuConfig, callback) { - menuConfig.options = menuConfig.options || {}; - menuConfig.options.menuFlags = menuConfig.options.menuFlags || []; - if(!Array.isArray(menuConfig.options.menuFlags)) { - menuConfig.options.menuFlags = [ menuConfig.options.menuFlags ]; - } + menuConfig.config = menuConfig.config || {}; + menuConfig.config.menuFlags = menuConfig.config.menuFlags || []; + if(!Array.isArray(menuConfig.config.menuFlags)) { + menuConfig.config.menuFlags = [ menuConfig.config.menuFlags ]; + } - const modAsset = asset.getModuleAsset(menuConfig.module); - const modSupplied = null !== modAsset; + const modAsset = asset.getModuleAsset(menuConfig.module); + const modSupplied = null !== modAsset; - const modLoadOpts = { - name : modSupplied ? modAsset.asset : 'standard_menu', - path : (!modSupplied || 'systemModule' === modAsset.type) ? __dirname : Config.paths.mods, - category : (!modSupplied || 'systemModule' === modAsset.type) ? null : 'mods', - }; + const modLoadOpts = { + name : modSupplied ? modAsset.asset : 'standard_menu', + path : (!modSupplied || 'systemModule' === modAsset.type) ? __dirname : Config().paths.mods, + category : (!modSupplied || 'systemModule' === modAsset.type) ? null : 'mods', + }; - moduleUtil.loadModuleEx(modLoadOpts, (err, mod) => { - const modData = { - name : modLoadOpts.name, - config : menuConfig, - mod : mod, - }; + moduleUtil.loadModuleEx(modLoadOpts, (err, mod) => { + const modData = { + name : modLoadOpts.name, + config : menuConfig, + mod : mod, + }; - return callback(err, modData); - }); - }, - function createModuleInstance(modData, callback) { - Log.trace( - { moduleName : modData.name, extraArgs : options.extraArgs, config : modData.config, info : modData.mod.modInfo }, - 'Creating menu module instance'); + return callback(err, modData); + }); + }, + function createModuleInstance(modData, callback) { + Log.trace( + { moduleName : modData.name, extraArgs : options.extraArgs, config : modData.config, info : modData.mod.modInfo }, + 'Creating menu module instance'); - let moduleInstance; - try { - moduleInstance = new modData.mod.getModule({ - menuName : options.name, - menuConfig : modData.config, - extraArgs : options.extraArgs, - client : options.client, - lastMenuResult : options.lastMenuResult, - }); - } catch(e) { - return callback(e); - } + let moduleInstance; + try { + moduleInstance = new modData.mod.getModule({ + menuName : options.name, + menuConfig : modData.config, + extraArgs : options.extraArgs, + client : options.client, + lastMenuResult : options.lastMenuResult, + }); + } catch(e) { + return callback(e); + } - return callback(null, moduleInstance); - } - ], - (err, modInst) => { - return cb(err, modInst); - } - ); + return callback(null, moduleInstance); + } + ], + (err, modInst) => { + return cb(err, modInst); + } + ); } function getFormConfigByIDAndMap(menuConfig, formId, mciMap, cb) { - assert(_.isObject(menuConfig)); + if(!_.isObject(menuConfig.form)) { + return cb(Errors.MissingParam('Invalid or missing "form" member for menu')); + } - if(!_.isObject(menuConfig.form)) { - cb(new Error('Invalid or missing \'form\' member for menu')); - return; - } + if(!_.isObject(menuConfig.form[formId])) { + return cb(Errors.DoesNotExist(`No form found for formId ${formId}`)); + } - if(!_.isObject(menuConfig.form[formId])) { - cb(new Error('No form found for formId ' + formId)); - return; - } + const formForId = menuConfig.form[formId]; + const mciReqKey = _.filter(_.map(_.sortBy(mciMap, 'code'), 'code'), (mci) => { + return MCIViewFactory.UserViewCodes.indexOf(mci) > -1; + }).join(''); - const formForId = menuConfig.form[formId]; - const mciReqKey = _.filter(_.map(_.sortBy(mciMap, 'code'), 'code'), (mci) => { - return MCIViewFactory.UserViewCodes.indexOf(mci) > -1; - }).join(''); + Log.trace( { mciKey : mciReqKey }, 'Looking for MCI configuration key'); - Log.trace( { mciKey : mciReqKey }, 'Looking for MCI configuration key'); + // + // Exact, explicit match? + // + if(_.isObject(formForId[mciReqKey])) { + Log.trace( { mciKey : mciReqKey }, 'Using exact configuration key match'); + return cb(null, formForId[mciReqKey]); + } - // - // Exact, explicit match? - // - if(_.isObject(formForId[mciReqKey])) { - Log.trace( { mciKey : mciReqKey }, 'Using exact configuration key match'); - cb(null, formForId[mciReqKey]); - return; - } + // + // Generic match + // + if(_.has(formForId, 'mci') || _.has(formForId, 'submit')) { + Log.trace('Using generic configuration'); + return cb(null, formForId); + } - // - // Generic match - // - if(_.has(formForId, 'mci') || _.has(formForId, 'submit')) { - Log.trace('Using generic configuration'); - return cb(null, formForId); - } - - cb(new Error('No matching form configuration found for key \'' + mciReqKey + '\'')); + return cb(Errors.DoesNotExist(`No matching form configuration found for key "${mciReqKey}"`)); } -// :TODO: Most of this should be moved elsewhere .... DRY... +// :TODO: Most of this should be moved elsewhere .... DRY... function callModuleMenuMethod(client, asset, path, formData, extraArgs, cb) { - if('' === paths.extname(path)) { - path += '.js'; - } + if('' === paths.extname(path)) { + path += '.js'; + } - try { - client.log.trace( - { path : path, methodName : asset.asset, formData : formData, extraArgs : extraArgs }, - 'Calling menu method'); + try { + client.log.trace( + { path : path, methodName : asset.asset, formData : formData, extraArgs : extraArgs }, + 'Calling menu method'); - const methodMod = require(path); - return methodMod[asset.asset](client.currentMenuModule, formData || { }, extraArgs, cb); - } catch(e) { - client.log.error( { error : e.toString(), methodName : asset.asset }, 'Failed to execute asset method'); - return cb(e); - } + const methodMod = require(path); + return methodMod[asset.asset](client.currentMenuModule, formData || { }, extraArgs, cb); + } catch(e) { + client.log.error( { error : e.toString(), methodName : asset.asset }, 'Failed to execute asset method'); + return cb(e); + } } function handleAction(client, formData, conf, cb) { - assert(_.isObject(conf)); - assert(_.isString(conf.action)); + if(!_.isObject(conf)) { + return cb(Errors.MissingParam('Missing config')); + } - const actionAsset = asset.parseAsset(conf.action); - assert(_.isObject(actionAsset)); + 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"')); + } - switch(actionAsset.type) { - case 'method' : - case 'systemMethod' : - if(_.isString(actionAsset.location)) { - return callModuleMenuMethod( - client, - actionAsset, - paths.join(Config.paths.mods, actionAsset.location), - formData, - conf.extraArgs, - cb); - } else if('systemMethod' === actionAsset.type) { - // :TODO: Need to pass optional args here -- conf.extraArgs and args between e.g. () - // :TODO: Probably better as system_method.js - return callModuleMenuMethod( - client, - actionAsset, - paths.join(__dirname, 'system_menu_method.js'), - formData, - conf.extraArgs, - cb); - } else { - // local to current module - const currentModule = client.currentMenuModule; - if(_.isFunction(currentModule.menuMethods[actionAsset.asset])) { - return currentModule.menuMethods[actionAsset.asset](formData, conf.extraArgs, cb); - } - - const err = new Error('Method does not exist'); - client.log.warn( { method : actionAsset.asset }, err.message); - return cb(err); - } + switch(actionAsset.type) { + case 'method' : + case 'systemMethod' : + if(_.isString(actionAsset.location)) { + return callModuleMenuMethod( + client, + actionAsset, + paths.join(Config().paths.mods, actionAsset.location), + formData, + conf.extraArgs, + cb); + } else if('systemMethod' === actionAsset.type) { + // :TODO: Need to pass optional args here -- conf.extraArgs and args between e.g. () + // :TODO: Probably better as system_method.js + return callModuleMenuMethod( + client, + actionAsset, + paths.join(__dirname, 'system_menu_method.js'), + formData, + conf.extraArgs, + cb); + } else { + // local to current module + const currentModule = client.currentMenuModule; + if(_.isFunction(currentModule.menuMethods[actionAsset.asset])) { + return currentModule.menuMethods[actionAsset.asset](formData, conf.extraArgs, cb); + } - case 'menu' : - return client.currentMenuModule.gotoMenu(actionAsset.asset, { formData : formData, extraArgs : conf.extraArgs }, cb ); - } + const err = Errors.DoesNotExist('Method does not exist'); + client.log.warn( { method : actionAsset.asset }, err.message); + return cb(err); + } + + case 'menu' : + return client.currentMenuModule.gotoMenu(actionAsset.asset, { formData : formData, extraArgs : conf.extraArgs }, cb ); + } +} + +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) { - assert(_.isString(nextSpec) || _.isArray(nextSpec)); - - if(_.isArray(nextSpec)) { - nextSpec = client.acs.getConditionalValue(nextSpec, 'next'); - } - - const nextAsset = asset.getAssetWithShorthand(nextSpec, 'menu'); - // :TODO: getAssetWithShorthand() can return undefined - handle it! - - conf = conf || {}; - const extraArgs = conf.extraArgs || {}; + nextSpec = getResolvedSpec(client, nextSpec, 'next'); + const nextAsset = asset.getAssetWithShorthand(nextSpec, 'menu'); + // :TODO: getAssetWithShorthand() can return undefined - handle it! - // :TODO: DRY this with handleAction() - switch(nextAsset.type) { - case 'method' : - case 'systemMethod' : - if(_.isString(nextAsset.location)) { - return callModuleMenuMethod(client, nextAsset, paths.join(Config.paths.mods, nextAsset.location), {}, extraArgs, cb); - } else if('systemMethod' === nextAsset.type) { - // :TODO: see other notes about system_menu_method.js here - return callModuleMenuMethod(client, nextAsset, paths.join(__dirname, 'system_menu_method.js'), {}, extraArgs, cb); - } else { - // local to current module - const currentModule = client.currentMenuModule; - if(_.isFunction(currentModule.menuMethods[nextAsset.asset])) { - const formData = {}; // we don't have any - return currentModule.menuMethods[nextAsset.asset]( formData, extraArgs, cb ); - } + conf = conf || {}; + const extraArgs = conf.extraArgs || {}; - const err = new Error('Method does not exist'); - client.log.warn( { method : nextAsset.asset }, err.message); - return cb(err); - } + // :TODO: DRY this with handleAction() + switch(nextAsset.type) { + case 'method' : + case 'systemMethod' : + if(_.isString(nextAsset.location)) { + return callModuleMenuMethod(client, nextAsset, paths.join(Config().paths.mods, nextAsset.location), {}, extraArgs, cb); + } else if('systemMethod' === nextAsset.type) { + // :TODO: see other notes about system_menu_method.js here + return callModuleMenuMethod(client, nextAsset, paths.join(__dirname, 'system_menu_method.js'), {}, extraArgs, cb); + } else { + // local to current module + const currentModule = client.currentMenuModule; + if(_.isFunction(currentModule.menuMethods[nextAsset.asset])) { + const formData = {}; // we don't have any + return currentModule.menuMethods[nextAsset.asset]( formData, extraArgs, cb ); + } - case 'menu' : - return client.currentMenuModule.gotoMenu(nextAsset.asset, { extraArgs : extraArgs }, cb ); - } + const err = Errors.DoesNotExist('Method does not exist'); + client.log.warn( { method : nextAsset.asset }, err.message); + return cb(err); + } - const err = new Error('Invalid asset type for "next"'); - client.log.error( { nextSpec : nextSpec }, err.message); - return cb(err); + case 'menu' : + return client.currentMenuModule.gotoMenu(nextAsset.asset, { extraArgs : extraArgs }, cb ); + } + + const err = Errors.Invalid('Invalid asset type for "next"'); + client.log.error( { nextSpec : nextSpec }, err.message); + return cb(err); } diff --git a/core/menu_view.js b/core/menu_view.js index 41f1302f..d9016153 100644 --- a/core/menu_view.js +++ b/core/menu_view.js @@ -1,190 +1,289 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const View = require('./view.js').View; -const miscUtil = require('./misc_util.js'); -const pipeToAnsi = require('./color_codes.js').pipeToAnsi; +// ENiGMA½ +const View = require('./view.js').View; +const miscUtil = require('./misc_util.js'); +const pipeToAnsi = require('./color_codes.js').pipeToAnsi; -// deps -const util = require('util'); -const assert = require('assert'); -const _ = require('lodash'); +// deps +const util = require('util'); +const assert = require('assert'); +const _ = require('lodash'); -exports.MenuView = MenuView; +exports.MenuView = MenuView; function MenuView(options) { - options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); - options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); - - View.call(this, options); + options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); + options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); - this.disablePipe = options.disablePipe || false; + View.call(this, options); - const self = this; + this.disablePipe = options.disablePipe || false; - if(options.items) { - this.setItems(options.items); - } else { - this.items = []; - } + const self = this; - this.caseInsensitiveHotKeys = miscUtil.valueWithDefault(options.caseInsensitiveHotKeys, true); + if(options.items) { + this.setItems(options.items); + } else { + this.items = []; + } - this.setHotKeys(options.hotKeys); + this.renderCache = {}; - this.focusedItemIndex = options.focusedItemIndex || 0; - this.focusedItemIndex = this.items.length >= this.focusedItemIndex ? this.focusedItemIndex : 0; + this.caseInsensitiveHotKeys = miscUtil.valueWithDefault(options.caseInsensitiveHotKeys, true); - this.itemSpacing = _.isNumber(options.itemSpacing) ? options.itemSpacing : 0; + this.setHotKeys(options.hotKeys); - // :TODO: probably just replace this with owner draw / pipe codes / etc. more control, less specialization - this.focusPrefix = options.focusPrefix || ''; - this.focusSuffix = options.focusSuffix || ''; + this.focusedItemIndex = options.focusedItemIndex || 0; + this.focusedItemIndex = this.items.length >= this.focusedItemIndex ? this.focusedItemIndex : 0; - this.fillChar = miscUtil.valueWithDefault(options.fillChar, ' ').substr(0, 1); - this.justify = options.justify || 'none'; + this.itemSpacing = _.isNumber(options.itemSpacing) ? options.itemSpacing : 0; - this.hasFocusItems = function() { - return !_.isUndefined(self.focusItems); - }; + // :TODO: probably just replace this with owner draw / pipe codes / etc. more control, less specialization + this.focusPrefix = options.focusPrefix || ''; + this.focusSuffix = options.focusSuffix || ''; - this.getHotKeyItemIndex = function(ch) { - if(ch && self.hotKeys) { - const keyIndex = self.hotKeys[self.caseInsensitiveHotKeys ? ch.toLowerCase() : ch]; - if(_.isNumber(keyIndex)) { - return keyIndex; - } - } - return -1; - }; + this.fillChar = miscUtil.valueWithDefault(options.fillChar, ' ').substr(0, 1); + this.justify = options.justify || 'none'; + + this.hasFocusItems = function() { + return !_.isUndefined(self.focusItems); + }; + + this.getHotKeyItemIndex = function(ch) { + if(ch && self.hotKeys) { + const keyIndex = self.hotKeys[self.caseInsensitiveHotKeys ? ch.toLowerCase() : ch]; + if(_.isNumber(keyIndex)) { + return keyIndex; + } + } + return -1; + }; + + this.emitIndexUpdate = function() { + self.emit('index update', self.focusedItemIndex); + }; } util.inherits(MenuView, View); MenuView.prototype.setItems = function(items) { - const self = this; + if(Array.isArray(items)) { + this.sorted = false; + this.renderCache = {}; - if(items) { - this.items = []; - items.forEach( itemText => { - this.items.push( - { - text : self.disablePipe ? itemText : pipeToAnsi(itemText, self.client) - } - ); - }); - } + // + // Items can be an array of strings or an array of objects. + // + // In the case of objects, items are considered complex and + // may have one or more members that can later be formatted + // against. The default member is 'text'. The member 'data' + // may be overridden to provide a form value other than the + // item's index. + // + // Items can be formatted with 'itemFormat' and 'focusItemFormat' + // + let text; + let stringItem; + this.items = items.map(item => { + stringItem = _.isString(item); + if(stringItem) { + text = item; + } else { + text = item.text || ''; + this.complexItems = true; + } + + text = this.disablePipe ? text : pipeToAnsi(text, this.client); + return Object.assign({ }, { text }, stringItem ? {} : item); // ensure we have a text member, plus any others + }); + + if(this.complexItems) { + this.itemFormat = this.itemFormat || '{text}'; + } + + this.invalidateRenderCache(); + } +}; + +MenuView.prototype.getRenderCacheItem = function(index, focusItem = false) { + const item = this.renderCache[index]; + return item && item[focusItem ? 'focus' : 'standard']; +}; + +MenuView.prototype.removeRenderCacheItem = function(index) { + delete this.renderCache[index]; +}; + +MenuView.prototype.setRenderCacheItem = function(index, rendered, focusItem = false) { + this.renderCache[index] = this.renderCache[index] || {}; + this.renderCache[index][focusItem ? 'focus' : 'standard'] = rendered; +}; + +MenuView.prototype.invalidateRenderCache = function() { + this.renderCache = {}; +}; + +MenuView.prototype.setSort = function(sort) { + if(this.sorted || !Array.isArray(this.items) || 0 === this.items.length) { + return; + } + + const key = true === sort ? 'text' : sort; + if('text' !== sort && !this.complexItems) { + return; // need a valid sort key + } + + this.items.sort( (a, b) => { + const a1 = a[key]; + const b1 = b[key]; + if(!a1) { + return -1; + } + if(!b1) { + return 1; + } + return a1.localeCompare( b1, { sensitivity : false, numeric : true } ); + }); + + this.sorted = true; }; MenuView.prototype.removeItem = function(index) { - this.items.splice(index, 1); - - if(this.focusItems) { - this.focusItems.splice(index, 1); - } + this.sorted = false; + this.items.splice(index, 1); - if(this.focusedItemIndex >= index) { - this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, 0); - } + if(this.focusItems) { + this.focusItems.splice(index, 1); + } - this.positionCacheExpired = true; + if(this.focusedItemIndex >= index) { + this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, 0); + } + + this.removeRenderCacheItem(index); + + this.positionCacheExpired = true; }; MenuView.prototype.getCount = function() { - return this.items.length; + return this.items.length; }; -MenuView.prototype.getItems = function() { - return this.items.map( item => { - return item.text; - }); +MenuView.prototype.getItems = function() { + if(this.complexItems) { + return this.items; + } + + return this.items.map( item => { + return item.text; + }); }; MenuView.prototype.getItem = function(index) { - return this.items[index].text; + if(this.complexItems) { + return this.items[index]; + } + + return this.items[index].text; }; MenuView.prototype.focusNext = function() { - this.emit('index update', this.focusedItemIndex); + this.emitIndexUpdate(); }; MenuView.prototype.focusPrevious = function() { - this.emit('index update', this.focusedItemIndex); + this.emitIndexUpdate(); }; MenuView.prototype.focusNextPageItem = function() { - this.emit('index update', this.focusedItemIndex); + this.emitIndexUpdate(); }; MenuView.prototype.focusPreviousPageItem = function() { - this.emit('index update', this.focusedItemIndex); + this.emitIndexUpdate(); +}; + +MenuView.prototype.focusFirst = function() { + this.emitIndexUpdate(); +}; + +MenuView.prototype.focusLast = function() { + this.emitIndexUpdate(); }; MenuView.prototype.setFocusItemIndex = function(index) { - this.focusedItemIndex = index; + this.focusedItemIndex = index; }; MenuView.prototype.onKeyPress = function(ch, key) { - const itemIndex = this.getHotKeyItemIndex(ch); - if(itemIndex >= 0) { - this.setFocusItemIndex(itemIndex); + const itemIndex = this.getHotKeyItemIndex(ch); + if(itemIndex >= 0) { + this.setFocusItemIndex(itemIndex); - if(true === this.hotKeySubmit) { - this.emit('action', 'accept'); - } - } + if(true === this.hotKeySubmit) { + this.emit('action', 'accept'); + } + } - MenuView.super_.prototype.onKeyPress.call(this, ch, key); + MenuView.super_.prototype.onKeyPress.call(this, ch, key); }; MenuView.prototype.setFocusItems = function(items) { - const self = this; - - if(items) { - this.focusItems = []; - items.forEach( itemText => { - this.focusItems.push( - { - text : self.disablePipe ? itemText : pipeToAnsi(itemText, self.client) - } - ); - }); - } + const self = this; + + if(items) { + this.focusItems = []; + items.forEach( itemText => { + this.focusItems.push( + { + text : self.disablePipe ? itemText : pipeToAnsi(itemText, self.client) + } + ); + }); + } }; MenuView.prototype.setItemSpacing = function(itemSpacing) { - itemSpacing = parseInt(itemSpacing); - assert(_.isNumber(itemSpacing)); + itemSpacing = parseInt(itemSpacing); + assert(_.isNumber(itemSpacing)); - this.itemSpacing = itemSpacing; - this.positionCacheExpired = true; + this.itemSpacing = itemSpacing; + this.positionCacheExpired = true; }; MenuView.prototype.setPropertyValue = function(propName, value) { - switch(propName) { - case 'itemSpacing' : this.setItemSpacing(value); break; - case 'items' : this.setItems(value); break; - case 'focusItems' : this.setFocusItems(value); break; - case 'hotKeys' : this.setHotKeys(value); break; - case 'hotKeySubmit' : this.hotKeySubmit = value; break; - case 'justify' : this.justify = value; break; - case 'focusItemIndex' : this.focusedItemIndex = value; break; - } + switch(propName) { + case 'itemSpacing' : this.setItemSpacing(value); break; + case 'items' : this.setItems(value); break; + case 'focusItems' : this.setFocusItems(value); break; + case 'hotKeys' : this.setHotKeys(value); break; + case 'hotKeySubmit' : this.hotKeySubmit = value; break; + case 'justify' : this.justify = value; break; + case 'focusItemIndex' : this.focusedItemIndex = value; break; - MenuView.super_.prototype.setPropertyValue.call(this, propName, value); + case 'itemFormat' : + case 'focusItemFormat' : + this[propName] = value; + break; + + case 'sort' : this.setSort(value); break; + } + + MenuView.super_.prototype.setPropertyValue.call(this, propName, value); }; MenuView.prototype.setHotKeys = function(hotKeys) { - if(_.isObject(hotKeys)) { - if(this.caseInsensitiveHotKeys) { - this.hotKeys = {}; - for(var key in hotKeys) { - this.hotKeys[key.toLowerCase()] = hotKeys[key]; - } - } else { - this.hotKeys = hotKeys; - } - } + if(_.isObject(hotKeys)) { + if(this.caseInsensitiveHotKeys) { + this.hotKeys = {}; + for(var key in hotKeys) { + this.hotKeys[key.toLowerCase()] = hotKeys[key]; + } + } else { + this.hotKeys = hotKeys; + } + } }; diff --git a/core/message.js b/core/message.js index 710444ce..b45e868c 100644 --- a/core/message.js +++ b/core/message.js @@ -1,631 +1,981 @@ /* jslint node: true */ 'use strict'; -const msgDb = require('./database.js').dbs.message; -const wordWrapText = require('./word_wrap.js').wordWrapText; -const ftnUtil = require('./ftn_util.js'); -const createNamedUUID = require('./uuid_util.js').createNamedUUID; -const getISOTimestampString = require('./database.js').getISOTimestampString; -const Errors = require('./enig_error.js').Errors; -const ANSI = require('./ansi_term.js'); +const msgDb = require('./database.js').dbs.message; +const wordWrapText = require('./word_wrap.js').wordWrapText; +const ftnUtil = require('./ftn_util.js'); +const createNamedUUID = require('./uuid_util.js').createNamedUUID; +const Errors = require('./enig_error.js').Errors; +const ANSI = require('./ansi_term.js'); +const { + sanitizeString, + getISOTimestampString } = require('./database.js'); -const { - isAnsi, isFormattedLine, - splitTextAtTerms, - renderSubstr -} = require('./string_util.js'); +const { isCP437Encodable } = require('./cp437util'); +const { containsNonLatinCodepoints } = require('./string_util'); -const ansiPrep = require('./ansi_prep.js'); +const { + isAnsi, isFormattedLine, + splitTextAtTerms, + renderSubstr +} = require('./string_util.js'); -// deps -const uuidParse = require('uuid-parse'); -const async = require('async'); -const _ = require('lodash'); -const assert = require('assert'); -const moment = require('moment'); -const iconvEncode = require('iconv-lite').encode; +const ansiPrep = require('./ansi_prep.js'); -module.exports = Message; +// deps +const uuidParse = require('uuid-parse'); +const async = require('async'); +const _ = require('lodash'); +const assert = require('assert'); +const moment = require('moment'); +const iconvEncode = require('iconv-lite').encode; -const ENIGMA_MESSAGE_UUID_NAMESPACE = uuidParse.parse('154506df-1df8-46b9-98f8-ebb5815baaf8'); +const ENIGMA_MESSAGE_UUID_NAMESPACE = uuidParse.parse('154506df-1df8-46b9-98f8-ebb5815baaf8'); -function Message(options) { - options = options || {}; - - this.messageId = options.messageId || 0; // always generated @ persist - this.areaTag = options.areaTag || Message.WellKnownAreaTags.Invalid; - - if(options.uuid) { - // note: new messages have UUID generated @ time of persist. See also Message.createMessageUUID() - this.uuid = options.uuid; - } - - this.replyToMsgId = options.replyToMsgId || 0; - this.toUserName = options.toUserName || ''; - this.fromUserName = options.fromUserName || ''; - this.subject = options.subject || ''; - this.message = options.message || ''; - - if(_.isDate(options.modTimestamp) || moment.isMoment(options.modTimestamp)) { - this.modTimestamp = moment(options.modTimestamp); - } else if(_.isString(options.modTimestamp)) { - this.modTimestamp = moment(options.modTimestamp); - } - - this.viewCount = options.viewCount || 0; - - this.meta = { - System : {}, // we'll always have this one - }; - - if(_.isObject(options.meta)) { - _.defaultsDeep(this.meta, options.meta); - } - - if(options.meta) { - this.meta = options.meta; - } - - this.hashTags = options.hashTags || []; - - this.isValid = function() { - // :TODO: validate as much as possible - return true; - }; - - this.isPrivate = function() { - return Message.isPrivateAreaTag(this.areaTag); - }; -} - -Message.WellKnownAreaTags = { - Invalid : '', - Private : 'private_mail', - Bulletin : 'local_bulletin', +const WELL_KNOWN_AREA_TAGS = { + Invalid : '', + Private : 'private_mail', + Bulletin : 'local_bulletin', }; -Message.isPrivateAreaTag = function(areaTag) { - return areaTag.toLowerCase() === Message.WellKnownAreaTags.Private; +const SYSTEM_META_NAMES = { + LocalToUserID : 'local_to_user_id', + LocalFromUserID : 'local_from_user_id', + StateFlags0 : 'state_flags0', // See Message.StateFlags0 + ExplicitEncoding : 'explicit_encoding', // Explicitly set encoding when exporting/etc. + ExternalFlavor : 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.AddressFlavor + RemoteToUser : 'remote_to_user', // Opaque value depends on external system, e.g. FTN address + RemoteFromUser : 'remote_from_user', // Opaque value depends on external system, e.g. FTN address }; -Message.SystemMetaNames = { - LocalToUserID : 'local_to_user_id', - LocalFromUserID : 'local_from_user_id', - StateFlags0 : 'state_flags0', // See Message.StateFlags0 - ExplicitEncoding : 'explicit_encoding', // Explicitly set encoding when exporting/etc. +// Types for Message.SystemMetaNames.ExternalFlavor meta +const ADDRESS_FLAVOR = { + Local : 'local', // local / non-remote addressing + FTN : 'ftn', // FTN style + Email : 'email', // From email + QWK : 'qwk', // QWK packet }; -Message.StateFlags0 = { - None : 0x00000000, - Imported : 0x00000001, // imported from foreign system - Exported : 0x00000002, // exported to foreign system +const STATE_FLAGS0 = { + None : 0x00000000, + Imported : 0x00000001, // imported from foreign system + Exported : 0x00000002, // exported to foreign system }; -Message.FtnPropertyNames = { - FtnOrigNode : 'ftn_orig_node', - FtnDestNode : 'ftn_dest_node', - FtnOrigNetwork : 'ftn_orig_network', - FtnDestNetwork : 'ftn_dest_network', - FtnAttrFlags : 'ftn_attr_flags', - FtnCost : 'ftn_cost', - FtnOrigZone : 'ftn_orig_zone', - FtnDestZone : 'ftn_dest_zone', - FtnOrigPoint : 'ftn_orig_point', - FtnDestPoint : 'ftn_dest_point', - - FtnAttribute : 'ftn_attribute', +// :TODO: these should really live elsewhere... +const FTN_PROPERTY_NAMES = { + // packet header oriented + FtnOrigNode : 'ftn_orig_node', + FtnDestNode : 'ftn_dest_node', + // :TODO: rename these to ftn_*_net vs network - ensure things won't break, may need mapping + FtnOrigNetwork : 'ftn_orig_network', + FtnDestNetwork : 'ftn_dest_network', + FtnAttrFlags : 'ftn_attr_flags', + FtnCost : 'ftn_cost', + FtnOrigZone : 'ftn_orig_zone', + FtnDestZone : 'ftn_dest_zone', + FtnOrigPoint : 'ftn_orig_point', + FtnDestPoint : 'ftn_dest_point', - FtnTearLine : 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001 - FtnOrigin : 'ftn_origin', // http://ftsc.org/docs/fts-0004.001 - FtnArea : 'ftn_area', // http://ftsc.org/docs/fts-0004.001 - FtnSeenBy : 'ftn_seen_by', // http://ftsc.org/docs/fts-0004.001 + // message header oriented + FtnMsgOrigNode : 'ftn_msg_orig_node', + FtnMsgDestNode : 'ftn_msg_dest_node', + FtnMsgOrigNet : 'ftn_msg_orig_net', + FtnMsgDestNet : 'ftn_msg_dest_net', + + FtnAttribute : 'ftn_attribute', + + FtnTearLine : 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001 + FtnOrigin : 'ftn_origin', // http://ftsc.org/docs/fts-0004.001 + FtnArea : 'ftn_area', // http://ftsc.org/docs/fts-0004.001 + FtnSeenBy : 'ftn_seen_by', // http://ftsc.org/docs/fts-0004.001 }; -// Note: kludges are stored with their names as-is - -Message.prototype.setLocalToUserId = function(userId) { - this.meta.System.local_to_user_id = userId; +const QWKPropertyNames = { + MessageNumber : 'qwk_msg_num', + MessageStatus : 'qwk_msg_status', // See http://wiki.synchro.net/ref:qwk for a decent list + ConferenceNumber : 'qwk_conf_num', + InReplyToNum : 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available }; -Message.prototype.setLocalFromUserId = function(userId) { - this.meta.System.local_from_user_id = userId; +// :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)! +const MESSAGE_ROW_MAP = { + reply_to_message_id : 'replyToMsgId', + modified_timestamp : 'modTimestamp' }; -Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) { - assert(_.isString(areaTag)); - assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp)); - assert(_.isString(subject)); - assert(_.isString(body)); +module.exports = class Message { + constructor( + { + messageId = 0, + areaTag = Message.WellKnownAreaTags.Invalid, + uuid, + replyToMsgId = 0, + toUserName = '', + fromUserName = '', + subject = '', + message = '', + modTimestamp = moment(), + meta, + hashTags = [], + } = { } + ) + { + this.messageId = messageId; + this.areaTag = areaTag; + this.messageUuid = uuid; + this.replyToMsgId = replyToMsgId; + this.toUserName = toUserName; + this.fromUserName = fromUserName; + this.subject = subject; + this.message = message; - if(!moment.isMoment(modTimestamp)) { - modTimestamp = moment(modTimestamp); - } - - areaTag = iconvEncode(areaTag.toUpperCase(), 'CP437'); - modTimestamp = iconvEncode(modTimestamp.format('DD MMM YY HH:mm:ss'), 'CP437'); - subject = iconvEncode(subject.toUpperCase().trim(), 'CP437'); - body = iconvEncode(body.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437'); - - return uuidParse.unparse(createNamedUUID(ENIGMA_MESSAGE_UUID_NAMESPACE, Buffer.concat( [ areaTag, modTimestamp, subject, body ] ))); -}; - -Message.getMessageIdByUuid = function(uuid, cb) { - msgDb.get( - `SELECT message_id - FROM message - WHERE message_uuid = ? - LIMIT 1;`, - [ uuid ], - (err, row) => { - if(err) { - cb(err); - } else { - const success = (row && row.message_id); - cb(success ? null : new Error('No match'), success ? row.message_id : null); - } - } - ); -}; - -Message.getMessageIdsByMetaValue = function(category, name, value, cb) { - msgDb.all( - `SELECT message_id - FROM message_meta - WHERE meta_category = ? AND meta_name = ? AND meta_value = ?;`, - [ category, name, value ], - (err, rows) => { - if(err) { - cb(err); - } else { - cb(null, rows.map(r => parseInt(r.message_id))); // return array of ID(s) - } - } - ); -}; - -Message.getMetaValuesByMessageId = function(messageId, category, name, cb) { - const sql = - `SELECT meta_value - FROM message_meta - WHERE message_id = ? AND meta_category = ? AND meta_name = ?;`; - - msgDb.all(sql, [ messageId, category, name ], (err, rows) => { - if(err) { - return cb(err); - } - - if(0 === rows.length) { - return cb(new Error('No value for category/name')); - } - - // single values are returned without an array - if(1 === rows.length) { - return cb(null, rows[0].meta_value); - } - - cb(null, rows.map(r => r.meta_value)); // map to array of values only - }); -}; - -Message.getMetaValuesByMessageUuid = function(uuid, category, name, cb) { - async.waterfall( - [ - function getMessageId(callback) { - Message.getMessageIdByUuid(uuid, (err, messageId) => { - callback(err, messageId); - }); - }, - function getMetaValues(messageId, callback) { - Message.getMetaValuesByMessageId(messageId, category, name, (err, values) => { - callback(err, values); - }); - } - ], - (err, values) => { - cb(err, values); - } - ); -}; - -Message.prototype.loadMeta = function(cb) { - /* - Example of loaded this.meta: - - meta: { - System: { - local_to_user_id: 1234, - }, - FtnProperty: { - ftn_seen_by: [ "1/102 103", "2/42 52 65" ] - } - } - */ - - const sql = - `SELECT meta_category, meta_name, meta_value - FROM message_meta - WHERE message_id = ?;`; - - let self = this; - msgDb.each(sql, [ this.messageId ], (err, row) => { - if(!(row.meta_category in self.meta)) { - self.meta[row.meta_category] = { }; - self.meta[row.meta_category][row.meta_name] = row.meta_value; - } else { - if(!(row.meta_name in self.meta[row.meta_category])) { - self.meta[row.meta_category][row.meta_name] = row.meta_value; - } else { - if(_.isString(self.meta[row.meta_category][row.meta_name])) { - self.meta[row.meta_category][row.meta_name] = [ self.meta[row.meta_category][row.meta_name] ]; - } - - self.meta[row.meta_category][row.meta_name].push(row.meta_value); - } - } - }, err => { - cb(err); - }); -}; - -Message.prototype.load = function(options, cb) { - assert(_.isString(options.uuid)); - - var self = this; - - async.series( - [ - function loadMessage(callback) { - msgDb.get( - 'SELECT message_id, area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, ' + - 'message, modified_timestamp, view_count ' + - 'FROM message ' + - 'WHERE message_uuid=? ' + - 'LIMIT 1;', - [ options.uuid ], - (err, msgRow) => { - if(err) { - return callback(err); - } - if(!msgRow) { - return callback(new Error('Message (no longer) available')); - } - - self.messageId = msgRow.message_id; - self.areaTag = msgRow.area_tag; - self.messageUuid = msgRow.message_uuid; - self.replyToMsgId = msgRow.reply_to_message_id; - self.toUserName = msgRow.to_user_name; - self.fromUserName = msgRow.from_user_name; - self.subject = msgRow.subject; - self.message = msgRow.message; - self.modTimestamp = moment(msgRow.modified_timestamp); - self.viewCount = msgRow.view_count; - - callback(err); - } - ); - }, - function loadMessageMeta(callback) { - self.loadMeta(err => { - callback(err); - }); - }, - function loadHashTags(callback) { - // :TODO: - callback(null); - } - ], - function complete(err) { - cb(err); - } - ); -}; - -Message.prototype.persistMetaValue = function(category, name, value, cb) { - const metaStmt = msgDb.prepare( - `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) - VALUES (?, ?, ?, ?);`); - - if(!_.isArray(value)) { - value = [ value ]; - } - - let self = this; - - async.each(value, (v, next) => { - metaStmt.run(self.messageId, category, name, v, err => { - next(err); - }); - }, err => { - cb(err); - }); -}; - -Message.startTransaction = function(cb) { - msgDb.run('BEGIN;', err => { - cb(err); - }); -}; - -Message.endTransaction = function(hadError, cb) { - msgDb.run(hadError ? 'ROLLBACK;' : 'COMMIT;', err => { - cb(err); - }); -}; - -Message.prototype.persist = function(cb) { - - if(!this.isValid()) { - return cb(new Error('Cannot persist invalid message!')); - } - - const self = this; - - async.series( - [ - function beginTransaction(callback) { - Message.startTransaction(err => { - return callback(err); - }); - }, - function storeMessage(callback) { - // generate a UUID for this message if required (general case) - const msgTimestamp = moment(); - if(!self.uuid) { - self.uuid = Message.createMessageUUID( - self.areaTag, - msgTimestamp, - self.subject, - self.message); - } - - msgDb.run( - `INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, - [ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, getISOTimestampString(msgTimestamp) ], - function inserted(err) { // use non-arrow function for 'this' scope - if(!err) { - self.messageId = this.lastID; - } - - return callback(err); - } - ); - }, - function storeMeta(callback) { - if(!self.meta) { - return callback(null); - } - /* - Example of self.meta: - - meta: { - System: { - local_to_user_id: 1234, - }, - FtnProperty: { - ftn_seen_by: [ "1/102 103", "2/42 52 65" ] - } - } - */ - async.each(Object.keys(self.meta), (category, nextCat) => { - async.each(Object.keys(self.meta[category]), (name, nextName) => { - self.persistMetaValue(category, name, self.meta[category][name], err => { - nextName(err); - }); - }, err => { - nextCat(err); - }); - - }, err => { - callback(err); - }); - }, - function storeHashTags(callback) { - // :TODO: hash tag support - return callback(null); - } - ], - err => { - Message.endTransaction(err, transErr => { - return cb(err ? err : transErr, self.messageId); - }); - } - ); -}; - -Message.prototype.getFTNQuotePrefix = function(source) { - source = source || 'fromUserName'; - - return ftnUtil.getQuotePrefix(this[source]); -}; - -Message.prototype.getTearLinePosition = function(input) { - const m = input.match(/^--- .+$(?![\s\S]*^--- .+$)/m); - return m ? m.index : -1; -}; - -Message.prototype.getQuoteLines = function(options, cb) { - if(!options.termWidth || !options.termHeight || !options.cols) { - return cb(Errors.MissingParam()); - } - - options.startCol = options.startCol || 1; - options.includePrefix = _.get(options, 'includePrefix', true); - options.ansiResetSgr = options.ansiResetSgr || ANSI.getSGRFromGraphicRendition( { fg : 39, bg : 49 }, true); - options.ansiFocusPrefixSgr = options.ansiFocusPrefixSgr || ANSI.getSGRFromGraphicRendition( { intensity : 'bold', fg : 39, bg : 49 } ); - options.isAnsi = options.isAnsi || isAnsi(this.message); // :TODO: If this.isAnsi, use that setting - - /* - Some long text that needs to be wrapped and quoted should look right after - doing so, don't ya think? yeah I think so - - Nu> Some long text that needs to be wrapped and quoted should look right - Nu> after doing so, don't ya think? yeah I think so - - Ot> Nu> Some long text that needs to be wrapped and quoted should look - Ot> Nu> right after doing so, don't ya think? yeah I think so - - */ - const quotePrefix = options.includePrefix ? this.getFTNQuotePrefix(options.prefixSource || 'fromUserName') : ''; - - function getWrapped(text, extraPrefix) { - extraPrefix = extraPrefix ? ` ${extraPrefix}` : ''; - - const wrapOpts = { - width : options.cols - (quotePrefix.length + extraPrefix.length), - tabHandling : 'expand', - tabWidth : 4, - }; - - return wordWrapText(text, wrapOpts).wrapped.map( (w, i) => { - return i === 0 ? `${quotePrefix}${w}` : `${quotePrefix}${extraPrefix}${w}`; - }); - } - - function getFormattedLine(line) { - // for pre-formatted text, we just append a line truncated to fit - let newLen; - const total = line.length + quotePrefix.length; - - if(total > options.cols) { - newLen = options.cols - total; - } else { - newLen = total; - } - - return `${quotePrefix}${line.slice(0, newLen)}`; - } - - if(options.isAnsi) { - ansiPrep( - this.message.replace(/\r?\n/g, '\r\n'), // normalized LF -> CRLF - { - termWidth : options.termWidth, - termHeight : options.termHeight, - cols : options.cols, - rows : 'auto', - startCol : options.startCol, - forceLineTerm : true, - }, - (err, prepped) => { - prepped = prepped || this.message; - - let lastSgr = ''; - const split = splitTextAtTerms(prepped); - - const quoteLines = []; - const focusQuoteLines = []; - - // - // Do not include quote prefixes (e.g. XX> ) on ANSI replies (and therefor quote builder) - // as while this works in ENiGMA, other boards such as Mystic, WWIV, etc. will try to - // strip colors, colorize the lines, etc. If we exclude the prefixes, this seems to do - // the trick and allow them to leave them alone! - // - split.forEach(l => { - quoteLines.push(`${lastSgr}${l}`); - - focusQuoteLines.push(`${options.ansiFocusPrefixSgr}>${lastSgr}${renderSubstr(l, 1, l.length - 1)}`); - lastSgr = (l.match(/(?:\x1b\x5b)[\?=;0-9]*m(?!.*(?:\x1b\x5b)[\?=;0-9]*m)/) || [])[0] || ''; // eslint-disable-line no-control-regex - }); - - quoteLines[quoteLines.length - 1] += options.ansiResetSgr; - - return cb(null, quoteLines, focusQuoteLines, true); - } - ); - } else { - const QUOTE_RE = /^ ((?:[A-Za-z0-9]{2}\> )+(?:[A-Za-z0-9]{2}\>)*) */; - const quoted = []; - const input = _.trimEnd(this.message).replace(/\b/g, ''); - - // find *last* tearline - let tearLinePos = this.getTearLinePosition(input); - tearLinePos = -1 === tearLinePos ? input.length : tearLinePos; // we just want the index or the entire string - - input.slice(0, tearLinePos).split(/\r\n\r\n|\n\n/).forEach(paragraph => { - // - // For each paragraph, a state machine: - // - New line - line - // - New (pre)quoted line - quote_line - // - Continuation of new/quoted line - // - // Also: - // - Detect pre-formatted lines & try to keep them as-is - // - let state; - let buf = ''; - let quoteMatch; - - if(quoted.length > 0) { - // - // Preserve paragraph seperation. - // - // FSC-0032 states something about leaving blank lines fully blank - // (without a prefix) but it seems nicer (and more consistent with other systems) - // to put 'em in. - // - quoted.push(quotePrefix); - } - - paragraph.split(/\r?\n/).forEach(line => { - if(0 === line.trim().length) { - // see blank line notes above - return quoted.push(quotePrefix); - } - - quoteMatch = line.match(QUOTE_RE); - - switch(state) { - case 'line' : - if(quoteMatch) { - if(isFormattedLine(line)) { - quoted.push(getFormattedLine(line.replace(/\s/, ''))); - } else { - quoted.push(...getWrapped(buf, quoteMatch[1])); - state = 'quote_line'; - buf = line; - } - } else { - buf += ` ${line}`; - } - break; - - case 'quote_line' : - if(quoteMatch) { - const rem = line.slice(quoteMatch[0].length); - if(!buf.startsWith(quoteMatch[0])) { - quoted.push(...getWrapped(buf, quoteMatch[1])); - buf = rem; - } else { - buf += ` ${rem}`; - } - } else { - quoted.push(...getWrapped(buf)); - buf = line; - state = 'line'; - } - break; - - default : - if(isFormattedLine(line)) { - quoted.push(getFormattedLine(line)); - } else { - state = quoteMatch ? 'quote_line' : 'line'; - buf = 'line' === state ? line : line.replace(/\s/, ''); // trim *first* leading space, if any - } - break; - } - }); - - quoted.push(...getWrapped(buf, quoteMatch ? quoteMatch[1] : null)); - }); - - input.slice(tearLinePos).split(/\r?\n/).forEach(l => { - quoted.push(...getWrapped(l)); - }); - - return cb(null, quoted, null, false); - } + if(_.isDate(modTimestamp) || _.isString(modTimestamp)) { + modTimestamp = moment(modTimestamp); + } + + this.modTimestamp = modTimestamp; + + this.meta = {}; + _.defaultsDeep(this.meta, { System : {} }, meta); + + this.hashTags = hashTags; + } + + get uuid() { // deprecated, will be removed in the near future + return this.messageUuid; + } + + isValid() { return true; } // :TODO: obviously useless; look into this or remove it + + static isPrivateAreaTag(areaTag) { + return areaTag.toLowerCase() === Message.WellKnownAreaTags.Private; + } + + isPrivate() { + return Message.isPrivateAreaTag(this.areaTag); + } + + isFromRemoteUser() { + return null !== _.get(this, 'meta.System.remote_from_user', null); + } + + isCP437Encodable() { + return isCP437Encodable(this.toUserName) && + isCP437Encodable(this.fromUserName) && + isCP437Encodable(this.subject) && + isCP437Encodable(this.message); + } + + containsNonLatinCodepoints() { + return containsNonLatinCodepoints(this.toUserName) || + containsNonLatinCodepoints(this.fromUserName) || + containsNonLatinCodepoints(this.subject) || + containsNonLatinCodepoints(this.message); + } + + /* + :TODO: finish me + static checkUserHasDeleteRights(user, messageIdOrUuid, cb) { + const isMessageId = _.isNumber(messageIdOrUuid); + const getMetaName = isMessageId ? 'getMetaValuesByMessageId' : 'getMetaValuesByMessageUuid'; + + Message[getMetaName](messageIdOrUuid, 'System', Message.SystemMetaNames.LocalToUserID, (err, localUserId) => { + if(err) { + return cb(err); + } + + // expect single value + if(!_.isString(localUserId)) { + return cb(Errors.Invalid(`Invalid ${Message.SystemMetaNames.LocalToUserID} value: ${localUserId}`)); + } + + localUserId = parseInt(localUserId); + }); + } + */ + + userHasDeleteRights(user) { + const messageLocalUserId = parseInt(this.meta.System[Message.SystemMetaNames.LocalToUserID]); + return (this.isPrivate() && user.userId === messageLocalUserId) || user.isSysOp(); + } + + static get WellKnownAreaTags() { + return WELL_KNOWN_AREA_TAGS; + } + + static get SystemMetaNames() { + return SYSTEM_META_NAMES; + } + + static get AddressFlavor() { + return ADDRESS_FLAVOR; + } + + static get StateFlags0() { + return STATE_FLAGS0; + } + + static get FtnPropertyNames() { + return FTN_PROPERTY_NAMES; + } + + static get QWKPropertyNames() { + return QWKPropertyNames; + } + + setLocalToUserId(userId) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalToUserID] = userId; + } + + setLocalFromUserId(userId) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId; + } + + setRemoteToUser(remoteTo) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo; + } + + setExternalFlavor(flavor) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor; + } + + static createMessageUUID(areaTag, modTimestamp, subject, body) { + assert(_.isString(areaTag)); + assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp)); + assert(_.isString(subject)); + assert(_.isString(body)); + + if(!moment.isMoment(modTimestamp)) { + modTimestamp = moment(modTimestamp); + } + + areaTag = iconvEncode(areaTag.toUpperCase(), 'CP437'); + modTimestamp = iconvEncode(modTimestamp.format('DD MMM YY HH:mm:ss'), 'CP437'); + subject = iconvEncode(subject.toUpperCase().trim(), 'CP437'); + body = iconvEncode(body.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437'); + + return uuidParse.unparse(createNamedUUID(ENIGMA_MESSAGE_UUID_NAMESPACE, Buffer.concat( [ areaTag, modTimestamp, subject, body ] ))); + } + + static getMessageFromRow(row) { + const msg = {}; + _.each(row, (v, k) => { + // :TODO: see notes around MESSAGE_ROW_MAP -- clean this up so we can just _camelCase()! + k = MESSAGE_ROW_MAP[k] || _.camelCase(k); + msg[k] = v; + }); + return msg; + } + + /* + Find message IDs or UUIDs by filter. Available filters/options: + + filter.uuids - use with resultType='id' + filter.ids - use with resultType='uuid' + 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.terms - FTS search + + filter.sort = modTimestamp | messageId + filter.order = ascending | (descending) + + filter.limit + filter.resultType = (id) | uuid | count | messageList + filter.extraFields = [] + + filter.privateTagUserId = - if set, only private messages belonging to are processed + - areaTags filter ignored + - if NOT present, private areas are skipped + + *=NYI + */ + static findMessages(filter, cb) { + filter = filter || {}; + + filter.resultType = filter.resultType || 'id'; + filter.extraFields = filter.extraFields || []; + filter.operator = filter.operator || 'AND'; + + if('messageList' === filter.resultType) { + filter.extraFields = _.uniq(filter.extraFields.concat( + [ 'area_tag', 'message_uuid', 'reply_to_message_id', 'to_user_name', 'from_user_name', 'subject', 'modified_timestamp' ] + )); + } + + const field = 'uuid' === filter.resultType ? 'message_uuid' : 'message_id'; + + if(moment.isMoment(filter.newerThanTimestamp)) { + filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp); + } + + let sql; + if('count' === filter.resultType) { + sql = + `SELECT COUNT() AS count + FROM message m`; + + } else { + sql = + `SELECT DISTINCT m.${field}${filter.extraFields.length > 0 ? ', ' + filter.extraFields.map(f => `m.${f}`).join(', ') : ''} + FROM message m`; + } + + const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC'; + let sqlOrderBy; + let sqlWhere = ''; + + function appendWhereClause(clause, op) { + if(sqlWhere) { + sqlWhere += ` ${op || filter.operator} `; + } else { + sqlWhere += ' WHERE '; + } + sqlWhere += clause; + } + + // currently only avail sort + if('modTimestamp' === filter.sort) { + sqlOrderBy = `ORDER BY m.modified_timestamp ${sqlOrderDir}`; + } else { + sqlOrderBy = `ORDER BY m.message_id ${sqlOrderDir}`; + } + + if(Array.isArray(filter.ids)) { + appendWhereClause(`m.message_id IN (${filter.ids.join(', ')})`); + } + + if(Array.isArray(filter.uuids)) { + const uuidList = filter.uuids.map(u => `"${u}"`).join(', '); + appendWhereClause(`m.message_id IN (${uuidList})`); + } + + + if(_.isNumber(filter.privateTagUserId)) { + appendWhereClause(`m.area_tag = "${Message.WellKnownAreaTags.Private}"`); + appendWhereClause( + `m.message_id IN ( + SELECT message_id + FROM message_meta + WHERE meta_category = "System" AND meta_name = "${Message.SystemMetaNames.LocalToUserID}" AND meta_value = ${filter.privateTagUserId} + )`); + } else { + if(filter.areaTag && filter.areaTag.length > 0) { + if (!Array.isArray(filter.areaTag)) { + filter.areaTag = [ filter.areaTag ]; + } + + const areaList = filter.areaTag + .filter(t => t !== Message.WellKnownAreaTags.Private) + .map(t => `"${t}"`).join(', '); + if(areaList.length > 0) { + appendWhereClause(`m.area_tag IN(${areaList})`); + } else { + // nothing to do; no areas remain + return cb(null, []); + } + } else { + // explicit exclude of Private + appendWhereClause(`m.area_tag != "${Message.WellKnownAreaTags.Private}"`, 'AND'); + } + } + + if(_.isNumber(filter.replyToMessageId)) { + appendWhereClause(`m.reply_to_message_id=${filter.replyToMessageId}`); + } + + [ 'toUserName', 'fromUserName' ].forEach(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); + } + }); + + if(_.isString(filter.newerThanTimestamp) && filter.newerThanTimestamp.length > 0) { + // :TODO: should be using "localtime" here? + appendWhereClause(`DATETIME(m.modified_timestamp) > DATETIME("${filter.newerThanTimestamp}", "+1 seconds")`); + } else if(moment.isMoment(filter.date)) { + appendWhereClause(`DATE(m.modified_timestamp, "localtime") = DATE("${filter.date.format('YYYY-MM-DD')}")`); + } + + if(_.isNumber(filter.newerThanMessageId)) { + appendWhereClause(`m.message_id > ${filter.newerThanMessageId}`); + } + + if(filter.terms && filter.terms.length > 0) { + // note the ':' in MATCH expr., see https://www.sqlite.org/cvstrac/wiki?p=FullTextIndex + appendWhereClause( + `m.message_id IN ( + SELECT rowid + FROM message_fts + WHERE message_fts MATCH ":${sanitizeString(filter.terms)}" + )` + ); + } + + 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)) { + sql += ` LIMIT ${filter.limit}`; + } + + sql += ';'; + + if('count' === filter.resultType) { + msgDb.get(sql, (err, row) => { + return cb(err, row ? row.count : 0); + }); + } else { + const matches = []; + const extra = filter.extraFields.length > 0; + + const rowConv = 'messageList' === filter.resultType ? Message.getMessageFromRow : row => row; + + msgDb.each(sql, (err, row) => { + if(_.isObject(row)) { + matches.push(extra ? rowConv(row) : row[field]); + } + }, err => { + return cb(err, matches); + }); + } + } + + // :TODO: use findMessages, by uuid, limit=1 + static getMessageIdByUuid(uuid, cb) { + msgDb.get( + `SELECT message_id + FROM message + WHERE message_uuid = ? + LIMIT 1;`, + [ uuid ], + (err, row) => { + if(err) { + return cb(err); + } + + const success = (row && row.message_id); + return cb( + success ? null : Errors.DoesNotExist(`No message for UUID ${uuid}`), + success ? row.message_id : null + ); + } + ); + } + + // :TODO: use findMessages + static getMessageIdsByMetaValue(category, name, value, cb) { + msgDb.all( + `SELECT message_id + FROM message_meta + WHERE meta_category = ? AND meta_name = ? AND meta_value = ?;`, + [ category, name, value ], + (err, rows) => { + if(err) { + return cb(err); + } + return cb(null, rows.map(r => parseInt(r.message_id))); // return array of ID(s) + } + ); + } + + static getMetaValuesByMessageId(messageId, category, name, cb) { + const sql = + `SELECT meta_value + FROM message_meta + WHERE message_id = ? AND meta_category = ? AND meta_name = ?;`; + + msgDb.all(sql, [ messageId, category, name ], (err, rows) => { + if(err) { + return cb(err); + } + + if(0 === rows.length) { + return cb(Errors.DoesNotExist('No value for category/name')); + } + + // single values are returned without an array + if(1 === rows.length) { + return cb(null, rows[0].meta_value); + } + + return cb(null, rows.map(r => r.meta_value)); // map to array of values only + }); + } + + static getMetaValuesByMessageUuid(uuid, category, name, cb) { + async.waterfall( + [ + function getMessageId(callback) { + Message.getMessageIdByUuid(uuid, (err, messageId) => { + return callback(err, messageId); + }); + }, + function getMetaValues(messageId, callback) { + Message.getMetaValuesByMessageId(messageId, category, name, (err, values) => { + return callback(err, values); + }); + } + ], + (err, values) => { + return cb(err, values); + } + ); + } + + loadMeta(cb) { + /* + Example of loaded this.meta: + + meta: { + System: { + local_to_user_id: 1234, + }, + FtnProperty: { + ftn_seen_by: [ "1/102 103", "2/42 52 65" ] + } + } + */ + const sql = + `SELECT meta_category, meta_name, meta_value + FROM message_meta + WHERE message_id = ?;`; + + const self = this; // :TODO: not required - arrow functions below: + msgDb.each(sql, [ this.messageId ], (err, row) => { + if(!(row.meta_category in self.meta)) { + self.meta[row.meta_category] = { }; + self.meta[row.meta_category][row.meta_name] = row.meta_value; + } else { + if(!(row.meta_name in self.meta[row.meta_category])) { + self.meta[row.meta_category][row.meta_name] = row.meta_value; + } else { + if(_.isString(self.meta[row.meta_category][row.meta_name])) { + self.meta[row.meta_category][row.meta_name] = [ self.meta[row.meta_category][row.meta_name] ]; + } + + self.meta[row.meta_category][row.meta_name].push(row.meta_value); + } + } + }, err => { + return cb(err); + }); + } + + load(loadWith, cb) { + assert(_.isString(loadWith.uuid) || _.isNumber(loadWith.messageId)); + + const self = this; + + async.series( + [ + function loadMessage(callback) { + const whereField = loadWith.uuid ? 'message_uuid' : 'message_id'; + msgDb.get( + `SELECT message_id, area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, + message, modified_timestamp, view_count + FROM message + WHERE ${whereField} = ? + LIMIT 1;`, + [ loadWith.uuid || loadWith.messageId ], + (err, msgRow) => { + if(err) { + return callback(err); + } + + if(!msgRow) { + return callback(Errors.DoesNotExist('Message (no longer) available')); + } + + self.messageId = msgRow.message_id; + self.areaTag = msgRow.area_tag; + self.messageUuid = msgRow.message_uuid; + self.replyToMsgId = msgRow.reply_to_message_id; + self.toUserName = msgRow.to_user_name; + self.fromUserName = msgRow.from_user_name; + self.subject = msgRow.subject; + self.message = msgRow.message; + self.modTimestamp = moment(msgRow.modified_timestamp); + + return callback(err); + } + ); + }, + function loadMessageMeta(callback) { + self.loadMeta(err => { + return callback(err); + }); + }, + function loadHashTags(callback) { + // :TODO: + return callback(null); + } + ], + err => { + return cb(err); + } + ); + } + + persistMetaValue(category, name, value, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = msgDb; + } + + const metaStmt = transOrDb.prepare( + `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) + VALUES (?, ?, ?, ?);`); + + if(!_.isArray(value)) { + value = [ value ]; + } + + const self = this; + + async.each(value, (v, next) => { + metaStmt.run(self.messageId, category, name, v, err => { + return next(err); + }); + }, err => { + return cb(err); + }); + } + + persist(cb) { + if(!this.isValid()) { + return cb(Errors.Invalid('Cannot persist invalid message!')); + } + + const self = this; + + async.waterfall( + [ + function beginTransaction(callback) { + return msgDb.beginTransaction(callback); + }, + function storeMessage(trans, callback) { + // generate a UUID for this message if required (general case) + const msgTimestamp = moment(); + if(!self.messageUuid) { + self.messageUuid = Message.createMessageUUID( + self.areaTag, + msgTimestamp, + self.subject, + self.message + ); + } + + trans.run( + `INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, + [ + self.areaTag, self.messageUuid, self.replyToMsgId, self.toUserName, + self.fromUserName, self.subject, self.message, getISOTimestampString(msgTimestamp) + ], + function inserted(err) { // use non-arrow function for 'this' scope + if(!err) { + self.messageId = this.lastID; + } + + return callback(err, trans); + } + ); + }, + function storeMeta(trans, callback) { + if(!self.meta) { + return callback(null, trans); + } + /* + Example of self.meta: + + meta: { + System: { + local_to_user_id: 1234, + }, + FtnProperty: { + ftn_seen_by: [ "1/102 103", "2/42 52 65" ] + } + } + */ + async.each(Object.keys(self.meta), (category, nextCat) => { + async.each(Object.keys(self.meta[category]), (name, nextName) => { + self.persistMetaValue(category, name, self.meta[category][name], trans, err => { + return nextName(err); + }); + }, err => { + return nextCat(err); + }); + + }, err => { + return callback(err, trans); + }); + }, + function storeHashTags(trans, callback) { + // :TODO: hash tag support + return callback(null, trans); + } + ], + (err, trans) => { + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr, self.messageId); + }); + } else { + return cb(err); + } + } + ); + } + + deleteMessage(requestingUser, cb) { + if(!this.userHasDeleteRights(requestingUser)) { + return cb(Errors.AccessDenied('User does not have rights to delete this message')); + } + + msgDb.run( + `DELETE FROM message + WHERE message_uuid = ?;`, + [ this.messageUuid ], + err => { + return cb(err); + } + ); + } + + // :TODO: FTN stuff doesn't have any business here + getFTNQuotePrefix(source) { + source = source || 'fromUserName'; + + return ftnUtil.getQuotePrefix(this[source]); + } + + getTearLinePosition(input) { + const m = input.match(/^--- .+$(?![\s\S]*^--- .+$)/m); + return m ? m.index : -1; + } + + getQuoteLines(options, cb) { + if(!options.termWidth || !options.termHeight || !options.cols) { + return cb(Errors.MissingParam()); + } + + options.startCol = options.startCol || 1; + options.includePrefix = _.get(options, 'includePrefix', true); + options.ansiResetSgr = options.ansiResetSgr || ANSI.getSGRFromGraphicRendition( { fg : 39, bg : 49 }, true); + options.ansiFocusPrefixSgr = options.ansiFocusPrefixSgr || ANSI.getSGRFromGraphicRendition( { intensity : 'bold', fg : 39, bg : 49 } ); + options.isAnsi = options.isAnsi || isAnsi(this.message); // :TODO: If this.isAnsi, use that setting + + /* + Some long text that needs to be wrapped and quoted should look right after + doing so, don't ya think? yeah I think so + + Nu> Some long text that needs to be wrapped and quoted should look right + Nu> after doing so, don't ya think? yeah I think so + + Ot> Nu> Some long text that needs to be wrapped and quoted should look + Ot> Nu> right after doing so, don't ya think? yeah I think so + + */ + const quotePrefix = options.includePrefix ? this.getFTNQuotePrefix(options.prefixSource || 'fromUserName') : ''; + + function getWrapped(text, extraPrefix) { + extraPrefix = extraPrefix ? ` ${extraPrefix}` : ''; + + const wrapOpts = { + width : options.cols - (quotePrefix.length + extraPrefix.length), + tabHandling : 'expand', + tabWidth : 4, + }; + + return wordWrapText(text, wrapOpts).wrapped.map( (w, i) => { + return i === 0 ? `${quotePrefix}${w}` : `${quotePrefix}${extraPrefix}${w}`; + }); + } + + function getFormattedLine(line) { + // for pre-formatted text, we just append a line truncated to fit + let newLen; + const total = line.length + quotePrefix.length; + + if(total > options.cols) { + newLen = options.cols - total; + } else { + newLen = total; + } + + return `${quotePrefix}${line.slice(0, newLen)}`; + } + + if(options.isAnsi) { + ansiPrep( + this.message.replace(/\r?\n/g, '\r\n'), // normalized LF -> CRLF + { + termWidth : options.termWidth, + termHeight : options.termHeight, + cols : options.cols, + rows : 'auto', + startCol : options.startCol, + forceLineTerm : true, + }, + (err, prepped) => { + prepped = prepped || this.message; + + let lastSgr = ''; + const split = splitTextAtTerms(prepped); + + const quoteLines = []; + const focusQuoteLines = []; + + // + // Do not include quote prefixes (e.g. XX> ) on ANSI replies (and therefor quote builder) + // as while this works in ENiGMA, other boards such as Mystic, WWIV, etc. will try to + // strip colors, colorize the lines, etc. If we exclude the prefixes, this seems to do + // the trick and allow them to leave them alone! + // + split.forEach(l => { + quoteLines.push(`${lastSgr}${l}`); + + focusQuoteLines.push(`${options.ansiFocusPrefixSgr}>${lastSgr}${renderSubstr(l, 1, l.length - 1)}`); + lastSgr = (l.match(/(?:\x1b\x5b)[?=;0-9]*m(?!.*(?:\x1b\x5b)[?=;0-9]*m)/) || [])[0] || ''; // eslint-disable-line no-control-regex + }); + + quoteLines[quoteLines.length - 1] += options.ansiResetSgr; + + return cb(null, quoteLines, focusQuoteLines, true); + } + ); + } else { + const QUOTE_RE = /^ ((?:[A-Za-z0-9]{2}> )+(?:[A-Za-z0-9]{2}>)*) */; + const quoted = []; + const input = _.trimEnd(this.message).replace(/\x08/g, ''); + + // find *last* tearline + let tearLinePos = this.getTearLinePosition(input); + tearLinePos = -1 === tearLinePos ? input.length : tearLinePos; // we just want the index or the entire string + + input.slice(0, tearLinePos).split(/\r\n\r\n|\n\n/).forEach(paragraph => { + // + // For each paragraph, a state machine: + // - New line - line + // - New (pre)quoted line - quote_line + // - Continuation of new/quoted line + // + // Also: + // - Detect pre-formatted lines & try to keep them as-is + // + let state; + let buf = ''; + let quoteMatch; + + if(quoted.length > 0) { + // + // Preserve paragraph seperation. + // + // FSC-0032 states something about leaving blank lines fully blank + // (without a prefix) but it seems nicer (and more consistent with other systems) + // to put 'em in. + // + quoted.push(quotePrefix); + } + + paragraph.split(/\r?\n/).forEach(line => { + if(0 === line.trim().length) { + // see blank line notes above + return quoted.push(quotePrefix); + } + + quoteMatch = line.match(QUOTE_RE); + + switch(state) { + case 'line' : + if(quoteMatch) { + if(isFormattedLine(line)) { + quoted.push(getFormattedLine(line.replace(/\s/, ''))); + } else { + quoted.push(...getWrapped(buf, quoteMatch[1])); + state = 'quote_line'; + buf = line; + } + } else { + buf += ` ${line}`; + } + break; + + case 'quote_line' : + if(quoteMatch) { + const rem = line.slice(quoteMatch[0].length); + if(!buf.startsWith(quoteMatch[0])) { + quoted.push(...getWrapped(buf, quoteMatch[1])); + buf = rem; + } else { + buf += ` ${rem}`; + } + } else { + quoted.push(...getWrapped(buf)); + buf = line; + state = 'line'; + } + break; + + default : + if(isFormattedLine(line)) { + quoted.push(getFormattedLine(line)); + } else { + state = quoteMatch ? 'quote_line' : 'line'; + buf = 'line' === state ? line : line.replace(/\s/, ''); // trim *first* leading space, if any + } + break; + } + }); + + quoted.push(...getWrapped(buf, quoteMatch ? quoteMatch[1] : null)); + }); + + input.slice(tearLinePos).split(/\r?\n/).forEach(l => { + quoted.push(...getWrapped(l)); + }); + + return cb(null, quoted, null, false); + } + } }; diff --git a/core/message_area.js b/core/message_area.js index 484d22ec..30ad14ed 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -1,671 +1,792 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const msgDb = require('./database.js').dbs.message; -const Config = require('./config.js').config; -const Message = require('./message.js'); -const Log = require('./logger.js').log; -const msgNetRecord = require('./msg_network.js').recordMessage; -const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +// ENiGMA½ +const msgDb = require('./database.js').dbs.message; +const Config = require('./config.js').get; +const Message = require('./message.js'); +const Log = require('./logger.js').log; +const msgNetRecord = require('./msg_network.js').recordMessage; +const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +const UserProps = require('./user_property.js'); +const StatLog = require('./stat_log.js'); +const SysProps = require('./system_property.js'); -// deps -const async = require('async'); -const _ = require('lodash'); -const assert = require('assert'); +// deps +const async = require('async'); +const _ = require('lodash'); +const assert = require('assert'); +const moment = require('moment'); +exports.startup = startup; +exports.shutdown = shutdown; exports.getAvailableMessageConferences = getAvailableMessageConferences; -exports.getSortedAvailMessageConferences = getSortedAvailMessageConferences; +exports.getSortedAvailMessageConferences = getSortedAvailMessageConferences; exports.getAvailableMessageAreasByConfTag = getAvailableMessageAreasByConfTag; exports.getSortedAvailMessageAreasByConfTag = getSortedAvailMessageAreasByConfTag; +exports.getAllAvailableMessageAreaTags = getAllAvailableMessageAreaTags; exports.getDefaultMessageConferenceTag = getDefaultMessageConferenceTag; exports.getDefaultMessageAreaTagByConfTag = getDefaultMessageAreaTagByConfTag; -exports.getMessageConferenceByTag = getMessageConferenceByTag; -exports.getMessageAreaByTag = getMessageAreaByTag; -exports.changeMessageConference = changeMessageConference; -exports.changeMessageArea = changeMessageArea; -exports.tempChangeMessageConfAndArea = tempChangeMessageConfAndArea; -exports.getMessageListForArea = getMessageListForArea; -exports.getNewMessageCountInAreaForUser = getNewMessageCountInAreaForUser; -exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; -exports.getMessageAreaLastReadId = getMessageAreaLastReadId; -exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId; -exports.persistMessage = persistMessage; -exports.trimMessageAreasScheduledEvent = trimMessageAreasScheduledEvent; +exports.getSuitableMessageConfAndAreaTags = getSuitableMessageConfAndAreaTags; +exports.getMessageConferenceByTag = getMessageConferenceByTag; +exports.getMessageAreaByTag = getMessageAreaByTag; +exports.getMessageConfTagByAreaTag = getMessageConfTagByAreaTag; +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; +exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; +exports.getMessageIdNewerThanTimestampByArea = getMessageIdNewerThanTimestampByArea; +exports.getMessageAreaLastReadId = getMessageAreaLastReadId; +exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId; +exports.persistMessage = persistMessage; +exports.trimMessageAreasScheduledEvent = trimMessageAreasScheduledEvent; + +function startup(cb) { + // by default, private messages are NOT included + async.series( + [ + (callback) => { + Message.findMessages( { resultType : 'count' }, (err, count) => { + if(count) { + StatLog.setNonPersistentSystemStat(SysProps.MessageTotalCount, count); + } + return callback(err); + }); + }, + (callback) => { + Message.findMessages( { resultType : 'count', date : moment() }, (err, count) => { + if(count) { + StatLog.setNonPersistentSystemStat(SysProps.MessagesToday, count); + } + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); +} + +function shutdown(cb) { + return cb(null); +} function getAvailableMessageConferences(client, options) { - options = options || { includeSystemInternal : false }; + options = options || { includeSystemInternal : false }; - assert(client || true === options.noClient); - - // perform ACS check per conf & omit system_internal if desired - return _.omitBy(Config.messageConferences, (conf, confTag) => { - if(!options.includeSystemInternal && 'system_internal' === confTag) { - return true; - } + assert(client || true === options.noClient); - return client && !client.acs.hasMessageConfRead(conf); - }); + // perform ACS check per conf & omit system_internal if desired + return _.omitBy(Config().messageConferences, (conf, confTag) => { + if(!options.includeSystemInternal && 'system_internal' === confTag) { + return true; + } + + return client && !client.acs.hasMessageConfRead(conf); + }); } function getSortedAvailMessageConferences(client, options) { - const confs = _.map(getAvailableMessageConferences(client, options), (v, k) => { - return { - confTag : k, - conf : v, - }; - }); + const confs = _.map(getAvailableMessageConferences(client, options), (v, k) => { + return { + confTag : k, + conf : v, + }; + }); - sortAreasOrConfs(confs, 'conf'); - - return confs; + sortAreasOrConfs(confs, 'conf'); + + return confs; } // Return an *object* of available areas within |confTag| function getAvailableMessageAreasByConfTag(confTag, options) { - options = options || {}; - + options = options || {}; + // :TODO: confTag === "" then find default - if(_.has(Config.messageConferences, [ confTag, 'areas' ])) { - const areas = Config.messageConferences[confTag].areas; + const config = Config(); + if(_.has(config.messageConferences, [ confTag, 'areas' ])) { + const areas = config.messageConferences[confTag].areas; - if(!options.client || true === options.noAcsCheck) { - // everything - no ACS checks - return areas; - } else { - // perform ACS check per area - return _.omitBy(areas, area => { - return !options.client.acs.hasMessageAreaRead(area); - }); - } - } + if(!options.client || true === options.noAcsCheck) { + // everything - no ACS checks + return areas; + } else { + // perform ACS check per area + return _.omitBy(areas, area => { + return !options.client.acs.hasMessageAreaRead(area); + }); + } + } } function getSortedAvailMessageAreasByConfTag(confTag, options) { - const areas = _.map(getAvailableMessageAreasByConfTag(confTag, options), (v, k) => { - return { - areaTag : k, - area : v, - }; - }); - - sortAreasOrConfs(areas, 'area'); - - return areas; + const areas = _.map(getAvailableMessageAreasByConfTag(confTag, options), (v, k) => { + return { + areaTag : k, + area : v, + }; + }); + + sortAreasOrConfs(areas, 'area'); + + return areas; +} + +function getAllAvailableMessageAreaTags(client, options) { + const areaTags = []; + + // mask over older messy APIs for now + const confOpts = Object.assign({}, options, { noClient : client ? false : true }); + const areaOpts = Object.assign({}, options, { client }); + + Object.keys(getAvailableMessageConferences(client, confOpts)).forEach(confTag => { + areaTags.push(...Object.keys(getAvailableMessageAreasByConfTag(confTag, areaOpts))); + }); + + return areaTags; } function getDefaultMessageConferenceTag(client, disableAcsCheck) { - // - // Find the first conference marked 'default'. If found, - // inspect |client| against *read* ACS using defaults if not - // specified. - // - // If the above fails, just go down the list until we get one - // that passes. - // - // It's possible that we end up with nothing here! - // - // Note that built in 'system_internal' is always ommited here - // - let defaultConf = _.findKey(Config.messageConferences, o => o.default); - if(defaultConf) { - const conf = Config.messageConferences[defaultConf]; - if(true === disableAcsCheck || client.acs.hasMessageConfRead(conf)) { - return defaultConf; - } - } + // + // Find the first conference marked 'default'. If found, + // inspect |client| against *read* ACS using defaults if not + // specified. + // + // If the above fails, just go down the list until we get one + // that passes. + // + // It's possible that we end up with nothing here! + // + // Note that built in 'system_internal' is always ommited here + // + const config = Config(); + let defaultConf = _.findKey(config.messageConferences, o => o.default); + if(defaultConf) { + const conf = config.messageConferences[defaultConf]; + if(true === disableAcsCheck || client.acs.hasMessageConfRead(conf)) { + return defaultConf; + } + } - // just use anything we can - defaultConf = _.findKey(Config.messageConferences, (conf, confTag) => { - return 'system_internal' !== confTag && (true === disableAcsCheck || client.acs.hasMessageConfRead(conf)); - }); - - return defaultConf; + // just use anything we can + defaultConf = _.findKey(config.messageConferences, (conf, confTag) => { + return 'system_internal' !== confTag && (true === disableAcsCheck || client.acs.hasMessageConfRead(conf)); + }); + + return defaultConf; } function getDefaultMessageAreaTagByConfTag(client, confTag, disableAcsCheck) { - // - // Similar to finding the default conference: - // Find the first entry marked 'default', if any. If found, check | client| against - // *read* ACS. If this fails, just find the first one we can that passes checks. - // - // It's possible that we end up with nothing! - // - confTag = confTag || getDefaultMessageConferenceTag(client); + // + // Similar to finding the default conference: + // Find the first entry marked 'default', if any. If found, check | client| against + // *read* ACS. If this fails, just find the first one we can that passes checks. + // + // It's possible that we end up with nothing! + // + confTag = confTag || getDefaultMessageConferenceTag(client); - if(confTag && _.has(Config.messageConferences, [ confTag, 'areas' ])) { - const areaPool = Config.messageConferences[confTag].areas; - let defaultArea = _.findKey(areaPool, o => o.default); - if(defaultArea) { - const area = areaPool[defaultArea]; - if(true === disableAcsCheck || client.acs.hasMessageAreaRead(area)) { - return defaultArea; - } - } - - defaultArea = _.findKey(areaPool, (area) => { - return (true === disableAcsCheck || client.acs.hasMessageAreaRead(area)); - }); - - return defaultArea; - } + const config = Config(); + if(confTag && _.has(config.messageConferences, [ confTag, 'areas' ])) { + const areaPool = config.messageConferences[confTag].areas; + let defaultArea = _.findKey(areaPool, o => o.default); + if(defaultArea) { + const area = areaPool[defaultArea]; + if(true === disableAcsCheck || client.acs.hasMessageAreaRead(area)) { + return defaultArea; + } + } + + defaultArea = _.findKey(areaPool, (area, areaTag) => { + if(Message.isPrivateAreaTag(areaTag)) { + return false; + } + return (true === disableAcsCheck || client.acs.hasMessageAreaRead(area)); + }); + + return defaultArea; + } +} + +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]; -} - -function getMessageConfByAreaTag(areaTag) { - const confs = Config.messageConferences; - let conf; - _.forEach(confs, (v) => { - if(_.has(v, [ 'areas', areaTag ])) { - conf = v; - return false; // stop iteration - } - }); - return conf; + return Object.assign({ confTag }, Config().messageConferences[confTag]); } function getMessageConfTagByAreaTag(areaTag) { - const confs = Config.messageConferences; - return Object.keys(confs).find( (confTag) => { - return _.has(confs, [ confTag, 'areas', areaTag]); - }); + const confs = Config().messageConferences; + return Object.keys(confs).find( (confTag) => { + return _.has(confs, [ confTag, 'areas', areaTag]); + }); } function getMessageAreaByTag(areaTag, optionalConfTag) { - const confs = Config.messageConferences; + const confs = Config().messageConferences; - if(_.isString(optionalConfTag)) { - if(_.has(confs, [ optionalConfTag, 'areas', areaTag ])) { - return 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]; - return false; // stop iteration - } - }); - - return area; - } + // :TODO: this could be cached + if(_.isString(optionalConfTag)) { + if(_.has(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, (conf, confTag) => { + if(_.has(conf, [ 'areas', areaTag ])) { + area = Object.assign({ areaTag, confTag }, conf.areas[areaTag]); + return false; // stop iteration + } + }); + + return area; + } } function changeMessageConference(client, confTag, cb) { - async.waterfall( - [ - function getConf(callback) { - const conf = getMessageConferenceByTag(confTag); - - if(conf) { - callback(null, conf); - } else { - callback(new Error('Invalid message conference tag')); - } - }, - function getDefaultAreaInConf(conf, callback) { - const areaTag = getDefaultMessageAreaTagByConfTag(client, confTag); - const area = getMessageAreaByTag(areaTag, confTag); - - if(area) { - callback(null, conf, { areaTag : areaTag, area : area } ); - } else { - callback(new Error('No available areas for this user in conference')); - } - }, - function validateAccess(conf, areaInfo, callback) { - if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(areaInfo.area)) { - return callback(new Error('Access denied to message area and/or conference')); - } else { - return callback(null, conf, areaInfo); - } - }, - function changeConferenceAndArea(conf, areaInfo, callback) { - const newProps = { - message_conf_tag : confTag, - message_area_tag : areaInfo.areaTag, - }; - client.user.persistProperties(newProps, err => { - callback(err, conf, areaInfo); - }); - }, - ], - function complete(err, conf, areaInfo) { - if(!err) { - client.log.info( { confTag : confTag, confName : conf.name, areaTag : areaInfo.areaTag }, 'Current message conference changed'); - } else { - client.log.warn( { confTag : confTag, error : err.message }, 'Could not change message conference'); - } - cb(err); - } - ); + async.waterfall( + [ + function getConf(callback) { + const conf = getMessageConferenceByTag(confTag); + + if(conf) { + callback(null, conf); + } else { + callback(new Error('Invalid message conference tag')); + } + }, + function getDefaultAreaInConf(conf, callback) { + const areaTag = getDefaultMessageAreaTagByConfTag(client, confTag); + const area = getMessageAreaByTag(areaTag, confTag); + + if(area) { + callback(null, conf, { areaTag : areaTag, area : area } ); + } else { + callback(new Error('No available areas for this user in conference')); + } + }, + function validateAccess(conf, areaInfo, callback) { + if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(areaInfo.area)) { + return callback(new Error('Access denied to message area and/or conference')); + } else { + return callback(null, conf, areaInfo); + } + }, + function changeConferenceAndArea(conf, areaInfo, callback) { + const newProps = { + [ UserProps.MessageConfTag ] : confTag, + [ UserProps.MessageAreaTag ] : areaInfo.areaTag, + }; + client.user.persistProperties(newProps, err => { + callback(err, conf, areaInfo); + }); + }, + ], + function complete(err, conf, areaInfo) { + if(!err) { + client.log.info( { confTag : confTag, confName : conf.name, areaTag : areaInfo.areaTag }, 'Current message conference changed'); + } else { + client.log.warn( { confTag : confTag, error : err.message }, 'Could not change message conference'); + } + cb(err); + } + ); } function changeMessageAreaWithOptions(client, areaTag, options, cb) { - options = options || {}; // :TODO: this is currently pointless... cb is required... + options = options || {}; // :TODO: this is currently pointless... cb is required... - async.waterfall( - [ - function getArea(callback) { - const area = getMessageAreaByTag(areaTag); - return callback(area ? null : new Error('Invalid message areaTag'), area); - }, - function validateAccess(area, callback) { + async.waterfall( + [ + function getArea(callback) { + const area = getMessageAreaByTag(areaTag); + return callback(area ? null : new Error('Invalid message areaTag'), area); + }, + function validateAccess(area, callback) { // // Need at least *read* to access the area // - if(!client.acs.hasMessageAreaRead(area)) { - return callback(new Error('Access denied to message area')); - } else { - return callback(null, area); - } - }, - function changeArea(area, callback) { - if(true === options.persist) { - client.user.persistProperty('message_area_tag', areaTag, function persisted(err) { - return callback(err, area); - }); - } else { - client.user.properties['message_area_tag'] = areaTag; - return callback(null, area); - } - } - ], - function complete(err, area) { - if(!err) { - client.log.info( { areaTag : areaTag, area : area }, 'Current message area changed'); - } else { - client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change message area'); - } + if(!client.acs.hasMessageAreaRead(area)) { + return callback(new Error('Access denied to message area')); + } else { + return callback(null, area); + } + }, + function changeArea(area, callback) { + if(true === options.persist) { + client.user.persistProperty(UserProps.MessageAreaTag, areaTag, function persisted(err) { + return callback(err, area); + }); + } else { + client.user.properties[UserProps.MessageAreaTag] = areaTag; + return callback(null, area); + } + } + ], + function complete(err, area) { + if(!err) { + client.log.info( { areaTag : areaTag, area : area }, 'Current message area changed'); + } else { + client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change message area'); + } - return cb(err); - } - ); + return cb(err); + } + ); } // -// Temporairly -- e.g. non-persisted -- change to an area and it's -// associated underlying conference. ACS is checked for both. +// Temporairly -- e.g. non-persisted -- change to an area and it's +// associated underlying conference. ACS is checked for both. // -// This is useful for example when doing a new scan +// This is useful for example when doing a new scan // function tempChangeMessageConfAndArea(client, areaTag) { - const area = getMessageAreaByTag(areaTag); - const confTag = getMessageConfTagByAreaTag(areaTag); + const area = getMessageAreaByTag(areaTag); + const confTag = getMessageConfTagByAreaTag(areaTag); - if(!area || !confTag) { - return false; - } + if(!area || !confTag) { + return false; + } - const conf = getMessageConferenceByTag(confTag); + const conf = getMessageConferenceByTag(confTag); - if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(area)) { - return false; - } - - client.user.properties.message_conf_tag = confTag; - client.user.properties.message_area_tag = areaTag; + if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(area)) { + return false; + } - return true; + client.user.properties[UserProps.MessageConfTag] = confTag; + client.user.properties[UserProps.MessageAreaTag] = areaTag; + + return true; } function changeMessageArea(client, areaTag, cb) { - changeMessageAreaWithOptions(client, areaTag, { persist : true }, cb); + changeMessageAreaWithOptions(client, areaTag, { persist : true }, cb); } -function getMessageFromRow(row) { - return { - messageId : row.message_id, - messageUuid : row.message_uuid, - replyToMsgId : row.reply_to_message_id, - toUserName : row.to_user_name, - fromUserName : row.from_user_name, - subject : row.subject, - modTimestamp : row.modified_timestamp, - viewCount : row.view_count, - }; +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 getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, what) { - // - // Helper for building SQL to fetch either a full message list or simply - // a count of new messages based on |what|. - // - // * If |areaTag| is Message.WellKnownAreaTags.Private, - // only messages addressed to |userId| should be returned/counted. - // - // * Only messages > |lastMessageId| should be returned/counted - // - const selectWhat = ('count' === what) ? - 'COUNT() AS count' : - 'message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count'; +function hasMessageConfAndAreaWrite(client, areaOrTag) { + if(_.isString(areaOrTag)) { + areaOrTag = getMessageAreaByTag(areaOrTag) || {}; + } + const conf = getMessageConferenceByTag(areaOrTag.confTag); + return client.acs.hasMessageConfWrite(conf) && client.acs.hasMessageAreaWrite(areaOrTag); +} - let sql = - `SELECT ${selectWhat} - FROM message - WHERE area_tag = "${areaTag}" AND message_id > ${lastMessageId}`; +function filterMessageAreaTagsByReadACS(client, areaTags) { + if(!Array.isArray(areaTags)) { + areaTags = [ areaTags ]; + } - if(Message.isPrivateAreaTag(areaTag)) { - sql += - ` AND message_id in ( - SELECT message_id - FROM message_meta - WHERE meta_category = "System" AND meta_name = "${Message.SystemMetaNames.LocalToUserID}" AND meta_value = ${userId} - )`; - } + return areaTags.filter( areaTag => { + const area = getMessageAreaByTag(areaTag); + return hasMessageConfAndAreaRead(client, area); + }); +} - if('count' === what) { - sql += ';'; - } else { - sql += ' ORDER BY message_id;'; - } +function filterMessageListByReadACS(client, messageList) { + // + // Filter out messages belonging to conf/areas the user + // doesn't have access to. + // - return sql; + // 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) { - async.waterfall( - [ - function getLastMessageId(callback) { - getMessageAreaLastReadId(userId, areaTag, function fetched(err, lastMessageId) { - callback(null, lastMessageId || 0); // note: willingly ignoring any errors here! - }); - }, - function getCount(lastMessageId, callback) { - const sql = getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, 'count'); - msgDb.get(sql, (err, row) => { - return callback(err, row ? row.count : 0); - }); - } - ], - cb - ); + getMessageAreaLastReadId(userId, areaTag, (err, lastMessageId) => { + lastMessageId = lastMessageId || 0; + + const filter = { + areaTag, + newerThanMessageId : lastMessageId, + resultType : 'count', + }; + + if(Message.isPrivateAreaTag(areaTag)) { + filter.privateTagUserId = userId; + } + + Message.findMessages(filter, (err, count) => { + return cb(err, count); + }); + }); } function getNewMessagesInAreaForUser(userId, areaTag, cb) { - // - // If |areaTag| is Message.WellKnownAreaTags.Private, - // only messages addressed to |userId| should be returned. - // - // Only messages > lastMessageId should be returned - // - let msgList = []; + getMessageAreaLastReadId(userId, areaTag, (err, lastMessageId) => { + lastMessageId = lastMessageId || 0; - async.waterfall( - [ - function getLastMessageId(callback) { - getMessageAreaLastReadId(userId, areaTag, function fetched(err, lastMessageId) { - callback(null, lastMessageId || 0); // note: willingly ignoring any errors here! - }); - }, - function getMessages(lastMessageId, callback) { - const sql = getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, 'messages'); + const filter = { + areaTag, + resultType : 'messageList', + newerThanMessageId : lastMessageId, + sort : 'messageId', + order : 'ascending', + }; - msgDb.each(sql, function msgRow(err, row) { - if(!err) { - msgList.push(getMessageFromRow(row)); - } - }, callback); - } - ], - function complete(err) { - cb(err, msgList); - } - ); + if(Message.isPrivateAreaTag(areaTag)) { + filter.privateTagUserId = userId; + } + + return Message.findMessages(filter, cb); + }); } -function getMessageListForArea(options, areaTag, cb) { - // - // options.client (required) - // +function getMessageListForArea(client, areaTag, filter, cb) +{ + if(!cb && _.isFunction(filter)) { + cb = filter; + filter = { + areaTag, + resultType : 'messageList', + sort : 'messageId', + order : 'ascending' + }; + } else { + Object.assign(filter, { areaTag } ); + } - options.client.log.debug( { areaTag : areaTag }, 'Fetching available messages'); + if(client) { + if(!hasMessageConfAndAreaRead(client, areaTag)) { + return cb(null, []); + } + } - assert(_.isObject(options.client)); + if(Message.isPrivateAreaTag(areaTag)) { + filter.privateTagUserId = client ? client.user.userId : 'INVALID_USER_ID'; + } - /* - [ - { - messageId, messageUuid, replyToId, toUserName, fromUserName, subject, modTimestamp, - status(new|old), - viewCount - } - ] - */ + return Message.findMessages(filter, cb); +} - let msgList = []; - - async.series( - [ - function fetchMessages(callback) { - let sql = - `SELECT message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count - FROM message - WHERE area_tag = ?`; - - if(Message.isPrivateAreaTag(areaTag)) { - sql += - ` AND message_id IN ( - SELECT message_id - FROM message_meta - WHERE meta_category = "System" AND meta_name = "${Message.SystemMetaNames.LocalToUserID}" AND meta_value = ${options.client.user.userId} - )`; - } - - sql += ' ORDER BY message_id;'; - - msgDb.each( - sql, - [ areaTag.toLowerCase() ], - (err, row) => { - if(!err) { - msgList.push(getMessageFromRow(row)); - } - }, - callback - ); - }, - function fetchStatus(callback) { - callback(null);// :TODO: fixmeh. - } - ], - function complete(err) { - cb(err, msgList); - } - ); +function getMessageIdNewerThanTimestampByArea(areaTag, newerThanTimestamp, cb) { + Message.findMessages( + { + areaTag, + newerThanTimestamp, + sort : 'modTimestamp', + order : 'ascending', + limit : 1, + }, + (err, id) => { + if(err) { + return cb(err); + } + return cb(null, id ? id[0] : null); + } + ); } function getMessageAreaLastReadId(userId, areaTag, cb) { - msgDb.get( - 'SELECT message_id ' + - 'FROM user_message_area_last_read ' + - 'WHERE user_id = ? AND area_tag = ?;', - [ userId, areaTag.toLowerCase() ], - function complete(err, row) { - cb(err, row ? row.message_id : 0); - } - ); + msgDb.get( + 'SELECT message_id ' + + 'FROM user_message_area_last_read ' + + 'WHERE user_id = ? AND area_tag = ?;', + [ userId, areaTag.toLowerCase() ], + function complete(err, row) { + cb(err, row ? row.message_id : 0); + } + ); } -function updateMessageAreaLastReadId(userId, areaTag, messageId, cb) { - // :TODO: likely a better way to do this... - async.waterfall( - [ - function getCurrent(callback) { - getMessageAreaLastReadId(userId, areaTag, function result(err, lastId) { - lastId = lastId || 0; - callback(null, lastId); // ignore errors as we default to 0 - }); - }, - function update(lastId, callback) { - if(messageId > lastId) { - msgDb.run( - 'REPLACE INTO user_message_area_last_read (user_id, area_tag, message_id) ' + - 'VALUES (?, ?, ?);', - [ userId, areaTag, messageId ], - function written(err) { - callback(err, true); // true=didUpdate - } - ); - } else { - callback(null); - } - } - ], - function complete(err, didUpdate) { - if(err) { - Log.debug( - { error : err.toString(), userId : userId, areaTag : areaTag, messageId : messageId }, - 'Failed updating area last read ID'); - } else { - if(true === didUpdate) { - Log.trace( - { userId : userId, areaTag : areaTag, messageId : messageId }, - 'Area last read ID updated'); - } - } - cb(err); - } - ); +function updateMessageAreaLastReadId(userId, areaTag, messageId, allowOlder, cb) { + if(!cb && _.isFunction(allowOlder)) { + cb = allowOlder; + allowOlder = false; + } + + // :TODO: likely a better way to do this... + async.waterfall( + [ + function getCurrent(callback) { + getMessageAreaLastReadId(userId, areaTag, function result(err, lastId) { + lastId = lastId || 0; + callback(null, lastId); // ignore errors as we default to 0 + }); + }, + function update(lastId, callback) { + if(allowOlder || messageId > lastId) { + msgDb.run( + 'REPLACE INTO user_message_area_last_read (user_id, area_tag, message_id) ' + + 'VALUES (?, ?, ?);', + [ userId, areaTag, messageId ], + function written(err) { + callback(err, true); // true=didUpdate + } + ); + } else { + callback(null); + } + } + ], + function complete(err, didUpdate) { + if(err) { + Log.debug( + { error : err.toString(), userId : userId, areaTag : areaTag, messageId : messageId }, + 'Failed updating area last read ID'); + } else { + if(true === didUpdate) { + Log.trace( + { userId : userId, areaTag : areaTag, messageId : messageId }, + 'Area last read ID updated'); + } + } + cb(err); + } + ); } function persistMessage(message, cb) { - async.series( - [ - function persistMessageToDisc(callback) { - return message.persist(callback); - }, - function recordToMessageNetworks(callback) { - return msgNetRecord(message, callback); - } - ], - cb - ); + async.series( + [ + function persistMessageToDisc(callback) { + return message.persist(callback); + }, + function recordToMessageNetworks(callback) { + return msgNetRecord(message, callback); + } + ], + cb + ); } -// method exposed for event scheduler +// method exposed for event scheduler function trimMessageAreasScheduledEvent(args, cb) { - - function trimMessageAreaByMaxMessages(areaInfo, cb) { - if(0 === areaInfo.maxMessages) { - return cb(null); - } - msgDb.run( - `DELETE FROM message - WHERE message_id IN( - SELECT message_id - FROM message - WHERE area_tag = ? - ORDER BY message_id DESC - LIMIT -1 OFFSET ${areaInfo.maxMessages} - );`, - [ areaInfo.areaTag.toLowerCase() ], - err => { - if(err) { - Log.error( { areaInfo : areaInfo, err : err, type : 'maxMessages' }, 'Error trimming message area'); - } else { - Log.debug( { areaInfo : areaInfo, type : 'maxMessages' }, 'Area trimmed successfully'); - } - return cb(err); - } - ); - } + function trimMessageAreaByMaxMessages(areaInfo, cb) { + if(0 === areaInfo.maxMessages) { + return cb(null); + } - function trimMessageAreaByMaxAgeDays(areaInfo, cb) { - if(0 === areaInfo.maxAgeDays) { - return cb(null); - } + msgDb.run( + `DELETE FROM message + WHERE message_id IN( + SELECT message_id + FROM message + WHERE area_tag = ? + ORDER BY message_id DESC + LIMIT -1 OFFSET ${areaInfo.maxMessages} + );`, + [ areaInfo.areaTag.toLowerCase() ], + function result(err) { // no arrow func; need this + if(err) { + Log.error( { areaInfo : areaInfo, error : err.message, type : 'maxMessages' }, 'Error trimming message area'); + } else { + Log.debug( { areaInfo : areaInfo, type : 'maxMessages', count : this.changes }, 'Area trimmed successfully'); + } + return cb(err); + } + ); + } - msgDb.run( - `DELETE FROM message - WHERE area_tag = ? AND modified_timestamp < date('now', '-${areaInfo.maxAgeDays} days');`, - [ areaInfo.areaTag ], - err => { - if(err) { - Log.warn( { areaInfo : areaInfo, err : err, type : 'maxAgeDays' }, 'Error trimming message area'); - } else { - Log.debug( { areaInfo : areaInfo, type : 'maxAgeDays' }, 'Area trimmed successfully'); - } - return cb(err); - } - ); - } - - async.waterfall( - [ - function getAreaTags(callback) { - let areaTags = []; - msgDb.each( - `SELECT DISTINCT area_tag - FROM message;`, - (err, row) => { - if(err) { - return callback(err); - } - areaTags.push(row.area_tag); - }, - err => { - return callback(err, areaTags); - } - ); - }, - function prepareAreaInfo(areaTags, callback) { - let areaInfos = []; + function trimMessageAreaByMaxAgeDays(areaInfo, cb) { + if(0 === areaInfo.maxAgeDays) { + return cb(null); + } - // determine maxMessages & maxAgeDays per area - areaTags.forEach(areaTag => { - - let maxMessages = Config.messageAreaDefaults.maxMessages; - let maxAgeDays = Config.messageAreaDefaults.maxAgeDays; - - const area = getMessageAreaByTag(areaTag); // note: we don't know the conf here - if(area) { - if(area.maxMessages) { - maxMessages = area.maxMessages; - } - if(area.maxAgeDays) { - maxAgeDays = area.maxAgeDays; - } - } + msgDb.run( + `DELETE FROM message + WHERE area_tag = ? AND modified_timestamp < date('now', '-${areaInfo.maxAgeDays} days');`, + [ areaInfo.areaTag ], + function result(err) { // no arrow func; need this + if(err) { + Log.warn( { areaInfo : areaInfo, error : err.message, type : 'maxAgeDays' }, 'Error trimming message area'); + } else { + Log.debug( { areaInfo : areaInfo, type : 'maxAgeDays', count : this.changes }, 'Area trimmed successfully'); + } + return cb(err); + } + ); + } - areaInfos.push( { - areaTag : areaTag, - maxMessages : maxMessages, - maxAgeDays : maxAgeDays, - } ); - }); + async.waterfall( + [ + function getAreaTags(callback) { + const areaTags = []; - return callback(null, areaInfos); - }, - function trimAreas(areaInfos, callback) { - async.each( - areaInfos, - (areaInfo, next) => { - trimMessageAreaByMaxMessages(areaInfo, err => { - if(err) { - return next(err); - } + // + // We use SQL here vs API such that no-longer-used tags are picked up + // + msgDb.each( + `SELECT DISTINCT area_tag + FROM message;`, + (err, row) => { + if(err) { + return callback(err); + } - trimMessageAreaByMaxAgeDays(areaInfo, err => { - return next(err); - }); - }); - }, - callback - ); - } - ], - err => { - return cb(err); - } - ); - + // We treat private mail special + if(!Message.isPrivateAreaTag(row.area_tag)) { + areaTags.push(row.area_tag); + } + }, + err => { + return callback(err, areaTags); + } + ); + }, + function prepareAreaInfo(areaTags, callback) { + let areaInfos = []; + + // determine maxMessages & maxAgeDays per area + const config = Config(); + areaTags.forEach(areaTag => { + + let maxMessages = config.messageAreaDefaults.maxMessages; + let maxAgeDays = config.messageAreaDefaults.maxAgeDays; + + const area = getMessageAreaByTag(areaTag); // note: we don't know the conf here + if(area) { + maxMessages = area.maxMessages || maxMessages; + maxAgeDays = area.maxAgeDays || maxAgeDays; + } + + areaInfos.push( { + areaTag : areaTag, + maxMessages : maxMessages, + maxAgeDays : maxAgeDays, + } ); + }); + + return callback(null, areaInfos); + }, + function trimGeneralAreas(areaInfos, callback) { + async.each( + areaInfos, + (areaInfo, next) => { + trimMessageAreaByMaxMessages(areaInfo, err => { + if(err) { + return next(err); + } + + trimMessageAreaByMaxAgeDays(areaInfo, err => { + return next(err); + }); + }); + }, + callback + ); + }, + function trimExternalPrivateSentMail(callback) { + // + // *External* (FTN, email, ...) outgoing is cleaned up *after export* + // if it is older than the configured |maxExternalSentAgeDays| days + // + // Outgoing externally exported private mail is: + // - In the 'private_mail' area + // - Marked exported (state_flags0 exported bit set) + // - Marked with any external flavor (we don't mark local) + // + const maxExternalSentAgeDays = _.get( + Config, + 'messageConferences.system_internal.areas.private_mail.maxExternalSentAgeDays', + 30 + ); + + msgDb.run( + `DELETE FROM message + WHERE message_id IN ( + SELECT m.message_id + FROM message m + JOIN message_meta mms + ON m.message_id = mms.message_id AND + (mms.meta_category='System' AND mms.meta_name='${Message.SystemMetaNames.StateFlags0}' AND (mms.meta_value & ${Message.StateFlags0.Exported} = ${Message.StateFlags0.Exported})) + JOIN message_meta mmf + ON m.message_id = mmf.message_id AND + (mmf.meta_category='System' AND mmf.meta_name='${Message.SystemMetaNames.ExternalFlavor}') + WHERE m.area_tag='${Message.WellKnownAreaTags.Private}' AND DATETIME('now') > DATETIME(m.modified_timestamp, '+${maxExternalSentAgeDays} days') + );`, + function results(err) { // no arrow func; need this + if(err) { + Log.warn( { error : err.message }, 'Error trimming private externally sent messages'); + } else { + Log.debug( { count : this.changes }, 'Private externally sent messages trimmed successfully'); + } + } + ); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); } \ No newline at end of file diff --git a/core/message_base_qwk_export.js b/core/message_base_qwk_export.js new file mode 100644 index 00000000..2f89c1bd --- /dev/null +++ b/core/message_base_qwk_export.js @@ -0,0 +1,428 @@ +// ENiGMA½ +const { MenuModule } = require('./menu_module'); +const Message = require('./message'); +const { Errors } = require('./enig_error'); +const { + getMessageAreaByTag, + getMessageConferenceByTag, + hasMessageConfAndAreaRead, + getAllAvailableMessageAreaTags, +} = require('./message_area'); +const FileArea = require('./file_base_area'); +const { QWKPacketWriter } = require('./qwk_mail_packet'); +const { renderSubstr } = require('./string_util'); +const Config = require('./config').get; +const FileEntry = require('./file_entry'); +const DownloadQueue = require('./download_queue'); +const { getISOTimestampString } = require('./database'); + +// deps +const async = require('async'); +const _ = require('lodash'); +const fse = require('fs-extra'); +const temptmp = require('temptmp'); +const paths = require('path'); +const { v4 : UUIDv4 } = require('uuid'); +const moment = require('moment'); + +const FormIds = { + main : 0, +}; + +const MciViewIds = { + main : { + status : 1, + progressBar : 2, + + customRangeStart : 10, + } +}; + +const UserProperties = { + ExportOptions : 'qwk_export_options', + ExportAreas : 'qwk_export_msg_areas', +}; + +exports.moduleInfo = { + name : 'QWK Export', + desc : 'Exports a QWK Packet for download', + author : 'NuSkooler', +}; + +exports.getModule = class MessageBaseQWKExport extends MenuModule { + constructor(options) { + super(options); + + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), options.extraArgs); + + this.config.progBarChar = renderSubstr( (this.config.progBarChar || '▒'), 0, 1); + this.config.bbsID = this.config.bbsID || _.get(Config(), 'messageNetworks.qwk.bbsID', 'ENIGMA'); + + this.tempName = `${UUIDv4().substr(-8).toUpperCase()}.QWK`; + this.sysTempDownloadArea = FileArea.getFileAreaByTag(FileArea.WellKnownAreaTags.TempDownloads); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if (err) { + return cb(err); + } + + async.waterfall( + [ + (callback) => { + this.prepViewController('main', FormIds.main, mciData.menu, err => { + return callback(err); + }); + }, + (callback) => { + this.temptmp = temptmp.createTrackedSession('qwkuserexp'); + this.temptmp.mkdir({ prefix : 'enigqwkwriter-'}, (err, tempDir) => { + if (err) { + return callback(err); + } + + this.tempPacketDir = tempDir; + + const sysTempDownloadDir = FileArea.getAreaDefaultStorageDirectory(this.sysTempDownloadArea); + + // ensure dir exists + fse.mkdirs(sysTempDownloadDir, err => { + return callback(err, sysTempDownloadDir); + }); + }); + }, + (sysTempDownloadDir, callback) => { + this._performExport(sysTempDownloadDir, err => { + return callback(err); + }); + }, + ], + err => { + this.temptmp.cleanup(); + + if (err) { + // :TODO: doesn't do anything currently: + if ('NORESULTS' === err.reasonCode) { + return this.gotoMenu(this.menuConfig.config.noResultsMenu || 'qwkExportNoResults'); + } + + return this.prevMenu(); + } + return cb(err); + } + ); + }); + } + + finishedLoading() { + this.prevMenu(); + } + + _getUserQWKExportOptions() { + let qwkOptions = this.client.user.getProperty(UserProperties.ExportOptions); + try { + qwkOptions = JSON.parse(qwkOptions); + } catch(e) { + qwkOptions = { + enableQWKE : true, + enableHeadersExtension : true, + enableAtKludges : true, + archiveFormat : 'application/zip', + }; + } + return qwkOptions; + } + + _getUserQWKExportAreas() { + let qwkExportAreas = this.client.user.getProperty(UserProperties.ExportAreas); + try { + qwkExportAreas = JSON.parse(qwkExportAreas).map(exportArea => { + if (exportArea.newerThanTimestamp) { + exportArea.newerThanTimestamp = moment(exportArea.newerThanTimestamp); + } + return exportArea; + }); + } catch(e) { + // default to all public and private without 'since' + qwkExportAreas = getAllAvailableMessageAreaTags(this.client).map(areaTag => { + return { areaTag }; + }); + + // Include user's private area + qwkExportAreas.push({ + areaTag : Message.WellKnownAreaTags.Private, + }); + } + + return qwkExportAreas; + } + + _performExport(sysTempDownloadDir, cb) { + const statusView = this.viewControllers.main.getView(MciViewIds.main.status); + const updateStatus = (status) => { + if (statusView) { + statusView.setText(status); + } + }; + + const progBarView = this.viewControllers.main.getView(MciViewIds.main.progressBar); + const updateProgressBar = (curr, total) => { + if (progBarView) { + const prog = Math.floor( (curr / total) * progBarView.dimens.width ); + progBarView.setText(this.config.progBarChar.repeat(prog)); + } + }; + + let cancel = false; + + let lastProgUpdate = 0; + const progressHandler = (state, next) => { + // we can produce a TON of updates; only update progress at most every 3/4s + if (Date.now() - lastProgUpdate > 750) { + switch (state.step) { + case 'next_area' : + updateStatus(state.status); + updateProgressBar(0, 0); + this.updateCustomViewTextsWithFilter('main', MciViewIds.main.customRangeStart, state); + break; + + case 'message' : + updateStatus(state.status); + updateProgressBar(state.current, state.total); + this.updateCustomViewTextsWithFilter('main', MciViewIds.main.customRangeStart, state); + break; + + default : + break; + } + lastProgUpdate = Date.now(); + } + + return next(cancel ? Errors.UserInterrupt('User canceled') : null); + }; + + const keyPressHandler = (ch, key) => { + if('escape' === key.name) { + cancel = true; + this.client.removeListener('key press', keyPressHandler); + } + }; + + let totalExported = 0; + const processMessagesWithFilter = (filter, cb) => { + Message.findMessages(filter, (err, messageIds) => { + if (err) { + return cb(err); + } + + let current = 1; + async.eachSeries(messageIds, (messageId, nextMessageId) => { + const message = new Message(); + message.load({ messageId }, err => { + if (err) { + return nextMessageId(err); + } + + const progress = { + message, + step : 'message', + total : ++totalExported, + areaCurrent : current, + areaCount : messageIds.length, + status : `${_.truncate(message.subject, { length : 25 })} (${current} / ${messageIds.length})`, + }; + + progressHandler(progress, err => { + if (err) { + return nextMessageId(err); + } + + packetWriter.appendMessage(message); + current += 1; + + return nextMessageId(null); + }); + }); + }, + err => { + return cb(err); + }); + }); + }; + + const packetWriter = new QWKPacketWriter( + Object.assign(this._getUserQWKExportOptions(), { + user : this.client.user, + bbsID : this.config.bbsID, + }) + ); + + packetWriter.on('warning', warning => { + this.client.log.warn( { warning }, 'QWK packet writer warning'); + }); + + async.waterfall( + [ + (callback) => { + // don't count idle monitor while processing + this.client.stopIdleMonitor(); + + // let user cancel + this.client.on('key press', keyPressHandler); + + packetWriter.once('ready', () => { + return callback(null); + }); + + packetWriter.once('error', err => { + this.client.log.error( { error : err.message }, 'QWK packet writer error'); + cancel = true; + }); + + packetWriter.init(); + }, + (callback) => { + // For each public area -> for each message + const userExportAreas = this._getUserQWKExportAreas(); + + const publicExportAreas = userExportAreas + .filter(exportArea => { + return exportArea.areaTag !== Message.WellKnownAreaTags.Private; + }); + async.eachSeries(publicExportAreas, (exportArea, nextExportArea) => { + const area = getMessageAreaByTag(exportArea.areaTag); + const conf = getMessageConferenceByTag(area.confTag); + if (!area || !conf) { + // :TODO: remove from user properties - this area does not exist + this.client.log.warn({ areaTag : exportArea.areaTag }, 'Cannot QWK export area as it does not exist'); + return nextExportArea(null); + } + + if (!hasMessageConfAndAreaRead(this.client, area)) { + this.client.log.warn({ areaTag : area.areaTag }, 'Cannot QWK export area due to ACS'); + return nextExportArea(null); + } + + const progress = { + conf, + area, + step : 'next_area', + status : `Gathering in ${conf.name} - ${area.name}...`, + }; + + progressHandler(progress, err => { + if (err) { + return nextExportArea(err); + } + + const filter = { + resultType : 'id', + areaTag : exportArea.areaTag, + newerThanTimestamp : exportArea.newerThanTimestamp + }; + + processMessagesWithFilter(filter, err => { + return nextExportArea(err); + }); + }); + }, + err => { + return callback(err, userExportAreas); + }); + }, + (userExportAreas, callback) => { + // Private messages to current user if the user has + // elected to export private messages + const privateExportArea = userExportAreas.find(exportArea => exportArea.areaTag === Message.WellKnownAreaTags.Private); + if (!privateExportArea) { + return callback(null); + } + + const filter = { + resultType : 'id', + privateTagUserId : this.client.user.userId, + newerThanTimestamp : privateExportArea.newerThanTimestamp, + }; + return processMessagesWithFilter(filter, callback); + }, + (callback) => { + let packetInfo; + packetWriter.once('packet', info => { + packetInfo = info; + }); + + packetWriter.once('finished', () => { + return callback(null, packetInfo); + }); + + packetWriter.finish(this.tempPacketDir); + }, + (packetInfo, callback) => { + if (0 === totalExported) { + return callback(Errors.NothingToDo('No messages exported')); + } + + const sysDownloadPath = paths.join(sysTempDownloadDir, this.tempName); + fse.move(packetInfo.path, sysDownloadPath, err => { + return callback(null, sysDownloadPath, packetInfo); + }); + }, + (sysDownloadPath, packetInfo, callback) => { + const newEntry = new FileEntry({ + areaTag : this.sysTempDownloadArea.areaTag, + fileName : paths.basename(sysDownloadPath), + storageTag : this.sysTempDownloadArea.storageTags[0], + meta : { + upload_by_username : this.client.user.username, + upload_by_user_id : this.client.user.userId, + byte_size : packetInfo.stats.size, + session_temp_dl : 1, // download is valid until session is over + + // :TODO: something like this: allow to override the displayed/downloaded as filename + // separate from the actual on disk filename. E.g. we could always download as "ENIGMA.QWK" + //visible_filename : paths.basename(packetInfo.path), + } + }); + + newEntry.desc = 'QWK Export'; + + newEntry.persist(err => { + if(!err) { + // queue it! + DownloadQueue.get(this.client).addTemporaryDownload(newEntry); + } + return callback(err); + }); + }, + (callback) => { + // update user's export area dates; they can always change/reset them again + const updatedUserExportAreas = this._getUserQWKExportAreas().map(exportArea => { + return Object.assign(exportArea, { + newerThanTimestamp : getISOTimestampString(), + }); + }); + + return this.client.user.persistProperty( + UserProperties.ExportAreas, + JSON.stringify(updatedUserExportAreas), + callback + ); + }, + ], + err => { + this.client.startIdleMonitor(); // re-enable + this.client.removeListener('key press', keyPressHandler); + + if (!err) { + updateStatus('A QWK packet has been placed in your download queue'); + } else if (err.code === Errors.NothingToDo().code) { + updateStatus('No messages to export with current criteria'); + err = null; + } + + return cb(err); + } + ); + } +}; \ No newline at end of file diff --git a/core/message_base_search.js b/core/message_base_search.js new file mode 100644 index 00000000..859d2320 --- /dev/null +++ b/core/message_base_search.js @@ -0,0 +1,164 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const { + getSortedAvailMessageConferences, + getAvailableMessageAreasByConfTag, + getSortedAvailMessageAreasByConfTag, + hasMessageConfAndAreaRead, + filterMessageListByReadACS, +} = require('./message_area.js'); +const Errors = require('./enig_error.js').Errors; +const Message = require('./message.js'); + +// deps +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Message Base Search', + desc : 'Module for quickly searching the message base', + author : 'NuSkooler', +}; + +const MciViewIds = { + search : { + searchTerms : 1, + search : 2, + conf : 3, + area : 4, + to : 5, + from : 6, + advSearch : 7, + } +}; + +exports.getModule = class MessageBaseSearch extends MenuModule { + constructor(options) { + super(options); + + this.menuMethods = { + search : (formData, extraArgs, cb) => { + return this.searchNow(formData, cb); + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + this.prepViewController('search', 0, mciData.menu, (err, vc) => { + if(err) { + return cb(err); + } + + const confView = vc.getView(MciViewIds.search.conf); + const areaView = vc.getView(MciViewIds.search.area); + + if(!confView || !areaView) { + return cb(Errors.DoesNotExist('Missing one or more required views')); + } + + const availConfs = [ { text : '-ALL-', data : '' } ].concat( + getSortedAvailMessageConferences(this.client).map(conf => Object.assign(conf, { text : conf.conf.name, data : conf.confTag } )) || [] + ); + + let availAreas = [ { text : '-ALL-', data : '' } ]; // note: will populate if conf changes from ALL + + confView.setItems(availConfs); + areaView.setItems(availAreas); + + confView.setFocusItemIndex(0); + areaView.setFocusItemIndex(0); + + confView.on('index update', idx => { + availAreas = [ { text : '-ALL-', data : '' } ].concat( + getSortedAvailMessageAreasByConfTag(availConfs[idx].confTag, { client : this.client }).map( + area => Object.assign(area, { text : area.area.name, data : area.areaTag } ) + ) + ); + areaView.setItems(availAreas); + areaView.setFocusItemIndex(0); + }); + + vc.switchFocus(MciViewIds.search.searchTerms); + return cb(null); + }); + }); + } + + searchNow(formData, cb) { + const isAdvanced = formData.submitId === MciViewIds.search.advSearch; + const value = formData.value; + + const filter = { + resultType : 'messageList', + sort : 'modTimestamp', + terms : value.searchTerms, + //extraFields : [ 'area_tag', 'message_uuid', 'reply_to_message_id', 'to_user_name', 'from_user_name', 'subject', 'modified_timestamp' ], + 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; + + if(value.confTag && !value.areaTag) { + // areaTag may be a string or array of strings + // getAvailableMessageAreasByConfTag() returns a obj - we only need tags + filter.areaTag = _.map( + getAvailableMessageAreasByConfTag(value.confTag, { client : this.client } ), + (area, areaTag) => areaTag + ); + } else if(value.areaTag) { + if(hasMessageConfAndAreaRead(this.client, value.areaTag)) { + filter.areaTag = value.areaTag; // specific conf + area + } else { + return returnNoResults(); + } + } + } + + Message.findMessages(filter, (err, messageList) => { + if(err) { + 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 returnNoResults(); + } + + const menuOpts = { + extraArgs : { + messageList, + noUpdateLastReadId : true + }, + menuFlags : [ 'popParent' ], + }; + + return this.gotoMenu( + this.menuConfig.config.messageListMenu || 'messageAreaMessageList', + menuOpts, + cb + ); + }); + } +}; diff --git a/core/mime_util.js b/core/mime_util.js index bdb88a53..ddf44432 100644 --- a/core/mime_util.js +++ b/core/mime_util.js @@ -1,42 +1,42 @@ /* jslint node: true */ 'use strict'; -// deps -const _ = require('lodash'); +// deps +const _ = require('lodash'); -const mimeTypes = require('mime-types'); +const mimeTypes = require('mime-types'); -exports.startup = startup; -exports.resolveMimeType = resolveMimeType; +exports.startup = startup; +exports.resolveMimeType = resolveMimeType; function startup(cb) { - // - // Add in types (not yet) supported by mime-db -- and therefor, mime-types - // - const ADDITIONAL_EXT_MIMETYPES = { - arj : 'application/x-arj', - ans : 'text/x-ansi', - gz : 'application/gzip', // not in mime-types 2.1.15 :( - }; + // + // Add in types (not yet) supported by mime-db -- and therefor, mime-types + // + const ADDITIONAL_EXT_MIMETYPES = { + ans : 'text/x-ansi', + gz : 'application/gzip', // not in mime-types 2.1.15 :( + lzx : 'application/x-lzx', // :TODO: submit to mime-types + }; - _.forEach(ADDITIONAL_EXT_MIMETYPES, (mimeType, ext) => { - // don't override any entries - if(!_.isString(mimeTypes.types[ext])) { - mimeTypes[ext] = mimeType; - } + _.forEach(ADDITIONAL_EXT_MIMETYPES, (mimeType, ext) => { + // don't override any entries + if(!_.isString(mimeTypes.types[ext])) { + mimeTypes[ext] = mimeType; + } - if(!mimeTypes.extensions[mimeType]) { - mimeTypes.extensions[mimeType] = [ ext ]; - } - }); + if(!mimeTypes.extensions[mimeType]) { + mimeTypes.extensions[mimeType] = [ ext ]; + } + }); - return cb(null); + return cb(null); } function resolveMimeType(query) { - if(mimeTypes.extensions[query]) { - return query; // alreaed a mime-type - } - - return mimeTypes.lookup(query) || undefined; // lookup() returns false; we want undefined + if(mimeTypes.extensions[query]) { + return query; // already a mime-type + } + + return mimeTypes.lookup(query) || undefined; // lookup() returns false; we want undefined } \ No newline at end of file diff --git a/core/misc_scheduled_events.js b/core/misc_scheduled_events.js new file mode 100644 index 00000000..1650b697 --- /dev/null +++ b/core/misc_scheduled_events.js @@ -0,0 +1,18 @@ +/* jslint node: true */ +'use strict'; + +const StatLog = require('./stat_log.js'); +const SysProps = require('./system_property.js'); + +exports.dailyMaintenanceScheduledEvent = dailyMaintenanceScheduledEvent; + +function dailyMaintenanceScheduledEvent(args, cb) { + // + // Various stats need reset daily + // + [ SysProps.LoginsToday, SysProps.MessagesToday ].forEach(prop => { + StatLog.setNonPersistentSystemStat(prop, 0); + }); + + return cb(null); +} diff --git a/core/misc_util.js b/core/misc_util.js index afe33dee..78c76719 100644 --- a/core/misc_util.js +++ b/core/misc_util.js @@ -1,52 +1,61 @@ /* jslint node: true */ 'use strict'; -const paths = require('path'); +// deps +const paths = require('path'); +const os = require('os'); -const os = require('os'); -const packageJson = require('../package.json'); +const packageJson = require('../package.json'); -exports.isProduction = isProduction; -exports.isDevelopment = isDevelopment; -exports.valueWithDefault = valueWithDefault; -exports.resolvePath = resolvePath; -exports.getCleanEnigmaVersion = getCleanEnigmaVersion; -exports.getEnigmaUserAgent = getEnigmaUserAgent; +exports.isProduction = isProduction; +exports.isDevelopment = isDevelopment; +exports.valueWithDefault = valueWithDefault; +exports.resolvePath = resolvePath; +exports.getCleanEnigmaVersion = getCleanEnigmaVersion; +exports.getEnigmaUserAgent = getEnigmaUserAgent; +exports.valueAsArray = valueAsArray; function isProduction() { - var env = process.env.NODE_ENV || 'dev'; - return 'production' === env; + var env = process.env.NODE_ENV || 'dev'; + return 'production' === env; } function isDevelopment() { - return (!(isProduction())); + return (!(isProduction())); } function valueWithDefault(val, defVal) { - return (typeof val !== 'undefined' ? val : defVal); + return (typeof val !== 'undefined' ? val : defVal); } function resolvePath(path) { - if(path.substr(0, 2) === '~/') { - var mswCombined = process.env.HOMEDRIVE + process.env.HOMEPATH; - path = (process.env.HOME || mswCombined || process.env.HOMEPATH || process.env.HOMEDIR || process.cwd()) + path.substr(1); - } - return paths.resolve(path); + if(path.substr(0, 2) === '~/') { + var mswCombined = process.env.HOMEDRIVE + process.env.HOMEPATH; + path = (process.env.HOME || mswCombined || process.env.HOMEPATH || process.env.HOMEDIR || process.cwd()) + path.substr(1); + } + return paths.resolve(path); } function getCleanEnigmaVersion() { - return packageJson.version - .replace(/\-/g, '.') - .replace(/alpha/,'a') - .replace(/beta/,'b') - ; + return packageJson.version + .replace(/-/g, '.') + .replace(/alpha/,'a') + .replace(/beta/,'b') + ; } -// See also ftn_util.js getTearLine() & getProductIdentifier() +// See also ftn_util.js getTearLine() & getProductIdentifier() function getEnigmaUserAgent() { - // can't have 1/2 or ½ in User-Agent according to RFC 1945 :( - const version = getCleanEnigmaVersion(); - const nodeVer = process.version.substr(1); // remove 'v' prefix + // can't have 1/2 or ½ in User-Agent according to RFC 1945 :( + const version = getCleanEnigmaVersion(); + const nodeVer = process.version.substr(1); // remove 'v' prefix - return `ENiGMA-BBS/${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; -} \ No newline at end of file + return `ENiGMA-BBS/${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; +} + +function valueAsArray(value) { + if(!value) { + return []; + } + return Array.isArray(value) ? value : [ value ]; +} diff --git a/core/mod_mixins.js b/core/mod_mixins.js index 291e0cc9..22e49407 100644 --- a/core/mod_mixins.js +++ b/core/mod_mixins.js @@ -1,31 +1,36 @@ /* jslint node: true */ 'use strict'; -const messageArea = require('../core/message_area.js'); +const messageArea = require('../core/message_area.js'); +const UserProps = require('./user_property.js'); +// deps +const { get } = require('lodash'); exports.MessageAreaConfTempSwitcher = Sup => class extends Sup { - - tempMessageConfAndAreaSwitch(messageAreaTag) { - messageAreaTag = messageAreaTag || this.messageAreaTag; - if(!messageAreaTag) { - return; // nothing to do! - } - this.prevMessageConfAndArea = { - confTag : this.client.user.properties.message_conf_tag, - areaTag : this.client.user.properties.message_area_tag, - }; + tempMessageConfAndAreaSwitch(messageAreaTag, recordPrevious = true) { + messageAreaTag = messageAreaTag || get(this, 'config.messageAreaTag', this.messageAreaTag); + if(!messageAreaTag) { + return; // nothing to do! + } - if(!messageArea.tempChangeMessageConfAndArea(this.client, this.messageAreaTag)) { - this.client.log.warn( { messageAreaTag : messageArea }, 'Failed to perform temporary message area/conf switch'); - } - } + if(recordPrevious) { + this.prevMessageConfAndArea = { + confTag : this.client.user.properties[UserProps.MessageConfTag], + areaTag : this.client.user.properties[UserProps.MessageAreaTag], + }; + } - tempMessageConfAndAreaRestore() { - if(this.prevMessageConfAndArea) { - this.client.user.properties.message_conf_tag = this.prevMessageConfAndArea.confTag; - this.client.user.properties.message_area_tag = this.prevMessageConfAndArea.areaTag; - } - } + if(!messageArea.tempChangeMessageConfAndArea(this.client, messageAreaTag)) { + this.client.log.warn( { messageAreaTag : messageArea }, 'Failed to perform temporary message area/conf switch'); + } + } + + tempMessageConfAndAreaRestore() { + if(this.prevMessageConfAndArea) { + this.client.user.properties[UserProps.MessageConfTag] = this.prevMessageConfAndArea.confTag; + this.client.user.properties[UserProps.MessageAreaTag] = this.prevMessageConfAndArea.areaTag; + } + } }; diff --git a/core/module_util.js b/core/module_util.js index 67e87306..033d6094 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -1,109 +1,174 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; +// ENiGMA½ +const Config = require('./config.js').get; +const Log = require('./logger.js').log; +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); -// deps -const fs = require('graceful-fs'); -const paths = require('path'); -const _ = require('lodash'); -const assert = require('assert'); -const async = require('async'); +// deps +const fs = require('graceful-fs'); +const paths = require('path'); +const _ = require('lodash'); +const assert = require('assert'); +const async = require('async'); +const glob = require('glob'); -// exports -exports.loadModuleEx = loadModuleEx; -exports.loadModule = loadModule; -exports.loadModulesForCategory = loadModulesForCategory; -exports.getModulePaths = getModulePaths; +// exports +exports.loadModuleEx = loadModuleEx; +exports.loadModule = loadModule; +exports.loadModulesForCategory = loadModulesForCategory; +exports.getModulePaths = getModulePaths; +exports.initializeModules = initializeModules; function loadModuleEx(options, cb) { - assert(_.isObject(options)); - assert(_.isString(options.name)); - assert(_.isString(options.path)); + assert(_.isObject(options)); + assert(_.isString(options.name)); + assert(_.isString(options.path)); - const modConfig = _.isObject(Config[options.category]) ? Config[options.category][options.name] : null; + const modConfig = _.isObject(Config[options.category]) ? Config[options.category][options.name] : null; - if(_.isObject(modConfig) && false === modConfig.enabled) { - const err = new Error(`Module "${options.name}" is disabled`); - err.code = 'EENIGMODDISABLED'; - return cb(err); - } + if(_.isObject(modConfig) && false === modConfig.enabled) { + return cb(Errors.AccessDenied(`Module "${options.name}" is disabled`, ErrorReasons.Disabled)); + } - // - // Modules are allowed to live in /path/to//.js or - // simply in /path/to/.js. This allows for more advanced modules - // to have their own containing folder, package.json & dependencies, etc. - // - let mod; - let modPath = paths.join(options.path, `${options.name}.js`); // general case first - try { - mod = require(modPath); - } catch(e) { - if('MODULE_NOT_FOUND' === e.code) { - modPath = paths.join(options.path, options.name, `${options.name}.js`); - try { - mod = require(modPath); - } catch(e) { - return cb(e); - } - } else { - return cb(e); - } - } + // + // Modules are allowed to live in /path/to//.js or + // simply in /path/to/.js. This allows for more advanced modules + // to have their own containing folder, package.json & dependencies, etc. + // + let mod; + let modPath = paths.join(options.path, `${options.name}.js`); // general case first + try { + mod = require(modPath); + } catch(e) { + if('MODULE_NOT_FOUND' === e.code) { + modPath = paths.join(options.path, options.name, `${options.name}.js`); + try { + mod = require(modPath); + } catch(e) { + return cb(e); + } + } else { + return cb(e); + } + } - if(!_.isObject(mod.moduleInfo)) { - return cb(new Error('Module is missing "moduleInfo" section')); - } + if(!_.isObject(mod.moduleInfo)) { + return cb(Errors.Invalid(`No exported "moduleInfo" block for module ${modPath}!`)); + } - if(!_.isFunction(mod.getModule)) { - return cb(new Error('Invalid or missing "getModule" method for module!')); - } + if(!_.isFunction(mod.getModule)) { + return cb(Errors.Invalid(`No exported "getModule" method for module ${modPath}!`)); + } - return cb(null, mod); + return cb(null, mod); } function loadModule(name, category, cb) { - const path = Config.paths[category]; + const path = Config().paths[category]; - if(!_.isString(path)) { - return cb(new Error(`Not sure where to look for "${name}" of category "${category}"`)); - } + if(!_.isString(path)) { + return cb(Errors.DoesNotExist(`Not sure where to look for module "${name}" of category "${category}"`)); + } - loadModuleEx( { name : name, path : path, category : category }, function loaded(err, mod) { - return cb(err, mod); - }); + loadModuleEx( { name : name, path : path, category : category }, function loaded(err, mod) { + return cb(err, mod); + }); } function loadModulesForCategory(category, iterator, complete) { - fs.readdir(Config.paths[category], (err, files) => { - if(err) { - return iterator(err); - } + fs.readdir(Config().paths[category], (err, files) => { + if(err) { + return iterator(err); + } - const jsModules = files.filter(file => { - return '.js' === paths.extname(file); - }); + const jsModules = files.filter(file => { + return '.js' === paths.extname(file); + }); - async.each(jsModules, (file, next) => { - loadModule(paths.basename(file, '.js'), category, (err, mod) => { - iterator(err, mod); - return next(); - }); - }, err => { - if(complete) { - return complete(err); - } - }); - }); + async.each(jsModules, (file, next) => { + loadModule(paths.basename(file, '.js'), category, (err, mod) => { + if(err) { + if(ErrorReasons.Disabled === err.reasonCode) { + Log.debug(err.message); + } else { + Log.info( { err : err }, 'Failed loading module'); + } + return next(null); // continue no matter what + } + return iterator(mod, next); + }); + }, err => { + if(complete) { + return complete(err); + } + }); + }); } function getModulePaths() { - return [ - Config.paths.mods, - Config.paths.loginServers, - Config.paths.contentServers, - Config.paths.scannerTossers, - ]; + const config = Config(); + return [ + config.paths.mods, + config.paths.loginServers, + config.paths.contentServers, + config.paths.chatServers, + config.paths.scannerTossers, + ]; +} + +function initializeModules(cb) { + const Events = require('./events.js'); + + const modulePaths = getModulePaths().concat(__dirname); + + async.each(modulePaths, (modulePath, nextPath) => { + glob('*{.js,/*.js}', { cwd : modulePath }, (err, files) => { + if(err) { + return nextPath(err); + } + + const ourPath = paths.join(__dirname, __filename); + + async.each(files, (moduleName, nextModule) => { + const fullModulePath = paths.join(modulePath, moduleName); + if(ourPath === fullModulePath) { + return nextModule(null); + } + + try { + const mod = require(fullModulePath); + + if(_.isFunction(mod.moduleInitialize)) { + const initInfo = { + events : Events, + }; + + mod.moduleInitialize(initInfo, err => { + if(err) { + Log.warn( { error : err.message, modulePath : fullModulePath }, 'Error during "moduleInitialize"'); + } + return nextModule(null); + }); + } else { + return nextModule(null); + } + } catch(e) { + Log.warn( { error : e.message, fullModulePath }, 'Exception during "moduleInitialize"'); + return nextModule(null); + } + }, + err => { + return nextPath(err); + }); + }); + }, + err => { + return cb(err); + }); } diff --git a/core/mrc.js b/core/mrc.js new file mode 100644 index 00000000..754e2728 --- /dev/null +++ b/core/mrc.js @@ -0,0 +1,575 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Log = require('./logger.js').log; +const { MenuModule } = require('./menu_module.js'); +const { + pipeToAnsi, + stripMciColorCodes +} = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); +const StringUtil = require('./string_util.js'); +const Config = require('./config.js').get; + +// deps +const _ = require('lodash'); +const async = require('async'); +const net = require('net'); +const moment = require('moment'); + +exports.moduleInfo = { + name : 'MRC Client', + desc : 'Connects to an MRC chat server', + author : 'RiPuk', + packageName : 'codes.l33t.enigma.mrc.client', + + // Whilst this module was put together by me (RiPuk), it should be noted that a lot of the ideas (and even some code snippets) were + // borrowed from the Synchronet implementation of MRC by echicken. So...thanks, your code was very helpful in putting this together. + // Source at http://cvs.synchro.net/cgi-bin/viewcvs.cgi/xtrn/mrc/. +}; + +const FormIds = { + mrcChat : 0, +}; + +const MciViewIds = { + mrcChat : { + chatLog : 1, + inputArea : 2, + roomName : 3, + roomTopic : 4, + mrcUsers : 5, + mrcBbses : 6, + + customRangeStart : 20, // 20+ = customs + } +}; + + + +// TODO: this is a bit shit, could maybe do it with an ansi instead +const helpText = ` +|15General Chat|08: +|03/|11rooms |08& |03/|11join |03 |08- |07List all or join a room +|03/|11pm |03 |08- |07Send a private message +---- +|03/|11whoon |08- |07Who's on what BBS +|03/|11chatters |08- |07Who's in what room +|03/|11clear |08- |07Clear back buffer +|03/|11topic |03 |08- |07Set the room topic +|03/|11bbses |08& |03/|11info |08- |07Info about BBS's connected +|03/|11meetups |08- |07Info about MRC MeetUps +--- +|03/|11l33t |03 |08- |07l337 5p34k +|03/|11kewl |03 |08- |07BBS KeWL SPeaK +|03/|11rainbow |03 |08- |07Crazy rainbow text +`; + + +exports.getModule = class mrcModule extends MenuModule { + constructor(options) { + super(options); + + this.log = Log.child( { module : 'MRC' } ); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.config.maxScrollbackLines = this.config.maxScrollbackLines || 500; + + this.state = { + socket: '', + alias: this.client.user.username, + room: '', + room_topic: '', + nicks: [], + lastSentMsg : {}, // used for latency est. + }; + + this.customFormatObj = { + roomName : '', + roomTopic : '', + roomUserCount : 0, + userCount : 0, + boardCount : 0, + roomCount : 0, + latencyMs : 0, + activityLevel : 0, + activityLevelIndicator : ' ', + }; + + this.menuMethods = { + + sendChatMessage : (formData, extraArgs, cb) => { + + const inputAreaView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.inputArea); + const inputData = inputAreaView.getData(); + + this.processOutgoingMessage(inputData); + inputAreaView.clearText(); + + return cb(null); + }, + + movementKeyPressed : (formData, extraArgs, cb) => { + const bodyView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + switch(formData.key.name) { + case 'down arrow' : bodyView.scrollDocumentUp(); break; + case 'up arrow' : bodyView.scrollDocumentDown(); break; + case 'page up' : bodyView.keyPressPageUp(); break; + case 'page down' : bodyView.keyPressPageDown(); break; + } + + this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea); + + return cb(null); + }, + + quit : (formData, extraArgs, cb) => { + return this.prevMenu(cb); + }, + + clearMessages : (formData, extraArgs, cb) => { + this.clearMessages(); + return cb(null); + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + return this.prepViewController('mrcChat', FormIds.mrcChat, mciData.menu, callback); + }, + (callback) => { + return this.validateMCIByViewIds('mrcChat', [ MciViewIds.mrcChat.chatLog, MciViewIds.mrcChat.inputArea ], callback); + }, + (callback) => { + const connectOpts = { + port : _.get(Config(), 'chatServers.mrc.multiplexerPort', 5000), + host : 'localhost', + }; + + // connect to multiplexer + this.state.socket = net.createConnection(connectOpts, () => { + this.client.once('end', () => { + this.quitServer(); + }); + + // handshake with multiplexer + this.state.socket.write(`--DUDE-ITS--|${this.state.alias}\n`); + + this.clientConnect(); + + // send register to central MRC and get stats every 60s + this.heartbeat = setInterval( () => { + this.sendHeartbeat(); + this.sendServerMessage('STATS'); + }, 60000); + + // override idle logout seconds if configured + const idleLogoutSeconds = parseInt(this.config.idleLogoutSeconds); + if(0 === idleLogoutSeconds) { + this.log.debug('Temporary disable idle monitor due to config'); + this.client.stopIdleMonitor(); + } else if (!isNaN(idleLogoutSeconds) && idleLogoutSeconds >= 60) { + this.log.debug( { idleLogoutSeconds }, 'Temporary override idle logout seconds due to config'); + this.client.overrideIdleLogoutSeconds(idleLogoutSeconds); + } + }); + + // when we get data, process it + this.state.socket.on('data', data => { + data = data.toString(); + this.processReceivedMessage(data); + }); + + this.state.socket.once('error', err => { + this.log.warn( { error : err.message }, 'MRC multiplexer socket error' ); + this.state.socket.destroy(); + delete this.state.socket; + + // bail with error - fall back to prev menu + return callback(err); + }); + + return(callback); + } + ], + err => { + return cb(err); + } + ); + }); + } + + leave() { + this.quitServer(); + + // restore idle monitor to previous state + this.log.debug('Restoring idle monitor to previous state'); + this.client.restoreIdleLogoutSeconds(); + this.client.startIdleMonitor(); + + return super.leave(); + } + + quitServer() { + clearInterval(this.heartbeat); + + if(this.state.socket) { + this.sendServerMessage('LOGOFF'); + this.state.socket.destroy(); + delete this.state.socket; + } + } + + /** + * Adds a message to the chat log on screen + */ + addMessageToChatLog(message) { + if(!Array.isArray(message)) { + message = [ message ]; + } + + message.forEach(msg => { + const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + const messageLength = stripMciColorCodes(msg).length; + const chatWidth = chatLogView.dimens.width; + let padAmount = 0; + let spaces = 2; + + if (messageLength > chatWidth) { + padAmount = chatWidth - (messageLength % chatWidth) - spaces; + } else { + padAmount = chatWidth - messageLength - spaces; + } + + if (padAmount < 0) padAmount = 0; + + const padding = ' |00' + ' '.repeat(padAmount); + chatLogView.addText(pipeToAnsi(msg + padding)); + + if(chatLogView.getLineCount() > this.config.maxScrollbackLines) { + chatLogView.deleteLine(0); + } + }); + } + + /** + * Processes data received from the MRC multiplexer + */ + processReceivedMessage(blob) { + blob.split('\n').forEach( message => { + + try { + message = JSON.parse(message); + } catch (e) { + return; + } + + if (message.from_user == 'SERVER') { + const params = message.body.split(':'); + + switch (params[0]) { + case 'BANNER': + this.addMessageToChatLog(params[1].replace(/^\s+/, '')); + break; + + case 'ROOMTOPIC': + this.setText(MciViewIds.mrcChat.roomName, `#${params[1]}`); + this.setText(MciViewIds.mrcChat.roomTopic, params[2]); + + this.customFormatObj.roomName = params[1]; + this.customFormatObj.roomTopic = params[2]; + this.updateCustomViews(); + + this.state.room = params[1]; + break; + + case 'USERLIST': + this.state.nicks = params[1].split(','); + + this.customFormatObj.roomUserCount = this.state.nicks.length; + this.updateCustomViews(); + break; + + case 'STATS': { + const [ + boardCount, + roomCount, + userCount, + activityLevel + ] = params[1].split(' ').map(v => parseInt(v)); + + const activityLevelIndicator = this.getActivityLevelIndicator(activityLevel); + + Object.assign( + this.customFormatObj, + { + boardCount, roomCount, userCount, + activityLevel, activityLevelIndicator + } + ); + + this.setText(MciViewIds.mrcChat.mrcUsers, userCount); + this.setText(MciViewIds.mrcChat.mrcBbses, boardCount); + + this.updateCustomViews(); + break; + } + + default: + this.addMessageToChatLog(message.body); + break; + } + + } else { + if(message.body === this.state.lastSentMsg.msg) { + this.customFormatObj.latencyMs = + moment.duration(moment().diff(this.state.lastSentMsg.time)).asMilliseconds(); + delete this.state.lastSentMsg.msg; + } + + if (message.to_room == this.state.room) { + // if we're here then we want to show it to the user + const currentTime = moment().format(this.client.currentTheme.helpers.getTimeFormat()); + this.addMessageToChatLog('|08' + currentTime + '|00 ' + message.body + '|00'); + } + } + + this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea); + }); + } + + getActivityLevelIndicator(level) { + let indicators = this.config.activityLevelIndicators; + if(!Array.isArray(indicators) || indicators.length < level + 1) { + indicators = [ ' ', '░', '▒', '▓' ]; + } + return indicators[level]; + } + + setText(mciId, text) { + return this.setViewText('mrcChat', mciId, text); + } + + updateCustomViews() { + return this.updateCustomViewTextsWithFilter( + 'mrcChat', + MciViewIds.mrcChat.customRangeStart, + this.customFormatObj + ); + } + + /** + * Receives the message input from the user and does something with it based on what it is + */ + processOutgoingMessage(message, to_user) { + if (message.startsWith('/')) { + this.processSlashCommand(message); + } else { + if (message == '') { + // don't do anything if message is blank, just update stats + this.sendServerMessage('STATS'); + return; + } + + // else just format and send + const textFormatObj = { + fromUserName : this.state.alias, + toUserName : to_user, + message : message + }; + + const messageFormat = + this.config.messageFormat || + '|00|10<|02{fromUserName}|10>|00 |03{message}|00'; + + const privateMessageFormat = + this.config.outgoingPrivateMessageFormat || + '|00|10<|02{fromUserName}|10|14->|02{toUserName}>|00 |03{message}|00'; + + let formattedMessage = ''; + if (to_user == undefined) { + // normal message + formattedMessage = stringFormat(messageFormat, textFormatObj); + } else { + // pm + formattedMessage = stringFormat(privateMessageFormat, textFormatObj); + } + + try { + this.state.lastSentMsg = { + msg : formattedMessage, + time : moment(), + }; + this.sendMessageToMultiplexer(to_user || '', '', this.state.room, formattedMessage); + } catch(e) { + this.client.log.warn( { error : e.message }, 'MRC error'); + } + } + + } + + /** + * Processes a message that begins with a slash + */ + processSlashCommand(message) { + const cmd = message.split(' '); + cmd[0] = cmd[0].substr(1).toLowerCase(); + + switch (cmd[0]) { + case 'pm': + this.processOutgoingMessage(cmd[2], cmd[1]); + break; + + case 'rainbow': { + // this is brutal, but i love it + const line = message.replace(/^\/rainbow\s/, '').split(' ').reduce(function (a, c) { + const cc = Math.floor((Math.random() * 31) + 1).toString().padStart(2, '0'); + a += `|${cc}${c}|00 `; + return a; + }, '').substr(0, 140).replace(/\\s\|\d*$/, ''); + + this.processOutgoingMessage(line); + break; + } + + case 'l33t': + this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), 'l33t')); + break; + + case 'kewl': { + const text_modes = Array('f','v','V','i','M'); + const mode = text_modes[Math.floor(Math.random() * text_modes.length)]; + this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), mode)); + break; + } + + case 'whoon': + this.sendServerMessage('WHOON'); + break; + + case 'motd': + this.sendServerMessage('MOTD'); + break; + + case 'meetups': + this.sendServerMessage('MEETUPS'); + break; + + case 'bbses': + this.sendServerMessage('CONNECTED'); + break; + + case 'topic': + this.sendServerMessage(`NEWTOPIC:${this.state.room}:${message.substr(7)}`); + break; + + case 'info': + this.sendServerMessage(`INFO ${cmd[1]}`); + break; + + case 'join': + this.joinRoom(cmd[1]); + break; + + case 'chatters': + this.sendServerMessage('CHATTERS'); + break; + + case 'rooms': + this.sendServerMessage('LIST'); + break; + + case 'quit' : + return this.prevMenu(); + + case 'clear': + this.clearMessages(); + break; + + case '?': + this.addMessageToChatLog(helpText.split(/\n/g)); + break; + + default: + + break; + } + + // just do something to get the cursor back to the right place ¯\_(ツ)_/¯ + // :TODO: fix me! + this.sendServerMessage('STATS'); + } + + clearMessages() { + const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + chatLogView.setText(''); + } + + /** + * Creates a json object, stringifies it and sends it to the MRC multiplexer + */ + sendMessageToMultiplexer(to_user, to_site, to_room, body) { + + const message = { + to_user, + to_site, + to_room, + body, + from_user : this.state.alias, + from_room : this.state.room, + }; + + if(this.state.socket) { + this.state.socket.write(JSON.stringify(message) + '\n'); + } + } + + /** + * Sends an MRC 'server' message + */ + sendServerMessage(command, to_site) { + Log.debug({ module: 'mrc', command: command }, 'Sending server command'); + this.sendMessageToMultiplexer('SERVER', to_site || '', this.state.room, command); + } + + /** + * Sends a heartbeat to the MRC server + */ + sendHeartbeat() { + this.sendServerMessage('IAMHERE'); + } + + /** + * Joins a room, unsurprisingly + */ + joinRoom(room) { + // room names are displayed with a # but referred to without. confusing. + room = room.replace(/^#/, ''); + this.state.room = room; + this.sendServerMessage(`NEWROOM:${this.state.room}:${room}`); + this.sendServerMessage('USERLIST'); + } + + /** + * Things that happen when a local user connects to the MRC multiplexer + */ + clientConnect() { + this.sendServerMessage('MOTD'); + this.joinRoom('lobby'); + this.sendServerMessage('STATS'); + this.sendHeartbeat(); + } +}; + + + + diff --git a/core/msg_area_list.js b/core/msg_area_list.js new file mode 100644 index 00000000..1d47f76c --- /dev/null +++ b/core/msg_area_list.js @@ -0,0 +1,127 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const messageArea = require('./message_area.js'); +const { Errors } = require('./enig_error.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Message Area List', + desc : 'Module for listing / choosing message areas', + author : 'NuSkooler', +}; + +// :TODO: Obv/2 others can show # of messages in area + +const MciViewIds = { + areaList : 1, + areaDesc : 2, // area desc updated @ index update + customRangeStart : 10, // updated @ index update +}; + +exports.getModule = class MessageAreaListModule extends MenuModule { + constructor(options) { + super(options); + + this.initList(); + + this.menuMethods = { + changeArea : (formData, extraArgs, cb) => { + if(1 === formData.submitId) { + const area = this.messageAreas[formData.value.area]; + + messageArea.changeMessageArea(this.client, area.areaTag, err => { + if(err) { + this.client.term.pipeWrite(`\n|00Cannot change area: ${err.message}\n`); + return this.prevMenuOnTimeout(1000, cb); + } + + if(area.hasArt) { + const menuOpts = { + extraArgs : { + areaTag : area.areaTag, + }, + menuFlags : [ 'popParent', 'noHistory' ] + }; + + return this.gotoMenu(this.menuConfig.config.changeAreaPreArtMenu || 'changeMessageAreaPreArt', menuOpts, cb); + } + + return this.prevMenu(cb); + }); + } else { + return cb(null); + } + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (next) => { + return this.prepViewController('areaList', 0, mciData.menu, next); + }, + (next) => { + const areaListView = this.viewControllers.areaList.getView(MciViewIds.areaList); + if(!areaListView) { + return cb(Errors.MissingMci(`Missing area list MCI ${MciViewIds.areaList}`)); + } + + areaListView.on('index update', idx => { + this.selectionIndexUpdate(idx); + }); + + areaListView.setItems(this.messageAreas); + areaListView.redraw(); + this.selectionIndexUpdate(0); + return next(null); + } + ], + err => { + if(err) { + this.client.log.error( { error : err.message }, 'Failed loading message area list'); + } + return cb(err); + } + ); + }); + } + + selectionIndexUpdate(idx) { + const area = this.messageAreas[idx]; + if(!area) { + return; + } + this.setViewText('areaList', MciViewIds.areaDesc, area.desc); + this.updateCustomViewTextsWithFilter('areaList', MciViewIds.customRangeStart, area); + } + + initList() { + let index = 1; + this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag( + this.client.user.properties[UserProps.MessageConfTag], + { client : this.client } + ).map(area => { + return { + index : index++, + areaTag : area.areaTag, + name : area.area.name, + text : area.area.name, // standard + desc : area.area.desc, + hasArt : _.isString(area.area.art), + }; + }); + } +}; diff --git a/core/msg_area_post_fse.js b/core/msg_area_post_fse.js new file mode 100644 index 00000000..0c92bbfb --- /dev/null +++ b/core/msg_area_post_fse.js @@ -0,0 +1,86 @@ +/* jslint node: true */ +'use strict'; + +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'); + +exports.moduleInfo = { + name : 'Message Area Post', + desc : 'Module for posting a new message to an area', + author : 'NuSkooler', +}; + +exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule { + constructor(options) { + super(options); + + const self = this; + + // we're posting, so always start with 'edit' mode + this.editorMode = 'edit'; + + this.menuMethods.editModeMenuSave = function(formData, extraArgs, cb) { + + var msg; + async.series( + [ + function getMessageObject(callback) { + self.getMessage(function gotMsg(err, msgObj) { + msg = msgObj; + return callback(err); + }); + }, + function saveMessage(callback) { + return persistMessage(msg, callback); + }, + function updateStats(callback) { + self.updateUserAndSystemStats(callback); + } + ], + function complete(err) { + if(err) { + // :TODO:... sooooo now what? + } else { + // note: not logging 'from' here as it's part of client.log.xxxx() + self.client.log.info( + { to : msg.toUserName, subject : msg.subject, uuid : msg.messageUuid }, + 'Message persisted' + ); + } + + return self.nextMenu(cb); + } + ); + }; + } + + enter() { + this.messageAreaTag = + this.messageAreaTag || + this.client.user.getProperty(UserProps.MessageAreaTag); + + super.enter(); + } + + initSequence() { + if(!hasMessageConfAndAreaWrite(this.client, this.messageAreaTag)) { + const noAcsMenu = + this.menuConfig.config.messageBasePostMessageNoAccess || + 'messageBasePostMessageNoAccess'; + + return this.gotoMenuOrShowMessage( + noAcsMenu, + 'You do not have the proper access to post here!', + ); + } + + super.initSequence(); + } +}; \ No newline at end of file diff --git a/core/msg_area_reply_fse.js b/core/msg_area_reply_fse.js new file mode 100644 index 00000000..11742865 --- /dev/null +++ b/core/msg_area_reply_fse.js @@ -0,0 +1,18 @@ +/* jslint node: true */ +'use strict'; + +var FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; + +exports.getModule = AreaReplyFSEModule; + +exports.moduleInfo = { + name : 'Message Area Reply', + desc : 'Module for replying to an area message', + author : 'NuSkooler', +}; + +function AreaReplyFSEModule(options) { + FullScreenEditorModule.call(this, options); +} + +require('util').inherits(AreaReplyFSEModule, FullScreenEditorModule); diff --git a/core/msg_area_view_fse.js b/core/msg_area_view_fse.js new file mode 100644 index 00000000..1ca5617c --- /dev/null +++ b/core/msg_area_view_fse.js @@ -0,0 +1,145 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; +const Message = require('./message.js'); + +// deps +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Message Area View', + desc : 'Module for viewing an area message', + author : 'NuSkooler', +}; + +exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule { + constructor(options) { + super(options); + + this.editorType = 'area'; + this.editorMode = 'view'; + + if(_.isObject(options.extraArgs)) { + this.messageList = options.extraArgs.messageList; + this.messageIndex = options.extraArgs.messageIndex; + this.lastMessageNextExit = options.extraArgs.lastMessageNextExit; + } + + this.messageList = this.messageList || []; + this.messageIndex = this.messageIndex || 0; + this.messageTotal = this.messageList.length; + + if(this.messageList.length > 0) { + this.messageAreaTag = this.messageList[this.messageIndex].areaTag; + } + + const self = this; + + // assign *additional* menuMethods + Object.assign(this.menuMethods, { + nextMessage : (formData, extraArgs, cb) => { + if(self.messageIndex + 1 < self.messageList.length) { + self.messageIndex++; + + this.messageAreaTag = this.messageList[this.messageIndex].areaTag; + this.tempMessageConfAndAreaSwitch(this.messageAreaTag, false); // false=don't record prev; we want what we entered the module with + + return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb); + } + + // auto-exit if no more to go? + if(self.lastMessageNextExit) { + self.lastMessageReached = true; + return self.prevMenu(cb); + } + + return cb(null); + }, + + prevMessage : (formData, extraArgs, cb) => { + if(self.messageIndex > 0) { + self.messageIndex--; + + this.messageAreaTag = this.messageList[this.messageIndex].areaTag; + this.tempMessageConfAndAreaSwitch(this.messageAreaTag, false); // false=don't record prev; we want what we entered the module with + + return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb); + } + + return cb(null); + }, + + movementKeyPressed : (formData, extraArgs, cb) => { + const bodyView = self.viewControllers.body.getView(1); // :TODO: use const here vs magic # + + // :TODO: Create methods for up/down vs using keyPressXXXXX + 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; + } + + // :TODO: need to stop down/page down if doing so would push the last + // visible page off the screen at all .... this should be handled by MLTEV though... + + return cb(null); + }, + + replyMessage : (formData, extraArgs, cb) => { + if(_.isString(extraArgs.menu)) { + const modOpts = { + extraArgs : { + messageAreaTag : self.messageAreaTag, + replyToMessage : self.message, + } + }; + + return self.gotoMenu(extraArgs.menu, modOpts, cb); + } + + self.client.log(extraArgs, 'Missing extraArgs.menu'); + return cb(null); + } + }); + } + + + loadMessageByUuid(uuid, cb) { + const msg = new Message(); + msg.load( { uuid : uuid, user : this.client.user }, () => { + this.setMessage(msg); + + if(cb) { + return cb(null); + } + }); + } + + finishedLoading() { + this.loadMessageByUuid(this.messageList[this.messageIndex].messageUuid); + } + + getSaveState() { + return { + messageList : this.messageList, + messageIndex : this.messageIndex, + messageTotal : this.messageList.length, + }; + } + + restoreSavedState(savedState) { + this.messageList = savedState.messageList; + this.messageIndex = savedState.messageIndex; + this.messageTotal = savedState.messageTotal; + } + + getMenuResult() { + return { + messageIndex : this.messageIndex, + lastMessageReached : this.lastMessageReached, + }; + } +}; diff --git a/core/msg_conf_list.js b/core/msg_conf_list.js new file mode 100644 index 00000000..7ba75376 --- /dev/null +++ b/core/msg_conf_list.js @@ -0,0 +1,122 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const messageArea = require('./message_area.js'); +const { Errors } = require('./enig_error.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Message Conference List', + desc : 'Module for listing / choosing message conferences', + author : 'NuSkooler', +}; + +const MciViewIds = { + confList : 1, + confDesc : 2, // description updated @ index update + customRangeStart : 10, // updated @ index update +}; + +exports.getModule = class MessageConfListModule extends MenuModule { + constructor(options) { + super(options); + + this.initList(); + + this.menuMethods = { + changeConference : (formData, extraArgs, cb) => { + if(1 === formData.submitId) { + const conf = this.messageConfs[formData.value.conf]; + + messageArea.changeMessageConference(this.client, conf.confTag, err => { + if(err) { + this.client.term.pipeWrite(`\n|00Cannot change conference: ${err.message}\n`); + return this.prevMenuOnTimeout(1000, cb); + } + + if(conf.hasArt) { + const menuOpts = { + extraArgs : { + confTag : conf.confTag, + }, + menuFlags : [ 'popParent', 'noHistory' ] + }; + + return this.gotoMenu(this.menuConfig.config.changeConfPreArtMenu || 'changeMessageConfPreArt', menuOpts, cb); + } + + return this.prevMenu(cb); + }); + } else { + return cb(null); + } + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (next) => { + return this.prepViewController('confList', 0, mciData.menu, next); + }, + (next) => { + const confListView = this.viewControllers.confList.getView(MciViewIds.confList); + if(!confListView) { + return next(Errors.MissingMci(`Missing conf list MCI ${MciViewIds.confList}`)); + } + + confListView.on('index update', idx => { + this.selectionIndexUpdate(idx); + }); + + confListView.setItems(this.messageConfs); + confListView.redraw(); + this.selectionIndexUpdate(0); + return next(null); + } + ], + err => { + if(err) { + this.client.log.error( { error : err.message }, 'Failed loading message conference list'); + } + } + ); + }); + } + + selectionIndexUpdate(idx) { + const conf = this.messageConfs[idx]; + if(!conf) { + return; + } + this.setViewText('confList', MciViewIds.confDesc, conf.desc); + this.updateCustomViewTextsWithFilter('confList', MciViewIds.customRangeStart, conf); + } + + initList() + { + let index = 1; + this.messageConfs = messageArea.getSortedAvailMessageConferences(this.client).map(conf => { + return { + index : index++, + confTag : conf.confTag, + name : conf.conf.name, + text : conf.conf.name, + desc : conf.conf.desc, + areaCount : Object.keys(conf.conf.areas || {}).length, + hasArt : _.isString(conf.conf.art), + }; + }); + } +}; diff --git a/core/msg_list.js b/core/msg_list.js new file mode 100644 index 00000000..380fc5e4 --- /dev/null +++ b/core/msg_list.js @@ -0,0 +1,418 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const messageArea = require('./message_area.js'); +const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher; +const Errors = require('./enig_error.js').Errors; +const Message = require('./message.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); + +/* + Available itemFormat/focusItemFormat members for |msgList| + + msgNum : Message number + to : To username/handle + from : From username/handle + subj : Subject + ts : Message mod timestamp (format with config.dateTimeFormat) + newIndicator : New mark/indicator (config.newIndicator) +*/ +exports.moduleInfo = { + name : 'Message List', + desc : 'Module for listing/browsing available messages', + author : 'NuSkooler', +}; + +const FormIds = { + allViews : 0, + delPrompt : 1, +}; + +const MciViewIds = { + allViews : { + msgList : 1, // VM1 - see above + delPromptXy : 2, // %XY2, e.g: delete confirmation + customRangeStart : 10, // Everything |msgList| has plus { msgNumSelected, msgNumTotal } + }, + + delPrompt: { + prompt : 1, + } +}; + +exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(MenuModule) { + constructor(options) { + super(options); + + // :TODO: consider this pattern in base MenuModule - clean up code all over + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), options.extraArgs); + + this.lastMessageReachedExit = _.get(options, 'lastMenuResult.lastMessageReached', false); + + this.menuMethods = { + selectMessage : (formData, extraArgs, cb) => { + if(MciViewIds.allViews.msgList === formData.submitId) { + this.initialFocusIndex = formData.value.message; + + const modOpts = { + extraArgs : { + messageAreaTag : this.getSelectedAreaTag(formData.value.message), + messageList : this.config.messageList, + messageIndex : formData.value.message, + lastMessageNextExit : true, + } + }; + + if(_.isBoolean(this.config.noUpdateLastReadId)) { + modOpts.extraArgs.noUpdateLastReadId = this.config.noUpdateLastReadId; + } + + // + // Provide a serializer so we don't dump *huge* bits of information to the log + // due to the size of |messageList|. See https://github.com/trentm/node-bunyan/issues/189 + // + const self = this; + modOpts.extraArgs.toJSON = function() { + const logMsgList = (self.config.messageList.length <= 4) ? + self.config.messageList : + self.config.messageList.slice(0, 2).concat(self.config.messageList.slice(-2)); + + return { + // note |this| is scope of toJSON()! + messageAreaTag : this.messageAreaTag, + apprevMessageList : logMsgList, + messageCount : this.messageList.length, + messageIndex : this.messageIndex, + }; + }; + + return this.gotoMenu(this.config.menuViewPost || 'messageAreaViewPost', modOpts, cb); + } else { + return cb(null); + } + }, + fullExit : (formData, extraArgs, cb) => { + this.menuResult = { fullExit : true }; + return this.prevMenu(cb); + }, + deleteSelected : (formData, extraArgs, cb) => { + if(MciViewIds.allViews.msgList != formData.submitId) { + return cb(null); + } + const messageIndex = _.get(formData, 'value.message'); + return this.promptDeleteMessageConfirm(messageIndex, cb); + }, + deleteMessageYes : (formData, extraArgs, cb) => { + const msgListView = this.viewControllers.allViews.getView(MciViewIds.allViews.msgList); + this.enableMessageListIndexUpdates(msgListView); + if(this.selectedMessageForDelete) { + this.selectedMessageForDelete.deleteMessage(this.client.user, err => { + if(err) { + this.client.log.error(`Failed to delete message: ${this.selectedMessageForDelete.messageUuid}`); + } else { + this.client.log.info(`User deleted message: ${this.selectedMessageForDelete.messageUuid}`); + this.config.messageList.splice(msgListView.focusedItemIndex, 1); + this.updateMessageNumbersAfterDelete(msgListView.focusedItemIndex); + msgListView.setItems(this.config.messageList); + } + this.selectedMessageForDelete = null; + msgListView.redraw(); + this.populateCustomLabelsForSelected(msgListView.focusedItemIndex); + return cb(null); + }); + } else { + return cb(null); + } + }, + deleteMessageNo : (formData, extraArgs, cb) => { + const msgListView = this.viewControllers.allViews.getView(MciViewIds.allViews.msgList); + this.enableMessageListIndexUpdates(msgListView); + return cb(null); + }, + markAllRead : (formData, extraArgs, cb) => { + if(this.config.noUpdateLastReadId) { + return cb(null); + } + + return this.markAllMessagesAsRead(cb); + } + }; + } + + getSelectedAreaTag(listIndex) { + return this.config.messageList[listIndex].areaTag || this.config.messageAreaTag; + } + + enter() { + if(this.lastMessageReachedExit) { + return this.prevMenu(); + } + + super.enter(); + + // + // Config can specify |messageAreaTag| else it comes from + // the user's current area. If |messageList| is supplied, + // each item is expected to contain |areaTag|, so we use that + // instead in those cases. + // + if(!Array.isArray(this.config.messageList)) { + if(this.config.messageAreaTag) { + this.tempMessageConfAndAreaSwitch(this.config.messageAreaTag); + } else { + this.config.messageAreaTag = this.client.user.properties[UserProps.MessageAreaTag]; + } + } + } + + leave() { + this.tempMessageConfAndAreaRestore(); + super.leave(); + } + + populateCustomLabelsForSelected(selectedIndex) { + const formatObj = Object.assign( + { + msgNumSelected : (selectedIndex + 1), + msgNumTotal : this.config.messageList.length, + }, + this.config.messageList[selectedIndex] // plus, all the selected message props + ); + return this.updateCustomViewTextsWithFilter('allViews', MciViewIds.allViews.customRangeStart, formatObj); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); + let configProvidedMessageList = false; + + async.series( + [ + function loadFromConfig(callback) { + const loadOpts = { + callingMenu : self, + mciMap : mciData.menu + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + }, + function fetchMessagesInArea(callback) { + // + // Config can supply messages else we'll need to populate the list now + // + if(_.isArray(self.config.messageList)) { + configProvidedMessageList = true; + return callback(0 === self.config.messageList.length ? new Error('No messages in area') : null); + } + + messageArea.getMessageListForArea(self.client, self.config.messageAreaTag, function msgs(err, msgList) { + if(!msgList || 0 === msgList.length) { + return callback(new Error('No messages in area')); + } + + self.config.messageList = msgList; + return callback(err); + }); + }, + function getLastReadMessageId(callback) { + // messageList entries can contain |isNew| if they want to be considered new + if(configProvidedMessageList) { + self.lastReadId = 0; + return callback(null); + } + + messageArea.getMessageAreaLastReadId(self.client.user.userId, self.config.messageAreaTag, function lastRead(err, lastReadId) { + self.lastReadId = lastReadId || 0; + return callback(null); // ignore any errors, e.g. missing value + }); + }, + function updateMessageListObjects(callback) { + const dateTimeFormat = self.menuConfig.config.dateTimeFormat || self.client.currentTheme.helpers.getDateTimeFormat(); + const newIndicator = self.menuConfig.config.newIndicator || '*'; + const regIndicator = ' '.repeat(newIndicator.length); // fill with space to avoid draw issues + + let msgNum = 1; + self.config.messageList.forEach( (listItem, index) => { + listItem.msgNum = msgNum++; + listItem.ts = moment(listItem.modTimestamp).format(dateTimeFormat); + const isNew = _.isBoolean(listItem.isNew) ? listItem.isNew : listItem.messageId > self.lastReadId; + listItem.newIndicator = isNew ? newIndicator : regIndicator; + + if(_.isUndefined(self.initialFocusIndex) && listItem.messageId > self.lastReadId) { + self.initialFocusIndex = index; + } + + listItem.text = `${listItem.msgNum} - ${listItem.subject} from ${listItem.fromUserName}`; // default text + }); + return callback(null); + }, + function populateAndDrawViews(callback) { + const msgListView = vc.getView(MciViewIds.allViews.msgList); + msgListView.setItems(self.config.messageList); + self.enableMessageListIndexUpdates(msgListView); + + if(self.initialFocusIndex > 0) { + // note: causes redraw() + msgListView.setFocusItemIndex(self.initialFocusIndex); + } else { + msgListView.redraw(); + } + + self.populateCustomLabelsForSelected(self.initialFocusIndex || 0); + return callback(null); + }, + ], + err => { + if(err) { + self.client.log.error( { error : err.message }, 'Error loading message list'); + } + return cb(err); + } + ); + }); + } + + getSaveState() { + return { initialFocusIndex : this.initialFocusIndex }; + } + + restoreSavedState(savedState) { + if(savedState) { + this.initialFocusIndex = savedState.initialFocusIndex; + } + } + + getMenuResult() { + return this.menuResult; + } + + enableMessageListIndexUpdates(msgListView) { + msgListView.on('index update', idx => this.populateCustomLabelsForSelected(idx) ); + } + + markAllMessagesAsRead(cb) { + if(!this.config.messageList || this.config.messageList.length === 0) { + return cb(null); // nothing to do. + } + + // + // Generally we'll have a message list for a specific area, + // but this is not always the case. For a given area, we need + // to find the highest message ID in the list to set a + // last read pointer. + // + const areaHighestIds = {}; + this.config.messageList.forEach(msg => { + const highestId = areaHighestIds[msg.areaTag]; + if(highestId) { + if(msg.messageId > highestId) { + areaHighestIds[msg.areaTag] = msg.messageId; + } + } else { + areaHighestIds[msg.areaTag] = msg.messageId; + } + }); + + const regIndicator = ' '.repeat( (this.menuConfig.config.newIndicator || '*').length ); + async.forEachOf(areaHighestIds, (highestId, areaTag, nextArea) => { + messageArea.updateMessageAreaLastReadId( + this.client.user.userId, + areaTag, + highestId, + err => { + if(err) { + this.client.log.warn( { error : err.message }, 'Failed marking area as read'); + } else { + // update newIndicator on messages + this.config.messageList.forEach(msg => { + if(areaTag === msg.areaTag) { + msg.newIndicator = regIndicator; + } + }); + const msgListView = this.viewControllers.allViews.getView(MciViewIds.allViews.msgList); + msgListView.setItems(this.config.messageList); + msgListView.redraw(); + this.client.log.info( { highestId, areaTag }, 'User marked area as read'); + } + return nextArea(null); // always continue + } + ); + }, () => { + return cb(null); + }); + } + + updateMessageNumbersAfterDelete(startIndex) { + // all index -= 1 from this point on. + for(let i = startIndex; i < this.config.messageList.length; ++i) { + const msgItem = this.config.messageList[i]; + msgItem.msgNum -= 1; + msgItem.text = `${msgItem.msgNum} - ${msgItem.subject} from ${msgItem.fromUserName}`; // default text + } + } + + promptDeleteMessageConfirm(messageIndex, cb) { + const messageInfo = this.config.messageList[messageIndex]; + if(!_.isObject(messageInfo)) { + return cb(Errors.Invalid(`Invalid message index: ${messageIndex}`)); + } + + // :TODO: create static userHasDeleteRights() that takes id || uuid that doesn't require full msg load + this.selectedMessageForDelete = new Message(); + this.selectedMessageForDelete.load( { uuid : messageInfo.messageUuid }, err => { + if(err) { + this.selectedMessageForDelete = null; + return cb(err); + } + + if(!this.selectedMessageForDelete.userHasDeleteRights(this.client.user)) { + this.selectedMessageForDelete = null; + return cb(Errors.AccessDenied('User does not have rights to delete this message')); + } + + // user has rights to delete -- prompt/confirm then proceed + return this.promptConfirmDelete(cb); + }); + } + + promptConfirmDelete(cb) { + const promptXyView = this.viewControllers.allViews.getView(MciViewIds.allViews.delPromptXy); + if(!promptXyView) { + return cb(Errors.MissingMci(`Missing prompt XY${MciViewIds.allViews.delPromptXy} MCI`)); + } + + const promptOpts = { + clearAtSubmit : true, + }; + if(promptXyView.dimens.width) { + promptOpts.clearWidth = promptXyView.dimens.width; + } + + return this.promptForInput( + { + formName : 'delPrompt', + formId : FormIds.delPrompt, + promptName : this.config.deleteMessageFromListPrompt || 'deleteMessageFromListPrompt', + prevFormName : 'allViews', + position : promptXyView.position, + }, + promptOpts, + err => { + return cb(err); + } + ); + } +}; diff --git a/core/msg_network.js b/core/msg_network.js index 9e0813f4..e0018ece 100644 --- a/core/msg_network.js +++ b/core/msg_network.js @@ -1,66 +1,65 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -let loadModulesForCategory = require('./module_util.js').loadModulesForCategory; +// ENiGMA½ +const loadModulesForCategory = require('./module_util.js').loadModulesForCategory; -// standard/deps -let async = require('async'); +// standard/deps +const async = require('async'); -exports.startup = startup; -exports.shutdown = shutdown; -exports.recordMessage = recordMessage; +exports.startup = startup; +exports.shutdown = shutdown; +exports.recordMessage = recordMessage; let msgNetworkModules = []; function startup(cb) { - async.series( - [ - function loadModules(callback) { - loadModulesForCategory('scannerTossers', (err, module) => { - if(!err) { - const modInst = new module.getModule(); + async.series( + [ + function loadModules(callback) { + loadModulesForCategory('scannerTossers', (module, nextModule) => { + const modInst = new module.getModule(); - modInst.startup(err => { - if(!err) { - msgNetworkModules.push(modInst); - } - }); - } - }, err => { - callback(err); - }); - } - ], - cb - ); + modInst.startup(err => { + if(!err) { + msgNetworkModules.push(modInst); + } + }); + return nextModule(null); + }, err => { + callback(err); + }); + } + ], + cb + ); } function shutdown(cb) { - async.each( - msgNetworkModules, - (msgNetModule, next) => { - msgNetModule.shutdown( () => { - return next(); - }); - }, - () => { - msgNetworkModules = []; - return cb(null); - } - ); + async.each( + msgNetworkModules, + (msgNetModule, next) => { + msgNetModule.shutdown( () => { + return next(); + }); + }, + () => { + msgNetworkModules = []; + return cb(null); + } + ); } function recordMessage(message, cb) { - // - // Give all message network modules (scanner/tossers) - // a chance to do something with |message|. Any or all can - // choose to ignore it. - // - async.each(msgNetworkModules, (modInst, next) => { - modInst.record(message); - next(); - }, err => { - cb(err); - }); + // + // Give all message network modules (scanner/tossers) + // a chance to do something with |message|. Any or all can + // choose to ignore it. + // + async.each(msgNetworkModules, (modInst, next) => { + modInst.record(message); + next(); + }, err => { + cb(err); + }); } \ No newline at end of file diff --git a/core/msg_scan_toss_module.js b/core/msg_scan_toss_module.js index 8172d77f..59c94be0 100644 --- a/core/msg_scan_toss_module.js +++ b/core/msg_scan_toss_module.js @@ -1,24 +1,24 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -var PluginModule = require('./plugin_module.js').PluginModule; +// ENiGMA½ +var PluginModule = require('./plugin_module.js').PluginModule; -exports.MessageScanTossModule = MessageScanTossModule; +exports.MessageScanTossModule = MessageScanTossModule; function MessageScanTossModule() { - PluginModule.call(this); + PluginModule.call(this); } require('util').inherits(MessageScanTossModule, PluginModule); MessageScanTossModule.prototype.startup = function(cb) { - cb(null); + return cb(null); }; MessageScanTossModule.prototype.shutdown = function(cb) { - cb(null); + return cb(null); }; -MessageScanTossModule.prototype.record = function(message) { +MessageScanTossModule.prototype.record = function(/*message*/) { }; \ No newline at end of file diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js index 9d46e8a1..9f099b16 100644 --- a/core/multi_line_edit_text_view.js +++ b/core/multi_line_edit_text_view.js @@ -1,23 +1,22 @@ /* jslint node: true */ 'use strict'; -const View = require('./view.js').View; -const strUtil = require('./string_util.js'); -const ansi = require('./ansi_term.js'); -const colorCodes = require('./color_codes.js'); -const wordWrapText = require('./word_wrap.js').wordWrapText; -const ansiPrep = require('./ansi_prep.js'); +const View = require('./view.js').View; +const strUtil = require('./string_util.js'); +const ansi = require('./ansi_term.js'); +const wordWrapText = require('./word_wrap.js').wordWrapText; +const ansiPrep = require('./ansi_prep.js'); -const assert = require('assert'); -const _ = require('lodash'); +const assert = require('assert'); +const _ = require('lodash'); -// :TODO: Determine CTRL-* keys for various things - // See http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt - // http://wiki.synchro.net/howto:editor:slyedit#edit_mode - // http://sublime-text-unofficial-documentation.readthedocs.org/en/latest/reference/keyboard_shortcuts_win.html +// :TODO: Determine CTRL-* keys for various things +// See http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt +// http://wiki.synchro.net/howto:editor:slyedit#edit_mode +// http://sublime-text-unofficial-documentation.readthedocs.org/en/latest/reference/keyboard_shortcuts_win.html - /* Mystic - [^B] Reformat Paragraph [^O] Show this help file +/* Mystic + [^B] Reformat Paragraph [^O] Show this help file [^I] Insert tab space [^Q] Enter quote mode [^K] Cut current line of text [^V] Toggle insert/overwrite [^U] Paste previously cut text [^Y] Delete current line @@ -30,1169 +29,1187 @@ const _ = require('lodash'); */ // -// Some other interesting implementations, resources, etc. +// Some other interesting implementations, resources, etc. // -// Editors - BBS -// * https://github.com/M-griffin/Enthral/blob/master/src/msg_fse.cpp +// Editors - BBS +// * https://github.com/M-griffin/Enthral/blob/master/src/msg_fse.cpp // // -// Editors - Other -// * http://joe-editor.sourceforge.net/ -// * http://www.jbox.dk/downloads/edit.c -// * https://github.com/dominictarr/hipster +// Editors - Other +// * http://joe-editor.sourceforge.net/ +// * http://www.jbox.dk/downloads/edit.c +// * https://github.com/dominictarr/hipster // -// Implementations - Word Wrap -// * https://github.com/protomouse/synchronet/blob/93b01c55b3102ebc3c4f4793c3a45b8c13d0dc2a/src/sbbs3/wordwrap.c +// Implementations - Word Wrap +// * https://github.com/protomouse/synchronet/blob/93b01c55b3102ebc3c4f4793c3a45b8c13d0dc2a/src/sbbs3/wordwrap.c // -// Misc notes -// * https://github.com/dominictarr/hipster/issues/15 (Deleting lines/etc.) +// Misc notes +// * https://github.com/dominictarr/hipster/issues/15 (Deleting lines/etc.) // -// Blessed -// insertLine: CSR(top, bottom) + CUP(y, 0) + IL(1) + CSR(0, height) -// deleteLine: CSR(top, bottom) + CUP(y, 0) + DL(1) + CSR(0, height) -// Quick Ansi -- update only what was changed: -// https://github.com/dominictarr/quickansi +// Blessed +// insertLine: CSR(top, bottom) + CUP(y, 0) + IL(1) + CSR(0, height) +// deleteLine: CSR(top, bottom) + CUP(y, 0) + DL(1) + CSR(0, height) +// Quick Ansi -- update only what was changed: +// https://github.com/dominictarr/quickansi // -// To-Do +// To-Do // -// * Index pos % for emit scroll events -// * Some of this shoudl be async'd where there is lots of processing (e.g. word wrap) -// * Fix backspace when col=0 (e.g. bs to prev line) -// * Add back word delete -// * +// * Index pos % for emit scroll events +// * Some of this should be async'd where there is lots of processing (e.g. word wrap) +// * Fix backspace when col=0 (e.g. bs to prev line) +// * Add word delete (CTRL+????) +// * const SPECIAL_KEY_MAP_DEFAULT = { - 'line feed' : [ 'return' ], - exit : [ 'esc' ], - backspace : [ 'backspace' ], - delete : [ 'del' ], - tab : [ 'tab' ], - up : [ 'up arrow' ], - down : [ 'down arrow' ], - end : [ 'end' ], - home : [ 'home' ], - left : [ 'left arrow' ], - right : [ 'right arrow' ], - 'delete line' : [ 'ctrl + y' ], - 'page up' : [ 'page up' ], - 'page down' : [ 'page down' ], - insert : [ 'insert', 'ctrl + v' ], + 'line feed' : [ 'return' ], + exit : [ 'esc' ], + backspace : [ 'backspace' ], + delete : [ 'delete' ], + tab : [ 'tab' ], + up : [ 'up arrow' ], + down : [ 'down arrow' ], + end : [ 'end' ], + home : [ 'home' ], + left : [ 'left arrow' ], + right : [ 'right arrow' ], + 'delete line' : [ 'ctrl + y' ], + 'page up' : [ 'page up' ], + 'page down' : [ 'page down' ], + insert : [ 'insert', 'ctrl + v' ], }; -exports.MultiLineEditTextView = MultiLineEditTextView; +exports.MultiLineEditTextView = MultiLineEditTextView; function MultiLineEditTextView(options) { - if(!_.isBoolean(options.acceptsFocus)) { - options.acceptsFocus = true; - } - - if(!_.isBoolean(this.acceptsInput)) { - options.acceptsInput = true; - } - - if(!_.isObject(options.specialKeyMap)) { - options.specialKeyMap = SPECIAL_KEY_MAP_DEFAULT; - } - - View.call(this, options); - - var self = this; - - // - // ANSI seems to want tabs to default to 8 characters. See the following: - // * http://www.ansi-bbs.org/ansi-bbs2/control_chars/ - // * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt - // - // This seems overkill though, so let's default to 4 :) - // :TODO: what shoudl this really be? Maybe 8 is OK - // - this.tabWidth = _.isNumber(options.tabWidth) ? options.tabWidth : 4; - - this.textLines = [ ]; - this.topVisibleIndex = 0; - this.mode = options.mode || 'edit'; // edit | preview | read-only - - if ('preview' === this.mode) { - this.autoScroll = options.autoScroll || true; - this.tabSwitchesView = true; - } else { - this.autoScroll = options.autoScroll || false; - this.tabSwitchesView = options.tabSwitchesView || false; - } - // - // cursorPos represents zero-based row, col positions - // within the editor itself - // - this.cursorPos = { col : 0, row : 0 }; - - this.getSGRFor = function(sgrFor) { - return { - text : self.getSGR(), - }[sgrFor] || self.getSGR(); - }; - - this.isEditMode = function() { - return 'edit' === self.mode; - }; - - this.isPreviewMode = function() { - return 'preview' === self.mode; - }; - - // :TODO: Most of the calls to this could be avoided via incrementRow(), decrementRow() that keeps track or such - this.getTextLinesIndex = function(row) { - if(!_.isNumber(row)) { - row = self.cursorPos.row; - } - var index = self.topVisibleIndex + row; - return index; - }; - - this.getRemainingLinesBelowRow = function(row) { - if(!_.isNumber(row)) { - row = self.cursorPos.row; - } - return self.textLines.length - (self.topVisibleIndex + row) - 1; - }; - - this.getNextEndOfLineIndex = function(startIndex) { - for(var i = startIndex; i < self.textLines.length; i++) { - if(self.textLines[i].eol) { - return i; - } - } - return self.textLines.length; - }; - - this.toggleTextCursor = function(action) { - self.client.term.rawWrite(`${self.getSGRFor('text')}${'hide' === action ? ansi.hideCursor() : ansi.showCursor()}`); - }; - - this.redrawRows = function(startRow, endRow) { - self.toggleTextCursor('hide'); - - const startIndex = self.getTextLinesIndex(startRow); - const endIndex = Math.min(self.getTextLinesIndex(endRow), self.textLines.length); - const absPos = self.getAbsolutePosition(startRow, 0); - - for(let i = startIndex; i < endIndex; ++i) { - //${self.getSGRFor('text')} - self.client.term.write( - `${ansi.goto(absPos.row++, absPos.col)}${self.getRenderText(i)}`, - false // convertLineFeeds - ); - } - - self.toggleTextCursor('show'); - - return absPos.row - self.position.row; // row we ended on - }; - - this.eraseRows = function(startRow, endRow) { - self.toggleTextCursor('hide'); - - const absPos = self.getAbsolutePosition(startRow, 0); - const absPosEnd = self.getAbsolutePosition(endRow, 0); - const eraseFiller = ' '.repeat(self.dimens.width);//new Array(self.dimens.width).join(' '); - - while(absPos.row < absPosEnd.row) { - self.client.term.write( - `${ansi.goto(absPos.row++, absPos.col)}${eraseFiller}`, - false // convertLineFeeds - ); - } - - self.toggleTextCursor('show'); - }; - - this.redrawVisibleArea = function() { - assert(self.topVisibleIndex <= self.textLines.length); - const lastRow = self.redrawRows(0, self.dimens.height); - - self.eraseRows(lastRow, self.dimens.height); - /* - - // :TOOD: create eraseRows(startRow, endRow) - if(lastRow < self.dimens.height) { - var absPos = self.getAbsolutePosition(lastRow, 0); - var empty = new Array(self.dimens.width).join(' '); - while(lastRow++ < self.dimens.height) { - self.client.term.write(ansi.goto(absPos.row++, absPos.col)); - self.client.term.write(empty); - } - } - */ - }; - - this.getVisibleText = function(index) { - if(!_.isNumber(index)) { - index = self.getTextLinesIndex(); - } - return self.textLines[index].text.replace(/\t/g, ' '); - }; - - this.getText = function(index) { - if(!_.isNumber(index)) { - index = self.getTextLinesIndex(); - } - return self.textLines.length > index ? self.textLines[index].text : ''; - }; - - this.getTextLength = function(index) { - if(!_.isNumber(index)) { - index = self.getTextLinesIndex(); - } - return self.textLines.length > index ? self.textLines[index].text.length : 0; - }; - - this.getCharacter = function(index, col) { - if(!_.isNumber(col)) { - col = self.cursorPos.col; - } - return self.getText(index).charAt(col); - }; - - this.isTab = function(index, col) { - return '\t' === self.getCharacter(index, col); - }; - - this.getTextEndOfLineColumn = function(index) { - return Math.max(0, self.getTextLength(index)); - }; - - this.getRenderText = function(index) { - let text = self.getVisibleText(index); - const remain = self.dimens.width - text.length; - - if(remain > 0) { - text += ' '.repeat(remain + 1); -// text += new Array(remain + 1).join(' '); - } - - return text; - }; - - this.getTextLines = function(startIndex, endIndex) { - var lines; - if(startIndex === endIndex) { - lines = [ self.textLines[startIndex] ]; - } else { - lines = self.textLines.slice(startIndex, endIndex + 1); // "slice extracts up to but not including end." - } - return lines; - }; - - this.getOutputText = function(startIndex, endIndex, eolMarker, options) { - const lines = self.getTextLines(startIndex, endIndex); - let text = ''; - const re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g'); - - lines.forEach(line => { - text += line.text.replace(re, '\t'); - - if(options.forceLineTerms || (eolMarker && line.eol)) { - text += eolMarker; - } - }); - - return text; - }; - - this.getContiguousText = function(startIndex, endIndex, includeEol) { - var lines = self.getTextLines(startIndex, endIndex); - var text = ''; - for(var i = 0; i < lines.length; ++i) { - text += lines[i].text; - if(includeEol && lines[i].eol) { - text += '\n'; - } - } - return text; - }; - - this.replaceCharacterInText = function(c, index, col) { - self.textLines[index].text = strUtil.replaceAt( - self.textLines[index].text, col, c); - }; - - /* - this.editTextAtPosition = function(editAction, text, index, col) { - switch(editAction) { - case 'insert' : - self.insertCharactersInText(text, index, col); - break; - - case 'deleteForward' : - break; - - case 'deleteBack' : - break; - - case 'replace' : - break; - } - }; - */ - - this.updateTextWordWrap = function(index) { - var nextEolIndex = self.getNextEndOfLineIndex(index); - var wrapped = self.wordWrapSingleLine(self.getContiguousText(index, nextEolIndex), 'tabsIntact'); - var newLines = wrapped.wrapped; - - for(var i = 0; i < newLines.length; ++i) { - newLines[i] = { text : newLines[i] }; - } - newLines[newLines.length - 1].eol = true; - - Array.prototype.splice.apply( - self.textLines, - [ index, (nextEolIndex - index) + 1 ].concat(newLines)); - - return wrapped.firstWrapRange; - }; - - this.removeCharactersFromText = function(index, col, operation, count) { - if('right' === operation) { - self.textLines[index].text = - self.textLines[index].text.slice(col, count) + - self.textLines[index].text.slice(col + count); - - self.cursorPos.col -= count; - - self.updateTextWordWrap(index); - self.redrawRows(self.cursorPos.row, self.dimens.height); - - if(0 === self.textLines[index].text) { - - } else { - self.redrawRows(self.cursorPos.row, self.dimens.height); - } - } else if ('backspace' === operation) { - // :TODO: method for splicing text - self.textLines[index].text = - self.textLines[index].text.slice(0, col - (count - 1)) + - self.textLines[index].text.slice(col + 1); - - self.cursorPos.col -= (count - 1); - - self.updateTextWordWrap(index); - self.redrawRows(self.cursorPos.row, self.dimens.height); - - self.moveClientCursorToCursorPos(); - } else if('delete line' === operation) { - // - // Delete a visible line. Note that this is *not* the "physical" line, or - // 1:n entries up to eol! This is to keep consistency with home/end, and - // some other text editors such as nano. Sublime for example want to - // treat all of these things using the physical approach, but this seems - // a bit odd in this context. - // - var isLastLine = (index === self.textLines.length - 1); - var hadEol = self.textLines[index].eol; - - self.textLines.splice(index, 1); - if(hadEol && self.textLines.length > index && !self.textLines[index].eol) { - self.textLines[index].eol = true; - } - - // - // Create a empty edit buffer if necessary - // :TODO: Make this a method - if(self.textLines.length < 1) { - self.textLines = [ { text : '', eol : true } ]; - isLastLine = false; // resetting - } - - self.cursorPos.col = 0; - - var lastRow = self.redrawRows(self.cursorPos.row, self.dimens.height); - self.eraseRows(lastRow, self.dimens.height); - - // - // If we just deleted the last line in the buffer, move up - // - if(isLastLine) { - self.cursorEndOfPreviousLine(); - } else { - self.moveClientCursorToCursorPos(); - } - } - }; - - this.insertCharactersInText = function(c, index, col) { - self.textLines[index].text = [ - self.textLines[index].text.slice(0, col), - c, - self.textLines[index].text.slice(col) - ].join(''); - - //self.cursorPos.col++; - self.cursorPos.col += c.length; - - var cursorOffset; - var absPos; - - if(self.getTextLength(index) > self.dimens.width) { - // - // Update word wrapping and |cursorOffset| if the cursor - // was within the bounds of the wrapped text - // - var lastCol = self.cursorPos.col - c.length; - var firstWrapRange = self.updateTextWordWrap(index); - if(lastCol >= firstWrapRange.start && lastCol <= firstWrapRange.end) { - cursorOffset = self.cursorPos.col - firstWrapRange.start; - } - - // redraw from current row to end of visible area - self.redrawRows(self.cursorPos.row, self.dimens.height); - - if(!_.isUndefined(cursorOffset)) { - self.cursorBeginOfNextLine(); - self.cursorPos.col += cursorOffset; - self.client.term.rawWrite(ansi.right(cursorOffset)); - } else { - self.moveClientCursorToCursorPos(); - } - } else { - // - // We must only redraw from col -> end of current visible line - // - absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col); - self.client.term.write( - ansi.hideCursor() + - self.getSGRFor('text') + - self.getRenderText(index).slice(self.cursorPos.col - c.length) + - ansi.goto(absPos.row, absPos.col) + - ansi.showCursor(), false - ); - } - }; - - this.getRemainingTabWidth = function(col) { - if(!_.isNumber(col)) { - col = self.cursorPos.col; - } - return self.tabWidth - (col % self.tabWidth); - }; - - this.calculateTabStops = function() { - self.tabStops = [ 0 ]; - var col = 0; - while(col < self.dimens.width) { - col += self.getRemainingTabWidth(col); - self.tabStops.push(col); - } - }; - - this.getNextTabStop = function(col) { - var i = self.tabStops.length; - while(self.tabStops[--i] > col); - return self.tabStops[++i]; - }; - - this.getPrevTabStop = function(col) { - var i = self.tabStops.length; - while(self.tabStops[--i] >= col); - return self.tabStops[i]; - }; - - this.expandTab = function(col, expandChar) { - expandChar = expandChar || ' '; - return new Array(self.getRemainingTabWidth(col)).join(expandChar); - }; - - this.wordWrapSingleLine = function(s, tabHandling, width) { - if(!_.isNumber(width)) { - width = self.dimens.width; - } - - return wordWrapText( - s, - { - width : width, - tabHandling : tabHandling || 'expand', - tabWidth : self.tabWidth, - tabChar : '\t', - } - ); - }; - - this.setTextLines = function(lines, index, termWithEol) { - if(0 === index && (0 === self.textLines.length || (self.textLines.length === 1 && '' === self.textLines[0].text) )) { - // quick path: just set the things - self.textLines = lines.slice(0, -1).map(l => { - return { text : l }; - }).concat( { text : lines[lines.length - 1], eol : termWithEol } ); - } else { - // insert somewhere in textLines... - if(index > self.textLines.length) { - // fill with empty - self.textLines.splice( - self.textLines.length, - 0, - ...Array.from( { length : index - self.textLines.length } ).map( () => { return { text : '' }; } ) - ); - } - - const newLines = lines.slice(0, -1).map(l => { - return { text : l }; - }).concat( { text : lines[lines.length - 1], eol : termWithEol } ); - - self.textLines.splice( - index, - 0, - ...newLines - ); - } - }; - - this.setAnsiWithOptions = function(ansi, options, cb) { - - function setLines(text) { - text = strUtil.splitTextAtTerms(text); - - let index = 0; - - text.forEach(line => { - self.setTextLines( [ line ], index, true); // true=termWithEol - index += 1; - }); - - self.cursorStartOfDocument(); - - if(cb) { - return cb(null); - } - } - - if(options.prepped) { - return setLines(ansi); - } - - ansiPrep( - ansi, - { - termWidth : this.client.term.termWidth, - termHeight : this.client.term.termHeight, - cols : this.dimens.width, - rows : 'auto', - startCol : this.position.col, - forceLineTerm : options.forceLineTerm, - }, - (err, preppedAnsi) => { - return setLines(err ? ansi : preppedAnsi); - } - ); - }; - - this.insertRawText = function(text, index, col) { - // - // Perform the following on |text|: - // * Normalize various line feed formats -> \n - // * Remove some control characters (e.g. \b) - // * Word wrap lines such that they fit in the visible workspace. - // Each actual line will then take 1:n elements in textLines[]. - // * Each tab will be appropriately expanded and take 1:n \t - // characters. This allows us to know when we're in tab space - // when doing cursor movement/etc. - // - // - // Try to handle any possible newline that can be fed to us. - // See http://stackoverflow.com/questions/5034781/js-regex-to-split-by-line - // - // :TODO: support index/col insertion point - - if(_.isNumber(index)) { - if(_.isNumber(col)) { - // - // Modify text to have information from index - // before and and after column - // - // :TODO: Need to clean this string (e.g. collapse tabs) - text = self.textLines; - - // :TODO: Remove original line @ index - } - } else { - index = self.textLines.length; - } - - text = strUtil.splitTextAtTerms(text); - - let wrapped; - text.forEach(line => { - wrapped = self.wordWrapSingleLine( - line, // line to wrap - 'expand', // tabHandling - self.dimens.width - ).wrapped; - - self.setTextLines(wrapped, index, true); // true=termWithEol - index += wrapped.length; - }); - }; - - this.getAbsolutePosition = function(row, col) { - return { - row : self.position.row + row, - col : self.position.col + col, - }; - }; - - this.moveClientCursorToCursorPos = function() { - var absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col); - self.client.term.rawWrite(ansi.goto(absPos.row, absPos.col)); - }; - - - this.keyPressCharacter = function(c) { - var index = self.getTextLinesIndex(); - - // - // :TODO: stuff that needs to happen - // * Break up into smaller methods - // * Even in overtype mode, word wrapping must apply if past bounds - // * A lot of this can be used for backspacing also - // * See how Sublime treats tabs in *non* overtype mode... just overwrite them? - // - // - - if(self.overtypeMode) { - // :TODO: special handing for insert over eol mark? - self.replaceCharacterInText(c, index, self.cursorPos.col); - self.cursorPos.col++; - self.client.term.write(c); - } else { - self.insertCharactersInText(c, index, self.cursorPos.col); - } - - self.emitEditPosition(); - }; - - this.keyPressUp = function() { - if(self.cursorPos.row > 0) { - self.cursorPos.row--; - self.client.term.rawWrite(ansi.up()); - - if(!self.adjustCursorToNextTab('up')) { - self.adjustCursorIfPastEndOfLine(false); - } - } else { - self.scrollDocumentDown(); - self.adjustCursorIfPastEndOfLine(true); - } - - self.emitEditPosition(); - }; - - this.keyPressDown = function() { - var lastVisibleRow = Math.min( - self.dimens.height, - (self.textLines.length - self.topVisibleIndex)) - 1; - - if(self.cursorPos.row < lastVisibleRow) { - self.cursorPos.row++; - self.client.term.rawWrite(ansi.down()); - - if(!self.adjustCursorToNextTab('down')) { - self.adjustCursorIfPastEndOfLine(false); - } - } else { - self.scrollDocumentUp(); - self.adjustCursorIfPastEndOfLine(true); - } - - self.emitEditPosition(); - }; - - this.keyPressLeft = function() { - if(self.cursorPos.col > 0) { - var prevCharIsTab = self.isTab(); - - self.cursorPos.col--; - self.client.term.rawWrite(ansi.left()); - - if(prevCharIsTab) { - self.adjustCursorToNextTab('left'); - } - } else { - self.cursorEndOfPreviousLine(); - } - - self.emitEditPosition(); - }; - - this.keyPressRight = function() { - var eolColumn = self.getTextEndOfLineColumn(); - if(self.cursorPos.col < eolColumn) { - var prevCharIsTab = self.isTab(); - - self.cursorPos.col++; - self.client.term.rawWrite(ansi.right()); - - if(prevCharIsTab) { - self.adjustCursorToNextTab('right'); - } - } else { - self.cursorBeginOfNextLine(); - } - - self.emitEditPosition(); - }; - - this.keyPressHome = function() { - var firstNonWhitespace = self.getVisibleText().search(/\S/); - if(-1 !== firstNonWhitespace) { - self.cursorPos.col = firstNonWhitespace; - } else { - self.cursorPos.col = 0; - } - self.moveClientCursorToCursorPos(); - - self.emitEditPosition(); - }; - - this.keyPressEnd = function() { - self.cursorPos.col = self.getTextEndOfLineColumn(); - self.moveClientCursorToCursorPos(); - self.emitEditPosition(); - }; - - this.keyPressPageUp = function() { - if(self.topVisibleIndex > 0) { - self.topVisibleIndex = Math.max(0, self.topVisibleIndex - self.dimens.height); - self.redraw(); - self.adjustCursorIfPastEndOfLine(true); - } else { - self.cursorPos.row = 0; - self.moveClientCursorToCursorPos(); // :TODO: ajust if eol, etc. - } - - self.emitEditPosition(); - }; - - this.keyPressPageDown = function() { - var linesBelow = self.getRemainingLinesBelowRow(); - if(linesBelow > 0) { - self.topVisibleIndex += Math.min(linesBelow, self.dimens.height); - self.redraw(); - self.adjustCursorIfPastEndOfLine(true); - } - - self.emitEditPosition(); - }; - - this.keyPressLineFeed = function() { - // - // Break up text from cursor position, redraw, and update cursor - // position to start of next line - // - var index = self.getTextLinesIndex(); - var nextEolIndex = self.getNextEndOfLineIndex(index); - var text = self.getContiguousText(index, nextEolIndex); - var newLines = self.wordWrapSingleLine(text.slice(self.cursorPos.col), 'tabsIntact').wrapped; - - newLines.unshift( { text : text.slice(0, self.cursorPos.col), eol : true } ); - for(var i = 1; i < newLines.length; ++i) { - newLines[i] = { text : newLines[i] }; - } - newLines[newLines.length - 1].eol = true; - - Array.prototype.splice.apply( - self.textLines, - [ index, (nextEolIndex - index) + 1 ].concat(newLines)); - - // redraw from current row to end of visible area - self.redrawRows(self.cursorPos.row, self.dimens.height); - self.cursorBeginOfNextLine(); - - self.emitEditPosition(); - }; - - this.keyPressInsert = function() { - self.toggleTextEditMode(); - }; - - this.keyPressTab = function() { - var index = self.getTextLinesIndex(); - self.insertCharactersInText(self.expandTab(self.cursorPos.col, '\t') + '\t', index, self.cursorPos.col); - - self.emitEditPosition(); - }; - - this.keyPressBackspace = function() { - if(self.cursorPos.col >= 1) { - // - // Don't want to delete character at cursor, but rather the character - // to the left of the cursor! - // - self.cursorPos.col -= 1; - - var index = self.getTextLinesIndex(); - var count; - - if(self.isTab()) { - var col = self.cursorPos.col; - var prevTabStop = self.getPrevTabStop(self.cursorPos.col); - while(col >= prevTabStop) { - if(!self.isTab(index, col)) { - break; - } - --col; - } - - count = (self.cursorPos.col - col); - } else { - count = 1; - } - - self.removeCharactersFromText( - index, - self.cursorPos.col, - 'backspace', - count); - } else { - // - // Delete character at end of line previous. - // * This may be a eol marker - // * Word wrapping will need re-applied - // - // :TODO: apply word wrapping such that text can be re-adjusted if it can now fit on prev - self.keyPressLeft(); // same as hitting left - jump to previous line - //self.keyPressBackspace(); - } - - self.emitEditPosition(); - }; - - this.keyPressDelete = function() { - self.removeCharactersFromText( - self.getTextLinesIndex(), - self.cursorPos.col, - 'right', - 1); - - self.emitEditPosition(); - }; - - this.keyPressDeleteLine = function() { - if(self.textLines.length > 0) { - self.removeCharactersFromText( - self.getTextLinesIndex(), - 0, - 'delete line'); - } - - self.emitEditPosition(); - }; - - this.adjustCursorIfPastEndOfLine = function(forceUpdate) { - var eolColumn = self.getTextEndOfLineColumn(); - if(self.cursorPos.col > eolColumn) { - self.cursorPos.col = eolColumn; - forceUpdate = true; - } - - if(forceUpdate) { - self.moveClientCursorToCursorPos(); - } - }; - - this.adjustCursorToNextTab = function(direction) { - if(self.isTab()) { - var move; - switch(direction) { - // - // Next tabstop to the right - // - case 'right' : - move = self.getNextTabStop(self.cursorPos.col) - self.cursorPos.col; - self.cursorPos.col += move; - self.client.term.rawWrite(ansi.right(move)); - break; - - // - // Next tabstop to the left - // - case 'left' : - move = self.cursorPos.col - self.getPrevTabStop(self.cursorPos.col); - self.cursorPos.col -= move; - self.client.term.rawWrite(ansi.left(move)); - break; - - case 'up' : - case 'down' : - // - // Jump to the tabstop nearest the cursor - // - var newCol = self.tabStops.reduce(function r(prev, curr) { - return (Math.abs(curr - self.cursorPos.col) < Math.abs(prev - self.cursorPos.col) ? curr : prev); - }); - - if(newCol > self.cursorPos.col) { - move = newCol - self.cursorPos.col; - self.cursorPos.col += move; - self.client.term.rawWrite(ansi.right(move)); - } else if(newCol < self.cursorPos.col) { - move = self.cursorPos.col - newCol; - self.cursorPos.col -= move; - self.client.term.rawWrite(ansi.left(move)); - } - break; - } - - return true; - } - return false; // did not fall on a tab - }; - - this.cursorStartOfDocument = function() { - self.topVisibleIndex = 0; - self.cursorPos = { row : 0, col : 0 }; - - self.redraw(); - self.moveClientCursorToCursorPos(); - }; - - this.cursorEndOfDocument = function() { - self.topVisibleIndex = Math.max(self.textLines.length - self.dimens.height, 0); - self.cursorPos.row = (self.textLines.length - self.topVisibleIndex) - 1; - self.cursorPos.col = self.getTextEndOfLineColumn(); - - self.redraw(); - self.moveClientCursorToCursorPos(); - }; - - this.cursorBeginOfNextLine = function() { - // e.g. when scrolling right past eol - var linesBelow = self.getRemainingLinesBelowRow(); - - if(linesBelow > 0) { - var lastVisibleRow = Math.min(self.dimens.height, self.textLines.length) - 1; - if(self.cursorPos.row < lastVisibleRow) { - self.cursorPos.row++; - } else { - self.scrollDocumentUp(); - } - self.keyPressHome(); // same as pressing 'home' - } - }; - - this.cursorEndOfPreviousLine = function() { - // e.g. when scrolling left past start of line - var moveToEnd; - if(self.cursorPos.row > 0) { - self.cursorPos.row--; - moveToEnd = true; - } else if(self.topVisibleIndex > 0) { - self.scrollDocumentDown(); - moveToEnd = true; - } - - if(moveToEnd) { - self.keyPressEnd(); // same as pressing 'end' - } - }; - - /* - this.cusorEndOfNextLine = function() { - var linesBelow = self.getRemainingLinesBelowRow(); - - if(linesBelow > 0) { - var lastVisibleRow = Math.min(self.dimens.height, self.textLines.length) - 1; - if(self.cursorPos.row < lastVisibleRow) { - self.cursorPos.row++; - } else { - self.scrollDocumentUp(); - } - self.keyPressEnd(); // same as pressing 'end' - } - }; - */ - - this.scrollDocumentUp = function() { - // - // Note: We scroll *up* when the cursor goes *down* beyond - // the visible area! - // - var linesBelow = self.getRemainingLinesBelowRow(); - if(linesBelow > 0) { - self.topVisibleIndex++; - self.redraw(); - } - }; - - this.scrollDocumentDown = function() { - // - // Note: We scroll *down* when the cursor goes *up* beyond - // the visible area! - // - if(self.topVisibleIndex > 0) { - self.topVisibleIndex--; - self.redraw(); - } - }; - - this.emitEditPosition = function() { - self.emit('edit position', self.getEditPosition()); - }; - - this.toggleTextEditMode = function() { - self.overtypeMode = !self.overtypeMode; - self.emit('text edit mode', self.getTextEditMode()); - }; - - this.insertRawText(''); // init to blank/empty + if(!_.isBoolean(options.acceptsFocus)) { + options.acceptsFocus = true; + } + + if(!_.isBoolean(this.acceptsInput)) { + options.acceptsInput = true; + } + + if(!_.isObject(options.specialKeyMap)) { + options.specialKeyMap = SPECIAL_KEY_MAP_DEFAULT; + } + + View.call(this, options); + + this.initDefaultWidth(); + + var self = this; + + // + // ANSI seems to want tabs to default to 8 characters. See the following: + // * http://www.ansi-bbs.org/ansi-bbs2/control_chars/ + // * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt + // + // This seems overkill though, so let's default to 4 :) + // :TODO: what shoudl this really be? Maybe 8 is OK + // + this.tabWidth = _.isNumber(options.tabWidth) ? options.tabWidth : 4; + + this.textLines = [ ]; + this.topVisibleIndex = 0; + this.mode = options.mode || 'edit'; // edit | preview | read-only + + if ('preview' === this.mode) { + this.autoScroll = options.autoScroll || true; + this.tabSwitchesView = true; + } else { + this.autoScroll = options.autoScroll || false; + this.tabSwitchesView = options.tabSwitchesView || false; + } + // + // cursorPos represents zero-based row, col positions + // within the editor itself + // + this.cursorPos = { col : 0, row : 0 }; + + this.getSGRFor = function(sgrFor) { + return { + text : self.getSGR(), + }[sgrFor] || self.getSGR(); + }; + + this.isEditMode = function() { + return 'edit' === self.mode; + }; + + this.isPreviewMode = function() { + return 'preview' === self.mode; + }; + + // :TODO: Most of the calls to this could be avoided via incrementRow(), decrementRow() that keeps track or such + this.getTextLinesIndex = function(row) { + if(!_.isNumber(row)) { + row = self.cursorPos.row; + } + var index = self.topVisibleIndex + row; + return index; + }; + + this.getRemainingLinesBelowRow = function(row) { + if(!_.isNumber(row)) { + row = self.cursorPos.row; + } + return self.textLines.length - (self.topVisibleIndex + row) - 1; + }; + + this.getNextEndOfLineIndex = function(startIndex) { + for(var i = startIndex; i < self.textLines.length; i++) { + if(self.textLines[i].eol) { + return i; + } + } + return self.textLines.length; + }; + + this.toggleTextCursor = function(action) { + self.client.term.rawWrite(`${self.getSGRFor('text')}${'hide' === action ? ansi.hideCursor() : ansi.showCursor()}`); + }; + + this.redrawRows = function(startRow, endRow) { + self.toggleTextCursor('hide'); + + const startIndex = self.getTextLinesIndex(startRow); + const endIndex = Math.min(self.getTextLinesIndex(endRow), self.textLines.length); + const absPos = self.getAbsolutePosition(startRow, 0); + + for(let i = startIndex; i < endIndex; ++i) { + //${self.getSGRFor('text')} + self.client.term.write( + `${ansi.goto(absPos.row++, absPos.col)}${self.getRenderText(i)}`, + false // convertLineFeeds + ); + } + + self.toggleTextCursor('show'); + + return absPos.row - self.position.row; // row we ended on + }; + + this.eraseRows = function(startRow, endRow) { + self.toggleTextCursor('hide'); + + const absPos = self.getAbsolutePosition(startRow, 0); + const absPosEnd = self.getAbsolutePosition(endRow, 0); + const eraseFiller = ' '.repeat(self.dimens.width);//new Array(self.dimens.width).join(' '); + + while(absPos.row < absPosEnd.row) { + self.client.term.write( + `${ansi.goto(absPos.row++, absPos.col)}${eraseFiller}`, + false // convertLineFeeds + ); + } + + self.toggleTextCursor('show'); + }; + + this.redrawVisibleArea = function() { + assert(self.topVisibleIndex <= self.textLines.length); + const lastRow = self.redrawRows(0, self.dimens.height); + + self.eraseRows(lastRow, self.dimens.height); + /* + + // :TOOD: create eraseRows(startRow, endRow) + if(lastRow < self.dimens.height) { + var absPos = self.getAbsolutePosition(lastRow, 0); + var empty = new Array(self.dimens.width).join(' '); + while(lastRow++ < self.dimens.height) { + self.client.term.write(ansi.goto(absPos.row++, absPos.col)); + self.client.term.write(empty); + } + } + */ + }; + + this.getVisibleText = function(index) { + if(!_.isNumber(index)) { + index = self.getTextLinesIndex(); + } + return self.textLines[index].text.replace(/\t/g, ' '); + }; + + this.getText = function(index) { + if(!_.isNumber(index)) { + index = self.getTextLinesIndex(); + } + return self.textLines.length > index ? self.textLines[index].text : ''; + }; + + this.getTextLength = function(index) { + if(!_.isNumber(index)) { + index = self.getTextLinesIndex(); + } + return self.textLines.length > index ? self.textLines[index].text.length : 0; + }; + + this.getCharacter = function(index, col) { + if(!_.isNumber(col)) { + col = self.cursorPos.col; + } + return self.getText(index).charAt(col); + }; + + this.isTab = function(index, col) { + return '\t' === self.getCharacter(index, col); + }; + + this.getTextEndOfLineColumn = function(index) { + return Math.max(0, self.getTextLength(index)); + }; + + this.getRenderText = function(index) { + let text = self.getVisibleText(index); + const remain = self.dimens.width - text.length; + + if(remain > 0) { + text += ' '.repeat(remain + 1); + // text += new Array(remain + 1).join(' '); + } + + return text; + }; + + this.getTextLines = function(startIndex, endIndex) { + var lines; + if(startIndex === endIndex) { + lines = [ self.textLines[startIndex] ]; + } else { + lines = self.textLines.slice(startIndex, endIndex + 1); // "slice extracts up to but not including end." + } + return lines; + }; + + this.getOutputText = function(startIndex, endIndex, eolMarker, options) { + const lines = self.getTextLines(startIndex, endIndex); + let text = ''; + const re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g'); + + lines.forEach(line => { + text += line.text.replace(re, '\t'); + + if(options.forceLineTerms || (eolMarker && line.eol)) { + text += eolMarker; + } + }); + + return text; + }; + + this.getContiguousText = function(startIndex, endIndex, includeEol) { + var lines = self.getTextLines(startIndex, endIndex); + var text = ''; + for(var i = 0; i < lines.length; ++i) { + text += lines[i].text; + if(includeEol && lines[i].eol) { + text += '\n'; + } + } + return text; + }; + + this.replaceCharacterInText = function(c, index, col) { + self.textLines[index].text = strUtil.replaceAt( + self.textLines[index].text, col, c); + }; + + /* + this.editTextAtPosition = function(editAction, text, index, col) { + switch(editAction) { + case 'insert' : + self.insertCharactersInText(text, index, col); + break; + + case 'deleteForward' : + break; + + case 'deleteBack' : + break; + + case 'replace' : + break; + } + }; + */ + + this.updateTextWordWrap = function(index) { + const nextEolIndex = self.getNextEndOfLineIndex(index); + const wrapped = self.wordWrapSingleLine(self.getContiguousText(index, nextEolIndex), 'tabsIntact'); + const newLines = wrapped.wrapped.map(l => { return { text : l }; } ); + + newLines[newLines.length - 1].eol = true; + + Array.prototype.splice.apply( + self.textLines, + [ index, (nextEolIndex - index) + 1 ].concat(newLines)); + + return wrapped.firstWrapRange; + }; + + this.removeCharactersFromText = function(index, col, operation, count) { + if('delete' === operation) { + self.textLines[index].text = + self.textLines[index].text.slice(0, col) + + self.textLines[index].text.slice(col + count); + + self.updateTextWordWrap(index); + self.redrawRows(self.cursorPos.row, self.dimens.height); + self.moveClientCursorToCursorPos(); + } else if ('backspace' === operation) { + // :TODO: method for splicing text + self.textLines[index].text = + self.textLines[index].text.slice(0, col - (count - 1)) + + self.textLines[index].text.slice(col + 1); + + self.cursorPos.col -= (count - 1); + + self.updateTextWordWrap(index); + self.redrawRows(self.cursorPos.row, self.dimens.height); + + self.moveClientCursorToCursorPos(); + } else if('delete line' === operation) { + // + // Delete a visible line. Note that this is *not* the "physical" line, or + // 1:n entries up to eol! This is to keep consistency with home/end, and + // some other text editors such as nano. Sublime for example want to + // treat all of these things using the physical approach, but this seems + // a bit odd in this context. + // + var isLastLine = (index === self.textLines.length - 1); + var hadEol = self.textLines[index].eol; + + self.textLines.splice(index, 1); + if(hadEol && self.textLines.length > index && !self.textLines[index].eol) { + self.textLines[index].eol = true; + } + + // + // Create a empty edit buffer if necessary + // :TODO: Make this a method + if(self.textLines.length < 1) { + self.textLines = [ { text : '', eol : true } ]; + isLastLine = false; // resetting + } + + self.cursorPos.col = 0; + + var lastRow = self.redrawRows(self.cursorPos.row, self.dimens.height); + self.eraseRows(lastRow, self.dimens.height); + + // + // If we just deleted the last line in the buffer, move up + // + if(isLastLine) { + self.cursorEndOfPreviousLine(); + } else { + self.moveClientCursorToCursorPos(); + } + } + }; + + this.insertCharactersInText = function(c, index, col) { + const prevTextLength = self.getTextLength(index); + let editingEol = self.cursorPos.col === prevTextLength; + + self.textLines[index].text = [ + self.textLines[index].text.slice(0, col), + c, + self.textLines[index].text.slice(col) + ].join(''); + + self.cursorPos.col += c.length; + + if(self.getTextLength(index) > self.dimens.width) { + // + // Update word wrapping and |cursorOffset| if the cursor + // was within the bounds of the wrapped text + // + let cursorOffset; + const lastCol = self.cursorPos.col - c.length; + const firstWrapRange = self.updateTextWordWrap(index); + if(lastCol >= firstWrapRange.start && lastCol <= firstWrapRange.end) { + cursorOffset = self.cursorPos.col - firstWrapRange.start; + editingEol = true; //override + } else { + cursorOffset = firstWrapRange.end; + } + + // redraw from current row to end of visible area + self.redrawRows(self.cursorPos.row, self.dimens.height); + + // If we're editing mid, we're done here. Else, we need to + // move the cursor to the new editing position after a wrap + if(editingEol) { + self.cursorBeginOfNextLine(); + self.cursorPos.col += cursorOffset; + self.client.term.rawWrite(ansi.right(cursorOffset)); + } else { + // adjust cursor after drawing new rows + const absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col); + self.client.term.rawWrite(ansi.goto(absPos.row, absPos.col)); + } + } else { + // + // We must only redraw from col -> end of current visible line + // + const absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col); + const renderText = self.getRenderText(index).slice(self.cursorPos.col - c.length); + + self.client.term.write( + `${ansi.hideCursor()}${self.getSGRFor('text')}${renderText}${ansi.goto(absPos.row, absPos.col)}${ansi.showCursor()}`, + false // convertLineFeeds + ); + } + }; + + this.getRemainingTabWidth = function(col) { + if(!_.isNumber(col)) { + col = self.cursorPos.col; + } + return self.tabWidth - (col % self.tabWidth); + }; + + this.calculateTabStops = function() { + self.tabStops = [ 0 ]; + var col = 0; + while(col < self.dimens.width) { + col += self.getRemainingTabWidth(col); + self.tabStops.push(col); + } + }; + + this.getNextTabStop = function(col) { + var i = self.tabStops.length; + while(self.tabStops[--i] > col); + return self.tabStops[++i]; + }; + + this.getPrevTabStop = function(col) { + var i = self.tabStops.length; + while(self.tabStops[--i] >= col); + return self.tabStops[i]; + }; + + this.expandTab = function(col, expandChar) { + expandChar = expandChar || ' '; + return new Array(self.getRemainingTabWidth(col)).join(expandChar); + }; + + this.wordWrapSingleLine = function(line, tabHandling = 'expand') { + return wordWrapText( + line, + { + width : self.dimens.width, + tabHandling : tabHandling, + tabWidth : self.tabWidth, + tabChar : '\t', + } + ); + }; + + this.setTextLines = function(lines, index, termWithEol) { + if(0 === index && (0 === self.textLines.length || (self.textLines.length === 1 && '' === self.textLines[0].text) )) { + // quick path: just set the things + self.textLines = lines.slice(0, -1).map(l => { + return { text : l }; + }).concat( { text : lines[lines.length - 1], eol : termWithEol } ); + } else { + // insert somewhere in textLines... + if(index > self.textLines.length) { + // fill with empty + self.textLines.splice( + self.textLines.length, + 0, + ...Array.from( { length : index - self.textLines.length } ).map( () => { return { text : '' }; } ) + ); + } + + const newLines = lines.slice(0, -1).map(l => { + return { text : l }; + }).concat( { text : lines[lines.length - 1], eol : termWithEol } ); + + self.textLines.splice( + index, + 0, + ...newLines + ); + } + }; + + this.setAnsiWithOptions = function(ansi, options, cb) { + + function setLines(text) { + text = strUtil.splitTextAtTerms(text); + + let index = 0; + + text.forEach(line => { + self.setTextLines( [ line ], index, true); // true=termWithEol + index += 1; + }); + + self.cursorStartOfDocument(); + + if(cb) { + return cb(null); + } + } + + if(options.prepped) { + return setLines(ansi); + } + + ansiPrep( + ansi, + { + termWidth : options.termWidth || this.client.term.termWidth, + termHeight : options.termHeight || this.client.term.termHeight, + cols : this.dimens.width, + rows : 'auto', + startCol : this.position.col, + forceLineTerm : options.forceLineTerm, + }, + (err, preppedAnsi) => { + return setLines(err ? ansi : preppedAnsi); + } + ); + }; + + this.insertRawText = function(text, index, col) { + // + // Perform the following on |text|: + // * Normalize various line feed formats -> \n + // * Remove some control characters (e.g. \b) + // * Word wrap lines such that they fit in the visible workspace. + // Each actual line will then take 1:n elements in textLines[]. + // * Each tab will be appropriately expanded and take 1:n \t + // characters. This allows us to know when we're in tab space + // when doing cursor movement/etc. + // + // + // Try to handle any possible newline that can be fed to us. + // See http://stackoverflow.com/questions/5034781/js-regex-to-split-by-line + // + // :TODO: support index/col insertion point + + if(_.isNumber(index)) { + if(_.isNumber(col)) { + // + // Modify text to have information from index + // before and and after column + // + // :TODO: Need to clean this string (e.g. collapse tabs) + text = self.textLines; + + // :TODO: Remove original line @ index + } + } else { + index = self.textLines.length; + } + + text = strUtil.splitTextAtTerms(text); + + let wrapped; + text.forEach(line => { + wrapped = self.wordWrapSingleLine(line, 'expand').wrapped; + + self.setTextLines(wrapped, index, true); // true=termWithEol + index += wrapped.length; + }); + }; + + this.getAbsolutePosition = function(row, col) { + return { + row : self.position.row + row, + col : self.position.col + col, + }; + }; + + this.moveClientCursorToCursorPos = function() { + var absPos = self.getAbsolutePosition(self.cursorPos.row, self.cursorPos.col); + self.client.term.rawWrite(ansi.goto(absPos.row, absPos.col)); + }; + + + this.keyPressCharacter = function(c) { + var index = self.getTextLinesIndex(); + + // + // :TODO: stuff that needs to happen + // * Break up into smaller methods + // * Even in overtype mode, word wrapping must apply if past bounds + // * A lot of this can be used for backspacing also + // * See how Sublime treats tabs in *non* overtype mode... just overwrite them? + // + // + + if(self.overtypeMode) { + // :TODO: special handing for insert over eol mark? + self.replaceCharacterInText(c, index, self.cursorPos.col); + self.cursorPos.col++; + self.client.term.write(c); + } else { + self.insertCharactersInText(c, index, self.cursorPos.col); + } + + self.emitEditPosition(); + }; + + this.keyPressUp = function() { + if(self.cursorPos.row > 0) { + self.cursorPos.row--; + self.client.term.rawWrite(ansi.up()); + + if(!self.adjustCursorToNextTab('up')) { + self.adjustCursorIfPastEndOfLine(false); + } + } else { + self.scrollDocumentDown(); + self.adjustCursorIfPastEndOfLine(true); + } + + self.emitEditPosition(); + }; + + this.keyPressDown = function() { + var lastVisibleRow = Math.min( + self.dimens.height, + (self.textLines.length - self.topVisibleIndex)) - 1; + + if(self.cursorPos.row < lastVisibleRow) { + self.cursorPos.row++; + self.client.term.rawWrite(ansi.down()); + + if(!self.adjustCursorToNextTab('down')) { + self.adjustCursorIfPastEndOfLine(false); + } + } else { + self.scrollDocumentUp(); + self.adjustCursorIfPastEndOfLine(true); + } + + self.emitEditPosition(); + }; + + this.keyPressLeft = function() { + if(self.cursorPos.col > 0) { + var prevCharIsTab = self.isTab(); + + self.cursorPos.col--; + self.client.term.rawWrite(ansi.left()); + + if(prevCharIsTab) { + self.adjustCursorToNextTab('left'); + } + } else { + self.cursorEndOfPreviousLine(); + } + + self.emitEditPosition(); + }; + + this.keyPressRight = function() { + var eolColumn = self.getTextEndOfLineColumn(); + if(self.cursorPos.col < eolColumn) { + var prevCharIsTab = self.isTab(); + + self.cursorPos.col++; + self.client.term.rawWrite(ansi.right()); + + if(prevCharIsTab) { + self.adjustCursorToNextTab('right'); + } + } else { + self.cursorBeginOfNextLine(); + } + + self.emitEditPosition(); + }; + + this.keyPressHome = function() { + var firstNonWhitespace = self.getVisibleText().search(/\S/); + if(-1 !== firstNonWhitespace) { + self.cursorPos.col = firstNonWhitespace; + } else { + self.cursorPos.col = 0; + } + self.moveClientCursorToCursorPos(); + + self.emitEditPosition(); + }; + + this.keyPressEnd = function() { + self.cursorPos.col = self.getTextEndOfLineColumn(); + self.moveClientCursorToCursorPos(); + self.emitEditPosition(); + }; + + this.keyPressPageUp = function() { + if(self.topVisibleIndex > 0) { + self.topVisibleIndex = Math.max(0, self.topVisibleIndex - self.dimens.height); + self.redraw(); + self.adjustCursorIfPastEndOfLine(true); + } else { + self.cursorPos.row = 0; + self.moveClientCursorToCursorPos(); // :TODO: ajust if eol, etc. + } + + self.emitEditPosition(); + }; + + this.keyPressPageDown = function() { + var linesBelow = self.getRemainingLinesBelowRow(); + if(linesBelow > 0) { + self.topVisibleIndex += Math.min(linesBelow, self.dimens.height); + self.redraw(); + self.adjustCursorIfPastEndOfLine(true); + } + + self.emitEditPosition(); + }; + + this.keyPressLineFeed = function() { + // + // Break up text from cursor position, redraw, and update cursor + // position to start of next line + // + var index = self.getTextLinesIndex(); + var nextEolIndex = self.getNextEndOfLineIndex(index); + var text = self.getContiguousText(index, nextEolIndex); + const newLines = self.wordWrapSingleLine(text.slice(self.cursorPos.col), 'tabsIntact').wrapped; + + newLines.unshift( { text : text.slice(0, self.cursorPos.col), eol : true } ); + for(var i = 1; i < newLines.length; ++i) { + newLines[i] = { text : newLines[i] }; + } + newLines[newLines.length - 1].eol = true; + + Array.prototype.splice.apply( + self.textLines, + [ index, (nextEolIndex - index) + 1 ].concat(newLines)); + + // redraw from current row to end of visible area + self.redrawRows(self.cursorPos.row, self.dimens.height); + self.cursorBeginOfNextLine(); + + self.emitEditPosition(); + }; + + this.keyPressInsert = function() { + self.toggleTextEditMode(); + }; + + this.keyPressTab = function() { + var index = self.getTextLinesIndex(); + self.insertCharactersInText(self.expandTab(self.cursorPos.col, '\t') + '\t', index, self.cursorPos.col); + + self.emitEditPosition(); + }; + + this.keyPressBackspace = function() { + if(self.cursorPos.col >= 1) { + // + // Don't want to delete character at cursor, but rather the character + // to the left of the cursor! + // + self.cursorPos.col -= 1; + + var index = self.getTextLinesIndex(); + var count; + + if(self.isTab()) { + var col = self.cursorPos.col; + var prevTabStop = self.getPrevTabStop(self.cursorPos.col); + while(col >= prevTabStop) { + if(!self.isTab(index, col)) { + break; + } + --col; + } + + count = (self.cursorPos.col - col); + } else { + count = 1; + } + + self.removeCharactersFromText( + index, + self.cursorPos.col, + 'backspace', + count); + } else { + // + // Delete character at end of line previous. + // * This may be a eol marker + // * Word wrapping will need re-applied + // + // :TODO: apply word wrapping such that text can be re-adjusted if it can now fit on prev + self.keyPressLeft(); // same as hitting left - jump to previous line + //self.keyPressBackspace(); + } + + self.emitEditPosition(); + }; + + this.keyPressDelete = function() { + const lineIndex = self.getTextLinesIndex(); + + if(0 === self.cursorPos.col && 0 === self.textLines[lineIndex].text.length && self.textLines.length > 0) { + // + // Start of line and nothing left. Just delete the line + // + self.removeCharactersFromText( + lineIndex, + 0, + 'delete line' + ); + } else { + self.removeCharactersFromText( + lineIndex, + self.cursorPos.col, + 'delete', + 1 + ); + } + + self.emitEditPosition(); + }; + + this.keyPressDeleteLine = function() { + if(self.textLines.length > 0) { + self.removeCharactersFromText( + self.getTextLinesIndex(), + 0, + 'delete line'); + } + + self.emitEditPosition(); + }; + + this.adjustCursorIfPastEndOfLine = function(forceUpdate) { + var eolColumn = self.getTextEndOfLineColumn(); + if(self.cursorPos.col > eolColumn) { + self.cursorPos.col = eolColumn; + forceUpdate = true; + } + + if(forceUpdate) { + self.moveClientCursorToCursorPos(); + } + }; + + this.adjustCursorToNextTab = function(direction) { + if(self.isTab()) { + var move; + switch(direction) { + // + // Next tabstop to the right + // + case 'right' : + move = self.getNextTabStop(self.cursorPos.col) - self.cursorPos.col; + self.cursorPos.col += move; + self.client.term.rawWrite(ansi.right(move)); + break; + + // + // Next tabstop to the left + // + case 'left' : + move = self.cursorPos.col - self.getPrevTabStop(self.cursorPos.col); + self.cursorPos.col -= move; + self.client.term.rawWrite(ansi.left(move)); + break; + + case 'up' : + case 'down' : + // + // Jump to the tabstop nearest the cursor + // + var newCol = self.tabStops.reduce(function r(prev, curr) { + return (Math.abs(curr - self.cursorPos.col) < Math.abs(prev - self.cursorPos.col) ? curr : prev); + }); + + if(newCol > self.cursorPos.col) { + move = newCol - self.cursorPos.col; + self.cursorPos.col += move; + self.client.term.rawWrite(ansi.right(move)); + } else if(newCol < self.cursorPos.col) { + move = self.cursorPos.col - newCol; + self.cursorPos.col -= move; + self.client.term.rawWrite(ansi.left(move)); + } + break; + } + + return true; + } + return false; // did not fall on a tab + }; + + this.cursorStartOfDocument = function() { + self.topVisibleIndex = 0; + self.cursorPos = { row : 0, col : 0 }; + + self.redraw(); + self.moveClientCursorToCursorPos(); + }; + + this.cursorEndOfDocument = function() { + self.topVisibleIndex = Math.max(self.textLines.length - self.dimens.height, 0); + self.cursorPos.row = (self.textLines.length - self.topVisibleIndex) - 1; + self.cursorPos.col = self.getTextEndOfLineColumn(); + + self.redraw(); + self.moveClientCursorToCursorPos(); + }; + + this.cursorBeginOfNextLine = function() { + // e.g. when scrolling right past eol + var linesBelow = self.getRemainingLinesBelowRow(); + + if(linesBelow > 0) { + var lastVisibleRow = Math.min(self.dimens.height, self.textLines.length) - 1; + if(self.cursorPos.row < lastVisibleRow) { + self.cursorPos.row++; + } else { + self.scrollDocumentUp(); + } + self.keyPressHome(); // same as pressing 'home' + } + }; + + this.cursorEndOfPreviousLine = function() { + // e.g. when scrolling left past start of line + var moveToEnd; + if(self.cursorPos.row > 0) { + self.cursorPos.row--; + moveToEnd = true; + } else if(self.topVisibleIndex > 0) { + self.scrollDocumentDown(); + moveToEnd = true; + } + + if(moveToEnd) { + self.keyPressEnd(); // same as pressing 'end' + } + }; + + /* + this.cusorEndOfNextLine = function() { + var linesBelow = self.getRemainingLinesBelowRow(); + + if(linesBelow > 0) { + var lastVisibleRow = Math.min(self.dimens.height, self.textLines.length) - 1; + if(self.cursorPos.row < lastVisibleRow) { + self.cursorPos.row++; + } else { + self.scrollDocumentUp(); + } + self.keyPressEnd(); // same as pressing 'end' + } + }; + */ + + this.scrollDocumentUp = function() { + // + // Note: We scroll *up* when the cursor goes *down* beyond + // the visible area! + // + var linesBelow = self.getRemainingLinesBelowRow(); + if(linesBelow > 0) { + self.topVisibleIndex++; + self.redraw(); + } + }; + + this.scrollDocumentDown = function() { + // + // Note: We scroll *down* when the cursor goes *up* beyond + // the visible area! + // + if(self.topVisibleIndex > 0) { + self.topVisibleIndex--; + self.redraw(); + } + }; + + this.emitEditPosition = function() { + self.emit('edit position', self.getEditPosition()); + }; + + this.toggleTextEditMode = function() { + self.overtypeMode = !self.overtypeMode; + self.emit('text edit mode', self.getTextEditMode()); + }; + + this.insertRawText(''); // init to blank/empty } require('util').inherits(MultiLineEditTextView, View); MultiLineEditTextView.prototype.setWidth = function(width) { - MultiLineEditTextView.super_.prototype.setWidth.call(this, width); + MultiLineEditTextView.super_.prototype.setWidth.call(this, width); - this.calculateTabStops(); + this.calculateTabStops(); }; MultiLineEditTextView.prototype.redraw = function() { - MultiLineEditTextView.super_.prototype.redraw.call(this); + MultiLineEditTextView.super_.prototype.redraw.call(this); - this.redrawVisibleArea(); + this.redrawVisibleArea(); }; MultiLineEditTextView.prototype.setFocus = function(focused) { - this.client.term.rawWrite(this.getSGRFor('text')); - this.moveClientCursorToCursorPos(); + this.client.term.rawWrite(this.getSGRFor('text')); + this.moveClientCursorToCursorPos(); - MultiLineEditTextView.super_.prototype.setFocus.call(this, focused); + MultiLineEditTextView.super_.prototype.setFocus.call(this, focused); }; -MultiLineEditTextView.prototype.setText = function(text) { - this.textLines = [ ]; - this.addText(text); - /*this.insertRawText(text); +MultiLineEditTextView.prototype.setText = function(text, options = { scrollMode : 'default' } ) { + this.textLines = [ ]; + this.addText(text, options); + /*this.insertRawText(text); - if(this.isEditMode()) { - this.cursorEndOfDocument(); - } else if(this.isPreviewMode()) { - this.cursorStartOfDocument(); - }*/ + if(this.isEditMode()) { + this.cursorEndOfDocument(); + } else if(this.isPreviewMode()) { + this.cursorStartOfDocument(); + }*/ }; MultiLineEditTextView.prototype.setAnsi = function(ansi, options = { prepped : false }, cb) { - this.textLines = [ ]; - return this.setAnsiWithOptions(ansi, options, cb); + this.textLines = [ ]; + return this.setAnsiWithOptions(ansi, options, cb); }; -MultiLineEditTextView.prototype.addText = function(text) { - this.insertRawText(text); +MultiLineEditTextView.prototype.addText = function(text, options = { scrollMode : 'default' }) { + this.insertRawText(text); - if(this.isEditMode() || this.autoScroll) { - this.cursorEndOfDocument(); - } else { - this.cursorStartOfDocument(); - } + switch(options.scrollMode) { + case 'default' : + if(this.isEditMode() || this.autoScroll) { + this.cursorEndOfDocument(); + } else { + this.cursorStartOfDocument(); + } + break; + + case 'top' : + case 'start' : + this.cursorStartOfDocument(); + break; + + case 'end' : + case 'bottom' : + this.cursorEndOfDocument(); + break; + } }; MultiLineEditTextView.prototype.getData = function(options = { forceLineTerms : false } ) { - return this.getOutputText(0, this.textLines.length, '\r\n', options); + return this.getOutputText(0, this.textLines.length, '\r\n', options); }; MultiLineEditTextView.prototype.setPropertyValue = function(propName, value) { - switch(propName) { - case 'mode' : - this.mode = value; - if('preview' === value && !this.specialKeyMap.next) { - this.specialKeyMap.next = [ 'tab' ]; - } - break; + switch(propName) { + case 'mode' : + this.mode = value; + if('preview' === value && !this.specialKeyMap.next) { + this.specialKeyMap.next = [ 'tab' ]; + } + break; - case 'autoScroll' : - this.autoScroll = value; - break; + case 'autoScroll' : + this.autoScroll = value; + break; - case 'tabSwitchesView' : - this.tabSwitchesView = value; - this.specialKeyMap.next = this.specialKeyMap.next || []; - this.specialKeyMap.next.push('tab'); - break; - } + case 'tabSwitchesView' : + this.tabSwitchesView = value; + this.specialKeyMap.next = this.specialKeyMap.next || []; + this.specialKeyMap.next.push('tab'); + break; + } - MultiLineEditTextView.super_.prototype.setPropertyValue.call(this, propName, value); + MultiLineEditTextView.super_.prototype.setPropertyValue.call(this, propName, value); }; const HANDLED_SPECIAL_KEYS = [ - 'up', 'down', 'left', 'right', - 'home', 'end', - 'page up', 'page down', - 'line feed', - 'insert', - 'tab', - 'backspace', 'del', - 'delete line', + 'up', 'down', 'left', 'right', + 'home', 'end', + 'page up', 'page down', + 'line feed', + 'insert', + 'tab', + 'backspace', 'delete', + 'delete line', ]; const PREVIEW_MODE_KEYS = [ - 'up', 'down', 'page up', 'page down' + 'up', 'down', 'page up', 'page down' ]; MultiLineEditTextView.prototype.onKeyPress = function(ch, key) { - const self = this; - let handled; + const self = this; + let handled; - if(key) { - HANDLED_SPECIAL_KEYS.forEach(function aKey(specialKey) { - if(self.isKeyMapped(specialKey, key.name)) { + if(key) { + HANDLED_SPECIAL_KEYS.forEach(function aKey(specialKey) { + if(self.isKeyMapped(specialKey, key.name)) { - if(self.isPreviewMode() && -1 === PREVIEW_MODE_KEYS.indexOf(specialKey)) { - return; - } + if(self.isPreviewMode() && -1 === PREVIEW_MODE_KEYS.indexOf(specialKey)) { + return; + } - if('tab' !== key.name || !self.tabSwitchesView) { - self[_.camelCase('keyPress ' + specialKey)](); - handled = true; - } - } - }); - } + if('tab' !== key.name || !self.tabSwitchesView) { + self[_.camelCase('keyPress ' + specialKey)](); + handled = true; + } + } + }); + } - if(self.isEditMode() && ch && strUtil.isPrintable(ch)) { - this.keyPressCharacter(ch); - } + if(self.isEditMode() && ch && strUtil.isPrintable(ch)) { + this.keyPressCharacter(ch); + } - if(!handled) { - MultiLineEditTextView.super_.prototype.onKeyPress.call(this, ch, key); - } + if(!handled) { + MultiLineEditTextView.super_.prototype.onKeyPress.call(this, ch, key); + } }; MultiLineEditTextView.prototype.scrollUp = function() { - this.scrollDocumentUp(); + this.scrollDocumentUp(); }; MultiLineEditTextView.prototype.scrollDown = function() { - this.scrollDocumentDown(); + this.scrollDocumentDown(); }; MultiLineEditTextView.prototype.deleteLine = function(line) { - this.textLines.splice(line, 1); + this.textLines.splice(line, 1); }; MultiLineEditTextView.prototype.getLineCount = function() { - return this.textLines.length; + return this.textLines.length; }; MultiLineEditTextView.prototype.getTextEditMode = function() { - return this.overtypeMode ? 'overtype' : 'insert'; + return this.overtypeMode ? 'overtype' : 'insert'; }; MultiLineEditTextView.prototype.getEditPosition = function() { - var currentIndex = this.getTextLinesIndex() + 1; + var currentIndex = this.getTextLinesIndex() + 1; - return { - row : this.getTextLinesIndex(this.cursorPos.row), - col : this.cursorPos.col, - percent : Math.floor(((currentIndex / this.textLines.length) * 100)), - below : this.getRemainingLinesBelowRow(), - }; + return { + row : this.getTextLinesIndex(this.cursorPos.row), + col : this.cursorPos.col, + percent : Math.floor(((currentIndex / this.textLines.length) * 100)), + below : this.getRemainingLinesBelowRow(), + }; }; diff --git a/core/my_messages.js b/core/my_messages.js new file mode 100644 index 00000000..ed1d3fe1 --- /dev/null +++ b/core/my_messages.js @@ -0,0 +1,65 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const Message = require('./message.js'); +const UserProps = require('./user_property.js'); +const { + filterMessageListByReadACS +} = require('./message_area.js'); + +exports.moduleInfo = { + name : 'My Messages', + desc : 'Finds messages addressed to the current user.', + author : 'NuSkooler', +}; + +exports.getModule = class MyMessagesModule extends MenuModule { + constructor(options) { + super(options); + } + + initSequence() { + const filter = { + toUserName : [ this.client.user.username, this.client.user.getProperty(UserProps.RealName) ], + sort : 'modTimestamp', + resultType : 'messageList', + limit : 1024 * 16, // we want some sort of limit... + }; + + Message.findMessages(filter, (err, messageList) => { + if(err) { + this.client.log.warn( { error : err.message }, 'Error finding messages addressed to current user'); + return this.prevMenu(); + } + + // don't include results without ACS + this.messageList = filterMessageListByReadACS(this.client, messageList); + + this.finishedLoading(); + }); + } + + finishedLoading() { + if(!this.messageList || 0 === this.messageList.length) { + return this.gotoMenu( + this.menuConfig.config.noResultsMenu || 'messageSearchNoResults', + { menuFlags : [ 'popParent' ] } + ); + } + + const menuOpts = { + extraArgs : { + messageList : this.messageList, + noUpdateLastReadId : true + }, + menuFlags : [ 'popParent' ], + }; + + return this.gotoMenu( + this.menuConfig.config.messageListMenu || 'messageAreaMessageList', + menuOpts + ); + } +}; diff --git a/core/new_scan.js b/core/new_scan.js index 3c3f1371..ac741ab3 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -1,261 +1,274 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const msgArea = require('./message_area.js'); -const MenuModule = require('./menu_module.js').MenuModule; -const ViewController = require('./view_controller.js').ViewController; -const stringFormat = require('./string_format.js'); -const FileEntry = require('./file_entry.js'); -const FileBaseFilters = require('./file_base_filter.js'); -const Errors = require('./enig_error.js').Errors; +// ENiGMA½ +const msgArea = require('./message_area.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const stringFormat = require('./string_format.js'); +const FileEntry = require('./file_entry.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const Errors = require('./enig_error.js').Errors; +const { getAvailableFileAreaTags } = require('./file_base_area.js'); +const { valueAsArray } = require('./misc_util.js'); -// deps -const _ = require('lodash'); -const async = require('async'); +// deps +const _ = require('lodash'); +const async = require('async'); exports.moduleInfo = { - name : 'New Scan', - desc : 'Performs a new scan against various areas of the system', - author : 'NuSkooler', + name : 'New Scan', + desc : 'Performs a new scan against various areas of the system', + author : 'NuSkooler', }; /* * :TODO: * * User configurable new scan: Area selection (avail from messages area) (sep module) * * Add status TL/VM (either/both should update if present) - * * - + * * + */ const MciCodeIds = { - ScanStatusLabel : 1, // TL1 - ScanStatusList : 2, // VM2 (appends) + ScanStatusLabel : 1, // TL1 + ScanStatusList : 2, // VM2 (appends) }; const Steps = { - MessageConfs : 'messageConferences', - FileBase : 'fileBase', - - Finished : 'finished', + MessageConfs : 'messageConferences', + FileBase : 'fileBase', + + Finished : 'finished', }; exports.getModule = class NewScanModule extends MenuModule { - constructor(options) { - super(options); + constructor(options) { + super(options); - this.newScanFullExit = _.get(options, 'lastMenuResult.fullExit', false); + this.newScanFullExit = _.get(options, 'lastMenuResult.fullExit', false); - this.currentStep = Steps.MessageConfs; - this.currentScanAux = {}; + this.currentStep = Steps.MessageConfs; + this.currentScanAux = {}; - // :TODO: Make this conf/area specific: - const config = this.menuConfig.config; - this.scanStartFmt = config.scanStartFmt || 'Scanning {confName} - {areaName}...'; - this.scanFinishNoneFmt = config.scanFinishNoneFmt || 'Nothing new'; - this.scanFinishNewFmt = config.scanFinishNewFmt || '{count} entries found'; - this.scanCompleteMsg = config.scanCompleteMsg || 'Finished newscan'; - } + // :TODO: Make this conf/area specific: + // :TODO: Use newer custom info format - TL10+ + const config = this.menuConfig.config; + this.scanStartFmt = config.scanStartFmt || 'Scanning {confName} - {areaName}...'; + this.scanFinishNoneFmt = config.scanFinishNoneFmt || 'Nothing new'; + this.scanFinishNewFmt = config.scanFinishNewFmt || '{count} entries found'; + this.scanCompleteMsg = config.scanCompleteMsg || 'Finished newscan'; + } - updateScanStatus(statusText) { - this.setViewText('allViews', MciCodeIds.ScanStatusLabel, statusText); - } - - newScanMessageConference(cb) { + updateScanStatus(statusText) { + this.setViewText('allViews', MciCodeIds.ScanStatusLabel, statusText); + } + + newScanMessageConference(cb) { // lazy init - if(!this.sortedMessageConfs) { - const getAvailOpts = { includeSystemInternal : true }; // find new private messages, bulletins, etc. + if(!this.sortedMessageConfs) { + const getAvailOpts = { includeSystemInternal : true }; // find new private messages, bulletins, etc. - this.sortedMessageConfs = _.map(msgArea.getAvailableMessageConferences(this.client, getAvailOpts), (v, k) => { - return { - confTag : k, - conf : v, - }; - }); + this.sortedMessageConfs = _.map(msgArea.getAvailableMessageConferences(this.client, getAvailOpts), (v, k) => { + return { + confTag : k, + conf : v, + }; + }); - // - // Sort conferences by name, other than 'system_internal' which should - // always come first such that we display private mails/etc. before - // other conferences & areas - // - this.sortedMessageConfs.sort((a, b) => { - if('system_internal' === a.confTag) { - return -1; - } else { - return a.conf.name.localeCompare(b.conf.name, { sensitivity : false, numeric : true } ); - } - }); + // + // Sort conferences by name, other than 'system_internal' which should + // always come first such that we display private mails/etc. before + // other conferences & areas + // + this.sortedMessageConfs.sort((a, b) => { + if('system_internal' === a.confTag) { + return -1; + } else { + return a.conf.name.localeCompare(b.conf.name, { sensitivity : false, numeric : true } ); + } + }); - this.currentScanAux.conf = this.currentScanAux.conf || 0; - this.currentScanAux.area = this.currentScanAux.area || 0; - } - - const currentConf = this.sortedMessageConfs[this.currentScanAux.conf]; + this.currentScanAux.conf = this.currentScanAux.conf || 0; + this.currentScanAux.area = this.currentScanAux.area || 0; + } - this.newScanMessageArea(currentConf, () => { - if(this.sortedMessageConfs.length > this.currentScanAux.conf + 1) { - this.currentScanAux.conf += 1; - this.currentScanAux.area = 0; - - return this.newScanMessageConference(cb); // recursive to next conf - } + const currentConf = this.sortedMessageConfs[this.currentScanAux.conf]; - this.updateScanStatus(this.scanCompleteMsg); - return cb(Errors.DoesNotExist('No more conferences')); - }); - } - - newScanMessageArea(conf, cb) { + this.newScanMessageArea(currentConf, () => { + if(this.sortedMessageConfs.length > this.currentScanAux.conf + 1) { + this.currentScanAux.conf += 1; + this.currentScanAux.area = 0; + + return this.newScanMessageConference(cb); // recursive to next conf + } + + this.updateScanStatus(this.scanCompleteMsg); + return cb(Errors.DoesNotExist('No more conferences')); + }); + } + + newScanMessageArea(conf, cb) { // :TODO: it would be nice to cache this - must be done by conf! - const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : this.client } ); - const currentArea = sortedAreas[this.currentScanAux.area]; - - // - // Scan and update index until we find something. If results are found, - // we'll goto the list module & show them. - // - const self = this; - async.waterfall( - [ - function checkAndUpdateIndex(callback) { - // Advance to next area if possible - if(sortedAreas.length >= self.currentScanAux.area + 1) { - self.currentScanAux.area += 1; - return callback(null); - } else { - self.updateScanStatus(self.scanCompleteMsg); - return callback(Errors.DoesNotExist('No more areas')); // this will stop our scan - } - }, - function updateStatusScanStarted(callback) { - self.updateScanStatus(stringFormat(self.scanStartFmt, { - confName : conf.conf.name, - confDesc : conf.conf.desc, - areaName : currentArea.area.name, - areaDesc : currentArea.area.desc - })); - return callback(null); - }, - function getNewMessagesCountInArea(callback) { - msgArea.getNewMessageCountInAreaForUser( - self.client.user.userId, currentArea.areaTag, (err, newMessageCount) => { - callback(err, newMessageCount); - } - ); - }, - function displayMessageList(newMessageCount) { - if(newMessageCount <= 0) { - return self.newScanMessageArea(conf, cb); // next area, if any - } + const omitMessageAreaTags = valueAsArray(_.get(this, 'menuConfig.config.omitMessageAreaTags', [])); + const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : this.client } ).filter(area => { + return !omitMessageAreaTags.includes(area.areaTag); + }); + const currentArea = sortedAreas[this.currentScanAux.area]; - const nextModuleOpts = { - extraArgs: { - messageAreaTag : currentArea.areaTag, - } - }; + // + // Scan and update index until we find something. If results are found, + // we'll goto the list module & show them. + // + const self = this; + async.waterfall( + [ + function checkAndUpdateIndex(callback) { + // Advance to next area if possible + if(sortedAreas.length >= self.currentScanAux.area + 1) { + self.currentScanAux.area += 1; + return callback(null); + } else { + self.updateScanStatus(self.scanCompleteMsg); + return callback(Errors.DoesNotExist('No more areas')); // this will stop our scan + } + }, + function updateStatusScanStarted(callback) { + self.updateScanStatus(stringFormat(self.scanStartFmt, { + confName : conf.conf.name, + confDesc : conf.conf.desc, + areaName : currentArea.area.name, + areaDesc : currentArea.area.desc + })); + return callback(null); + }, + function getNewMessagesCountInArea(callback) { + msgArea.getNewMessageCountInAreaForUser( + self.client.user.userId, currentArea.areaTag, (err, newMessageCount) => { + callback(err, newMessageCount); + } + ); + }, + function displayMessageList(newMessageCount) { + if(newMessageCount <= 0) { + return self.newScanMessageArea(conf, cb); // next area, if any + } - return self.gotoMenu(self.menuConfig.config.newScanMessageList || 'newScanMessageList', nextModuleOpts); - } - ], - err => { - return cb(err); - } - ); - } + const nextModuleOpts = { + extraArgs: { + messageAreaTag : currentArea.areaTag, + } + }; - newScanFileBase(cb) { - // :TODO: add in steps - FileEntry.findFiles( - { newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user) }, - (err, fileIds) => { - if(err || 0 === fileIds.length) { - return cb(err ? err : Errors.DoesNotExist('No more new files')); - } + return self.gotoMenu(self.menuConfig.config.newScanMessageList || 'newScanMessageList', nextModuleOpts); + } + ], + err => { + return cb(err); + } + ); + } - FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[0] ); + newScanFileBase(cb) { + // :TODO: add in steps + const omitFileAreaTags = valueAsArray(_.get(this, 'menuConfig.config.omitFileAreaTags', [])); + const filterCriteria = { + newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user), + areaTag : getAvailableFileAreaTags(this.client).filter(ft => !omitFileAreaTags.includes(ft)), + order : 'ascending', // oldest first + }; - const menuOpts = { - extraArgs : { - fileList : fileIds, - }, - }; + FileEntry.findFiles( + filterCriteria, + (err, fileIds) => { + if(err || 0 === fileIds.length) { + return cb(err ? err : Errors.DoesNotExist('No more new files')); + } - return this.gotoMenu(this.menuConfig.config.newScanFileBaseList || 'newScanFileBaseList', menuOpts); - } - ); - } + FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[fileIds.length - 1] ); - getSaveState() { - return { - currentStep : this.currentStep, - currentScanAux : this.currentScanAux, - }; - } + const menuOpts = { + extraArgs : { + fileList : fileIds, + }, + }; - restoreSavedState(savedState) { - this.currentStep = savedState.currentStep; - this.currentScanAux = savedState.currentScanAux; - } + return this.gotoMenu(this.menuConfig.config.newScanFileBaseList || 'newScanFileBaseList', menuOpts); + } + ); + } - performScanCurrentStep(cb) { - switch(this.currentStep) { - case Steps.MessageConfs : - this.newScanMessageConference( () => { - this.currentStep = Steps.FileBase; - return this.performScanCurrentStep(cb); - }); - break; - - case Steps.FileBase : - this.newScanFileBase( () => { - this.currentStep = Steps.Finished; - return this.performScanCurrentStep(cb); - }); - break; - - default : return cb(null); - } - } + getSaveState() { + return { + currentStep : this.currentStep, + currentScanAux : this.currentScanAux, + }; + } - mciReady(mciData, cb) { - if(this.newScanFullExit) { - // user has canceled the entire scan @ message list view - return cb(null); - } + restoreSavedState(savedState) { + this.currentStep = savedState.currentStep; + this.currentScanAux = savedState.currentScanAux; + } - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } + performScanCurrentStep(cb) { + switch(this.currentStep) { + case Steps.MessageConfs : + this.newScanMessageConference( () => { + this.currentStep = Steps.FileBase; + return this.performScanCurrentStep(cb); + }); + break; - const self = this; - const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); + case Steps.FileBase : + this.newScanFileBase( () => { + this.currentStep = Steps.Finished; + return this.performScanCurrentStep(cb); + }); + break; - // :TODO: display scan step/etc. + default : return cb(null); + } + } - async.series( - [ - function loadFromConfig(callback) { - const loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - noInput : true, - }; + mciReady(mciData, cb) { + if(this.newScanFullExit) { + // user has canceled the entire scan @ message list view + return cb(null); + } - vc.loadFromMenuConfig(loadOpts, callback); - }, - function performCurrentStepScan(callback) { - return self.performScanCurrentStep(callback); - } - ], - err => { - if(err) { - self.client.log.error( { error : err.toString() }, 'Error during new scan'); - } - return cb(err); - } - ); - }); - } + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); + + // :TODO: display scan step/etc. + + async.series( + [ + function loadFromConfig(callback) { + const loadOpts = { + callingMenu : self, + mciMap : mciData.menu, + noInput : true, + }; + + vc.loadFromMenuConfig(loadOpts, callback); + }, + function performCurrentStepScan(callback) { + return self.performScanCurrentStep(callback); + } + ], + err => { + if(err) { + self.client.log.error( { error : err.toString() }, 'Error during new scan'); + } + return cb(err); + } + ); + }); + } }; diff --git a/core/node_msg.js b/core/node_msg.js new file mode 100644 index 00000000..bb64757c --- /dev/null +++ b/core/node_msg.js @@ -0,0 +1,220 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { + getActiveConnectionList, + getConnectionByNodeId, +} = require('./client_connections.js'); +const UserInterruptQueue = require('./user_interrupt_queue.js'); +const { getThemeArt } = require('./theme.js'); +const { pipeToAnsi } = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); +const { renderStringLength } = require('./string_util.js'); +const Events = require('./events.js'); + +// deps +const series = require('async/series'); +const _ = require('lodash'); +const async = require('async'); +const moment = require('moment'); + +exports.moduleInfo = { + name : 'Node Message', + desc : 'Multi-node messaging', + author : 'NuSkooler', +}; + +const FormIds = { + sendMessage : 0, +}; + +const MciViewIds = { + sendMessage : { + nodeSelect : 1, + message : 2, + preview : 3, + + customRangeStart : 10, + } +}; + +exports.getModule = class NodeMessageModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.menuMethods = { + sendMessage : (formData, extraArgs, cb) => { + const nodeId = this.nodeList[formData.value.node].node; // index from from -> node! + const message = _.get(formData.value, 'message', '').trim(); + + if(0 === renderStringLength(message)) { + return this.prevMenu(cb); + } + + this.createInterruptItem(message, (err, interruptItem) => { + if(-1 === nodeId) { + // ALL nodes + UserInterruptQueue.queue(interruptItem, { omit : this.client }); + } else { + const conn = getConnectionByNodeId(nodeId); + if(conn) { + UserInterruptQueue.queue(interruptItem, { clients : conn } ); + } + } + + Events.emit(Events.getSystemEvents().UserSendNodeMsg, { user : this.client.user, global : -1 === nodeId } ); + + return this.prevMenu(cb); + }); + }, + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + series( + [ + (callback) => { + return this.prepViewController('sendMessage', FormIds.sendMessage, mciData.menu, callback); + }, + (callback) => { + return this.validateMCIByViewIds( + 'sendMessage', + [ MciViewIds.sendMessage.nodeSelect, MciViewIds.sendMessage.message ], + callback + ); + }, + (callback) => { + const nodeSelectView = this.viewControllers.sendMessage.getView(MciViewIds.sendMessage.nodeSelect); + this.prepareNodeList(); + + nodeSelectView.on('index update', idx => { + this.nodeListSelectionIndexUpdate(idx); + }); + + nodeSelectView.setItems(this.nodeList); + nodeSelectView.redraw(); + this.nodeListSelectionIndexUpdate(0); + return callback(null); + }, + (callback) => { + const previewView = this.viewControllers.sendMessage.getView(MciViewIds.sendMessage.preview); + if(!previewView) { + return callback(null); // preview is optional + } + + const messageView = this.viewControllers.sendMessage.getView(MciViewIds.sendMessage.message); + let timerId; + messageView.on('key press', () => { + clearTimeout(timerId); + const focused = this.viewControllers.sendMessage.getFocusedView(); + if(focused === messageView) { + previewView.setText(messageView.getData()); + focused.setFocus(true); + } + }, 500); + } + ], + err => { + return cb(err); + } + ); + }); + } + + createInterruptItem(message, cb) { + const dateTimeFormat = this.config.dateTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat(); + + const textFormatObj = { + fromUserName : this.client.user.username, + fromRealName : this.client.user.properties.real_name, + fromNodeId : this.client.node, + message : message, + timestamp : moment().format(dateTimeFormat), + }; + + const messageFormat = + this.config.messageFormat || + 'Message from {fromUserName} on node {fromNodeId}:\r\n{message}'; + + const item = { + text : stringFormat(messageFormat, textFormatObj), + pause : true, + }; + + const getArt = (name, callback) => { + const spec = _.get(this.config, `art.${name}`); + if(!spec) { + return callback(null); + } + const getArtOpts = { + name : spec, + client : this.client, + random : false, + }; + getThemeArt(getArtOpts, (err, artInfo) => { + // ignore errors + return callback(artInfo ? artInfo.data : null); + }); + }; + + async.waterfall( + [ + (callback) => { + getArt('header', headerArt => { + return callback(null, headerArt); + }); + }, + (headerArt, callback) => { + getArt('footer', footerArt => { + return callback(null, headerArt, footerArt); + }); + }, + (headerArt, footerArt, callback) => { + if(headerArt || footerArt) { + item.contents = `${headerArt || ''}\r\n${pipeToAnsi(item.text)}\r\n${footerArt || ''}`; + } + return callback(null); + } + ], + err => { + return cb(err, item); + } + ); + } + + prepareNodeList() { + // standard node list with {text} field added for compliance + this.nodeList = [{ + text : '-ALL-', + // dummy fields: + node : -1, + authenticated : false, + userId : 0, + action : 'N/A', + userName : 'Everyone', + realName : 'All Users', + location : 'N/A', + affils : 'N/A', + timeOn : 'N/A', + }].concat(getActiveConnectionList(true) + .map(node => Object.assign(node, { text : -1 == node.node ? '-ALL-' : node.node.toString() } )) + ).filter(node => node.node !== this.client.node); // remove our client's node + this.nodeList.sort( (a, b) => a.node - b.node ); // sort by node + } + + nodeListSelectionIndexUpdate(idx) { + const node = this.nodeList[idx]; + if(!node) { + return; + } + this.updateCustomViewTextsWithFilter('sendMessage', MciViewIds.sendMessage.customRangeStart, node); + } +}; diff --git a/core/nua.js b/core/nua.js new file mode 100644 index 00000000..2cb4c26b --- /dev/null +++ b/core/nua.js @@ -0,0 +1,157 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const User = require('./user.js'); +const theme = require('./theme.js'); +const login = require('./system_menu_method.js').login; +const Config = require('./config.js').get; +const messageArea = require('./message_area.js'); +const { + getISOTimestampString +} = require('./database.js'); +const UserProps = require('./user_property.js'); + +// deps +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'NUA', + desc : 'New User Application', +}; + +const MciViewIds = { + userName : 1, + password : 9, + confirm : 10, + errMsg : 11, +}; + +exports.getModule = class NewUserAppModule extends MenuModule { + + constructor(options) { + super(options); + + const self = this; + + this.menuMethods = { + // + // Validation stuff + // + validatePassConfirmMatch : function(data, cb) { + const passwordView = self.viewControllers.menu.getView(MciViewIds.password); + return cb(passwordView.getData() === data ? null : new Error('Passwords do not match')); + }, + + viewValidationListener : function(err, cb) { + const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg); + let newFocusId; + + if(err) { + errMsgView.setText(err.message); + err.view.clearText(); + + if(err.view.getId() === MciViewIds.confirm) { + newFocusId = MciViewIds.password; + self.viewControllers.menu.getView(MciViewIds.password).clearText(); + } + } else { + errMsgView.clearText(); + } + + return cb(newFocusId); + }, + + + // + // Submit handlers + // + submitApplication : function(formData, extraArgs, cb) { + const newUser = new User(); + const config = Config(); + + newUser.username = formData.value.username; + + // + // We have to disable ACS checks for initial default areas as the user is not yet ready + // + let confTag = messageArea.getDefaultMessageConferenceTag(self.client, true); // true=disableAcsCheck + let areaTag = messageArea.getDefaultMessageAreaTagByConfTag(self.client, confTag, true); // true=disableAcsCheck + + // can't store undefined! + confTag = confTag || ''; + areaTag = areaTag || ''; + + newUser.properties = { + [ UserProps.RealName ] : formData.value.realName, + [ UserProps.Birthdate ] : getISOTimestampString(formData.value.birthdate), + [ UserProps.Sex ] : formData.value.sex, + [ UserProps.Location ] : formData.value.location, + [ UserProps.Affiliations ] : formData.value.affils, + [ UserProps.EmailAddress ] : formData.value.email, + [ UserProps.WebAddress ] : formData.value.web, + [ UserProps.AccountCreated ] : getISOTimestampString(), + + [ UserProps.MessageConfTag ] : confTag, + [ UserProps.MessageAreaTag ] : areaTag, + + [ UserProps.TermHeight ] : self.client.term.termHeight, + [ UserProps.TermWidth ] : self.client.term.termWidth, + + // :TODO: Other defaults + // :TODO: should probably have a place to create defaults/etc. + }; + + const defaultTheme = _.get(config, 'theme.default'); + if('*' === defaultTheme) { + newUser.properties[UserProps.ThemeId] = theme.getRandomTheme(); + } else { + newUser.properties[UserProps.ThemeId] = defaultTheme; + } + + // :TODO: User.create() should validate email uniqueness! + const createUserInfo = { + password : formData.value.password, + sessionId : self.client.session.uniqueId, // used for events/etc. + }; + newUser.create(createUserInfo, err => { + if(err) { + self.client.log.info( { error : err, username : formData.value.username }, 'New user creation failed'); + + self.gotoMenu(extraArgs.error, err => { + if(err) { + return self.prevMenu(cb); + } + return cb(null); + }); + } else { + self.client.log.info( { username : formData.value.username, userId : newUser.userId }, 'New user created'); + + // Cache SysOp information now + // :TODO: Similar to bbs.js. DRY + if(newUser.isSysOp()) { + config.general.sysOp = { + username : formData.value.username, + properties : newUser.properties, + }; + } + + if(User.AccountStatus.inactive === self.client.user.properties[UserProps.AccountStatus]) { + return self.gotoMenu(extraArgs.inactive, cb); + } else { + // + // If active now, we need to call login() to authenticate + // + return login(self, formData, extraArgs, cb); + } + } + }); + }, + }; + } + + mciReady(mciData, cb) { + return this.standardMCIReadyHandler(mciData, cb); + } +}; diff --git a/core/onelinerz.js b/core/onelinerz.js new file mode 100644 index 00000000..d840f731 --- /dev/null +++ b/core/onelinerz.js @@ -0,0 +1,319 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; + +const { + getModDatabasePath, + getTransactionDatabase +} = require('./database.js'); + +// deps +const sqlite3 = require('sqlite3'); +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); + +/* + Module :TODO: + * Add ability to at least alternate formatStrings -- every other +*/ + +exports.moduleInfo = { + name : 'Onelinerz', + desc : 'Standard local onelinerz', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.onelinerz', +}; + +const MciViewIds = { + view : { + entries : 1, + addPrompt : 2, + }, + add : { + newEntry : 1, + entryPreview : 2, + addPrompt : 3, + } +}; + +const FormIds = { + view : 0, + add : 1, +}; + +exports.getModule = class OnelinerzModule extends MenuModule { + constructor(options) { + super(options); + + const self = this; + + this.menuMethods = { + viewAddScreen : function(formData, extraArgs, cb) { + return self.displayAddScreen(cb); + }, + + addEntry : function(formData, extraArgs, cb) { + if(_.isString(formData.value.oneliner) && formData.value.oneliner.length > 0) { + const oneliner = formData.value.oneliner.trim(); // remove any trailing ws + + self.storeNewOneliner(oneliner, err => { + if(err) { + self.client.log.warn( { error : err.message }, 'Failed saving oneliner'); + } + + self.clearAddForm(); + return self.displayViewScreen(true, cb); // true=cls + }); + + } else { + // empty message - treat as if cancel was hit + return self.displayViewScreen(true, cb); // true=cls + } + }, + + cancelAdd : function(formData, extraArgs, cb) { + self.clearAddForm(); + return self.displayViewScreen(true, cb); // true=cls + } + }; + } + + initSequence() { + const self = this; + async.series( + [ + function beforeDisplayArt(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + return self.displayViewScreen(false, callback); + } + ], + err => { + if(err) { + // :TODO: Handle me -- initSequence() should really take a completion callback + } + self.finishedLoading(); + } + ); + } + + displayViewScreen(clearScreen, cb) { + const self = this; + + async.waterfall( + [ + function prepArtAndViewController(callback) { + if(self.viewControllers.add) { + self.viewControllers.add.setFocus(false); + } + + return self.prepViewControllerWithArt( + 'view', + FormIds.view, + { + clearScreen, + trailingLF : false + }, + (err, artInfo, wasCreated) => { + if(!err && !wasCreated) { + self.viewControllers.view.setFocus(true); + self.viewControllers.view.getView(MciViewIds.view.addPrompt).redraw(); + } + return callback(err); + } + ); + }, + function fetchEntries(callback) { + const entriesView = self.viewControllers.view.getView(MciViewIds.view.entries); + const limit = entriesView.dimens.height; + let entries = []; + + self.db.each( + `SELECT * + FROM ( + SELECT * + FROM onelinerz + ORDER BY timestamp DESC + LIMIT ${limit} + ) + ORDER BY timestamp ASC;`, + (err, row) => { + if(!err) { + row.timestamp = moment(row.timestamp); // convert -> moment + entries.push(row); + } + }, + err => { + return callback(err, entriesView, entries); + } + ); + }, + function populateEntries(entriesView, entries, callback) { + const tsFormat = + self.menuConfig.config.dateTimeFormat || + self.menuConfig.config.timestampFormat || // deprecated + self.client.currentTheme.helpers.getDateFormat('short'); + + entriesView.setItems(entries.map( e => { + return { + text : e.oneliner, // standard + userId : e.user_id, + userName : e.user_name, + oneliner : e.oneliner, + ts : e.timestamp.format(tsFormat), + }; + })); + + entriesView.redraw(); + return callback(null); + }, + function finalPrep(callback) { + const promptView = self.viewControllers.view.getView(MciViewIds.view.addPrompt); + promptView.setFocusItemIndex(1); // default to NO + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayAddScreen(cb) { + const self = this; + + async.waterfall( + [ + function clearAndDisplayArt(callback) { + self.viewControllers.view.setFocus(false); + + return self.prepViewControllerWithArt( + 'add', + FormIds.add, + { + clearScreen : true, + trailingLF : false + }, + (err, artInfo, wasCreated) => { + if(!wasCreated) { + self.viewControllers.add.setFocus(true); + self.viewControllers.add.redrawAll(); + self.viewControllers.add.switchFocus(MciViewIds.add.newEntry); + } + return callback(err); + } + ); + }, + function initPreviewUpdates(callback) { + const previewView = self.viewControllers.add.getView(MciViewIds.add.entryPreview); + const entryView = self.viewControllers.add.getView(MciViewIds.add.newEntry); + if(previewView) { + let timerId; + entryView.on('key press', () => { + clearTimeout(timerId); + timerId = setTimeout( () => { + const focused = self.viewControllers.add.getFocusedView(); + if(focused === entryView) { + previewView.setText(entryView.getData()); + focused.setFocus(true); + } + }, 500); + }); + } + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + clearAddForm() { + this.setViewText('add', MciViewIds.add.newEntry, ''); + this.setViewText('add', MciViewIds.add.entryPreview, ''); + } + + initDatabase(cb) { + const self = this; + + async.series( + [ + function openDatabase(callback) { + self.db = getTransactionDatabase(new sqlite3.Database( + getModDatabasePath(exports.moduleInfo), + err => { + return callback(err); + } + )); + }, + function createTables(callback) { + self.db.run( + `CREATE TABLE IF NOT EXISTS onelinerz ( + id INTEGER PRIMARY KEY, + user_id INTEGER_NOT NULL, + user_name VARCHAR NOT NULL, + oneliner VARCHAR NOT NULL, + timestamp DATETIME NOT NULL + );` + , + err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + + storeNewOneliner(oneliner, cb) { + const self = this; + const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + + async.series( + [ + function addRec(callback) { + self.db.run( + `INSERT INTO onelinerz (user_id, user_name, oneliner, timestamp) + VALUES (?, ?, ?, ?);`, + [ self.client.user.userId, self.client.user.username, oneliner, ts ], + callback + ); + }, + function removeOld(callback) { + // keep 25 max most recent items by default - remove the older ones + const retainCount = self.menuConfig.config.retainCount || 25; + self.db.run( + `DELETE FROM onelinerz + WHERE id IN ( + SELECT id + FROM onelinerz + ORDER BY id DESC + LIMIT -1 OFFSET ${retainCount} + );`, + callback + ); + } + ], + err => { + return cb(err); + } + ); + } + + beforeArt(cb) { + super.beforeArt(err => { + return err ? cb(err) : this.initDatabase(cb); + }); + } +}; diff --git a/core/oputil/oputil_common.js b/core/oputil/oputil_common.js index 21e1a6d0..e1d6c962 100644 --- a/core/oputil/oputil_common.js +++ b/core/oputil/oputil_common.js @@ -2,88 +2,137 @@ /* eslint-disable no-console */ 'use strict'; -const resolvePath = require('../misc_util.js').resolvePath; - const config = require('../../core/config.js'); const db = require('../../core/database.js'); const _ = require('lodash'); const async = require('async'); +const inq = require('inquirer'); +const fs = require('fs'); +const hjson = require('hjson'); + +const packageJson = require('../../package.json'); exports.printUsageAndSetExitCode = printUsageAndSetExitCode; exports.getDefaultConfigPath = getDefaultConfigPath; exports.getConfigPath = getConfigPath; exports.initConfigAndDatabases = initConfigAndDatabases; exports.getAreaAndStorage = getAreaAndStorage; +exports.looksLikePattern = looksLikePattern; +exports.getAnswers = getAnswers; +exports.writeConfig = writeConfig; + +const HJSONStringifyCommonOpts = exports.HJSONStringifyCommonOpts = { + emitRootBraces : true, + bracesSameLine : true, + space : 4, + keepWsc : true, + quotes : 'min', + eol : '\n', +}; const exitCodes = exports.ExitCodes = { - SUCCESS : 0, - ERROR : -1, - BAD_COMMAND : -2, - BAD_ARGS : -3, + SUCCESS : 0, + ERROR : -1, + BAD_COMMAND : -2, + BAD_ARGS : -3, }; const argv = exports.argv = require('minimist')(process.argv.slice(2), { - alias : { - h : 'help', - v : 'version', - c : 'config', - n : 'no-prompt', - } + alias : { + h : 'help', + v : 'version', + c : 'config', + n : 'no-prompt', + } }); function printUsageAndSetExitCode(errMsg, exitCode) { - if(_.isUndefined(exitCode)) { - exitCode = exitCodes.ERROR; - } + if(_.isUndefined(exitCode)) { + exitCode = exitCodes.ERROR; + } - process.exitCode = exitCode; + process.exitCode = exitCode; - if(errMsg) { - console.error(errMsg); - } + if(errMsg) { + console.error(errMsg); + } } function getDefaultConfigPath() { - return resolvePath('~/.config/enigma-bbs/config.hjson'); + return './config/'; } function getConfigPath() { - return argv.config ? argv.config : config.getDefaultPath(); + const baseConfigPath = argv.config ? argv.config : config.getDefaultPath(); + return baseConfigPath + 'config.hjson'; } function initConfig(cb) { - const configPath = getConfigPath(); + const configPath = getConfigPath(); - config.init(configPath, { keepWsc : true }, cb); + config.init(configPath, { keepWsc : true, noWatch : true }, cb); } function initConfigAndDatabases(cb) { - async.series( - [ - function init(callback) { - initConfig(callback); - }, - function initDb(callback) { - db.initializeDatabases(callback); - }, - ], - err => { - return cb(err); - } - ); + async.series( + [ + function init(callback) { + initConfig(callback); + }, + function initDb(callback) { + db.initializeDatabases(callback); + }, + function initArchiveUtil(callback) { + // ensure we init ArchiveUtil without events + require('../../core/archive_util').getInstance(true); // true=noWatch + return callback(null); + } + ], + err => { + return cb(err); + } + ); } function getAreaAndStorage(tags) { - return tags.map(tag => { - const parts = tag.toString().split('@'); - const entry = { - areaTag : parts[0], - }; - entry.pattern = entry.areaTag; // handy - if(parts[1]) { - entry.storageTag = parts[1]; - } - return entry; - }); + return tags.map(tag => { + const parts = tag.toString().split('@'); + const entry = { + areaTag : parts[0], + }; + entry.pattern = entry.areaTag; // handy + if(parts[1]) { + entry.storageTag = parts[1]; + } + return entry; + }); +} + +function looksLikePattern(tag) { + // globs can start with @ + if(tag.indexOf('@') > 0) { + return false; + } + + return /[*?[\]!()+|^]/.test(tag); +} + +function getAnswers(questions, cb) { + inq.prompt(questions).then( answers => { + return cb(answers); + }); +} + +function writeConfig(config, path) { + config = hjson.stringify(config, HJSONStringifyCommonOpts) + .replace(/%ENIG_VERSION%/g, packageJson.version) + .replace(/%HJSON_VERSION%/g, hjson.version); + + try { + fs.writeFileSync(path, config, 'utf8'); + return true; + } catch(e) { + return false; + } } \ No newline at end of file diff --git a/core/oputil/oputil_config.js b/core/oputil/oputil_config.js index 4d1eb54b..8a51fdb5 100644 --- a/core/oputil/oputil_config.js +++ b/core/oputil/oputil_config.js @@ -4,557 +4,287 @@ // ENiGMA½ const resolvePath = require('../../core/misc_util.js').resolvePath; -const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; -const ExitCodes = require('./oputil_common.js').ExitCodes; -const argv = require('./oputil_common.js').argv; -const getConfigPath = require('./oputil_common.js').getConfigPath; +const { + printUsageAndSetExitCode, + getConfigPath, + argv, + ExitCodes, + getAnswers, + writeConfig, + HJSONStringifyCommonOpts, +} = require('./oputil_common.js'); const getHelpFor = require('./oputil_help.js').getHelpFor; -const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; -const Errors = require('../../core/enig_error.js').Errors; // deps -const async = require('async'); -const inq = require('inquirer'); -const mkdirsSync = require('fs-extra').mkdirsSync; -const fs = require('graceful-fs'); -const hjson = require('hjson'); -const paths = require('path'); -const _ = require('lodash'); +const async = require('async'); +const inq = require('inquirer'); +const mkdirsSync = require('fs-extra').mkdirsSync; +const fs = require('graceful-fs'); +const hjson = require('hjson'); +const paths = require('path'); +const _ = require('lodash'); +const sanatizeFilename = require('sanitize-filename'); exports.handleConfigCommand = handleConfigCommand; - -function getAnswers(questions, cb) { - inq.prompt(questions).then( answers => { - return cb(answers); - }); -} +const ConfigIncludeKeys = [ + 'theme', + 'users.preAuthIdleLogoutSeconds', 'users.idleLogoutSeconds', + 'users.newUserNames', 'users.failedLogin', 'users.unlockAtEmailPwReset', + 'paths.logs', + 'loginServers', + 'contentServers', + 'fileBase.areaStoragePrefix', + 'logging.rotatingFile', +]; const QUESTIONS = { - Intro : [ - { - name : 'createNewConfig', - message : 'Create a new configuration?', - type : 'confirm', - default : false, - }, - { - name : 'configPath', - message : 'Configuration path:', - default : getConfigPath(), - when : answers => answers.createNewConfig - }, - ], - - OverwriteConfig : [ - { - name : 'overwriteConfig', - message : 'Config file exists. Overwrite?', - type : 'confirm', - default : false, - } - ], - - Basic : [ - { - name : 'boardName', - message : 'BBS name:', - default : 'New ENiGMA½ BBS', - }, - ], - - Misc : [ - { - name : 'loggingLevel', - message : 'Logging level:', - type : 'list', - choices : [ 'Error', 'Warn', 'Info', 'Debug', 'Trace' ], - default : 2, - filter : s => s.toLowerCase(), - }, - { - name : 'sevenZipExe', - message : '7-Zip executable:', - type : 'list', - choices : [ '7z', '7za', 'None' ] - } - ], - - MessageConfAndArea : [ - { - name : 'msgConfName', - message : 'First message conference:', - default : 'Local', - }, - { - name : 'msgConfDesc', - message : 'Conference description:', - default : 'Local Areas', - }, - { - name : 'msgAreaName', - message : 'First area in message conference:', - default : 'General', - }, - { - name : 'msgAreaDesc', - message : 'Area description:', - default : 'General chit-chat', - } - ] + Intro : [ + { + name : 'createNewConfig', + message : 'Create a new configuration?', + type : 'confirm', + default : false, + }, + { + name : 'configPath', + message : 'Configuration path:', + default : getConfigPath(), + when : answers => answers.createNewConfig + }, + ], + + OverwriteConfig : [ + { + name : 'overwriteConfig', + message : 'Config file exists. Overwrite?', + type : 'confirm', + default : false, + } + ], + + Basic : [ + { + name : 'boardName', + message : 'BBS name:', + default : 'New ENiGMA½ BBS', + }, + ], + + Misc : [ + { + name : 'loggingLevel', + message : 'Logging level:', + type : 'list', + choices : [ 'Error', 'Warn', 'Info', 'Debug', 'Trace' ], + default : 2, + filter : s => s.toLowerCase(), + }, + ], + + MessageConfAndArea : [ + { + name : 'msgConfName', + message : 'First message conference:', + default : 'Local', + }, + { + name : 'msgConfDesc', + message : 'Conference description:', + default : 'Local Areas', + }, + { + name : 'msgAreaName', + message : 'First area in message conference:', + default : 'General', + }, + { + name : 'msgAreaDesc', + message : 'Area description:', + default : 'General chit-chat', + } + ] }; function makeMsgConfAreaName(s) { - return s.toLowerCase().replace(/\s+/g, '_'); + return s.toLowerCase().replace(/\s+/g, '_'); } function askNewConfigQuestions(cb) { - - const ui = new inq.ui.BottomBar(); - - let configPath; - let config; - - async.waterfall( - [ - function intro(callback) { - getAnswers(QUESTIONS.Intro, answers => { - if(!answers.createNewConfig) { - return callback('exit'); - } - - // adjust for ~ and the like - configPath = resolvePath(answers.configPath); - - const configDir = paths.dirname(configPath); - mkdirsSync(configDir); - - // - // Check if the file exists and can be written to - // - fs.access(configPath, fs.F_OK | fs.W_OK, err => { - if(err) { - if('EACCES' === err.code) { - ui.log.write(`${configPath} cannot be written to`); - callback('exit'); - } else if('ENOENT' === err.code) { - callback(null, false); - } - } else { - callback(null, true); // exists + writable - } - }); - }); - }, - function promptOverwrite(needPrompt, callback) { - if(needPrompt) { - getAnswers(QUESTIONS.OverwriteConfig, answers => { - callback(answers.overwriteConfig ? null : 'exit'); - }); - } else { - callback(null); - } - }, - function basic(callback) { - getAnswers(QUESTIONS.Basic, answers => { - config = { - general : { - boardName : answers.boardName, - }, - }; - - callback(null); - }); - }, - function msgConfAndArea(callback) { - getAnswers(QUESTIONS.MessageConfAndArea, answers => { - config.messageConferences = {}; - - const confName = makeMsgConfAreaName(answers.msgConfName); - const areaName = makeMsgConfAreaName(answers.msgAreaName); - - config.messageConferences[confName] = { - name : answers.msgConfName, - desc : answers.msgConfDesc, - sort : 1, - default : true, - }; - - config.messageConferences.another_sample_conf = { - name : 'Another Sample Conference', - desc : 'Another conference example. Change me!', - sort : 2, - }; - - config.messageConferences[confName].areas = {}; - config.messageConferences[confName].areas[areaName] = { - name : answers.msgAreaName, - desc : answers.msgAreaDesc, - sort : 1, - default : true, - }; - - config.messageConferences.another_sample_conf = { - name : 'Another Sample Conference', - desc : 'Another conf sample. Change me!', - areas : { - another_sample_area : { - name : 'Another Sample Area', - desc : 'Another area example. Change me!', - sort : 2 - } - } - }; - - callback(null); - }); - }, - function misc(callback) { - getAnswers(QUESTIONS.Misc, answers => { - if('None' !== answers.sevenZipExe) { - config.archivers = { - zip : { - compressCmd : answers.sevenZipExe, - decompressCmd : answers.sevenZipExe, - } - }; - } - - config.logging = { - level : answers.loggingLevel, - }; - - callback(null); - }); - } - ], - err => { - cb(err, configPath, config); - } - ); + const ui = new inq.ui.BottomBar(); + + let configPath; + let config; + + async.waterfall( + [ + function intro(callback) { + getAnswers(QUESTIONS.Intro, answers => { + if(!answers.createNewConfig) { + return callback('exit'); + } + + // adjust for ~ and the like + configPath = resolvePath(answers.configPath); + + const configDir = paths.dirname(configPath); + mkdirsSync(configDir); + + // + // Check if the file exists and can be written to + // + fs.access(configPath, fs.F_OK | fs.W_OK, err => { + if(err) { + if('EACCES' === err.code) { + ui.log.write(`${configPath} cannot be written to`); + callback('exit'); + } else if('ENOENT' === err.code) { + callback(null, false); + } + } else { + callback(null, true); // exists + writable + } + }); + }); + }, + function promptOverwrite(needPrompt, callback) { + if(needPrompt) { + getAnswers(QUESTIONS.OverwriteConfig, answers => { + return callback(answers.overwriteConfig ? null : 'exit'); + }); + } else { + return callback(null); + } + }, + function basic(callback) { + getAnswers(QUESTIONS.Basic, answers => { + const defaultConfig = require('../../core/config.js').getDefaultConfig(); + + // start by plopping in values we want directly from config.js + const template = hjson.rt.parse(fs.readFileSync(paths.join(__dirname, '../../misc/config_template.in.hjson'), 'utf8')); + + const direct = {}; + _.each(ConfigIncludeKeys, keyPath => { + _.set(direct, keyPath, _.get(defaultConfig, keyPath)); + }); + + config = _.mergeWith(template, direct); + + // we can override/add to it based on user input from this point on... + config.general.boardName = answers.boardName; + + return callback(null); + }); + }, + function msgConfAndArea(callback) { + getAnswers(QUESTIONS.MessageConfAndArea, answers => { + const confName = makeMsgConfAreaName(answers.msgConfName); + const areaName = makeMsgConfAreaName(answers.msgAreaName); + + config.messageConferences[confName] = { + name : answers.msgConfName, + desc : answers.msgConfDesc, + sort : 1, + default : true, + }; + + config.messageConferences[confName].areas = {}; + config.messageConferences[confName].areas[areaName] = { + name : answers.msgAreaName, + desc : answers.msgAreaDesc, + sort : 1, + default : true, + }; + + return callback(null); + }); + }, + function misc(callback) { + getAnswers(QUESTIONS.Misc, answers => { + config.logging.rotatingFile.level = answers.loggingLevel; + + return callback(null); + }); + } + ], + err => { + return cb(err, configPath, config); + } + ); } -function writeConfig(config, path) { - config = hjson.stringify(config, { bracesSameLine : true, spaces : '\t', keepWsc : true, quotes : 'strings' } ); - - try { - fs.writeFileSync(path, config, 'utf8'); - return true; - } catch(e) { - return false; - } -} +const copyFileSyncSilent = (to, from, flags) => { + try { + fs.copyFileSync(to, from, flags); + } catch(e) { + /* absorb! */ + } +}; function buildNewConfig() { - askNewConfigQuestions( (err, configPath, config) => { - if(err) { - return; - } + askNewConfigQuestions( (err, configPath, config) => { + if(err) { return; + } - if(writeConfig(config, configPath)) { - console.info('Configuration generated'); - } else { - console.error('Failed writing configuration'); - } - }); + const bn = sanatizeFilename(config.general.boardName) + .replace(/[^a-z0-9_-]/ig, '_') + .replace(/_+/g, '_') + .toLowerCase(); + const menuFile = `${bn}-menu.hjson`; + copyFileSyncSilent( + paths.join(__dirname, '../../misc/menu_template.in.hjson'), + paths.join(__dirname, '../../config/', menuFile), + fs.constants.COPYFILE_EXCL + ); + + const promptFile = `${bn}-prompt.hjson`; + copyFileSyncSilent( + paths.join(__dirname, '../../misc/prompt_template.in.hjson'), + paths.join(__dirname, '../../config/', promptFile), + fs.constants.COPYFILE_EXCL + ); + + config.general.menuFile = menuFile; + config.general.promptFile = promptFile; + + if(writeConfig(config, configPath)) { + console.info('Configuration generated'); + } else { + console.error('Failed writing configuration'); + } + }); } -function validateUplinks(uplinks) { - const ftnAddress = require('../../core/ftn_address.js'); - const valid = uplinks.every(ul => { - const addr = ftnAddress.fromString(ul); - return addr; - }); - return valid; -} +function catCurrentConfig() { + try { + const config = hjson.rt.parse(fs.readFileSync(getConfigPath(), 'utf8')); + const hjsonOpts = Object.assign({}, HJSONStringifyCommonOpts, { + colors : false === argv.colors ? false : true, + keepWsc : false === argv.comments ? false : true, + }); -function getMsgAreaImportType(path) { - if(argv.type) { - return argv.type.toLowerCase(); - } - - const ext = paths.extname(path).toLowerCase().substr(1); - return ext; // .bbs|.na|... -} - -function importAreas() { - const importPath = argv._[argv._.length - 1]; - if(argv._.length < 3 || !importPath || 0 === importPath.length) { - return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); - } - - const importType = getMsgAreaImportType(importPath); - if('na' !== importType && 'bbs' !== importType) { - return console.error(`"${importType}" is not a recognized import file type`); - } - - // optional data - we'll prompt if for anything not found - let confTag = argv.conf; - let networkName = argv.network; - let uplinks = argv.uplinks; - if(uplinks) { - uplinks = uplinks.split(/[\s,]+/); - } - - let importEntries; - - async.waterfall( - [ - function readImportFile(callback) { - fs.readFile(importPath, 'utf8', (err, importData) => { - if(err) { - return callback(err); - } - - importEntries = getImportEntries(importType, importData); - if(0 === importEntries.length) { - return callback(Errors.Invalid('Invalid or empty import file')); - } - - // We should have enough to validate uplinks - if('bbs' === importType) { - for(let i = 0; i < importEntries.length; ++i) { - if(!validateUplinks(importEntries[i].uplinks)) { - return callback(Errors.Invalid('Invalid uplink(s)')); - } - } - } else { - if(!validateUplinks(uplinks)) { - return callback(Errors.Invalid('Invalid uplink(s)')); - } - } - - return callback(null); - }); - }, - function init(callback) { - return initConfigAndDatabases(callback); - }, - function validateAndCollectInput(callback) { - const msgArea = require('../../core/message_area.js'); - const Config = require('../../core/config.js').config; - - let msgConfs = msgArea.getSortedAvailMessageConferences(null, { noClient : true } ); - if(!msgConfs) { - return callback(Errors.DoesNotExist('No conferences exist in your configuration')); - } - - msgConfs = msgConfs.map(mc => { - return { - name : mc.conf.name, - value : mc.confTag, - }; - }); - - if(confTag && !msgConfs.find(mc => { - return confTag === mc.value; - })) - { - return callback(Errors.DoesNotExist(`Conference "${confTag}" does not exist`)); - } - - let existingNetworkNames = []; - if(_.has(Config, 'messageNetworks.ftn.networks')) { - existingNetworkNames = Object.keys(Config.messageNetworks.ftn.networks); - } - - if(0 === existingNetworkNames.length) { - return callback(Errors.DoesNotExist('No FTN style networks exist in your configuration')); - } - - if(networkName && !existingNetworkNames.find(net => networkName === net)) { - return callback(Errors.DoesNotExist(`FTN style Network "${networkName}" does not exist`)); - } - - getAnswers([ - { - name : 'confTag', - message : 'Message conference:', - type : 'list', - choices : msgConfs, - pageSize : 10, - when : !confTag, - }, - { - name : 'networkName', - message : 'Network name:', - type : 'list', - choices : existingNetworkNames, - when : !networkName, - }, - { - name : 'uplinks', - message : 'Uplink(s) (comma separated):', - type : 'input', - validate : (input) => { - const inputUplinks = input.split(/[\s,]+/); - return validateUplinks(inputUplinks) ? true : 'Invalid uplink(s)'; - }, - when : !uplinks && 'bbs' !== importType, - } - ], - answers => { - confTag = confTag || answers.confTag; - networkName = networkName || answers.networkName; - uplinks = uplinks || answers.uplinks; - - importEntries.forEach(ie => { - ie.areaTag = ie.ftnTag.toLowerCase(); - }); - - return callback(null); - }); - }, - function confirmWithUser(callback) { - const Config = require('../../core/config.js').config; - - console.info(`Importing the following for "${confTag}" - (${Config.messageConferences[confTag].name} - ${Config.messageConferences[confTag].desc})`); - importEntries.forEach(ie => { - console.info(` ${ie.ftnTag} - ${ie.name}`); - }); - - console.info(''); - console.info('Importing will NOT create required FTN network configurations.'); - console.info('If you have not yet done this, you will need to complete additional steps after importing.'); - console.info('See docs/msg_networks.md for details.'); - console.info(''); - - getAnswers([ - { - name : 'proceed', - message : 'Proceed?', - type : 'confirm', - } - ], - answers => { - return callback(answers.proceed ? null : Errors.General('User canceled')); - }); - - }, - function loadConfigHjson(callback) { - const configPath = getConfigPath(); - fs.readFile(configPath, 'utf8', (err, confData) => { - if(err) { - return callback(err); - } - - let config; - try { - config = hjson.parse(confData, { keepWsc : true } ); - } catch(e) { - return callback(e); - } - return callback(null, config); - - }); - }, - function performImport(config, callback) { - const confAreas = { messageConferences : {} }; - confAreas.messageConferences[confTag] = { areas : {} }; - - const msgNetworks = { messageNetworks : { ftn : { areas : {} } } }; - - importEntries.forEach(ie => { - const specificUplinks = ie.uplinks || uplinks; // AREAS.BBS has specific uplinks per area - - confAreas.messageConferences[confTag].areas[ie.areaTag] = { - name : ie.name, - desc : ie.name, - }; - - msgNetworks.messageNetworks.ftn.areas[ie.areaTag] = { - network : networkName, - tag : ie.ftnTag, - uplinks : specificUplinks - }; - }); - - - const newConfig = _.defaultsDeep(config, confAreas, msgNetworks); - const configPath = getConfigPath(); - - if(!writeConfig(newConfig, configPath)) { - return callback(Errors.UnexpectedState('Failed writing configuration')); - } - - return callback(null); - } - ], - err => { - if(err) { - console.error(err.reason ? err.reason : err.message); - } else { - const addFieldUpd = 'bbs' === importType ? '"name" and "desc"' : '"desc"'; - console.info('Configuration generated.'); - console.info(`You may wish to validate changes made to ${getConfigPath()}`); - console.info(`as well as update ${addFieldUpd} fields, sorting, etc.`); - console.info(''); - } - } - ); - -} - -function getImportEntries(importType, importData) { - let importEntries = []; - - if('na' === importType) { - // - // parse out - // TAG DESC - // - const re = /^([^\s]+)\s+([^\r\n]+)/gm; - let m; - - while( (m = re.exec(importData) )) { - importEntries.push({ - ftnTag : m[1], - name : m[2], - }); - } - } else if ('bbs' === importType) { - // - // Various formats for AREAS.BBS seem to exist. We want to support as much as possible. - // - // SBBS http://www.synchro.net/docs/sbbsecho.html#AREAS.BBS - // CODE TAG UPLINKS - // - // VADV https://www.vadvbbs.com/products/vadv/support/docs/docs_vfido.php#AREAS.BBS - // TAG UPLINKS - // - // Misc - // PATH|OTHER TAG UPLINKS - // - // Assume the second item is TAG and 1:n UPLINKS (space and/or comma sep) after (at the end) - // - const re = /^[^\s]+\s+([^\s]+)\s+([^\n]+)$/gm; - let m; - while ( (m = re.exec(importData) )) { - const tag = m[1]; - - importEntries.push({ - ftnTag : tag, - name : `Area: ${tag}`, - uplinks : m[2].split(/[\s,]+/), - }); - } - } - - return importEntries; + console.log(hjson.stringify(config, hjsonOpts)); + } catch(e) { + if('ENOENT' == e.code) { + console.error(`File not found: ${getConfigPath()}`); + } else { + console.error(e); + } + } } function handleConfigCommand() { - if(true === argv.help) { - return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); - } + if(true === argv.help) { + return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); + } - const action = argv._[1]; + const action = argv._[1]; - switch(action) { - case 'new' : return buildNewConfig(); - case 'import-areas' : return importAreas(); + switch(action) { + case 'new' : return buildNewConfig(); + case 'cat' : return catCurrentConfig(); - default : return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); - } + default : return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); + } } diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index 5cbe10e3..308d3581 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -7,8 +7,14 @@ const ExitCodes = require('./oputil_common.js').ExitCodes; const argv = require('./oputil_common.js').argv; const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; const getHelpFor = require('./oputil_help.js').getHelpFor; -const getAreaAndStorage = require('./oputil_common.js').getAreaAndStorage; -const Errors = require('../../core/enig_error.js').Errors; +const { + getAreaAndStorage, + looksLikePattern, + getConfigPath, + getAnswers, + writeConfig +} = require('./oputil_common.js'); +const Errors = require('../enig_error.js').Errors; const async = require('async'); const fs = require('graceful-fs'); @@ -16,6 +22,10 @@ const paths = require('path'); const _ = require('lodash'); const moment = require('moment'); const inq = require('inquirer'); +const glob = require('glob'); +const sanatizeFilename = require('sanitize-filename'); +const hjson = require('hjson'); +const { mkdirs } = require('fs-extra'); exports.handleFileBaseCommand = handleFileBaseCommand; @@ -24,7 +34,7 @@ exports.handleFileBaseCommand = handleFileBaseCommand; Global options: --yes: assume yes - --no-prompt: try to avoid user input + --no-prompt: try to avoid user input Prompt for import and description before scan * Only after finding duplicate-by-path @@ -34,512 +44,1007 @@ exports.handleFileBaseCommand = handleFileBaseCommand; let fileArea; // required during init -function finalizeEntryAndPersist(fileEntry, descHandler, cb) { - async.series( - [ - function getDescFromHandlerIfNeeded(callback) { - if((fileEntry.desc && fileEntry.desc.length > 0 ) && !argv['desc-file']) { - return callback(null); // we have a desc already and are NOT overriding with desc file - } +function finalizeEntryAndPersist(isUpdate, fileEntry, descHandler, cb) { + async.series( + [ + function getDescFromHandlerIfNeeded(callback) { + if((fileEntry.desc && fileEntry.descSrc != 'fileName' && fileEntry.desc.length > 0 ) && !argv['desc-file']) { + return callback(null); // we have a desc already and are NOT overriding with desc file + } - if(!descHandler) { - return callback(null); // not much we can do! - } + if(!descHandler) { + return callback(null); // not much we can do! + } - const desc = descHandler.getDescription(fileEntry.fileName); - if(desc) { - fileEntry.desc = desc; - } - return callback(null); - }, - function getDescFromUserIfNeeded(callback) { - if(false === argv.prompt || ( fileEntry.desc && fileEntry.desc.length > 0 ) ) { - return callback(null); - } + const desc = descHandler.getDescription(fileEntry.fileName); + if(desc) { + fileEntry.desc = desc; + } + return callback(null); + }, + function getDescFromUserIfNeeded(callback) { + if(fileEntry.desc && fileEntry.desc.length > 0 ) { + return callback(null); + } - const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; + const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; + const descFromFile = getDescFromFileName(fileEntry.fileName); - const questions = [ - { - name : 'desc', - message : `Description for ${fileEntry.fileName}:`, - type : 'input', - default : getDescFromFileName(fileEntry.fileName), - } - ]; + if(false === argv.prompt) { + fileEntry.desc = descFromFile; + return callback(null); + } - inq.prompt(questions).then( answers => { - fileEntry.desc = answers.desc; - return callback(null); - }); - }, - function persist(callback) { - fileEntry.persist( err => { - return callback(err); - }); - } - ], - err => { - return cb(err); - } - ); + const questions = [ + { + name : 'desc', + message : `Description for ${fileEntry.fileName}:`, + type : 'input', + default : descFromFile, + } + ]; + + inq.prompt(questions).then( answers => { + fileEntry.desc = answers.desc; + return callback(null); + }); + }, + function persist(callback) { + fileEntry.persist(isUpdate, err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); } -const SCAN_EXCLUDE_FILENAMES = [ 'DESCRIPT.ION', 'FILES.BBS' ]; +const SCAN_EXCLUDE_FILENAMES = [ + 'DESCRIPT.ION', + 'FILES.BBS', + 'ALLFILES.TXT', +]; function loadDescHandler(path, cb) { - const DescIon = require('../../core/descript_ion_file.js'); + const handlerClassFromFileName = { + 'descript.ion' : require('../../core/descript_ion_file.js'), + 'files.bbs' : require('../../core/files_bbs_file.js'), + }[paths.basename(path).toLowerCase()]; - // :TODO: support FILES.BBS also + if(!handlerClassFromFileName) { + return cb(Errors.DoesNotExist(`No handlers registered for ${paths.basename(path)}`)); + } - DescIon.createFromFile(path, (err, descHandler) => { - return cb(err, descHandler); - }); + handlerClassFromFileName.createFromFile(path, (err, descHandler) => { + return cb(err, descHandler); + }); +} + +// +// Try to find a suitable description handler by +// checking for common filenames. +// +function findSuitableDescHandler(basePath, cb) { + const commonFiles = [ 'FILES.BBS', 'DESCRIPT.ION' ]; + + async.eachSeries(commonFiles, (fileName, nextFileName) => { + loadDescHandler(paths.join(basePath, fileName), (err, handler) => { + if(!err && handler) { + return cb(null, handler); + } + return nextFileName(null); + }); + }, + () => { + return cb(Errors.DoesNotExist('No suitable description handler available')); + }); } function scanFileAreaForChanges(areaInfo, options, cb) { - const storageLocations = fileArea.getAreaStorageLocations(areaInfo).filter(sl => { - return options.areaAndStorageInfo.find(asi => { - return !asi.storageTag || sl.storageTag === asi.storageTag; - }); - }); - - async.eachSeries(storageLocations, (storageLoc, nextLocation) => { - async.waterfall( - [ - function initDescFile(callback) { - if(options.descFileHandler) { - return callback(null, options.descFileHandler); // we're going to use the global handler - } + const storageLocations = fileArea.getAreaStorageLocations(areaInfo).filter(sl => { + return options.areaAndStorageInfo.find(asi => { + return !asi.storageTag || sl.storageTag === asi.storageTag; + }); + }); - loadDescHandler(paths.join(storageLoc.dir, 'DESCRIPT.ION'), (err, descHandler) => { - return callback(null, descHandler); - }); - }, - function scanPhysFiles(descHandler, callback) { - const physDir = storageLoc.dir; + function updateTags(fe) { + if(Array.isArray(options.tags)) { + fe.hashTags = new Set(options.tags); + } + } - fs.readdir(physDir, (err, files) => { - if(err) { - return callback(err); - } + const FileEntry = require('../file_entry.js'); - async.eachSeries(files, (fileName, nextFile) => { - const fullPath = paths.join(physDir, fileName); + const readDir = options.glob ? + (dir, next) => { + return glob(options.glob, { cwd : dir, nodir : true }, next); + } : + (dir, next) => { + return fs.readdir(dir, next); + }; - if(SCAN_EXCLUDE_FILENAMES.includes(fileName.toUpperCase())) { - console.info(`Excluding ${fullPath}`); - return nextFile(null); - } + async.eachSeries(storageLocations, (storageLoc, nextLocation) => { + async.waterfall( + [ + function initDescFile(callback) { + if(options.descFileHandler) { + return callback(null, options.descFileHandler); // we're going to use the global handler + } - fs.stat(fullPath, (err, stats) => { - if(err) { - // :TODO: Log me! - return nextFile(null); // always try next file - } + findSuitableDescHandler(storageLoc.dir, (err, descHandler) => { + return callback(null, descHandler); + }); + }, + function scanPhysFiles(descHandler, callback) { + const physDir = storageLoc.dir; - if(!stats.isFile()) { - return nextFile(null); - } + readDir(physDir, (err, files) => { + if(err) { + return callback(err); + } - process.stdout.write(`Scanning ${fullPath}... `); + async.eachSeries(files, (fileName, nextFile) => { + const fullPath = paths.join(physDir, fileName); - fileArea.scanFile( - fullPath, - { - areaTag : areaInfo.areaTag, - storageTag : storageLoc.storageTag - }, - (err, fileEntry, dupeEntries) => { - if(err) { - // :TODO: Log me!!! - console.info(`Error: ${err.message}`); - return nextFile(null); // try next anyway - } + if(SCAN_EXCLUDE_FILENAMES.includes(fileName.toUpperCase())) { + console.info(`Excluding ${fullPath}`); + return nextFile(null); + } - if(dupeEntries.length > 0) { - // :TODO: Handle duplidates -- what to do here??? - console.info('Dupe'); - return nextFile(null); - } else { - console.info('Done!'); - if(Array.isArray(options.tags)) { - options.tags.forEach(tag => { - fileEntry.hashTags.add(tag); - }); - } + fs.stat(fullPath, (err, stats) => { + if(err) { + // :TODO: Log me! + return nextFile(null); // always try next file + } - finalizeEntryAndPersist(fileEntry, descHandler, err => { - return nextFile(err); - }); - } - } - ); - }); - }, err => { - return callback(err); - }); - }); - }, - function scanDbEntries(callback) { - // :TODO: Look @ db entries for area that were *not* processed above - return callback(null); - } - ], - err => { - return nextLocation(err); - } - ); - }, - err => { - return cb(err); - }); + if(!stats.isFile()) { + return nextFile(null); + } + + process.stdout.write(`Scanning ${fullPath}... `); + + async.series( + [ + function quickCheck(next) { + if(options['full']) { + return next(null); + } + + FileEntry.quickCheckExistsByPath(fullPath, (err, exists) => { + if(exists) { + console.info('Dupe'); + return nextFile(null); + } + + return next(null); + }); + }, + function fullScan() { + fileArea.scanFile( + fullPath, + { + 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}`); + return nextFile(null); // try next anyway + } + + // + // We'll update the entry if the following conditions are met: + // * We have a single duplicate, and: + // * --update was passed or the existing entry's desc, + // longDesc, or est_release_year meta are blank/empty + // + if(argv.update && 1 === dupeEntries.length) { + const FileEntry = require('../../core/file_entry.js'); + const existingEntry = new FileEntry(); + + return existingEntry.load(dupeEntries[0].fileId, err => { + if(err) { + console.info('Dupe (cannot update)'); + return nextFile(null); + } + + // + // Update only if tags or desc changed + // + const optTags = Array.isArray(options.tags) ? new Set(options.tags) : existingEntry.hashTags; + const tagsEq = _.isEqual(optTags, existingEntry.hashTags); + + let descSauceCompare; + if(existingEntry.meta.desc_sauce) { + descSauceCompare = JSON.stringify(existingEntry.meta.desc_sauce); + } + + if( tagsEq && + fileEntry.desc === existingEntry.desc && + fileEntry.descLong === existingEntry.descLong && + fileEntry.meta.est_release_year === existingEntry.meta.est_release_year && + fileEntry.meta.desc_sauce === descSauceCompare + ) + { + console.info('Dupe'); + return nextFile(null); + } + + console.info('Dupe (updating)'); + + // don't allow overwrite of values if new version is blank + existingEntry.desc = fileEntry.desc || existingEntry.desc; + existingEntry.descLong = fileEntry.descLong || existingEntry.descLong; + + if(fileEntry.meta.est_release_year) { + existingEntry.meta.est_release_year = fileEntry.meta.est_release_year; + } + + if(fileEntry.meta.desc_sauce) { + existingEntry.meta.desc_sauce = fileEntry.meta.desc_sauce; + } + + updateTags(existingEntry); + + finalizeEntryAndPersist(true, existingEntry, descHandler, err => { + return nextFile(err); + }); + }); + } else if(dupeEntries.length > 0) { + console.info('Dupe'); + return nextFile(null); + } + + console.info('Done!'); + updateTags(fileEntry); + + finalizeEntryAndPersist(false, fileEntry, descHandler, err => { + return nextFile(err); + }); + } + ); + } + ] + ); + }); + }, err => { + return callback(err); + }); + }); + }, + function scanDbEntries(callback) { + // :TODO: Look @ db entries for area that were *not* processed above + return callback(null); + } + ], + err => { + return nextLocation(err); + } + ); + }, + err => { + return cb(err); + }); } function dumpAreaInfo(areaInfo, areaAndStorageInfo, cb) { - console.info(`areaTag: ${areaInfo.areaTag}`); - console.info(`name: ${areaInfo.name}`); - console.info(`desc: ${areaInfo.desc}`); + console.info(`areaTag: ${areaInfo.areaTag}`); + console.info(`name: ${areaInfo.name}`); + console.info(`desc: ${areaInfo.desc}`); - areaInfo.storage.forEach(si => { - console.info(`storageTag: ${si.storageTag} => ${si.dir}`); - }); - console.info(''); - - return cb(null); + areaInfo.storage.forEach(si => { + console.info(`storageTag: ${si.storageTag} => ${si.dir}`); + }); + console.info(''); + + return cb(null); } function getFileEntries(pattern, cb) { - // spec: FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA - const FileEntry = require('../../core/file_entry.js'); + // spec: FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA + const FileEntry = require('../../core/file_entry.js'); - async.waterfall( - [ - function tryByFileId(callback) { - const fileId = parseInt(pattern); - if(!/^[0-9]+$/.test(pattern) || isNaN(fileId)) { - return callback(null, null); // try SHA - } + async.waterfall( + [ + function tryByFileId(callback) { + const fileId = parseInt(pattern); + if(!/^[0-9]+$/.test(pattern) || isNaN(fileId)) { + return callback(null, null); // try SHA + } - const fileEntry = new FileEntry(); - fileEntry.load(fileId, err => { - return callback(null, err ? null : [ fileEntry ] ); - }); - }, - function tryByShaOrPartialSha(entries, callback) { - if(entries) { - return callback(null, entries); // already got it by FILE_ID - } + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + return callback(null, err ? null : [ fileEntry ] ); + }); + }, + function tryByShaOrPartialSha(entries, callback) { + if(entries) { + return callback(null, entries); // already got it by FILE_ID + } - FileEntry.findFileBySha(pattern, (err, fileEntry) => { - return callback(null, fileEntry ? [ fileEntry ] : null ); - }); - }, - function tryByFileNameWildcard(entries, callback) { - if(entries) { - return callback(null, entries); // already got by FILE_ID|SHA - } + FileEntry.findBySha(pattern, (err, fileEntry) => { + return callback(null, fileEntry ? [ fileEntry ] : null ); + }); + }, + function tryByFileNameWildcard(entries, callback) { + if(entries) { + return callback(null, entries); // already got by FILE_ID|SHA + } - return FileEntry.findByFileNameWildcard(pattern, callback); - } - ], - (err, entries) => { - return cb(err, entries); - } - ); + return FileEntry.findByFileNameWildcard(pattern, callback); + } + ], + (err, entries) => { + return cb(err, entries); + } + ); } function dumpFileInfo(shaOrFileId, cb) { - async.waterfall( - [ - function getEntry(callback) { - getFileEntries(shaOrFileId, (err, entries) => { - if(err) { - return callback(err); - } + async.waterfall( + [ + function getEntry(callback) { + getFileEntries(shaOrFileId, (err, entries) => { + if(err) { + return callback(err); + } - return callback(null, entries[0]); - }); - }, - function dumpInfo(fileEntry, callback) { - const fullPath = paths.join(fileArea.getAreaStorageDirectoryByTag(fileEntry.storageTag), fileEntry.fileName); + return callback(null, entries[0]); + }); + }, + function dumpInfo(fileEntry, callback) { + const fullPath = paths.join(fileArea.getAreaStorageDirectoryByTag(fileEntry.storageTag), fileEntry.fileName); - console.info(`file_id: ${fileEntry.fileId}`); - console.info(`sha_256: ${fileEntry.fileSha256}`); - console.info(`area_tag: ${fileEntry.areaTag}`); - console.info(`storage_tag: ${fileEntry.storageTag}`); - console.info(`path: ${fullPath}`); - console.info(`hashTags: ${Array.from(fileEntry.hashTags).join(', ')}`); - console.info(`uploaded: ${moment(fileEntry.uploadTimestamp).format()}`); - - _.each(fileEntry.meta, (metaValue, metaName) => { - console.info(`${metaName}: ${metaValue}`); - }); + console.info(`file_id: ${fileEntry.fileId}`); + console.info(`sha_256: ${fileEntry.fileSha256}`); + console.info(`area_tag: ${fileEntry.areaTag}`); + console.info(`storage_tag: ${fileEntry.storageTag}`); + console.info(`path: ${fullPath}`); + console.info(`hashTags: ${Array.from(fileEntry.hashTags).join(', ')}`); + console.info(`uploaded: ${moment(fileEntry.uploadTimestamp).format()}`); - if(argv['show-desc']) { - console.info(`${fileEntry.desc}`); - } - console.info(''); + _.each(fileEntry.meta, (metaValue, metaName) => { + console.info(`${metaName}: ${metaValue}`); + }); - return callback(null); - } - ], - err => { - return cb(err); - } - ); + if(argv['show-desc']) { + console.info(`${fileEntry.desc}`); + } + console.info(''); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); } -function displayFileAreaInfo() { - // AREA_TAG[@STORAGE_TAG] - // SHA256|PARTIAL - // if sha: dump file info - // if area/stoarge dump area(s) + +function displayFileOrAreaInfo() { + // AREA_TAG[@STORAGE_TAG] + // SHA256|PARTIAL|FILE_ID|FILENAME_WILDCARD + // if sha: dump file info + // if area/storage dump area(s) + - async.series( - [ - function init(callback) { - return initConfigAndDatabases(callback); - }, - function dumpInfo(callback) { - const Config = require('../../core/config.js').config; - let suppliedAreas = argv._.slice(2); - if(!suppliedAreas || 0 === suppliedAreas.length) { - suppliedAreas = _.map(Config.fileBase.areas, (areaInfo, areaTag) => areaTag); - } + async.series( + [ + function init(callback) { + return initConfigAndDatabases(callback); + }, + function dumpInfo(callback) { + const sysConfig = require('../../core/config.js').get(); + let suppliedAreas = argv._.slice(2); + if(!suppliedAreas || 0 === suppliedAreas.length) { + suppliedAreas = _.map(sysConfig.fileBase.areas, (areaInfo, areaTag) => areaTag); + } - const areaAndStorageInfo = getAreaAndStorage(suppliedAreas); + const areaAndStorageInfo = getAreaAndStorage(suppliedAreas); - fileArea = require('../../core/file_base_area.js'); + fileArea = require('../../core/file_base_area.js'); - async.eachSeries(areaAndStorageInfo, (areaAndStorage, nextArea) => { - const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); - if(areaInfo) { - return dumpAreaInfo(areaInfo, areaAndStorageInfo, nextArea); - } else { - return dumpFileInfo(areaAndStorage.areaTag, nextArea); - } - }, - err => { - return callback(err); - }); - } - ], - err => { - if(err) { - process.exitCode = ExitCodes.ERROR; - console.error(err.message); - } - } - ); + async.eachSeries(areaAndStorageInfo, (areaAndStorage, nextArea) => { + const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); + if(areaInfo) { + return dumpAreaInfo(areaInfo, areaAndStorageInfo, nextArea); + } else { + return dumpFileInfo(areaAndStorage.areaTag, nextArea); + } + }, + err => { + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } + ); } function scanFileAreas() { - const options = {}; + const options = {}; - const tags = argv.tags; - if(tags) { - options.tags = tags.split(','); - } + const tags = argv.tags; + if(tags) { + options.tags = tags.split(','); + } - options.descFile = argv['desc-file']; // --desc-file or --desc-file PATH - - options.areaAndStorageInfo = getAreaAndStorage(argv._.slice(2)); + options.descFile = argv['desc-file']; // --desc-file or --desc-file PATH + options['full'] = argv.full; - async.series( - [ - function init(callback) { - return initConfigAndDatabases(callback); - }, - function initGlobalDescHandler(callback) { - // - // If options.descFile is a String, it represents a FILE|PATH. We'll init - // the description handler now. Else, we'll attempt to look for a description - // file in each storage location. - // - if(!_.isString(options.descFile)) { - return callback(null); - } + options.areaAndStorageInfo = getAreaAndStorage(argv._.slice(2)); - loadDescHandler(options.descFile, (err, descHandler) => { - options.descFileHandler = descHandler; - return callback(null); - }); - }, - function scanAreas(callback) { - fileArea = require('../../core/file_base_area.js'); + const last = argv._[argv._.length - 1]; + if(options.areaAndStorageInfo.length > 1 && looksLikePattern(last)) { + options.glob = last; + options.areaAndStorageInfo.length -= 1; + } - async.eachSeries(options.areaAndStorageInfo, (areaAndStorage, nextAreaTag) => { - const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); - if(!areaInfo) { - return nextAreaTag(new Error(`Invalid file base area tag: ${areaAndStorage.areaTag}`)); - } + async.series( + [ + function init(callback) { + return initConfigAndDatabases(callback); + }, + function initMime(callback) { + return require('../../core/mime_util.js').startup(callback); + }, + function initGlobalDescHandler(callback) { + // + // If options.descFile is a String, it represents a FILE|PATH. We'll init + // the description handler now. Else, we'll attempt to look for a description + // file in each storage location. + // + if(!_.isString(options.descFile)) { + return callback(null); + } - console.info(`Processing area "${areaInfo.name}":`); + loadDescHandler(options.descFile, (err, descHandler) => { + options.descFileHandler = descHandler; + return callback(null); + }); + }, + function scanAreas(callback) { + fileArea = require('../../core/file_base_area'); - scanFileAreaForChanges(areaInfo, options, err => { - return callback(err); - }); - }, err => { - return callback(err); - }); - } - ], - err => { - if(err) { - process.exitCode = ExitCodes.ERROR; - console.error(err.message); - } - } - ); + // Further expand any wildcards + let areaAndStorageInfoExpanded = []; + options.areaAndStorageInfo.forEach(info => { + if (info.areaTag.indexOf('*') > -1) { + const areas = fileArea.getFileAreasByTagWildcardRule(info.areaTag); + areas.forEach(area => { + areaAndStorageInfoExpanded.push(Object.assign({}, info, { + areaTag : area.areaTag, + })); + }); + } else { + areaAndStorageInfoExpanded.push(info); + } + }); + + options.areaAndStorageInfo = areaAndStorageInfoExpanded; + + async.eachSeries(options.areaAndStorageInfo, (areaAndStorage, nextAreaTag) => { + const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); + if(!areaInfo) { + return nextAreaTag(new Error(`Invalid file base area tag: ${areaAndStorage.areaTag}`)); + } + + console.info(`Processing area "${areaInfo.name}":`); + + scanFileAreaForChanges(areaInfo, options, err => { + return callback(err); + }); + }, err => { + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } + ); +} + +function expandFileTargets(targets, cb) { + let entries = []; + + // Each entry may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] + const FileEntry = require('../../core/file_entry.js'); + + async.eachSeries(targets, (areaAndStorage, next) => { + const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); + + if(areaInfo) { + // AREA_TAG[@STORAGE_TAG] - all files in area@tag + const findFilter = { + areaTag : areaAndStorage.areaTag, + }; + + if(areaAndStorage.storageTag) { + findFilter.storageTag = areaAndStorage.storageTag; + } + + FileEntry.findFiles(findFilter, (err, fileIds) => { + if(err) { + return next(err); + } + + async.each(fileIds, (fileId, nextFileId) => { + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + if(!err) { + entries.push(fileEntry); + } + return nextFileId(err); + }); + }, + err => { + return next(err); + }); + }); + + } else { + // FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA + // :TODO: FULL_PATH -> entries + getFileEntries(areaAndStorage.pattern, (err, fileEntries) => { + if(err) { + return next(err); + } + + entries = entries.concat(fileEntries); + return next(null); + }); + } + }, + err => { + return cb(err, entries); + }); } function moveFiles() { - // - // oputil fb move SRC [SRC2 ...] DST - // - // SRC: FILENAME_WC|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] - // DST: AREA_TAG[@STORAGE_TAG] - // - if(argv._.length < 4) { - return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR); - } + // + // oputil fb move SRC [SRC2 ...] DST + // + // SRC: FILENAME_WC|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] + // DST: AREA_TAG[@STORAGE_TAG] + // + if(argv._.length < 4) { + return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR); + } - const moveArgs = argv._.slice(2); - let src = getAreaAndStorage(moveArgs.slice(0, -1)); - let dst = getAreaAndStorage(moveArgs.slice(-1))[0]; - let FileEntry; + const moveArgs = argv._.slice(2); + const src = getAreaAndStorage(moveArgs.slice(0, -1)); + const dst = getAreaAndStorage(moveArgs.slice(-1))[0]; - async.waterfall( - [ - function init(callback) { - return initConfigAndDatabases( err => { - if(!err) { - fileArea = require('../../core/file_base_area.js'); - } - return callback(err); - }); - }, - function validateAndExpandSourceAndDest(callback) { - let srcEntries = []; + let FileEntry; - const areaInfo = fileArea.getFileAreaByTag(dst.areaTag); - if(areaInfo) { - dst.areaInfo = areaInfo; - } else { - return callback(Errors.DoesNotExist('Invalid or unknown destination area')); - } + async.waterfall( + [ + function init(callback) { + return initConfigAndDatabases( err => { + if(!err) { + fileArea = require('../../core/file_base_area.js'); + } + return callback(err); + }); + }, + function validateAndExpandSourceAndDest(callback) { + const areaInfo = fileArea.getFileAreaByTag(dst.areaTag); + if(areaInfo) { + dst.areaInfo = areaInfo; + } else { + return callback(Errors.DoesNotExist('Invalid or unknown destination area')); + } - // Each SRC may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] - FileEntry = require('../../core/file_entry.js'); + FileEntry = require('../../core/file_entry.js'); - async.eachSeries(src, (areaAndStorage, next) => { - const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); + expandFileTargets(src, (err, srcEntries) => { + return callback(err, srcEntries); + }); + }, + function moveEntries(srcEntries, callback) { - if(areaInfo) { - // AREA_TAG[@STORAGE_TAG] - all files in area@tag - src.areaInfo = areaInfo; + if(!dst.storageTag) { + dst.storageTag = dst.areaInfo.storageTags[0]; + } - const findFilter = { - areaTag : areaAndStorage.areaTag, - }; + const destDir = FileEntry.getAreaStorageDirectoryByTag(dst.storageTag); - if(areaAndStorage.storageTag) { - findFilter.storageTag = areaAndStorage.storageTag; - } + async.eachSeries(srcEntries, (entry, nextEntry) => { + const srcPath = entry.filePath; + const dstPath = paths.join(destDir, entry.fileName); - FileEntry.findFiles(findFilter, (err, fileIds) => { - if(err) { - return next(err); - } + process.stdout.write(`Moving ${srcPath} => ${dstPath}... `); - async.each(fileIds, (fileId, nextFileId) => { - const fileEntry = new FileEntry(); - fileEntry.load(fileId, err => { - if(!err) { - srcEntries.push(fileEntry); - } - return nextFileId(err); - }); - }, - err => { - return next(err); - }); - }); - - } else { - // FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA - // :TODO: FULL_PATH -> entries - getFileEntries(areaAndStorage.pattern, (err, entries) => { - if(err) { - return next(err); - } - - srcEntries = srcEntries.concat(entries); - return next(null); - }); - } - }, - err => { - return callback(err, srcEntries); - }); - }, - function moveEntries(srcEntries, callback) { - - if(!dst.storageTag) { - dst.storageTag = dst.areaInfo.storageTags[0]; - } - - const destDir = FileEntry.getAreaStorageDirectoryByTag(dst.storageTag); - - async.eachSeries(srcEntries, (entry, nextEntry) => { - const srcPath = entry.filePath; - const dstPath = paths.join(destDir, entry.fileName); - - process.stdout.write(`Moving ${srcPath} => ${dstPath}... `); - - FileEntry.moveEntry(entry, dst.areaTag, dst.storageTag, err => { - if(err) { - console.info(`Failed: ${err.message}`); - } else { - console.info('Done'); - } - return nextEntry(null); // always try next - }); - }, - err => { - return callback(err); - }); - } - ] - ); + FileEntry.moveEntry(entry, dst.areaTag, dst.storageTag, err => { + if(err) { + console.info(`Failed: ${err.message}`); + } else { + console.info('Done'); + } + return nextEntry(null); // always try next + }); + }, + err => { + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } + ); } function removeFiles() { - // - // REMOVE SHA|FILE_ID [SHA|FILE_ID ...] + // + // oputil fb rm|remove|del|delete SRC [SRC2 ...] + // + // SRC: FILENAME_WC|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] + // + // AREA_TAG[@STORAGE_TAG] remove all entries matching + // supplied area/storage tags + // + // --phys-file removes backing physical file(s) + // + if(argv._.length < 3) { + return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR); + } + + const removePhysFile = argv['phys-file']; + + const src = getAreaAndStorage(argv._.slice(2)); + + async.waterfall( + [ + function init(callback) { + return initConfigAndDatabases( err => { + if(!err) { + fileArea = require('../../core/file_base_area.js'); + } + return callback(err); + }); + }, + function expandSources(callback) { + expandFileTargets(src, (err, srcEntries) => { + return callback(err, srcEntries); + }); + }, + function removeEntries(srcEntries, callback) { + const FileEntry = require('../../core/file_entry.js'); + + const extraOutput = removePhysFile ? ' (including physical file)' : ''; + + async.eachSeries(srcEntries, (entry, nextEntry) => { + + process.stdout.write(`Removing ${entry.filePath}${extraOutput}... `); + + FileEntry.removeEntry(entry, { removePhysFile }, err => { + if(err) { + console.info(`Failed: ${err.message}`); + } else { + console.info('Done'); + } + + return nextEntry(err); + }); + }, err => { + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } + ); +} + +function getFileBaseImportType(path) { + if(argv.type) { + return argv.type.toLowerCase(); + } + + return paths.extname(path).substr(1).toLowerCase(); // zxx, ... +} + +function importFileAreas() { + // + // FILEGATE.ZXX "RAID" format currently the only supported format. + // + // See http://www.filegate.net/info/filegate.zxx + // ...same format as FILEBONE.NA: + // http://wiki.mysticbbs.com/doku.php?id=mutil_import_filebone_na + // + const importPath = argv._[argv._.length - 1]; + if(argv._.length < 3 || !importPath || 0 === importPath.length) { + return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR); + } + + const importType = getFileBaseImportType(importPath); + if(!['zxx', 'na'].includes(importType)) { + return console.error(`"${importType}" is not a recognized import file type`); + } + + const createDirs = argv['create-dirs']; + // :TODO: --base-dir (override config base/relative dir; use full paths) + + async.waterfall( + [ + (callback) => { + fs.readFile(importPath, 'utf8', (err, importData) => { + if(err) { + return callback(err); + } + + const importInfo = { + storageTags : {}, + areas : {}, + count : 0, + }; + + const re = /Area\s+([^\s]+)\s+[0-9]\s+(?:!|\*&)\s+([^\r\n]+)/gm; + let m; + while((m = re.exec(importData))) { + const dir = m[1].trim(); + const name = m[2].trim(); + const safeName = sanatizeFilename(name); + + const stPrefix = _.snakeCase(sanatizeFilename(safeName)); + const storageTag = `${stPrefix}__${_.snakeCase(sanatizeFilename(dir))}`; + const areaTag = _.snakeCase(safeName); + + if(!dir || !name || !storageTag || !areaTag) { + console.info(`Skipping entry: ${m[0]}`); + continue; + } + + importInfo.storageTags[storageTag] = dir; + importInfo.areas[areaTag] = { + name : name, + desc : name, + storageTags : [ storageTag ], + }; + ++importInfo.count; + } + + if(0 === importInfo.count) { + return callback(new Error('Nothing to import')); + } + + return callback(null, importInfo); + }); + }, + (importInfo, callback) => { + return initConfigAndDatabases(err => { + return callback(err, importInfo); + }); + }, + (importInfo, callback) => { + console.info(`Read to import the following ${importInfo.count} areas:`); + console.info(''); + _.each(importInfo.areas, (area, areaTag) => { + console.info(`${area.name} (${areaTag}):`); + const dir = importInfo.storageTags[area.storageTags[0]]; + console.info(` storage: ${area.storageTags[0]} => ${dir}`); + }); + + getAnswers([ + { + name : 'proceed', + message : 'Proceed?', + type : 'confirm', + } + ], + answers => { + if(answers.proceed) { + return callback(null, importInfo); + } + return callback(Errors.General('User canceled')); + }); + }, + (importInfo, callback) => { + fs.readFile(getConfigPath(), 'utf8', (err, configData) => { + if(err) { + return callback(err); + } + let config; + try { + config = hjson.rt.parse(configData); + } catch(e) { + return callback(e); + } + return callback(null, importInfo, config); + }); + }, + (importInfo, config, callback) => { + const newStorageTagDirs = []; + _.each(importInfo.areas, (area, areaTag) => { + const existingArea = _.get(config, [ 'fileBase', 'areas', areaTag ]); + if(existingArea) { + return console.info(`Skipping ${area.name}. Area tag "${areaTag}" already exists.`); + } + + const storageTag = area.storageTags[0]; + const existingStorageTag = _.get(config, [ 'fileBase', 'storageTags', storageTag ]); + if(existingStorageTag) { + return console.info(`Skipping ${area.name} (${areaTag}). Storage tag "${storageTag}" already exists`); + } + + const dir = importInfo.storageTags[storageTag]; + newStorageTagDirs.push(dir); + + config.fileBase.storageTags[storageTag] = dir; + config.fileBase.areas[areaTag] = area; + }); + + return callback(null, newStorageTagDirs, config); + }, + (newStorageTagDirs, config, callback) => { + if(!createDirs) { + return callback(null, config); + } + + // + // Create all directories + // + const prefixDir = config.fileBase.areaStoragePrefix; + async.eachSeries(newStorageTagDirs, (dir, nextDir) => { + const isAbs = paths.isAbsolute(dir); + if(!isAbs) { + dir = paths.join(prefixDir, dir); + } + mkdirs(dir, err => { + if(!err) { + console.log(`Created ${dir}`); + } + return nextDir(err); + }); + }, + err => { + return callback(err, config); + }); + }, + (config, callback) => { + const written = writeConfig(config, getConfigPath()); + return callback(written ? null : new Error('Failed to write config!')); + } + ], + err => { + if(err) { + return console.error(err.reason ? err.reason : err.message); + } + + console.info('Import complete.'); + console.info(`You may wish to validate changes made to ${getConfigPath()}`); + } + ); +} + +function setFileDescription() { + // + // ./oputil.js fb set-desc CRITERIA # will prompt + // ./oputil.js fb set-desc CRITERIA "The new description" + // + let fileCriteria; + let desc; + if(argv._.length > 3) { + fileCriteria = argv._[argv._.length - 2]; + desc = argv._[argv._.length - 1]; + } else { + fileCriteria = argv._[argv._.length - 1]; + } + + async.waterfall( + [ + (callback) => { + return initConfigAndDatabases(callback); + }, + (callback) => { + getFileEntries(fileCriteria, (err, entries) => { + if(err) { + return callback(err); + } + + if(entries.length > 1) { + return callback(Errors.General('Criteria not specific enough.')); + } + + return callback(null, entries[0]); + }); + }, + (fileEntry, callback) => { + if(desc) { + return callback(null, fileEntry, desc); + } + + getAnswers([ + { + name : 'userDesc', + message : 'Description:', + type : 'editor', + } + ], + answers => { + if(!answers.userDesc) { + return callback(Errors.General('User canceled')); + } + return callback(null, fileEntry, answers.userDesc); + }); + }, + (fileEntry, newDesc, callback) => { + fileEntry.desc = newDesc; + fileEntry.persist(true, err => { // true=isUpdate + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } else { + console.info('Description updated.'); + } + } + ); } function handleFileBaseCommand() { - function errUsage() { - return printUsageAndSetExitCode( - getHelpFor('FileBase') + getHelpFor('FileOpsInfo'), - ExitCodes.ERROR - ); - } + function errUsage() { + return printUsageAndSetExitCode( + getHelpFor('FileBase') + getHelpFor('FileOpsInfo'), + ExitCodes.ERROR + ); + } - if(true === argv.help) { - return errUsage(); - } + if(true === argv.help) { + return errUsage(); + } - const action = argv._[1]; + const action = argv._[1]; - return ({ - info : displayFileAreaInfo, - scan : scanFileAreas, - move : moveFiles, - remove : removeFiles, - }[action] || errUsage)(); + return ({ + info : displayFileOrAreaInfo, + scan : scanFileAreas, + + mv : moveFiles, + move : moveFiles, + + rm : removeFiles, + remove : removeFiles, + del : removeFiles, + delete : removeFiles, + + 'import-areas' : importFileAreas, + + desc : setFileDescription, + description : setFileDescription, + }[action] || errUsage)(); } \ No newline at end of file diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index 5c20e3a5..50bc3b5e 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -7,83 +7,204 @@ const getDefaultConfigPath = require('./oputil_common.js').getDefaultConfigPat exports.getHelpFor = getHelpFor; const usageHelp = exports.USAGE_HELP = { - General : -`usage: optutil.js [--version] [--help] - [] + General : +`usage: oputil.js [--version] [--help] + [] -global args: - -c, --config PATH specify config path (${getDefaultConfigPath()}) - -n, --no-prompt assume defaults/don't prompt for input where possible +Global arguments: + -c, --config PATH Specify config path (default is ${getDefaultConfigPath()}) + -n, --no-prompt Assume defaults (don't prompt for input where possible) + --verbose Verbose output, where applicable -commands: - user user utilities - config config file management - fb file base management +Commands: + user User management + config Configuration management + fb File base management + mb Message base management `, - User : -`usage: optutil.js user --user USERNAME + User : +`usage: oputil.js user [] -valid args: - --user USERNAME specify username for further actions - --password PASS set new password - --delete delete user - --activate activate user - --deactivate deactivate user +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 + + list [FILTER] List users with optional FILTER. + + Valid filters: + all : All users (default). + disabled : Disabled users. + inactive : Inactive users. + active : Active (regular) users. + locked : Locked users. + +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: optutil.js config [] + Config : +`usage: oputil.js config [] -actions: - new generate a new/initial configuration - import-areas PATH import areas using fidonet *.NA or AREAS.BBS file from PATH +Actions: + new Generate a new / default configuration -import-areas args: - --conf CONF_TAG specify conference tag in which to import areas - --network NETWORK specify network name/key to associate FTN areas - --uplinks UL1,UL2,... specify one or more comma separated uplinks - --type TYPE specifies area import type. valid options are "bbs" and "na" + cat Write current configuration to stdout + +cat arguments: + --no-color Disable color + --no-comments Strip any comments `, - FileBase : -`usage: oputil.js fb [] [] + FileBase : +`usage: oputil.js fb [] -actions: - scan AREA_TAG[@STORAGE_TAG] scan specified area +Actions: + scan AREA_TAG[@STORAGE_TAG] Scan specified area - info AREA_TAG|SHA|FILE_ID display information about areas and/or files - SHA may be a full or partial SHA-256 + Tips: + - May contain optional GLOB as last parameter. + Example: ./oputil.js fb scan d0pew4r3z *.zip - move 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] + - AREA_TAG may contain simple wildcards. + Example: ./oputil.js fb scan *warez* - remove SHA|FILE_ID removes a entry from the system + info CRITERIA Display information about areas and/or files -scan args: - --tags TAG1,TAG2,... specify tag(s) to assign to discovered entries - --desc-file [PATH] prefer file descriptions from DESCRIPT.ION file over - other sources such as FILE_ID.DIZ. - if PATH is specified, use DESCRIPT.ION at PATH instead - of looking in specific storage locations + mv SRC [SRC...] DST Move matching entry(s) + (move) -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 + Destination is area tag with optional @storageTag suffix -remove args: - --delete also remove underlying physical file + rm SRC [SRC...] Remove entry(s) from the system + (del|delete|remove) + + Source may be any of the following: + - Filename including '*' wildcards + - SHA-1 + - File ID + - Area tag with optional @storageTag suffix + + desc CRITERIA Updates an file base entry's description + + 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 : + FileOpsInfo : ` -general information: - AREA_TAG[@STORAGE_TAG] can specify an area tag and optionally, a storage specific tag - example: retro@bbs - - 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 +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. +`, + MessageBase : +`usage: oputil.js mb [] + +Actions: + areafix CMD1 CMD2 ... ADDR Sends an AreaFix NetMail + + 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 + + qwk-dump PATH Dumps a QWK packet to stdout. + qwk-export [AREA_TAGS] PATH Exports one or more configured message area to a QWK + packet in the directory specified by PATH. The QWK + BBS ID will be obtained by the final component of PATH. + +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". + +qwk-export arguments: + --user USER User in which to export for. Defaults to the SysOp. + --after TIMESTAMP Export only messages with a timestamp later than + TIMESTAMP. + --no-qwke Disable QWKE extensions. + --no-synchronet Disable Synchronet style extensions. ` }; function getHelpFor(command) { - return usageHelp[command]; -} \ No newline at end of file + return usageHelp[command]; +} diff --git a/core/oputil/oputil_main.js b/core/oputil/oputil_main.js index 83af6b5e..aafc8ef1 100644 --- a/core/oputil/oputil_main.js +++ b/core/oputil/oputil_main.js @@ -7,38 +7,30 @@ const argv = require('./oputil_common.js').argv; const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; const handleUserCommand = require('./oputil_user.js').handleUserCommand; const handleFileBaseCommand = require('./oputil_file_base.js').handleFileBaseCommand; +const handleMessageBaseCommand = require('./oputil_message_base.js').handleMessageBaseCommand; const handleConfigCommand = require('./oputil_config.js').handleConfigCommand; const getHelpFor = require('./oputil_help.js').getHelpFor; module.exports = function() { - process.exitCode = ExitCodes.SUCCESS; + process.exitCode = ExitCodes.SUCCESS; - if(true === argv.version) { - return console.info(require('../package.json').version); - } + if(true === argv.version) { + return console.info(require('../../package.json').version); + } - if(0 === argv._.length || + if(0 === argv._.length || 'help' === argv._[0]) - { - return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.SUCCESS); - } + { + return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.SUCCESS); + } - switch(argv._[0]) { - case 'user' : - handleUserCommand(); - break; - - case 'config' : - handleConfigCommand(); - break; - - case 'fb' : - handleFileBaseCommand(); - break; - - default: - return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.BAD_COMMAND); - } + switch(argv._[0]) { + case 'user' : return handleUserCommand(); + case 'config' : return handleConfigCommand(); + case 'fb' : return handleFileBaseCommand(); + case 'mb' : return handleMessageBaseCommand(); + default : return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.BAD_COMMAND); + } }; diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js new file mode 100644 index 00000000..1790b1e5 --- /dev/null +++ b/core/oputil/oputil_message_base.js @@ -0,0 +1,654 @@ +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const { + printUsageAndSetExitCode, + getConfigPath, + ExitCodes, + argv, + initConfigAndDatabases, + getAnswers, + writeConfig, +} = require('./oputil_common.js'); + +const getHelpFor = require('./oputil_help.js').getHelpFor; +const Address = require('../ftn_address.js'); +const Errors = require('../enig_error.js').Errors; + +// deps +const async = require('async'); +const paths = require('path'); +const fs = require('fs'); +const hjson = require('hjson'); +const _ = require('lodash'); +const moment = require('moment'); + +exports.handleMessageBaseCommand = handleMessageBaseCommand; + +function areaFix() { + // + // oputil mb areafix CMD1 CMD2 ... ADDR [--password PASS] + // + if(argv._.length < 3) { + return printUsageAndSetExitCode( + getHelpFor('MessageBase'), + ExitCodes.ERROR + ); + } + + async.waterfall( + [ + function init(callback) { + return initConfigAndDatabases(callback); + }, + function validateAddress(callback) { + const addrArg = argv._.slice(-1)[0]; + const ftnAddr = Address.fromString(addrArg); + + if(!ftnAddr) { + return callback(Errors.Invalid(`"${addrArg}" is not a valid FTN address`)); + } + + // + // We need to validate the address targets a system we know unless + // the --force option is used + // + // :TODO: + return callback(null, ftnAddr); + }, + function fetchFromUser(ftnAddr, callback) { + // + // --from USER || +op from system + // + // If possible, we want the user ID of the supplied user as well + // + const User = require('../user.js'); + + if(argv.from) { + User.getUserIdAndNameByLookup(argv.from, (err, userId, fromName) => { + if(err) { + return callback(null, ftnAddr, argv.from, 0); + } + + // fromName is the same as argv.from, but case may be differnet (yet correct) + return callback(null, ftnAddr, fromName, userId); + }); + } else { + User.getUserName(User.RootUserID, (err, fromName) => { + return callback(null, ftnAddr, fromName || 'SysOp', err ? 0 : User.RootUserID); + }); + } + }, + function createMessage(ftnAddr, fromName, fromUserId, callback) { + // + // Build message as commands separated by line feed + // + // We need to remove quotes from arguments. These are required + // in the case of e.g. removing an area: "-SOME_AREA" would end + // up confusing minimist, therefor they must be quoted: "'-SOME_AREA'" + // + const messageBody = argv._.slice(2, -1).map(arg => { + return arg.replace(/["']/g, ''); + }).join('\r\n') + '\n'; + + const Message = require('../message.js'); + + const message = new Message({ + toUserName : argv.to || 'AreaFix', + fromUserName : fromName, + subject : argv.password || '', + message : messageBody, + areaTag : Message.WellKnownAreaTags.Private, // mark private + meta : { + System : { + [ Message.SystemMetaNames.RemoteToUser ] : ftnAddr.toString(), // where to send it + [ Message.SystemMetaNames.ExternalFlavor ] : Message.AddressFlavor.FTN, // on FTN-style network + } + } + }); + + if(0 !== fromUserId) { + message.setLocalFromUserId(fromUserId); + } + + return callback(null, message); + }, + function persistMessage(message, callback) { + message.persist(err => { + if(!err) { + console.log('AreaFix message persisted and will be exported at next scheduled scan'); + } + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(`${err.message}${err.reason ? ': ' + err.reason : ''}`); + } + } + ); +} + +function validateUplinks(uplinks) { + const ftnAddress = require('../../core/ftn_address.js'); + const valid = uplinks.every(ul => { + const addr = ftnAddress.fromString(ul); + return addr; + }); + return valid; +} + +function getMsgAreaImportType(path) { + if(argv.type) { + return argv.type.toLowerCase(); + } + + return paths.extname(path).substr(1).toLowerCase(); // bbs|na|... +} + +function importAreas() { + const importPath = argv._[argv._.length - 1]; + if(argv._.length < 3 || !importPath || 0 === importPath.length) { + return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR); + } + + const importType = getMsgAreaImportType(importPath); + if('na' !== importType && 'bbs' !== importType) { + return console.error(`"${importType}" is not a recognized import file type`); + } + + // optional data - we'll prompt if for anything not found + let confTag = argv.conf; + let networkName = argv.network; + let uplinks = argv.uplinks; + if(uplinks) { + uplinks = uplinks.split(/[\s,]+/); + } + + let importEntries; + + async.waterfall( + [ + function readImportFile(callback) { + fs.readFile(importPath, 'utf8', (err, importData) => { + if(err) { + return callback(err); + } + + importEntries = getImportEntries(importType, importData); + if(0 === importEntries.length) { + return callback(Errors.Invalid('Invalid or empty import file')); + } + + // We should have enough to validate uplinks + if('bbs' === importType) { + for(let i = 0; i < importEntries.length; ++i) { + if(!validateUplinks(importEntries[i].uplinks)) { + return callback(Errors.Invalid('Invalid uplink(s)')); + } + } + } else { + if(!validateUplinks(uplinks || [])) { + return callback(Errors.Invalid('Invalid uplink(s)')); + } + } + + return callback(null); + }); + }, + function init(callback) { + return initConfigAndDatabases(callback); + }, + function validateAndCollectInput(callback) { + const msgArea = require('../../core/message_area.js'); + const sysConfig = require('../../core/config.js').get(); + + let msgConfs = msgArea.getSortedAvailMessageConferences(null, { noClient : true } ); + if(!msgConfs) { + return callback(Errors.DoesNotExist('No conferences exist in your configuration')); + } + + msgConfs = msgConfs.map(mc => { + return { + name : mc.conf.name, + value : mc.confTag, + }; + }); + + if(confTag && !msgConfs.find(mc => { + return confTag === mc.value; + })) + { + return callback(Errors.DoesNotExist(`Conference "${confTag}" does not exist`)); + } + + const existingNetworkNames = Object.keys(_.get(sysConfig, 'messageNetworks.ftn.networks', {})); + + if(networkName && !existingNetworkNames.find(net => networkName === net)) { + return callback(Errors.DoesNotExist(`FTN style Network "${networkName}" does not exist`)); + } + + // can't use --uplinks without a network + if(!networkName && 0 === existingNetworkNames.length && uplinks) { + return callback(Errors.Invalid('Cannot use --uplinks without an FTN network to import to')); + } + + getAnswers([ + { + name : 'confTag', + message : 'Message conference:', + type : 'list', + choices : msgConfs, + pageSize : 10, + when : !confTag, + }, + { + name : 'networkName', + message : 'FTN network name:', + type : 'list', + choices : [ '-None-' ].concat(existingNetworkNames), + pageSize : 10, + when : !networkName && existingNetworkNames.length > 0, + filter : (choice) => { + return '-None-' === choice ? undefined : choice; + } + }, + ], + answers => { + confTag = confTag || answers.confTag; + networkName = networkName || answers.networkName; + uplinks = uplinks || answers.uplinks; + + importEntries.forEach(ie => { + ie.areaTag = ie.ftnTag.toLowerCase(); + }); + + return callback(null); + }); + }, + function collectUplinks(callback) { + if(!networkName || uplinks || 'bbs' === importType) { + return callback(null); + } + + getAnswers([ + { + name : 'uplinks', + message : 'Uplink(s) (comma separated):', + type : 'input', + validate : (input) => { + const inputUplinks = input.split(/[\s,]+/); + return validateUplinks(inputUplinks) ? true : 'Invalid uplink(s)'; + }, + } + ], + answers => { + uplinks = answers.uplinks; + return callback(null); + }); + }, + function confirmWithUser(callback) { + const sysConfig = require('../../core/config.js').get(); + + console.info(`Importing the following for "${confTag}"`); + console.info(`(${sysConfig.messageConferences[confTag].name} - ${sysConfig.messageConferences[confTag].desc})`); + console.info(''); + importEntries.forEach(ie => { + console.info(` ${ie.ftnTag} - ${ie.name}`); + }); + + if(networkName) { + console.info(''); + console.info(`For FTN network: ${networkName}`); + console.info(`Uplinks: ${uplinks}`); + console.info(''); + console.info('Importing will NOT create required FTN network configurations.'); + console.info('If you have not yet done this, you will need to complete additional steps after importing.'); + console.info('See Message Networks docs for details.'); + console.info(''); + } + + getAnswers([ + { + name : 'proceed', + message : 'Proceed?', + type : 'confirm', + } + ], + answers => { + return callback(answers.proceed ? null : Errors.General('User canceled')); + }); + + }, + function loadConfigHjson(callback) { + const configPath = getConfigPath(); + fs.readFile(configPath, 'utf8', (err, confData) => { + if(err) { + return callback(err); + } + + let config; + try { + config = hjson.parse(confData, { keepWsc : true } ); + } catch(e) { + return callback(e); + } + return callback(null, config); + + }); + }, + function performImport(config, callback) { + const confAreas = { messageConferences : {} }; + confAreas.messageConferences[confTag] = { areas : {} }; + + const msgNetworks = { messageNetworks : { ftn : { areas : {} } } }; + + importEntries.forEach(ie => { + const specificUplinks = ie.uplinks || uplinks; // AREAS.BBS has specific uplinks per area + + confAreas.messageConferences[confTag].areas[ie.areaTag] = { + name : ie.name, + desc : ie.name, + }; + + if(networkName) { + msgNetworks.messageNetworks.ftn.areas[ie.areaTag] = { + network : networkName, + tag : ie.ftnTag, + uplinks : specificUplinks + }; + } + }); + + + const newConfig = _.defaultsDeep(config, confAreas, msgNetworks); + const configPath = getConfigPath(); + + if(!writeConfig(newConfig, configPath)) { + return callback(Errors.UnexpectedState('Failed writing configuration')); + } + + return callback(null); + } + ], + err => { + if(err) { + console.error(err.reason ? err.reason : err.message); + } else { + const addFieldUpd = 'bbs' === importType ? '"name" and "desc"' : '"desc"'; + console.info('Import complete.'); + console.info(`You may wish to validate changes made to ${getConfigPath()}`); + console.info(`as well as update ${addFieldUpd} fields, sorting, etc.`); + console.info(''); + } + } + ); +} + +function getImportEntries(importType, importData) { + let importEntries = []; + + if('na' === importType) { + // + // parse out + // TAG DESC + // + const re = /^([^\s]+)\s+([^\r\n]+)/gm; + let m; + + while( (m = re.exec(importData) )) { + importEntries.push({ + ftnTag : m[1].trim(), + name : m[2].trim(), + }); + } + } else if ('bbs' === importType) { + // + // Various formats for AREAS.BBS seem to exist. We want to support as much as possible. + // + // SBBS http://www.synchro.net/docs/sbbsecho.html#AREAS.BBS + // CODE TAG UPLINKS + // + // VADV https://www.vadvbbs.com/products/vadv/support/docs/docs_vfido.php#AREAS.BBS + // TAG UPLINKS + // + // Misc + // PATH|OTHER TAG UPLINKS + // + // Assume the second item is TAG and 1:n UPLINKS (space and/or comma sep) after (at the end) + // + const re = /^[^\s]+\s+([^\s]+)\s+([^\n]+)$/gm; + let m; + while ( (m = re.exec(importData) )) { + const tag = m[1].trim(); + + importEntries.push({ + ftnTag : tag, + name : `Area: ${tag}`, + uplinks : m[2].trim().split(/[\s,]+/), + }); + } + } + + return importEntries; +} + +function dumpQWKPacket() { + const packetPath = argv._[argv._.length - 1]; + if(argv._.length < 3 || !packetPath || 0 === packetPath.length) { + return printUsageAndSetExitCode(getHelpFor('MessageBase'), ExitCodes.ERROR); + } + + async.waterfall( + [ + (callback) => { + return initConfigAndDatabases(callback); + }, + (callback) => { + const { QWKPacketReader } = require('../qwk_mail_packet'); + const reader = new QWKPacketReader(packetPath); + + reader.on('error', err => { + console.error(`ERROR: ${err.message}`); + return callback(err); + }); + + reader.on('done', () => { + return callback(null); + }); + + reader.on('archive type', archiveType => { + console.info(`-> Archive type: ${archiveType}`); + }); + + reader.on('creator', creator => { + console.info(`-> Creator: ${creator}`); + }); + + reader.on('message', message => { + console.info('--- message ---'); + console.info(`To: ${message.toUserName}`); + console.info(`From: ${message.fromUserName}`); + console.info(`Subject: ${message.subject}`); + console.info(`Message:\r\n${message.message}`); + }); + + reader.read(); + } + ], + err => { + + } + ) +} + +function exportQWKPacket() { + let packetPath = argv._[argv._.length - 1]; + if(argv._.length < 3 || !packetPath || 0 === packetPath.length) { + return printUsageAndSetExitCode(getHelpFor('MessageBase'), ExitCodes.ERROR); + } + + // oputil mb qwk-export TAGS PATH [--user USER] [--after TIMESTAMP] + // [areaTag1,areaTag2,...] PATH --user USER --after TIMESTAMP + let bbsID = 'ENIGMA'; + const filename = paths.basename(packetPath); + if (filename) { + const ext = paths.extname(filename); + bbsID = paths.basename(filename, ext); + } + + packetPath = paths.dirname(packetPath); + + const posArgLen = argv._.length; + + let areaTags; + if (4 === posArgLen) { + areaTags = argv._[posArgLen - 2].split(','); + } else { + areaTags = []; + } + + let newerThanTimestamp = null; + if (argv.after) { + const ts = moment(argv.after); + if (ts.isValid()) { + newerThanTimestamp = ts.format(); + } + } + + const userName = argv.user || '-'; + + const writerOptions = { + enableQWKE : !(false === argv.qwke), + enableHeadersExtension : !(false === argv.synchronet), + enableAtKludges : !(false === argv.synchronet), + archiveFormat : argv.format || 'application/zip' + }; + + let totalExported = 0; + async.waterfall( + [ + (callback) => { + return initConfigAndDatabases(callback); + }, + (callback) => { + const User = require('../../core/user.js'); + + User.getUserIdAndName(userName, (err, userId) => { + if (err) { + if ('-' === userName) { + userId = 1; + } else { + return callback(err); + } + } + return User.getUser(userId, callback); + }); + }, + (user, callback) => { + // populate area tags with all available to user + // if they were not explicitly supplied + if (!areaTags.length) { + const { + getAllAvailableMessageAreaTags + } = require('../../core/message_area'); + + areaTags = getAllAvailableMessageAreaTags(); + } + return callback(null, user); + }, + (user, callback) => { + const Message = require('../message'); + + const filter = { + resultType : 'id', + areaTag : areaTags, + newerThanTimestamp, + }; + + // public + Message.findMessages(filter, (err, publicMessageIds) => { + if (err) { + return callback(err); + } + + delete filter.areaTag; + filter.privateTagUserId = user.userId; + + Message.findMessages(filter, (err, privateMessageIds) => { + return callback(err, user, Message, privateMessageIds.concat(publicMessageIds)); + }); + }); + }, + (user, Message, messageIds, callback) => { + const { QWKPacketWriter } = require('../qwk_mail_packet'); + const writer = new QWKPacketWriter(Object.assign(writerOptions, { + bbsID, + user, + })); + + writer.on('ready', () => { + async.eachSeries(messageIds, (messageId, nextMessageId) => { + const message = new Message(); + message.load( { messageId }, err => { + if (!err) { + writer.appendMessage(message); + ++totalExported; + } + return nextMessageId(err); + }); + }, + (err) => { + writer.finish(packetPath); + if (err) { + console.error(`Failed to write one or more messages: ${err.message}`); + } + }); + }); + + writer.on('warning', err => { + console.warn(`!!! ${err.reason ? err.reason : err.message}`); + }); + + writer.on('finished', () => { + return callback(null); + }); + + writer.init(); + } + ], + err => { + if(err) { + return console.error(err.reason ? err.reason : err.message); + } + + console.info(`-> Exported ${totalExported} messages`); + } + ); +} + +function handleMessageBaseCommand() { + + function errUsage() { + return printUsageAndSetExitCode( + getHelpFor('MessageBase'), + ExitCodes.ERROR + ); + } + + if(true === argv.help) { + return errUsage(); + } + + const action = argv._[1]; + + return({ + areafix : areaFix, + 'import-areas' : importAreas, + 'qwk-dump' : dumpQWKPacket, + 'qwk-export' : exportQWKPacket, + }[action] || errUsage)(); +} \ No newline at end of file diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js index afe243bf..07f9227a 100644 --- a/core/oputil/oputil_user.js +++ b/core/oputil/oputil_user.js @@ -2,112 +2,582 @@ /* eslint-disable no-console */ 'use strict'; -const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; -const ExitCodes = require('./oputil_common.js').ExitCodes; -const argv = require('./oputil_common.js').argv; -const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; +const { + printUsageAndSetExitCode, + getAnswers, + ExitCodes, + argv, + initConfigAndDatabases +} = require('./oputil_common.js'); const getHelpFor = require('./oputil_help.js').getHelpFor; +const Errors = require('../enig_error.js').Errors; +const UserProps = require('../user_property.js'); const async = require('async'); const _ = require('lodash'); +const moment = require('moment'); +const fs = require('fs-extra'); exports.handleUserCommand = handleUserCommand; -function handleUserCommand() { - if(true === argv.help || !_.isString(argv.user) || 0 === argv.user.length) { - return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); - } - - if(_.isString(argv.password)) { - if(0 === argv.password.length) { - process.exitCode = ExitCodes.BAD_ARGS; - return console.error('Invalid password'); - } - - async.waterfall( - [ - function init(callback) { - initAndGetUser(argv.user, callback); - }, - function setNewPass(user, callback) { - user.setNewAuthCredentials(argv.password, function credsSet(err) { - if(err) { - process.exitCode = ExitCodes.ERROR; - callback(new Error('Failed setting password')); - } else { - callback(null); - } - }); - } - ], - function complete(err) { - if(err) { - console.error(err.message); - } else { - console.info('Password set'); - } - } - ); - } else if(argv.activate) { - setAccountStatus(argv.user, true); - } else if(argv.deactivate) { - setAccountStatus(argv.user, false); - } -} - -function getUser(userName, cb) { - const User = require('../../core/user.js'); - User.getUserIdAndName(argv.user, function userNameAndId(err, userId) { - if(err) { - process.exitCode = ExitCodes.BAD_ARGS; - return cb(new Error('Failed to retrieve user')); - } else { - let u = new User(); - u.userId = userId; - return cb(null, u); - } - }); -} - function initAndGetUser(userName, cb) { - async.waterfall( - [ - function init(callback) { - initConfigAndDatabases(callback); - }, - function getUserObject(callback) { - getUser(argv.user, (err, user) => { - if(err) { - process.exitCode = ExitCodes.BAD_ARGS; - return callback(err); - } - return callback(null, user); - }); - } - ], - (err, user) => { - return cb(err, user); - } - ); + async.waterfall( + [ + function init(callback) { + initConfigAndDatabases(callback); + }, + function getUserObject(callback) { + const User = require('../../core/user.js'); + User.getUserIdAndName(userName, (err, userId) => { + if(err) { + return callback(err); + } + return User.getUser(userId, callback); + }); + } + ], + (err, user) => { + return cb(err, user); + } + ); } -function setAccountStatus(userName, active) { - async.waterfall( - [ - function init(callback) { - initAndGetUser(argv.user, callback); - }, - function activateUser(user, callback) { - const AccountStatus = require('../../core/user.js').AccountStatus; - user.persistProperty('account_status', active ? AccountStatus.active : AccountStatus.inactive, callback); - } - ], - err => { - if(err) { - console.error(err.message); - } else { - console.info('User ' + ((true === active) ? 'activated' : 'deactivated')); - } - } - ); +function setAccountStatus(user, status) { + if(argv._.length < 3) { + return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); + } + + const AccountStatus = require('../../core/user.js').AccountStatus; + + status = { + activate : AccountStatus.active, + deactivate : AccountStatus.inactive, + disable : AccountStatus.disabled, + lock : AccountStatus.locked, + }[status]; + + const statusDesc = _.invert(AccountStatus)[status]; + + async.series( + [ + (callback) => { + return user.persistProperty(UserProps.AccountStatus, status, callback); + }, + (callback) => { + if(AccountStatus.active !== status) { + return callback(null); + } + + return user.unlockAccount(callback); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } else { + console.info(`User status set to ${statusDesc}`); + } + } + ); +} + +function setUserPassword(user) { + if(argv._.length < 4) { + return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); + } + + async.waterfall( + [ + function validate(callback) { + // :TODO: prompt if no password provided (more secure, no history, etc.) + const password = argv._[argv._.length - 1]; + if(0 === password.length) { + return callback(Errors.Invalid('Invalid password')); + } + return callback(null, password); + }, + function set(password, callback) { + user.setNewAuthCredentials(password, err => { + if(err) { + process.exitCode = ExitCodes.BAD_ARGS; + } + return callback(err); + }); + } + ], + err => { + if(err) { + console.error(err.message); + } else { + console.info('New password set'); + } + } + ); +} + +function removeUserRecordsFromDbAndTable(dbName, tableName, userId, col, cb) { + const db = require('../../core/database.js').dbs[dbName]; + db.run( + `DELETE FROM ${tableName} + WHERE ${col} = ?;`, + [ userId ], + err => { + return cb(err); + } + ); +} + +function removeUser(user) { + async.series( + [ + (callback) => { + if(user.isRoot()) { + return callback(Errors.Invalid('Cannot delete root/SysOp user!')); + } + + return callback(null); + }, + (callback) => { + if(false === argv.prompt) { + return callback(null); + } + + console.info('About to permanently delete the following user:'); + console.info(`Username : ${user.username}`); + console.info(`Real name: ${user.properties[UserProps.RealName] || 'N/A'}`); + console.info(`User ID : ${user.userId}`); + console.info('WARNING: This cannot be undone!'); + getAnswers([ + { + name : 'proceed', + message : `Proceed in deleting ${user.username}?`, + type : 'confirm', + } + ], + answers => { + if(answers.proceed) { + return callback(null); + } + return callback(Errors.General('User canceled')); + }); + }, + (callback) => { + // op has confirmed they are wanting ready to proceed (or passed --no-prompt) + const DeleteFrom = { + message : [ 'user_message_area_last_read' ], + system : [ 'user_event_log', ], + user : [ 'user_group_member', 'user' ], + }; + + async.eachSeries(Object.keys(DeleteFrom), (dbName, nextDbName) => { + const tables = DeleteFrom[dbName]; + async.eachSeries(tables, (tableName, nextTableName) => { + const col = ('user' === dbName && 'user' === tableName) ? 'id' : 'user_id'; + removeUserRecordsFromDbAndTable(dbName, tableName, user.userId, col, err => { + return nextTableName(err); + }); + }, + err => { + return nextDbName(err); + }); + }, + err => { + return callback(err); + }); + }, + (callback) => { + // + // Clean up *private* messages *to* this user + // + const Message = require('../../core/message.js'); + const MsgDb = require('../../core/database.js').dbs.message; + + const filter = { + resultType : 'id', + privateTagUserId : user.userId, + }; + Message.findMessages(filter, (err, ids) => { + if(err) { + return callback(err); + } + + async.eachSeries(ids, (messageId, nextMessageId) => { + MsgDb.run( + `DELETE FROM message + WHERE message_id = ?;`, + [ messageId ], + err => { + return nextMessageId(err); + } + ); + }, + err => { + return callback(err); + }); + }); + } + ], + err => { + if(err) { + return console.error(err.reason ? err.reason : err.message); + } + + console.info('User has been deleted.'); + } + ); +} + +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); + } + + let groupName = argv._[argv._.length - 1].toString().replace(/["']/g, ''); // remove any quotes - necessary to allow "-foo" + let action = groupName[0]; // + or - + + if('-' === action || '+' === action) { + groupName = groupName.substr(1); + } + + action = action || '+'; + + if(0 === groupName.length) { + return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); + } + + // + // Groups are currently arbritary, so do a slight validation + // + if(!/[A-Za-z0-9]+/.test(groupName)) { + process.exitCode = ExitCodes.BAD_ARGS; + return console.error('Bad group name'); + } + + function done(err) { + if(err) { + process.exitCode = ExitCodes.BAD_ARGS; + console.error(err.message); + } else { + console.info('User groups modified'); + } + } + + const UserGroup = require('../../core/user_group.js'); + if('-' === action) { + UserGroup.removeUserFromGroup(user.userId, groupName, done); + } else { + UserGroup.addUserToGroup(user.userId, groupName, done); + } +} + +function showUserInfo(user) { + + const User = require('../../core/user.js'); + + const statusDesc = () => { + const status = user.properties[UserProps.AccountStatus]; + return _.invert(User.AccountStatus)[status] || 'unknown'; + }; + + const created = () => { + const ac = user.properties[UserProps.AccountCreated]; + return ac ? moment(ac).format() : 'N/A'; + }; + + const lastLogin = () => { + const ll = user.properties[UserProps.LastLoginTs]; + return ll ? moment(ll).format() : 'N/A'; + }; + + const propOrNA = p => { + return user.properties[p] || 'N/A'; + }; + + const stdInfo = `User information: +Username : ${user.username}${user.isRoot() ? ' (root/SysOp)' : ''} +Real name : ${propOrNA(UserProps.RealName)} +ID : ${user.userId} +Status : ${statusDesc()} +Groups : ${user.groups.join(', ')} +Created : ${created()} +Last login : ${lastLogin()} +Login count : ${propOrNA(UserProps.LoginCount)} +Email : ${propOrNA(UserProps.EmailAddress)} +Location : ${propOrNA(UserProps.Location)} +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 listUsers() { + // oputil user list [disabled|inactive|active|locked|all] + // :TODO: --created-since SPEC and --last-called SPEC + // --created-since SPEC + // SPEC can be TIMESTAMP or e.g. "-1hour" or "-90days" + // :TODO: --sort name|id + let listWhat; + if (argv._.length > 2) { + listWhat = argv._[argv._.length - 1]; + } else { + listWhat = 'all'; + } + + const User = require('../../core/user'); + if (![ 'all' ].concat(Object.keys(User.AccountStatus)).includes(listWhat)) { + return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); + } + + async.waterfall( + [ + (callback) => { + const UserProps = require('../../core/user_property'); + + const userListOpts = { + properties : [ + UserProps.AccountStatus, + ], + }; + + User.getUserList(userListOpts, (err, userList) => { + if (err) { + return callback(err); + } + + if ('all' === listWhat) { + return callback(null, userList); + } + + const accountStatusFilter = User.AccountStatus[listWhat].toString(); + + return callback(null, userList.filter(user => { + return user[UserProps.AccountStatus] === accountStatusFilter; + })); + }); + }, + (userList, callback) => { + userList.forEach(user => { + + console.info(`${user.userId}: ${user.userName}`); + }); + }, + ], + err => { + if(err) { + return console.error(err.reason ? err.reason : err.message); + } + } + ); +} + +function handleUserCommand() { + function errUsage() { + return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); + } + + if(true === argv.help) { + return errUsage(); + } + + const action = argv._[1]; + const userRequired = ![ 'list' ].includes(action); + + let userName; + if (userRequired) { + const usernameIdx = [ + 'pw', 'pass', 'passwd', 'password', + 'group', + 'mv', 'rename', + '2fa-otp', 'otp' + ].includes(action) ? argv._.length - 2 : argv._.length - 1; + userName = argv._[usernameIdx]; + } + + if(!userName && userRequired) { + return errUsage(); + } + + initAndGetUser(userName, (err, user) => { + if(userName && err) { + process.exitCode = ExitCodes.ERROR; + return console.error(err.message); + } + + return ({ + pw : setUserPassword, + passwd : setUserPassword, + password : setUserPassword, + + rm : removeUser, + remove : removeUser, + del : removeUser, + delete : removeUser, + + mv : renameUser, + rename : renameUser, + + activate : setAccountStatus, + deactivate : setAccountStatus, + disable : setAccountStatus, + lock : setAccountStatus, + + group : modUserGroups, + + info : showUserInfo, + + '2fa-otp' : twoFactorAuthOTP, + otp : twoFactorAuthOTP, + list : listUsers, + }[action] || errUsage)(user, action); + }); } \ No newline at end of file diff --git a/core/plugin_module.js b/core/plugin_module.js index 31ba6f01..60b878aa 100644 --- a/core/plugin_module.js +++ b/core/plugin_module.js @@ -1,7 +1,7 @@ /* jslint node: true */ 'use strict'; -exports.PluginModule = PluginModule; +exports.PluginModule = PluginModule; -function PluginModule(options) { +function PluginModule(/*options*/) { } diff --git a/core/predefined_mci.js b/core/predefined_mci.js index afebd24c..a1182a79 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -1,242 +1,304 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const Log = require('./logger.js').log; -const getMessageAreaByTag = require('./message_area.js').getMessageAreaByTag; -const getMessageConferenceByTag = require('./message_area.js').getMessageConferenceByTag; -const clientConnections = require('./client_connections.js'); -const StatLog = require('./stat_log.js'); -const FileBaseFilters = require('./file_base_filter.js'); -const formatByteSize = require('./string_util.js').formatByteSize; +// ENiGMA½ +const Config = require('./config.js').get; +const Log = require('./logger.js').log; +const { + getMessageAreaByTag, + getMessageConferenceByTag +} = require('./message_area.js'); +const clientConnections = require('./client_connections.js'); +const StatLog = require('./stat_log.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const { + formatByteSize, +} = require('./string_util.js'); +const ANSI = require('./ansi_term.js'); +const UserProps = require('./user_property.js'); +const SysProps = require('./system_property.js'); +const SysLogKeys = require('./system_log.js'); -// deps -const packageJson = require('../package.json'); -const os = require('os'); -const _ = require('lodash'); -const moment = require('moment'); +// deps +const packageJson = require('../package.json'); +const os = require('os'); +const _ = require('lodash'); +const moment = require('moment'); -exports.getPredefinedMCIValue = getPredefinedMCIValue; -exports.init = init; +exports.getPredefinedMCIValue = getPredefinedMCIValue; +exports.init = init; function init(cb) { - setNextRandomRumor(cb); + setNextRandomRumor(cb); } function setNextRandomRumor(cb) { - StatLog.getSystemLogEntries('system_rumorz', StatLog.Order.Random, 1, (err, entry) => { - if(entry) { - entry = entry[0]; - } - const randRumor = entry && entry.log_value ? entry.log_value : ''; - StatLog.setNonPeristentSystemStat('random_rumor', randRumor); - if(cb) { - return cb(null); - } - }); + StatLog.getSystemLogEntries(SysLogKeys.UserAddedRumorz, StatLog.Order.Random, 1, (err, entry) => { + if(entry) { + entry = entry[0]; + } + const randRumor = entry && entry.log_value ? entry.log_value : ''; + StatLog.setNonPersistentSystemStat(SysProps.NextRandomRumor, randRumor); + if(cb) { + return cb(null); + } + }); } function getUserRatio(client, propA, propB) { - const a = StatLog.getUserStatNum(client.user, propA); - const b = StatLog.getUserStatNum(client.user, propB); - const ratio = ~~((a / b) * 100); - return `${ratio}%`; + const a = StatLog.getUserStatNum(client.user, propA); + const b = StatLog.getUserStatNum(client.user, propB); + const ratio = ~~((a / b) * 100); + return `${ratio}%`; } function userStatAsString(client, statName, defaultValue) { - return (StatLog.getUserStat(client.user, statName) || defaultValue).toString(); + return (StatLog.getUserStat(client.user, statName) || defaultValue).toLocaleString(); +} + +function toNumberWithCommas(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +function userStatAsCountString(client, statName, defaultValue) { + const value = StatLog.getUserStatNum(client.user, statName) || defaultValue; + return toNumberWithCommas(value); } function sysStatAsString(statName, defaultValue) { - return (StatLog.getSystemStat(statName) || defaultValue).toString(); + return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString(); } const PREDEFINED_MCI_GENERATORS = { - // - // Board - // - BN : function boardName() { return Config.general.boardName; }, + // + // Board + // + BN : function boardName() { return Config().general.boardName; }, - // ENiGMA - VL : function versionLabel() { return 'ENiGMA½ v' + packageJson.version; }, - VN : function version() { return packageJson.version; }, + // ENiGMA + VL : function versionLabel() { return 'ENiGMA½ v' + packageJson.version; }, + VN : function version() { return packageJson.version; }, - // +op info - SN : function opUserName() { return StatLog.getSystemStat('sysop_username'); }, - SR : function opRealName() { return StatLog.getSystemStat('sysop_real_name'); }, - SL : function opLocation() { return StatLog.getSystemStat('sysop_location'); }, - SA : function opAffils() { return StatLog.getSystemStat('sysop_affiliation'); }, - SS : function opSex() { return StatLog.getSystemStat('sysop_sex'); }, - SE : function opEmail() { return StatLog.getSystemStat('sysop_email_address'); }, - // :TODO: op age, web, ????? + // +op info + SN : function opUserName() { return StatLog.getSystemStat(SysProps.SysOpUsername); }, + SR : function opRealName() { return StatLog.getSystemStat(SysProps.SysOpRealName); }, + SL : function opLocation() { return StatLog.getSystemStat(SysProps.SysOpLocation); }, + SA : function opAffils() { return StatLog.getSystemStat(SysProps.SysOpAffiliations); }, + SS : function opSex() { return StatLog.getSystemStat(SysProps.SysOpSex); }, + SE : function opEmail() { return StatLog.getSystemStat(SysProps.SysOpEmailAddress); }, + // :TODO: op age, web, ????? - // - // Current user / session - // - UN : function userName(client) { return client.user.username; }, - UI : function userId(client) { return client.user.userId.toString(); }, - UG : function groups(client) { return _.values(client.user.groups).join(', '); }, - UR : function realName(client) { return userStatAsString(client, 'real_name', ''); }, - LO : function location(client) { return userStatAsString(client, 'location', ''); }, - UA : function age(client) { return client.user.getAge().toString(); }, - BD : function birthdate(client) { return moment(client.user.properties.birthdate).format(client.currentTheme.helpers.getDateFormat()); }, // iNiQUiTY - US : function sex(client) { return userStatAsString(client, 'sex', ''); }, - UE : function emailAddres(client) { return userStatAsString(client, 'email_address', ''); }, - UW : function webAddress(client) { return userStatAsString(client, 'web_address', ''); }, - UF : function affils(client) { return userStatAsString(client, 'affiliation', ''); }, - UT : function themeId(client) { return userStatAsString(client, 'theme_id', ''); }, - UC : function loginCount(client) { return userStatAsString(client, 'login_count', 0); }, - ND : function connectedNode(client) { return client.node.toString(); }, - IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version - ST : function serverName(client) { return client.session.serverName; }, - FN : function activeFileBaseFilterName(client) { - const activeFilter = FileBaseFilters.getActiveFilter(client); - return activeFilter ? activeFilter.name : ''; - }, - DN : function userNumDownloads(client) { return userStatAsString(client, 'dl_total_count', 0); }, // Obv/2 - DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes - const byteSize = StatLog.getUserStatNum(client.user, 'dl_total_bytes'); - return formatByteSize(byteSize, true); // true=withAbbr - }, - UP : function userNumUploads(client) { return userStatAsString(client, 'ul_total_count', 0); }, // Obv/2 - UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes - const byteSize = StatLog.getUserStatNum(client.user, 'ul_total_bytes'); - return formatByteSize(byteSize, true); // true=withAbbr - }, - NR : function userUpDownRatio(client) { // Obv/2 - return getUserRatio(client, 'ul_total_count', 'dl_total_count'); - }, - KR : function userUpDownByteRatio(client) { // Obv/2 uses KR=upload/download Kbyte ratio - return getUserRatio(client, 'ul_total_bytes', 'dl_total_bytes'); - }, + // + // Current user / session + // + UN : function userName(client) { return client.user.username; }, + UI : function userId(client) { return client.user.userId.toString(); }, + UG : function groups(client) { return _.values(client.user.groups).join(', '); }, + UR : function realName(client) { return userStatAsString(client, UserProps.RealName, ''); }, + LO : function location(client) { return userStatAsString(client, UserProps.Location, ''); }, + UA : function age(client) { return client.user.getAge().toString(); }, + BD : function birthdate(client) { // iNiQUiTY + return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat()); + }, + US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); }, + UE : function emailAddress(client) { return userStatAsString(client, UserProps.EmailAddress, ''); }, + UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); }, + UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); }, + UT : function themeName(client) { + return _.get(client, 'currentTheme.info.name', userStatAsString(client, UserProps.ThemeId, '')); + }, + UD : function themeId(client) { return userStatAsString(client, UserProps.ThemeId, ''); }, + UC : function loginCount(client) { return userStatAsCountString(client, UserProps.LoginCount, 0); }, + ND : function connectedNode(client) { return client.node.toString(); }, + IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version + ST : function serverName(client) { return client.session.serverName; }, + FN : function activeFileBaseFilterName(client) { + const activeFilter = FileBaseFilters.getActiveFilter(client); + return activeFilter ? activeFilter.name : '(Unknown)'; + }, + DN : function userNumDownloads(client) { return userStatAsCountString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2 + DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes + const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes); + return formatByteSize(byteSize, true); // true=withAbbr + }, + UP : function userNumUploads(client) { return userStatAsCountString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2 + UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes + const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes); + return formatByteSize(byteSize, true); // true=withAbbr + }, + NR : function userUpDownRatio(client) { // Obv/2 + return getUserRatio(client, UserProps.FileUlTotalCount, UserProps.FileDlTotalCount); + }, + KR : function userUpDownByteRatio(client) { // Obv/2 uses KR=upload/download Kbyte ratio + return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes); + }, - MS : function accountCreatedclient(client) { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); }, - PS : function userPostCount(client) { return userStatAsString(client, 'post_count', 0); }, - PC : function userPostCallRatio(client) { return getUserRatio(client, 'post_count', 'login_count'); }, + MS : function accountCreated(client) { + return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat()); + }, + PS : function userPostCount(client) { return userStatAsCountString(client, UserProps.MessagePostCount, 0); }, + PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); }, - MD : function currentMenuDescription(client) { - return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : ''; - }, + MD : function currentMenuDescription(client) { + return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : ''; + }, - MA : function messageAreaName(client) { - const area = getMessageAreaByTag(client.user.properties.message_area_tag); - return area ? area.name : ''; - }, - MC : function messageConfName(client) { - const conf = getMessageConferenceByTag(client.user.properties.message_conf_tag); - return conf ? conf.name : ''; - }, - ML : function messageAreaDescription(client) { - const area = getMessageAreaByTag(client.user.properties.message_area_tag); - return area ? area.desc : ''; - }, - CM : function messageConfDescription(client) { - const conf = getMessageConferenceByTag(client.user.properties.message_conf_tag); - return conf ? conf.desc : ''; - }, + MA : function messageAreaName(client) { + const area = getMessageAreaByTag(client.user.properties[UserProps.MessageAreaTag]); + return area ? area.name : ''; + }, + MC : function messageConfName(client) { + const conf = getMessageConferenceByTag(client.user.properties[UserProps.MessageConfTag]); + return conf ? conf.name : ''; + }, + ML : function messageAreaDescription(client) { + const area = getMessageAreaByTag(client.user.properties[UserProps.MessageAreaTag]); + return area ? area.desc : ''; + }, + CM : function messageConfDescription(client) { + const conf = getMessageConferenceByTag(client.user.properties[UserProps.MessageConfTag]); + return conf ? conf.desc : ''; + }, - SH : function termHeight(client) { return client.term.termHeight.toString(); }, - SW : function termWidth(client) { return client.term.termWidth.toString(); }, + SH : function termHeight(client) { return client.term.termHeight.toString(); }, + SW : function termWidth(client) { return client.term.termWidth.toString(); }, - // - // Date/Time - // - // :TODO: change to CD for 'Current Date' - DT : function date(client) { return moment().format(client.currentTheme.helpers.getDateFormat()); }, - CT : function time(client) { return moment().format(client.currentTheme.helpers.getTimeFormat()) ;}, + AC : function achievementCount(client) { return userStatAsCountString(client, UserProps.AchievementTotalCount, 0); }, + AP : function achievementPoints(client) { return userStatAsCountString(client, UserProps.AchievementTotalPoints, 0); }, - // - // OS/System Info - // - OS : function operatingSystem() { - return { - linux : 'Linux', - darwin : 'Mac OS X', - win32 : 'Windows', - sunos : 'SunOS', - freebsd : 'FreeBSD', - }[os.platform()] || os.type(); - }, + DR : function doorRuns(client) { return userStatAsCountString(client, UserProps.DoorRunTotalCount, 0); }, + DM : function doorFriendlyRunTime(client) { + const minutes = client.user.properties[UserProps.DoorRunTotalMinutes] || 0; + return moment.duration(minutes, 'minutes').humanize(); + }, + TO : function friendlyTotalTimeOnSystem(client) { + const minutes = client.user.properties[UserProps.MinutesOnlineTotalCount] || 0; + return moment.duration(minutes, 'minutes').humanize(); + }, - OA : function systemArchitecture() { return os.arch(); }, - - SC : function systemCpuModel() { - // - // Clean up CPU strings a bit for better display - // - return os.cpus()[0].model - .replace(/\(R\)|\(TM\)|processor|CPU/g, '') - .replace(/\s+(?= )/g, ''); - }, + // + // Date/Time + // + DT : function date(client) { return moment().format(client.currentTheme.helpers.getDateFormat()); }, + CT : function time(client) { return moment().format(client.currentTheme.helpers.getTimeFormat()) ;}, - // :TODO: MCI for core count, e.g. os.cpus().length + // + // OS/System Info + // + // https://github.com/nodejs/node-v0.x-archive/issues/25769 + // + OS : function operatingSystem() { + return { + linux : 'Linux', + darwin : 'OS X', + win32 : 'Windows', + sunos : 'SunOS', + freebsd : 'FreeBSD', + android : 'Android', + openbsd : 'OpenBSD', + aix : 'IBM AIX', + }[os.platform()] || os.type(); + }, - // :TODO: cpu load average (over N seconds): http://stackoverflow.com/questions/9565912/convert-the-output-of-os-cpus-in-node-js-to-percentage - NV : function nodeVersion() { return process.version; }, + OA : function systemArchitecture() { return os.arch(); }, - AN : function activeNodes() { return clientConnections.getActiveConnections().length.toString(); }, + SC : function systemCpuModel() { + // + // Clean up CPU strings a bit for better display + // + return os.cpus()[0].model + .replace(/\(R\)|\(TM\)|processor|CPU/ig, '') + .replace(/\s+(?= )/g, '') + .trim(); + }, - TC : function totalCalls() { return StatLog.getSystemStat('login_count').toString(); }, + // :TODO: MCI for core count, e.g. os.cpus().length - RR : function randomRumor() { - // start the process of picking another random one - setNextRandomRumor(); + // :TODO: cpu load average (over N seconds): http://stackoverflow.com/questions/9565912/convert-the-output-of-os-cpus-in-node-js-to-percentage + NV : function nodeVersion() { return process.version; }, - return StatLog.getSystemStat('random_rumor'); - }, + AN : function activeNodes() { return clientConnections.getActiveConnections().length.toString(); }, - // - // System File Base, Up/Download Info - // - // :TODO: DD - Today's # of downloads (iNiQUiTY) - // - SD : function systemNumDownloads() { return sysStatAsString('dl_total_count', 0); }, - SO : function systemByteDownload() { - const byteSize = StatLog.getSystemStatNum('dl_total_bytes'); - return formatByteSize(byteSize, true); // true=withAbbr - }, - SU : function systemNumUploads() { return sysStatAsString('ul_total_count', 0); }, - SP : function systemByteUpload() { - const byteSize = StatLog.getSystemStatNum('ul_total_bytes'); - return formatByteSize(byteSize, true); // true=withAbbr - }, + TC : function totalCalls() { return StatLog.getSystemStat(SysProps.LoginCount).toLocaleString(); }, + TT : function totalCallsToday() { + return StatLog.getSystemStat(SysProps.LoginsToday).toLocaleString(); + }, - // :TODO: PT - Messages posted *today* (Obv/2) - // -> Include FTN/etc. - // :TODO: NT - New users today (Obv/2) - // :TODO: CT - Calls *today* (Obv/2) - // :TODO: TF - Total files on the system (Obv/2) - // :TODO: FT - Files uploaded/added *today* (Obv/2) - // :TODO: DD - Files downloaded *today* (iNiQUiTY) - // :TODO: TP - total message/posts on the system (Obv/2) - // -> Include FTN/etc. - // :TODO: LC - name of last caller to system (Obv/2) - // :TODO: TZ - Average *system* post/call ratio (iNiQUiTY) - + RR : function randomRumor() { + // start the process of picking another random one + setNextRandomRumor(); - // - // Special handling for XY - // - XY : function xyHack() { return; /* nothing */ }, + return StatLog.getSystemStat('random_rumor'); + }, + + // + // System File Base, Up/Download Info + // + // :TODO: DD - Today's # of downloads (iNiQUiTY) + // + SD : function systemNumDownloads() { return sysStatAsString(SysProps.FileDlTotalCount, 0); }, + SO : function systemByteDownload() { + const byteSize = StatLog.getSystemStatNum(SysProps.FileDlTotalBytes); + return formatByteSize(byteSize, true); // true=withAbbr + }, + SU : function systemNumUploads() { return sysStatAsString(SysProps.FileUlTotalCount, 0); }, + SP : function systemByteUpload() { + const byteSize = StatLog.getSystemStatNum(SysProps.FileUlTotalBytes); + return formatByteSize(byteSize, true); // true=withAbbr + }, + TF : function totalFilesOnSystem() { + const areaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats); + return _.get(areaStats, 'totalFiles', 0).toLocaleString(); + }, + TB : function totalBytesOnSystem() { + const areaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats); + const totalBytes = parseInt(_.get(areaStats, 'totalBytes', 0)); + return formatByteSize(totalBytes, true); // true=withAbbr + }, + PT : function messagesPostedToday() { // Obv/2 + return sysStatAsString(SysProps.MessagesToday, 0); + }, + TP : function totalMessagesOnSystem() { // Obv/2 + return sysStatAsString(SysProps.MessageTotalCount, 0); + }, + + // :TODO: NT - New users today (Obv/2) + // :TODO: FT - Files uploaded/added *today* (Obv/2) + // :TODO: DD - Files downloaded *today* (iNiQUiTY) + // :TODO: LC - name of last caller to system (Obv/2) + // :TODO: TZ - Average *system* post/call ratio (iNiQUiTY) + + + // + // Special handling for XY + // + XY : function xyHack() { return; /* nothing */ }, + + // + // Various movement by N + // + CF : function cursorForwardBy(client, n = 1) { return ANSI.forward(n); }, + CB : function cursorBackBy(client, n = 1) { return ANSI.back(n); }, + CU : function cursorUpBy(client, n = 1) { return ANSI.up(n); }, + CD : function cursorDownBy(client, n = 1) { return ANSI.down(n); }, }; -function getPredefinedMCIValue(client, code) { +function getPredefinedMCIValue(client, code, extra) { - if(!client || !code) { - return; - } + if(!client || !code) { + return; + } - const generator = PREDEFINED_MCI_GENERATORS[code]; + const generator = PREDEFINED_MCI_GENERATORS[code]; - if(generator) { - let value; - try { - value = generator(client); - } catch(e) { - Log.error( { code : code, exception : e.message }, 'Exception caught generating predefined MCI value' ); - } + if(generator) { + let value; + try { + value = generator(client, extra); + } catch(e) { + Log.error( { code : code, exception : e.message }, 'Exception caught generating predefined MCI value' ); + } - return value; - } + return value; + } } diff --git a/core/qwk_mail_packet.js b/core/qwk_mail_packet.js new file mode 100644 index 00000000..de42abf8 --- /dev/null +++ b/core/qwk_mail_packet.js @@ -0,0 +1,1538 @@ +const ArchiveUtil = require('./archive_util'); +const { Errors } = require('./enig_error'); +const Message = require('./message'); +const { splitTextAtTerms } = require('./string_util'); +const { + getMessageConfTagByAreaTag, + getMessageAreaByTag, + getMessageConferenceByTag, + getAllAvailableMessageAreaTags, +} = require('./message_area'); +const StatLog = require('./stat_log'); +const Config = require('./config').get; +const SysProps = require('./system_property'); +const UserProps = require('./user_property'); +const { numToMbf32 } = require('./mbf'); +const { getEncodingFromCharacterSetIdentifier } = require('./ftn_util'); + +const { EventEmitter } = require('events'); +const temptmp = require('temptmp'); +const async = require('async'); +const fs = require('graceful-fs'); +const paths = require('path'); +const { Parser } = require('binary-parser'); +const iconv = require('iconv-lite'); +const moment = require('moment'); +const _ = require('lodash'); +const IniConfigParser = require('ini-config-parser'); + +const enigmaVersion = require('../package.json').version; + +// Synchronet smblib TZ to a UTC offset +// see https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h +const SMBTZToUTCOffset = { + // US Standard + '40F0' : '-04:00', // Atlantic + '412C' : '-05:00', // Eastern + '4168' : '-06:00', // Central + '41A4' : '-07:00', // Mountain + '41E0' : '-08:00', // Pacific + '421C' : '-09:00', // Yukon + '4258' : '-10:00', // Hawaii/Alaska + '4294' : '-11:00', // Bering + + // US Daylight + 'C0F0' : '-03:00', // Atlantic + 'C12C' : '-04:00', // Eastern + 'C168' : '-05:00', // Central + 'C1A4' : '-06:00', // Mountain + 'C1E0' : '-07:00', // Pacific + 'C21C' : '-08:00', // Yukon + 'C258' : '-09:00', // Hawaii/Alaska + 'C294' : '-10:00', // Bering + + // "Non-Standard" + '2294' : '-11:00', // Midway + '21E0' : '-08:00', // Vancouver + '21A4' : '-07:00', // Edmonton + '2168' : '-06:00', // Winnipeg + '212C' : '-05:00', // Bogota + '20F0' : '-04:00', // Caracas + '20B4' : '-03:00', // Rio de Janeiro + '2078' : '-02:00', // Fernando de Noronha + '203C' : '-01:00', // Azores + '1000' : '+00:00', // London + '103C' : '+01:00', // Berlin + '1078' : '+02:00', // Athens + '10B4' : '+03:00', // Moscow + '10F0' : '+04:00', // Dubai + '110E' : '+04:30', // Kabul + '112C' : '+05:00', // Karachi + '114A' : '+05:30', // Bombay + '1159' : '+05:45', // Kathmandu + '1168' : '+06:00', // Dhaka + '11A4' : '+07:00', // Bangkok + '11E0' : '+08:00', // Hong Kong + '121C' : '+09:00', // Tokyo + '1258' : '+10:00', // Sydney + '1294' : '+11:00', // Noumea + '12D0' : '+12:00', // Wellington +}; + +const UTCOffsetToSMBTZ = _.invert(SMBTZToUTCOffset); + +const QWKMessageBlockSize = 128; +const QWKHeaderTimestampFormat = 'MM-DD-YYHH:mm'; +const QWKLF = 0xe3; + +const QWKMessageStatusCodes = { + UnreadPublic : ' ', + ReadPublic : '-', + UnreadPrivate : '+', + ReadPrivate : '*', + UnreadCommentToSysOp : '~', + ReadCommentToSysOp : '`', + UnreadSenderPWProtected : '%', + ReadSenderPWProtected : '^', + UnreadGroupPWProtected : '!', + ReadGroupPWProtected : '#', + PWProtectedToAll : '$', + Vote : 'V', +}; + +const QWKMessageActiveStatus = { + Active : 255, + Deleted : 226, +}; + +const QWKNetworkTagIndicator = { + Present : '*', + NotPresent : ' ', +}; + +// See the following: +// - http://fileformats.archiveteam.org/wiki/QWK +// - http://wiki.synchro.net/ref:qwk +// +const MessageHeaderParser = new Parser() + .endianess('little') + .string('status', { + encoding : 'ascii', + length : 1, + }) + .string('num', { // message num or conf num for REP's + encoding : 'ascii', + length : 7, + formatter : n => { + return parseInt(n); + } + }) + .string('timestamp', { + encoding : 'ascii', + length : 13, + }) + // these fields may be encoded in something other than ascii/CP437 + .array('toName', { + type : 'uint8', + length : 25, + }) + .array('fromName', { + type : 'uint8', + length : 25, + }) + .array('subject', { + type : 'uint8', + length : 25, + }) + .string('password', { + encoding : 'ascii', + length : 12, + }) + .string('replyToNum', { + encoding : 'ascii', + length : 8, + formatter : n => { + return parseInt(n); + } + }) + .string('numBlocks', { + encoding : 'ascii', + length : 6, + formatter : n => { + return parseInt(n); + } + }) + .uint8('status2') + .uint16('confNum') + .uint16('relNum') + .uint8('netTag'); + +const replaceCharInBuffer = (buffer, search, replace) => { + let i = 0; + search = Buffer.from([search]); + while (i < buffer.length) { + i = buffer.indexOf(search, i); + if (-1 === i) { + break; + } + buffer[i] = replace; + ++i; + } +} + +class QWKPacketReader extends EventEmitter { + constructor( + packetPath, + { mode = QWKPacketReader.Modes.Guess, keepTearAndOrigin = true } = { mode : QWKPacketReader.Modes.Guess, keepTearAndOrigin : true }) + { + super(); + + this.packetPath = packetPath; + this.options = { mode, keepTearAndOrigin }; + this.temptmp = temptmp.createTrackedSession('qwkpacketreader'); + } + + static get Modes() { + return { + Guess : 'guess', // try to guess + QWK : 'qwk', // standard incoming packet + REP : 'rep', // a reply packet + }; + } + + read() { + // + // A general overview: + // + // - Find out what kind of archive we're dealing with + // - Extract to temporary location + // - Process various files + // - Emit messages we find, information about the packet, so on + // + async.waterfall( + [ + // determine packet archive type + (callback) => { + const archiveUtil = ArchiveUtil.getInstance(); + archiveUtil.detectType(this.packetPath, (err, archiveType) => { + if (err) { + return callback(err); + } + this.emit('archive type', archiveType); + return callback(null, archiveType); + }); + }, + // create a temporary location to do processing + (archiveType, callback) => { + this.temptmp.mkdir( { prefix : 'enigqwkreader-'}, (err, tempDir) => { + if (err) { + return callback(err); + } + + return callback(null, archiveType, tempDir); + }); + }, + // extract it + (archiveType, tempDir, callback) => { + const archiveUtil = ArchiveUtil.getInstance(); + archiveUtil.extractTo(this.packetPath, tempDir, archiveType, err => { + if (err) { + return callback(err); + } + + return callback(null, tempDir); + }); + }, + // gather extracted file list + (tempDir, callback) => { + fs.readdir(tempDir, (err, files) => { + if (err) { + return callback(err); + } + + // Discover basic information about well known files + async.reduce( + files, + {}, + (out, filename, next) => { + const key = filename.toUpperCase(); + + switch (key) { + case 'MESSAGES.DAT' : // QWK + if (this.options.mode === QWKPacketReader.Modes.Guess) { + this.options.mode = QWKPacketReader.Modes.QWK; + } + if (this.options.mode === QWKPacketReader.Modes.QWK) { + out.messages = { filename }; + } + break; + + case 'ID.MSG' : + if (this.options.mode === QWKPacketReader.Modes.Guess) { + this.options.mode = Modes.REP; + } + + if (this.options.mode === QWKPacketReader.Modes.REP) { + out.messages = { filename }; + } + break; + + case 'HEADERS.DAT' : // Synchronet + out.headers = { filename }; + break; + + case 'VOTING.DAT' : // Synchronet + out.voting = { filename }; + break; + + case 'CONTROL.DAT' : // QWK + out.control = { filename }; + break; + + case 'DOOR.ID' : // QWK + out.door = { filename }; + break; + + case 'NETFLAGS.DAT' : // QWK + out.netflags = { filename }; + break; + + case 'NEWFILES.DAT' : // QWK + out.newfiles = { filename }; + break; + + case 'PERSONAL.NDX' : // QWK + out.personal = { filename }; + break; + + case '000.NDX' : // QWK + out.inbox = { filename }; + break; + + case 'TOREADER.EXT' : // QWKE + out.toreader = { filename }; + break; + + case 'QLR.DAT' : + out.qlr = { filename }; + break; + + default : + if (/[0-9]+\.NDX/.test(key)) { // QWK + out.pointers = out.pointers || { filenames: [] }; + out.pointers.filenames.push(filename); + } else { + out[key] = { filename }; + } + break; + } + + return next(null, out); + }, + (err, packetFileInfo) => { + this.packetInfo = Object.assign( + {}, + packetFileInfo, + { + tempDir, + } + ); + return callback(null); + } + ); + }); + }, + (callback) => { + return this.processPacketFiles(callback); + }, + ], + err => { + this.temptmp.cleanup(); + + if (err) { + return this.emit('error', err); + } + + this.emit('done'); + } + ); + } + + processPacketFiles(cb) { + async.series( + [ + (callback) => { + return this.readControl(callback); + }, + (callback) => { + return this.readHeadersExtension(callback); + }, + (callback) => { + return this.readMessages(callback); + } + ], + err => { + return cb(err); + } + ) + } + + readControl(cb) { + // + // CONTROL.DAT is a CRLF text file containing information about + // the originating BBS, conf number <> name mapping, etc. + // + // References: + // - http://fileformats.archiveteam.org/wiki/QWK + // + if (!this.packetInfo.control) { + return cb(Errors.DoesNotExist('No control file found within QWK packet')); + } + + const path = paths.join(this.packetInfo.tempDir, this.packetInfo.control.filename); + + // note that we read as UTF-8. Legacy says it should be CP437/ASCII + // but this seems safer for now so conference names and the like + // can be non-English for example. + fs.readFile(path, { encoding : 'utf8' }, (err, controlLines) => { + if (err) { + return cb(err); + } + + controlLines = splitTextAtTerms(controlLines); + + let state = 'header'; + const control = { confMap : {} }; + let currConfNumber; + for (let lineNumber = 0; lineNumber < controlLines.length; ++lineNumber) { + const line = controlLines[lineNumber].trim(); + switch (lineNumber) { + // first set of lines is header info + case 0 : control.bbsName = line; break; + case 1 : control.bbsLocation = line; break; + case 2 : control.bbsPhone = line; break; + case 3 : control.bbsSysOp = line; break; + case 4 : control.doorRegAndBoardID = line; break; + case 5 : control.packetCreationTime = line; break; + case 6 : control.toUser = line; break; + case 7 : break; // Qmail menu + case 8 : break; // unknown, always 0? + case 9 : break; // total messages in packet (often set to 0) + case 10 : + control.totalMessages = (parseInt(line) + 1); + state = 'confNumber'; + break; + + default : + switch (state) { + case 'confNumber' : + currConfNumber = parseInt(line); + if (isNaN(currConfNumber)) { + state = 'news'; + + control.welcomeFile = line; + } else { + state = 'confName'; + } + break; + + case 'confName' : + control.confMap[currConfNumber] = line; + state = 'confNumber'; + break; + + case 'news' : + control.newsFile = line; + state = 'logoff'; + break; + + case 'logoff' : + control.logoffFile = line; + state = 'footer'; + break; + + case 'footer' : + // some systems append additional info; we don't care. + break; + } + } + } + + return cb(null); + }); + } + + readHeadersExtension(cb) { + if (!this.packetInfo.headers) { + return cb(null); // nothing to do + } + + const path = paths.join(this.packetInfo.tempDir, this.packetInfo.headers.filename); + fs.readFile(path, { encoding : 'utf8' }, (err, iniData) => { + if (err) { + this.emit('warning', Errors.Invalid(`Problem reading HEADERS.DAT: ${err.message}`)); + return cb(null); // non-fatal + } + + try { + const parserOptions = { + lineComment : false, // no line comments; consume full lines + nativeType : false, // just keep everything as strings + dotKey : false, // 'a.b.c = value' stays 'a.b.c = value' + }; + this.packetInfo.headers.ini = IniConfigParser.parse(iniData, parserOptions); + } catch (e) { + this.emit('warning', Errors.Invalid(`HEADERS.DAT file appears to be invalid: ${e.message}`)); + } + + return cb(null); + }); + } + + readMessages(cb) { + if (!this.packetInfo.messages) { + return cb(Errors.DoesNotExist('No messages file found within QWK packet')); + } + + const encodingToSpec = 'cp437'; + let encoding; + + const path = paths.join(this.packetInfo.tempDir, this.packetInfo.messages.filename); + fs.open(path, 'r', (err, fd) => { + if (err) { + return cb(err); + } + + // Some mappings/etc. used in loops below.... + // Sync sets these in HEADERS.DAT: http://wiki.synchro.net/ref:qwk + const FTNPropertyMapping = { + 'X-FTN-AREA' : Message.FtnPropertyNames.FtnArea, + 'X-FTN-SEEN-BY' : Message.FtnPropertyNames.FtnSeenBy, + }; + + const FTNKludgeMapping = { + 'X-FTN-PATH' : 'PATH', + 'X-FTN-MSGID' : 'MSGID', + 'X-FTN-REPLY' : 'REPLY', + 'X-FTN-PID' : 'PID', + 'X-FTN-FLAGS' : 'FLAGS', + 'X-FTN-TID' : 'TID', + 'X-FTN-CHRS' : 'CHRS', + // :TODO: X-FTN-KLUDGE - not sure what this is? + }; + + // + // Various kludge tags defined by QWKE, etc. + // See the following: + // - ftp://vert.synchro.net/main/BBS/qwke.txt + // - http://wiki.synchro.net/ref:qwk + // + const Kludges = { + // QWKE + To : 'To:', + From : 'From:', + Subject : 'Subject:', + + // Synchronet + Via : '@VIA:', + MsgID : '@MSGID:', + Reply : '@REPLY:', + TZ : '@TZ:', // https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h + ReplyTo : '@REPLYTO:', + + // :TODO: Look into other non-standards + // https://github.com/wmcbrine/MultiMail/blob/master/mmail/qwk.cc + // title, @subject, etc. + }; + + let blockCount = 0; + let currMessage = { }; + let state; + let messageBlocksRemain; + const buffer = Buffer.alloc(QWKMessageBlockSize); + + const readNextBlock = () => { + fs.read(fd, buffer, 0, QWKMessageBlockSize, null, (err, read) => { + if (err) { + return cb(err); + } + + if (0 == read) { + // we're done consuming all blocks + return fs.close(fd, err => { + return cb(err); + }); + } + + if (QWKMessageBlockSize !== read) { + return cb(Errors.Invalid(`Invalid QWK message block size. Expected ${QWKMessageBlockSize} got ${read}`)); + } + + if (0 === blockCount) { + // first 128 bytes is a space padded ID + const id = buffer.toString('ascii').trim(); + this.emit('creator', id); + state = 'header'; + } else { + switch (state) { + case 'header' : + const header = MessageHeaderParser.parse(buffer); + encoding = encodingToSpec; // reset per message + + // massage into something a little more sane (things we can't quite do in the parser directly) + ['toName', 'fromName', 'subject'].forEach(field => { + // note: always use to-spec encoding here + header[field] = iconv.decode(header[field], encodingToSpec).trim(); + }); + + header.timestamp = moment(header.timestamp, QWKHeaderTimestampFormat); + + currMessage = { + header, + // these may be overridden + toName : header.toName, + fromName : header.fromName, + subject : header.subject, + }; + + if (_.has(this.packetInfo, 'headers.ini')) { + // Sections for a message in HEADERS.DAT are by current byte offset. + // 128 = first message header = 0x80 = section [80] + const headersSectionId = (blockCount * QWKMessageBlockSize).toString(16); + currMessage.headersExtension = this.packetInfo.headers.ini[headersSectionId]; + } + + // if we have HEADERS.DAT with a 'Utf8' override for this message, + // the overridden to/from/subject/message fields are UTF-8 + if (currMessage.headersExtension && 'true' === currMessage.headersExtension.Utf8.toLowerCase()) { + encoding = 'utf8'; + } + + // remainder of blocks until the end of this message + messageBlocksRemain = header.numBlocks - 1; + state = 'message'; + break; + + case 'message' : + if (!currMessage.body) { + currMessage.body = Buffer.from(buffer); + } else { + currMessage.body = Buffer.concat([currMessage.body, buffer]); + } + messageBlocksRemain -= 1; + + if (0 === messageBlocksRemain) { + // 1:n buffers to make up body. Decode: + // First, replace QWK style line feeds (0xe3) unless the message is UTF-8. + // If the message is UTF-8, we assume it's using standard line feeds. + if (encoding !== 'utf8') { + replaceCharInBuffer(currMessage.body, QWKLF, 0x0a); + } + + // + // Decode the message based on our final message encoding. Split the message + // into lines so we can extract various bits such as QWKE headers, origin, tear + // lines, etc. + // + const messageLines = splitTextAtTerms(iconv.decode(currMessage.body, encoding).trimEnd()); + const bodyLines = []; + + let bodyState = 'kludge'; + + const MessageTrailers = { + // While technically FTN oriented, these can come from any network + // (though we'll be processing a lot of messages that routed through FTN + // at some point) + Origin : /^[ ]{1,2}\* Origin: /, + Tear : /^--- /, + }; + + const qwkKludge = {}; + const ftnProperty = {}; + const ftnKludge = {}; + + messageLines.forEach(line => { + if (0 === line.length) { + return bodyLines.push(''); + } + + switch (bodyState) { + case 'kludge' : + // :TODO: Update these to use the well known consts: + if (line.startsWith(Kludges.To)) { + currMessage.toName = line.substring(Kludges.To.length).trim(); + } else if (line.startsWith(Kludges.From)) { + currMessage.fromName = line.substring(Kludges.From.length).trim(); + } else if (line.startsWith(Kludges.Subject)) { + currMessage.subject = line.substring(Kludges.Subject.length).trim(); + } else if (line.startsWith(Kludges.Via)) { + qwkKludge['@VIA'] = line; + } else if (line.startsWith(Kludges.MsgID)) { + qwkKludge['@MSGID'] = line.substring(Kludges.MsgID.length).trim(); + } else if (line.startsWith(Kludges.Reply)) { + qwkKludge['@REPLY'] = line.substring(Kludges.Reply.length).trim(); + } else if (line.startsWith(Kludges.TZ)) { + qwkKludge['@TZ'] = line.substring(Kludges.TZ.length).trim(); + } else if (line.startsWith(Kludges.ReplyTo)) { + qwkKludge['@REPLYTO'] = line.substring(Kludges.ReplyTo.length).trim(); + } else { + bodyState = 'body'; // past this point and up to any tear/origin/etc., is the real message body + bodyLines.push(line); + } + break; + + case 'body' : + case 'trailers' : + if (MessageTrailers.Origin.test(line)) { + ftnProperty.ftn_origin = line; + bodyState = 'trailers'; + } else if (MessageTrailers.Tear.test(line)) { + ftnProperty.ftn_tear_line = line; + bodyState = 'trailers'; + } else if ('body' === bodyState) { + bodyLines.push(line); + } + } + }); + + let messageTimestamp = currMessage.header.timestamp; + + // HEADERS.DAT support. + let useTZKludge = true; + if (currMessage.headersExtension) { + const ext = currMessage.headersExtension; + + // to and subject can be overridden yet again if entries are present + currMessage.toName = ext.To || currMessage.toName; + currMessage.subject = ext.Subject || currMessage.subject; + currMessage.from = ext.Sender || currMessage.fromName; // why not From? Who the fuck knows. + + // possibly override message ID kludge + qwkKludge['@MSGID'] = ext['Message-ID'] || qwkKludge['@MSGID']; + + // WhenWritten contains a ISO-8601-ish timestamp and a Synchronet/SMB style TZ offset: + // 20180101174837-0600 4168 + // We can use this to get a very slightly better precision on the timestamp (addition of seconds) + // over the headers value. Why not milliseconds? Who the fuck knows. + if (ext.WhenWritten) { + const whenWritten = moment(ext.WhenWritten, 'YYYYMMDDHHmmssZ'); + if (whenWritten.isValid()) { + messageTimestamp = whenWritten; + useTZKludge = false; + } + } + + if (ext.Tags) { + currMessage.hashTags = (ext.Tags).toString().split(' '); + } + + // FTN style properties/kludges represented as X-FTN-XXXX + for (let [extName, propName] of Object.entries(FTNPropertyMapping)) { + const v = ext[extName]; + if (v) { + ftnProperty[propName] = v; + } + } + + for (let [extName, kludgeName] of Object.entries(FTNKludgeMapping)) { + const v = ext[extName]; + if (v) { + ftnKludge[kludgeName] = v; + } + } + } + + const message = new Message({ + toUserName : currMessage.toName, + fromUserName : currMessage.fromName, + subject : currMessage.subject, + modTimestamp : messageTimestamp, + message : bodyLines.join('\n'), + hashTags : currMessage.hashTags, + }); + + // Indicate this message was imported from a QWK packet + message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.AddressFlavor.QWK; + + if (!_.isEmpty(qwkKludge)) { + message.meta.QwkKludge = qwkKludge; + } + + if (!_.isEmpty(ftnProperty)) { + message.meta.FtnProperty = ftnProperty; + } + + if (!_.isEmpty(ftnKludge)) { + message.meta.FtnKludge = ftnKludge; + } + + // Add in tear line and origin if requested + if (this.options.keepTearAndOrigin) { + if (ftnProperty.ftn_tear_line) { + message.message += `\r\n${ftnProperty.ftn_tear_line}\r\n`; + } + + if (ftnProperty.ftn_origin) { + message.message += `${ftnProperty.ftn_origin}\r\n`; + } + } + + // Update the timestamp if we have a valid TZ + if (useTZKludge && qwkKludge['@TZ']) { + const tzOffset = SMBTZToUTCOffset[qwkKludge['@TZ']]; + if (tzOffset) { + message.modTimestamp.utcOffset(tzOffset); + } + } + + message.meta.QwkProperty = { + qwk_msg_status : currMessage.header.status, + qwk_in_reply_to_num : currMessage.header.replyToNum, + }; + + if (this.options.mode === QWKPacketReader.Modes.QWK) { + message.meta.QwkProperty.qwk_msg_num = currMessage.header.num; + message.meta.QwkProperty.qwk_conf_num = currMessage.header.confNum; + } else { + // For REP's, prefer the larger field. + message.meta.QwkProperty.qwk_conf_num = currMessage.header.num || currMessage.header.confNum; + } + + // Another quick HEADERS.DAT fix-up + if (currMessage.headersExtension) { + message.meta.QwkProperty.qwk_conf_num = currMessage.headersExtension.Conference || message.meta.QwkProperty.qwk_conf_num; + } + + this.emit('message', message); + state = 'header'; + } + break; + } + } + + ++blockCount; + readNextBlock(); + }); + }; + + // start reading blocks + readNextBlock(); + }); + } +}; + +class QWKPacketWriter extends EventEmitter { + constructor( + { + mode = QWKPacketWriter.Modes.User, + enableQWKE = true, + enableHeadersExtension = true, + enableAtKludges = true, + systemDomain = 'enigma-bbs', + bbsID = 'ENIGMA', + user = null, + archiveFormat = 'application/zip', + forceEncoding = null, + } = QWKPacketWriter.DefaultOptions) + { + super(); + + this.options = { + mode, + enableQWKE, + enableHeadersExtension, + enableAtKludges, + systemDomain, + bbsID, + user, + archiveFormat, + forceEncoding : forceEncoding ? forceEncoding.toLowerCase() : null, + }; + + this.temptmp = temptmp.createTrackedSession('qwkpacketwriter'); + + this.areaTagConfMap = {}; + } + + static get DefaultOptions() { + return { + mode : QWKPacketWriter.Modes.User, + enableQWKE : true, + enableHeadersExtension : true, + enableAtKludges : true, + systemDomain : 'enigma-bbs', + bbsID : 'ENIGMA', + user : null, + archiveFormat :'application/zip', + forceEncoding : null, + }; + } + + static get Modes() { + return { + User : 'user', // creation of a packet for a user (non-network); non-mapped confs allowed + Network : 'network', // creation of a packet for QWK network + }; + } + + init() { + async.series( + [ + (callback) => { + return StatLog.init(callback); + }, + (callback) => { + this.temptmp.mkdir( { prefix : 'enigqwkwriter-'}, (err, workDir) => { + this.workDir = workDir; + return callback(err); + }); + }, + (callback) => { + // + // Prepare areaTag -> conference number mapping: + // - In User mode, areaTags's that are not explicitly configured + // will have their conference number auto-generated. + // - In Network mode areaTags's missing a configuration will not + // be mapped, and thus skipped. + // + const configuredAreas = _.get(Config(), 'messageNetworks.qwk.areas'); + if (configuredAreas) { + Object.keys(configuredAreas).forEach(areaTag => { + const confNumber = configuredAreas[areaTag].conference; + if (confNumber) { + this.areaTagConfMap[areaTag] = confNumber; + } + }); + } + + if (this.options.mode === QWKPacketWriter.Modes.User) { + // All the rest + // Start at 1000 to work around what seems to be a bug with some readers + let confNumber = 1000; + const usedConfNumbers = new Set(Object.values(this.areaTagConfMap)); + getAllAvailableMessageAreaTags().forEach(areaTag => { + if (this.areaTagConfMap[areaTag]) { + return; + } + + while (confNumber < 10001 && usedConfNumbers.has(confNumber)) { + ++confNumber; + } + + // we can go up to 65535 for some things, but NDX files are limited to 9999 + if (confNumber === 10000) { // sanity... + this.emit('warning', Errors.General(`To many conferences`)); + } else { + this.areaTagConfMap[areaTag] = confNumber; + ++confNumber; + } + }); + } + + return callback(null); + }, + (callback) => { + this.messagesStream = fs.createWriteStream(paths.join(this.workDir, 'messages.dat')); + + if (this.options.enableHeadersExtension) { + this.headersDatStream = fs.createWriteStream(paths.join(this.workDir, 'headers.dat')); + } + + // First block is a space padded ID + const id = `Created with ENiGMA 1/2 BBS v${enigmaVersion} Copyright (c) 2015-2020 Bryan Ashby`; + this.messagesStream.write(id.padEnd(QWKMessageBlockSize, ' '), 'ascii'); + this.currentMessageOffset = QWKMessageBlockSize; + + this.totalMessages = 0; + this.areaTagsSeen = new Set(); + this.personalIndex = []; // messages addressed to 'user' + this.inboxIndex = []; // private messages for 'user' + this.publicIndex = new Map(); + + return callback(null); + }, + ], + err => { + if (err) { + return this.emit('error', err); + } + + this.emit('ready'); + } + ) + } + + makeMessageIdentifier(message) { + return `<${message.messageId}.${message.messageUuid}@${this.options.systemDomain}>`; + } + + _encodeWithFallback(s, encoding) { + try { + return iconv.encode(s, encoding); + } catch (e) { + this.emit('warning', Errors.General(`Failed to encode buffer using ${encoding}; Falling back to 'ascii'`)); + return iconv.encode(s, 'ascii'); + } + } + + appendMessage(message) { + // + // Each message has to: + // - Append to MESSAGES.DAT + // - Append to HEADERS.DAT if enabled + // + // If this is a personal (ie: non-network) packet: + // - Produce PERSONAL.NDX + // - Produce 000.NDX with pointers to the users personal "inbox" mail + // - Produce ####.NDX with pointers to the public/conference mail + // - Produce TOREADER.EXT if QWKE support is enabled + // + + let fullMessageBody = ''; + + // Start of body is kludges if enabled + if (this.options.enableQWKE) { + if (message.toUserName.length > 25) { + fullMessageBody += `To: ${message.toUserName}\n`; + } + if (message.fromUserName.length > 25) { + fullMessageBody += `From: ${message.fromUserName}\n`; + } + if (message.subject.length > 25) { + fullMessageBody += `Subject: ${message.subject}\n`; + } + } + + if (this.options.enableAtKludges) { + // Add in original kludges (perhaps in a different order) if + // they were originally imported + if (Message.AddressFlavor.QWK == message.meta.System[Message.SystemMetaNames.ExternalFlavor]) { + if (message.meta.QwkKludge) { + for (let [kludge, value] of Object.entries(message.meta.QwkKludge)) { + fullMessageBody += `${kludge}: ${value}\n`; + }; + } + } else { + fullMessageBody += `@MSGID: ${this.makeMessageIdentifier(message)}\n`; + fullMessageBody += `@TZ: ${UTCOffsetToSMBTZ[moment().format('Z')]}\n`; + // :TODO: REPLY and REPLYTO + } + } + + // Sanitize line feeds (e.g. CRLF -> LF, and possibly -> QWK style below) + splitTextAtTerms(message.message).forEach(line => { + fullMessageBody += `${line}\n`; + }); + + const encoding = this._getEncoding(message); + + const encodedMessage = this._encodeWithFallback(fullMessageBody, encoding); + + // + // QWK spec wants line feeds as 0xe3 for some reason, so we'll have + // to replace the \n's. If we're going against the spec and using UTF-8 + // we can just leave them be. + // + if ('utf8' !== encoding) { + replaceCharInBuffer(encodedMessage, 0x0a, QWKLF); + } + + // Messages must comprise of multiples of 128 bit blocks with the last + // block padded by spaces or nulls (we use nulls) + const fullBlocks = Math.trunc(encodedMessage.length / QWKMessageBlockSize); + const remainBytes = QWKMessageBlockSize - (encodedMessage.length % QWKMessageBlockSize); + const totalBlocks = fullBlocks + 1 + (remainBytes ? 1 : 0); + + // The first block is always a header + if (!this._writeMessageHeader( + message, + totalBlocks + )) + { + // we can't write this message + return; + } + + this.messagesStream.write(encodedMessage); + + if (remainBytes) { + this.messagesStream.write(Buffer.alloc(remainBytes, ' ')); + } + + this._updateIndexTracking(message); + + if (this.options.enableHeadersExtension) { + this._appendHeadersExtensionData(message, encoding); + } + + // next message starts at this block + this.currentMessageOffset += totalBlocks * QWKMessageBlockSize; + + this.totalMessages += 1; + this.areaTagsSeen.add(message.areaTag); + } + + _getEncoding(message) { + if (this.options.forceEncoding) { + return this.options.forceEncoding; + } + + // If the system has stored an explicit encoding, use that. + let encoding = _.get(message.meta, 'System.explicit_encoding'); + if (encoding) { + return encoding; + } + + // If the message is already tagged with a supported encoding + // indicator such as FTN-style CHRS, try to use that. + encoding = _.get(message.meta, 'FtnKludge.CHRS'); + if (encoding) { + // convert from CHRS to something standard + encoding = getEncodingFromCharacterSetIdentifier(encoding); + if (encoding) { + return encoding; + } + } + + // The to-spec default is CP437/ASCII. If it can be encoded as + // such then do so. + if (message.isCP437Encodable()) { + return 'cp437'; + } + + // Something more modern... + return 'utf8'; + } + + _messageAddressedToUser(message) { + if (_.isUndefined(this.cachedCompareNames)) { + if (this.options.user) { + this.cachedCompareNames = [ + this.options.user.username.toLowerCase() + ]; + const realName = this.options.user.getProperty(UserProps.RealName); + if (realName) { + this.cachedCompareNames.push(realName.toLowerCase()); + } + } else { + this.cachedCompareNames = []; + } + }; + + return this.cachedCompareNames.includes(message.toUserName.toLowerCase()); + } + + _updateIndexTracking(message) { + // index points at start of *message* not the header for... reasons? + const index = (this.currentMessageOffset / QWKMessageBlockSize) + 1; + if (message.isPrivate()) { + this.inboxIndex.push(index); + } else { + if (this._messageAddressedToUser(message)) { + // :TODO: add to both indexes??? + this.personalIndex.push(index); + } + + const areaTag = message.areaTag; + if (!this.publicIndex.has(areaTag)) { + this.publicIndex.set(areaTag, [index]); + } else { + this.publicIndex.get(areaTag).push(index); + } + } + } + + appendNewFile() { + + } + + finish(packetDirectory) { + async.series( + [ + (callback) => { + this.messagesStream.on('close', () => { + return callback(null); + }); + this.messagesStream.end(); + }, + (callback) => { + if (!this.headersDatStream) { + return callback(null); + } + this.headersDatStream.on('close', () => { + return callback(null); + }); + this.headersDatStream.end(); + }, + (callback) => { + return this._createControlData(callback); + }, + (callback) => { + return this._createIndexes(callback); + }, + (callback) => { + return this._producePacketArchive(packetDirectory, callback); + } + ], + err => { + this.temptmp.cleanup(); + + if (err) { + return this.emit('error', err); + } + + this.emit('finished'); + } + ) + } + + _getNextAvailPacketFileName(packetDirectory, cb) { + // + // According to http://wiki.synchro.net/ref:qwk filenames should + // start with .QWK -> .QW1 ... .QW9 -> .Q10 ... .Q99 + // + let digits = 0; + async.doWhilst( callback => { + let ext; + if (0 === digits) { + ext = 'QWK'; + } else if (digits < 10) { + ext = `QW${digits}`; + } else if (digits < 100) { + ext = `Q${digits}`; + } else { + return callback(Errors.UnexpectedState(`Unable to choose a valid QWK output filename`)); + } + + ++digits; + + const filename = `${this.options.bbsID}.${ext}`; + fs.stat(paths.join(packetDirectory, filename), (err, stats) => { + if (err && 'ENOENT' === err.code) { + return callback(null, filename); + } else { + return callback(null, null); + } + }); + }, + (filename, callback) => { + return callback(null, filename ? false : true); + }, + (err, filename) => { + return cb(err, filename); + }); + } + + _producePacketArchive(packetDirectory, cb) { + const archiveUtil = ArchiveUtil.getInstance(); + + fs.readdir(this.workDir, (err, files) => { + if (err) { + return cb(err); + } + + this._getNextAvailPacketFileName(packetDirectory, (err, filename) => { + if (err) { + return cb(err); + } + + const packetPath = paths.join(packetDirectory, filename); + archiveUtil.compressTo( + this.options.archiveFormat, + packetPath, + files, + this.workDir, + err => { + fs.stat(packetPath, (err, stats) => { + if (stats) { + this.emit('packet', { stats, path : packetPath } ); + } + return cb(err); + }); + } + ); + }); + }); + } + + _qwkMessageStatus(message) { + // - Public vs Private + // - Look at message pointers for read status + // - If +op is exporting and this message is to +op + // - + // :TODO: this needs addressed - handle unread vs read, +op, etc. + // ....see getNewMessagesInAreaForUser(); Variant with just IDs, or just a way to get first new message ID per area? + + if (message.isPrivate()) { + return QWKMessageStatusCodes.UnreadPrivate; + } + return QWKMessageStatusCodes.UnreadPublic; + } + + _writeMessageHeader(message, totalBlocks) { + const asciiNum = (n, l) => { + if (isNaN(n)) { + return ''; + } + return n.toString().substr(0, l); + }; + + const asciiTotalBlocks = asciiNum(totalBlocks, 6); + if (asciiTotalBlocks.length > 6) { + this.emit('warning', Errors.General('Message too large for packet'), message); + return false; + } + + const conferenceNumber = this._getMessageConferenceNumberByAreaTag(message.areaTag); + if (isNaN(conferenceNumber)) { + this.emit('warning', Errors.MissingConfig(`No QWK conference mapping for areaTag ${message.areaTag}`)); + return false; + } + + const header = Buffer.alloc(QWKMessageBlockSize, ' '); + header.write(this._qwkMessageStatus(message), 0, 1, 'ascii'); + header.write(asciiNum(message.messageId), 1, 'ascii'); + header.write(message.modTimestamp.format(QWKHeaderTimestampFormat), 8, 13, 'ascii'); + header.write(message.toUserName.substr(0, 25), 21, 'ascii'); + header.write(message.fromUserName.substr(0, 25), 46, 'ascii'); + header.write(message.subject.substr(0, 25), 71, 'ascii'); + header.write(' '.repeat(12), 96, 'ascii'); // we don't use the password field + header.write(asciiNum(message.replyToMsgId), 108, 'ascii'); + header.write(asciiTotalBlocks, 116, 'ascii'); + header.writeUInt8(QWKMessageActiveStatus.Active, 122); + header.writeUInt16LE(conferenceNumber, 123); + header.writeUInt16LE(this.totalMessages + 1, 125); + header.write(QWKNetworkTagIndicator.NotPresent, 127, 1, 'ascii'); // :TODO: Present if for network output? + + this.messagesStream.write(header); + + return true; + } + + _getMessageConferenceNumberByAreaTag(areaTag) { + if (Message.isPrivateAreaTag(areaTag)) { + return 0; + } + + return this.areaTagConfMap[areaTag]; + } + + _getExportForUsername() { + return _.get(this.options, 'user.username', 'Any'); + } + + _getExportSysOpUsername() { + return StatLog.getSystemStat(SysProps.SysOpUsername) || 'SysOp'; + } + + _createControlData(cb) { + const areas = Array.from(this.areaTagsSeen).map(areaTag => { + if (Message.isPrivateAreaTag(areaTag)) { + return { + areaTag : Message.WellKnownAreaTags.Private, + name : 'Private', + desc : 'Private Messages', + }; + } + return getMessageAreaByTag(areaTag); + }); + + const controlStream = fs.createWriteStream(paths.join(this.workDir, 'control.dat')); + controlStream.setDefaultEncoding('ascii'); + + controlStream.on('close', () => { + return cb(null); + }); + + controlStream.on('error', err => { + return cb(err); + }); + + const initialControlData = [ + Config().general.boardName, + 'Earth', + 'XXX-XXX-XXX', + `${this._getExportSysOpUsername()}, Sysop`, + `0000,${this.options.bbsID}`, + moment().format('MM-DD-YYYY,HH:mm:ss'), + this._getExportForUsername(), + '', // name of Qmail menu + '0', // uh, OK + this.totalMessages.toString(), + // this next line is total conferences - 1: + // We have areaTag <> conference mapping, so the number should work out + (this.areaTagsSeen.size - 1).toString(), + ]; + + initialControlData.forEach(line => { + controlStream.write(`${line}\r\n`); + }); + + // map areas as conf #\r\nDescription\r\n pairs + areas.forEach(area => { + const conferenceNumber = this._getMessageConferenceNumberByAreaTag(area.areaTag); + const conf = getMessageConferenceByTag(area.confTag); + const desc = `${conf.name} - ${area.name}`; + + controlStream.write(`${conferenceNumber}\r\n`); + controlStream.write(`${desc}\r\n`); + }); + + // :TODO: do we ever care here?! + ['HELLO', 'BBSNEWS', 'GOODBYE'].forEach(trailer => { + controlStream.write(`${trailer}\r\n`); + }); + + controlStream.end(); + } + + _createIndexes(cb) { + const appendIndexData = (stream, offset) => { + const msb = numToMbf32(offset); + stream.write(msb); + + // technically, the conference #, but only as a byte, so pretty much useless + // AND the filename itself is the conference number... dafuq. + stream.write(Buffer.from([0x00])); + }; + + async.series( + [ + (callback) => { + // Create PERSONAL.NDX + if (!this.personalIndex.length) { + return callback(null); + } + + const indexStream = fs.createWriteStream(paths.join(this.workDir, 'personal.ndx')); + this.personalIndex.forEach(offset => appendIndexData(indexStream, offset)); + + indexStream.on('close', err => { + return callback(err); + }); + + indexStream.end(); + }, + (callback) => { + // 000.NDX of private mails + if (!this.inboxIndex.length) { + return callback(null); + } + + const indexStream = fs.createWriteStream(paths.join(this.workDir, '000.ndx')); + this.inboxIndex.forEach(offset => appendIndexData(indexStream, offset)); + + indexStream.on('close', err => { + return callback(err); + }); + + indexStream.end(); + }, + (callback) => { + // ####.NDX + async.eachSeries(this.publicIndex.keys(), (areaTag, nextArea) => { + const offsets = this.publicIndex.get(areaTag); + const conferenceNumber = this._getMessageConferenceNumberByAreaTag(areaTag); + const indexStream = fs.createWriteStream(paths.join(this.workDir, `${conferenceNumber.toString().padStart(4, '0')}.ndx`)); + offsets.forEach(offset => appendIndexData(indexStream, offset)); + + indexStream.on('close', err => { + return nextArea(err); + }); + + indexStream.end(); + }, + err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + + _makeSynchronetTimestamp(ts) { + const syncTimestamp = ts.format('YYYYMMDDHHmmssZZ'); + const syncTZ = UTCOffsetToSMBTZ[ts.format('Z')] || '0000'; // :TODO: what if we don't have a map? + return `${syncTimestamp} ${syncTZ}`; + } + + _appendHeadersExtensionData(message, encoding) { + const messageData = { + // Synchronet style + Utf8 : ('utf8' === encoding ? 'true' : 'false'), + 'Message-ID' : this.makeMessageIdentifier(message), + + WhenWritten : this._makeSynchronetTimestamp(message.modTimestamp), + // WhenImported : '', // :TODO: only if we have a imported time from another external system? + ExportedFrom : `${this.options.systemID} ${message.areaTag} ${message.messageId}`, + Sender : message.fromUserName, + + // :TODO: if exporting for QWK-Net style/etc. + //SenderNetAddr + + SenderIpAddr : '127.0.0.1', // no sir, that's private. + SenderHostName : this.options.systemDomain, + // :TODO: if exported: + //SenderProtocol + Organization : 'BBS', + + //'Reply-To' : :TODO: "address to direct replies".... ?! + Subject : message.subject, + To : message.toUserName, + //ToNetAddr : :TODO: net addr to?! + + // :TODO: Only set if not imported: + Tags : message.hashTags.join(' '), + + // :TODO: Needs tested with Sync/etc.; Sync wants Conference *numbers* + Conference : message.isPrivate() ? '0' : getMessageConfTagByAreaTag(message.areaTag), + + // ENiGMA Headers + MessageUUID : message.messageUuid, + ModTimestamp : message.modTimestamp.format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + AreaTag : message.areaTag, + }; + + const externalFlavor = message.meta.System[Message.SystemMetaNames.ExternalFlavor]; + if (externalFlavor === Message.AddressFlavor.FTN) { + // Add FTN properties if it came from such an origin + if (message.meta.FtnProperty) { + const ftnProp = message.meta.FtnProperty; + messageData['X-FTN-AREA'] = ftnProp[Message.FtnPropertyNames.FtnArea]; + messageData['X-FTN-SEEN-BY'] = ftnProp[Message.FtnPropertyNames.FtnSeenBy]; + } + + if (message.meta.FtnKludge) { + const ftnKludge = message.meta.FtnKludge; + messageData['X-FTN-PATH'] = ftnKludge.PATH; + messageData['X-FTN-MSGID'] = ftnKludge.MSGID; + messageData['X-FTN-REPLY'] = ftnKludge.REPLY; + messageData['X-FTN-PID'] = ftnKludge.PID; + messageData['X-FTN-FLAGS'] = ftnKludge.FLAGS; + messageData['X-FTN-TID'] = ftnKludge.TID; + messageData['X-FTN-CHRS'] = ftnKludge.CHRS; + } + } else { + messageData.WhenExported = this._makeSynchronetTimestamp(moment()); + messageData.Editor = `ENiGMA 1/2 BBS FSE v${enigmaVersion}`; + } + + this.headersDatStream.write(this._encodeWithFallback(`[${this.currentMessageOffset.toString(16)}]\r\n`, encoding)); + + for (let [name, value] of Object.entries(messageData)) { + if (value) { + this.headersDatStream.write(this._encodeWithFallback(`${name}: ${value}\r\n`, encoding)); + } + } + + this.headersDatStream.write('\r\n'); + } +} + +module.exports = { + QWKPacketReader, + QWKPacketWriter, +} diff --git a/core/rumorz.js b/core/rumorz.js new file mode 100644 index 00000000..51e58d53 --- /dev/null +++ b/core/rumorz.js @@ -0,0 +1,252 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const theme = require('./theme.js'); +const resetScreen = require('./ansi_term.js').resetScreen; +const StatLog = require('./stat_log.js'); +const renderStringLength = require('./string_util.js').renderStringLength; +const SystemLogKeys = require('./system_log.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Rumorz', + desc : 'Standard local rumorz', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.rumorz', +}; + +const FormIds = { + View : 0, + Add : 1, +}; + +const MciCodeIds = { + ViewForm : { + Entries : 1, + AddPrompt : 2, + }, + AddForm : { + NewEntry : 1, + EntryPreview : 2, + AddPrompt : 3, + } +}; + +exports.getModule = class RumorzModule extends MenuModule { + constructor(options) { + super(options); + + this.menuMethods = { + viewAddScreen : (formData, extraArgs, cb) => { + return this.displayAddScreen(cb); + }, + + addEntry : (formData, extraArgs, cb) => { + if(_.isString(formData.value.rumor) && renderStringLength(formData.value.rumor) > 0) { + const rumor = formData.value.rumor.trim(); // remove any trailing ws + + StatLog.appendSystemLogEntry( + SystemLogKeys.UserAddedRumorz, + rumor, + StatLog.KeepDays.Forever, + StatLog.KeepType.Forever, + () => { + this.clearAddForm(); + return this.displayViewScreen(true, cb); // true=cls + } + ); + } else { + // empty message - treat as if cancel was hit + return this.displayViewScreen(true, cb); // true=cls + } + }, + + cancelAdd : (formData, extraArgs, cb) => { + this.clearAddForm(); + return this.displayViewScreen(true, cb); // true=cls + } + }; + } + + get config() { return this.menuConfig.config; } + + clearAddForm() { + const newEntryView = this.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry); + const previewView = this.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview); + + newEntryView.setText(''); + + // preview is optional + if(previewView) { + previewView.setText(''); + } + } + + initSequence() { + const self = this; + + async.series( + [ + function beforeDisplayArt(callback) { + self.beforeArt(callback); + }, + function display(callback) { + self.displayViewScreen(false, callback); + } + ], + err => { + if(err) { + // :TODO: Handle me -- initSequence() should really take a completion callback + } + self.finishedLoading(); + } + ); + } + + displayViewScreen(clearScreen, cb) { + const self = this; + async.waterfall( + [ + function clearAndDisplayArt(callback) { + if(self.viewControllers.add) { + self.viewControllers.add.setFocus(false); + } + + if(clearScreen) { + self.client.term.rawWrite(resetScreen()); + } + + theme.displayThemedAsset( + self.config.art.entries, + self.client, + { font : self.menuConfig.font, trailingLF : false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function initOrRedrawViewController(artData, callback) { + if(_.isUndefined(self.viewControllers.add)) { + const vc = self.addViewController( + 'view', + new ViewController( { client : self.client, formId : FormIds.View } ) + ); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds.View, + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } else { + self.viewControllers.view.setFocus(true); + self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt).redraw(); + return callback(null); + } + }, + function fetchEntries(callback) { + const entriesView = self.viewControllers.view.getView(MciCodeIds.ViewForm.Entries); + + StatLog.getSystemLogEntries(SystemLogKeys.UserAddedRumorz, StatLog.Order.Timestamp, (err, entries) => { + return callback(err, entriesView, entries); + }); + }, + function populateEntries(entriesView, entries, callback) { + entriesView.setItems(entries.map(e => { + return { + text : e.log_value, // standard + rumor : e.log_value, + }; + })); + + entriesView.redraw(); + + return callback(null); + }, + function finalPrep(callback) { + const promptView = self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt); + promptView.setFocusItemIndex(1); // default to NO + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayAddScreen(cb) { + const self = this; + + async.waterfall( + [ + function clearAndDisplayArt(callback) { + self.viewControllers.view.setFocus(false); + self.client.term.rawWrite(resetScreen()); + + theme.displayThemedAsset( + self.config.art.add, + self.client, + { font : self.menuConfig.font }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function initOrRedrawViewController(artData, callback) { + if(_.isUndefined(self.viewControllers.add)) { + const vc = self.addViewController( + 'add', + new ViewController( { client : self.client, formId : FormIds.Add } ) + ); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds.Add, + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } else { + self.viewControllers.add.setFocus(true); + self.viewControllers.add.redrawAll(); + self.viewControllers.add.switchFocus(MciCodeIds.AddForm.NewEntry); + return callback(null); + } + }, + function initPreviewUpdates(callback) { + const previewView = self.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview); + const entryView = self.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry); + if(previewView) { + let timerId; + entryView.on('key press', () => { + clearTimeout(timerId); + timerId = setTimeout( () => { + const focused = self.viewControllers.add.getFocusedView(); + if(focused === entryView) { + previewView.setText(entryView.getData()); + focused.setFocus(true); + } + }, 500); + }); + } + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } +}; diff --git a/core/sauce.js b/core/sauce.js index 295a6069..40434861 100644 --- a/core/sauce.js +++ b/core/sauce.js @@ -1,169 +1,191 @@ /* jslint node: true */ 'use strict'; -var binary = require('binary'); -var iconv = require('iconv-lite'); +const Errors = require('./enig_error.js').Errors; -exports.readSAUCE = readSAUCE; +// deps +const iconv = require('iconv-lite'); +const { Parser } = require('binary-parser'); -const SAUCE_SIZE = 128; -const SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE' -const COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT' +exports.readSAUCE = readSAUCE; -exports.SAUCE_SIZE = SAUCE_SIZE; -// :TODO: SAUCE should be a class -// - with getFontName() -// - ...other methods +const SAUCE_SIZE = 128; +const SAUCE_ID = Buffer.from([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE' + +// :TODO read comments +//const COMNT_ID = Buffer.from([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT' + +exports.SAUCE_SIZE = SAUCE_SIZE; +// :TODO: SAUCE should be a class +// - with getFontName() +// - ...other methods // -// See -// http://www.acid.org/info/sauce/sauce.htm +// See +// http://www.acid.org/info/sauce/sauce.htm // const SAUCE_VALID_DATA_TYPES = [0, 1, 2, 3, 4, 5, 6, 7, 8 ]; +const SAUCEParser = new Parser() + .buffer('id', { length : 5 } ) + .buffer('version', { length : 2 } ) + .buffer('title', { length: 35 } ) + .buffer('author', { length : 20 } ) + .buffer('group', { length: 20 } ) + .buffer('date', { length: 8 } ) + .uint32le('fileSize') + .int8('dataType') + .int8('fileType') + .uint16le('tinfo1') + .uint16le('tinfo2') + .uint16le('tinfo3') + .uint16le('tinfo4') + .int8('numComments') + .int8('flags') + // :TODO: does this need to be optional? + .buffer('tinfos', { length: 22 } ); // SAUCE 00.5 + function readSAUCE(data, cb) { - if(data.length < SAUCE_SIZE) { - cb(new Error('No SAUCE record present')); - return; - } + if(data.length < SAUCE_SIZE) { + return cb(Errors.DoesNotExist('No SAUCE record present')); + } - var offset = data.length - SAUCE_SIZE; - var sauceRec = data.slice(offset); + let sauceRec; + try { + sauceRec = SAUCEParser.parse(data.slice(data.length - SAUCE_SIZE)); + } catch(e) { + return cb(Errors.Invalid('Invalid SAUCE record')); + } - binary.parse(sauceRec) - .buffer('id', 5) - .buffer('version', 2) - .buffer('title', 35) - .buffer('author', 20) - .buffer('group', 20) - .buffer('date', 8) - .word32lu('fileSize') - .word8('dataType') - .word8('fileType') - .word16lu('tinfo1') - .word16lu('tinfo2') - .word16lu('tinfo3') - .word16lu('tinfo4') - .word8('numComments') - .word8('flags') - .buffer('tinfos', 22) // SAUCE 00.5 - .tap(function onVars(vars) { + if(!SAUCE_ID.equals(sauceRec.id)) { + return cb(Errors.DoesNotExist('No SAUCE record present')); + } - if(!SAUCE_ID.equals(vars.id)) { - return cb(new Error('No SAUCE record present')); - } + const ver = iconv.decode(sauceRec.version, 'cp437'); - var ver = iconv.decode(vars.version, 'cp437'); + if('00' !== ver) { + return cb(Errors.Invalid(`Unsupported SAUCE version: ${ver}`)); + } - if('00' !== ver) { - return cb(new Error('Unsupported SAUCE version: ' + ver)); - } + if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(sauceRec.dataType)) { + return cb(Errors.Invalid(`Unsupported SAUCE DataType: ${sauceRec.dataType}`)); + } - if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(vars.dataType)) { - return cb(new Error('Unsupported SAUCE DataType: ' + vars.dataType)); - } + const sauce = { + id : iconv.decode(sauceRec.id, 'cp437'), + version : iconv.decode(sauceRec.version, 'cp437').trim(), + title : iconv.decode(sauceRec.title, 'cp437').trim(), + author : iconv.decode(sauceRec.author, 'cp437').trim(), + group : iconv.decode(sauceRec.group, 'cp437').trim(), + date : iconv.decode(sauceRec.date, 'cp437').trim(), + fileSize : sauceRec.fileSize, + dataType : sauceRec.dataType, + fileType : sauceRec.fileType, + tinfo1 : sauceRec.tinfo1, + tinfo2 : sauceRec.tinfo2, + tinfo3 : sauceRec.tinfo3, + tinfo4 : sauceRec.tinfo4, + numComments : sauceRec.numComments, + flags : sauceRec.flags, + tinfos : sauceRec.tinfos, + }; - var sauce = { - id : iconv.decode(vars.id, 'cp437'), - version : iconv.decode(vars.version, 'cp437').trim(), - title : iconv.decode(vars.title, 'cp437').trim(), - author : iconv.decode(vars.author, 'cp437').trim(), - group : iconv.decode(vars.group, 'cp437').trim(), - date : iconv.decode(vars.date, 'cp437').trim(), - fileSize : vars.fileSize, - dataType : vars.dataType, - fileType : vars.fileType, - tinfo1 : vars.tinfo1, - tinfo2 : vars.tinfo2, - tinfo3 : vars.tinfo3, - tinfo4 : vars.tinfo4, - numComments : vars.numComments, - flags : vars.flags, - tinfos : vars.tinfos, - }; + const dt = SAUCE_DATA_TYPES[sauce.dataType]; + if(dt && dt.parser) { + sauce[dt.name] = dt.parser(sauce); + } - var dt = SAUCE_DATA_TYPES[sauce.dataType]; - if(dt && dt.parser) { - sauce[dt.name] = dt.parser(sauce); - } - - cb(null, sauce); - }); + return cb(null, sauce); } -// :TODO: These need completed: -var SAUCE_DATA_TYPES = {}; -SAUCE_DATA_TYPES[0] = { name : 'None' }; -SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE }; -SAUCE_DATA_TYPES[2] = 'Bitmap'; -SAUCE_DATA_TYPES[3] = 'Vector'; -SAUCE_DATA_TYPES[4] = 'Audio'; -SAUCE_DATA_TYPES[5] = 'BinaryText'; -SAUCE_DATA_TYPES[6] = 'XBin'; -SAUCE_DATA_TYPES[7] = 'Archive'; -SAUCE_DATA_TYPES[8] = 'Executable'; - -var SAUCE_CHARACTER_FILE_TYPES = {}; -SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII'; -SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi'; -SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation'; -SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script'; -SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard'; -SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar'; -SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML'; -SAUCE_CHARACTER_FILE_TYPES[7] = 'Source'; -SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw'; - -// -// Map of SAUCE font -> encoding hint -// -// Note that this is the same mapping that x84 uses. Be compatible! -// -var SAUCE_FONT_TO_ENCODING_HINT = { - 'Amiga MicroKnight' : 'amiga', - 'Amiga MicroKnight+' : 'amiga', - 'Amiga mOsOul' : 'amiga', - 'Amiga P0T-NOoDLE' : 'amiga', - 'Amiga Topaz 1' : 'amiga', - 'Amiga Topaz 1+' : 'amiga', - 'Amiga Topaz 2' : 'amiga', - 'Amiga Topaz 2+' : 'amiga', - 'Atari ATASCII' : 'atari', - 'IBM EGA43' : 'cp437', - 'IBM EGA' : 'cp437', - 'IBM VGA25G' : 'cp437', - 'IBM VGA50' : 'cp437', - 'IBM VGA' : 'cp437', +// :TODO: These need completed: +const SAUCE_DATA_TYPES = { + 0 : { name : 'None' }, + 1 : { name : 'Character', parser : parseCharacterSAUCE }, + 2 : 'Bitmap', + 3 : 'Vector', + 4 : 'Audio', + 5 : 'BinaryText', + 6 : 'XBin', + 7 : 'Archive', + 8 : 'Executable', }; -['437', '720', '737', '775', '819', '850', '852', '855', '857', '858', -'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) { - var codec = 'cp' + page; - SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec; +const SAUCE_CHARACTER_FILE_TYPES = { + 0 : 'ASCII', + 1 : 'ANSi', + 2 : 'ANSiMation', + 3 : 'RIP script', + 4 : 'PCBoard', + 5 : 'Avatar', + 6 : 'HTML', + 7 : 'Source', + 8 : 'TundraDraw', +}; + +// +// Map of SAUCE font -> encoding hint +// +// Note that this is the same mapping that x84 uses. Be compatible! +// +const SAUCE_FONT_TO_ENCODING_HINT = { + 'Amiga MicroKnight' : 'amiga', + 'Amiga MicroKnight+' : 'amiga', + 'Amiga mOsOul' : 'amiga', + 'Amiga P0T-NOoDLE' : 'amiga', + 'Amiga Topaz 1' : 'amiga', + 'Amiga Topaz 1+' : 'amiga', + 'Amiga Topaz 2' : 'amiga', + 'Amiga Topaz 2+' : 'amiga', + 'Atari ATASCII' : 'atari', + 'IBM EGA43' : 'cp437', + 'IBM EGA' : 'cp437', + 'IBM VGA25G' : 'cp437', + 'IBM VGA50' : 'cp437', + 'IBM VGA' : 'cp437', +}; + +[ + '437', '720', '737', '775', '819', '850', '852', '855', '857', '858', + '860', '861', '862', '863', '864', '865', '866', '869', '872' +].forEach( page => { + const codec = 'cp' + page; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec; }); function parseCharacterSAUCE(sauce) { - var result = {}; + const result = {}; - result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; + result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; - if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) { - // convience: create ansiFlags - sauce.ansiFlags = sauce.flags; + if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) { + // convenience: create ansiFlags + sauce.ansiFlags = sauce.flags; - var i = 0; - while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { - ++i; - } - var fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); - if(fontName.length > 0) { - result.fontName = fontName; - } - } + let i = 0; + while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { + ++i; + } - return result; + const fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); + if(fontName.length > 0) { + result.fontName = fontName; + } + + const setDimen = (v, field) => { + const i = parseInt(v, 10); + if(!isNaN(i)) { + result[field] = i; + } + }; + + setDimen(sauce.tinfo1, 'characterWidth'); + setDimen(sauce.tinfo2, 'characterHeight'); + } + + return result; } \ No newline at end of file diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 039caac7..99a537ec 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1,1828 +1,2384 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const MessageScanTossModule = require('../msg_scan_toss_module.js').MessageScanTossModule; -const Config = require('../config.js').config; -const ftnMailPacket = require('../ftn_mail_packet.js'); -const ftnUtil = require('../ftn_util.js'); -const Address = require('../ftn_address.js'); -const Log = require('../logger.js').log; -const ArchiveUtil = require('../archive_util.js'); -const msgDb = require('../database.js').dbs.message; -const Message = require('../message.js'); -const TicFileInfo = require('../tic_file_info.js'); -const Errors = require('../enig_error.js').Errors; -const FileEntry = require('../file_entry.js'); -const scanFile = require('../file_base_area.js').scanFile; -const getFileAreaByTag = require('../file_base_area.js').getFileAreaByTag; -const getDescFromFileName = require('../file_base_area.js').getDescFromFileName; -const copyFileWithCollisionHandling = require('../file_util.js').copyFileWithCollisionHandling; -const getAreaStorageDirectoryByTag = require('../file_base_area.js').getAreaStorageDirectoryByTag; -const isValidStorageTag = require('../file_base_area.js').isValidStorageTag; +// ENiGMA½ +const MessageScanTossModule = require('../msg_scan_toss_module.js').MessageScanTossModule; +const Config = require('../config.js').get; +const ftnMailPacket = require('../ftn_mail_packet.js'); +const ftnUtil = require('../ftn_util.js'); +const Address = require('../ftn_address.js'); +const Log = require('../logger.js').log; +const ArchiveUtil = require('../archive_util.js'); +const msgDb = require('../database.js').dbs.message; +const Message = require('../message.js'); +const TicFileInfo = require('../tic_file_info.js'); +const Errors = require('../enig_error.js').Errors; +const FileEntry = require('../file_entry.js'); +const scanFile = require('../file_base_area.js').scanFile; +const getFileAreaByTag = require('../file_base_area.js').getFileAreaByTag; +const getDescFromFileName = require('../file_base_area.js').getDescFromFileName; +const copyFileWithCollisionHandling = require('../file_util.js').copyFileWithCollisionHandling; +const getAreaStorageDirectoryByTag = require('../file_base_area.js').getAreaStorageDirectoryByTag; +const isValidStorageTag = require('../file_base_area.js').isValidStorageTag; +const User = require('../user.js'); +const StatLog = require('../stat_log.js'); +const SysProps = require('../system_property.js'); -// deps -const moment = require('moment'); -const _ = require('lodash'); -const paths = require('path'); -const async = require('async'); -const fs = require('graceful-fs'); -const later = require('later'); -const temptmp = require('temptmp').createTrackedSession('ftn_bso'); -const assert = require('assert'); -const gaze = require('gaze'); -const fse = require('fs-extra'); -const iconv = require('iconv-lite'); -const uuidV4 = require('uuid/v4'); +// deps +const moment = require('moment'); +const _ = require('lodash'); +const paths = require('path'); +const async = require('async'); +const fs = require('graceful-fs'); +const later = require('later'); +const temptmp = require('temptmp').createTrackedSession('ftn_bso'); +const assert = require('assert'); +const sane = require('sane'); +const fse = require('fs-extra'); +const iconv = require('iconv-lite'); +const { v4 : UUIDv4 } = require('uuid'); exports.moduleInfo = { - name : 'FTN BSO', - desc : 'BSO style message scanner/tosser for FTN networks', - author : 'NuSkooler', + name : 'FTN BSO', + desc : 'BSO style message scanner/tosser for FTN networks', + author : 'NuSkooler', }; /* - :TODO: - * Support (approx) max bundle size - * Support NetMail - * NetMail needs explicit isNetMail() check - * NetMail filename / location / etc. is still unknown - need to post on groups & get real answers - * Validate packet passwords!!!! - => secure vs insecure landing areas - -*/ + :TODO: + * Support (approx) max bundle size + * Validate packet passwords!!!! + => secure vs insecure landing areas +*/ exports.getModule = FTNMessageScanTossModule; -const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:|@immediate)([^\0]+)?$/; +const SCHEDULE_REGEXP = /(?:^|or )?(@watch:|@immediate)([^\0]+)?$/; function FTNMessageScanTossModule() { - MessageScanTossModule.call(this); - - let self = this; + MessageScanTossModule.call(this); - this.archUtil = ArchiveUtil.getInstance(); + const self = this; - if(_.has(Config, 'scannerTossers.ftn_bso')) { - this.moduleConfig = Config.scannerTossers.ftn_bso; - } - - this.getDefaultNetworkName = function() { - if(this.moduleConfig.defaultNetwork) { - return this.moduleConfig.defaultNetwork.toLowerCase(); - } - - const networkNames = Object.keys(Config.messageNetworks.ftn.networks); - if(1 === networkNames.length) { - return networkNames[0].toLowerCase(); - } - }; - - - this.getDefaultZone = function(networkName) { - if(_.isNumber(Config.messageNetworks.ftn.networks[networkName].defaultZone)) { - return Config.messageNetworks.ftn.networks[networkName].defaultZone; - } - - // non-explicit: default to local address zone - const networkLocalAddress = Config.messageNetworks.ftn.networks[networkName].localAddress; - if(networkLocalAddress) { - const addr = Address.fromString(networkLocalAddress); - return addr.zone; - } - }; - - /* - this.isDefaultDomainZone = function(networkName, address) { - const defaultNetworkName = this.getDefaultNetworkName(); - return(networkName === defaultNetworkName && address.zone === this.moduleConfig.defaultZone); - }; - */ - - this.getNetworkNameByAddress = function(remoteAddress) { - return _.findKey(Config.messageNetworks.ftn.networks, network => { - const localAddress = Address.fromString(network.localAddress); - return !_.isUndefined(localAddress) && localAddress.isEqual(remoteAddress); - }); - }; - - this.getNetworkNameByAddressPattern = function(remoteAddressPattern) { - return _.findKey(Config.messageNetworks.ftn.networks, network => { - const localAddress = Address.fromString(network.localAddress); - return !_.isUndefined(localAddress) && localAddress.isPatternMatch(remoteAddressPattern); - }); - }; - - this.getLocalAreaTagByFtnAreaTag = function(ftnAreaTag) { - ftnAreaTag = ftnAreaTag.toUpperCase(); // always compare upper - return _.findKey(Config.messageNetworks.ftn.areas, areaConf => { - return areaConf.tag.toUpperCase() === ftnAreaTag; - }); - }; - - this.getExportType = function(nodeConfig) { - return _.isString(nodeConfig.exportType) ? nodeConfig.exportType.toLowerCase() : 'crash'; - }; - - /* - this.getSeenByAddresses = function(messageSeenBy) { - if(!_.isArray(messageSeenBy)) { - messageSeenBy = [ messageSeenBy ]; - } - - let seenByAddrs = []; - messageSeenBy.forEach(sb => { - seenByAddrs = seenByAddrs.concat(ftnUtil.parseAbbreviatedNetNodeList(sb)); - }); - return seenByAddrs; - }; - */ - - this.messageHasValidMSGID = function(msg) { - return _.isString(msg.meta.FtnKludge.MSGID) && msg.meta.FtnKludge.MSGID.length > 0; - }; - - /* - this.getOutgoingPacketDir = function(networkName, destAddress) { - let dir = this.moduleConfig.paths.outbound; - if(!this.isDefaultDomainZone(networkName, destAddress)) { - const hexZone = `000${destAddress.zone.toString(16)}`.substr(-3); - dir = paths.join(dir, `${networkName.toLowerCase()}.${hexZone}`); - } - return dir; - }; - */ - - this.getOutgoingPacketDir = function(networkName, destAddress) { - networkName = networkName.toLowerCase(); - - let dir = this.moduleConfig.paths.outbound; - - const defaultNetworkName = this.getDefaultNetworkName(); - const defaultZone = this.getDefaultZone(networkName); - - let zoneExt; - if(defaultZone !== destAddress.zone) { - zoneExt = '.' + `000${destAddress.zone.toString(16)}`.substr(-3); - } else { - zoneExt = ''; - } - - if(defaultNetworkName === networkName) { - dir = paths.join(dir, `outbound${zoneExt}`); - } else { - dir = paths.join(dir, `${networkName}${zoneExt}`); - } - - return dir; - }; - - this.getOutgoingPacketFileName = function(basePath, messageId, isTemp, fileCase) { - // - // Generating an outgoing packet file name comes with a few issues: - // * We must use DOS 8.3 filenames due to legacy systems that receive - // the packet not understanding LFNs - // * We need uniqueness; This is especially important with packets that - // end up in bundles and on the receiving/remote system where conflicts - // with other systems could also occur - // - // There are a lot of systems in use here for the name: - // * HEX CRC16/32 of data - // * HEX UNIX timestamp - // * Mystic at least at one point, used Hex8(day of month + seconds past midnight + hundredths of second) - // See https://groups.google.com/forum/#!searchin/alt.bbs.mystic/netmail$20filename/alt.bbs.mystic/m1xLnY8i1pU/YnG2excdl6MJ - // * SBBSEcho uses DDHHMMSS - see https://github.com/ftnapps/pkg-sbbs/blob/master/docs/fidonet.txt - // * We already have a system for 8-character serial number gernation that is - // used for e.g. in FTS-0009.001 MSGIDs... let's use that! - // - const name = ftnUtil.getMessageSerialNumber(messageId); - const ext = (true === isTemp) ? 'pk_' : 'pkt'; - - let fileName = `${name}.${ext}`; - if('upper' === fileCase) { - fileName = fileName.toUpperCase(); - } + this.archUtil = ArchiveUtil.getInstance(); - return paths.join(basePath, fileName); - }; - - this.getOutgoingFlowFileExtension = function(destAddress, flowType, exportType, fileCase) { - let ext; - - switch(flowType) { - case 'mail' : ext = `${exportType.toLowerCase()[0]}ut`; break; - case 'ref' : ext = `${exportType.toLowerCase()[0]}lo`; break; - case 'busy' : ext = 'bsy'; break; - case 'request' : ext = 'req'; break; - case 'requests' : ext = 'hrq'; break; - } + const config = Config(); + if(_.has(config, 'scannerTossers.ftn_bso')) { + this.moduleConfig = config.scannerTossers.ftn_bso; + } - if('upper' === fileCase) { - ext = ext.toUpperCase(); - } - - return ext; - }; + this.getDefaultNetworkName = function() { + if(this.moduleConfig.defaultNetwork) { + return this.moduleConfig.defaultNetwork.toLowerCase(); + } - this.getOutgoingFlowFileName = function(basePath, destAddress, flowType, exportType, fileCase) { - let basename; - - const ext = self.getOutgoingFlowFileExtension( - destAddress, - flowType, - exportType, - fileCase - ); - - if(destAddress.point) { + const networkNames = Object.keys(config.messageNetworks.ftn.networks); + if(1 === networkNames.length) { + return networkNames[0].toLowerCase(); + } + }; - } else { - // - // Use |destAddress| nnnnNNNN.??? where nnnn is dest net and NNNN is dest - // node. This seems to match what Mystic does - // - basename = - `0000${destAddress.net.toString(16)}`.substr(-4) + - `0000${destAddress.node.toString(16)}`.substr(-4); - } + this.getDefaultZone = function(networkName) { + const config = Config(); + if(_.isNumber(config.messageNetworks.ftn.networks[networkName].defaultZone)) { + return config.messageNetworks.ftn.networks[networkName].defaultZone; + } - if('upper' === fileCase) { - basename = basename.toUpperCase(); - } - - return paths.join(basePath, `${basename}.${ext}`); - }; - - this.flowFileAppendRefs = function(filePath, fileRefs, directive, cb) { - const appendLines = fileRefs.reduce( (content, ref) => { - return content + `${directive}${ref}\n`; - }, ''); - - fs.appendFile(filePath, appendLines, err => { - cb(err); - }); - }; - - this.getOutgoingBundleFileName = function(basePath, sourceAddress, destAddress, cb) { - // - // Base filename is constructed as such: - // * If this |destAddress| is *not* a point address, we use NNNNnnnn where - // NNNN is 0 padded hex of dest net - source net and and nnnn is 0 padded - // hex of dest node - source node. - // * If |destAddress| is a point, NNNN becomes 0000 and nnnn becomes 'p' + - // 3 digit 0 padded hex point - // - // Extension is dd? where dd is Su...Mo and ? is 0...Z as collisions arise - // - let basename; - if(destAddress.point) { - const pointHex = `000${destAddress.point}`.substr(-3); - basename = `0000p${pointHex}`; - } else { - basename = - `0000${Math.abs(sourceAddress.net - destAddress.net).toString(16)}`.substr(-4) + - `0000${Math.abs(sourceAddress.node - destAddress.node).toString(16)}`.substr(-4); - } - - // - // We need to now find the first entry that does not exist starting - // with dd0 to ddz - // - const EXT_SUFFIXES = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); - let fileName = `${basename}.${moment().format('dd').toLowerCase()}`; - async.detectSeries(EXT_SUFFIXES, (suffix, callback) => { - const checkFileName = fileName + suffix; - fs.stat(paths.join(basePath, checkFileName), err => { - callback(null, (err && 'ENOENT' === err.code) ? true : false); - }); - }, (err, finalSuffix) => { - if(finalSuffix) { - return cb(null, paths.join(basePath, fileName + finalSuffix)); - } - - return cb(new Error('Could not acquire a bundle filename!')); - }); - }; - - this.prepareMessage = function(message, options) { - // - // Set various FTN kludges/etc. - // - message.meta.FtnProperty = message.meta.FtnProperty || {}; - message.meta.FtnKludge = message.meta.FtnKludge || {}; - - message.meta.FtnProperty.ftn_orig_node = options.network.localAddress.node; - message.meta.FtnProperty.ftn_dest_node = options.destAddress.node; - message.meta.FtnProperty.ftn_orig_network = options.network.localAddress.net; - message.meta.FtnProperty.ftn_dest_network = options.destAddress.net; - message.meta.FtnProperty.ftn_cost = 0; - message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); + // non-explicit: default to local address zone + const networkLocalAddress = config.messageNetworks.ftn.networks[networkName].localAddress; + if(networkLocalAddress) { + const addr = Address.fromString(networkLocalAddress); + return addr.zone; + } + }; - // :TODO: Need an explicit isNetMail() check - let ftnAttribute = - ftnMailPacket.Packet.Attribute.Local; // message from our system - - if(message.isPrivate()) { - ftnAttribute |= ftnMailPacket.Packet.Attribute.Private; - - // - // NetMail messages need a FRL-1005.001 "Via" line - // http://ftsc.org/docs/frl-1005.001 - // - if(_.isString(message.meta.FtnKludge.Via)) { - message.meta.FtnKludge.Via = [ message.meta.FtnKludge.Via ]; - } - message.meta.FtnKludge.Via = message.meta.FtnKludge.Via || []; - message.meta.FtnKludge.Via.push(ftnUtil.getVia(options.network.localAddress)); - } else { - // - // Set appropriate attribute flag for export type - // - switch(this.getExportType(options.nodeConfig)) { - case 'crash' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Crash; break; - case 'hold' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Hold; break; - // :TODO: Others? - } - - // - // EchoMail requires some additional properties & kludges - // - message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(options.network.localAddress); - message.meta.FtnProperty.ftn_area = Config.messageNetworks.ftn.areas[message.areaTag].tag; - - // - // When exporting messages, we should create/update SEEN-BY - // with remote address(s) we are exporting to. - // - const seenByAdditions = - [ `${options.network.localAddress.net}/${options.network.localAddress.node}` ].concat(Config.messageNetworks.ftn.areas[message.areaTag].uplinks); - message.meta.FtnProperty.ftn_seen_by = - ftnUtil.getUpdatedSeenByEntries(message.meta.FtnProperty.ftn_seen_by, seenByAdditions); + /* + this.isDefaultDomainZone = function(networkName, address) { + const defaultNetworkName = this.getDefaultNetworkName(); + return(networkName === defaultNetworkName && address.zone === this.moduleConfig.defaultZone); + }; + */ - // - // And create/update PATH for ourself - // - message.meta.FtnKludge.PATH = - ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, options.network.localAddress); - } - - message.meta.FtnProperty.ftn_attr_flags = ftnAttribute; - - // - // Additional kludges - // - // Check for existence of MSGID as we may already have stored it from a previous - // export that failed to finish - // - if(!message.meta.FtnKludge.MSGID) { - message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier(message, options.network.localAddress); - } - - message.meta.FtnKludge.TZUTC = ftnUtil.getUTCTimeZoneOffset(); - - // - // According to FSC-0046: - // - // "When a Conference Mail processor adds a TID to a message, it may not - // add a PID. An existing TID should, however, be replaced. TIDs follow - // the same format used for PIDs, as explained above." - // - message.meta.FtnKludge.TID = ftnUtil.getProductIdentifier(); - - // - // Determine CHRS and actual internal encoding name. If the message has an - // explicit encoding set, use it. Otherwise, try to preserve any CHRS/encoding already set. - // - let encoding = options.nodeConfig.encoding || Config.scannerTossers.ftn_bso.packetMsgEncoding || 'utf8'; - const explicitEncoding = _.get(message.meta, 'System.explicit_encoding'); - if(explicitEncoding) { - encoding = explicitEncoding; - } else if(message.meta.FtnKludge.CHRS) { - const encFromChars = ftnUtil.getEncodingFromCharacterSetIdentifier(message.meta.FtnKludge.CHRS); - if(encFromChars) { - encoding = encFromChars; - } - } - - // - // Ensure we ended up with something useable. If not, back to utf8! - // - if(!iconv.encodingExists(encoding)) { - Log.debug( { encoding : encoding }, 'Unknown encoding. Falling back to utf8'); - encoding = 'utf8'; - } - - options.encoding = encoding; // save for later - message.meta.FtnKludge.CHRS = ftnUtil.getCharacterSetIdentifierByEncoding(encoding); - // :TODO: FLAGS kludge? - }; - - this.setReplyKludgeFromReplyToMsgId = function(message, cb) { - // - // Look up MSGID kludge for |message.replyToMsgId|, if any. - // If found, we can create a REPLY kludge with the previously - // discovered MSGID. - // - - if(0 === message.replyToMsgId) { - return cb(null); // nothing to do - } - - Message.getMetaValuesByMessageId(message.replyToMsgId, 'FtnKludge', 'MSGID', (err, msgIdVal) => { - if(!err) { - assert(_.isString(msgIdVal), 'Expected string but got ' + (typeof msgIdVal) + ' (' + msgIdVal + ')'); - // got a MSGID - create a REPLY - message.meta.FtnKludge.REPLY = msgIdVal; - } - - cb(null); // this method always passes - }); - }; - - // check paths, Addresses, etc. - this.isAreaConfigValid = function(areaConfig) { - if(!areaConfig || !_.isString(areaConfig.tag) || !_.isString(areaConfig.network)) { - return false; - } - - if(_.isString(areaConfig.uplinks)) { - areaConfig.uplinks = areaConfig.uplinks.split(' '); - } - - return (_.isArray(areaConfig.uplinks)); - }; - - - this.hasValidConfiguration = function() { - if(!_.has(this, 'moduleConfig.nodes') || !_.has(Config, 'messageNetworks.ftn.areas')) { - return false; - } - - // :TODO: need to check more! - - return true; - }; - - this.parseScheduleString = function(schedStr) { - if(!schedStr) { - return; // nothing to parse! - } - - let schedule = {}; - - const m = SCHEDULE_REGEXP.exec(schedStr); - if(m) { - schedStr = schedStr.substr(0, m.index).trim(); - - if('@watch:' === m[1]) { - schedule.watchFile = m[2]; - } else if('@immediate' === m[1]) { - schedule.immediate = true; - } - } + this.getNetworkNameByAddress = function(remoteAddress) { + return _.findKey(Config().messageNetworks.ftn.networks, network => { + const localAddress = Address.fromString(network.localAddress); + return !_.isUndefined(localAddress) && localAddress.isEqual(remoteAddress); + }); + }; - if(schedStr.length > 0) { - const sched = later.parse.text(schedStr); - if(-1 === sched.error) { - schedule.sched = sched; - } - } - - // return undefined if we couldn't parse out anything useful - if(!_.isEmpty(schedule)) { - return schedule; - } - }; - - this.getAreaLastScanId = function(areaTag, cb) { - const sql = - `SELECT area_tag, message_id - FROM message_area_last_scan - WHERE scan_toss = "ftn_bso" AND area_tag = ? - LIMIT 1;`; - - msgDb.get(sql, [ areaTag ], (err, row) => { - cb(err, row ? row.message_id : 0); - }); - }; - - this.setAreaLastScanId = function(areaTag, lastScanId, cb) { - const sql = - `REPLACE INTO message_area_last_scan (scan_toss, area_tag, message_id) - VALUES ("ftn_bso", ?, ?);`; - - msgDb.run(sql, [ areaTag, lastScanId ], err => { - cb(err); - }); - }; - - this.getNodeConfigKeyByAddress = function(uplink) { - // :TODO: sort by least # of '*' & take top? - const nodeKey = _.filter(Object.keys(this.moduleConfig.nodes), addr => { - return Address.fromString(addr).isPatternMatch(uplink); - })[0]; + this.getNetworkNameByAddressPattern = function(remoteAddressPattern) { + return _.findKey(Config().messageNetworks.ftn.networks, network => { + const localAddress = Address.fromString(network.localAddress); + return !_.isUndefined(localAddress) && localAddress.isPatternMatch(remoteAddressPattern); + }); + }; - return nodeKey; - }; - - this.exportMessagesByUuid = function(messageUuids, exportOpts, cb) { - // - // This method has a lot of madness going on: - // - Try to stuff messages into packets until we've hit the target size - // - We need to wait for write streams to finish before proceeding in many cases - // or data will be cut off when closing and creating a new stream - // - let exportedFiles = []; - let currPacketSize = self.moduleConfig.packetTargetByteSize; - let packet; - let ws; - let remainMessageBuf; - let remainMessageId; - const createTempPacket = !_.isString(exportOpts.nodeConfig.archiveType) || 0 === exportOpts.nodeConfig.archiveType.length; + this.getLocalAreaTagByFtnAreaTag = function(ftnAreaTag) { + ftnAreaTag = ftnAreaTag.toUpperCase(); // always compare upper + return _.findKey(Config().messageNetworks.ftn.areas, areaConf => { + return _.isString(areaConf.tag) && areaConf.tag.toUpperCase() === ftnAreaTag; + }); + }; - function finalizePacket(cb) { - packet.writeTerminator(ws); - ws.end(); - ws.once('finish', () => { - return cb(null); - }); - } - - async.each(messageUuids, (msgUuid, nextUuid) => { - let message = new Message(); - - async.series( - [ - function finalizePrevious(callback) { - if(packet && currPacketSize >= self.moduleConfig.packetTargetByteSize) { - return finalizePacket(callback); - } else { - callback(null); - } - }, - function loadMessage(callback) { - message.load( { uuid : msgUuid }, err => { - if(err) { - return callback(err); - } - - // General preperation - self.prepareMessage(message, exportOpts); - - self.setReplyKludgeFromReplyToMsgId(message, err => { - callback(err); - }); - }); - }, - function createNewPacket(callback) { - if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { - packet = new ftnMailPacket.Packet(); - - const packetHeader = new ftnMailPacket.PacketHeader( - exportOpts.network.localAddress, - exportOpts.destAddress, - exportOpts.nodeConfig.packetType); + this.getExportType = function(nodeConfig) { + return _.isString(nodeConfig.exportType) ? nodeConfig.exportType.toLowerCase() : 'crash'; + }; - packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; - - // use current message ID for filename seed - const pktFileName = self.getOutgoingPacketFileName( - self.exportTempDir, - message.messageId, - createTempPacket, - exportOpts.fileCase - ); + /* + this.getSeenByAddresses = function(messageSeenBy) { + if(!_.isArray(messageSeenBy)) { + messageSeenBy = [ messageSeenBy ]; + } - exportedFiles.push(pktFileName); - - ws = fs.createWriteStream(pktFileName); - - currPacketSize = packet.writeHeader(ws, packetHeader); - - if(remainMessageBuf) { - currPacketSize += packet.writeMessageEntry(ws, remainMessageBuf); - remainMessageBuf = null; - } - } - - callback(null); - }, - function appendMessage(callback) { - packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => { - if(err) { - return callback(err); - } + let seenByAddrs = []; + messageSeenBy.forEach(sb => { + seenByAddrs = seenByAddrs.concat(ftnUtil.parseAbbreviatedNetNodeList(sb)); + }); + return seenByAddrs; + }; + */ - currPacketSize += msgBuf.length; - - if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { - remainMessageBuf = msgBuf; // save for next packet - remainMessageId = message.messageId; - } else { - ws.write(msgBuf); - } - - return callback(null); - }); - }, - function storeStateFlags0Meta(callback) { - message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), err => { - callback(err); - }); - }, - function storeMsgIdMeta(callback) { - // - // We want to store some meta as if we had imported - // this message for later reference - // - if(message.meta.FtnKludge.MSGID) { - message.persistMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, err => { - callback(err); - }); - } else { - callback(null); - } - } - ], - err => { - nextUuid(err); - } - ); - }, err => { - if(err) { - cb(err); - } else { - async.series( - [ - function terminateLast(callback) { - if(packet) { - return finalizePacket(callback); - } else { - callback(null); - } - }, - function writeRemainPacket(callback) { - if(remainMessageBuf) { - // :TODO: DRY this with the code above -- they are basically identical - packet = new ftnMailPacket.Packet(); - - const packetHeader = new ftnMailPacket.PacketHeader( - exportOpts.network.localAddress, - exportOpts.destAddress, - exportOpts.nodeConfig.packetType); + this.messageHasValidMSGID = function(msg) { + return _.isString(msg.meta.FtnKludge.MSGID) && msg.meta.FtnKludge.MSGID.length > 0; + }; - packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; - - // use current message ID for filename seed - const pktFileName = self.getOutgoingPacketFileName( - self.exportTempDir, - remainMessageId, - createTempPacket, - exportOpts.filleCase - ); + /* + this.getOutgoingEchoMailPacketDir = function(networkName, destAddress) { + let dir = this.moduleConfig.paths.outbound; + if(!this.isDefaultDomainZone(networkName, destAddress)) { + const hexZone = `000${destAddress.zone.toString(16)}`.substr(-3); + dir = paths.join(dir, `${networkName.toLowerCase()}.${hexZone}`); + } + return dir; + }; + */ - exportedFiles.push(pktFileName); - - ws = fs.createWriteStream(pktFileName); - - packet.writeHeader(ws, packetHeader); - ws.write(remainMessageBuf); - return finalizePacket(callback); - } else { - callback(null); - } - } - ], - err => { - cb(err, exportedFiles); - } - ); - } - }); - }; - - this.exportMessagesToUplinks = function(messageUuids, areaConfig, cb) { - async.each(areaConfig.uplinks, (uplink, nextUplink) => { - const nodeConfigKey = self.getNodeConfigKeyByAddress(uplink); - if(!nodeConfigKey) { - return nextUplink(); - } - - const exportOpts = { - nodeConfig : self.moduleConfig.nodes[nodeConfigKey], - network : Config.messageNetworks.ftn.networks[areaConfig.network], - destAddress : Address.fromString(uplink), - networkName : areaConfig.network, - fileCase : self.moduleConfig.nodes[nodeConfigKey].fileCase || 'lower', - }; - - if(_.isString(exportOpts.network.localAddress)) { - exportOpts.network.localAddress = Address.fromString(exportOpts.network.localAddress); - } - - const outgoingDir = self.getOutgoingPacketDir(exportOpts.networkName, exportOpts.destAddress); - const exportType = self.getExportType(exportOpts.nodeConfig); - - async.waterfall( - [ - function createOutgoingDir(callback) { - fse.mkdirs(outgoingDir, err => { - callback(err); - }); - }, - function exportToTempArea(callback) { - self.exportMessagesByUuid(messageUuids, exportOpts, callback); - }, - function createArcMailBundle(exportedFileNames, callback) { - if(self.archUtil.haveArchiver(exportOpts.nodeConfig.archiveType)) { - // :TODO: support bundleTargetByteSize: - // - // Compress to a temp location then we'll move it in the next step - // - // Note that we must use the *final* output dir for getOutgoingBundleFileName() - // as it checks for collisions in bundle names! - // - self.getOutgoingBundleFileName(outgoingDir, exportOpts.network.localAddress, exportOpts.destAddress, (err, bundlePath) => { - if(err) { - return callback(err); - } - - // adjust back to temp path - const tempBundlePath = paths.join(self.exportTempDir, paths.basename(bundlePath)); - - self.archUtil.compressTo( - exportOpts.nodeConfig.archiveType, - tempBundlePath, - exportedFileNames, err => { - callback(err, [ tempBundlePath ] ); - } - ); - }); - } else { - callback(null, exportedFileNames); - } - }, - function moveFilesToOutgoing(exportedFileNames, callback) { - async.each(exportedFileNames, (oldPath, nextFile) => { - const ext = paths.extname(oldPath).toLowerCase(); - if('.pk_' === ext.toLowerCase()) { - // - // For a given temporary .pk_ file, we need to move it to the outoing - // directory with the appropriate BSO style filename. - // - const newExt = self.getOutgoingFlowFileExtension( - exportOpts.destAddress, - 'mail', - exportType, - exportOpts.fileCase - ); - - const newPath = paths.join( - outgoingDir, - `${paths.basename(oldPath, ext)}${newExt}`); - - fse.move(oldPath, newPath, nextFile); - } else { - const newPath = paths.join(outgoingDir, paths.basename(oldPath)); - fse.move(oldPath, newPath, err => { - if(err) { - Log.warn( - { oldPath : oldPath, newPath : newPath, error : err.toString() }, - 'Failed moving temporary bundle file!'); - - return nextFile(); - } - - // - // For bundles, we need to append to the appropriate flow file - // - const flowFilePath = self.getOutgoingFlowFileName( - outgoingDir, - exportOpts.destAddress, - 'ref', - exportType, - exportOpts.fileCase - ); - - // directive of '^' = delete file after transfer - self.flowFileAppendRefs(flowFilePath, [ newPath ], '^', err => { - if(err) { - Log.warn( { path : flowFilePath }, 'Failed appending flow reference record!'); - } - nextFile(); - }); - }); - } - }, callback); - } - ], - err => { - // :TODO: do something with |err| ? - if(err) { - Log.warn(err.message); - } - nextUplink(); - } - ); - }, cb); // complete - }; - - this.setReplyToMsgIdFtnReplyKludge = function(message, cb) { - // - // Given a FTN REPLY kludge, set |message.replyToMsgId|, if possible, - // by looking up an associated MSGID kludge meta. - // - // See also: http://ftsc.org/docs/fts-0009.001 - // - if(!_.isString(message.meta.FtnKludge.REPLY)) { - // nothing to do - return cb(); - } - - Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.REPLY, (err, msgIds) => { - if(msgIds && msgIds.length > 0) { - // expect a single match, but dupe checking is not perfect - warn otherwise - if(1 === msgIds.length) { - message.replyToMsgId = msgIds[0]; - } else { - Log.warn( { msgIds : msgIds, replyKludge : message.meta.FtnKludge.REPLY }, 'Found 2:n MSGIDs matching REPLY kludge!'); - } - } - cb(); - }); - }; - - this.importEchoMailToArea = function(localAreaTag, header, message, cb) { - async.series( - [ - function validateDestinationAddress(callback) { - const localNetworkPattern = `${message.meta.FtnProperty.ftn_dest_network}/${message.meta.FtnProperty.ftn_dest_node}`; - const localNetworkName = self.getNetworkNameByAddressPattern(localNetworkPattern); - - callback(_.isString(localNetworkName) ? null : new Error('Packet destination is not us')); - }, - function checkForDupeMSGID(callback) { - // - // If we have a MSGID, don't allow a dupe - // - if(!_.has(message.meta, 'FtnKludge.MSGID')) { - return callback(null); - } + this.getOutgoingEchoMailPacketDir = function(networkName, destAddress) { + networkName = networkName.toLowerCase(); - Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, (err, msgIds) => { - if(msgIds && msgIds.length > 0) { - const err = new Error('Duplicate MSGID'); - err.code = 'DUPE_MSGID'; - return callback(err); - } + let dir = this.moduleConfig.paths.outbound; - return callback(null); - }); - }, - function basicSetup(callback) { - message.areaTag = localAreaTag; - - // - // If we *allow* dupes (disabled by default), then just generate - // a random UUID. Otherwise, don't assign the UUID just yet. It will be - // generated at persist() time and should be consistent across import/exports - // - if(Config.messageNetworks.ftn.areas[localAreaTag].allowDupes) { - // just generate a UUID & therefor always allow for dupes - message.uuid = uuidV4(); - } - - callback(null); - }, - function setReplyToMessageId(callback) { - self.setReplyToMsgIdFtnReplyKludge(message, () => { - callback(null); - }); - }, - function persistImport(callback) { - // mark as imported - message.meta.System.state_flags0 = Message.StateFlags0.Imported.toString(); - - // save to disc - message.persist(err => { - callback(err); - }); - } - ], - err => { - cb(err); - } - ); - }; + const defaultNetworkName = this.getDefaultNetworkName(); + const defaultZone = this.getDefaultZone(networkName); - this.appendTearAndOrigin = function(message) { - if(message.meta.FtnProperty.ftn_tear_line) { - message.message += `\r\n${message.meta.FtnProperty.ftn_tear_line}\r\n`; - } + let zoneExt; + if(defaultZone !== destAddress.zone) { + zoneExt = '.' + `000${destAddress.zone.toString(16)}`.substr(-3); + } else { + zoneExt = ''; + } - if(message.meta.FtnProperty.ftn_origin) { - message.message += `${message.meta.FtnProperty.ftn_origin}\r\n`; - } - }; - - // - // Ref. implementations on import: - // * https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/pkt.c - // https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/handle.c - // - this.importMessagesFromPacketFile = function(packetPath, password, cb) { - let packetHeader; - - const packetOpts = { keepTearAndOrigin : false }; // needed so we can calc message UUID without these; we'll add later - - let importStats = { - areaSuccess : {}, // areaTag->count - areaFail : {}, // areaTag->count - otherFail : 0, - }; - - new ftnMailPacket.Packet(packetOpts).read(packetPath, (entryType, entryData, next) => { - if('header' === entryType) { - packetHeader = entryData; - - const localNetworkName = self.getNetworkNameByAddress(packetHeader.destAddress); - if(!_.isString(localNetworkName)) { - const addrString = new Address(packetHeader.destAddress).toString(); - return next(new Error(`No local configuration for packet addressed to ${addrString}`)); - } else { - - // :TODO: password needs validated - need to determine if it will use the same node config (which can have wildcards) or something else?! - return next(null); - } - - } else if('message' === entryType) { - const message = entryData; - const areaTag = message.meta.FtnProperty.ftn_area; + if(defaultNetworkName === networkName) { + dir = paths.join(dir, `outbound${zoneExt}`); + } else { + dir = paths.join(dir, `${networkName}${zoneExt}`); + } - if(areaTag) { - // - // EchoMail - // - const localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); - if(localAreaTag) { - message.uuid = Message.createMessageUUID( - localAreaTag, - message.modTimestamp, - message.subject, - message.message); + return dir; + }; - self.appendTearAndOrigin(message); - - self.importEchoMailToArea(localAreaTag, packetHeader, message, err => { - if(err) { - // bump area fail stats - importStats.areaFail[localAreaTag] = (importStats.areaFail[localAreaTag] || 0) + 1; - - if('SQLITE_CONSTRAINT' === err.code || 'DUPE_MSGID' === err.code) { - const msgId = _.has(message.meta, 'FtnKludge.MSGID') ? message.meta.FtnKludge.MSGID : 'N/A'; - Log.info( - { area : localAreaTag, subject : message.subject, uuid : message.uuid, MSGID : msgId }, - 'Not importing non-unique message'); - - return next(null); - } - } else { - // bump area success - importStats.areaSuccess[localAreaTag] = (importStats.areaSuccess[localAreaTag] || 0) + 1; - } - - return next(err); - }); - } else { - // - // No local area configured for this import - // - // :TODO: Handle the "catch all" case, if configured - Log.warn( { areaTag : areaTag }, 'No local area configured for this packet file!'); - - // bump generic failure - importStats.otherFail += 1; - - return next(null); - } - } else { - // - // NetMail - // - Log.warn('NetMail import not yet implemented!'); - return next(null); - } - } - }, err => { - // - // try to produce something helpful in the log - // - const finalStats = Object.assign(importStats, { packetPath : packetPath } ); - if(err || Object.keys(finalStats.areaFail).length > 0) { - if(err) { - Object.assign(finalStats, { error : err.message } ); - } + this.getOutgoingPacketFileName = function(basePath, messageId, isTemp, fileCase) { + // + // Generating an outgoing packet file name comes with a few issues: + // * We must use DOS 8.3 filenames due to legacy systems that receive + // the packet not understanding LFNs + // * We need uniqueness; This is especially important with packets that + // end up in bundles and on the receiving/remote system where conflicts + // with other systems could also occur + // + // There are a lot of systems in use here for the name: + // * HEX CRC16/32 of data + // * HEX UNIX timestamp + // * Mystic at least at one point, used Hex8(day of month + seconds past midnight + hundredths of second) + // See https://groups.google.com/forum/#!searchin/alt.bbs.mystic/netmail$20filename/alt.bbs.mystic/m1xLnY8i1pU/YnG2excdl6MJ + // * SBBSEcho uses DDHHMMSS - see https://github.com/ftnapps/pkg-sbbs/blob/master/docs/fidonet.txt + // * We already have a system for 8-character serial number gernation that is + // used for e.g. in FTS-0009.001 MSGIDs... let's use that! + // + const name = ftnUtil.getMessageSerialNumber(messageId); + const ext = (true === isTemp) ? 'pk_' : 'pkt'; - Log.warn(finalStats, 'Import completed with error(s)'); - } else { - Log.info(finalStats, 'Import complete'); - } - - cb(err); - }); - }; + let fileName = `${name}.${ext}`; + if('upper' === fileCase) { + fileName = fileName.toUpperCase(); + } - this.maybeArchiveImportFile = function(origPath, type, status, cb) { - // - // type : pkt|tic|bundle - // status : good|reject - // - // Status of "good" is only applied to pkt files & placed - // in |retain| if set. This is generally used for debugging only. - // - let archivePath; - const ts = moment().format('YYYY-MM-DDTHH.mm.ss.SSS'); - const fn = paths.basename(origPath); + return paths.join(basePath, fileName); + }; - if('good' === status && type === 'pkt') { - if(!_.isString(self.moduleConfig.paths.retain)) { - return cb(null); - } - - archivePath = paths.join(self.moduleConfig.paths.retain, `good-pkt-${ts}--${fn}`); - } else if('good' !== status) { - archivePath = paths.join(self.moduleConfig.paths.reject, `${status}-${type}--${ts}-${fn}`); - } else { - return cb(null); // don't archive non-good/pkt files - } + this.getOutgoingFlowFileExtension = function(destAddress, flowType, exportType, fileCase) { + let ext; - Log.debug( { origPath : origPath, archivePath : archivePath, type : type, status : status }, 'Archiving import file'); + switch(flowType) { + case 'mail' : ext = `${exportType.toLowerCase()[0]}ut`; break; + case 'ref' : ext = `${exportType.toLowerCase()[0]}lo`; break; + case 'busy' : ext = 'bsy'; break; + case 'request' : ext = 'req'; break; + case 'requests' : ext = 'hrq'; break; + } - fse.copy(origPath, archivePath, err => { - if(err) { - Log.warn( { error : err.message, origPath : origPath, archivePath : archivePath, type : type, status : status }, 'Failed to archive packet file'); - } + if('upper' === fileCase) { + ext = ext.toUpperCase(); + } - return cb(null); // never fatal - }); - }; - - this.importPacketFilesFromDirectory = function(importDir, password, cb) { - async.waterfall( - [ - function getPacketFiles(callback) { - fs.readdir(importDir, (err, files) => { - if(err) { - return callback(err); - } - callback(null, files.filter(f => '.pkt' === paths.extname(f).toLowerCase())); - }); - }, - function importPacketFiles(packetFiles, callback) { - let rejects = []; - async.eachSeries(packetFiles, (packetFile, nextFile) => { - self.importMessagesFromPacketFile(paths.join(importDir, packetFile), '', err => { - if(err) { - Log.debug( - { path : paths.join(importDir, packetFile), error : err.toString() }, - 'Failed to import packet file'); - - rejects.push(packetFile); - } - nextFile(); - }); - }, err => { - // :TODO: Handle err! we should try to keep going though... - callback(err, packetFiles, rejects); - }); - }, - function handleProcessedFiles(packetFiles, rejects, callback) { - async.each(packetFiles, (packetFile, nextFile) => { - // possibly archive, then remove original - const fullPath = paths.join(importDir, packetFile); - self.maybeArchiveImportFile( - fullPath, - 'pkt', - rejects.includes(packetFile) ? 'reject' : 'good', - () => { - fs.unlink(fullPath, () => { - return nextFile(null); - }); - } - ); - }, err => { - callback(err); - }); - } - ], - err => { - cb(err); - } - ); - }; - - this.importFromDirectory = function(inboundType, importDir, cb) { - async.waterfall( - [ - // start with .pkt files - function importPacketFiles(callback) { - self.importPacketFilesFromDirectory(importDir, '', err => { - callback(err); - }); - }, - function discoverBundles(callback) { - fs.readdir(importDir, (err, files) => { - // :TODO: if we do much more of this, probably just use the glob module - const bundleRegExp = /\.(su|mo|tu|we|th|fr|sa)[0-9a-z]/i; - files = files.filter(f => { - const fext = paths.extname(f); - return bundleRegExp.test(fext); - }); - - async.map(files, (file, transform) => { - const fullPath = paths.join(importDir, file); - self.archUtil.detectType(fullPath, (err, archName) => { - transform(null, { path : fullPath, archName : archName } ); - }); - }, (err, bundleFiles) => { - callback(err, bundleFiles); - }); - }); - }, - function importBundles(bundleFiles, callback) { - let rejects = []; - - async.each(bundleFiles, (bundleFile, nextFile) => { - if(_.isUndefined(bundleFile.archName)) { - Log.warn( - { fileName : bundleFile.path }, - 'Unknown bundle archive type'); - - rejects.push(bundleFile.path); - - return nextFile(); // unknown archive type - } + return ext; + }; - Log.debug( { bundleFile : bundleFile }, 'Processing bundle' ); - - self.archUtil.extractTo( - bundleFile.path, - self.importTempDir, - bundleFile.archName, - err => { - if(err) { - Log.warn( - { path : bundleFile.path, error : err.message }, - 'Failed to extract bundle'); - - rejects.push(bundleFile.path); - } - - nextFile(); - } - ); - }, err => { - if(err) { - return callback(err); - } - - // - // All extracted - import .pkt's - // - self.importPacketFilesFromDirectory(self.importTempDir, '', err => { - // :TODO: handle |err| - callback(null, bundleFiles, rejects); - }); - }); - }, - function handleProcessedBundleFiles(bundleFiles, rejects, callback) { - async.each(bundleFiles, (bundleFile, nextFile) => { - self.maybeArchiveImportFile( - bundleFile.path, - 'bundle', - rejects.includes(bundleFile.path) ? 'reject' : 'good', - () => { - fs.unlink(bundleFile.path, err => { - if(err) { - Log.error( { path : bundleFile.path, error : err.message }, 'Failed unlinking bundle'); - } - return nextFile(null); - }); - } - ); - }, err => { - callback(err); - }); - }, - function importTicFiles(callback) { - self.processTicFilesInDirectory(importDir, err => { - return callback(err); - }); - } - ], - err => { - cb(err); - } - ); - }; - - this.createTempDirectories = function(cb) { - temptmp.mkdir( { prefix : 'enigftnexport-' }, (err, tempDir) => { - if(err) { - return cb(err); - } - - self.exportTempDir = tempDir; - - temptmp.mkdir( { prefix : 'enigftnimport-' }, (err, tempDir) => { - self.importTempDir = tempDir; - - cb(err); - }); - }); - }; - - // Starts an export block - returns true if we can proceed - this.exportingStart = function() { - if(!this.exportRunning) { - this.exportRunning = true; - return true; - } - - return false; - }; + this.getOutgoingFlowFileName = function(basePath, destAddress, flowType, exportType, fileCase) { + // + // Refs + // * http://ftsc.org/docs/fts-5005.003 + // * http://wiki.synchro.net/ref:fidonet_files#flow_files + // + let controlFileBaseName; + let pointDir; - // ends an export block - this.exportingEnd = function() { - this.exportRunning = false; - }; + const ext = self.getOutgoingFlowFileExtension( + destAddress, + flowType, + exportType, + fileCase + ); - this.copyTicAttachment = function(src, dst, isUpdate, cb) { - if(isUpdate) { - fse.copy(src, dst, err => { - return cb(err, dst); - }); - } else { - copyFileWithCollisionHandling(src, dst, (err, finalPath) => { - return cb(err, finalPath); - }); - } - }; + const netComponent = `0000${destAddress.net.toString(16)}`.substr(-4); + const nodeComponent = `0000${destAddress.node.toString(16)}`.substr(-4); - this.getLocalAreaTagsForTic = function() { - return _.union(Object.keys(Config.scannerTossers.ftn_bso.ticAreas || {} ), Object.keys(Config.fileBase.areas)); - }; + if(destAddress.point) { + // point's go in an extra subdir, e.g. outbound/NNNNnnnn.pnt/00000001.pnt (for a point of 1) + pointDir = `${netComponent}${nodeComponent}.pnt`; + controlFileBaseName = `00000000${destAddress.point.toString(16)}`.substr(-8); + } else { + pointDir = ''; - this.processSingleTicFile = function(ticFileInfo, cb) { - const self = this; + // + // Use |destAddress| nnnnNNNN.??? where nnnn is dest net and NNNN is dest + // node. This seems to match what Mystic does + // + controlFileBaseName = `${netComponent}${nodeComponent}`; + } - Log.debug( { tic : ticFileInfo.path, file : ticFileInfo.getAsString('File') }, 'Processing TIC file'); + // + // From FTS-5005.003: "Lower case filenames are prefered if supported by the file system." + // ...but we let the user override. + // + if('upper' === fileCase) { + controlFileBaseName = controlFileBaseName.toUpperCase(); + pointDir = pointDir.toUpperCase(); + } - async.waterfall( - [ - function generalValidation(callback) { - const config = { - nodes : Config.scannerTossers.ftn_bso.nodes, - defaultPassword : Config.scannerTossers.ftn_bso.tic.password, - localAreaTags : self.getLocalAreaTagsForTic(), - }; + return paths.join(basePath, pointDir, `${controlFileBaseName}.${ext}`); + }; - return ticFileInfo.validate(config, (err, localInfo) => { - if(err) { - return callback(err); - } + this.flowFileAppendRefs = function(filePath, fileRefs, directive, cb) { + // + // We have to ensure the *directory* of |filePath| exists here esp. + // for cases such as point destinations where a subdir may be + // present in the path that doesn't yet exist. + // + const flowFileDir = paths.dirname(filePath); + fse.mkdirs(flowFileDir, () => { // note not checking err; let's try appendFile + const appendLines = fileRefs.reduce( (content, ref) => { + return content + `${directive}${ref}\n`; + }, ''); - // We may need to map |localAreaTag| back to real areaTag if it's a mapping/alias - const mappedLocalAreaTag = _.get(Config.scannerTossers.ftn_bso, [ 'ticAreas', localInfo.areaTag ]); + fs.appendFile(filePath, appendLines, err => { + return cb(err); + }); + }); + }; - if(mappedLocalAreaTag) { - if(_.isString(mappedLocalAreaTag.areaTag)) { - localInfo.areaTag = mappedLocalAreaTag.areaTag; - localInfo.hashTags = mappedLocalAreaTag.hashTags; // override default for node - localInfo.storageTag = mappedLocalAreaTag.storageTag; // override default - } else if(_.isString(mappedLocalAreaTag)) { - localInfo.areaTag = mappedLocalAreaTag; - } - } + this.getOutgoingBundleFileName = function(basePath, sourceAddress, destAddress, cb) { + // + // Base filename is constructed as such: + // * If this |destAddress| is *not* a point address, we use NNNNnnnn where + // NNNN is 0 padded hex of dest net - source net and and nnnn is 0 padded + // hex of dest node - source node. + // * If |destAddress| is a point, NNNN becomes 0000 and nnnn becomes 'p' + + // 3 digit 0 padded hex point + // + // Extension is dd? where dd is Su...Mo and ? is 0...Z as collisions arise + // + let basename; + if(destAddress.point) { + const pointHex = `000${destAddress.point}`.substr(-3); + basename = `0000p${pointHex}`; + } else { + basename = + `0000${Math.abs(sourceAddress.net - destAddress.net).toString(16)}`.substr(-4) + + `0000${Math.abs(sourceAddress.node - destAddress.node).toString(16)}`.substr(-4); + } - return callback(null, localInfo); - }); - }, - function findExistingItem(localInfo, callback) { - // - // We will need to look for an existing item to replace/update if: - // a) The TIC file has a "Replaces" field - // b) The general or node specific |allowReplace| is true - // - // Replace specifies a DOS 8.3 *pattern* which is allowed to have - // ? and * characters. For example, RETRONET.* - // - // Lastly, we will only replace if the item is in the same/specified area - // and that come from the same origin as a previous entry. - // - const allowReplace = _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'allowReplace' ] ) || Config.scannerTossers.ftn_bso.tic.allowReplace; - const replaces = ticFileInfo.getAsString('Replaces'); + // + // We need to now find the first entry that does not exist starting + // with dd0 to ddz + // + const EXT_SUFFIXES = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); + let fileName = `${basename}.${moment().format('dd').toLowerCase()}`; + async.detectSeries(EXT_SUFFIXES, (suffix, callback) => { + const checkFileName = fileName + suffix; + fs.stat(paths.join(basePath, checkFileName), err => { + callback(null, (err && 'ENOENT' === err.code) ? true : false); + }); + }, (err, finalSuffix) => { + if(finalSuffix) { + return cb(null, paths.join(basePath, fileName + finalSuffix)); + } - if(!allowReplace || !replaces) { - return callback(null, localInfo); - } + return cb(new Error('Could not acquire a bundle filename!')); + }); + }; - const metaPairs = [ - { - name : 'short_file_name', - value : replaces.toUpperCase(), // we store upper as well - wcValue : true, // value may contain wildcards - }, - { - name : 'tic_origin', - value : ticFileInfo.getAsString('Origin'), - } - ]; + this.prepareMessage = function(message, options) { + // + // Set various FTN kludges/etc. + // + const localAddress = new Address(options.network.localAddress); // ensure we have an Address obj not a string version - FileEntry.findFiles( { metaPairs : metaPairs, areaTag : localInfo.areaTag }, (err, fileIds) => { - if(err) { - return callback(err); - } + // :TODO: create Address.toMeta() / similar + message.meta.FtnProperty = message.meta.FtnProperty || {}; + message.meta.FtnKludge = message.meta.FtnKludge || {}; - // 0:1 allowed - if(1 === fileIds.length) { - localInfo.existingFileId = fileIds[0]; + message.meta.FtnProperty.ftn_orig_node = localAddress.node; + message.meta.FtnProperty.ftn_orig_network = localAddress.net; + message.meta.FtnProperty.ftn_cost = 0; + message.meta.FtnProperty.ftn_msg_orig_node = localAddress.node; + message.meta.FtnProperty.ftn_msg_orig_net = localAddress.net; - // fetch old filename - we may need to remove it if replacing with a new name - FileEntry.loadBasicEntry(localInfo.existingFileId, {}, (cb, info) => { - localInfo.oldFileName = info.fileName; - localInfo.oldStorageTag = info.storageTag; - return callback(null, localInfo); - }); - } else if(fileIds.legnth > 1) { - return callback(Errors.General(`More than one existing entry for TIC in ${localInfo.areaTag} ([${fileIds.join(', ')}])`)); - } else { - return callback(null, localInfo); - } - }); - }, - function scan(localInfo, callback) { - const scanOpts = { - sha256 : localInfo.sha256, // *may* have already been calculated - meta : { - // some TIC-related metadata we always want - short_file_name : ticFileInfo.getAsString('File').toUpperCase(), // upper to ensure no case issues later; this should be a DOS 8.3 name - tic_origin : ticFileInfo.getAsString('Origin'), - tic_desc : ticFileInfo.getAsString('Desc'), - upload_by_username : _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'uploadBy' ]) || Config.scannerTossers.ftn_bso.tic.uploadBy, - } - }; + const destAddress = options.routeAddress || options.destAddress; + message.meta.FtnProperty.ftn_dest_node = destAddress.node; + message.meta.FtnProperty.ftn_dest_network = destAddress.net; - const ldesc = ticFileInfo.getAsString('Ldesc', '\n'); - if(ldesc) { - scanOpts.meta.tic_ldesc = ldesc; - } + if(destAddress.zone) { + message.meta.FtnProperty.ftn_dest_zone = destAddress.zone; + } + if(destAddress.point) { + message.meta.FtnProperty.ftn_dest_point = destAddress.point; + } - // - // We may have TIC auto-tagging for this node and/or specific (remote) area - // - const hashTags = - localInfo.hashTags || - _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'hashTags' ] ); // catch-all*/ + // tear line and origin can both go in EchoMail & NetMail + message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); + message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(localAddress); - if(hashTags) { - scanOpts.hashTags = new Set(hashTags.split(/[\s,]+/)); - } + let ftnAttribute = ftnMailPacket.Packet.Attribute.Local; // message from our system - if(localInfo.crc32) { - scanOpts.meta.file_crc32 = localInfo.crc32.toString(16); // again, *may* have already been calculated - } + const config = Config(); + if(self.isNetMailMessage(message)) { + // + // Set route and message destination properties -- they may differ + // + message.meta.FtnProperty.ftn_msg_dest_node = options.destAddress.node; + message.meta.FtnProperty.ftn_msg_dest_net = options.destAddress.net; - scanFile( - ticFileInfo.filePath, - scanOpts, - (err, fileEntry) => { - localInfo.fileEntry = fileEntry; - return callback(err, localInfo); - } - ); - }, - function store(localInfo, callback) { - // - // Move file to final area storage and persist to DB - // - const areaInfo = getFileAreaByTag(localInfo.areaTag); - if(!areaInfo) { - return callback(Errors.UnexpectedState(`Could not get area for tag ${localInfo.areaTag}`)); - } + ftnAttribute |= ftnMailPacket.Packet.Attribute.Private; - const storageTag = localInfo.storageTag || areaInfo.storageTags[0]; - if(!isValidStorageTag(storageTag)) { - return callback(Errors.Invalid(`Invalid storage tag: ${storageTag}`)); - } + // + // NetMail messages need a FRL-1005.001 "Via" line + // http://ftsc.org/docs/frl-1005.001 + // + // :TODO: We need to do this when FORWARDING NetMail + /* + if(_.isString(message.meta.FtnKludge.Via)) { + message.meta.FtnKludge.Via = [ message.meta.FtnKludge.Via ]; + } + message.meta.FtnKludge.Via = message.meta.FtnKludge.Via || []; + message.meta.FtnKludge.Via.push(ftnUtil.getVia(options.network.localAddress)); + */ - localInfo.fileEntry.storageTag = storageTag; - localInfo.fileEntry.areaTag = localInfo.areaTag; - localInfo.fileEntry.fileName = ticFileInfo.longFileName; + // + // We need to set INTL, and possibly FMPT and/or TOPT + // See http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac + // + message.meta.FtnKludge.INTL = ftnUtil.getIntl(options.destAddress, localAddress); - // we default to .DIZ/etc. desc, but use from TIC if needed - if(!localInfo.fileEntry.desc || 0 === localInfo.fileEntry.desc.length) { - localInfo.fileEntry.desc = ticFileInfo.getAsString('Ldesc') || ticFileInfo.getAsString('Desc') || getDescFromFileName(ticFileInfo.filePath); - } + if(_.isNumber(localAddress.point) && localAddress.point > 0) { + message.meta.FtnKludge.FMPT = localAddress.point; + } - const areaStorageDir = getAreaStorageDirectoryByTag(storageTag); - if(!areaStorageDir) { - return callback(Errors.UnexpectedState(`Could not get storage directory for tag ${localInfo.areaTag}`)); - } + if(_.isNumber(options.destAddress.point) && options.destAddress.point > 0) { + message.meta.FtnKludge.TOPT = options.destAddress.point; + } + } else { + // + // Set appropriate attribute flag for export type + // + switch(this.getExportType(options.nodeConfig)) { + case 'crash' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Crash; break; + case 'hold' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Hold; break; + // :TODO: Others? + } - const isUpdate = localInfo.existingFileId ? true : false; + // + // EchoMail requires some additional properties & kludges + // + message.meta.FtnProperty.ftn_area = config.messageNetworks.ftn.areas[message.areaTag].tag; - if(isUpdate) { - // we need to *update* an existing record/file - localInfo.fileEntry.fileId = localInfo.existingFileId; - } + // + // When exporting messages, we should create/update SEEN-BY + // with remote address(s) we are exporting to. + // + const seenByAdditions = + [ `${localAddress.net}/${localAddress.node}` ].concat(config.messageNetworks.ftn.areas[message.areaTag].uplinks); + message.meta.FtnProperty.ftn_seen_by = + ftnUtil.getUpdatedSeenByEntries(message.meta.FtnProperty.ftn_seen_by, seenByAdditions); - const dst = paths.join(areaStorageDir, localInfo.fileEntry.fileName); + // + // And create/update PATH for ourself + // + message.meta.FtnKludge.PATH = ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, localAddress); + } - self.copyTicAttachment(ticFileInfo.filePath, dst, isUpdate, (err, finalPath) => { - if(err) { - return callback(err); - } + message.meta.FtnProperty.ftn_attr_flags = ftnAttribute; - if(dst !== finalPath) { - localInfo.fileEntry.fileName = paths.basename(finalPath); - } - - localInfo.fileEntry.persist(isUpdate, err => { - return callback(err, localInfo); - }); - }); - }, - // :TODO: from here, we need to re-toss files if needed, before they are removed - function cleanupOldFile(localInfo, callback) { - if(!localInfo.existingFileId) { - return callback(null, localInfo); - } + // + // Additional kludges + // + // Check for existence of MSGID as we may already have stored it from a previous + // export that failed to finish + // + if(!message.meta.FtnKludge.MSGID) { + message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier( + message, + localAddress, + message.isPrivate() // true = isNetMail + ); + } - const oldStorageDir = getAreaStorageDirectoryByTag(localInfo.oldStorageTag); - const oldPath = paths.join(oldStorageDir, localInfo.oldFileName); - - fs.unlink(oldPath, err => { - if(err) { - Log.warn( { error : err.message, oldPath : oldPath }, 'Failed removing old physical file during TIC replacement'); - } else { - Log.debug( { oldPath : oldPath }, 'Removed old physical file during TIC replacement'); - } - return callback(null, localInfo); // continue even if err - }); - }, - ], - (err, localInfo) => { - if(err) { - Log.error( { error : err.message, reason : err.reason, tic : ticFileInfo.path }, 'Failed import/update TIC record' ); - } else { - Log.debug( - { tic : ticFileInfo.path, file : ticFileInfo.filePath, area : localInfo.areaTag }, - 'TIC imported successfully' - ); - } - return cb(err); - } - ); - }; + message.meta.FtnKludge.TZUTC = ftnUtil.getUTCTimeZoneOffset(); - this.removeAssocTicFiles = function(ticFileInfo, cb) { - async.each( [ ticFileInfo.path, ticFileInfo.filePath ], (path, nextPath) => { - fs.unlink(path, err => { - if(err && 'ENOENT' !== err.code) { // don't log when the file doesn't exist - Log.warn( { error : err.message, path : path }, 'Failed unlinking TIC file'); - } - return nextPath(null); - }); - }, err => { - return cb(err); - }); - }; + // + // According to FSC-0046: + // + // "When a Conference Mail processor adds a TID to a message, it may not + // add a PID. An existing TID should, however, be replaced. TIDs follow + // the same format used for PIDs, as explained above." + // + message.meta.FtnKludge.TID = ftnUtil.getProductIdentifier(); + + // + // Determine CHRS and actual internal encoding name. If the message has an + // explicit encoding set, use it. Otherwise, try to preserve any CHRS/encoding already set. + // + let encoding = options.nodeConfig.encoding || config.scannerTossers.ftn_bso.packetMsgEncoding || 'utf8'; + const explicitEncoding = _.get(message.meta, 'System.explicit_encoding'); + if(explicitEncoding) { + encoding = explicitEncoding; + } else if(message.meta.FtnKludge.CHRS) { + const encFromChars = ftnUtil.getEncodingFromCharacterSetIdentifier(message.meta.FtnKludge.CHRS); + if(encFromChars) { + encoding = encFromChars; + } + } + + // + // Ensure we ended up with something useable. If not, back to utf8! + // + if(!iconv.encodingExists(encoding)) { + Log.debug( { encoding : encoding }, 'Unknown encoding. Falling back to utf8'); + encoding = 'utf8'; + } + + options.encoding = encoding; // save for later + message.meta.FtnKludge.CHRS = ftnUtil.getCharacterSetIdentifierByEncoding(encoding); + }; + + this.setReplyKludgeFromReplyToMsgId = function(message, cb) { + // + // Look up MSGID kludge for |message.replyToMsgId|, if any. + // If found, we can create a REPLY kludge with the previously + // discovered MSGID. + // + + if(0 === message.replyToMsgId) { + return cb(null); // nothing to do + } + + Message.getMetaValuesByMessageId(message.replyToMsgId, 'FtnKludge', 'MSGID', (err, msgIdVal) => { + if(!err) { + assert(_.isString(msgIdVal), 'Expected string but got ' + (typeof msgIdVal) + ' (' + msgIdVal + ')'); + // got a MSGID - create a REPLY + message.meta.FtnKludge.REPLY = msgIdVal; + } + + cb(null); // this method always passes + }); + }; + + // check paths, Addresses, etc. + this.isAreaConfigValid = function(areaConfig) { + if(!areaConfig || !_.isString(areaConfig.tag) || !_.isString(areaConfig.network)) { + return false; + } + + if(_.isString(areaConfig.uplinks)) { + areaConfig.uplinks = areaConfig.uplinks.split(' '); + } + + return (_.isArray(areaConfig.uplinks)); + }; + + + this.hasValidConfiguration = function({shouldLog = false} = {}) { + const hasNodes = _.has(this, 'moduleConfig.nodes'); + const hasAreas = _.has(Config(), 'messageNetworks.ftn.areas'); + + if(!hasNodes && !hasAreas) { + if (shouldLog) { + Log.warn( + { + 'scannerTossers.ftn_bso.nodes' : hasNodes, + 'messageNetworks.ftn.areas' : hasAreas, + }, + 'Missing one or more required configuration blocks' + ); + } + return false; + } + + // :TODO: need to check more! + + return true; + }; + + this.parseScheduleString = function(schedStr) { + if(!schedStr) { + return; // nothing to parse! + } + + let schedule = {}; + + const m = SCHEDULE_REGEXP.exec(schedStr); + if(m) { + schedStr = schedStr.substr(0, m.index).trim(); + + if('@watch:' === m[1]) { + schedule.watchFile = m[2]; + } else if('@immediate' === m[1]) { + schedule.immediate = true; + } + } + + if(schedStr.length > 0) { + const sched = later.parse.text(schedStr); + if(-1 === sched.error) { + schedule.sched = sched; + } + } + + // return undefined if we couldn't parse out anything useful + if(!_.isEmpty(schedule)) { + return schedule; + } + }; + + this.getAreaLastScanId = function(areaTag, cb) { + const sql = + `SELECT area_tag, message_id + FROM message_area_last_scan + WHERE scan_toss = "ftn_bso" AND area_tag = ? + LIMIT 1;`; + + msgDb.get(sql, [ areaTag ], (err, row) => { + return cb(err, row ? row.message_id : 0); + }); + }; + + this.setAreaLastScanId = function(areaTag, lastScanId, cb) { + const sql = + `REPLACE INTO message_area_last_scan (scan_toss, area_tag, message_id) + VALUES ("ftn_bso", ?, ?);`; + + msgDb.run(sql, [ areaTag, lastScanId ], err => { + return cb(err); + }); + }; + + this.getNodeConfigByAddress = function(addr) { + addr = _.isString(addr) ? Address.fromString(addr) : addr; + + // :TODO: sort wildcard nodes{} entries by most->least explicit according to FTN hierarchy + return _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { + return addr.isPatternMatch(nodeAddrWildcard); + }); + }; + + this.exportNetMailMessagePacket = function(message, exportOpts, cb) { + // + // For NetMail, we always create a *single* packet per message. + // + async.series( + [ + function generalPrep(callback) { + self.prepareMessage(message, exportOpts); + + return self.setReplyKludgeFromReplyToMsgId(message, callback); + }, + function createPacket(callback) { + const packet = new ftnMailPacket.Packet(); + + const packetHeader = new ftnMailPacket.PacketHeader( + exportOpts.network.localAddress, + exportOpts.routeAddress, + exportOpts.nodeConfig.packetType + ); + + packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; + + // use current message ID for filename seed + exportOpts.pktFileName = self.getOutgoingPacketFileName( + self.exportTempDir, + message.messageId, + false, // createTempPacket=false + exportOpts.fileCase + ); + + const ws = fs.createWriteStream(exportOpts.pktFileName); + + packet.writeHeader(ws, packetHeader); + + packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => { + if(err) { + return callback(err); + } + + ws.write(msgBuf); + + packet.writeTerminator(ws); + + ws.end(); + ws.once('finish', () => { + return callback(null); + }); + }); + } + ], + err => { + return cb(err); + } + ); + }; + + this.exportMessagesByUuid = function(messageUuids, exportOpts, cb) { + // + // This method has a lot of madness going on: + // - Try to stuff messages into packets until we've hit the target size + // - We need to wait for write streams to finish before proceeding in many cases + // or data will be cut off when closing and creating a new stream + // + let exportedFiles = []; + let currPacketSize = self.moduleConfig.packetTargetByteSize; + let packet; + let ws; + let remainMessageBuf; + let remainMessageId; + const createTempPacket = !_.isString(exportOpts.nodeConfig.archiveType) || 0 === exportOpts.nodeConfig.archiveType.length; + + function finalizePacket(cb) { + packet.writeTerminator(ws); + ws.end(); + ws.once('finish', () => { + return cb(null); + }); + } + + async.each(messageUuids, (msgUuid, nextUuid) => { + let message = new Message(); + + async.series( + [ + function finalizePrevious(callback) { + if(packet && currPacketSize >= self.moduleConfig.packetTargetByteSize) { + return finalizePacket(callback); + } else { + callback(null); + } + }, + function loadMessage(callback) { + message.load( { uuid : msgUuid }, err => { + if(err) { + return callback(err); + } + + // General preperation + self.prepareMessage(message, exportOpts); + + self.setReplyKludgeFromReplyToMsgId(message, err => { + callback(err); + }); + }); + }, + function createNewPacket(callback) { + if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { + packet = new ftnMailPacket.Packet(); + + const packetHeader = new ftnMailPacket.PacketHeader( + exportOpts.network.localAddress, + exportOpts.destAddress, + exportOpts.nodeConfig.packetType); + + packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; + + // use current message ID for filename seed + const pktFileName = self.getOutgoingPacketFileName( + self.exportTempDir, + message.messageId, + createTempPacket, + exportOpts.fileCase + ); + + exportedFiles.push(pktFileName); + + ws = fs.createWriteStream(pktFileName); + + currPacketSize = packet.writeHeader(ws, packetHeader); + + if(remainMessageBuf) { + currPacketSize += packet.writeMessageEntry(ws, remainMessageBuf); + remainMessageBuf = null; + } + } + + callback(null); + }, + function appendMessage(callback) { + packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => { + if(err) { + return callback(err); + } + + currPacketSize += msgBuf.length; + + if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { + remainMessageBuf = msgBuf; // save for next packet + remainMessageId = message.messageId; + } else { + ws.write(msgBuf); + } + + return callback(null); + }); + }, + function storeStateFlags0Meta(callback) { + message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), err => { + callback(err); + }); + }, + function storeMsgIdMeta(callback) { + // + // We want to store some meta as if we had imported + // this message for later reference + // + if(message.meta.FtnKludge.MSGID) { + message.persistMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, err => { + callback(err); + }); + } else { + callback(null); + } + } + ], + err => { + nextUuid(err); + } + ); + }, err => { + if(err) { + cb(err); + } else { + async.series( + [ + function terminateLast(callback) { + if(packet) { + return finalizePacket(callback); + } else { + callback(null); + } + }, + function writeRemainPacket(callback) { + if(remainMessageBuf) { + // :TODO: DRY this with the code above -- they are basically identical + packet = new ftnMailPacket.Packet(); + + const packetHeader = new ftnMailPacket.PacketHeader( + exportOpts.network.localAddress, + exportOpts.destAddress, + exportOpts.nodeConfig.packetType); + + packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; + + // use current message ID for filename seed + const pktFileName = self.getOutgoingPacketFileName( + self.exportTempDir, + remainMessageId, + createTempPacket, + exportOpts.filleCase + ); + + exportedFiles.push(pktFileName); + + ws = fs.createWriteStream(pktFileName); + + packet.writeHeader(ws, packetHeader); + ws.write(remainMessageBuf); + return finalizePacket(callback); + } else { + callback(null); + } + } + ], + err => { + cb(err, exportedFiles); + } + ); + } + }); + }; + + this.getNetMailRoute = function(dstAddr) { + // + // Route full|wildcard -> full adddress/network lookup + // + const routes = _.get(Config(), 'scannerTossers.ftn_bso.netMail.routes'); + if(!routes) { + return; + } + + return _.find(routes, (route, addrWildcard) => { + return dstAddr.isPatternMatch(addrWildcard); + }); + }; + + this.getNetMailRouteInfoFromAddress = function(destAddress, cb) { + // + // Attempt to find route information for |destAddress|: + // + // 1) Routes: scannerTossers.ftn_bso.netMail.routes{} -> scannerTossers.ftn_bso.nodes{} -> config + // - Where we send may not be where destAddress is (it's routed!) + // 2) Direct to nodes: scannerTossers.ftn_bso.nodes{} -> config + // - Where we send is direct to destAddress + // + // In both cases, attempt to look up Zone:Net/* to discover local "from" network/address + // falling back to Config.scannerTossers.ftn_bso.defaultNetwork + // + const route = this.getNetMailRoute(destAddress); + + let routeAddress; + let networkName; + let isRouted; + if(route) { + routeAddress = Address.fromString(route.address); + networkName = route.network; + isRouted = true; + } else { + routeAddress = destAddress; + isRouted = false; + } + + networkName = networkName || this.getNetworkNameByAddress(routeAddress); + + const config = _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { + return routeAddress.isPatternMatch(nodeAddrWildcard); + }) || { packetType : '2+', encoding : Config().scannerTossers.ftn_bso.packetMsgEncoding }; + + // we should never be failing here; we may just be using defaults. + return cb( + networkName ? null : Errors.DoesNotExist(`No NetMail route for ${destAddress.toString()}`), + { destAddress, routeAddress, networkName, config, isRouted } + ); + }; + + this.exportNetMailMessagesToUplinks = function(messagesOrMessageUuids, cb) { + // for each message/UUID, find where to send the thing + async.each(messagesOrMessageUuids, (msgOrUuid, nextMessageOrUuid) => { + + const exportOpts = {}; + const message = new Message(); + + async.series( + [ + function loadMessage(callback) { + if(_.isString(msgOrUuid)) { + message.load( { uuid : msgOrUuid }, err => { + return callback(err, message); + }); + } else { + return callback(null, msgOrUuid); + } + }, + function discoverUplink(callback) { + const dstAddr = new Address(message.meta.System[Message.SystemMetaNames.RemoteToUser]); + + self.getNetMailRouteInfoFromAddress(dstAddr, (err, routeInfo) => { + if(err) { + return callback(err); + } + + exportOpts.nodeConfig = routeInfo.config; + exportOpts.destAddress = dstAddr; + exportOpts.routeAddress = routeInfo.routeAddress; + exportOpts.fileCase = routeInfo.config.fileCase || 'lower'; + exportOpts.network = Config().messageNetworks.ftn.networks[routeInfo.networkName]; + exportOpts.networkName = routeInfo.networkName; + exportOpts.outgoingDir = self.getOutgoingEchoMailPacketDir(exportOpts.networkName, exportOpts.destAddress); + exportOpts.exportType = self.getExportType(routeInfo.config); + + if(!exportOpts.network) { + return callback(Errors.DoesNotExist(`No configuration found for network ${routeInfo.networkName}`)); + } + + return callback(null); + }); + }, + function createOutgoingDir(callback) { + // ensure outgoing NetMail directory exists + return fse.mkdirs(exportOpts.outgoingDir, callback); + }, + function exportPacket(callback) { + return self.exportNetMailMessagePacket(message, exportOpts, callback); + }, + function moveToOutgoing(callback) { + const newExt = exportOpts.fileCase === 'lower' ? '.pkt' : '.PKT'; + exportOpts.exportedToPath = paths.join( + exportOpts.outgoingDir, + `${paths.basename(exportOpts.pktFileName, paths.extname(exportOpts.pktFileName))}${newExt}` + ); + + return fse.move(exportOpts.pktFileName, exportOpts.exportedToPath, callback); + }, + function prepareFloFile(callback) { + const flowFilePath = self.getOutgoingFlowFileName( + exportOpts.outgoingDir, + exportOpts.routeAddress, + 'ref', + exportOpts.exportType, + exportOpts.fileCase + ); + + return self.flowFileAppendRefs(flowFilePath, [ exportOpts.exportedToPath ], '^', callback); + }, + function storeStateFlags0Meta(callback) { + return message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), callback); + }, + function storeMsgIdMeta(callback) { + // Store meta as if we had imported this message -- for later reference + if(message.meta.FtnKludge.MSGID) { + return message.persistMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, callback); + } + + return callback(null); + } + ], + err => { + if(err) { + Log.warn( { error : err.message }, 'Error exporting message' ); + } + return nextMessageOrUuid(null); + } + ); + }, err => { + if(err) { + Log.warn( { error : err.message }, 'Error(s) during NetMail export'); + } + return cb(err); + }); + }; + + this.exportEchoMailMessagesToUplinks = function(messageUuids, areaConfig, cb) { + const config = Config(); + async.each(areaConfig.uplinks, (uplink, nextUplink) => { + const nodeConfig = self.getNodeConfigByAddress(uplink); + if(!nodeConfig) { + return nextUplink(); + } + + const exportOpts = { + nodeConfig, + network : config.messageNetworks.ftn.networks[areaConfig.network], + destAddress : Address.fromString(uplink), + networkName : areaConfig.network, + fileCase : nodeConfig.fileCase || 'lower', + }; + + if(_.isString(exportOpts.network.localAddress)) { + exportOpts.network.localAddress = Address.fromString(exportOpts.network.localAddress); + } + + const outgoingDir = self.getOutgoingEchoMailPacketDir(exportOpts.networkName, exportOpts.destAddress); + const exportType = self.getExportType(exportOpts.nodeConfig); + + async.waterfall( + [ + function createOutgoingDir(callback) { + fse.mkdirs(outgoingDir, err => { + callback(err); + }); + }, + function exportToTempArea(callback) { + self.exportMessagesByUuid(messageUuids, exportOpts, callback); + }, + function createArcMailBundle(exportedFileNames, callback) { + if(self.archUtil.haveArchiver(exportOpts.nodeConfig.archiveType)) { + // :TODO: support bundleTargetByteSize: + // + // Compress to a temp location then we'll move it in the next step + // + // Note that we must use the *final* output dir for getOutgoingBundleFileName() + // as it checks for collisions in bundle names! + // + self.getOutgoingBundleFileName(outgoingDir, exportOpts.network.localAddress, exportOpts.destAddress, (err, bundlePath) => { + if(err) { + return callback(err); + } + + // adjust back to temp path + const tempBundlePath = paths.join(self.exportTempDir, paths.basename(bundlePath)); + + self.archUtil.compressTo( + exportOpts.nodeConfig.archiveType, + tempBundlePath, + exportedFileNames, err => { + callback(err, [ tempBundlePath ] ); + } + ); + }); + } else { + callback(null, exportedFileNames); + } + }, + function moveFilesToOutgoing(exportedFileNames, callback) { + async.each(exportedFileNames, (oldPath, nextFile) => { + const ext = paths.extname(oldPath).toLowerCase(); + if('.pk_' === ext.toLowerCase()) { + // + // For a given temporary .pk_ file, we need to move it to the outoing + // directory with the appropriate BSO style filename. + // + const newExt = self.getOutgoingFlowFileExtension( + exportOpts.destAddress, + 'mail', + exportType, + exportOpts.fileCase + ); + + const newPath = paths.join( + outgoingDir, + `${paths.basename(oldPath, ext)}${newExt}`); + + fse.move(oldPath, newPath, nextFile); + } else { + const newPath = paths.join(outgoingDir, paths.basename(oldPath)); + fse.move(oldPath, newPath, err => { + if(err) { + Log.warn( + { oldPath : oldPath, newPath : newPath, error : err.toString() }, + 'Failed moving temporary bundle file!'); + + return nextFile(); + } + + // + // For bundles, we need to append to the appropriate flow file + // + const flowFilePath = self.getOutgoingFlowFileName( + outgoingDir, + exportOpts.destAddress, + 'ref', + exportType, + exportOpts.fileCase + ); + + // directive of '^' = delete file after transfer + self.flowFileAppendRefs(flowFilePath, [ newPath ], '^', err => { + if(err) { + Log.warn( { path : flowFilePath }, 'Failed appending flow reference record!'); + } + nextFile(); + }); + }); + } + }, callback); + } + ], + err => { + // :TODO: do something with |err| ? + if(err) { + Log.warn(err.message); + } + nextUplink(); + } + ); + }, cb); // complete + }; + + this.setReplyToMsgIdFtnReplyKludge = function(message, cb) { + // + // Given a FTN REPLY kludge, set |message.replyToMsgId|, if possible, + // by looking up an associated MSGID kludge meta. + // + // See also: http://ftsc.org/docs/fts-0009.001 + // + if(!_.isString(message.meta.FtnKludge.REPLY)) { + // nothing to do + return cb(); + } + + Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.REPLY, (err, msgIds) => { + if(msgIds && msgIds.length > 0) { + // expect a single match, but dupe checking is not perfect - warn otherwise + if(1 === msgIds.length) { + message.replyToMsgId = msgIds[0]; + } else { + Log.warn( { msgIds : msgIds, replyKludge : message.meta.FtnKludge.REPLY }, 'Found 2:n MSGIDs matching REPLY kludge!'); + } + } + cb(); + }); + }; + + this.getLocalUserNameFromAlias = function(lookup) { + lookup = lookup.toLowerCase(); + + const aliases = _.get(Config(), 'messageNetworks.ftn.netMail.aliases'); + if(!aliases) { + return lookup; // keep orig + } + + const alias = _.find(aliases, (localName, alias) => { + return alias.toLowerCase() === lookup; + }); + + return alias || lookup; + }; + + this.getAddressesFromNetMailMessage = function(message) { + const intlKludge = _.get(message, 'meta.FtnKludge.INTL'); + + if(!intlKludge) { + return {}; + } + + let [ to, from ] = intlKludge.split(' '); + if(!to || !from) { + return {}; + } + + const fromPoint = _.get(message, 'meta.FtnKludge.FMPT'); + const toPoint = _.get(message, 'meta.FtnKludge.TOPT'); + + if(fromPoint) { + from += `.${fromPoint}`; + } + + if(toPoint) { + to += `.${toPoint}`; + } + + return { to : Address.fromString(to), from : Address.fromString(from) }; + }; + + this.importMailToArea = function(config, header, message, cb) { + async.series( + [ + function validateDestinationAddress(callback) { + const localNetworkPattern = `${message.meta.FtnProperty.ftn_dest_network}/${message.meta.FtnProperty.ftn_dest_node}`; + const localNetworkName = self.getNetworkNameByAddressPattern(localNetworkPattern); + + return callback(_.isString(localNetworkName) ? null : new Error('Packet destination is not us')); + }, + function checkForDupeMSGID(callback) { + // + // If we have a MSGID, don't allow a dupe + // + if(!_.has(message.meta, 'FtnKludge.MSGID')) { + return callback(null); + } + + Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, (err, msgIds) => { + if(msgIds && msgIds.length > 0) { + const err = new Error('Duplicate MSGID'); + err.code = 'DUPE_MSGID'; + return callback(err); + } + + return callback(null); + }); + }, + function basicSetup(callback) { + message.areaTag = config.localAreaTag; + + // indicate this was imported from FTN + message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.AddressFlavor.FTN; + + // + // If we *allow* dupes (disabled by default), then just generate + // a random UUID. Otherwise, don't assign the UUID just yet. It will be + // generated at persist() time and should be consistent across import/exports + // + if(true === _.get(Config(), [ 'messageNetworks', 'ftn', 'areas', config.localAreaTag, 'allowDupes' ], false)) { + // just generate a UUID & therefor always allow for dupes + message.messageUuid = UUIDv4(); + } + + return callback(null); + }, + function setReplyToMessageId(callback) { + self.setReplyToMsgIdFtnReplyKludge(message, () => { + return callback(null); + }); + }, + function setupPrivateMessage(callback) { + // + // If this is a private message (e.g. NetMail) we set the local user ID + // + if(Message.WellKnownAreaTags.Private !== config.localAreaTag) { + return callback(null); + } + + // + // Create a meta value for the *remote* from user. In the case here with FTN, + // their fully qualified FTN from address + // + const { from } = self.getAddressesFromNetMailMessage(message); + + if(!from) { + return callback(Errors.Invalid('Cannot import FTN NetMail without valid INTL line')); + } + + message.meta.System[Message.SystemMetaNames.RemoteFromUser] = from.toString(); + + const lookupName = self.getLocalUserNameFromAlias(message.toUserName); + + User.getUserIdAndNameByLookup(lookupName, (err, localToUserId, localUserName) => { + if(err) { + // + // Couldn't find a local username. If the toUserName itself is a FTN address + // we can only assume the message is to the +op, else we'll have to fail. + // + const toUserNameAsAddress = Address.fromString(message.toUserName); + if(toUserNameAsAddress && toUserNameAsAddress.isValid()) { + + Log.info( + { toUserName : message.toUserName, fromUserName : message.fromUserName }, + 'No local "to" username for FTN message. Appears to be a FTN address only; assuming addressed to SysOp' + ); + + User.getUserName(User.RootUserID, (err, sysOpUserName) => { + if(err) { + return callback(Errors.UnexpectedState('Failed to get SysOp user information')); + } + + message.meta.System[Message.SystemMetaNames.LocalToUserID] = User.RootUserID; + message.toUserName = sysOpUserName; + return callback(null); + }); + } else { + return callback(Errors.DoesNotExist(`Could not get local user ID for "${message.toUserName}": ${err.message}`)); + } + } + + // we do this after such that error cases can be preserved above + if(lookupName !== message.toUserName) { + message.toUserName = localUserName; + } + + // set the meta information - used elsewhere for retrieval + message.meta.System[Message.SystemMetaNames.LocalToUserID] = localToUserId; + return callback(null); + }); + }, + function persistImport(callback) { + // mark as imported + message.meta.System.state_flags0 = Message.StateFlags0.Imported.toString(); + + // save to disc + message.persist(err => { + if(!message.isPrivate()) { + StatLog.incrementNonPersistentSystemStat(SysProps.MessageTotalCount, 1); + StatLog.incrementNonPersistentSystemStat(SysProps.MessagesToday, 1); + } + return callback(err); + }); + } + ], + err => { + cb(err); + } + ); + }; + + this.appendTearAndOrigin = function(message) { + if(message.meta.FtnProperty.ftn_tear_line) { + message.message += `\r\n${message.meta.FtnProperty.ftn_tear_line}\r\n`; + } + + if(message.meta.FtnProperty.ftn_origin) { + message.message += `${message.meta.FtnProperty.ftn_origin}\r\n`; + } + }; + + // + // Ref. implementations on import: + // * https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/pkt.c + // https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/handle.c + // + this.importMessagesFromPacketFile = function(packetPath, password, cb) { + let packetHeader; + + const packetOpts = { keepTearAndOrigin : false }; // needed so we can calc message UUID without these; we'll add later + + let importStats = { + areaSuccess : {}, // areaTag->count + areaFail : {}, // areaTag->count + otherFail : 0, + }; + + new ftnMailPacket.Packet(packetOpts).read(packetPath, (entryType, entryData, next) => { + if('header' === entryType) { + packetHeader = entryData; + + const localNetworkName = self.getNetworkNameByAddress(packetHeader.destAddress); + if(!_.isString(localNetworkName)) { + const addrString = new Address(packetHeader.destAddress).toString(); + return next(new Error(`No local configuration for packet addressed to ${addrString}`)); + } else { + + // :TODO: password needs validated - need to determine if it will use the same node config (which can have wildcards) or something else?! + return next(null); + } + + } else if('message' === entryType) { + const message = entryData; + const areaTag = message.meta.FtnProperty.ftn_area; + + let localAreaTag; + if(areaTag) { + localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); + + if(!localAreaTag) { + // + // No local area configured for this import + // + // :TODO: Handle the "catch all" area bucket case if configured + Log.warn( { areaTag : areaTag }, 'No local area configured for this packet file!'); + + // bump generic failure + importStats.otherFail += 1; + + return next(null); + } + } else { + // + // No area tag: If marked private in attributes, this is a NetMail + // + if(message.meta.FtnProperty.ftn_attr_flags & ftnMailPacket.Packet.Attribute.Private) { + localAreaTag = Message.WellKnownAreaTags.Private; + } else { + Log.warn('Non-private message without area tag'); + importStats.otherFail += 1; + return next(null); + } + } + + message.messageUuid = Message.createMessageUUID( + localAreaTag, + message.modTimestamp, + message.subject, + message.message); + + self.appendTearAndOrigin(message); + + const importConfig = { + localAreaTag : localAreaTag, + }; + + self.importMailToArea(importConfig, packetHeader, message, err => { + if(err) { + // bump area fail stats + importStats.areaFail[localAreaTag] = (importStats.areaFail[localAreaTag] || 0) + 1; + + if('SQLITE_CONSTRAINT' === err.code || 'DUPE_MSGID' === err.code) { + const msgId = _.has(message.meta, 'FtnKludge.MSGID') ? message.meta.FtnKludge.MSGID : 'N/A'; + Log.info( + { area : localAreaTag, subject : message.subject, uuid : message.messageUuid, MSGID : msgId }, + 'Not importing non-unique message'); + + return next(null); + } + } else { + // bump area success + importStats.areaSuccess[localAreaTag] = (importStats.areaSuccess[localAreaTag] || 0) + 1; + } + + return next(err); + }); + } + }, err => { + // + // try to produce something helpful in the log + // + const finalStats = Object.assign(importStats, { packetPath : packetPath } ); + if(err || Object.keys(finalStats.areaFail).length > 0) { + if(err) { + Object.assign(finalStats, { error : err.message } ); + } + + Log.warn(finalStats, 'Import completed with error(s)'); + } else { + Log.info(finalStats, 'Import complete'); + } + + cb(err); + }); + }; + + this.maybeArchiveImportFile = function(origPath, type, status, cb) { + // + // type : pkt|tic|bundle + // status : good|reject + // + // Status of "good" is only applied to pkt files & placed + // in |retain| if set. This is generally used for debugging only. + // + let archivePath; + const ts = moment().format('YYYY-MM-DDTHH.mm.ss.SSS'); + const fn = paths.basename(origPath); + + if('good' === status && type === 'pkt') { + if(!_.isString(self.moduleConfig.paths.retain)) { + return cb(null); + } + + archivePath = paths.join(self.moduleConfig.paths.retain, `good-pkt-${ts}--${fn}`); + } else if('good' !== status) { + archivePath = paths.join(self.moduleConfig.paths.reject, `${status}-${type}--${ts}-${fn}`); + } else { + return cb(null); // don't archive non-good/pkt files + } + + Log.debug( { origPath : origPath, archivePath : archivePath, type : type, status : status }, 'Archiving import file'); + + fse.copy(origPath, archivePath, err => { + if(err) { + Log.warn( { error : err.message, origPath : origPath, archivePath : archivePath, type : type, status : status }, 'Failed to archive packet file'); + } + + return cb(null); // never fatal + }); + }; + + this.importPacketFilesFromDirectory = function(importDir, password, cb) { + async.waterfall( + [ + function getPacketFiles(callback) { + fs.readdir(importDir, (err, files) => { + if(err) { + return callback(err); + } + callback(null, files.filter(f => '.pkt' === paths.extname(f).toLowerCase())); + }); + }, + function importPacketFiles(packetFiles, callback) { + let rejects = []; + async.eachSeries(packetFiles, (packetFile, nextFile) => { + self.importMessagesFromPacketFile(paths.join(importDir, packetFile), '', err => { + if(err) { + Log.debug( + { path : paths.join(importDir, packetFile), error : err.toString() }, + 'Failed to import packet file'); + + rejects.push(packetFile); + } + nextFile(); + }); + }, err => { + // :TODO: Handle err! we should try to keep going though... + callback(err, packetFiles, rejects); + }); + }, + function handleProcessedFiles(packetFiles, rejects, callback) { + async.each(packetFiles, (packetFile, nextFile) => { + // possibly archive, then remove original + const fullPath = paths.join(importDir, packetFile); + self.maybeArchiveImportFile( + fullPath, + 'pkt', + rejects.includes(packetFile) ? 'reject' : 'good', + () => { + fs.unlink(fullPath, () => { + return nextFile(null); + }); + } + ); + }, err => { + callback(err); + }); + } + ], + err => { + cb(err); + } + ); + }; + + this.importFromDirectory = function(inboundType, importDir, cb) { + async.waterfall( + [ + // start with .pkt files + function importPacketFiles(callback) { + self.importPacketFilesFromDirectory(importDir, '', err => { + callback(err); + }); + }, + function discoverBundles(callback) { + fs.readdir(importDir, (err, files) => { + // :TODO: if we do much more of this, probably just use the glob module + const bundleRegExp = /\.(su|mo|tu|we|th|fr|sa)[0-9a-z]/i; + files = files.filter(f => { + const fext = paths.extname(f); + return bundleRegExp.test(fext); + }); + + async.map(files, (file, transform) => { + const fullPath = paths.join(importDir, file); + self.archUtil.detectType(fullPath, (err, archName) => { + transform(null, { path : fullPath, archName : archName } ); + }); + }, (err, bundleFiles) => { + callback(err, bundleFiles); + }); + }); + }, + function importBundles(bundleFiles, callback) { + let rejects = []; + + async.each(bundleFiles, (bundleFile, nextFile) => { + if(_.isUndefined(bundleFile.archName)) { + Log.warn( + { fileName : bundleFile.path }, + 'Unknown bundle archive type'); + + rejects.push(bundleFile.path); + + return nextFile(); // unknown archive type + } + + Log.debug( { bundleFile : bundleFile }, 'Processing bundle' ); + + self.archUtil.extractTo( + bundleFile.path, + self.importTempDir, + bundleFile.archName, + err => { + if(err) { + Log.warn( + { path : bundleFile.path, error : err.message }, + 'Failed to extract bundle'); + + rejects.push(bundleFile.path); + } + + nextFile(); + } + ); + }, err => { + if(err) { + return callback(err); + } + + // + // All extracted - import .pkt's + // + self.importPacketFilesFromDirectory(self.importTempDir, '', () => { + // :TODO: handle |err| + callback(null, bundleFiles, rejects); + }); + }); + }, + function handleProcessedBundleFiles(bundleFiles, rejects, callback) { + async.each(bundleFiles, (bundleFile, nextFile) => { + self.maybeArchiveImportFile( + bundleFile.path, + 'bundle', + rejects.includes(bundleFile.path) ? 'reject' : 'good', + () => { + fs.unlink(bundleFile.path, err => { + if(err) { + Log.error( { path : bundleFile.path, error : err.message }, 'Failed unlinking bundle'); + } + return nextFile(null); + }); + } + ); + }, err => { + callback(err); + }); + }, + function importTicFiles(callback) { + self.processTicFilesInDirectory(importDir, err => { + return callback(err); + }); + } + ], + err => { + cb(err); + } + ); + }; + + this.createTempDirectories = function(cb) { + temptmp.mkdir( { prefix : 'enigftnexport-' }, (err, tempDir) => { + if(err) { + return cb(err); + } + + self.exportTempDir = tempDir; + + temptmp.mkdir( { prefix : 'enigftnimport-' }, (err, tempDir) => { + self.importTempDir = tempDir; + + cb(err); + }); + }); + }; + + // Starts an export block - returns true if we can proceed + this.exportingStart = function() { + if(!this.exportRunning) { + this.exportRunning = true; + return true; + } + + return false; + }; + + // ends an export block + this.exportingEnd = function(cb) { + this.exportRunning = false; + + if(cb) { + return cb(null); + } + }; + + this.copyTicAttachment = function(src, dst, isUpdate, cb) { + if(isUpdate) { + fse.copy(src, dst, { overwrite : true }, err => { + return cb(err, dst); + }); + } else { + copyFileWithCollisionHandling(src, dst, (err, finalPath) => { + return cb(err, finalPath); + }); + } + }; + + this.getLocalAreaTagsForTic = function() { + const config = Config(); + return _.union(Object.keys(config.scannerTossers.ftn_bso.ticAreas || {} ), Object.keys(config.fileBase.areas)); + }; + + this.processSingleTicFile = function(ticFileInfo, cb) { + Log.debug( { tic : ticFileInfo.path, file : ticFileInfo.getAsString('File') }, 'Processing TIC file'); + + async.waterfall( + [ + function generalValidation(callback) { + const sysConfig = Config(); + const config = { + nodes : sysConfig.scannerTossers.ftn_bso.nodes, + defaultPassword : sysConfig.scannerTossers.ftn_bso.tic.password, + localAreaTags : self.getLocalAreaTagsForTic(), + }; + + ticFileInfo.validate(config, (err, localInfo) => { + if(err) { + Log.trace( { reason : err.message }, 'Validation failure'); + return callback(err); + } + + // We may need to map |localAreaTag| back to real areaTag if it's a mapping/alias + const mappedLocalAreaTag = _.get(Config().scannerTossers.ftn_bso, [ 'ticAreas', localInfo.areaTag ]); + + if(mappedLocalAreaTag) { + if(_.isString(mappedLocalAreaTag.areaTag)) { + localInfo.areaTag = mappedLocalAreaTag.areaTag; + localInfo.hashTags = mappedLocalAreaTag.hashTags; // override default for node + localInfo.storageTag = mappedLocalAreaTag.storageTag; // override default + } else if(_.isString(mappedLocalAreaTag)) { + localInfo.areaTag = mappedLocalAreaTag; + } + } + + return callback(null, localInfo); + }); + }, + function findExistingItem(localInfo, callback) { + // + // We will need to look for an existing item to replace/update if: + // a) The TIC file has a "Replaces" field + // b) The general or node specific |allowReplace| is true + // + // Replace specifies a DOS 8.3 *pattern* which is allowed to have + // ? and * characters. For example, RETRONET.* + // + // Lastly, we will only replace if the item is in the same/specified area + // and that come from the same origin as a previous entry. + // + const allowReplace = _.get(Config().scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'allowReplace' ], Config().scannerTossers.ftn_bso.tic.allowReplace); + const replaces = ticFileInfo.getAsString('Replaces'); + + if(!allowReplace || !replaces) { + return callback(null, localInfo); + } + + const metaPairs = [ + { + name : 'short_file_name', + value : replaces.toUpperCase(), // we store upper as well + wildcards : true, // value may contain wildcards + }, + { + name : 'tic_origin', + value : ticFileInfo.getAsString('Origin'), + } + ]; + + FileEntry.findFiles( { metaPairs : metaPairs, areaTag : localInfo.areaTag }, (err, fileIds) => { + if(err) { + return callback(err); + } + + // 0:1 allowed + if(1 === fileIds.length) { + localInfo.existingFileId = fileIds[0]; + + // fetch old filename - we may need to remove it if replacing with a new name + FileEntry.loadBasicEntry(localInfo.existingFileId, {}, (err, info) => { + if(info) { + Log.trace( + { fileId : localInfo.existingFileId, oldFileName : info.fileName, oldStorageTag : info.storageTag }, + 'Existing TIC file target to be replaced' + ); + + localInfo.oldFileName = info.fileName; + localInfo.oldStorageTag = info.storageTag; + } + return callback(null, localInfo); // continue even if we couldn't find an old match + }); + } else if(fileIds.length > 1) { + return callback(Errors.General(`More than one existing entry for TIC in ${localInfo.areaTag} ([${fileIds.join(', ')}])`)); + } else { + return callback(null, localInfo); + } + }); + }, + function scan(localInfo, callback) { + const scanOpts = { + sha256 : localInfo.sha256, // *may* have already been calculated + meta : { + // some TIC-related metadata we always want + short_file_name : ticFileInfo.getAsString('File').toUpperCase(), // upper to ensure no case issues later; this should be a DOS 8.3 name + tic_origin : ticFileInfo.getAsString('Origin'), + tic_desc : ticFileInfo.getAsString('Desc'), + upload_by_username : _.get(Config().scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'uploadBy' ], Config().scannerTossers.ftn_bso.tic.uploadBy), + } + }; + + const ldesc = ticFileInfo.getAsString('Ldesc', '\n'); + if(ldesc) { + scanOpts.meta.tic_ldesc = ldesc; + } + + // + // We may have TIC auto-tagging for this node and/or specific (remote) area + // + const hashTags = + localInfo.hashTags || + _.get(Config().scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'hashTags' ] ); // catch-all*/ + + if(hashTags) { + scanOpts.hashTags = new Set(hashTags.split(/[\s,]+/)); + } + + if(localInfo.crc32) { + scanOpts.meta.file_crc32 = localInfo.crc32.toString(16); // again, *may* have already been calculated + } + + scanFile( + ticFileInfo.filePath, + scanOpts, + (err, fileEntry) => { + if(err) { + Log.trace( { reason : err.message }, 'Scanning failed'); + } + + localInfo.fileEntry = fileEntry; + return callback(err, localInfo); + } + ); + }, + function store(localInfo, callback) { + // + // Move file to final area storage and persist to DB + // + const areaInfo = getFileAreaByTag(localInfo.areaTag); + if(!areaInfo) { + return callback(Errors.UnexpectedState(`Could not get area for tag ${localInfo.areaTag}`)); + } + + const storageTag = localInfo.storageTag || areaInfo.storageTags[0]; + if(!isValidStorageTag(storageTag)) { + return callback(Errors.Invalid(`Invalid storage tag: ${storageTag}`)); + } + + localInfo.fileEntry.storageTag = storageTag; + localInfo.fileEntry.areaTag = localInfo.areaTag; + localInfo.fileEntry.fileName = ticFileInfo.longFileName; + + // + // We may now have two descriptions: from .DIZ/etc. or the TIC itself. + // Determine which one to use using |descPriority| and availability. + // + // We will still fallback as needed from -> -> + // + const descPriority = _.get( + Config().scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'descPriority' ], + Config().scannerTossers.ftn_bso.tic.descPriority + ); + + if('tic' === descPriority) { + const origDesc = localInfo.fileEntry.desc; + localInfo.fileEntry.desc = ticFileInfo.getAsString('Ldesc') || origDesc || getDescFromFileName(ticFileInfo.filePath); + } else { + // see if we got desc from .DIZ/etc. + const fromDescFile = 'descFile' === localInfo.fileEntry.descSrc; + localInfo.fileEntry.desc = fromDescFile ? localInfo.fileEntry.desc : ticFileInfo.getAsString('Ldesc'); + localInfo.fileEntry.desc = localInfo.fileEntry.desc || getDescFromFileName(ticFileInfo.filePath); + } + + const areaStorageDir = getAreaStorageDirectoryByTag(storageTag); + if(!areaStorageDir) { + return callback(Errors.UnexpectedState(`Could not get storage directory for tag ${localInfo.areaTag}`)); + } + + const isUpdate = localInfo.existingFileId ? true : false; + + if(isUpdate) { + // we need to *update* an existing record/file + localInfo.fileEntry.fileId = localInfo.existingFileId; + } + + const dst = paths.join(areaStorageDir, localInfo.fileEntry.fileName); + + self.copyTicAttachment(ticFileInfo.filePath, dst, isUpdate, (err, finalPath) => { + if(err) { + Log.info( { reason : err.message }, 'Failed to copy TIC attachment'); + return callback(err); + } + + if(dst !== finalPath) { + localInfo.fileEntry.fileName = paths.basename(finalPath); + } + + localInfo.fileEntry.persist(isUpdate, err => { + return callback(err, localInfo); + }); + }); + }, + // :TODO: from here, we need to re-toss files if needed, before they are removed + function cleanupOldFile(localInfo, callback) { + if(!localInfo.existingFileId) { + return callback(null, localInfo); + } + + const oldStorageDir = getAreaStorageDirectoryByTag(localInfo.oldStorageTag); + const oldPath = paths.join(oldStorageDir, localInfo.oldFileName); + + fs.unlink(oldPath, err => { + if(err) { + Log.warn( { error : err.message, oldPath : oldPath }, 'Failed removing old physical file during TIC replacement'); + } else { + Log.trace( { oldPath : oldPath }, 'Removed old physical file during TIC replacement'); + } + return callback(null, localInfo); // continue even if err + }); + }, + ], + (err, localInfo) => { + if(err) { + Log.error( { error : err.message, reason : err.reason, tic : ticFileInfo.filePath }, 'Failed to import/update TIC' ); + } else { + Log.info( + { tic : ticFileInfo.path, file : ticFileInfo.filePath, area : localInfo.areaTag }, + 'TIC imported successfully' + ); + } + return cb(err); + } + ); + }; + + this.removeAssocTicFiles = function(ticFileInfo, cb) { + async.each( [ ticFileInfo.path, ticFileInfo.filePath ], (path, nextPath) => { + fs.unlink(path, err => { + if(err && 'ENOENT' !== err.code) { // don't log when the file doesn't exist + Log.warn( { error : err.message, path : path }, 'Failed unlinking TIC file'); + } + return nextPath(null); + }); + }, err => { + return cb(err); + }); + }; + + + this.performEchoMailExport = function(cb) { + // + // Select all messages with a |message_id| > |lastScanId|. + // Additionally exclude messages with the System state_flags0 which will be present for + // imported or already exported messages + // + // NOTE: If StateFlags0 starts to use additional bits, we'll likely need to check them here! + // + const getNewUuidsSql = + `SELECT message_id, message_uuid + FROM message m + WHERE area_tag = ? AND message_id > ? AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id AND meta_category = 'System' AND meta_name = 'state_flags0') = 0 + ORDER BY message_id;` + ; + + // we shouldn't, but be sure we don't try to pick up private mail here + const config = Config(); + const areaTags = Object.keys(config.messageNetworks.ftn.areas) + .filter(areaTag => Message.WellKnownAreaTags.Private !== areaTag); + + async.each(areaTags, (areaTag, nextArea) => { + const areaConfig = config.messageNetworks.ftn.areas[areaTag]; + if(!this.isAreaConfigValid(areaConfig)) { + return nextArea(); + } + + // + // For each message that is newer than that of the last scan + // we need to export to each configured associated uplink(s) + // + async.waterfall( + [ + function getLastScanId(callback) { + self.getAreaLastScanId(areaTag, callback); + }, + function getNewUuids(lastScanId, callback) { + msgDb.all(getNewUuidsSql, [ areaTag, lastScanId ], (err, rows) => { + if(err) { + callback(err); + } else { + if(0 === rows.length) { + let nothingToDoErr = new Error('Nothing to do!'); + nothingToDoErr.noRows = true; + callback(nothingToDoErr); + } else { + callback(null, rows); + } + } + }); + }, + function exportToConfiguredUplinks(msgRows, callback) { + const uuidsOnly = msgRows.map(r => r.message_uuid); // convert to array of UUIDs only + self.exportEchoMailMessagesToUplinks(uuidsOnly, areaConfig, err => { + const newLastScanId = msgRows[msgRows.length - 1].message_id; + + Log.info( + { areaTag : areaTag, messagesExported : msgRows.length, newLastScanId : newLastScanId }, + 'Export complete'); + + callback(err, newLastScanId); + }); + }, + function updateLastScanId(newLastScanId, callback) { + self.setAreaLastScanId(areaTag, newLastScanId, callback); + } + ], + () => { + return nextArea(); + } + ); + }, + err => { + return cb(err); + }); + }; + + this.performNetMailExport = function(cb) { + // + // Select all messages with a |message_id| > |lastScanId| in the private area + // that are schedule for export to FTN-style networks. + // + // Just like EchoMail, we additionally exclude messages with the System state_flags0 + // which will be present for imported or already exported messages + // + // + // :TODO: fill out the rest of the consts here + // :TODO: this statement is crazy ugly -- use JOIN / NOT EXISTS for state_flags & 0x02 + const getNewUuidsSql = + `SELECT message_id, message_uuid + FROM message m + WHERE area_tag = '${Message.WellKnownAreaTags.Private}' AND message_id > ? AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id + AND meta_category = 'System' + AND (meta_name = 'state_flags0' OR meta_name = 'local_to_user_id') + ) = 0 + AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id + AND meta_category = 'System' + AND meta_name = '${Message.SystemMetaNames.ExternalFlavor}' + AND meta_value = '${Message.AddressFlavor.FTN}' + ) = 1 + ORDER BY message_id; + `; + + async.waterfall( + [ + function getLastScanId(callback) { + return self.getAreaLastScanId(Message.WellKnownAreaTags.Private, callback); + }, + function getNewUuids(lastScanId, callback) { + msgDb.all(getNewUuidsSql, [ lastScanId ], (err, rows) => { + if(err) { + return callback(err); + } + + if(0 === rows.length) { + return cb(null); // note |cb| -- early bail out! + } + + return callback(null, rows); + }); + }, + function exportMessages(rows, callback) { + const messageUuids = rows.map(r => r.message_uuid); + return self.exportNetMailMessagesToUplinks(messageUuids, callback); + } + ], + err => { + return cb(err); + } + ); + }; + + this.isNetMailMessage = function(message) { + return message.isPrivate() && + null === _.get(message, 'meta.System.LocalToUserID', null) && + Message.AddressFlavor.FTN === _.get(message, 'meta.System.external_flavor', null); + }; } require('util').inherits(FTNMessageScanTossModule, MessageScanTossModule); -// :TODO: *scheduled* portion of this stuff should probably use event_scheduler - @immediate would still use record(). +// :TODO: *scheduled* portion of this stuff should probably use event_scheduler - @immediate would still use record(). FTNMessageScanTossModule.prototype.processTicFilesInDirectory = function(importDir, cb) { - // :TODO: pass in 'inbound' vs 'secInbound' -- pass along to processSingleTicFile() where password will be checked + // :TODO: pass in 'inbound' vs 'secInbound' -- pass along to processSingleTicFile() where password will be checked - const self = this; - async.waterfall( - [ - function findTicFiles(callback) { - fs.readdir(importDir, (err, files) => { - if(err) { - return callback(err); - } + const self = this; + async.waterfall( + [ + function findTicFiles(callback) { + fs.readdir(importDir, (err, files) => { + if(err) { + return callback(err); + } - return callback(null, files.filter(f => '.tic' === paths.extname(f).toLowerCase())); - }); - }, - function gatherInfo(ticFiles, callback) { - const ticFilesInfo = []; + return callback(null, files.filter(f => '.tic' === paths.extname(f).toLowerCase())); + }); + }, + function gatherInfo(ticFiles, callback) { + const ticFilesInfo = []; - async.each(ticFiles, (fileName, nextFile) => { - const fullPath = paths.join(importDir, fileName); + async.each(ticFiles, (fileName, nextFile) => { + const fullPath = paths.join(importDir, fileName); - TicFileInfo.createFromFile(fullPath, (err, ticInfo) => { - if(err) { - Log.warn( { error : err.message, path : fullPath }, 'Failed reading TIC file'); - } else { - ticFilesInfo.push(ticInfo); - } + TicFileInfo.createFromFile(fullPath, (err, ticInfo) => { + if(err) { + Log.warn( { error : err.message, path : fullPath }, 'Failed reading TIC file'); + } else { + ticFilesInfo.push(ticInfo); + } - return nextFile(null); - }); - }, - err => { - return callback(err, ticFilesInfo); - }); - }, - function process(ticFilesInfo, callback) { - async.eachSeries(ticFilesInfo, (ticFileInfo, nextTicInfo) => { - self.processSingleTicFile(ticFileInfo, err => { - if(err) { - // archive rejected TIC stuff (.TIC + attach) - async.each( [ ticFileInfo.path, ticFileInfo.filePath ], (path, nextPath) => { - if(!path) { // possibly rejected due to "File" not existing/etc. - return nextPath(null); - } + return nextFile(null); + }); + }, + err => { + return callback(err, ticFilesInfo); + }); + }, + function process(ticFilesInfo, callback) { + async.eachSeries(ticFilesInfo, (ticFileInfo, nextTicInfo) => { + self.processSingleTicFile(ticFileInfo, err => { + if(err) { + // :TODO: If ENOENT -OR- failed due to CRC mismatch: create a pending state & try again later; the "attached" file may not yet be ready. - self.maybeArchiveImportFile( - path, - 'tic', - 'reject', - () => { - return nextPath(null); - } - ); - }, - () => { - self.removeAssocTicFiles(ticFileInfo, () => { - return nextTicInfo(null); - }); - }); - } else { - self.removeAssocTicFiles(ticFileInfo, () => { - return nextTicInfo(null); - }); - } - }); - }, err => { - return callback(err); - }); - } - ], - err => { - return cb(err); - } - ); + // archive rejected TIC stuff (.TIC + attach) + async.each( [ ticFileInfo.path, ticFileInfo.filePath ], (path, nextPath) => { + if(!path) { // possibly rejected due to "File" not existing/etc. + return nextPath(null); + } + + self.maybeArchiveImportFile( + path, + 'tic', + 'reject', + () => { + return nextPath(null); + } + ); + }, + () => { + self.removeAssocTicFiles(ticFileInfo, () => { + return nextTicInfo(null); + }); + }); + } else { + self.removeAssocTicFiles(ticFileInfo, () => { + return nextTicInfo(null); + }); + } + }); + }, err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); }; FTNMessageScanTossModule.prototype.startup = function(cb) { - Log.info(`${exports.moduleInfo.name} Scanner/Tosser starting up`); - - let importing = false; - - let self = this; - - function tryImportNow(reasonDesc) { - if(!importing) { - importing = true; - - Log.info( { module : exports.moduleInfo.name }, reasonDesc); - - self.performImport( () => { - importing = false; - }); - } - } - - this.createTempDirectories(err => { - if(err) { - Log.warn( { error : err.toStrong() }, 'Failed creating temporary directories!'); - return cb(err); - } - - if(_.isObject(this.moduleConfig.schedule)) { - const exportSchedule = this.parseScheduleString(this.moduleConfig.schedule.export); - if(exportSchedule) { - Log.debug( - { - schedule : this.moduleConfig.schedule.export, - schedOK : -1 === exportSchedule.sched.error, - next : moment(later.schedule(exportSchedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a'), - immediate : exportSchedule.immediate ? true : false, - }, - 'Export schedule loaded' - ); - - if(exportSchedule.sched) { - this.exportTimer = later.setInterval( () => { - if(this.exportingStart()) { - Log.info( { module : exports.moduleInfo.name }, 'Performing scheduled message scan/export...'); - - this.performExport( () => { - this.exportingEnd(); - }); - } - }, exportSchedule.sched); - } - - if(_.isBoolean(exportSchedule.immediate)) { - this.exportImmediate = exportSchedule.immediate; - } - } - - const importSchedule = this.parseScheduleString(this.moduleConfig.schedule.import); - if(importSchedule) { - Log.debug( - { - schedule : this.moduleConfig.schedule.import, - schedOK : -1 === importSchedule.sched.error, - next : moment(later.schedule(importSchedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a'), - watchFile : _.isString(importSchedule.watchFile) ? importSchedule.watchFile : 'None', - }, - 'Import schedule loaded' - ); - - if(importSchedule.sched) { - this.importTimer = later.setInterval( () => { - tryImportNow('Performing scheduled message import/toss...'); - }, importSchedule.sched); - } - - if(_.isString(importSchedule.watchFile)) { - gaze(importSchedule.watchFile, (err, watcher) => { - watcher.on('all', (event, watchedPath) => { - if(importSchedule.watchFile === watchedPath) { - tryImportNow(`Performing import/toss due to @watch: ${watchedPath} (${event})`); - } - }); - }); - } - } - } - - FTNMessageScanTossModule.super_.prototype.startup.call(this, cb); - }); + Log.info(`${exports.moduleInfo.name} Scanner/Tosser starting up`); + + this.hasValidConfiguration({ shouldLog : true }); // just check and log + + let importing = false; + + let self = this; + + function tryImportNow(reasonDesc, extraInfo) { + if(!importing) { + importing = true; + + Log.info( Object.assign({ module : exports.moduleInfo.name }, extraInfo), reasonDesc); + + self.performImport( () => { + importing = false; + }); + } + } + + this.createTempDirectories(err => { + if(err) { + Log.warn( { error : err.toStrong() }, 'Failed creating temporary directories!'); + return cb(err); + } + + if(_.isObject(this.moduleConfig.schedule)) { + const exportSchedule = this.parseScheduleString(this.moduleConfig.schedule.export); + if(exportSchedule) { + Log.debug( + { + schedule : this.moduleConfig.schedule.export, + schedOK : -1 === _.get(exportSchedule, 'sched.error'), + next : exportSchedule.sched ? moment(later.schedule(exportSchedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a') : 'N/A', + immediate : exportSchedule.immediate ? true : false, + }, + 'Export schedule loaded' + ); + + if(exportSchedule.sched) { + this.exportTimer = later.setInterval( () => { + if(this.exportingStart()) { + Log.info( { module : exports.moduleInfo.name }, 'Performing scheduled message scan/export...'); + + this.performExport( () => { + this.exportingEnd(); + }); + } + }, exportSchedule.sched); + } + + if(_.isBoolean(exportSchedule.immediate)) { + this.exportImmediate = exportSchedule.immediate; + } + } + + const importSchedule = this.parseScheduleString(this.moduleConfig.schedule.import); + if(importSchedule) { + Log.debug( + { + schedule : this.moduleConfig.schedule.import, + schedOK : -1 === _.get(importSchedule, 'sched.error'), + next : importSchedule.sched ? moment(later.schedule(importSchedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a') : 'N/A', + watchFile : _.isString(importSchedule.watchFile) ? importSchedule.watchFile : 'None', + }, + 'Import schedule loaded' + ); + + if(importSchedule.sched) { + this.importTimer = later.setInterval( () => { + tryImportNow('Performing scheduled message import/toss...'); + }, importSchedule.sched); + } + + if(_.isString(importSchedule.watchFile)) { + const watcher = sane( + paths.dirname(importSchedule.watchFile), + { + glob : `**/${paths.basename(importSchedule.watchFile)}` + } + ); + + [ 'change', 'add', 'delete' ].forEach(event => { + watcher.on(event, (fileName, fileRoot) => { + const eventPath = paths.join(fileRoot, fileName); + if(paths.join(fileRoot, fileName) === importSchedule.watchFile) { + tryImportNow('Performing import/toss due to @watch', { eventPath, event } ); + } + }); + }); + + // + // If the watch file already exists, kick off now + // https://github.com/NuSkooler/enigma-bbs/issues/122 + // + fse.exists(importSchedule.watchFile, exists => { + if(exists) { + tryImportNow('Performing import/toss due to @watch', { eventPath : importSchedule.watchFile, event : 'initial exists' } ); + } + }); + } + } + } + + FTNMessageScanTossModule.super_.prototype.startup.call(this, cb); + }); }; FTNMessageScanTossModule.prototype.shutdown = function(cb) { - Log.info('FidoNet Scanner/Tosser shutting down'); - - if(this.exportTimer) { - this.exportTimer.clear(); - } - - if(this.importTimer) { - this.importTimer.clear(); - } - - // - // Clean up temp dir/files we created - // - temptmp.cleanup( paths => { - const fullStats = { - exportDir : this.exportTempDir, - importTemp : this.importTempDir, - paths : paths, - sessionId : temptmp.sessionId, - }; - - Log.trace(fullStats, 'Temporary directories cleaned up'); - - FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); - }); + Log.info('FidoNet Scanner/Tosser shutting down'); - FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); + if(this.exportTimer) { + this.exportTimer.clear(); + } + + if(this.importTimer) { + this.importTimer.clear(); + } + + // + // Clean up temp dir/files we created + // + temptmp.cleanup( paths => { + const fullStats = { + exportDir : this.exportTempDir, + importTemp : this.importTempDir, + paths : paths, + sessionId : temptmp.sessionId, + }; + + Log.trace(fullStats, 'Temporary directories cleaned up'); + + FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); + }); + + FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); }; FTNMessageScanTossModule.prototype.performImport = function(cb) { - if(!this.hasValidConfiguration()) { - return cb(new Error('Missing or invalid configuration')); - } - - const self = this; - - async.each( [ 'inbound', 'secInbound' ], (inboundType, nextDir) => { - self.importFromDirectory(inboundType, self.moduleConfig.paths[inboundType], () => { - return nextDir(null); - }); - }, cb); + if(!this.hasValidConfiguration()) { + return cb(Errors.MissingConfig('Invalid or missing configuration')); + } + + const self = this; + + async.each( [ 'inbound', 'secInbound' ], (inboundType, nextDir) => { + const importDir = self.moduleConfig.paths[inboundType]; + self.importFromDirectory(inboundType, importDir, err => { + if (err) { + Log.trace({ importDir, error : err.message }, 'Cannot perform FTN import for directory'); + } + + return nextDir(null); + }); + }, cb); }; FTNMessageScanTossModule.prototype.performExport = function(cb) { - // - // We're only concerned with areas related to FTN. For each area, loop though - // and let's find out what messages need exported. - // - if(!this.hasValidConfiguration()) { - return cb(new Error('Missing or invalid configuration')); - } - - // - // Select all messages with a |message_id| > |lastScanId|. - // Additionally exclude messages with the System state_flags0 which will be present for - // imported or already exported messages - // - // NOTE: If StateFlags0 starts to use additional bits, we'll likely need to check them here! - // - const getNewUuidsSql = - `SELECT message_id, message_uuid - FROM message m - WHERE area_tag = ? AND message_id > ? AND - (SELECT COUNT(message_id) - FROM message_meta - WHERE message_id = m.message_id AND meta_category = 'System' AND meta_name = 'state_flags0') = 0 - ORDER BY message_id;`; - - let self = this; - - async.each(Object.keys(Config.messageNetworks.ftn.areas), (areaTag, nextArea) => { - const areaConfig = Config.messageNetworks.ftn.areas[areaTag]; - if(!this.isAreaConfigValid(areaConfig)) { - return nextArea(); - } - - // - // For each message that is newer than that of the last scan - // we need to export to each configured associated uplink(s) - // - async.waterfall( - [ - function getLastScanId(callback) { - self.getAreaLastScanId(areaTag, callback); - }, - function getNewUuids(lastScanId, callback) { - msgDb.all(getNewUuidsSql, [ areaTag, lastScanId ], (err, rows) => { - if(err) { - callback(err); - } else { - if(0 === rows.length) { - let nothingToDoErr = new Error('Nothing to do!'); - nothingToDoErr.noRows = true; - callback(nothingToDoErr); - } else { - callback(null, rows); - } - } - }); - }, - function exportToConfiguredUplinks(msgRows, callback) { - const uuidsOnly = msgRows.map(r => r.message_uuid); // convert to array of UUIDs only - self.exportMessagesToUplinks(uuidsOnly, areaConfig, err => { - const newLastScanId = msgRows[msgRows.length - 1].message_id; - - Log.info( - { areaTag : areaTag, messagesExported : msgRows.length, newLastScanId : newLastScanId }, - 'Export complete'); - - callback(err, newLastScanId); - }); - }, - function updateLastScanId(newLastScanId, callback) { - self.setAreaLastScanId(areaTag, newLastScanId, callback); - } - ], - () => { - return nextArea(); - } - ); - }, err => { - return cb(err); - }); + // + // We're only concerned with areas related to FTN. For each area, loop though + // and let's find out what messages need exported. + // + if(!this.hasValidConfiguration()) { + return cb(Errors.MissingConfig('Invalid or missing configuration')); + } + + const self = this; + + async.eachSeries( [ 'EchoMail', 'NetMail' ], (type, nextType) => { + self[`perform${type}Export`]( err => { + if(err) { + Log.warn( { type, error : err.message }, 'Error(s) during export' ); + } + return nextType(null); // try next, always + }); + }, () => { + return cb(null); + }); }; FTNMessageScanTossModule.prototype.record = function(message) { - // - // This module works off schedules, but we do support @immediate for export - // - if(true !== this.exportImmediate || !this.hasValidConfiguration()) { - return; - } - - if(message.isPrivate()) { - // :TODO: support NetMail - } else if(message.areaTag) { - const areaConfig = Config.messageNetworks.ftn.areas[message.areaTag]; - if(!this.isAreaConfigValid(areaConfig)) { - return; - } - - if(this.exportingStart()) { - this.exportMessagesToUplinks( [ message.uuid ], areaConfig, err => { - const info = { uuid : message.uuid, subject : message.subject }; - - if(err) { - Log.warn(info, 'Failed exporting message'); - } else { - Log.info(info, 'Message exported'); - } - - this.exportingEnd(); - }); - } - } + // + // This module works off schedules, but we do support @immediate for export + // + if(true !== this.exportImmediate || !this.hasValidConfiguration()) { + return; + } + + const info = { uuid : message.messageUuid, subject : message.subject }; + + function exportLog(err) { + if(err) { + Log.warn(info, 'Failed exporting message'); + } else { + Log.info(info, 'Message exported'); + } + } + + if(this.isNetMailMessage(message)) { + Object.assign(info, { type : 'NetMail' } ); + + if(this.exportingStart()) { + this.exportNetMailMessagesToUplinks( [ message.messageUuid ], err => { + this.exportingEnd( () => exportLog(err) ); + }); + } + } else if(message.areaTag) { + Object.assign(info, { type : 'EchoMail' } ); + + const areaConfig = Config().messageNetworks.ftn.areas[message.areaTag]; + if(!this.isAreaConfigValid(areaConfig)) { + return; + } + + if(this.exportingStart()) { + this.exportEchoMailMessagesToUplinks( [ message.messageUuid ], areaConfig, err => { + this.exportingEnd( () => exportLog(err) ); + }); + } + } }; diff --git a/core/server_module.js b/core/server_module.js index d1b8ccc6..9ba522bf 100644 --- a/core/server_module.js +++ b/core/server_module.js @@ -1,15 +1,18 @@ /* jslint node: true */ 'use strict'; -var PluginModule = require('./plugin_module.js').PluginModule; +const PluginModule = require('./plugin_module.js').PluginModule; -exports.ServerModule = ServerModule; +exports.ServerModule = class ServerModule extends PluginModule { + constructor(options) { + super(options); + } -function ServerModule() { - PluginModule.call(this); -} + createServer(cb) { + return cb(null); + } -require('util').inherits(ServerModule, PluginModule); - -ServerModule.prototype.createServer = function() { + listen(cb) { + return cb(null); + } }; diff --git a/core/servers/chat/mrc_multiplexer.js b/core/servers/chat/mrc_multiplexer.js new file mode 100644 index 00000000..34991de7 --- /dev/null +++ b/core/servers/chat/mrc_multiplexer.js @@ -0,0 +1,303 @@ +/* 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); + this.sendToMrcServer(message.from_user, message.from_room, message.to_user, message.to_site, message.to_room, message.body); + } catch (e) { + Log.debug({ server : 'MRC', user : username, message : message }, 'Dodgy message received from client'); + } + } + + /** + * Converts a message back into the MRC format and sends it to the central MRC server + */ + sendToMrcServer(fromUser, fromRoom, toUser, toSite, toRoom, messageBody) { + + const line = [ + fromUser, + this.boardName, + sanitiseRoomName(fromRoom || ''), + sanitiseName(toUser || ''), + sanitiseName(toSite || ''), + sanitiseRoomName(toRoom || ''), + sanitiseMessage(messageBody || '') + ].join('~') + '~'; + + // Log.debug({ server : 'MRC', data : line }, 'Sending data'); + this.sendRaw(line); + } + + sendRaw(message) { + // optionally log messages here + this.mrcClient.write(message + '\n'); + } +}; + +/** + * User / site name must be ASCII 33-125, no MCI, 30 chars max, underscores + */ +function sanitiseName(str) { + return str.replace( + /\s/g, '_' + ).replace( + /[^\x21-\x7D]|(\|\w\w)/g, '' // Non-printable & MCI + ).substr( + 0, 30 + ); +} + +function sanitiseRoomName(message) { + return message.replace(/[^\x21-\x7D]|(\|\w\w)/g, '').substr(0, 30); +} + +function sanitiseMessage(message) { + return message.replace(/[^\x20-\x7D]/g, ''); +} + diff --git a/core/servers/content/gopher.js b/core/servers/content/gopher.js new file mode 100644 index 00000000..4e51c889 --- /dev/null +++ b/core/servers/content/gopher.js @@ -0,0 +1,374 @@ +/* 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 { + splitTextAtTerms, + isAnsi, + stripAnsiControlCodes +} = require('../../string_util.js'); +const { + getMessageConferenceByTag, + getMessageAreaByTag, + getMessageListForArea, +} = require('../../message_area.js'); +const { sortAreasOrConfs } = require('../../conf_area_util.js'); +const AnsiPrep = require('../../ansi_prep.js'); +const { wordWrapText } = require('../../word_wrap.js'); +const { stripMciColorCodes } = require('../../color_codes.js'); + +// deps +const net = require('net'); +const _ = require('lodash'); +const fs = require('graceful-fs'); +const paths = require('path'); +const moment = require('moment'); + +const ModuleInfo = exports.moduleInfo = { + name : 'Gopher', + desc : 'A RFC-1436-ish Gopher Server', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.gopher.server', + notes : 'https://tools.ietf.org/html/rfc1436', +}; + +const Message = require('../../message.js'); + +const ItemTypes = { + Invalid : '', // not really a type, of course! + + // Canonical, RFC-1436 + TextFile : '0', + SubMenu : '1', + CCSONameserver : '2', + Error : '3', + BinHexFile : '4', + DOSFile : '5', + UuEncodedFile : '6', + FullTextSearch : '7', + Telnet : '8', + BinaryFile : '9', + AltServer : '+', + GIFFile : 'g', + ImageFile : 'I', + Telnet3270 : 'T', + + // Non-canonical + HtmlFile : 'h', + InfoMessage : 'i', + SoundFile : 's', +}; + +exports.getModule = class GopherModule extends ServerModule { + + constructor() { + super(); + + this.routes = new Map(); // selector->generator => gopher item + this.log = Log.child( { server : 'Gopher' } ); + } + + createServer(cb) { + if(!this.enabled) { + return cb(null); + } + + const config = Config(); + this.publicHostname = config.contentServers.gopher.publicHostname; + this.publicPort = config.contentServers.gopher.publicPort; + + this.addRoute(/^\/?\r\n$/, this.defaultGenerator); + this.addRoute(/^\/msgarea(\/[a-z0-9_-]+(\/[a-z0-9_-]+)?(\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(_raw)?)?)?\/?\r\n$/, this.messageAreaGenerator); + + this.server = net.createServer( socket => { + socket.setEncoding('ascii'); + + socket.on('data', data => { + this.routeRequest(data, socket); + }); + + socket.on('error', err => { + if('ECONNRESET' !== err.code) { // normal + this.log.trace( { error : err.message }, 'Socket error'); + } + }); + }); + + return cb(null); + } + + listen(cb) { + if(!this.enabled) { + return cb(null); + } + + const config = Config(); + const port = parseInt(config.contentServers.gopher.port); + if(isNaN(port)) { + this.log.warn( { port : config.contentServers.gopher.port, server : ModuleInfo.name }, 'Invalid port' ); + return cb(Errors.Invalid(`Invalid port: ${config.contentServers.gopher.port}`)); + } + + return this.server.listen(port, config.contentServers.gopher.address, cb); + } + + get enabled() { + return _.get(Config(), 'contentServers.gopher.enabled', false) && this.isConfigured(); + } + + isConfigured() { + // public hostname & port must be set; responses contain them! + const config = Config(); + return _.isString(_.get(config, 'contentServers.gopher.publicHostname')) && + _.isNumber(_.get(config, 'contentServers.gopher.publicPort')); + } + + addRoute(selectorRegExp, generatorHandler) { + if(_.isString(selectorRegExp)) { + try { + selectorRegExp = new RegExp(`${selectorRegExp}\r\n`); + } catch(e) { + this.log.warn( { pattern : selectorRegExp }, 'Invalid RegExp for selector' ); + return false; + } + } + this.routes.set(selectorRegExp, generatorHandler.bind(this)); + } + + routeRequest(selector, socket) { + let match; + for(let [regex, gen] of this.routes) { + match = selector.match(regex); + if(match) { + return gen(match, res => { + return socket.end(`${res}`); + }); + } + } + this.notFoundGenerator(selector, res => { + return socket.end(`${res}`); + }); + } + + makeItem(itemType, text, selector, hostname, port) { + selector = selector || ''; // e.g. for info + hostname = hostname || this.publicHostname; + port = port || this.publicPort; + return `${itemType}${text}\t${selector}\t${hostname}\t${port}\r\n`; + } + + defaultGenerator(selectorMatch, cb) { + this.log.debug( { selector : selectorMatch[0] }, 'Serving default content'); + + let bannerFile = _.get(Config(), 'contentServers.gopher.bannerFile', 'gopher_banner.asc'); + bannerFile = paths.isAbsolute(bannerFile) ? bannerFile : paths.join(__dirname, '../../../misc', bannerFile); + fs.readFile(bannerFile, 'utf8', (err, banner) => { + if(err) { + return cb('You have reached an ENiGMA½ Gopher server!'); + } + + banner = splitTextAtTerms(banner).map(l => this.makeItem(ItemTypes.InfoMessage, l)).join(''); + banner += this.makeItem(ItemTypes.SubMenu, 'Public Message Area', '/msgarea'); + return cb(banner); + }); + } + + notFoundGenerator(selector, cb) { + this.log.debug( { selector }, 'Serving not found content'); + return cb('Not found'); + } + + isAreaAndConfExposed(confTag, areaTag) { + const conf = _.get(Config(), [ 'contentServers', 'gopher', 'messageConferences', confTag ]); + return Array.isArray(conf) && conf.includes(areaTag); + } + + prepareMessageBody(body, cb) { + // + // From RFC-1436: + // "User display strings are intended to be displayed on a line on a + // typical screen for a user's viewing pleasure. While many screens can + // accommodate 80 character lines, some space is needed to display a tag + // of some sort to tell the user what sort of item this is. Because of + // this, the user display string should be kept under 70 characters in + // length. Clients may truncate to a length convenient to them." + // + // Messages on BBSes however, have generally been <= 79 characters. If we + // start wrapping earlier, things will generally be OK except: + // * When we're doing with FTN-style quoted lines + // * When dealing with ANSI/ASCII art + // + // Anyway, the spec says "should" and not MUST or even SHOULD! ...so, to + // to follow the KISS principle: Wrap at 79. + // + const WordWrapColumn = 79; + if(isAnsi(body)) { + AnsiPrep( + body, + { + cols : WordWrapColumn, // See notes above + forceLineTerm : true, // Ensure each line is term'd + asciiMode : true, // Export to ASCII + fillLines : false, // Don't fill up to |cols| + }, + (err, prepped) => { + return cb(prepped || body); + } + ); + } else { + const cleaned = stripMciColorCodes( + stripAnsiControlCodes(body, { all : true } ) + ); + const prepped = + splitTextAtTerms(cleaned) + .map(l => (wordWrapText(l, { width : WordWrapColumn } ).wrapped || []).join('\n')) + .join('\n'); + + return cb(prepped); + } + } + + shortenSubject(subject) { + return _.truncate(subject, { length : 30 } ); + } + + messageAreaGenerator(selectorMatch, cb) { + this.log.debug( { selector : selectorMatch[0] }, 'Serving message area content'); + // + // Selector should be: + // /msgarea - list confs + // /msgarea/conftag - list areas in conf + // /msgarea/conftag/areatag - list messages in area + // /msgarea/conftag/areatag/ - message as text + // /msgarea/conftag/areatag/_raw - full message as text + headers + // + if(selectorMatch[3] || selectorMatch[4]) { + // message + //const raw = selectorMatch[4] ? true : false; + // :TODO: support 'raw' + const msgUuid = selectorMatch[3].replace(/\r\n|\//g, ''); + const confTag = selectorMatch[1].substr(1).split('/')[0]; + const areaTag = selectorMatch[2].replace(/\r\n|\//g, ''); + const message = new Message(); + + return message.load( { uuid : msgUuid }, err => { + if(err) { + this.log.debug( { uuid : msgUuid }, 'Attempted access to non-existent message UUID!'); + return this.notFoundGenerator(selectorMatch, cb); + } + + if(message.areaTag !== areaTag || !this.isAreaAndConfExposed(confTag, areaTag)) { + this.log.warn( { areaTag }, 'Attempted access to non-exposed conference and/or area!'); + return this.notFoundGenerator(selectorMatch, cb); + } + + if(Message.isPrivateAreaTag(areaTag)) { + this.log.warn( { areaTag }, 'Attempted access to message in private area!'); + return this.notFoundGenerator(selectorMatch, cb); + } + + this.prepareMessageBody(message.message, msgBody => { + const response = `${'-'.repeat(70)} +To : ${message.toUserName} +From : ${message.fromUserName} +When : ${moment(message.modTimestamp).format('dddd, MMMM Do YYYY, h:mm:ss a (UTCZ)')} +Subject: ${message.subject} +ID : ${message.messageUuid} (${message.messageId}) +${'-'.repeat(70)} +${msgBody} + `; + return cb(response); + }); + }); + } else if(selectorMatch[2]) { + // list messages in area + const confTag = selectorMatch[1].substr(1).split('/')[0]; + const areaTag = selectorMatch[2].replace(/\r\n|\//g, ''); + const area = getMessageAreaByTag(areaTag); + + if(Message.isPrivateAreaTag(areaTag)) { + this.log.warn( { areaTag }, 'Attempted access to private area!'); + return cb(this.makeItem(ItemTypes.InfoMessage, 'Area is private')); + } + + if(!area || !this.isAreaAndConfExposed(confTag, areaTag)) { + this.log.warn( { confTag, areaTag }, 'Attempted access to non-exposed conference and/or area!'); + return this.notFoundGenerator(selectorMatch, cb); + } + + const filter = { + resultType : 'messageList', + sort : 'messageId', + order : 'descending', // we want newest messages first for Gopher + }; + + return getMessageListForArea(null, areaTag, filter, (err, msgList) => { + const response = [ + this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), + this.makeItem(ItemTypes.InfoMessage, `Messages in ${area.name}`), + this.makeItem(ItemTypes.InfoMessage, '(newest first)'), + this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), + ...msgList.map(msg => this.makeItem( + ItemTypes.TextFile, + `${moment(msg.modTimestamp).format('YYYY-MM-DD hh:mma')}: ${this.shortenSubject(msg.subject)} (${msg.fromUserName} to ${msg.toUserName})`, + `/msgarea/${confTag}/${areaTag}/${msg.messageUuid}` + )) + ].join(''); + + return cb(response); + }); + } else if(selectorMatch[1]) { + // list areas in conf + const sysConfig = Config(); + const confTag = selectorMatch[1].replace(/\r\n|\//g, ''); + const conf = _.get(sysConfig, [ 'contentServers', 'gopher', 'messageConferences', confTag ]) && getMessageConferenceByTag(confTag); + if(!conf) { + return this.notFoundGenerator(selectorMatch, cb); + } + + const areas = _.get(sysConfig, [ 'contentServers', 'gopher', 'messageConferences', confTag ], {}) + .map(areaTag => Object.assign( { areaTag }, getMessageAreaByTag(areaTag))) + .filter(area => area && !Message.isPrivateAreaTag(area.areaTag)); + + if(0 === areas.length) { + return cb(this.makeItem(ItemTypes.InfoMessage, 'No message areas available')); + } + + sortAreasOrConfs(areas); + + const response = [ + this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), + this.makeItem(ItemTypes.InfoMessage, `Message areas in ${conf.name}`), + this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), + ...areas.map(area => this.makeItem(ItemTypes.SubMenu, `${area.name} ${area.desc ? '- ' + area.desc : ''}`, `/msgarea/${confTag}/${area.areaTag}`)) + ].join(''); + + return cb(response); + } else { + // message area base (list confs) + const confs = Object.keys(_.get(Config(), 'contentServers.gopher.messageConferences', {})) + .map(confTag => Object.assign( { confTag }, getMessageConferenceByTag(confTag))) + .filter(conf => conf); // remove any baddies + + if(0 === confs.length) { + return cb(this.makeItem(ItemTypes.InfoMessage, 'No message conferences available')); + } + + sortAreasOrConfs(confs); + + const response = [ + this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), + this.makeItem(ItemTypes.InfoMessage, 'Available Message Conferences'), + this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), + this.makeItem(ItemTypes.InfoMessage, ''), + ...confs.map(conf => this.makeItem(ItemTypes.SubMenu, `${conf.name} ${conf.desc ? '- ' + conf.desc : ''}`, `/msgarea/${conf.confTag}`)) + ].join(''); + + return cb(response); + } + } +}; \ No newline at end of file diff --git a/core/servers/content/nntp.js b/core/servers/content/nntp.js new file mode 100644 index 00000000..237c0994 --- /dev/null +++ b/core/servers/content/nntp.js @@ -0,0 +1,997 @@ +/* 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 { + getTransactionDatabase, + getModDatabasePath +} = require('../../database.js'); +const { + getMessageAreaByTag, + getMessageConferenceByTag, +} = require('../../message_area.js'); +const User = require('../../user.js'); +const Errors = require('../../enig_error.js').Errors; +const Message = require('../../message.js'); +const FTNAddress = require('../../ftn_address.js'); +const { + isAnsi, + stripAnsiControlCodes, + splitTextAtTerms, +} = require('../../string_util.js'); +const AnsiPrep = require('../../ansi_prep.js'); +const { + stripMciColorCodes +} = require('../../color_codes.js'); + +// deps +const NNTPServerBase = require('nntp-server'); +const _ = require('lodash'); +const fs = require('fs-extra'); +const forEachSeries = require('async/forEachSeries'); +const asyncReduce = require('async/reduce'); +const asyncMap = require('async/map'); +const asyncSeries = require('async/series'); +const asyncWaterfall = require('async/waterfall'); +const LRU = require('lru-cache'); +const sqlite3 = require('sqlite3'); +const paths = require('path'); + +// +// Network News Transfer Protocol (NNTP) +// +// RFCS +// - https://www.w3.org/Protocols/rfc977/rfc977 +// - https://tools.ietf.org/html/rfc3977 +// - https://tools.ietf.org/html/rfc2980 +// - https://tools.ietf.org/html/rfc5536 + +// +exports.moduleInfo = { + name : 'NNTP', + desc : 'Network News Transfer Protocol (NNTP) Server', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.nntp.server', +}; + +exports.performMaintenanceTask = performMaintenanceTask; + +/* + General TODO + - ACS checks need worked out. Currently ACS relies on |client|. We need a client + spec that can be created even without a login server. Some checks and simply + return false/fail. +*/ + +// simple DB maps NNTP Message-ID's which are +// sequential per group -> ENiG messages +// A single instance is shared across NNTP and/or NNTPS +class NNTPDatabase +{ + constructor() { + } + + init(cb) { + asyncSeries( + [ + (callback) => { + this.db = getTransactionDatabase(new sqlite3.Database( + getModDatabasePath(exports.moduleInfo), + err => { + return callback(err); + } + )); + }, + (callback) => { + this.db.serialize( () => { + this.db.run( + `CREATE TABLE IF NOT EXISTS nntp_area_message ( + nntp_message_id INTEGER NOT NULL, + message_id INTEGER NOT NULL, + message_area_tag VARCHAR NOT NULL, + message_uuid VARCHAR NOT NULL, + + UNIQUE(nntp_message_id, message_area_tag) + );` + ); + + this.db.run( + `CREATE INDEX IF NOT EXISTS nntp_area_message_by_uuid_index + ON nntp_area_message (message_uuid);` + ); + + return callback(null); + }); + } + ], + err => { + return cb(err); + } + ); + } +} + +let nntpDatabase; + +class NNTPServer extends NNTPServerBase { + constructor(options, serverName) { + super(options); + + this.log = Log.child( { server : serverName } ); + + const config = Config(); + this.groupCache = new LRU({ + max : _.get(config, 'contentServers.nntp.cache.maxItems', 200), + maxAge : _.get(config, 'contentServers.nntp.cache.maxAge', 1000 * 30), // default=30s + }); + } + + _needAuth(session, command) { + return super._needAuth(session, command); + } + + _authenticate(session) { + const username = session.authinfo_user; + const password = session.authinfo_pass; + + this.log.trace( { username }, 'Authentication request'); + + return new Promise( resolve => { + const user = new User(); + user.authenticateFactor1({ type : User.AuthFactor1Types.Password, username, password }, err => { + if(err) { + // :TODO: Log IP address + this.log.debug( { username, reason : err.message }, 'Authentication failure'); + return resolve(false); + } + + session.authUser = user; + + this.log.debug( { username }, 'User authenticated successfully'); + return resolve(true); + }); + }); + } + + isGroupSelected(session) { + return Array.isArray(_.get(session, 'groupInfo.messageList')); + } + + getJAMStyleFrom(message, fromName) { + // + // Try to to create a (JamNTTPd) JAM style "From" field: + // + // - If we're dealing with a FTN address, create an email-like format + // but do not include ':' or '/' characters as it may cause clients + // to puke. FTN addresses are formatted how JamNTTPd does it for + // some sort of compliance. We also extend up to 5D addressing. + // - If we have an email address, then it's ready to go. + // + const remoteFrom = _.get(message.meta, [ 'System', Message.SystemMetaNames.RemoteFromUser ]); + let jamStyleFrom; + if(remoteFrom) { + const flavor = _.get(message.meta, [ 'System', Message.SystemMetaNames.ExternalFlavor ]); + switch(flavor) { + case [ Message.AddressFlavor.FTN ] : + { + let ftnAddr = FTNAddress.fromString(remoteFrom); + if(ftnAddr && ftnAddr.isValid()) { + // In general, addresses are in point, node, net, zone, domain order + if(ftnAddr.domain) { // 5D + // point.node.net.zone@domain or node.net.zone@domain + jamStyleFrom = `${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}@${ftnAddr.domain}`; + if(ftnAddr.point) { + jamStyleFrom = `${ftnAddr.point}.` + jamStyleFrom; + } + } else { + if(ftnAddr.point) { + jamStyleFrom = `${ftnAddr.point}@${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}`; + } else { + jamStyleFrom = `0@${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}`; + } + } + } + } + break; + + case [ Message.AddressFlavor.Email ] : + jamStyleFrom = `${fromName} <${remoteFrom}>`; + break; + } + } + + if(!jamStyleFrom) { + jamStyleFrom = fromName; + } + + return jamStyleFrom; + } + + populateNNTPHeaders(session, message, cb) { + // + // Build compliant headers + // + // Resources: + // - https://tools.ietf.org/html/rfc5536#section-3.1 + // - https://github.com/ftnapps/jamnntpd/blob/master/src/nntpserv.c#L962 + // + const toName = this.getMessageTo(message); + const fromName = this.getMessageFrom(message); + + message.nntpHeaders = { + From : this.getJAMStyleFrom(message, fromName), + 'X-Comment-To' : toName, + Newsgroups : session.group.name, + Subject : message.subject, + Date : this.getMessageDate(message), + 'Message-ID' : this.getMessageIdentifier(message), + Path : 'ENiGMA1/2!not-for-mail', + 'Content-Type' : 'text/plain; charset=utf-8', + }; + + const externalFlavor = _.get(message.meta.System, [ Message.SystemMetaNames.ExternalFlavor ]); + if(externalFlavor) { + message.nntpHeaders['X-ENiG-MessageFlavor'] = externalFlavor; + } + + // Any FTN properties -> X-FTN-* + _.each(message.meta.FtnProperty, (v, k) => { + const suffix = { + [ Message.FtnPropertyNames.FtnTearLine ] : 'Tearline', + [ Message.FtnPropertyNames.FtnOrigin ] : 'Origin', + [ Message.FtnPropertyNames.FtnArea ] : 'AREA', + [ Message.FtnPropertyNames.FtnSeenBy ] : 'SEEN-BY', + }[k]; + + if(suffix) { + // some special treatment. + if('Tearline' === suffix) { + v = v.replace(/^--- /, ''); + } else if('Origin' === suffix) { + v = v.replace(/^[ ]{1,2}\* Origin: /, ''); + } + if(Array.isArray(v)) { // ie: SEEN-BY[] -> one big list + v = v.join(' '); + } + message.nntpHeaders[`X-FTN-${suffix}`] = v.trim(); + } + }); + + // Other FTN kludges + _.each(message.meta.FtnKludge, (v, k) => { + if(Array.isArray(v)) { + v = v.join(' '); // same as above + } + message.nntpHeaders[`X-FTN-${k.toUpperCase()}`] = v.toString().trim(); + }); + + // + // Set X-FTN-To and X-FTN-From: + // - If remote to/from : joeuser + // - Without remote : joeuser + // + 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 ]); + message.nntpHeaders['X-FTN-To'] = remoteTo ? `${toName} <${remoteTo}>` : toName; + + if(!message.replyToMsgId) { + return cb(null); + } + + // replyToMessageId -> Message-ID formatted ID + const filter = { + resultType : 'uuid', + ids : [ parseInt(message.replyToMsgId) ], + limit : 1, + }; + Message.findMessages(filter, (err, uuids) => { + if(!err && Array.isArray(uuids)) { + message.nntpHeaders.References = this.makeMessageIdentifier(message.replyToMsgId, uuids[0]); + } + return cb(null); + }); + } + + getMessageUUIDFromMessageID(session, messageId) { + let messageUuid; + + // Direct ID request + if((_.isString(messageId) && '<' !== messageId.charAt(0)) || _.isNumber(messageId)) { + // group must be in session + if(!this.isGroupSelected(session)) { + return null; + } + + messageId = parseInt(messageId); + if(isNaN(messageId)) { + return null; + } + + const msg = session.groupInfo.messageList.find(m => { + return m.index === messageId; + }); + + messageUuid = msg && msg.messageUuid; + } else { + // request + [ , messageUuid ] = this.getMessageIdentifierParts(messageId); + } + + if(!_.isString(messageUuid)) { + return null; + } + + return messageUuid; + } + + _getArticle(session, messageId) { + return new Promise( resolve => { + this.log.trace( { messageId }, 'Get article'); + + const messageUuid = this.getMessageUUIDFromMessageID(session, messageId); + if(!messageUuid) { + this.log.debug( { messageId }, 'Unable to retrieve message UUID for article request'); + return resolve(null); + } + + const message = new Message(); + asyncSeries( + [ + (callback) => { + return message.load( { uuid : messageUuid }, callback); + }, + (callback) => { + if(!_.has(session, 'groupInfo.areaTag')) { + // :TODO: if this is needed, how to validate properly? + this.log.warn( { messageUuid, messageId }, 'Get article request without group selection'); + return resolve(null); + } + + if(session.groupInfo.areaTag !== message.areaTag) { + return resolve(null); + } + + if(!this.hasConfAndAreaReadAccess(session, session.groupInfo.confTag, session.groupInfo.areaTag)) { + this.log.info( { messageUuid, messageId}, 'Access denied for message'); + return resolve(null); + } + + return callback(null); + }, + (callback) => { + return this.populateNNTPHeaders(session, message, callback); + }, + (callback) => { + return this.prepareMessageBody(message, callback); + } + ], + err => { + if(err) { + this.log.error( { error : err.message, messageUuid }, 'Failed to load article'); + return resolve(null); + } + + this.log.info( { messageUuid, messageId, areaTag : message.areaTag }, 'Serving article'); + return resolve(message); + } + ); + }); + } + + _getRange(session, first, last /*options*/) { + return new Promise(resolve => { + // + // Build an array of message objects that can later + // be used with the various _build* methods. + // + // :TODO: Handle |options| + if(!this.isGroupSelected(session)) { + return resolve(null); + } + + const uuids = session.groupInfo.messageList.filter(m => { + if(m.areaTag !== session.groupInfo.areaTag) { + return false; + } + if(m.index < first || m.index > last) { + return false; + } + return true; + }).map(m => { + return { uuid : m.messageUuid, index : m.index }; + }); + + asyncMap(uuids, (msgInfo, nextMessageUuid) => { + const message = new Message(); + message.load( { uuid : msgInfo.uuid }, err => { + if(err) { + return nextMessageUuid(err); + } + + message.index = msgInfo.index; + + this.populateNNTPHeaders(session, message, () => { + this.prepareMessageBody(message, () => { + return nextMessageUuid(null, message); + }); + }); + }); + }, + (err, messages) => { + return resolve(err ? null : messages); + }); + }); + } + + _selectGroup (session, groupName) { + this.log.trace( { groupName }, 'Select group request'); + + return new Promise( resolve => { + this.getGroup(session, groupName, (err, group) => { + if(err) { + return resolve(false); + } + + session.group = Object.assign( + {}, // start clean + { + description : group.friendlyDesc || group.friendlyName, + current_article : group.nntp.total ? group.nntp.min_index : 0, + }, + group.nntp + ); + + session.groupInfo = group; // full set of info + + return resolve(true); + }); + }); + } + + _getGroups(session, time, wildmat) { + this.log.trace( { time, wildmat }, 'Get groups request'); + + // :TODO: handle time - probably use as caching mechanism - must consider user/auth/rights + // :TODO: handle |time| if possible. + return new Promise( (resolve, reject) => { + const config = Config(); + + // :TODO: merge confs avail to authenticated user + const publicConfs = _.get(config, 'contentServers.nntp.publicMessageConferences', {}); + + asyncReduce(Object.keys(publicConfs), [], (groups, confTag, nextConfTag) => { + const areaTags = publicConfs[confTag]; + // :TODO: merge area tags available to authenticated user + asyncMap(areaTags, (areaTag, nextAreaTag) => { + const groupName = this.getGroupName(confTag, areaTag); + + // filter on |wildmat| if supplied. We will remove + // empty areas below in the final results. + if(wildmat && !wildmat.test(groupName)) { + return nextAreaTag(null, null); + } + + this.getGroup(session, groupName, (err, group) => { + if(err) { + return nextAreaTag(null, null); // try others + } + return nextAreaTag(null, group.nntp); + }); + }, + (err, areas) => { + if(err) { + return nextConfTag(err); + } + + areas = areas.filter(a => a && Object.keys(a).length > 0); // remove empty + groups.push(...areas); + + return nextConfTag(null, groups); + }); + }, + (err, groups) => { + if(err) { + return reject(err); + } + return resolve(groups); + }); + }); + } + + isConfAndAreaPubliclyExposed(confTag, areaTag) { + const publicAreaTags = _.get(Config(), [ 'contentServers', 'nntp', 'publicMessageConferences', confTag ] ); + return Array.isArray(publicAreaTags) && publicAreaTags.includes(areaTag); + } + + hasConfAndAreaReadAccess(session, confTag, areaTag) { + if(Message.isPrivateAreaTag(areaTag)) { + return false; + } + + if(this.isConfAndAreaPubliclyExposed(confTag, areaTag)) { + return true; + } + + // further checks require an authenticated user & ACS + if(!session || !session.authUser) { + return false; + } + + const conf = getMessageConferenceByTag(confTag); + if(!conf) { + return false; + } + // :TODO: validate ACS + + const area = getMessageAreaByTag(areaTag, confTag); + if(!area) { + return false; + } + // :TODO: validate ACS + + return false; + } + + getGroup(session, groupName, cb) { + let group = this.groupCache.get(groupName); + if(group) { + return cb(null, group); + } + + const [ confTag, areaTag ] = groupName.split('.'); + if(!confTag || !areaTag) { + return cb(Errors.UnexpectedState(`Invalid NNTP group name: ${groupName}`)); + } + + if(!this.hasConfAndAreaReadAccess(session, confTag, areaTag)) { + return cb(Errors.AccessDenied(`No access to conference ${confTag} and/or area ${areaTag}`)); + } + + const area = getMessageAreaByTag(areaTag, confTag); + if(!area) { + return cb(Errors.DoesNotExist(`No area for areaTag "${areaTag}" / confTag "${confTag}"`)); + } + + this.getMappedMessageListForArea(areaTag, (err, messageList) => { + if(err) { + return cb(err); + } + + if(0 === messageList.length) { + // + // Handle empty group + // See https://tools.ietf.org/html/rfc3977#section-6.1.1.2 + // + return cb(null, { + messageList : [], + confTag, + areaTag, + friendlyName : area.name, + friendlyDesc : area.desc, + nntp : { + name : groupName, + description : area.desc, + min_index : 0, + max_index : 0, + total : 0, + } + }); + } + + group = { + messageList, + confTag, + areaTag, + friendlyName : area.name, + friendlyDesc : area.desc, + nntp : { + name : groupName, + min_index : messageList[0].index, + max_index : messageList[messageList.length - 1].index, + total : messageList.length, + }, + }; + + this.groupCache.set(groupName, group); + + return cb(null, group); + }); + } + + getMappedMessageListForArea(areaTag, cb) { + // + // Get all messages in mapped database. Then, find any messages that are not + // yet mapped with ID's > the highest ID we have. Any new messages will have + // new mappings created. + // + // :TODO: introduce caching + asyncWaterfall( + [ + (callback) => { + nntpDatabase.db.all( + `SELECT nntp_message_id, message_id, message_uuid + FROM nntp_area_message + WHERE message_area_tag = ? + ORDER BY nntp_message_id;`, + [ areaTag ], + (err, rows) => { + if(err) { + return callback(err); + } + + let messageList; + const lastMessageId = rows.length > 0 ? rows[rows.length - 1].message_id : 0; + if(!lastMessageId) { + messageList = []; + } else { + messageList = rows.map(r => { + return { + areaTag, + index : r.nntp_message_id, // node-nntp wants this name + messageUuid : r.message_uuid, + }; + }); + } + + return callback(null, messageList, lastMessageId); + } + ); + }, + (messageList, lastMessageId, callback) => { + // Find any new entries + const filter = { + areaTag, + newerThanMessageId : lastMessageId, + sort : 'messageId', + order : 'ascending', + resultType : 'messageList', + }; + Message.findMessages(filter, (err, newMessageList) => { + if(err) { + return callback(err); + } + + let index = messageList.length > 0 ? + messageList[messageList.length - 1].index + 1 + : 1; + newMessageList = newMessageList.map(m => { + return Object.assign(m, { index : index++ } ); + }); + + if(0 === newMessageList.length) { + return callback(null, messageList); + } + + // populate mapping DB with any new entries + nntpDatabase.db.beginTransaction( (err, trans) => { + if(err) { + return callback(err); + } + + forEachSeries(newMessageList, (newMessage, nextNewMessage) => { + trans.run( + `INSERT INTO nntp_area_message (nntp_message_id, message_id, message_area_tag, message_uuid) + VALUES (?, ?, ?, ?);`, + [ newMessage.index, newMessage.messageId, areaTag, newMessage.messageUuid ], + err => { + return nextNewMessage(err); + } + ); + }, + err => { + if(err) { + return trans.rollback( () => { + return callback(err); + }); + } + + trans.commit( () => { + messageList.push(...newMessageList.map(m => { + return { + areaTag, + index : m.nntpMessageId, + messageUuid : m.messageUuid, + }; + })); + + return callback(null, messageList); + }); + }); + }); + }); + } + ], + (err, messageList) => { + return cb(err, messageList); + } + ); + } + + _buildHead(session, message) { + return _.map(message.nntpHeaders, (v, k) => `${k}: ${v}`).join('\r\n'); + } + + _buildBody(session, message) { + return message.preparedBody; + } + + _buildHeaderField(session, message, field) { + const body = message.preparedBody || message.message; + const value = { + ':bytes' : Buffer.byteLength(body).toString(), + ':lines' : splitTextAtTerms(body).length.toString(), + }[field] + || _.find(message.nntpHeaders, (v, k) => { + return k.toLowerCase() === field; + }); + + if(!value) { + // + // Clients will check some headers just to see if they exist. + // Don't spam logs with these. For others, it's good to know. + // + if(!['references', 'xref'].includes(field)) { + this.log.trace(`No value for requested header field "${field}"`); + } + } + + return value; + } + + _getOverviewFmt(session) { + return super._getOverviewFmt(session); + } + + _getNewNews(session, time, wildmat) { + // Currently seems pointless to implement. No semi-modern clients seem to use it anyway. + this.log.debug( { time, wildmat }, 'Request made using unsupported NEWNEWS command'); + throw new Errors.Invalid('NEWNEWS is not enabled on this server'); + } + + getMessageDate(message) { + // https://tools.ietf.org/html/rfc5536#section-3.1.1 -> https://tools.ietf.org/html/rfc5322#section-3.3 + return message.modTimestamp.format('ddd, D MMM YYYY HH:mm:ss ZZ'); + } + + makeMessageIdentifier(messageId, messageUuid) { + // + // Spec : RFC-5536 Section 3.1.3 @ https://tools.ietf.org/html/rfc5536#section-3.1.3 + // Example : <2456.0f6587f7-5512-4d03-8740-4d592190145a@enigma-bbs> + // + return `<${messageId}.${messageUuid}@enigma-bbs>`; + } + + getMessageIdentifier(message) { + // note that we use the *real* message ID here, not the NNTP-specific index. + return this.makeMessageIdentifier(message.messageId, message.messageUuid); + } + + getMessageIdentifierParts(messageId) { + const m = messageId.match(/<([0-9]+)\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})@enigma-bbs>/); + if(m) { + return [ m[1], m[2] ]; + } + return []; + } + + getMessageTo(message) { + // :TODO: same as From -- check config + return message.toUserName; + } + + getMessageFrom(message) { + // :TODO: NNTP config > conf > area config for real names + return message.fromUserName; + } + + prepareMessageBody(message, cb) { + if(isAnsi(message.message)) { + AnsiPrep( + message.message, + { + rows : 'auto', + cols : 79, + forceLineTerm : true, + asciiMode : true, + fillLines : false, + }, + (err, prepped) => { + message.preparedBody = prepped || message.message; + return cb(null); + } + ); + } else { + message.preparedBody = stripMciColorCodes(stripAnsiControlCodes(message.message, { all : true })); + return cb(null); + } + } + + getGroupName(confTag, areaTag) { + // + // Example: + // input : fsxNet (confTag) fsx_bbs (areaTag) + // output: fsx_net.fsx_bbs + // + // Note also that periods are replaced in conf and area + // tags such that we *only* have a period separator + // between the two for a group name! + // + return `${_.snakeCase(confTag).replace(/\./g, '_')}.${_.snakeCase(areaTag).replace(/\./g, '_')}`; + } +} + +exports.getModule = class NNTPServerModule extends ServerModule { + constructor() { + super(); + } + + isEnabled() { + return this.enableNntp || this.enableNttps; + } + + get enableNntp() { + return _.get(Config(), 'contentServers.nntp.nntp.enabled', false); + } + + get enableNttps() { + return _.get(Config(), 'contentServers.nntp.nntps.enabled', false); + } + + isConfigured() { + const config = Config(); + + // + // Any conf/areas exposed? + // + const publicConfs = _.get(config, 'contentServers.nntp.publicMessageConferences', {}); + const areasExposed = _.some(publicConfs, areas => { + return Array.isArray(areas) && areas.length > 0; + }); + + if(!areasExposed) { + return false; + } + + const nntp = _.get(config, 'contentServers.nntp.nntp'); + if(nntp && this.enableNntp) { + if(isNaN(nntp.port)) { + return false; + } + } + + const nntps = _.get(config, 'contentServers.nntp.nntps'); + if(nntps && this.enableNttps) { + if(isNaN(nntps.port)) { + return false; + } + + if(!_.isString(nntps.certPem) || !_.isString(nntps.keyPem)) { + return false; + } + } + + return true; + } + + createServer(cb) { + if(!this.isEnabled() || !this.isConfigured()) { + return cb(null); + } + + const config = Config(); + + const commonOptions = { + //requireAuth : true, // :TODO: re-enable! + // :TODO: override |session| - use our own debug to Bunyan, etc. + }; + + if(this.enableNntp) { + this.nntpServer = new NNTPServer( + // :TODO: according to docs: if connection is non-tls, but behind proxy (assuming TLS termination?!!) then set this to true + Object.assign( { secure : false }, commonOptions), + 'NNTP' + ); + } + + if(this.enableNttps) { + this.nntpsServer = new NNTPServer( + Object.assign( + { + secure : true, + tls : { + cert : fs.readFileSync(config.contentServers.nntp.nntps.certPem), + key : fs.readFileSync(config.contentServers.nntp.nntps.keyPem), + } + }, + commonOptions + ), + 'NTTPS' + ); + } + + nntpDatabase = new NNTPDatabase(); + nntpDatabase.init(err => { + return cb(err); + }); + } + + listen(cb) { + const config = Config(); + forEachSeries([ 'nntp', 'nntps' ], (service, nextService) => { + const server = this[`${service}Server`]; + if(server) { + const port = config.contentServers.nntp[service].port; + server.listen(this.listenURI(port, service)) + .catch(e => { + Log.warn( { error : e.message, port }, `${service.toUpperCase()} failed to listen`); + return nextService(null); // try next anyway + }).then( () => { + return nextService(null); + }); + } else { + return nextService(null); + } + }, + err => { + return cb(err); + }); + } + + listenURI(port, service = 'nntp') { + return `${service}://0.0.0.0:${port}`; + } +}; + +function performMaintenanceTask(args, cb) { + // + // Delete any message mapping that no longer have + // an actual message associated with them. + // + if(!nntpDatabase) { + Log.trace('Cannot perform NNTP maintenance without NNTP database initialized'); + return cb(null); + } + + let attached = false; + asyncSeries( + [ + (callback) => { + const messageDbPath = paths.join(Config().paths.db, 'message.sqlite3'); + nntpDatabase.db.run( + `ATTACH DATABASE "${messageDbPath}" AS msgdb;`, + err => { + attached = !err; + return callback(err); + } + ); + }, + (callback) => { + nntpDatabase.db.run( + `DELETE FROM nntp_area_message + WHERE message_uuid NOT IN ( + SELECT message_uuid + FROM msgdb.message + );`, + function result(err) { // no arrow func; need |this.changes| + if(err) { + Log.warn( { error : err.message }, 'Failed to delete from NNTP database'); + } else { + Log.debug( { count : this.changes }, 'Deleted mapped message IDs from NNTP database'); + } + return callback(err); + } + ); + } + ], + err => { + if(attached) { + nntpDatabase.db.run('DETACH DATABASE msgdb;'); + } + return cb(err); + } + ); +} \ No newline at end of file diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 31c617e2..04b9ccdd 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -1,243 +1,271 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Log = require('../../logger.js').log; -const ServerModule = require('../../server_module.js').ServerModule; -const Config = require('../../config.js').config; +// ENiGMA½ +const Log = require('../../logger.js').log; +const ServerModule = require('../../server_module.js').ServerModule; +const Config = require('../../config.js').get; +const { Errors } = require('../../enig_error.js'); -// deps -const http = require('http'); -const https = require('https'); -const _ = require('lodash'); -const fs = require('graceful-fs'); -const paths = require('path'); -const mimeTypes = require('mime-types'); +// deps +const http = require('http'); +const https = require('https'); +const _ = require('lodash'); +const fs = require('graceful-fs'); +const paths = require('path'); +const mimeTypes = require('mime-types'); +const forEachSeries = require('async/forEachSeries'); const ModuleInfo = exports.moduleInfo = { - name : 'Web', - desc : 'Web Server', - author : 'NuSkooler', - packageName : 'codes.l33t.enigma.web.server', + name : 'Web', + desc : 'Web Server', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.web.server', }; class Route { - constructor(route) { - Object.assign(this, route); - - if(this.method) { - this.method = this.method.toUpperCase(); - } + constructor(route) { + Object.assign(this, route); - try { - this.pathRegExp = new RegExp(this.path); - } catch(e) { - Log.debug( { route : route }, 'Invalid regular expression for route path' ); - } - } + if(this.method) { + this.method = this.method.toUpperCase(); + } - isValid() { - return ( - this.pathRegExp instanceof RegExp && - ( -1 !== [ 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', ].indexOf(this.method) ) || - !_.isFunction(this.handler) - ); - } + try { + this.pathRegExp = new RegExp(this.path); + } catch(e) { + Log.debug( { route : route }, 'Invalid regular expression for route path' ); + } + } - matchesRequest(req) { - return req.method === this.method && this.pathRegExp.test(req.url); - } + isValid() { + return ( + this.pathRegExp instanceof RegExp && + ( -1 !== [ 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', ].indexOf(this.method) ) || + !_.isFunction(this.handler) + ); + } - getRouteKey() { return `${this.method}:${this.path}`; } + matchesRequest(req) { + return req.method === this.method && this.pathRegExp.test(req.url); + } + + getRouteKey() { return `${this.method}:${this.path}`; } } exports.getModule = class WebServerModule extends ServerModule { - constructor() { - super(); + constructor() { + super(); - this.enableHttp = Config.contentServers.web.http.enabled || false; - this.enableHttps = Config.contentServers.web.https.enabled || false; + const config = Config(); + this.enableHttp = config.contentServers.web.http.enabled || false; + this.enableHttps = config.contentServers.web.https.enabled || false; - this.routes = {}; + this.routes = {}; - if(this.isEnabled() && Config.contentServers.web.staticRoot) { - this.addRoute({ - method : 'GET', - path : '/static/.*$', - handler : this.routeStaticFile.bind(this), - }); - } - } + if(this.isEnabled() && config.contentServers.web.staticRoot) { + this.addRoute({ + method : 'GET', + path : '/static/.*$', + handler : this.routeStaticFile.bind(this), + }); + } + } - buildUrl(pathAndQuery) { - // - // Create a URL such as - // https://l33t.codes:44512/ + |pathAndQuery| - // - // Prefer HTTPS over HTTP. Be explicit about the port - // only if non-standard. Allow users to override full prefix in config. - // - if(_.isString(Config.contentServers.web.overrideUrlPrefix)) { - return `${Config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`; - } + buildUrl(pathAndQuery) { + // + // Create a URL such as + // https://l33t.codes:44512/ + |pathAndQuery| + // + // Prefer HTTPS over HTTP. Be explicit about the port + // only if non-standard. Allow users to override full prefix in config. + // + const config = Config(); + if(_.isString(config.contentServers.web.overrideUrlPrefix)) { + return `${config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`; + } - let schema; - let port; - if(Config.contentServers.web.https.enabled) { - schema = 'https://'; - port = (443 === Config.contentServers.web.https.port) ? - '' : - `:${Config.contentServers.web.https.port}`; - } else { - schema = 'http://'; - port = (80 === Config.contentServers.web.http.port) ? - '' : - `:${Config.contentServers.web.http.port}`; - } - - return `${schema}${Config.contentServers.web.domain}${port}${pathAndQuery}`; - } + let schema; + let port; + if(config.contentServers.web.https.enabled) { + schema = 'https://'; + port = (443 === config.contentServers.web.https.port) ? + '' : + `:${config.contentServers.web.https.port}`; + } else { + schema = 'http://'; + port = (80 === config.contentServers.web.http.port) ? + '' : + `:${config.contentServers.web.http.port}`; + } - isEnabled() { - return this.enableHttp || this.enableHttps; - } + return `${schema}${config.contentServers.web.domain}${port}${pathAndQuery}`; + } - createServer() { - if(this.enableHttp) { - this.httpServer = http.createServer( (req, resp) => this.routeRequest(req, resp) ); - } + isEnabled() { + return this.enableHttp || this.enableHttps; + } - if(this.enableHttps) { - const options = { - cert : fs.readFileSync(Config.contentServers.web.https.certPem), - key : fs.readFileSync(Config.contentServers.web.https.keyPem), - }; + createServer(cb) { + if(this.enableHttp) { + this.httpServer = http.createServer( (req, resp) => this.routeRequest(req, resp) ); + } - // additional options - Object.assign(options, Config.contentServers.web.https.options || {} ); + const config = Config(); + if(this.enableHttps) { + const options = { + cert : fs.readFileSync(config.contentServers.web.https.certPem), + key : fs.readFileSync(config.contentServers.web.https.keyPem), + }; - this.httpsServer = https.createServer(options, (req, resp) => this.routeRequest(req, resp) ); - } - } + // additional options + Object.assign(options, config.contentServers.web.https.options || {} ); - listen() { - let ok = true; + this.httpsServer = https.createServer(options, (req, resp) => this.routeRequest(req, resp) ); + } - [ 'http', 'https' ].forEach(service => { - const name = `${service}Server`; - if(this[name]) { - const port = parseInt(Config.contentServers.web[service].port); - if(isNaN(port)) { - ok = false; - return Log.warn( { port : Config.contentServers.web[service].port, server : ModuleInfo.name }, `Invalid port (${service})` ); - } - return this[name].listen(port); - } - }); + return cb(null); + } - return ok; - } + listen(cb) { + const config = Config(); + forEachSeries([ 'http', 'https' ], (service, nextService) => { + const name = `${service}Server`; + if(this[name]) { + const port = parseInt(config.contentServers.web[service].port); + if(isNaN(port)) { + Log.warn( { port : config.contentServers.web[service].port, server : ModuleInfo.name }, `Invalid port (${service})` ); + return nextService(Errors.Invalid(`Invalid port: ${config.contentServers.web[service].port}`)); + } - addRoute(route) { - route = new Route(route); + this[name].listen(port, config.contentServers.web[service].address, err => { + return nextService(err); + }); + } else { + return nextService(null); + } + }, + err => { + return cb(err); + }); + } - if(!route.isValid()) { - Log.warn( { route : route }, 'Cannot add route: missing or invalid required members' ); - return false; - } + addRoute(route) { + route = new Route(route); - const routeKey = route.getRouteKey(); - if(routeKey in this.routes) { - Log.warn( { route : route }, 'Cannot add route: duplicate method/path combination exists' ); - return false; - } + if(!route.isValid()) { + Log.warn( { route : route }, 'Cannot add route: missing or invalid required members' ); + return false; + } - this.routes[routeKey] = route; - return true; - } + const routeKey = route.getRouteKey(); + if(routeKey in this.routes) { + Log.warn( { route : route, routeKey : routeKey }, 'Cannot add route: duplicate method/path combination exists' ); + return false; + } - routeRequest(req, resp) { - const route = _.find(this.routes, r => r.matchesRequest(req) ); - return route ? route.handler(req, resp) : this.accessDenied(resp); - } + this.routes[routeKey] = route; + return true; + } - respondWithError(resp, code, bodyText, title) { - const customErrorPage = paths.join(Config.contentServers.web.staticRoot, `${code}.html`); + routeRequest(req, resp) { + const route = _.find(this.routes, r => r.matchesRequest(req) ); - fs.readFile(customErrorPage, 'utf8', (err, data) => { - resp.writeHead(code, { 'Content-Type' : 'text/html' } ); + if(!route && '/' === req.url) { + return this.routeIndex(req, resp); + } - if(err) { - return resp.end(` - - - - ${title} - - - -
-

${bodyText}

-
- - ` - ); - } + return route ? route.handler(req, resp) : this.accessDenied(resp); + } - return resp.end(data); - }); - } + respondWithError(resp, code, bodyText, title) { + const customErrorPage = paths.join(Config().contentServers.web.staticRoot, `${code}.html`); - accessDenied(resp) { - return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied'); - } + fs.readFile(customErrorPage, 'utf8', (err, data) => { + resp.writeHead(code, { 'Content-Type' : 'text/html' } ); - fileNotFound(resp) { - return this.respondWithError(resp, 404, 'File not found.', 'File Not Found'); - } + if(err) { + return resp.end(` + + + + ${title} + + + +
+

${bodyText}

+
+ + ` + ); + } - routeStaticFile(req, resp) { - const fileName = req.url.substr(req.url.indexOf('/', 1)); - const filePath = paths.join(Config.contentServers.web.staticRoot, fileName); - const self = this; + return resp.end(data); + }); + } - fs.stat(filePath, (err, stats) => { - if(err) { - return self.fileNotFound(resp); - } + accessDenied(resp) { + return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied'); + } - const headers = { - 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), - 'Content-Length' : stats.size, - }; + fileNotFound(resp) { + return this.respondWithError(resp, 404, 'File not found.', 'File Not Found'); + } - const readStream = fs.createReadStream(filePath); - resp.writeHead(200, headers); - return readStream.pipe(resp); - }); - } + routeIndex(req, resp) { + const filePath = paths.join(Config().contentServers.web.staticRoot, 'index.html'); - routeTemplateFilePage(templatePath, preprocessCallback, resp) { - const self = this; + return this.returnStaticPage(filePath, resp); + } - fs.readFile(templatePath, 'utf8', (err, templateData) => { - if(err) { - return self.fileNotFound(resp); - } + routeStaticFile(req, resp) { + const fileName = req.url.substr(req.url.indexOf('/', 1)); + const filePath = paths.join(Config().contentServers.web.staticRoot, fileName); - preprocessCallback(templateData, (err, finalPage, contentType) => { - if(err || !finalPage) { - return self.respondWithError(resp, 500, 'Internal Server Error.', 'Internal Server Error'); - } + return this.returnStaticPage(filePath, resp); + } - const headers = { - 'Content-Type' : contentType || mimeTypes.contentType('.html'), - 'Content-Length' : finalPage.length, - }; + returnStaticPage(filePath, resp) { + const self = this; - resp.writeHead(200, headers); - return resp.end(finalPage); - }); - }); - } + fs.stat(filePath, (err, stats) => { + if(err || !stats.isFile()) { + return self.fileNotFound(resp); + } + + const headers = { + 'Content-Type' : mimeTypes.contentType(paths.basename(filePath)) || mimeTypes.contentType('.bin'), + 'Content-Length' : stats.size, + }; + + const readStream = fs.createReadStream(filePath); + resp.writeHead(200, headers); + return readStream.pipe(resp); + }); + } + + routeTemplateFilePage(templatePath, preprocessCallback, resp) { + const self = this; + + fs.readFile(templatePath, 'utf8', (err, templateData) => { + if(err) { + return self.fileNotFound(resp); + } + + preprocessCallback(templateData, (err, finalPage, contentType) => { + if(err || !finalPage) { + return self.respondWithError(resp, 500, 'Internal Server Error.', 'Internal Server Error'); + } + + const headers = { + 'Content-Type' : contentType || mimeTypes.contentType('.html'), + 'Content-Length' : finalPage.length, + }; + + resp.writeHead(200, headers); + return resp.end(finalPage); + }); + }); + } }; diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js index cd9ca1a9..ddb1ebfd 100644 --- a/core/servers/login/ssh.js +++ b/core/servers/login/ssh.js @@ -1,269 +1,390 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('../../config.js').config; -const baseClient = require('../../client.js'); -const Log = require('../../logger.js').log; -const LoginServerModule = require('../../login_server_module.js'); -const userLogin = require('../../user_login.js').userLogin; -const enigVersion = require('../../../package.json').version; -const theme = require('../../theme.js'); -const stringFormat = require('../../string_format.js'); +// ENiGMA½ +const Config = require('../../config.js').get; +const baseClient = require('../../client.js'); +const Log = require('../../logger.js').log; +const LoginServerModule = require('../../login_server_module.js'); +const userLogin = require('../../user_login.js').userLogin; +const enigVersion = require('../../../package.json').version; +const theme = require('../../theme.js'); +const stringFormat = require('../../string_format.js'); +const { + Errors, + ErrorReasons +} = require('../../enig_error.js'); +const User = require('../../user.js'); +const UserProps = require('../../user_property.js'); -// deps -const ssh2 = require('ssh2'); -const fs = require('graceful-fs'); -const util = require('util'); -const _ = require('lodash'); -const assert = require('assert'); +// deps +const ssh2 = require('ssh2'); +const fs = require('graceful-fs'); +const util = require('util'); +const _ = require('lodash'); +const assert = require('assert'); const ModuleInfo = exports.moduleInfo = { - name : 'SSH', - desc : 'SSH Server', - author : 'NuSkooler', - isSecure : true, - packageName : 'codes.l33t.enigma.ssh.server', + name : 'SSH', + desc : 'SSH Server', + author : 'NuSkooler', + isSecure : true, + packageName : 'codes.l33t.enigma.ssh.server', }; function SSHClient(clientConn) { - baseClient.Client.apply(this, arguments); + baseClient.Client.apply(this, arguments); - // - // WARNING: Until we have emit 'ready', self.input, and self.output and - // not yet defined! - // + // + // WARNING: Until we have emit 'ready', self.input, and self.output and + // not yet defined! + // - const self = this; + const self = this; - let loginAttempts = 0; + clientConn.on('authentication', function authAttempt(ctx) { + const username = ctx.username || ''; + const config = Config(); + self.isNewUser = (config.users.newUserNames || []).indexOf(username) > -1; - clientConn.on('authentication', function authAttempt(ctx) { - const username = ctx.username || ''; - const password = ctx.password || ''; - - self.isNewUser = (Config.users.newUserNames || []).indexOf(username) > -1; + self.log.trace( { method : ctx.method, username : username, newUser : self.isNewUser }, 'SSH authentication attempt'); - self.log.trace( { method : ctx.method, username : username, newUser : self.isNewUser }, 'SSH authentication attempt'); + const safeContextReject = (param) => { + try { + return ctx.reject(param); + } catch(e) { + return; + } + }; - function terminateConnection() { - ctx.reject(); - clientConn.end(); - } + const terminateConnection = () => { + safeContextReject(); + return clientConn.end(); + }; - // - // If the system is open and |isNewUser| is true, the login - // sequence is hijacked in order to start the applicaiton process. - // - if(false === Config.general.closedSystem && self.isNewUser) { - return ctx.accept(); - } + // slow version to thwart brute force attacks + const slowTerminateConnection = () => { + setTimeout( () => { + return terminateConnection(); + }, 2000); + }; - if(username.length > 0 && password.length > 0) { - loginAttempts += 1; + const promptAndTerm = (msg, method = 'standard') => { + if('keyboard-interactive' === ctx.method) { + ctx.prompt(msg); + } + return 'slow' === method ? slowTerminateConnection() : terminateConnection(); + }; - userLogin(self, ctx.username, ctx.password, function authResult(err) { - if(err) { - if(err.existingConn) { - // :TODO: Can we display somthing here? - terminateConnection(); - return; - } else { - return ctx.reject(SSHClient.ValidAuthMethods); - } - } else { - ctx.accept(); - } - }); - } else { - if(-1 === SSHClient.ValidAuthMethods.indexOf(ctx.method)) { - return ctx.reject(SSHClient.ValidAuthMethods); - } + const accountAlreadyLoggedIn = (username) => { + return promptAndTerm(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`); + }; - if(0 === username.length) { - // :TODO: can we display something here? - return ctx.reject(); - } + const accountDisabled = (username) => { + return promptAndTerm(`${username} is disabled.\n(Press any key to continue)`); + }; - let interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false }; + const accountInactive = (username) => { + return promptAndTerm(`${username} is waiting for +op activation.\n(Press any key to continue)`); + }; - ctx.prompt(interactivePrompt, function retryPrompt(answers) { - loginAttempts += 1; + const accountLocked = (username) => { + return promptAndTerm(`${username} is locked.\n(Press any key to continue)`, 'slow'); + }; - userLogin(self, username, (answers[0] || ''), err => { - if(err) { - if(err.existingConn) { - // :TODO: can we display something here? - terminateConnection(); - } else { - if(loginAttempts >= Config.general.loginAttempts) { - terminateConnection(); - } else { - const artOpts = { - client : self, - name : 'SSHPMPT.ASC', - readSauce : false, - }; + const isSpecialHandleError = (err) => { + return [ ErrorReasons.AlreadyLoggedIn, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked ].includes(err.reasonCode); + }; - theme.getThemeArt(artOpts, (err, artInfo) => { - if(err) { - interactivePrompt.prompt = `Access denied\n${ctx.username}'s password: `; - } else { - const newUserNameList = _.has(Config, 'users.newUserNames') && Config.users.newUserNames.length > 0 ? - Config.users.newUserNames.map(newName => '"' + newName + '"').join(', ') : - '(No new user names enabled!)'; + const handleSpecialError = (err, username) => { + switch(err.reasonCode) { + case ErrorReasons.AlreadyLoggedIn : return accountAlreadyLoggedIn(username); + case ErrorReasons.Inactive : return accountInactive(username); + case ErrorReasons.Disabled : return accountDisabled(username); + case ErrorReasons.Locked : return accountLocked(username); + default : return terminateConnection(); + } + }; - interactivePrompt.prompt = `Access denied\n${stringFormat(artInfo.data, { newUserNames : newUserNameList })}\n${ctx.username}'s password'`; - } - return ctx.prompt(interactivePrompt, retryPrompt); - }); - } - } - } else { - 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); + } - this.updateTermInfo = function(info) { - // - // From ssh2 docs: - // "rows and cols override width and height when rows and cols are non-zero." - // - let termHeight; - let termWidth; + if(Errors.BadLogin().code === err.code) { + return slowTerminateConnection(); + } - if(info.rows > 0 && info.cols > 0) { - termHeight = info.rows; - termWidth = info.cols; - } else if(info.width > 0 && info.height > 0) { - termHeight = info.height; - termWidth = info.width; - } + return safeContextReject(SSHClient.ValidAuthMethods); + } - assert(_.isObject(self.term)); + 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(); + } + return ctx.accept(); + } + }; - // - // Note that if we fail here, connect.js attempts some non-standard - // queries/etc., and ultimately will default to 80x24 if all else fails - // - if(termHeight > 0 && termWidth > 0) { - self.term.termHeight = termHeight; - self.term.termWidth = termWidth; + const authKeyboardInteractive = () => { + if(0 === username.length) { + return safeContextReject(); + } - self.clearMciCache(); // term size changes = invalidate cache - } + const interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false }; - if(_.isString(info.term) && info.term.length > 0 && 'unknown' === self.term.termType) { - self.setTermType(info.term); - } - }; + ctx.prompt(interactivePrompt, function retryPrompt(answers) { + userLogin(self, username, (answers[0] || ''), err => { + if(err) { + if(isSpecialHandleError(err)) { + return handleSpecialError(err, username); + } - clientConn.once('ready', function clientReady() { - self.log.info('SSH authentication success'); + if(Errors.BadLogin().code === err.code) { + return slowTerminateConnection(); + } - clientConn.on('session', accept => { - - const session = accept(); + const artOpts = { + client : self, + name : 'SSHPMPT.ASC', + readSauce : false, + }; - session.on('pty', function pty(accept, reject, info) { - self.log.debug(info, 'SSH pty event'); + theme.getThemeArt(artOpts, (err, artInfo) => { + if(err) { + interactivePrompt.prompt = `Access denied\n${ctx.username}'s password: `; + } else { + const newUserNameList = _.has(config, 'users.newUserNames') && config.users.newUserNames.length > 0 ? + config.users.newUserNames.map(newName => '"' + newName + '"').join(', ') : + '(No new user names enabled!)'; - if(_.isFunction(accept)) { - accept(); - } + interactivePrompt.prompt = `Access denied\n${stringFormat(artInfo.data, { newUserNames : newUserNameList })}\n${ctx.username}'s password:`; + } + return ctx.prompt(interactivePrompt, retryPrompt); + }); + } else { + ctx.accept(); + } + }); + }); + }; - if(self.input) { // do we have I/O? - self.updateTermInfo(info); - } else { - self.cachedPtyInfo = info; - } - }); + // + // 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(); + } - session.on('shell', accept => { - self.log.debug('SSH shell event'); + switch(ctx.method) { + case 'password' : + return authWithPasswordOrPubKey(User.AuthFactor1Types.Password); + //return authWithPassword(); - const channel = accept(); + case 'publickey' : + return authWithPasswordOrPubKey(User.AuthFactor1Types.SSHPubKey); + //return authWithPubKey(); - self.setInputOutput(channel.stdin, channel.stdout); + case 'keyboard-interactive' : + return authKeyboardInteractive(); - channel.stdin.on('data', data => { - self.emit('data', data); - }); + default : + return safeContextReject(SSHClient.ValidAuthMethods); + } + }); - if(self.cachedPtyInfo) { - self.updateTermInfo(self.cachedPtyInfo); - delete self.cachedPtyInfo; - } + this.dataHandler = function(data) { + self.emit('data', data); + }; - // we're ready! - const firstMenu = self.isNewUser ? Config.loginServers.ssh.firstMenuNewUser : Config.loginServers.ssh.firstMenu; - self.emit('ready', { firstMenu : firstMenu } ); - }); + this.updateTermInfo = function(info) { + // + // From ssh2 docs: + // "rows and cols override width and height when rows and cols are non-zero." + // + let termHeight; + let termWidth; - session.on('window-change', (accept, reject, info) => { - self.log.debug(info, 'SSH window-change event'); - - self.updateTermInfo(info); - }); + if(info.rows > 0 && info.cols > 0) { + termHeight = info.rows; + termWidth = info.cols; + } else if(info.width > 0 && info.height > 0) { + termHeight = info.height; + termWidth = info.width; + } - }); - }); + assert(_.isObject(self.term)); - clientConn.on('end', () => { - self.emit('end'); // remove client connection/tracking - }); + // + // Note that if we fail here, connect.js attempts some non-standard + // queries/etc., and ultimately will default to 80x24 if all else fails + // + if(termHeight > 0 && termWidth > 0) { + self.term.termHeight = termHeight; + self.term.termWidth = termWidth; - clientConn.on('error', err => { - self.log.warn( { error : err.message, code : err.code }, 'SSH connection error'); - }); + self.clearMciCache(); // term size changes = invalidate cache + } + + if(_.isString(info.term) && info.term.length > 0 && 'unknown' === self.term.termType) { + self.setTermType(info.term); + } + }; + + clientConn.once('ready', function clientReady() { + self.log.info('SSH authentication success'); + + clientConn.on('session', accept => { + + const session = accept(); + + session.on('pty', function pty(accept, reject, info) { + self.log.debug(info, 'SSH pty event'); + + if(_.isFunction(accept)) { + accept(); + } + + if(self.input) { // do we have I/O? + self.updateTermInfo(info); + } else { + self.cachedTermInfo = info; + } + }); + + session.on('env', (accept, reject, info) => { + self.log.debug(info, 'SSH env event'); + + if(_.isFunction(accept)) { + accept(); + } + }); + + session.on('shell', accept => { + self.log.debug('SSH shell event'); + + const channel = accept(); + + self.setInputOutput(channel.stdin, channel.stdout); + + channel.stdin.on('data', self.dataHandler); + + if(self.cachedTermInfo) { + self.updateTermInfo(self.cachedTermInfo); + delete self.cachedTermInfo; + } + + // we're ready! + const firstMenu = self.isNewUser ? Config().loginServers.ssh.firstMenuNewUser : Config().loginServers.ssh.firstMenu; + self.emit('ready', { firstMenu : firstMenu } ); + }); + + session.on('window-change', (accept, reject, info) => { + self.log.debug(info, 'SSH window-change event'); + + if(self.input) { + self.updateTermInfo(info); + } else { + self.cachedTermInfo = info; + } + }); + + }); + }); + + clientConn.once('end', () => { + return self.emit('end'); // remove client connection/tracking + }); + + clientConn.on('error', err => { + self.log.warn( { error : err.message, code : err.code }, 'SSH connection error'); + }); + + this.disconnect = function() { + return clientConn.end(); + }; } util.inherits(SSHClient, baseClient.Client); -SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ]; +SSHClient.ValidAuthMethods = [ + 'password', + 'keyboard-interactive', + 'publickey', +]; exports.getModule = class SSHServerModule extends LoginServerModule { - constructor() { - super(); - } + constructor() { + super(); + } - createServer() { - const serverConf = { - hostKeys : [ - { - key : fs.readFileSync(Config.loginServers.ssh.privateKeyPem), - passphrase : Config.loginServers.ssh.privateKeyPass, - } - ], - ident : 'enigma-bbs-' + enigVersion + '-srv', - - // Note that sending 'banner' breaks at least EtherTerm! - debug : (sshDebugLine) => { - if(true === Config.loginServers.ssh.traceConnections) { - Log.trace(`SSH: ${sshDebugLine}`); - } - }, - }; + createServer(cb) { + const config = Config(); + if(true != config.loginServers.ssh.enabled) { + return cb(null); + } - this.server = ssh2.Server(serverConf); - this.server.on('connection', (conn, info) => { - Log.info(info, 'New SSH connection'); - this.handleNewClient(new SSHClient(conn), conn._sock, ModuleInfo); - }); - } + const serverConf = { + hostKeys : [ + { + key : fs.readFileSync(config.loginServers.ssh.privateKeyPem), + passphrase : config.loginServers.ssh.privateKeyPass, + } + ], + ident : 'enigma-bbs-' + enigVersion + '-srv', - listen() { - const port = parseInt(Config.loginServers.ssh.port); - if(isNaN(port)) { - Log.error( { server : ModuleInfo.name, port : Config.loginServers.ssh.port }, 'Cannot load server (invalid port)' ); - return false; - } + // Note that sending 'banner' breaks at least EtherTerm! - this.server.listen(port); - Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' ); - return true; - } + debug : (sshDebugLine) => { + if(true === config.loginServers.ssh.traceConnections) { + Log.trace(`SSH: ${sshDebugLine}`); + } + }, + algorithms : config.loginServers.ssh.algorithms, + }; + + // + // This is a terrible hack, and we should not have to do it; + // However, as of this writing, NetRunner and SyncTERM both + // fail to respond to OpenSSH keep-alive pings (keepalive@openssh.com) + // + ssh2.Server.KEEPALIVE_INTERVAL = 0; + + this.server = ssh2.Server(serverConf); + this.server.on('connection', (conn, info) => { + Log.info(info, 'New SSH connection'); + this.handleNewClient(new SSHClient(conn), conn._sock, ModuleInfo); + }); + + return cb(null); + } + + listen(cb) { + const config = Config(); + if(true != config.loginServers.ssh.enabled) { + return cb(null); + } + + const port = parseInt(config.loginServers.ssh.port); + if(isNaN(port)) { + Log.error( { server : ModuleInfo.name, port : config.loginServers.ssh.port }, 'Cannot load server (invalid port)' ); + return cb(Errors.Invalid(`Invalid port: ${config.loginServers.ssh.port}`)); + } + + this.server.listen(port, config.loginServers.ssh.address, err => { + if(!err) { + Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' ); + } + return cb(err); + }); + } }; diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index 4377f029..240ce06b 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -1,850 +1,273 @@ -/* jslint node: true */ -'use strict'; +// ENiGMA½ +const LoginServerModule = require('../../login_server_module'); +const { Client } = require('../../client'); +const Config = require('../../config').get; +const { log: Log } = require('../../logger'); -// ENiGMA½ -const baseClient = require('../../client.js'); -const Log = require('../../logger.js').log; -const LoginServerModule = require('../../login_server_module.js'); -const Config = require('../../config.js').config; -const EnigAssert = require('../../enigma_assert.js'); - -// deps -const net = require('net'); -const buffers = require('buffers'); -const binary = require('binary'); -const util = require('util'); - -//var debug = require('debug')('telnet'); +// deps +const net = require('net'); +const { + TelnetSocket, + TelnetSpec: { Options, Commands } +} = require('telnet-socket'); +const { inherits } = require('util'); const ModuleInfo = exports.moduleInfo = { - name : 'Telnet', - desc : 'Telnet Server', - author : 'NuSkooler', - isSecure : false, - packageName : 'codes.l33t.enigma.telnet.server', + name : 'Telnet', + desc : 'Telnet Server v2', + author : 'NuSkooler', + isSecure : false, + packageName : 'codes.l33t.enigma.telnet.server.v2', }; -exports.TelnetClient = TelnetClient; +class TelnetClient { + constructor(socket) { + Client.apply(this, socket, socket); -// -// Telnet Protocol Resources -// * http://pcmicro.com/netfoss/telnet.html -// * http://mud-dev.wikidot.com/telnet:negotiation -// + this.socket = new TelnetSocket(socket); + this.setInputOutput(this.socket, this.socket); -/* - TODO: - * Document COMMANDS -- add any missing - * Document OPTIONS -- add any missing - * Internally handle OPTIONS: - * Some should be emitted generically - * Some shoudl be handled internally -- denied, handled, etc. - * + // + // Wait up to 3s to hear about from our terminal type request + // then go ahead and move on... + // + setTimeout(() => { + this._clientReady(); + }, 3000); - * Allow term (ttype) to be set by environ sub negotiation + this.dataHandler = function(data) { + this.emit('data', data); + }.bind(this); - * Process terms in loop.... research needed + this.socket.on('data', this.dataHandler); - * Handle will/won't - * Handle do's, .. - * Some won't should close connection + this.socket.on('error', err => { + this._logDebug({ error : err.message }, 'Socket error'); + return this.emit('end'); + }); - * Options/Commands we don't understand shouldn't crash the server!! + this.socket.on('end', () => { + this.emit('end'); + }); + this.socket.on('command error', (command, err) => { + this._logDebug({ command, error : err.message }, 'Command error'); + }); -*/ + this.socket.on('DO', command => { + switch (command.option) { + // We've already stated we WILL do the following via + // the banner - some terminals will ask over and over + // if we respond to a DO with a WILL, so just don't + // do anything... + case Options.SGA : + case Options.ECHO : + case Options.TRANSMIT_BINARY : + break; -const COMMANDS = { - SE : 240, // End of Sub-Negotation Parameters - NOP : 241, // No Operation - DM : 242, // Data Mark - BRK : 243, // Break - IP : 244, // Interrupt Process - AO : 245, // Abort Output - AYT : 246, // Are You There? - EC : 247, // Erase Character - EL : 248, // Erase Line - GA : 249, // Go Ahead - SB : 250, // Start Sub-Negotiation Parameters - WILL : 251, // - WONT : 252, - DO : 253, - DONT : 254, - IAC : 255, // (Data Byte) + default : + return this.socket.command(Commands.WONT, command.option); + } + }); + + this.socket.on('DONT', command => { + this._logTrace(command, 'DONT'); + }); + + this.socket.on('WILL', command => { + switch (command.option) { + case Options.TTYPE : + return this.socket.sb.send.ttype(); + + case Options.NEW_ENVIRON : + return this.socket.sb.send.new_environ( + [ 'ROWS', 'COLUMNS', 'TERM', 'TERM_PROGRAM' ] + ); + + default : + break; + } + }); + + this.socket.on('WONT', command => { + return this._logTrace(command, 'WONT'); + }); + + this.socket.on('SB', command => { + switch (command.option) { + case Options.TTYPE : + this.setTermType(command.optionData.ttype); + return this._clientReady(); + + case Options.NEW_ENVIRON : + { + this._logDebug( + { vars : command.optionData.vars, userVars : command.optionData.userVars }, + 'New environment received' + ); + + // get a value from vars with fallback of user vars + const getValue = (name) => { + return command.optionData.vars.find(nv => nv.name === name) || + command.optionData.userVars.find(nv => nv.name === name); + }; + + if ('unknown' === this.term.termType) { + // allow from vars or user vars + const term = getValue('TERM') || getValue('TERM_PROGRAM'); + if (term) { + this.setTermType(term.value); + } + } + + if (0 === this.term.termHeight || 0 === this.term.termWidth) { + const updateTermSize = (what) => { + const value = parseInt(getValue(what)); + if (value) { + this.term[what === 'ROWS' ? 'termHeight' : 'termWidth'] = value; + this.clearMciCache(); + this._logDebug( + { [ what ] : value, source : 'NEW-ENVIRON' }, + 'Window size updated' + ); + } + }; + + updateTermSize('ROWS'); + updateTermSize('COLUMNS'); + } + } + break; + + case Options.NAWS : + { + const { width, height } = command.optionData; + + this.term.termWidth = width; + this.term.termHeight = height; + + if (width) { + this.term.env.COLUMNS = width; + } + + if (height) { + this.term.env.ROWS = height; + } + + this.clearMciCache(); + + this._logDebug( + { width, height, source : 'NAWS' }, + 'Windows size updated' + ); + } + break; + + default : + return this._logTrace(command, 'SB'); + } + }); + + this.socket.on('IP', command => { + this._logDebug(command, 'Interrupt Process (IP) - Ending session'); + return this.disconnect(); + }); + + this.socket.on('AYT', () => { + this.socket.write('\b'); + return this._logTrace(command, 'Are You There (AYT) - Replied'); + }); + } + + get dataPassthrough() { + return this.socket.passthrough; + } + + set dataPassthrough(passthrough) { + this.socket.passthrough = passthrough; + } + + disconnect() { + try { + return this.socket.rawSocket.end(); + } catch (e) { + // ignored + } + } + + banner() { + this.socket.dont.echo(); // don't echo characters + this.socket.will.echo(); // ...we'll echo them back + + this.socket.will.sga(); + this.socket.do.sga(); + + this.socket.do.transmit_binary(); + this.socket.will.transmit_binary(); + + this.socket.do.ttype(); + this.socket.do.naws(); + this.socket.do.new_environ(); + } + + _logTrace(info, msg) { + if (Config().loginServers.telnet.traceConnections) { + const log = this.log || Log; + return log.trace(info, `Telnet: ${msg}`); + } + } + + _logDebug(info, msg) { + const log = this.log || Log; + return log.debug(info, `Telnet: ${msg}`); + } + + _clientReady() { + if (this.clientReadyHandled) { + return; // already processed + } + + this.clientReadyHandled = true; + this.emit('ready', { firstMenu : Config().loginServers.telnet.firstMenu } ); + } }; -// -// Resources: -// * http://www.faqs.org/rfcs/rfc1572.html -// -const SB_COMMANDS = { - IS : 0, - SEND : 1, - INFO : 2, -}; - -// -// Telnet Options -// -// Resources -// * http://mars.netanya.ac.il/~unesco/cdrom/booklet/HTML/NETWORKING/node300.html -// -const OPTIONS = { - TRANSMIT_BINARY : 0, // http://tools.ietf.org/html/rfc856 - ECHO : 1, // http://tools.ietf.org/html/rfc857 - // RECONNECTION : 2 - SUPPRESS_GO_AHEAD : 3, // aka 'SGA': RFC 858 @ http://tools.ietf.org/html/rfc858 - //APPROX_MESSAGE_SIZE : 4 - STATUS : 5, // http://tools.ietf.org/html/rfc859 - TIMING_MARK : 6, // http://tools.ietf.org/html/rfc860 - //RC_TRANS_AND_ECHO : 7, // aka 'RCTE' @ http://www.rfc-base.org/txt/rfc-726.txt - //OUPUT_LINE_WIDTH : 8, - //OUTPUT_PAGE_SIZE : 9, // - //OUTPUT_CARRIAGE_RETURN_DISP : 10, // RFC 652 - //OUTPUT_HORIZ_TABSTOPS : 11, // RFC 653 - //OUTPUT_HORIZ_TAB_DISP : 12, // RFC 654 - //OUTPUT_FORMFEED_DISP : 13, // RFC 655 - //OUTPUT_VERT_TABSTOPS : 14, // RFC 656 - //OUTPUT_VERT_TAB_DISP : 15, // RFC 657 - //OUTPUT_LF_DISP : 16, // RFC 658 - //EXTENDED_ASCII : 17, // RFC 659 - //LOGOUT : 18, // RFC 727 - //BYTE_MACRO : 19, // RFC 753 - //DATA_ENTRY_TERMINAL : 20, // RFC 1043 - //SUPDUP : 21, // RFC 736 - //SUPDUP_OUTPUT : 22, // RFC 749 - SEND_LOCATION : 23, // RFC 779 - TERMINAL_TYPE : 24, // aka 'TTYPE': RFC 1091 @ http://tools.ietf.org/html/rfc1091 - //END_OF_RECORD : 25, // RFC 885 - //TACACS_USER_ID : 26, // RFC 927 - //OUTPUT_MARKING : 27, // RFC 933 - //TERMINCAL_LOCATION_NUMBER : 28, // RFC 946 - //TELNET_3270_REGIME : 29, // RFC 1041 - WINDOW_SIZE : 31, // aka 'NAWS': RFC 1073 @ http://tools.ietf.org/html/rfc1073 - TERMINAL_SPEED : 32, // RFC 1079 @ http://tools.ietf.org/html/rfc1079 - REMOTE_FLOW_CONTROL : 33, // RFC 1072 @ http://tools.ietf.org/html/rfc1372 - LINEMODE : 34, // RFC 1184 @ http://tools.ietf.org/html/rfc1184 - X_DISPLAY_LOCATION : 35, // aka 'XDISPLOC': RFC 1096 @ http://tools.ietf.org/html/rfc1096 - NEW_ENVIRONMENT_DEP : 36, // aka 'NEW-ENVIRON': RFC 1408 @ http://tools.ietf.org/html/rfc1408 (note: RFC 1572 is an update to this) - AUTHENTICATION : 37, // RFC 2941 @ http://tools.ietf.org/html/rfc2941 - ENCRYPT : 38, // RFC 2946 @ http://tools.ietf.org/html/rfc2946 - NEW_ENVIRONMENT : 39, // aka 'NEW-ENVIRON': RFC 1572 @ http://tools.ietf.org/html/rfc1572 (note: update to RFC 1408) - //TN3270E : 40, // RFC 2355 - //XAUTH : 41, - //CHARSET : 42, // RFC 2066 - //REMOTE_SERIAL_PORT : 43, - //COM_PORT_CONTROL : 44, // RFC 2217 - //SUPRESS_LOCAL_ECHO : 45, - //START_TLS : 46, - //KERMIT : 47, // RFC 2840 - //SEND_URL : 48, - //FORWARD_X : 49, - - //PRAGMA_LOGON : 138, - //SSPI_LOGON : 139, - //PRAGMA_HEARTBEAT : 140 - - ARE_YOU_THERE : 246, // aka 'AYT' RFC 854 @ https://tools.ietf.org/html/rfc854 - - EXTENDED_OPTIONS_LIST : 255, // RFC 861 (STD 32) -}; - -// Commands used within NEW_ENVIRONMENT[_DEP] -const NEW_ENVIRONMENT_COMMANDS = { - VAR : 0, - VALUE : 1, - ESC : 2, - USERVAR : 3, -}; - -const IAC_BUF = new Buffer([ COMMANDS.IAC ]); -const IAC_SE_BUF = new Buffer([ COMMANDS.IAC, COMMANDS.SE ]); - -const COMMAND_NAMES = Object.keys(COMMANDS).reduce(function(names, name) { - names[COMMANDS[name]] = name.toLowerCase(); - return names; -}, {}); - -const COMMAND_IMPLS = {}; -[ 'do', 'dont', 'will', 'wont', 'sb' ].forEach(function(command) { - const code = COMMANDS[command.toUpperCase()]; - COMMAND_IMPLS[code] = function(bufs, i, event) { - if(bufs.length < (i + 1)) { - return MORE_DATA_REQUIRED; - } - return parseOption(bufs, i, event); - }; -}); - -// :TODO: See TooTallNate's telnet.js: Handle COMMAND_IMPL for IAC in binary mode - -// Create option names such as 'transmit binary' -> OPTIONS.TRANSMIT_BINARY -const OPTION_NAMES = Object.keys(OPTIONS).reduce(function(names, name) { - names[OPTIONS[name]] = name.toLowerCase().replace(/_/g, ' '); - return names; -}, {}); - -function unknownOption(bufs, i, event) { - Log.warn( { bufs : bufs, i : i, event : event }, 'Unknown Telnet option'); -} - -const OPTION_IMPLS = {}; -// :TODO: fill in the rest... -OPTION_IMPLS.NO_ARGS = -OPTION_IMPLS[OPTIONS.ECHO] = -OPTION_IMPLS[OPTIONS.STATUS] = -OPTION_IMPLS[OPTIONS.LINEMODE] = -OPTION_IMPLS[OPTIONS.TRANSMIT_BINARY] = -OPTION_IMPLS[OPTIONS.AUTHENTICATION] = -OPTION_IMPLS[OPTIONS.TERMINAL_SPEED] = -OPTION_IMPLS[OPTIONS.REMOTE_FLOW_CONTROL] = -OPTION_IMPLS[OPTIONS.X_DISPLAY_LOCATION] = -OPTION_IMPLS[OPTIONS.SEND_LOCATION] = -OPTION_IMPLS[OPTIONS.ARE_YOU_THERE] = -OPTION_IMPLS[OPTIONS.SUPPRESS_GO_AHEAD] = function(bufs, i, event) { - event.buf = bufs.splice(0, i).toBuffer(); - return event; -}; - -OPTION_IMPLS[OPTIONS.TERMINAL_TYPE] = function(bufs, i, event) { - if(event.commandCode !== COMMANDS.SB) { - OPTION_IMPLS.NO_ARGS(bufs, i, event); - } else { - // We need 4 bytes header + data + IAC SE - if(bufs.length < 7) { - return MORE_DATA_REQUIRED; - } - - let end = bufs.indexOf(IAC_SE_BUF, 5); // look past header bytes - if(-1 === end) { - return MORE_DATA_REQUIRED; - } - - // eat up and process the header - let buf = bufs.splice(0, 4).toBuffer(); - binary.parse(buf) - .word8('iac1') - .word8('sb') - .word8('ttype') - .word8('is') - .tap(function(vars) { - EnigAssert(vars.iac1 === COMMANDS.IAC); - EnigAssert(vars.sb === COMMANDS.SB); - EnigAssert(vars.ttype === OPTIONS.TERMINAL_TYPE); - EnigAssert(vars.is === SB_COMMANDS.IS); - }); - - // eat up the rest - end -= 4; - buf = bufs.splice(0, end).toBuffer(); - - // - // From this point -> |end| is our ttype - // - // Look for trailing NULL(s). Clients such as NetRunner do this. - // If none is found, we take the entire buffer - // - let trimAt = 0; - for(; trimAt < buf.length; ++trimAt) { - if(0x00 === buf[trimAt]) { - break; - } - } - - event.ttype = buf.toString('ascii', 0, trimAt); - - // pop off the terminating IAC SE - bufs.splice(0, 2); - } - - return event; -}; - -OPTION_IMPLS[OPTIONS.WINDOW_SIZE] = function(bufs, i, event) { - if(event.commandCode !== COMMANDS.SB) { - OPTION_IMPLS.NO_ARGS(bufs, i, event); - } else { - // we need 9 bytes - if(bufs.length < 9) { - return MORE_DATA_REQUIRED; - } - - event.buf = bufs.splice(0, 9).toBuffer(); - binary.parse(event.buf) - .word8('iac1') - .word8('sb') - .word8('naws') - .word16bu('width') - .word16bu('height') - .word8('iac2') - .word8('se') - .tap(function(vars) { - EnigAssert(vars.iac1 == COMMANDS.IAC); - EnigAssert(vars.sb == COMMANDS.SB); - EnigAssert(vars.naws == OPTIONS.WINDOW_SIZE); - EnigAssert(vars.iac2 == COMMANDS.IAC); - EnigAssert(vars.se == COMMANDS.SE); - - event.cols = event.columns = event.width = vars.width; - event.rows = event.height = vars.height; - }); - } - return event; -}; - -// Build an array of delimiters for parsing NEW_ENVIRONMENT[_DEP] -const NEW_ENVIRONMENT_DELIMITERS = []; -Object.keys(NEW_ENVIRONMENT_COMMANDS).forEach(function onKey(k) { - NEW_ENVIRONMENT_DELIMITERS.push(NEW_ENVIRONMENT_COMMANDS[k]); -}); - -// Handle the deprecated RFC 1408 & the updated RFC 1572: -OPTION_IMPLS[OPTIONS.NEW_ENVIRONMENT_DEP] = -OPTION_IMPLS[OPTIONS.NEW_ENVIRONMENT] = function(bufs, i, event) { - if(event.commandCode !== COMMANDS.SB) { - OPTION_IMPLS.NO_ARGS(bufs, i, event); - } else { - // - // We need 4 bytes header + + IAC SE - // Many terminals send a empty list: - // IAC SB NEW-ENVIRON IS IAC SE - // - if(bufs.length < 6) { - return MORE_DATA_REQUIRED; - } - - let end = bufs.indexOf(IAC_SE_BUF, 4); // look past header bytes - if(-1 === end) { - return MORE_DATA_REQUIRED; - } - - // eat up and process the header - let buf = bufs.splice(0, 4).toBuffer(); - binary.parse(buf) - .word8('iac1') - .word8('sb') - .word8('newEnv') - .word8('isOrInfo') // initial=IS, updates=INFO - .tap(function(vars) { - EnigAssert(vars.iac1 === COMMANDS.IAC); - EnigAssert(vars.sb === COMMANDS.SB); - EnigAssert(vars.newEnv === OPTIONS.NEW_ENVIRONMENT || vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP); - EnigAssert(vars.isOrInfo === SB_COMMANDS.IS || vars.isOrInfo === SB_COMMANDS.INFO); - - event.type = vars.isOrInfo; - - if(vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP) { - // :TODO: bring all this into Telnet class - Log.log.warn('Handling deprecated RFC 1408 NEW-ENVIRON'); - } - }); - - // eat up the rest - end -= 4; - buf = bufs.splice(0, end).toBuffer(); - - // - // This part can become messy. The basic spec is: - // IAC SB NEW-ENVIRON IS type ... [ VALUE ... ] [ type ... [ VALUE ... ] [ ... ] ] IAC SE - // - // See RFC 1572 @ http://www.faqs.org/rfcs/rfc1572.html - // - // Start by splitting up the remaining buffer. Keep the delimiters - // as prefixes we can use for processing. - // - // :TODO: Currently not supporting ESCaped values (ESC + ). Probably not really in the wild, but we should be compliant - // :TODO: Could probably just convert this to use a regex & handle delims + escaped values... in any case, this is sloppy... - const params = []; - let p = 0; - let j; - let l; - for(j = 0, l = buf.length; j < l; ++j) { - if(NEW_ENVIRONMENT_DELIMITERS.indexOf(buf[j]) === -1) { - continue; - } - - params.push(buf.slice(p, j)); - p = j; - } - - // remainder - if(p < l) { - params.push(buf.slice(p, l)); - } - - let varName; - event.envVars = {}; - // :TODO: handle cases where a variable was present in a previous exchange, but missing here...e.g removed - for(j = 0; j < params.length; ++j) { - if(params[j].length < 2) { - continue; - } - - let cmd = params[j].readUInt8(); - if(cmd === NEW_ENVIRONMENT_COMMANDS.VAR || cmd === NEW_ENVIRONMENT_COMMANDS.USERVAR) { - varName = params[j].slice(1).toString('utf8'); // :TODO: what encoding should this really be? - } else { - event.envVars[varName] = params[j].slice(1).toString('utf8'); // :TODO: again, what encoding? - } - } - - // pop off remaining IAC SE - bufs.splice(0, 2); - } - - return event; -}; - -const MORE_DATA_REQUIRED = 0xfeedface; - -function parseBufs(bufs) { - EnigAssert(bufs.length >= 2); - EnigAssert(bufs.get(0) === COMMANDS.IAC); - return parseCommand(bufs, 1, {}); -} - -function parseCommand(bufs, i, event) { - const command = bufs.get(i); // :TODO: fix deprecation... [i] is not the same - event.commandCode = command; - event.command = COMMAND_NAMES[command]; - - const handler = COMMAND_IMPLS[command]; - if(handler) { - return handler(bufs, i + 1, event); - } else { - if(2 !== bufs.length) { - Log.warn( { bufsLength : bufs.length }, 'Expected bufs length of 2'); // expected: IAC + COMMAND - } - - event.buf = bufs.splice(0, 2).toBuffer(); - return event; - } -} - -function parseOption(bufs, i, event) { - const option = bufs.get(i); // :TODO: fix deprecation... [i] is not the same - event.optionCode = option; - event.option = OPTION_NAMES[option]; - - const handler = OPTION_IMPLS[option]; - return handler ? handler(bufs, i + 1, event) : unknownOption(bufs, i + 1, event); -} - - -function TelnetClient(input, output) { - baseClient.Client.apply(this, arguments); - - const self = this; - - let bufs = buffers(); - this.bufs = bufs; - - this.sentDont = {}; // DON'T's we've already sent - - this.setInputOutput(input, output); - - this.negotiationsComplete = false; // are we in the 'negotiation' phase? - this.didReady = false; // have we emit the 'ready' event? - - this.subNegotiationState = { - newEnvironRequested : false, - }; - - this.setTemporaryDirectDataHandler = function(handler) { - this.input.removeAllListeners('data'); - this.input.on('data', handler); - }; - - this.restoreDataHandler = function() { - this.input.removeAllListeners('data'); - this.input.on('data', this.dataHandler); - }; - - this.dataHandler = function(b) { - if(!Buffer.isBuffer(b)) { - EnigAssert(false, `Cannot push non-buffer ${typeof b}`); - return; - } - - bufs.push(b); - - let i; - while((i = bufs.indexOf(IAC_BUF)) >= 0) { - - // - // Some clients will send even IAC separate from data - // - if(bufs.length <= (i + 1)) { - i = MORE_DATA_REQUIRED; - break; - } - - EnigAssert(bufs.length > (i + 1)); - - if(i > 0) { - self.emit('data', bufs.splice(0, i).toBuffer()); - } - - i = parseBufs(bufs); - - if(MORE_DATA_REQUIRED === i) { - break; - } else if(i) { - if(i.option) { - self.emit(i.option, i); // "transmit binary", "echo", ... - } - - self.handleTelnetEvent(i); - - if(i.data) { - self.emit('data', i.data); - } - } - } - - if(MORE_DATA_REQUIRED !== i && bufs.length > 0) { - // - // Standard data payload. This can still be "non-user" data - // such as ANSI control, but we don't handle that here. - // - self.emit('data', bufs.splice(0).toBuffer()); - } - }; - - this.input.on('data', this.dataHandler); - - this.input.on('end', () => { - self.emit('end'); - }); - - this.input.on('error', err => { - this.connectionDebug( { err : err }, 'Socket error' ); - return self.emit('end'); - }); - - this.connectionTrace = (info, msg) => { - if(Config.loginServers.telnet.traceConnections) { - const logger = self.log || Log; - return logger.trace(info, `Telnet: ${msg}`); - } - }; - - this.connectionDebug = (info, msg) => { - const logger = self.log || Log; - return logger.debug(info, `Telnet: ${msg}`); - }; - - this.connectionWarn = (info, msg) => { - const logger = self.log || Log; - return logger.warn(info, `Telnet: ${msg}`); - }; -} - -util.inherits(TelnetClient, baseClient.Client); - -/////////////////////////////////////////////////////////////////////////////// -// Telnet Command/Option handling -/////////////////////////////////////////////////////////////////////////////// -TelnetClient.prototype.handleTelnetEvent = function(evt) { - - if(!evt.command) { - return this.connectionWarn( { evt : evt }, 'No command for event'); - } - - // handler name e.g. 'handleWontCommand' - const handlerName = `handle${evt.command.charAt(0).toUpperCase()}${evt.command.substr(1)}Command`; - - if(this[handlerName]) { - // specialized - this[handlerName](evt); - } else { - // generic-ish - this.handleMiscCommand(evt); - } -}; - -TelnetClient.prototype.handleWillCommand = function(evt) { - if('terminal type' === evt.option) { - // - // See RFC 1091 @ http://www.faqs.org/rfcs/rfc1091.html - // - this.requestTerminalType(); - } else if('new environment' === evt.option) { - // - // See RFC 1572 @ http://www.faqs.org/rfcs/rfc1572.html - // - this.requestNewEnvironment(); - } else { - // :TODO: temporary: - this.connectionTrace(evt, 'WILL'); - } -}; - -TelnetClient.prototype.handleWontCommand = function(evt) { - if(this.sentDont[evt.option]) { - return this.connectionTrace(evt, 'WONT - DON\'T already sent'); - } - - this.sentDont[evt.option] = true; - - if('new environment' === evt.option) { - this.dont.new_environment(); - } else { - this.connectionTrace(evt, 'WONT'); - } -}; - -TelnetClient.prototype.handleDoCommand = function(evt) { - // :TODO: handle the rest, e.g. echo nd the like - - if('linemode' === evt.option) { - // - // Client wants to enable linemode editing. Denied. - // - this.wont.linemode(); - } else if('encrypt' === evt.option) { - // - // Client wants to enable encryption. Denied. - // - this.wont.encrypt(); - } else { - // :TODO: temporary: - this.connectionTrace(evt, 'DO'); - } -}; - -TelnetClient.prototype.handleDontCommand = function(evt) { - this.connectionTrace(evt, 'DONT'); -}; - -TelnetClient.prototype.handleSbCommand = function(evt) { - const self = this; - - if('terminal type' === evt.option) { - // - // See RFC 1091 @ http://www.faqs.org/rfcs/rfc1091.html - // - // :TODO: According to RFC 1091 @ http://www.faqs.org/rfcs/rfc1091.html - // We should keep asking until we see a repeat. From there, determine the best type/etc. - self.setTermType(evt.ttype); - - self.negotiationsComplete = true; // :TODO: throw in a array of what we've taken care. Complete = array satisified or timeout - - if(!self.didReady) { - self.didReady = true; - self.emit('ready', { firstMenu : Config.loginServers.telnet.firstMenu } ); - } - } else if('new environment' === evt.option) { - // - // Handling is as follows: - // * Map 'TERM' -> 'termType' and only update if ours is 'unknown' - // * Map COLUMNS -> 'termWidth' and only update if ours is 0 - // * Map ROWS -> 'termHeight' and only update if ours is 0 - // * Add any new variables, ignore any existing - // - Object.keys(evt.envVars || {} ).forEach(function onEnv(name) { - if('TERM' === name && 'unknown' === self.term.termType) { - self.setTermType(evt.envVars[name]); - } else if('COLUMNS' === name && 0 === self.term.termWidth) { - self.term.termWidth = parseInt(evt.envVars[name]); - self.clearMciCache(); // term size changes = invalidate cache - self.connectionDebug({ termWidth : self.term.termWidth, source : 'NEW-ENVIRON'}, 'Window width updated'); - } else if('ROWS' === name && 0 === self.term.termHeight) { - self.term.termHeight = parseInt(evt.envVars[name]); - self.clearMciCache(); // term size changes = invalidate cache - self.connectionDebug({ termHeight : self.term.termHeight, source : 'NEW-ENVIRON'}, 'Window height updated'); - } else { - if(name in self.term.env) { - - EnigAssert( - SB_COMMANDS.INFO === evt.type || SB_COMMANDS.IS === evt.type, - 'Unexpected type: ' + evt.type - ); - - self.connectionWarn( - { varName : name, value : evt.envVars[name], existingValue : self.term.env[name] }, - 'Environment variable already exists' - ); - } else { - self.term.env[name] = evt.envVars[name]; - self.connectionDebug( { varName : name, value : evt.envVars[name] }, 'New environment variable' ); - } - } - }); - - } else if('window size' === evt.option) { - // - // Update termWidth & termHeight. - // Set LINES and COLUMNS environment variables as well. - // - self.term.termWidth = evt.width; - self.term.termHeight = evt.height; - - if(evt.width > 0) { - self.term.env.COLUMNS = evt.height; - } - - if(evt.height > 0) { - self.term.env.ROWS = evt.height; - } - - self.clearMciCache(); // term size changes = invalidate cache - - self.connectionDebug({ termWidth : evt.width , termHeight : evt.height, source : 'NAWS' }, 'Window size updated'); - } else { - self.connectionDebug(evt, 'SB'); - } -}; - -const IGNORED_COMMANDS = []; -[ COMMANDS.EL, COMMANDS.GA, COMMANDS.NOP, COMMANDS.DM, COMMANDS.BRK ].forEach(function onCommandCode(cc) { - IGNORED_COMMANDS.push(cc); -}); - - -TelnetClient.prototype.handleMiscCommand = function(evt) { - EnigAssert(evt.command !== 'undefined' && evt.command.length > 0); - - // - // See: - // * RFC 854 @ http://tools.ietf.org/html/rfc854 - // - if('ip' === evt.command) { - // Interrupt Process (IP) - this.log.debug('Interrupt Process (IP) - Ending'); - - this.input.end(); - } else if('ayt' === evt.command) { - this.output.write('\b'); - - this.log.debug('Are You There (AYT) - Replied "\\b"'); - } else if(IGNORED_COMMANDS.indexOf(evt.commandCode)) { - this.log.debug({ evt : evt }, 'Ignoring command'); - } else { - this.log.warn({ evt : evt }, 'Unknown command'); - } -}; - -TelnetClient.prototype.requestTerminalType = function() { - const buf = new Buffer( [ - COMMANDS.IAC, - COMMANDS.SB, - OPTIONS.TERMINAL_TYPE, - SB_COMMANDS.SEND, - COMMANDS.IAC, - COMMANDS.SE ]); - this.output.write(buf); -}; - -const WANTED_ENVIRONMENT_VAR_BUFS = [ - new Buffer( 'LINES' ), - new Buffer( 'COLUMNS' ), - new Buffer( 'TERM' ), - new Buffer( 'TERM_PROGRAM' ) -]; - -TelnetClient.prototype.requestNewEnvironment = function() { - - if(this.subNegotiationState.newEnvironRequested) { - this.log.debug('New environment already requested'); - return; - } - - const self = this; - - const bufs = buffers(); - bufs.push(new Buffer( [ - COMMANDS.IAC, - COMMANDS.SB, - OPTIONS.NEW_ENVIRONMENT, - SB_COMMANDS.SEND ] - )); - - for(let i = 0; i < WANTED_ENVIRONMENT_VAR_BUFS.length; ++i) { - bufs.push(new Buffer( [ NEW_ENVIRONMENT_COMMANDS.VAR ] ), WANTED_ENVIRONMENT_VAR_BUFS[i] ); - } - - bufs.push(new Buffer([ NEW_ENVIRONMENT_COMMANDS.USERVAR, COMMANDS.IAC, COMMANDS.SE ])); - - self.output.write(bufs.toBuffer()); - - this.subNegotiationState.newEnvironRequested = true; -}; - -TelnetClient.prototype.banner = function() { - this.will.echo(); - - this.will.suppress_go_ahead(); - this.do.suppress_go_ahead(); - - this.do.transmit_binary(); - this.will.transmit_binary(); - - this.do.terminal_type(); - - this.do.window_size(); - this.do.new_environment(); -}; - -function Command(command, client) { - this.command = COMMANDS[command.toUpperCase()]; - this.client = client; -} - -// Create Command objects with echo, transmit_binary, ... -Object.keys(OPTIONS).forEach(function(name) { - const code = OPTIONS[name]; - - Command.prototype[name.toLowerCase()] = function() { - const buf = new Buffer(3); - buf[0] = COMMANDS.IAC; - buf[1] = this.command; - buf[2] = code; - return this.client.output.write(buf); - }; -}); - -// Create do, dont, etc. methods on Client -['do', 'dont', 'will', 'wont'].forEach(function(command) { - const get = function() { - return new Command(command, this); - }; - - Object.defineProperty(TelnetClient.prototype, command, { - get : get, - enumerable : true, - configurable : true - }); -}); +inherits(TelnetClient, Client); exports.getModule = class TelnetServerModule extends LoginServerModule { - constructor() { - super(); - } + constructor() { + super(); + } - createServer() { - this.server = net.createServer( sock => { - const client = new TelnetClient(sock, sock); + createServer(cb) { + this.server = net.createServer( socket => { + const client = new TelnetClient(socket); + client.banner(); // start negotiations + this.handleNewClient(client, socket, ModuleInfo); + }); - client.banner(); + this.server.on('error', err => { + Log.info( { error : err.message }, 'Telnet server error'); + }); - this.handleNewClient(client, sock, ModuleInfo); - }); + return cb(null); + } - this.server.on('error', err => { - Log.info( { error : err.message }, 'Telnet server error'); - }); - } + listen(cb) { + const config = Config(); + const port = parseInt(config.loginServers.telnet.port); + if(isNaN(port)) { + Log.error( { server : ModuleInfo.name, port : config.loginServers.telnet.port }, 'Cannot load server (invalid port)' ); + return cb(Errors.Invalid(`Invalid port: ${config.loginServers.telnet.port}`)); + } - listen() { - const port = parseInt(Config.loginServers.telnet.port); - if(isNaN(port)) { - Log.error( { server : ModuleInfo.name, port : Config.loginServers.telnet.port }, 'Cannot load server (invalid port)' ); - return false; - } - - this.server.listen(port); - Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' ); - return true; - } + this.server.listen(port, config.loginServers.telnet.address, err => { + if(!err) { + Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' ); + } + return cb(err); + }); + } }; + +exports.TelnetClient = TelnetClient; // WebSockets is a wrapper on top of this diff --git a/core/servers/login/websocket.js b/core/servers/login/websocket.js index 378af7ef..42a22723 100644 --- a/core/servers/login/websocket.js +++ b/core/servers/login/websocket.js @@ -1,205 +1,222 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('../../config.js').config; -const TelnetClient = require('./telnet.js').TelnetClient; -const Log = require('../../logger.js').log; -const LoginServerModule = require('../../login_server_module.js'); +// ENiGMA½ +const Config = require('../../config.js').get; +const TelnetClient = require('./telnet.js').TelnetClient; +const Log = require('../../logger.js').log; +const LoginServerModule = require('../../login_server_module.js'); +const { Errors } = require('../../enig_error.js'); -// deps -const _ = require('lodash'); -const WebSocketServer = require('ws').Server; -const http = require('http'); -const https = require('https'); -const fs = require('graceful-fs'); -const Writable = require('stream'); +// deps +const _ = require('lodash'); +const WebSocketServer = require('ws').Server; +const http = require('http'); +const https = require('https'); +const fs = require('graceful-fs'); +const Writable = require('stream'); +const { Duplex } = require('stream'); +const forEachSeries = require('async/forEachSeries'); const ModuleInfo = exports.moduleInfo = { - name : 'WebSocket', - desc : 'WebSocket Server', - author : 'NuSkooler', - packageName : 'codes.l33t.enigma.websocket.server', + name : 'WebSocket', + desc : 'WebSocket Server', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.websocket.server', }; -function WebSocketClient(ws, req, serverType) { +class WebSocketClient extends TelnetClient { + constructor(ws, req, serverType) { + // allow WebSocket to act like a Duplex (socket) + const wsDuplex = new class WebSocketDuplex extends Duplex { + constructor(ws) { + super(); + this.ws = ws; - Object.defineProperty(this, 'isSecure', { - get : () => ('secure' === serverType || true === this.proxied) ? true : false, - }); + this.ws.on('close', err => this.emit('close', err)); + this.ws.on('error', err => this.emit('error', err)); + this.ws.on('message', data => this._data(data)); + } - const self = this; + setClient(client, httpRequest) { + this.client = client; - // - // This bridge makes accessible various calls that client sub classes - // want to access on I/O socket - // - this.socketBridge = new class SocketBridge extends Writable { - constructor(ws) { - super(); - this.ws = ws; - } + // Support X-Forwarded-For and X-Real-IP headers for proxied connections + this.resolvedRemoteAddress = + (this.client.proxied && (httpRequest.headers['x-forwarded-for'] || httpRequest.headers['x-real-ip'])) || + httpRequest.connection.remoteAddress; + } - end() { - return ws.close(); - } + get remoteAddress() { + return this.resolvedRemoteAddress; + } - write(data, cb) { - cb = cb || ( () => { /* eat it up */} ); // handle data writes after close + _write(data, encoding, cb) { + cb = cb || ( () => { /* eat it up */} ); // handle data writes after close + return this.ws.send(data, { binary : true }, cb); + } - return this.ws.send(data, { binary : true }, cb); - } + _read() { + // dummy + } - // we need to fake some streaming work - unpipe() { - Log.trace('WebSocket SocketBridge unpipe()'); - } + _data(data) { + this.push(data); + } + }(ws); - resume() { - Log.trace('WebSocket SocketBridge resume()'); - } + super(wsDuplex); + wsDuplex.setClient(this, req); - get remoteAddress() { - // Support X-Forwarded-For and X-Real-IP headers for proxied connections - return (self.proxied && (req.headers['x-forwarded-for'] || req.headers['x-real-ip'])) || req.connection.remoteAddress; - } - }(ws); + // fudge remoteAddress on socket, which is now TelnetSocket + this.socket.remoteAddress = wsDuplex.remoteAddress; - ws.on('message', data => { - this.socketBridge.emit('data', data); - }); + wsDuplex.on('close', () => { + // we'll remove client connection which will in turn end() via our SocketBridge above + return this.emit('end'); + }); - ws.on('close', () => { - // we'll remove client connection which will in turn end() via our SocketBridge above - return this.emit('end'); - }); + this.serverType = serverType; - // - // Montior connection status with ping/pong - // - ws.on('pong', () => { - Log.trace(`Pong from ${this.socketBridge.remoteAddress}`); - ws.isConnectionAlive = true; - }); + // + // Monitor connection status with ping/pong + // + ws.on('pong', () => { + Log.trace(`Pong from ${wsDuplex.remoteAddress}`); + ws.isConnectionAlive = true; + }); - TelnetClient.call(this, this.socketBridge, this.socketBridge); + Log.trace( { headers : req.headers }, 'WebSocket connection headers' ); - Log.trace( { headers : req.headers }, 'WebSocket connection headers' ); + // + // If the config allows it, look for 'x-forwarded-proto' as "https" + // to override |isSecure| + // + if(true === _.get(Config(), 'loginServers.webSocket.proxied') && + 'https' === req.headers['x-forwarded-proto']) + { + Log.debug(`Assuming secure connection due to X-Forwarded-Proto of "${req.headers['x-forwarded-proto']}"`); + this.proxied = true; + } else { + this.proxied = false; + } - // - // If the config allows it, look for 'x-forwarded-proto' as "https" - // to override |isSecure| - // - if(true === _.get(Config, 'loginServers.webSocket.proxied') && - 'https' === req.headers['x-forwarded-proto']) - { - Log.debug(`Assuming secure connection due to X-Forwarded-Proto of "${req.headers['x-forwarded-proto']}"`); - this.proxied = true; - } else { - this.proxied = false; - } + // start handshake process + this.banner(); + } - // start handshake process - this.banner(); + get isSecure() { + return ('secure' === this.serverType || true === this.proxied) ? true : false; + } } -require('util').inherits(WebSocketClient, TelnetClient); - const WSS_SERVER_TYPES = [ 'insecure', 'secure' ]; exports.getModule = class WebSocketLoginServer extends LoginServerModule { - constructor() { - super(); - } + constructor() { + super(); + } - createServer() { - // - // We will actually create up to two servers: - // * insecure websocket (ws://) - // * secure (tls) websocket (wss://) - // - const config = _.get(Config, 'loginServers.webSocket') || { enabled : false }; - if(!config || true !== config.enabled || !(config.port || config.securePort)) { - return; - } + createServer(cb) { + // + // We will actually create up to two servers: + // * insecure websocket (ws://) + // * secure (tls) websocket (wss://) + // + const config = _.get(Config(), 'loginServers.webSocket'); + if(!_.isObject(config)) { + return cb(null); + } - if(config.port) { - const httpServer = http.createServer( (req, resp) => { - // dummy handler - resp.writeHead(200); - return resp.end('ENiGMA½ BBS WebSocket Server!'); - }); + const wsPort = _.get(config, 'ws.port'); + const wssPort = _.get(config, 'wss.port'); - this.insecure = { - httpServer : httpServer, - wsServer : new WebSocketServer( { server : httpServer } ), - }; - } + if(true === _.get(config, 'ws.enabled') && _.isNumber(wsPort)) { + const httpServer = http.createServer( (req, resp) => { + // dummy handler + resp.writeHead(200); + return resp.end('ENiGMA½ BBS WebSocket Server!'); + }); - if(config.securePort) { - const httpServer = https.createServer({ - key : fs.readFileSync(Config.loginServers.webSocket.keyPem), - cert : fs.readFileSync(Config.loginServers.webSocket.certPem), - }); + this.insecure = { + httpServer : httpServer, + wsServer : new WebSocketServer( { server : httpServer } ), + }; + } - this.secure = { - httpServer : httpServer, - wsServer : new WebSocketServer( { server : httpServer } ), - }; - } - } + if(_.isObject(config, 'wss') && true === _.get(config, 'wss.enabled') && _.isNumber(wssPort)) { + const httpServer = https.createServer({ + key : fs.readFileSync(config.wss.keyPem), + cert : fs.readFileSync(config.wss.certPem), + }); - listen() { - WSS_SERVER_TYPES.forEach(serverType => { - const server = this[serverType]; - if(!server) { - return; - } + this.secure = { + httpServer : httpServer, + wsServer : new WebSocketServer( { server : httpServer } ), + }; + } - const serverName = `${ModuleInfo.name} (${serverType})`; - const port = parseInt(_.get(Config, [ 'loginServers', 'webSocket', 'secure' === serverType ? 'securePort' : 'port' ] )); + return cb(null); + } - if(isNaN(port)) { - Log.error( { server : serverName, port : port }, 'Cannot load server (invalid port)' ); - return; - } + listen(cb) { + // + // Send pings every 30s + // + setInterval( () => { + WSS_SERVER_TYPES.forEach(serverType => { + if(this[serverType]) { + this[serverType].wsServer.clients.forEach(ws => { + if(false === ws.isConnectionAlive) { + Log.debug('WebSocket connection seems inactive. Terminating.'); + return ws.terminate(); + } - server.httpServer.listen(port); + ws.isConnectionAlive = false; // pong will reset this - server.wsServer.on('connection', (ws, req) => { - const webSocketClient = new WebSocketClient(ws, req, serverType); - this.handleNewClient(webSocketClient, webSocketClient.socketBridge, ModuleInfo); - }); + Log.trace('Ping to remote WebSocket client'); + try { + ws.ping('', false); // false=don't mask + } catch(e) { // don't barf on closing state + /* nothing */ + } + }); + } + }); + }, 30000); - Log.info( { server : serverName, port : port }, 'Listening for connections' ); - }); + forEachSeries(WSS_SERVER_TYPES, (serverType, nextServerType) => { + const server = this[serverType]; + if(!server) { + return nextServerType(null); + } - // - // Send pings every 30s - // - setInterval( () => { - WSS_SERVER_TYPES.forEach(serverType => { - if(this[serverType]) { - this[serverType].wsServer.clients.forEach(ws => { - if(false === ws.isConnectionAlive) { - Log.debug('WebSocket connection seems inactive. Terminating.'); - return ws.terminate(); - } + const serverName = `${ModuleInfo.name} (${serverType})`; + const conf = _.get(Config(), [ 'loginServers', 'webSocket', 'secure' === serverType ? 'wss' : 'ws' ] ); + const confPort = conf.port; + const port = parseInt(confPort); - ws.isConnectionAlive = false; // pong will reset this - - Log.trace('Ping to remote WebSocket client'); - return ws.ping('', false, true); - }); - } - }); - }, 30000); + if(isNaN(port)) { + Log.error( { server : serverName, port : confPort }, 'Cannot load server (invalid port)' ); + return nextServerType(Errors.Invalid(`Invalid port: ${confPort}`)); + } - return true; - } + server.httpServer.listen(port, conf.address, err => { + if(err) { + return nextServerType(err); + } - webSocketConnection(conn) { - const webSocketClient = new WebSocketClient(conn); - this.handleNewClient(webSocketClient, webSocketClient.socketShim, ModuleInfo); - } + server.wsServer.on('connection', (ws, req) => { + const webSocketClient = new WebSocketClient(ws, req, serverType); + this.handleNewClient(webSocketClient, webSocketClient.socket, ModuleInfo); + }); + + Log.info( { server : serverName, port : port }, 'Listening for connections' ); + return nextServerType(null); + }); + }, + err => { + cb(err); + }); + } }; diff --git a/core/set_newscan_date.js b/core/set_newscan_date.js new file mode 100644 index 00000000..7713f647 --- /dev/null +++ b/core/set_newscan_date.js @@ -0,0 +1,260 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const Errors = require('./enig_error.js').Errors; +const FileEntry = require('./file_entry.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const { getAvailableFileAreaTags } = require('./file_base_area.js'); +const { + getSortedAvailMessageConferences, + getSortedAvailMessageAreasByConfTag, + updateMessageAreaLastReadId, + getMessageIdNewerThanTimestampByArea +} = require('./message_area.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); +const moment = require('moment'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Set New Scan Date', + desc : 'Sets new scan date for applicable scans', + author : 'NuSkooler', +}; + +const MciViewIds = { + main : { + scanDate : 1, + targetSelection : 2, + } +}; + +// :TODO: for messages, we could insert "conf - all areas" into targets, and allow such + +exports.getModule = class SetNewScanDate extends MenuModule { + constructor(options) { + super(options); + + const config = this.menuConfig.config; + + this.target = config.target || 'message'; + this.scanDateFormat = config.scanDateFormat || 'YYYYMMDD'; + + this.menuMethods = { + scanDateSubmit : (formData, extraArgs, cb) => { + let scanDate = _.get(formData, 'value.scanDate'); + if(!scanDate) { + return cb(Errors.MissingParam('"scanDate" missing from form data')); + } + + scanDate = moment(scanDate, this.scanDateFormat); + if(!scanDate.isValid()) { + return cb(Errors.Invalid(`"${_.get(formData, 'value.scanDate')}" is not a valid date`)); + } + + const targetSelection = _.get(formData, 'value.targetSelection'); // may be undefined if N/A + + this[`setNewScanDateFor${_.capitalize(this.target)}Base`](targetSelection, scanDate, () => { + return this.prevMenu(cb); + }); + }, + }; + } + + setNewScanDateForMessageBase(targetSelection, scanDate, cb) { + const target = this.targetSelections[targetSelection]; + if(!target) { + return cb(Errors.UnexpectedState('Unable to get target in which to set new scan')); + } + + // selected area, or all of 'em + let updateAreaTags; + if('' === target.area.areaTag) { + updateAreaTags = this.targetSelections + .map( targetSelection => targetSelection.area.areaTag ) + .filter( areaTag => areaTag ); // remove the blank 'all' entry + } else { + updateAreaTags = [ target.area.areaTag ]; + } + + async.each(updateAreaTags, (areaTag, nextAreaTag) => { + getMessageIdNewerThanTimestampByArea(areaTag, scanDate, (err, messageId) => { + if(err) { + return nextAreaTag(err); + } + + if(!messageId) { + return nextAreaTag(null); // nothing to do + } + + messageId = Math.max(messageId - 1, 0); + + return updateMessageAreaLastReadId( + this.client.user.userId, + areaTag, + messageId, + true, // allowOlder + nextAreaTag + ); + }); + }, err => { + return cb(err); + }); + } + + setNewScanDateForFileBase(targetSelection, scanDate, cb) { + // + // ENiGMA doesn't currently have the concept of per-area + // scan pointers for users, so we use all areas avail + // to the user. + // + const filterCriteria = { + areaTag : getAvailableFileAreaTags(this.client), + newerThanTimestamp : scanDate, + limit : 1, + orderBy : 'upload_timestamp', + order : 'ascending', + }; + + FileEntry.findFiles(filterCriteria, (err, fileIds) => { + if(err) { + return cb(err); + } + + if(!fileIds || 0 === fileIds.length) { + // nothing to do + return cb(null); + } + + const pointerFileId = Math.max(fileIds[0] - 1, 0); + + return FileBaseFilters.setFileBaseLastViewedFileIdForUser( + this.client.user, + pointerFileId, + true, // allowOlder + cb + ); + }); + } + + loadAvailMessageBaseSelections(cb) { + // + // Create an array of objects with conf/area information per entry, + // sorted naturally or via the 'sort' member in config + // + const selections = []; + getSortedAvailMessageConferences(this.client).forEach(conf => { + getSortedAvailMessageAreasByConfTag(conf.confTag, { client : this.client } ).forEach(area => { + selections.push({ + conf : { + confTag : conf.confTag, + text : conf.conf.name, // standard + name : conf.conf.name, + desc : conf.conf.desc, + }, + area : { + areaTag : area.areaTag, + text : area.area.name, // standard + name : area.area.name, + desc : area.area.desc, + } + }); + }); + }); + + selections.unshift({ + conf : { + confTag : '', + text : 'All conferences', + name : 'All conferences', + desc : 'All conferences', + }, + area : { + areaTag : '', + text : 'All areas', + name : 'All areas', + desc : 'All areas', + } + }); + + // Find current conf/area & move it directly under "All" + const currConfTag = this.client.user.properties[UserProps.MessageConfTag]; + const currAreaTag = this.client.user.properties[UserProps.MessageAreaTag]; + if(currConfTag && currAreaTag) { + const confAreaIndex = selections.findIndex( confArea => { + return confArea.conf.confTag === currConfTag && confArea.area.areaTag === currAreaTag; + }); + + if(confAreaIndex > -1) { + selections.splice(1, 0, selections.splice(confAreaIndex, 1)[0]); + } + } + + this.targetSelections = selections; + + return cb(null); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.addViewController( 'main', new ViewController( { client : this.client } ) ); + + async.series( + [ + function validateConfig(callback) { + if(![ 'message', 'file' ].includes(self.target)) { + return callback(Errors.Invalid(`Invalid "target" in config: ${self.target}`)); + } + // :TOD0: validate scanDateFormat + return callback(null); + }, + function loadFromConfig(callback) { + return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function loadAvailSelections(callback) { + switch(self.target) { + case 'message' : + return self.loadAvailMessageBaseSelections(callback); + + default : + return callback(null); + } + }, + function populateForm(callback) { + const today = moment(); + + const scanDateView = vc.getView(MciViewIds.main.scanDate); + + // :TODO: MaskTextEditView needs some love: If setText() with input that matches the mask, we should ignore the non-mask chars! Hack in place for now + const scanDateFormat = self.scanDateFormat.replace(/[/\-. ]/g, ''); + scanDateView.setText(today.format(scanDateFormat)); + + if('message' === self.target) { + const targetSelectionView = vc.getView(MciViewIds.main.targetSelection); + + targetSelectionView.setItems(self.targetSelections); + targetSelectionView.setFocusItemIndex(0); + } + + self.viewControllers.main.resetInitialFocus(); + //vc.switchFocus(MciViewIds.main.scanDate); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } +}; diff --git a/core/show_art.js b/core/show_art.js new file mode 100644 index 00000000..7e53ca60 --- /dev/null +++ b/core/show_art.js @@ -0,0 +1,210 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const Errors = require('../core/enig_error.js').Errors; +const ANSI = require('./ansi_term.js'); +const Config = require('./config.js').get; +const { + getMessageAreaByTag +} = require('./message_area.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Show Art', + desc : 'Module for more advanced methods of displaying art', + author : 'NuSkooler', +}; + +exports.getModule = class ShowArtModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.config.method = this.config.method || 'random'; + this.config.optional = _.get(this.config, 'optional', true); + } + + initSequence() { + const self = this; + + async.series( + [ + function before(callback) { + return self.beforeArt(callback); + }, + function showArt(callback) { + // + // How we show art depends on our configuration + // + let handler = { + extraArgs : self.showByExtraArgs, + sequence : self.showBySequence, + random : self.showByRandom, + fileBaseArea : self.showByFileBaseArea, + messageConf : self.showByMessageConf, + messageArea : self.showByMessageArea, + }[self.config.method] || self.showRandomArt; + + handler = handler.bind(self); + + return handler(callback); + } + ], + err => { + if(err && !self.config.optional) { + self.client.log.warn('Error during init sequence', { error : err.message } ); + return self.prevMenu( () => { /* dummy */ } ); + } + + self.finishedLoading(); + return self.autoNextMenu( () => { /* dummy */ } ); + } + ); + } + + 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); + } + const options = { + pause : this.shouldPause(), + desc : 'extraArgs', + }; + return this.displaySingleArtWithOptions(artSpec, options, cb); + }); + } + + showBySequence(cb) { + return cb(null); + } + + showByRandom(cb) { + return cb(null); + } + + showByFileBaseArea(cb) { + this.getArtKeyValue('areaTag', (err, key) => { + if(err) { + return cb(err); + } + return this.displaySingleArtByConfigPath( [ 'fileBase', 'areas', key, 'art' ], cb); + }); + } + + showByMessageConf(cb) { + this.getArtKeyValue('confTag', (err, key) => { + if(err) { + return cb(err); + } + return this.displaySingleArtByConfigPath( [ 'messageConferences', key, 'art' ], cb); + }); + } + + showByMessageArea(cb) { + this.getArtKeyValue('areaTag', (err, key) => { + if(err) { + return cb(err); + } + + const area = getMessageAreaByTag(key); + if(!area) { + return cb(Errors.DoesNotExist(`No area by areaTag ${key} found`)); + } + return cb(null); // :TODO: REMOVE ME --- currently NYI + }); + } + + displaySingleArtByConfigPath(configPath, cb) { + const desc = configPath.join('.'); + const artSpec = _.get(Config(), configPath); + if(!artSpec) { + return cb(Errors.MissingConfig(`No art defined at path ${desc}`)); + } + const options = { + desc, + pause : this.shouldPause(), + }; + return this.displaySingleArtWithOptions(artSpec, options, cb); + } + + getArtKeyValue(defaultKey, cb) { + const key = this.config.key || defaultKey; + if(!_.isString(key)) { + return cb(Errors.MissingConfig('Config option "key" is required for method "extraArgs"')); + } + + const path = key.split('.'); + const artKey = _.get(this.config, [ 'extraArgs' ].concat(path) ); + if(!_.isString(artKey)) { + return cb(Errors.MissingParam(`Invalid or missing "extraArgs.${key}" value`)); + } + + return cb(null, artKey); + } + + displaySingleArtWithOptions(artSpec, options, cb) { + const self = this; + async.waterfall( + [ + function art(callback) { + // :TODO: we really need a way to supply an explicit path to look in, e.g. general/area_art/ + self.displayAsset( + artSpec, + self.menuConfig.config, + (err, artData) => { + if(err) { + return callback(err); + } + const mciData = { menu : artData.mciMap }; + return callback(null, mciData); + } + ); + }, + function recordCursorPosition(mciData, callback) { + if(!options.pause) { + return callback(null, mciData, null); // cursor position not needed + } + + self.client.once('cursor position report', pos => { + const pausePosition = { row : pos[0], col : 1 }; + return callback(null, mciData, pausePosition); + }); + + self.client.term.rawWrite(ANSI.queryPos()); + }, + function afterArtDisplayed(mciData, pausePosition, callback) { + self.mciReady(mciData, err => { + return callback(err, pausePosition); + }); + }, + function displayPauseIfRequested(pausePosition, callback) { + if(!options.pause) { + return callback(null); + } + return self.pausePrompt(pausePosition, callback); + }, + ], + err => { + if(err) { + self.client.log.warn( { artSpec, error : err.message }, `Failed to display "${options.desc}" art`); + } + return cb(err); + } + ); + } +}; diff --git a/core/spinner_menu_view.js b/core/spinner_menu_view.js index 65ac10af..9547c9b8 100644 --- a/core/spinner_menu_view.js +++ b/core/spinner_menu_view.js @@ -1,114 +1,114 @@ /* jslint node: true */ 'use strict'; -var MenuView = require('./menu_view.js').MenuView; -var ansi = require('./ansi_term.js'); -var strUtil = require('./string_util.js'); +const MenuView = require('./menu_view.js').MenuView; +const ansi = require('./ansi_term.js'); +const strUtil = require('./string_util.js'); +const { pipeToAnsi } = require('./color_codes.js'); +const formatString = require('./string_format'); -var util = require('util'); -var assert = require('assert'); -var _ = require('lodash'); +const util = require('util'); +const assert = require('assert'); +const _ = require('lodash'); -exports.SpinnerMenuView = SpinnerMenuView; +exports.SpinnerMenuView = SpinnerMenuView; function SpinnerMenuView(options) { - options.justify = options.justify || 'center'; - options.cursor = options.cursor || 'hide'; + options.justify = options.justify || 'left'; + options.cursor = options.cursor || 'hide'; - MenuView.call(this, options); - - var self = this; + MenuView.call(this, options); - /* - this.cachePositions = function() { - self.positionCacheExpired = false; - }; - */ + this.initDefaultWidth(); - this.updateSelection = function() { - //assert(!self.positionCacheExpired); + var self = this; - assert(this.focusedItemIndex >= 0 && this.focusedItemIndex <= self.items.length); - - self.drawItem(this.focusedItemIndex); - }; + /* + this.cachePositions = function() { + self.positionCacheExpired = false; + }; + */ - this.drawItem = function() { - var item = self.items[this.focusedItemIndex]; - if(!item) { - return; - } + this.updateSelection = function() { + //assert(!self.positionCacheExpired); - this.client.term.write(ansi.goto(this.position.row, this.position.col)); - this.client.term.write(self.hasFocus ? self.getFocusSGR() : self.getSGR()); + assert(this.focusedItemIndex >= 0 && this.focusedItemIndex <= self.items.length); - var text = strUtil.stylizeString(item.text, item.focused ? self.focusTextStyle : self.textStyle); + this.drawItem(this.focusedItemIndex); + this.emit('index update', this.focusedItemIndex); + }; - self.client.term.write( - strUtil.pad(text, this.dimens.width + 1, this.fillChar, this.justify)); - }; + this.drawItem = function(index) { + const item = this.items[index]; + if(!item) { + return; + } + + const cached = this.getRenderCacheItem(index, this.hasFocus); + if(cached) { + return this.client.term.write(`${ansi.goto(this.position.row, this.position.col)}${cached}`); + } + + let text; + let sgr; + if(this.complexItems) { + text = pipeToAnsi(formatString(this.hasFocus && this.focusItemFormat ? this.focusItemFormat : this.itemFormat, item)); + sgr = this.focusItemFormat ? '' : (this.hasFocus ? this.getFocusSGR() : self.getSGR()); + } else { + text = strUtil.stylizeString(item.text, this.hasFocus ? self.focusTextStyle : self.textStyle); + sgr = this.hasFocus ? this.getFocusSGR() : this.getSGR(); + } + + text = `${sgr}${strUtil.pad(text, this.dimens.width, this.fillChar, this.justify)}`; + this.client.term.write(`${ansi.goto(this.position.row, this.position.col)}${text}`); + this.setRenderCacheItem(index, text, this.hasFocus); + }; } util.inherits(SpinnerMenuView, MenuView); SpinnerMenuView.prototype.redraw = function() { - SpinnerMenuView.super_.prototype.redraw.call(this); - - //this.cachePositions(); - this.drawItem(this.focusedItemIndex); + SpinnerMenuView.super_.prototype.redraw.call(this); + this.drawItem(this.focusedItemIndex); }; SpinnerMenuView.prototype.setFocus = function(focused) { - SpinnerMenuView.super_.prototype.setFocus.call(this, focused); - - this.redraw(); + SpinnerMenuView.super_.prototype.setFocus.call(this, focused); + this.redraw(); }; SpinnerMenuView.prototype.setFocusItemIndex = function(index) { - SpinnerMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex - - this.updateSelection(); // will redraw + SpinnerMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + this.updateSelection(); // will redraw }; SpinnerMenuView.prototype.onKeyPress = function(ch, key) { - if(key) { - if(this.isKeyMapped('up', key.name)) { - if(0 === this.focusedItemIndex) { - this.focusedItemIndex = this.items.length - 1; - } else { - this.focusedItemIndex--; - } - - this.updateSelection(); - return; - } else if(this.isKeyMapped('down', key.name)) { - if(this.items.length - 1 === this.focusedItemIndex) { - this.focusedItemIndex = 0; - } else { - this.focusedItemIndex++; - } - - this.updateSelection(); - return; - } - } + if(key) { + if(this.isKeyMapped('up', key.name)) { + if(0 === this.focusedItemIndex) { + this.focusedItemIndex = this.items.length - 1; + } else { + this.focusedItemIndex--; + } - SpinnerMenuView.super_.prototype.onKeyPress.call(this, ch, key); + this.updateSelection(); + return; + } else if(this.isKeyMapped('down', key.name)) { + if(this.items.length - 1 === this.focusedItemIndex) { + this.focusedItemIndex = 0; + } else { + this.focusedItemIndex++; + } + + this.updateSelection(); + return; + } + } + + SpinnerMenuView.super_.prototype.onKeyPress.call(this, ch, key); }; SpinnerMenuView.prototype.getData = function() { - return this.focusedItemIndex; + const item = this.getItem(this.focusedItemIndex); + return _.isString(item.data) ? item.data : this.focusedItemIndex; }; - -SpinnerMenuView.prototype.setItems = function(items) { - SpinnerMenuView.super_.prototype.setItems.call(this, items); - - var longest = 0; - for(var i = 0; i < this.items.length; ++i) { - if(longest < this.items[i].text.length) { - longest = this.items[i].text.length; - } - } - - this.dimens.width = longest; -}; \ No newline at end of file diff --git a/core/standard_menu.js b/core/standard_menu.js index ddaebff3..a4dacb95 100644 --- a/core/standard_menu.js +++ b/core/standard_menu.js @@ -1,27 +1,27 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('./menu_module.js').MenuModule; +const MenuModule = require('./menu_module.js').MenuModule; exports.moduleInfo = { - name : 'Standard Menu Module', - desc : 'A Menu Module capable of handing standard configurations', - author : 'NuSkooler', + name : 'Standard Menu Module', + desc : 'A Menu Module capable of handing standard configurations', + author : 'NuSkooler', }; exports.getModule = class StandardMenuModule extends MenuModule { - constructor(options) { - super(options); - } + constructor(options) { + super(options); + } - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } - // we do this so other modules can be both customized and still perform standard tasks - return this.standardMCIReadyHandler(mciData, cb); - }); - } + // we do this so other modules can be both customized and still perform standard tasks + return this.standardMCIReadyHandler(mciData, cb); + }); + } }; diff --git a/core/stat_log.js b/core/stat_log.js index d6a53d28..af88ff57 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -1,285 +1,378 @@ /* jslint node: true */ 'use strict'; -const sysDb = require('./database.js').dbs.system; +const sysDb = require('./database.js').dbs.system; +const { + getISOTimestampString +} = require('./database.js'); +const Errors = require('./enig_error.js'); -// deps -const _ = require('lodash'); -const moment = require('moment'); +// deps +const _ = require('lodash'); +const moment = require('moment'); /* - System Event Log & Stats - ------------------------ - - System & user specific: - * Events for generating various statistics, logs such as last callers, etc. - * Stats such as counters + System Event Log & Stats + ------------------------ - User specific stats are simply an alternate interface to user properties, while - system wide entries are handled on their own. Both are read accessible non-blocking - making them easily available for MCI codes for example. + System & user specific: + * Events for generating various statistics, logs such as last callers, etc. + * Stats such as counters + + User specific stats are simply an alternate interface to user properties, while + system wide entries are handled on their own. Both are read accessible non-blocking + making them easily available for MCI codes for example. */ class StatLog { - constructor() { - this.systemStats = {}; - } + constructor() { + this.systemStats = {}; + } - init(cb) { - // - // Load previous state/values of |this.systemStats| - // - const self = this; + init(cb) { + // + // Load previous state/values of |this.systemStats| + // + const self = this; - sysDb.each( - `SELECT stat_name, stat_value - FROM system_stat;`, - (err, row) => { - if(row) { - self.systemStats[row.stat_name] = row.stat_value; - } - }, - err => { - return cb(err); - } - ); - } + sysDb.each( + `SELECT stat_name, stat_value + FROM system_stat;`, + (err, row) => { + if(row) { + self.systemStats[row.stat_name] = row.stat_value; + } + }, + err => { + return cb(err); + } + ); + } - get KeepDays() { - return { - Forever : -1, - }; - } + get KeepDays() { + return { + Forever : -1, + }; + } - get KeepType() { - return { - Forever : 'forever', - Days : 'days', - Max : 'max', - Count : 'max', - }; - } + get KeepType() { + return { + Forever : 'forever', + Days : 'days', + Max : 'max', + Count : 'max', + }; + } - get Order() { - return { - Timestamp : 'timestamp_asc', - TimestampAsc : 'timestamp_asc', - TimestampDesc : 'timestamp_desc', - Random : 'random', - }; - } + get Order() { + return { + Timestamp : 'timestamp_asc', + TimestampAsc : 'timestamp_asc', + TimestampDesc : 'timestamp_desc', + Random : 'random', + }; + } - setNonPeristentSystemStat(statName, statValue) { - this.systemStats[statName] = statValue; - } + setNonPersistentSystemStat(statName, statValue) { + this.systemStats[statName] = statValue; + } - setSystemStat(statName, statValue, cb) { - // live stats - this.systemStats[statName] = statValue; + incrementNonPersistentSystemStat(statName, incrementBy) { + incrementBy = incrementBy || 1; - // persisted stats - sysDb.run( - `REPLACE INTO system_stat (stat_name, stat_value) - VALUES (?, ?);`, - [ statName, statValue ], - err => { - // cb optional - callers may fire & forget - if(cb) { - return cb(err); - } - } - ); - } + let newValue = parseInt(this.systemStats[statName]); + if(!isNaN(newValue)) { + newValue += incrementBy; + } else { + newValue = incrementBy; + } + this.setNonPersistentSystemStat(statName, newValue); + return newValue; + } - getSystemStat(statName) { return this.systemStats[statName]; } + setSystemStat(statName, statValue, cb) { + // live stats + this.systemStats[statName] = statValue; - getSystemStatNum(statName) { - return parseInt(this.getSystemStat(statName)) || 0; - } + // persisted stats + sysDb.run( + `REPLACE INTO system_stat (stat_name, stat_value) + VALUES (?, ?);`, + [ statName, statValue ], + err => { + // cb optional - callers may fire & forget + if(cb) { + return cb(err); + } + } + ); + } - incrementSystemStat(statName, incrementBy, cb) { - incrementBy = incrementBy || 1; + getSystemStat(statName) { return this.systemStats[statName]; } - let newValue = parseInt(this.systemStats[statName]); - if(newValue) { - if(!_.isNumber(newValue)) { - return cb(new Error(`Value for ${statName} is not a number!`)); - } + getSystemStatNum(statName) { + return parseInt(this.getSystemStat(statName)) || 0; + } - newValue += incrementBy; - } else { - newValue = incrementBy; - } + incrementSystemStat(statName, incrementBy, cb) { + const newValue = this.incrementNonPersistentSystemStat(statName, incrementBy); + return this.setSystemStat(statName, newValue, cb); + } - return this.setSystemStat(statName, newValue, cb); - } + // + // User specific stats + // These are simply convenience methods to the user's properties + // + setUserStatWithOptions(user, statName, statValue, options, cb) { + // note: cb is optional in PersistUserProperty + user.persistProperty(statName, statValue, cb); - // - // User specific stats - // These are simply convience methods to the user's properties - // - setUserStat(user, statName, statValue, cb) { - // note: cb is optional in PersistUserProperty - return user.persistProperty(statName, statValue, cb); - } + if(!options.noEvent) { + const Events = require('./events.js'); // we need to late load currently + Events.emit(Events.getSystemEvents().UserStatSet, { user, statName, statValue } ); + } + } - getUserStat(user, statName) { - return user.properties[statName]; - } + setUserStat(user, statName, statValue, cb) { + return this.setUserStatWithOptions(user, statName, statValue, {}, cb); + } - getUserStatNum(user, statName) { - return parseInt(this.getUserStat(user, statName)) || 0; - } + getUserStat(user, statName) { + return user.properties[statName]; + } - incrementUserStat(user, statName, incrementBy, cb) { - incrementBy = incrementBy || 1; + getUserStatNum(user, statName) { + return parseInt(this.getUserStat(user, statName)) || 0; + } - let newValue = parseInt(user.properties[statName]); - if(newValue) { - if(!_.isNumber(newValue)) { - return cb(new Error(`Value for ${statName} is not a number!`)); - } + incrementUserStat(user, statName, incrementBy, cb) { + incrementBy = incrementBy || 1; - newValue += incrementBy; - } else { - newValue = incrementBy; - } + const oldValue = user.getPropertyAsNumber(statName) || 0; + const newValue = oldValue + incrementBy; - return this.setUserStat(user, statName, newValue, cb); - } + this.setUserStatWithOptions( + user, + statName, + newValue, + { noEvent : true }, + err => { + if(!err) { + const Events = require('./events.js'); // we need to late load currently + Events.emit( + Events.getSystemEvents().UserStatIncrement, + { + user, + statName, + oldValue, + statIncrementBy : incrementBy, + statValue : newValue + } + ); + } - // the time "now" in the ISO format we use and love :) - get now() { return moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ'); } + if(cb) { + return cb(err); + } + } + ); + } - appendSystemLogEntry(logName, logValue, keep, keepType, cb) { - sysDb.run( - `INSERT INTO system_event_log (timestamp, log_name, log_value) - VALUES (?, ?, ?);`, - [ this.now, logName, logValue ], - () => { - // - // Handle keep - // - if(-1 === keep) { - if(cb) { - return cb(null); - } - return; - } + // the time "now" in the ISO format we use and love :) + get now() { + return getISOTimestampString(); + } - switch(keepType) { - // keep # of days - case 'days' : - sysDb.run( - `DELETE FROM system_event_log - WHERE log_name = ? AND timestamp <= DATETIME("now", "-${keep} day");`, - [ logName ], - err => { - // cb optional - callers may fire & forget - if(cb) { - return cb(err); - } - } - ); - break; + appendSystemLogEntry(logName, logValue, keep, keepType, cb) { + sysDb.run( + `INSERT INTO system_event_log (timestamp, log_name, log_value) + VALUES (?, ?, ?);`, + [ this.now, logName, logValue ], + () => { + // + // Handle keep + // + if(-1 === keep) { + if(cb) { + return cb(null); + } + return; + } - case 'count': - case 'max' : - // keep max of N/count - sysDb.run( - `DELETE FROM system_event_log - WHERE id IN( - SELECT id - FROM system_event_log - WHERE log_name = ? - ORDER BY id DESC - LIMIT -1 OFFSET ${keep} - );`, - [ logName ], - err => { - if(cb) { - return cb(err); - } - } - ); - break; + switch(keepType) { + // keep # of days + case 'days' : + sysDb.run( + `DELETE FROM system_event_log + WHERE log_name = ? AND timestamp <= DATETIME("now", "-${keep} day");`, + [ logName ], + err => { + // cb optional - callers may fire & forget + if(cb) { + return cb(err); + } + } + ); + break; - case 'forever' : - default : - // nop - break; - } - } - ); - } + case 'count': + case 'max' : + // keep max of N/count + sysDb.run( + `DELETE FROM system_event_log + WHERE id IN( + SELECT id + FROM system_event_log + WHERE log_name = ? + ORDER BY id DESC + LIMIT -1 OFFSET ${keep} + );`, + [ logName ], + err => { + if(cb) { + return cb(err); + } + } + ); + break; - getSystemLogEntries(logName, order, limit, cb) { - let sql = - `SELECT timestamp, log_value - FROM system_event_log - WHERE log_name = ?`; + case 'forever' : + default : + // nop + break; + } + } + ); + } - switch(order) { - case 'timestamp' : - case 'timestamp_asc' : - sql += ' ORDER BY timestamp ASC'; - break; + /* + Find System Log entries by |filter|: - case 'timestamp_desc' : - sql += ' ORDER BY timestamp DESC'; - break; + filter.logName (required) + filter.resultType = (obj) | count + where obj contains timestamp and log_value + filter.limit + filter.date - exact date to filter against + filter.order = (timestamp) | timestamp_asc | timestamp_desc | random + */ + findSystemLogEntries(filter, cb) { + filter = filter || {}; + if(!_.isString(filter.logName)) { + return cb(Errors.MissingParam('filter.logName is required')); + } - case 'random' : - sql += ' ORDER BY RANDOM()'; - } + filter.resultType = filter.resultType || 'obj'; + filter.order = filter.order || 'timestamp'; - if(!cb && _.isFunction(limit)) { - cb = limit; - limit = 0; - } else { - limit = limit || 0; - } + let sql; + if('count' === filter.resultType) { + sql = + `SELECT COUNT() AS count + FROM system_event_log`; + } else { + sql = + `SELECT timestamp, log_value + FROM system_event_log`; + } - if(0 !== limit) { - sql += ` LIMIT ${limit}`; - } + sql += ' WHERE log_name = ?'; - sql += ';'; + if(filter.date) { + filter.date = moment(filter.date); + sql += ` AND DATE(timestamp, "localtime") = DATE("${filter.date.format('YYYY-MM-DD')}")`; + } - sysDb.all(sql, [ logName ], (err, rows) => { - return cb(err, rows); - }); - } + if('count' !== filter.resultType) { + switch(filter.order) { + case 'timestamp' : + case 'timestamp_asc' : + sql += ' ORDER BY timestamp ASC'; + break; - appendUserLogEntry(user, logName, logValue, keepDays, cb) { - sysDb.run( - `INSERT INTO user_event_log (timestamp, user_id, log_name, log_value) - VALUES (?, ?, ?, ?);`, - [ this.now, user.userId, logName, logValue ], - () => { - // - // Handle keepDays - // - if(-1 === keepDays) { - if(cb) { - return cb(null); - } - return; - } + case 'timestamp_desc' : + sql += ' ORDER BY timestamp DESC'; + break; - sysDb.run( - `DELETE FROM user_event_log - WHERE user_id = ? AND log_name = ? AND timestamp <= DATETIME("now", "-${keepDays} day");`, - [ user.userId, logName ], - err => { - // cb optional - callers may fire & forget - if(cb) { - return cb(err); - } - } - ); - } - ); - } + case 'random' : + sql += ' ORDER BY RANDOM()'; + break; + } + } + + if(_.isNumber(filter.limit) && 0 !== filter.limit) { + sql += ` LIMIT ${filter.limit}`; + } + + sql += ';'; + + if('count' === filter.resultType) { + sysDb.get(sql, [ filter.logName ], (err, row) => { + return cb(err, row ? row.count : 0); + }); + } else { + sysDb.all(sql, [ filter.logName ], (err, rows) => { + return cb(err, rows); + }); + } + } + + getSystemLogEntries(logName, order, limit, cb) { + if(!cb && _.isFunction(limit)) { + cb = limit; + limit = 0; + } else { + limit = limit || 0; + } + + const filter = { + logName, + order, + limit, + }; + return this.findSystemLogEntries(filter, cb); + } + + appendUserLogEntry(user, logName, logValue, keepDays, cb) { + sysDb.run( + `INSERT INTO user_event_log (timestamp, user_id, session_id, log_name, log_value) + VALUES (?, ?, ?, ?, ?);`, + [ this.now, user.userId, user.sessionId, logName, logValue ], + err => { + if(err) { + if(cb) { + cb(err); + } + return; + } + // + // Handle keepDays + // + if(-1 === keepDays) { + if(cb) { + return cb(null); + } + return; + } + + sysDb.run( + `DELETE FROM user_event_log + WHERE user_id = ? AND log_name = ? AND timestamp <= DATETIME("now", "-${keepDays} day");`, + [ user.userId, logName ], + err => { + // cb optional - callers may fire & forget + if(cb) { + return cb(err); + } + } + ); + } + ); + } + + initUserEvents(cb) { + const systemEventUserLogInit = require('./sys_event_user_log.js'); + systemEventUserLogInit(this); + return cb(null); + } } module.exports = new StatLog(); diff --git a/core/stats.js b/core/stats.js deleted file mode 100644 index ecc472de..00000000 --- a/core/stats.js +++ /dev/null @@ -1,30 +0,0 @@ -/* jslint node: true */ -'use strict'; - -var userDb = require('./database.js').dbs.user; - -exports.getSystemLoginHistory = getSystemLoginHistory; - -function getSystemLoginHistory(numRequested, cb) { - - numRequested = Math.max(1, numRequested); - - var loginHistory = []; - - userDb.each( - 'SELECT user_id, user_name, timestamp ' + - 'FROM user_login_history ' + - 'ORDER BY timestamp DESC ' + - 'LIMIT ' + numRequested + ';', - function historyRow(err, histEntry) { - loginHistory.push( { - userId : histEntry.user_id, - userName : histEntry.user_name, - timestamp : histEntry.timestamp, - } ); - }, - function complete(err, recCount) { - cb(err, loginHistory); - } - ); -} diff --git a/core/status_bar_view.js b/core/status_bar_view.js deleted file mode 100644 index ed47ca7d..00000000 --- a/core/status_bar_view.js +++ /dev/null @@ -1,64 +0,0 @@ -/* jslint node: true */ -'use strict'; - -var View = require('./view.js').View; -var TextView = require('./text_view.js').TextView; - -var assert = require('assert'); -var _ = require('lodash'); - -function StatusBarView(options) { - View.call(this, options); - - var self = this; - - -} - -require('util').inherits(StatusBarView, View); - -StatusBarView.prototype.redraw = function() { - - StatusBarView.super_.prototype.redraw.call(this); - -}; - -StatusBarView.prototype.setPanels = function(panels) { - -/* - "panels" : [ - { - "text" : "things and stuff", - "width" 20, - ... - }, - { - "width" : 40 // no text, etc... = spacer - } - ] - - |---------------------------------------------| - | stuff | -*/ - assert(_.isArray(panels)); - - this.panels = []; - - var tvOpts = { - cursor : 'hide', - position : { row : this.position.row, col : 0 }, - }; - - panels.forEach(function panel(p) { - assert(_.isObject(p)); - assert(_.has(p, 'width')); - - if(p.text) { - this.panels.push( new TextView( { })) - } else { - this.panels.push( { width : p.width } ); - } - }); - -}; - diff --git a/core/string_format.js b/core/string_format.js index dd1ece78..4a5b110c 100644 --- a/core/string_format.js +++ b/core/string_format.js @@ -1,348 +1,369 @@ /* jslint node: true */ 'use strict'; -const EnigError = require('./enig_error.js').EnigError; -const pad = require('./string_util.js').pad; -const stylizeString = require('./string_util.js').stylizeString; -const renderStringLength = require('./string_util.js').renderStringLength; -const renderSubstr = require('./string_util.js').renderSubstr; -const formatByteSize = require('./string_util.js').formatByteSize; -const formatByteSizeAbbr = require('./string_util.js').formatByteSizeAbbr; +const EnigError = require('./enig_error.js').EnigError; -// deps -const _ = require('lodash'); +const { + pad, + stylizeString, + renderStringLength, + renderSubstr, + formatByteSize, formatByteSizeAbbr, + formatCount, formatCountAbbr, +} = require('./string_util.js'); + +// deps +const _ = require('lodash'); +const moment = require('moment'); /* - String formatting HEAVILY inspired by David Chambers string-format library - and the mini-language branch specifically which was gratiously released - under the DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE. + String formatting HEAVILY inspired by David Chambers string-format library + and the mini-language branch specifically which was gratiously released + under the DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE. - We need some extra functionality. Namely, support for RA style pipe codes - and ANSI escape sequences. + We need some extra functionality. Namely, support for RA style pipe codes + and ANSI escape sequences. */ class ValueError extends EnigError { } class KeyError extends EnigError { } const SpecRegExp = { - FillAlign : /^(.)?([<>=^])/, - Sign : /^[ +-]/, - Width : /^\d*/, - Precision : /^\d+/, + FillAlign : /^(.)?([<>=^])/, + Sign : /^[ +-]/, + Width : /^\d*/, + Precision : /^\d+/, }; function tokenizeFormatSpec(spec) { - const tokens = { - fill : '', - align : '', - sign : '', - '#' : false, - '0' : false, - width : '', - ',' : false, - precision : '', - type : '', - }; + const tokens = { + fill : '', + align : '', + sign : '', + '#' : false, + '0' : false, + width : '', + ',' : false, + precision : '', + type : '', + }; - let index = 0; - let match; + let index = 0; + let match; - function incIndexByMatch() { - index += match[0].length; - } + function incIndexByMatch() { + index += match[0].length; + } - match = SpecRegExp.FillAlign.exec(spec); - if(match) { - if(match[1]) { - tokens.fill = match[1]; - } - tokens.align = match[2]; - incIndexByMatch(); - } + match = SpecRegExp.FillAlign.exec(spec); + if(match) { + if(match[1]) { + tokens.fill = match[1]; + } + tokens.align = match[2]; + incIndexByMatch(); + } - match = SpecRegExp.Sign.exec(spec.slice(index)); - if(match) { - tokens.sign = match[0]; - incIndexByMatch(); - } + match = SpecRegExp.Sign.exec(spec.slice(index)); + if(match) { + tokens.sign = match[0]; + incIndexByMatch(); + } - if('#' === spec.charAt(index)) { - tokens['#'] = true; - ++index; - } + if('#' === spec.charAt(index)) { + tokens['#'] = true; + ++index; + } - if('0' === spec.charAt(index)) { - tokens['0'] = true; - ++index; - } + if('0' === spec.charAt(index)) { + tokens['0'] = true; + ++index; + } - match = SpecRegExp.Width.exec(spec.slice(index)); - tokens.width = match[0]; - incIndexByMatch(); + match = SpecRegExp.Width.exec(spec.slice(index)); + tokens.width = match[0]; + incIndexByMatch(); - if(',' === spec.charAt(index)) { - tokens[','] = true; - ++index; - } + if(',' === spec.charAt(index)) { + tokens[','] = true; + ++index; + } - if('.' === spec.charAt(index)) { - ++index; + if('.' === spec.charAt(index)) { + ++index; - match = SpecRegExp.Precision.exec(spec.slice(index)); - if(!match) { - throw new ValueError('Format specifier missing precision'); - } + match = SpecRegExp.Precision.exec(spec.slice(index)); + if(!match) { + throw new ValueError('Format specifier missing precision'); + } - tokens.precision = match[0]; - incIndexByMatch(); - } + tokens.precision = match[0]; + incIndexByMatch(); + } - if(index < spec.length) { - tokens.type = spec.charAt(index); - ++index; - } + if(index < spec.length) { + tokens.type = spec.charAt(index); + ++index; + } - if(index < spec.length) { - throw new ValueError('Invalid conversion specification'); - } + if(index < spec.length) { + throw new ValueError('Invalid conversion specification'); + } - if(tokens[','] && 's' === tokens.type) { - throw new ValueError(`Cannot specify ',' with 's'`); // eslint-disable-line quotes - } + if(tokens[','] && 's' === tokens.type) { + throw new ValueError(`Cannot specify ',' with 's'`); // eslint-disable-line quotes + } - return tokens; + return tokens; } function quote(s) { - return `"${s.replace(/"/g, '\\"')}"`; + return `"${s.replace(/"/g, '\\"')}"`; } function getPadAlign(align) { - return { - '<' : 'right', - '>' : 'left', - '^' : 'center', - }[align] || '<'; + return { + '<' : 'left', + '>' : 'right', + '^' : 'center', + }[align] || '>'; } function formatString(value, tokens) { - const fill = tokens.fill || (tokens['0'] ? '0' : ' '); - const align = tokens.align || (tokens['0'] ? '=' : '<'); - const precision = Number(tokens.precision || renderStringLength(value) + 1); + const fill = tokens.fill || (tokens['0'] ? '0' : ' '); + const align = tokens.align || (tokens['0'] ? '=' : '<'); + const precision = Number(tokens.precision || renderStringLength(value) + 1); - if('' !== tokens.type && 's' !== tokens.type) { - throw new ValueError(`Unknown format code "${tokens.type}" for String object`); - } + if('' !== tokens.type && 's' !== tokens.type) { + throw new ValueError(`Unknown format code "${tokens.type}" for String object`); + } - if(tokens[',']) { - throw new ValueError(`Cannot specify ',' with 's'`); // eslint-disable-line quotes - } + if(tokens[',']) { + throw new ValueError(`Cannot specify ',' with 's'`); // eslint-disable-line quotes + } - if(tokens.sign) { - throw new ValueError('Sign not allowed in string format specifier'); - } + if(tokens.sign) { + throw new ValueError('Sign not allowed in string format specifier'); + } - if(tokens['#']) { - throw new ValueError('Alternate form (#) not allowed in string format specifier'); - } + if(tokens['#']) { + throw new ValueError('Alternate form (#) not allowed in string format specifier'); + } - if('=' === align) { - throw new ValueError('"=" alignment not allowed in string format specifier'); - } + if('=' === align) { + throw new ValueError('"=" alignment not allowed in string format specifier'); + } - return pad(renderSubstr(value, 0, precision), Number(tokens.width), fill, getPadAlign(align)); + return pad(renderSubstr(value, 0, precision), Number(tokens.width), fill, getPadAlign(align)); } const FormatNumRegExp = { - UpperType : /[A-Z]/, - ExponentRep : /e[+-](?=\d$)/, + UpperType : /[A-Z]/, + ExponentRep : /e[+-](?=\d$)/, }; function formatNumberHelper(n, precision, type) { - if(FormatNumRegExp.UpperType.test(type)) { - return formatNumberHelper(n, precision, type.toLowerCase()).toUpperCase(); - } + if(FormatNumRegExp.UpperType.test(type)) { + return formatNumberHelper(n, precision, type.toLowerCase()).toUpperCase(); + } - switch(type) { - case 'c' : return String.fromCharCode(n); - case 'd' : return n.toString(10); - case 'b' : return n.toString(2); - case 'o' : return n.toString(8); - case 'x' : return n.toString(16); - case 'e' : return n.toExponential(precision).replace(FormatNumRegExp.ExponentRep, '$&0'); - case 'f' : return n.toFixed(precision); - case 'g' : - // we don't want useless trailing zeros. parseFloat -> back to string fixes this for us - return parseFloat(n.toPrecision(precision || 1)).toString(); + switch(type) { + case 'c' : return String.fromCharCode(n); + case 'd' : return n.toString(10); + case 'b' : return n.toString(2); + case 'o' : return n.toString(8); + case 'x' : return n.toString(16); + case 'e' : return n.toExponential(precision).replace(FormatNumRegExp.ExponentRep, '$&0'); + case 'f' : return n.toFixed(precision); + case 'g' : + // we don't want useless trailing zeros. parseFloat -> back to string fixes this for us + return parseFloat(n.toPrecision(precision || 1)).toString(); - case '%' : return formatNumberHelper(n * 100, precision, 'f') + '%'; - case '' : return formatNumberHelper(n, precision, 'd'); - - default : - throw new ValueError(`Unknown format code "${type}" for object of type 'float'`); - } + case '%' : return formatNumberHelper(n * 100, precision, 'f') + '%'; + case '' : return formatNumberHelper(n, precision, 'd'); + + default : + throw new ValueError(`Unknown format code "${type}" for object of type 'float'`); + } } function formatNumber(value, tokens) { - const fill = tokens.fill || (tokens['0'] ? '0' : ' '); - const align = tokens.align || (tokens['0'] ? '=' : '>'); - const width = Number(tokens.width); - const type = tokens.type || (tokens.precision ? 'g' : ''); + const fill = tokens.fill || (tokens['0'] ? '0' : ' '); + const align = tokens.align || (tokens['0'] ? '=' : '>'); + const width = Number(tokens.width); + const type = tokens.type || (tokens.precision ? 'g' : ''); - if( [ 'c', 'd', 'b', 'o', 'x', 'X' ].indexOf(type) > -1) { - if(0 !== value % 1) { - throw new ValueError(`Cannot format non-integer with format specifier "${type}"`); - } + if( [ 'c', 'd', 'b', 'o', 'x', 'X' ].indexOf(type) > -1) { + if(0 !== value % 1) { + throw new ValueError(`Cannot format non-integer with format specifier "${type}"`); + } - if('' !== tokens.sign && 'c' !== type) { - throw new ValueError(`Sign not allowed with integer format specifier 'c'`); // eslint-disable-line quotes - } + if('' !== tokens.sign && 'c' !== type) { + throw new ValueError(`Sign not allowed with integer format specifier 'c'`); // eslint-disable-line quotes + } - if(tokens[','] && 'd' !== type) { - throw new ValueError(`Cannot specify ',' with '${type}'`); - } + if(tokens[','] && 'd' !== type) { + throw new ValueError(`Cannot specify ',' with '${type}'`); + } - if('' !== tokens.precision) { - throw new ValueError('Precision not allowed in integer format specifier'); - } - } else if( [ 'e', 'E', 'f', 'F', 'g', 'G', '%' ].indexOf(type) > - 1) { - if(tokens['#']) { - throw new ValueError('Alternate form (#) not allowed in float format specifier'); - } - } + if('' !== tokens.precision) { + throw new ValueError('Precision not allowed in integer format specifier'); + } + } else if( [ 'e', 'E', 'f', 'F', 'g', 'G', '%' ].indexOf(type) > - 1) { + if(tokens['#']) { + throw new ValueError('Alternate form (#) not allowed in float format specifier'); + } + } - const s = formatNumberHelper(Math.abs(value), Number(tokens.precision || 6), type); - const sign = value < 0 || 1 / value < 0 ? - '-' : - '-' === tokens.sign ? '' : tokens.sign; + const s = formatNumberHelper(Math.abs(value), Number(tokens.precision || 6), type); + const sign = value < 0 || 1 / value < 0 ? + '-' : + '-' === tokens.sign ? '' : tokens.sign; - const prefix = tokens['#'] && ( [ 'b', 'o', 'x', 'X' ].indexOf(type) > -1 ) ? '0' + type : ''; + const prefix = tokens['#'] && ( [ 'b', 'o', 'x', 'X' ].indexOf(type) > -1 ) ? '0' + type : ''; - if(tokens[',']) { - const match = /^(\d*)(.*)$/.exec(s); - const separated = match[1].replace(/.(?=(...)+$)/g, '$&,') + match[2]; + if(tokens[',']) { + const match = /^(\d*)(.*)$/.exec(s); + const separated = match[1].replace(/.(?=(...)+$)/g, '$&,') + match[2]; - if('=' !== align) { - return pad(sign + separated, width, fill, getPadAlign(align)); - } + if('=' !== align) { + return pad(sign + separated, width, fill, getPadAlign(align)); + } - if('0' === fill) { - const shortfall = Math.max(0, width - sign.length - separated.length); - const digits = /^\d*/.exec(separated)[0].length; - let padding = ''; - // :TODO: do this differntly... - for(let n = 0; n < shortfall; n++) { - padding = ((digits + n) % 4 === 3 ? ',' : '0') + padding; - } + if('0' === fill) { + const shortfall = Math.max(0, width - sign.length - separated.length); + const digits = /^\d*/.exec(separated)[0].length; + let padding = ''; + // :TODO: do this differntly... + for(let n = 0; n < shortfall; n++) { + padding = ((digits + n) % 4 === 3 ? ',' : '0') + padding; + } - return sign + (/^,/.test(padding) ? '0' : '') + padding + separated; - } + return sign + (/^,/.test(padding) ? '0' : '') + padding + separated; + } - return sign + pad(separated, width - sign.length, fill, getPadAlign('>')); - } + return sign + pad(separated, width - sign.length, fill, getPadAlign('>')); + } - if(0 === width) { - return sign + prefix + s; - } + if(0 === width) { + return sign + prefix + s; + } - if('=' === align) { - return sign + prefix + pad(s, width - sign.length - prefix.length, fill, getPadAlign('>')); - } + if('=' === align) { + return sign + prefix + pad(s, width - sign.length - prefix.length, fill, getPadAlign('>')); + } - return pad(sign + prefix + s, width, fill, getPadAlign(align)); + return pad(sign + prefix + s, width, fill, getPadAlign(align)); } const transformers = { - // String standard - toUpperCase : String.prototype.toUpperCase, - toLowerCase : String.prototype.toLowerCase, + // String standard + toUpperCase : String.prototype.toUpperCase, + toLowerCase : String.prototype.toLowerCase, - // some super l33b BBS styles!! - styleUpper : (s) => stylizeString(s, 'upper'), - styleLower : (s) => stylizeString(s, 'lower'), - styleTitle : (s) => stylizeString(s, 'title'), - styleFirstLower : (s) => stylizeString(s, 'first lower'), - styleSmallVowels : (s) => stylizeString(s, 'small vowels'), - styleBigVowels : (s) => stylizeString(s, 'big vowels'), - styleSmallI : (s) => stylizeString(s, 'small i'), - styleMixed : (s) => stylizeString(s, 'mixed'), - styleL33t : (s) => stylizeString(s, 'l33t'), + // some super l33b BBS styles!! + styleUpper : (s) => stylizeString(s, 'upper'), + styleLower : (s) => stylizeString(s, 'lower'), + styleTitle : (s) => stylizeString(s, 'title'), + styleFirstLower : (s) => stylizeString(s, 'first lower'), + styleSmallVowels : (s) => stylizeString(s, 'small vowels'), + styleBigVowels : (s) => stylizeString(s, 'big vowels'), + styleSmallI : (s) => stylizeString(s, 'small i'), + styleMixed : (s) => stylizeString(s, 'mixed'), + styleL33t : (s) => stylizeString(s, 'l33t'), - // toMegs(), toKilobytes(), ... - // toList(), toCommaList(), - sizeWithAbbr : (n) => formatByteSize(n, true, 2), - sizeWithoutAbbr : (n) => formatByteSize(n, false, 2), - sizeAbbr : (n) => formatByteSizeAbbr(n), + // :TODO: + // toMegs(), toKilobytes(), ... + // toList(), toCommaList(), + + sizeWithAbbr : (n) => formatByteSize(n, true, 2), + sizeWithoutAbbr : (n) => formatByteSize(n, false, 2), + sizeAbbr : (n) => formatByteSizeAbbr(n), + countWithAbbr : (n) => formatCount(n, true, 0), + countWithoutAbbr : (n) => formatCount(n, false, 0), + countAbbr : (n) => formatCountAbbr(n), + + durationHours : (h) => moment.duration(h, 'hours').humanize(), + durationMinutes : (m) => moment.duration(m, 'minutes').humanize(), + durationSeconds : (s) => moment.duration(s, 'seconds').humanize(), }; function transformValue(transformerName, value) { - if(transformerName in transformers) { - const transformer = transformers[transformerName]; - value = transformer.apply(value, [ value ] ); - } + if(transformerName in transformers) { + const transformer = transformers[transformerName]; + value = transformer.apply(value, [ value ] ); + } - return value; + return value; } -// :TODO: Use explicit set of chars for paths & function/transforms such that } is allowed as fill/etc. -const REGEXP_BASIC_FORMAT = /{([^.!:}]+(?:\.[^.!:}]+)*)(?:\!([^:}]+))?(?:\:([^}]+))?}/g; +// :TODO: Use explicit set of chars for paths & function/transforms such that } is allowed as fill/etc. +const REGEXP_BASIC_FORMAT = /{([^.!:}]+(?:\.[^.!:}]+)*)(?:!([^:}]+))?(?::([^}]+))?}/g; function getValue(obj, path) { - const value = _.get(obj, path); - if(!_.isUndefined(value)) { - return _.isFunction(value) ? value() : value; - } - - throw new KeyError(quote(path)); + const value = _.get(obj, path); + if(!_.isUndefined(value)) { + return _.isFunction(value) ? value() : value; + } + + throw new KeyError(quote(path)); } module.exports = function format(fmt, obj) { - const re = REGEXP_BASIC_FORMAT; - re.lastIndex = 0; // reset from prev + const re = REGEXP_BASIC_FORMAT; + re.lastIndex = 0; // reset from prev - let match; - let pos; - let out = ''; - let objPath ; - let transformer; - let formatSpec; - let value; - let tokens; + let match; + let pos; + let out = ''; + let objPath ; + let transformer; + let formatSpec; + let value; + let tokens; - do { - pos = re.lastIndex; - match = re.exec(fmt); + do { + pos = re.lastIndex; + match = re.exec(fmt); - if(match) { - if(match.index > pos) { - out += fmt.slice(pos, match.index); - } + if(match) { + if(match.index > pos) { + out += fmt.slice(pos, match.index); + } - objPath = match[1]; - transformer = match[2]; - formatSpec = match[3]; + objPath = match[1]; + transformer = match[2]; + formatSpec = match[3]; - value = getValue(obj, objPath); - if(transformer) { - value = transformValue(transformer, value); - } + try { + value = getValue(obj, objPath); + if(transformer) { + value = transformValue(transformer, value); + } - tokens = tokenizeFormatSpec(formatSpec || ''); + tokens = tokenizeFormatSpec(formatSpec || ''); - if(_.isNumber(value)) { - out += formatNumber(value, tokens); - } else { - out += formatString(value, tokens); - } - } + if(_.isNumber(value)) { + out += formatNumber(value, tokens); + } else { + out += formatString(value, tokens); + } + } catch(e) { + if(e instanceof KeyError) { + out += match[0]; // preserve full thing + } else if(e instanceof ValueError) { + out += value.toString(); + } + } + } - } while(0 !== re.lastIndex); + } while(0 !== re.lastIndex); - // remainder - if(pos < fmt.length) { - out += fmt.slice(pos); - } + // remainder + if(pos < fmt.length) { + out += fmt.slice(pos); + } - return out; + return out; }; diff --git a/core/string_util.js b/core/string_util.js index ed6df231..2f6596c8 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -1,634 +1,482 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const miscUtil = require('./misc_util.js'); -const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser; -const ANSI = require('./ansi_term.js'); +// ENiGMA½ +const ANSI = require('./ansi_term.js'); -// deps -const iconv = require('iconv-lite'); -const _ = require('lodash'); +// deps +const iconv = require('iconv-lite'); +const _ = require('lodash'); -exports.stylizeString = stylizeString; -exports.pad = pad; -exports.insert = insert; -exports.replaceAt = replaceAt; -exports.isPrintable = isPrintable; -exports.stripAllLineFeeds = stripAllLineFeeds; -exports.debugEscapedString = debugEscapedString; -exports.stringFromNullTermBuffer = stringFromNullTermBuffer; -exports.stringToNullTermBuffer = stringToNullTermBuffer; -exports.renderSubstr = renderSubstr; -exports.renderStringLength = renderStringLength; -exports.formatByteSizeAbbr = formatByteSizeAbbr; -exports.formatByteSize = formatByteSize; -exports.cleanControlCodes = cleanControlCodes; -exports.isAnsi = isAnsi; -exports.isAnsiLine = isAnsiLine; -exports.isFormattedLine = isFormattedLine; -exports.splitTextAtTerms = splitTextAtTerms; +exports.stylizeString = stylizeString; +exports.pad = pad; +exports.insert = insert; +exports.replaceAt = replaceAt; +exports.isPrintable = isPrintable; +exports.containsNonLatinCodepoints = containsNonLatinCodepoints; +exports.stripAllLineFeeds = stripAllLineFeeds; +exports.debugEscapedString = debugEscapedString; +exports.stringFromNullTermBuffer = stringFromNullTermBuffer; +exports.stringToNullTermBuffer = stringToNullTermBuffer; +exports.renderSubstr = renderSubstr; +exports.renderStringLength = renderStringLength; +exports.formatByteSizeAbbr = formatByteSizeAbbr; +exports.formatByteSize = formatByteSize; +exports.formatCountAbbr = formatCountAbbr; +exports.formatCount = formatCount; +exports.stripAnsiControlCodes = stripAnsiControlCodes; +exports.isAnsi = isAnsi; +exports.isAnsiLine = isAnsiLine; +exports.isFormattedLine = isFormattedLine; +exports.splitTextAtTerms = splitTextAtTerms; +exports.wildcardMatch = wildcardMatch; -// :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', - 'e' : '3', - 'i' : '1', - 'o' : '0', - 's' : '5', - 't' : '7' + 'a' : '4', + 'e' : '3', + 'i' : '1', + 'o' : '0', + 's' : '5', + 't' : '7' }; function stylizeString(s, style) { - var len = s.length; - var c; - var i; - var stylized = ''; + var len = s.length; + var c; + var i; + var stylized = ''; - switch(style) { - // None/normal - case 'normal' : - case 'N' : - return s; + switch(style) { + // None/normal + case 'normal' : + case 'N' : + return s; - // UPPERCASE - case 'upper' : - case 'U' : - return s.toUpperCase(); + // UPPERCASE + case 'upper' : + case 'U' : + return s.toUpperCase(); - // lowercase - case 'lower' : - case 'l' : - return s.toLowerCase(); + // lowercase + case 'lower' : + case 'l' : + return s.toLowerCase(); - // Title Case - case 'title' : - case 'T' : - return s.replace(/\w\S*/g, function onProperCaseChar(t) { - return t.charAt(0).toUpperCase() + t.substr(1).toLowerCase(); - }); + // Title Case + case 'title' : + case 'T' : + return s.replace(/\w\S*/g, function onProperCaseChar(t) { + return t.charAt(0).toUpperCase() + t.substr(1).toLowerCase(); + }); - // fIRST lOWER - case 'first lower' : - case 'f' : - return s.replace(/\w\S*/g, function onFirstLowerChar(t) { - return t.charAt(0).toLowerCase() + t.substr(1).toUpperCase(); - }); + // fIRST lOWER + case 'first lower' : + case 'f' : + return s.replace(/\w\S*/g, function onFirstLowerChar(t) { + return t.charAt(0).toLowerCase() + t.substr(1).toUpperCase(); + }); - // SMaLL VoWeLS - case 'small vowels' : - case 'v' : - for(i = 0; i < len; ++i) { - c = s[i]; - if(-1 !== VOWELS.indexOf(c)) { - stylized += c.toLowerCase(); - } else { - stylized += c.toUpperCase(); - } - } - return stylized; + // SMaLL VoWeLS + case 'small vowels' : + case 'v' : + for(i = 0; i < len; ++i) { + c = s[i]; + if(-1 !== VOWELS.indexOf(c)) { + stylized += c.toLowerCase(); + } else { + stylized += c.toUpperCase(); + } + } + return stylized; - // bIg vOwELS - case 'big vowels' : - case 'V' : - for(i = 0; i < len; ++i) { - c = s[i]; - if(-1 !== VOWELS.indexOf(c)) { - stylized += c.toUpperCase(); - } else { - stylized += c.toLowerCase(); - } - } - return stylized; + // bIg vOwELS + case 'big vowels' : + case 'V' : + for(i = 0; i < len; ++i) { + c = s[i]; + if(-1 !== VOWELS.indexOf(c)) { + stylized += c.toUpperCase(); + } else { + stylized += c.toLowerCase(); + } + } + return stylized; - // Small i's: DEMENTiA - case 'small i' : - case 'i' : - return s.toUpperCase().replace(/I/g, 'i'); + // Small i's: DEMENTiA + case 'small i' : + case 'i' : + return s.toUpperCase().replace(/I/g, 'i'); - // mIxeD CaSE (random upper/lower) - case 'mixed' : - case 'M' : - for(i = 0; i < len; i++) { - if(Math.random() < 0.5) { - stylized += s[i].toUpperCase(); - } else { - stylized += s[i].toLowerCase(); - } - } - return stylized; + // mIxeD CaSE (random upper/lower) + case 'mixed' : + case 'M' : + for(i = 0; i < len; i++) { + if(Math.random() < 0.5) { + stylized += s[i].toUpperCase(); + } else { + stylized += s[i].toLowerCase(); + } + } + return stylized; - // l337 5p34k - case 'l33t' : - case '3' : - for(i = 0; i < len; ++i) { - c = SIMPLE_ELITE_MAP[s[i].toLowerCase()]; - stylized += c || s[i]; - } - return stylized; - } + // l337 5p34k + case 'l33t' : + case '3' : + for(i = 0; i < len; ++i) { + c = SIMPLE_ELITE_MAP[s[i].toLowerCase()]; + stylized += c || s[i]; + } + return stylized; + } - return s; + return s; } -// Based on http://www.webtoolkit.info/ -// :TODO: Look into lodash padLeft, padRight, etc. -function pad(s, len, padChar, dir, stringSGR, padSGR, useRenderLen) { - len = miscUtil.valueWithDefault(len, 0); - padChar = miscUtil.valueWithDefault(padChar, ' '); - dir = miscUtil.valueWithDefault(dir, 'right'); - stringSGR = miscUtil.valueWithDefault(stringSGR, ''); - padSGR = miscUtil.valueWithDefault(padSGR, ''); - useRenderLen = miscUtil.valueWithDefault(useRenderLen, true); +function pad(s, len, padChar, justify, stringSGR, padSGR, useRenderLen) { + len = len || 0; + padChar = padChar || ' '; + justify = justify || 'left'; + stringSGR = stringSGR || ''; + padSGR = padSGR || ''; + useRenderLen = _.isUndefined(useRenderLen) ? true : useRenderLen; - const renderLen = useRenderLen ? renderStringLength(s) : s.length; - const padlen = len >= renderLen ? len - renderLen : 0; + const renderLen = useRenderLen ? renderStringLength(s) : s.length; + const padlen = len >= renderLen ? len - renderLen : 0; - switch(dir) { - case 'L' : - case 'left' : - s = padSGR + new Array(padlen).join(padChar) + stringSGR + s; - break; + switch(justify) { + case 'L' : + case 'left' : + s = `${stringSGR}${s}${padSGR}${Array(padlen).join(padChar)}`; + break; - case 'C' : - case 'center' : - case 'both' : - { - const right = Math.ceil(padlen / 2); - const left = padlen - right; - s = padSGR + new Array(left + 1).join(padChar) + stringSGR + s + padSGR + new Array(right + 1).join(padChar); - } - break; + case 'C' : + case 'center' : + case 'both' : + { + const right = Math.ceil(padlen / 2); + const left = padlen - right; + s = `${padSGR}${Array(left + 1).join(padChar)}${stringSGR}${s}${padSGR}${Array(right + 1).join(padChar)}`; + } + break; - case 'R' : - case 'right' : - s = stringSGR + s + padSGR + new Array(padlen).join(padChar); - break; + case 'R' : + case 'right' : + s = `${padSGR}${Array(padlen).join(padChar)}${stringSGR}${s}`; + break; - default : break; - } + default : break; + } - return stringSGR + s; + return stringSGR + s; } function insert(s, index, substr) { - return `${s.slice(0, index)}${substr}${s.slice(index)}`; + return `${s.slice(0, index)}${substr}${s.slice(index)}`; } function replaceAt(s, n, t) { - return s.substring(0, n) + t + s.substring(n + 1); + return s.substring(0, n) + t + s.substring(n + 1); } -const RE_NON_PRINTABLE = - /[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/; // eslint-disable-line no-control-regex +const RE_NON_PRINTABLE = + /[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/; // eslint-disable-line no-control-regex function isPrintable(s) { - // - // See the following: - // https://mathiasbynens.be/notes/javascript-unicode - // http://stackoverflow.com/questions/11598786/how-to-replace-non-printable-unicode-characters-javascript - // http://stackoverflow.com/questions/12052825/regular-expression-for-all-printable-characters-in-javascript - // - // :TODO: Probably need somthing better here. - return !RE_NON_PRINTABLE.test(s); + // + // See the following: + // https://mathiasbynens.be/notes/javascript-unicode + // http://stackoverflow.com/questions/11598786/how-to-replace-non-printable-unicode-characters-javascript + // http://stackoverflow.com/questions/12052825/regular-expression-for-all-printable-characters-in-javascript + // + // :TODO: Probably need somthing better here. + return !RE_NON_PRINTABLE.test(s); } -function stringLength(s) { - // :TODO: See https://mathiasbynens.be/notes/javascript-unicode - return s.length; +const NonLatinCodePointsRegExp = /[^\u0000-\u00ff]/; + +function containsNonLatinCodepoints(s) { + if (!s.length) { + return false; + } + + if (s.charCodeAt(0) > 255) { + return true; + } + + return NonLatinCodepointsRegEx.test(s); } function stripAllLineFeeds(s) { - return s.replace(/\r?\n|[\r\u2028\u2029]/g, ''); + return s.replace(/\r?\n|[\r\u2028\u2029]/g, ''); } function debugEscapedString(s) { - return JSON.stringify(s).slice(1, -1); + return JSON.stringify(s).slice(1, -1); } function stringFromNullTermBuffer(buf, encoding) { - let nullPos = buf.indexOf(new Buffer( [ 0x00 ] )); - if(-1 === nullPos) { - nullPos = buf.length; - } + let nullPos = buf.indexOf( 0x00 ); + if(-1 === nullPos) { + nullPos = buf.length; + } - return iconv.decode(buf.slice(0, nullPos), encoding || 'utf-8'); + return iconv.decode(buf.slice(0, nullPos), encoding || 'utf-8'); } function stringToNullTermBuffer(s, options = { encoding : 'utf8', maxBufLen : -1 } ) { - let buf = iconv.encode( `${s}\0`, options.encoding ).slice(0, options.maxBufLen); - buf[buf.length - 1] = '\0'; // make abs sure we null term even if truncated - return buf; + let buf = iconv.encode( `${s}\0`, options.encoding ).slice(0, options.maxBufLen); + buf[buf.length - 1] = '\0'; // make abs sure we null term even if truncated + return buf; } -const PIPE_REGEXP = /(\|[A-Z\d]{2})/g; -//const ANSI_REGEXP = /[\u001b\u009b][[()#;?]*([0-9]{1,4}(?:;[0-9]{0,4})*)?([0-9A-ORZcf-npqrsuy=><])/g; -//const ANSI_OR_PIPE_REGEXP = new RegExp(PIPE_REGEXP.source + '|' + ANSI_REGEXP.source, 'g'); -const ANSI_OR_PIPE_REGEXP = new RegExp(PIPE_REGEXP.source + '|' + ANSI.getFullMatchRegExp().source, 'g'); +const PIPE_REGEXP = /(\|[A-Z\d]{2})/g; +const ANSI_OR_PIPE_REGEXP = new RegExp(PIPE_REGEXP.source + '|' + ANSI.getFullMatchRegExp().source, 'g'); // -// Similar to substr() but works with ANSI/Pipe code strings +// Similar to substr() but works with ANSI/Pipe code strings // function renderSubstr(str, start, length) { - // shortcut for empty strings - if(0 === str.length) { - return str; - } + // shortcut for empty strings + if(0 === str.length) { + return str; + } - start = start || 0; - length = length || str.length - start; + start = start || 0; + length = length || str.length - start; - const re = ANSI_OR_PIPE_REGEXP; - re.lastIndex = 0; // we recycle the obj; must reset! + const re = ANSI_OR_PIPE_REGEXP; + re.lastIndex = 0; // we recycle the obj; must reset! - let pos = 0; - let match; - let out = ''; - let renderLen = 0; - let s; - do { - pos = re.lastIndex; - match = re.exec(str); + let pos = 0; + let match; + let out = ''; + let renderLen = 0; + let s; + do { + pos = re.lastIndex; + match = re.exec(str); - if(match) { - if(match.index > pos) { - s = str.slice(pos + start, Math.min(match.index, pos + (length - renderLen))); - start = 0; // start offset applies only once - out += s; - renderLen += s.length; - } + if(match) { + if(match.index > pos) { + s = str.slice(pos + start, Math.min(match.index, pos + (length - renderLen))); + start = 0; // start offset applies only once + out += s; + renderLen += s.length; + } - out += match[0]; - } - } while(renderLen < length && 0 !== re.lastIndex); + out += match[0]; + } + } while(renderLen < length && 0 !== re.lastIndex); - // remainder - if(pos + start < str.length && renderLen < length) { - out += str.slice(pos + start, (pos + start + (length - renderLen))); - //out += str.slice(pos + start, Math.max(1, pos + (length - renderLen - 1))); - } + // remainder + if(pos + start < str.length && renderLen < length) { + out += str.slice(pos + start, (pos + start + (length - renderLen))); + //out += str.slice(pos + start, Math.max(1, pos + (length - renderLen - 1))); + } - return out; + return out; } // -// Method to return the "rendered" length taking into account Pipe and ANSI color codes. +// Method to return the "rendered" length taking into account Pipe and ANSI color codes. // -// We additionally account for ANSI *forward* movement ESC sequences -// in the form of ESC[C where is the "go forward" character count. +// We additionally account for ANSI *forward* movement ESC sequences +// in the form of ESC[C where is the "go forward" character count. // -// See also https://github.com/chalk/ansi-regex/blob/master/index.js +// See also https://github.com/chalk/ansi-regex/blob/master/index.js // function renderStringLength(s) { - let m; - let pos; - let len = 0; + let m; + let pos; + let len = 0; - const re = ANSI_OR_PIPE_REGEXP; - re.lastIndex = 0; // we recycle the rege; reset - - // - // Loop counting only literal (non-control) sequences - // paying special attention to ESC[C which means forward - // - do { - pos = re.lastIndex; - m = re.exec(s); - - if(m) { - if(m.index > pos) { - len += s.slice(pos, m.index).length; - } - - if('C' === m[3]) { // ESC[C is foward/right - len += parseInt(m[2], 10) || 0; - } - } - } while(0 !== re.lastIndex); - - if(pos < s.length) { - len += s.slice(pos).length; - } - - return len; + const re = ANSI_OR_PIPE_REGEXP; + re.lastIndex = 0; // we recycle the rege; reset + + // + // Loop counting only literal (non-control) sequences + // paying special attention to ESC[C which means forward + // + do { + pos = re.lastIndex; + m = re.exec(s); + + if(m) { + if(m.index > pos) { + len += s.slice(pos, m.index).length; + } + + if('C' === m[3]) { // ESC[C is foward/right + len += parseInt(m[2], 10) || 0; + } + } + } while(0 !== re.lastIndex); + + if(pos < s.length) { + len += s.slice(pos).length; + } + + return len; } -const SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :) +const BYTE_SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :) function formatByteSizeAbbr(byteSize) { - if(0 === byteSize) { - return SIZE_ABBRS[0]; // B - } - - return SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; + if(0 === byteSize) { + return BYTE_SIZE_ABBRS[0]; // B + } + + return BYTE_SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; } -function formatByteSize(byteSize, withAbbr, decimals) { - withAbbr = withAbbr || false; - decimals = decimals || 3; - const i = 0 === byteSize ? byteSize : Math.floor(Math.log(byteSize) / Math.log(1024)); - let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)); - if(withAbbr) { - result += ` ${SIZE_ABBRS[i]}`; - } - return result; +function formatByteSize(byteSize, withAbbr = false, decimals = 2) { + const i = 0 === byteSize ? byteSize : Math.floor(Math.log(byteSize) / Math.log(1024)); + let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)); + if(withAbbr) { + result += ` ${BYTE_SIZE_ABBRS[i]}`; + } + return result; +} + +const COUNT_ABBRS = [ '', 'K', 'M', 'B', 'T', 'P', 'E', 'Z', 'Y' ]; + +function formatCountAbbr(count) { + if(count < 1000) { + return ''; + } + + return COUNT_ABBRS[Math.floor(Math.log(count) / Math.log(1000))]; +} + +function formatCount(count, withAbbr = false, decimals = 2) { + const i = 0 === count ? count : Math.floor(Math.log(count) / Math.log(1000)); + let result = parseFloat((count / Math.pow(1000, i)).toFixed(decimals)); + if(withAbbr) { + result += `${COUNT_ABBRS[i]}`; + } + return result; } -// :TODO: See notes in word_wrap.js about need to consolidate the various ANSI related RegExp's -//const REGEXP_ANSI_CONTROL_CODES = /(\x1b\x5b)([\?=;0-9]*?)([0-9A-ORZcf-npsu=><])/g; -const REGEXP_ANSI_CONTROL_CODES = /(?:\x1b\x5b)([\?=;0-9]*?)([A-ORZcf-npsu=><])/g; // eslint-disable-line no-control-regex -const ANSI_OPCODES_ALLOWED_CLEAN = [ - //'A', 'B', // up, down - //'C', 'D', // right, left - 'm', // color +// :TODO: See notes in word_wrap.js about need to consolidate the various ANSI related RegExp's +//const REGEXP_ANSI_CONTROL_CODES = /(\x1b\x5b)([\?=;0-9]*?)([0-9A-ORZcf-npsu=><])/g; +const REGEXP_ANSI_CONTROL_CODES = /(?:\x1b\x5b)([?=;0-9]*?)([A-ORZcf-npsu=><])/g; // eslint-disable-line no-control-regex +const ANSI_OPCODES_ALLOWED_CLEAN = [ + //'A', 'B', // up, down + //'C', 'D', // right, left + 'm', // color ]; -function cleanControlCodes(input, options) { - let m; - let pos; - let cleaned = ''; - - options = options || {}; - - // - // Loop through |input| adding only allowed ESC - // sequences and literals to |cleaned| - // - do { - pos = REGEXP_ANSI_CONTROL_CODES.lastIndex; - m = REGEXP_ANSI_CONTROL_CODES.exec(input); - - if(m) { - if(m.index > pos) { - cleaned += input.slice(pos, m.index); - } +function stripAnsiControlCodes(input, options) { + let m; + let pos; + let cleaned = ''; - if(options.all) { - continue; - } + options = options || {}; - if(ANSI_OPCODES_ALLOWED_CLEAN.indexOf(m[2].charAt(0)) > -1) { - cleaned += m[0]; - } - } - - } while(0 !== REGEXP_ANSI_CONTROL_CODES.lastIndex); - - // remainder - if(pos < input.length) { - cleaned += input.slice(pos); - } - - return cleaned; -} + // + // Loop through |input| adding only allowed ESC + // sequences and literals to |cleaned| + // + do { + pos = REGEXP_ANSI_CONTROL_CODES.lastIndex; + m = REGEXP_ANSI_CONTROL_CODES.exec(input); -function prepAnsi(input, options, cb) { - if(!input) { - return cb(null, ''); - } + if(m) { + if(m.index > pos) { + cleaned += input.slice(pos, m.index); + } - options.termWidth = options.termWidth || 80; - options.termHeight = options.termHeight || 25; - options.cols = options.cols || options.termWidth || 80; - options.rows = options.rows || options.termHeight || 'auto'; - options.startCol = options.startCol || 1; - options.exportMode = options.exportMode || false; + if(options.all) { + continue; + } - const canvas = Array.from( { length : 'auto' === options.rows ? 25 : options.rows }, () => Array.from( { length : options.cols}, () => new Object() ) ); - const parser = new ANSIEscapeParser( { termHeight : options.termHeight, termWidth : options.termWidth } ); + if(ANSI_OPCODES_ALLOWED_CLEAN.indexOf(m[2].charAt(0)) > -1) { + cleaned += m[0]; + } + } - const state = { - row : 0, - col : 0, - }; + } while(0 !== REGEXP_ANSI_CONTROL_CODES.lastIndex); - let lastRow = 0; + // remainder + if(pos < input.length) { + cleaned += input.slice(pos); + } - function ensureRow(row) { - if(Array.isArray(canvas[row])) { - return; - } - - canvas[row] = Array.from( { length : options.cols}, () => new Object() ); - } - - parser.on('position update', (row, col) => { - state.row = row - 1; - state.col = col - 1; - - lastRow = Math.max(state.row, lastRow); - }); - - parser.on('literal', literal => { - // - // CR/LF are handled for 'position update'; we don't need the chars themselves - // - literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, ''); - - for(let c of literal) { - if(state.col < options.cols && ('auto' === options.rows || state.row < options.rows)) { - ensureRow(state.row); - - canvas[state.row][state.col].char = c; - - if(state.sgr) { - canvas[state.row][state.col].sgr = state.sgr; - state.sgr = null; - } - } - - state.col += 1; - } - }); - - parser.on('control', (match, opCode) => { - // - // Movement is handled via 'position update', so we really only care about - // display opCodes - // - switch(opCode) { - case 'm' : - state.sgr = (state.sgr || '') + match; - break; - - default : - break; - } - }); - - function getLastPopulatedColumn(row) { - let col = row.length; - while(--col > 0) { - if(row[col].char || row[col].sgr) { - break; - } - } - return col; - } - - parser.on('complete', () => { - let output = ''; - let lastSgr = ''; - let line; - - canvas.slice(0, lastRow + 1).forEach(row => { - const lastCol = getLastPopulatedColumn(row) + 1; - - let i; - line = ''; - for(i = 0; i < lastCol; ++i) { - const col = row[i]; - if(col.sgr) { - lastSgr = col.sgr; - } - line += `${col.sgr || ''}${col.char || ' '}`; - } - - output += line; - - if(i < row.length) { - output += `${ANSI.blackBG()}${row.slice(i).map( () => ' ').join('')}${lastSgr}`; - } - - //if(options.startCol + options.cols < options.termWidth || options.forceLineTerm) { - if(options.startCol + i < options.termWidth || options.forceLineTerm) { - output += '\r\n'; - } - }); - - if(options.exportMode) { - // - // If we're in export mode, we do some additional hackery: - // - // * Hard wrap ALL lines at <= 79 *characters* (not visible columns) - // if a line must wrap early, we'll place a ESC[A ESC[C where - // represents chars to get back to the position we were previously at - // - // * Replace contig spaces with ESC[C as well to save... space. - // - // :TODO: this would be better to do as part of the processing above, but this will do for now - const MAX_CHARS = 79 - 8; // 79 max, - 8 for max ESC seq's we may prefix a line with - let exportOutput = ''; - - let m; - let afterSeq; - let wantMore; - let renderStart; - - splitTextAtTerms(output).forEach(fullLine => { - renderStart = 0; - - while(fullLine.length > 0) { - let splitAt; - const ANSI_REGEXP = ANSI.getFullMatchRegExp(); - wantMore = true; - - while((m = ANSI_REGEXP.exec(fullLine))) { - afterSeq = m.index + m[0].length; - - if(afterSeq < MAX_CHARS) { - // after current seq - splitAt = afterSeq; - } else { - if(m.index < MAX_CHARS) { - // before last found seq - splitAt = m.index; - wantMore = false; // can't eat up any more - } - - break; // seq's beyond this point are >= MAX_CHARS - } - } - - if(splitAt) { - if(wantMore) { - splitAt = Math.min(fullLine.length, MAX_CHARS - 1); - } - } else { - splitAt = Math.min(fullLine.length, MAX_CHARS - 1); - } - - const part = fullLine.slice(0, splitAt); - fullLine = fullLine.slice(splitAt); - renderStart += renderStringLength(part); - exportOutput += `${part}\r\n`; - - if(fullLine.length > 0) { // more to go for this line? - exportOutput += `${ANSI.up()}${ANSI.right(renderStart)}`; - } else { - exportOutput += ANSI.up(); - } - } - }); - - return cb(null, exportOutput); - } - - return cb(null, output); - }); - - parser.parse(input); + return cleaned; } function isAnsiLine(line) { - return isAnsi(line);// || renderStringLength(line) < line.length; + return isAnsi(line);// || renderStringLength(line) < line.length; } // -// Returns true if the line is considered "formatted". A line is -// considered formatted if it contains: -// * ANSI -// * Pipe codes -// * Extended (CP437) ASCII - https://www.ascii-codes.com/ -// * Tabs -// * Contigous 3+ spaces before the end of the line +// Returns true if the line is considered "formatted". A line is +// considered formatted if it contains: +// * ANSI +// * Pipe codes +// * Extended (CP437) ASCII - https://www.ascii-codes.com/ +// * Tabs +// * Contigous 3+ spaces before the end of the line // function isFormattedLine(line) { - if(renderStringLength(line) < line.length) { - return true; // ANSI or Pipe Codes - } + if(renderStringLength(line) < line.length) { + return true; // ANSI or Pipe Codes + } - if(line.match(/[\t\x00-\x1f\x80-\xff]/)) { // eslint-disable-line no-control-regex - return true; - } + if(line.match(/[\t\x00-\x1f\x80-\xff]/)) { // eslint-disable-line no-control-regex + return true; + } - if(_.trimEnd(line).match(/[ ]{3,}/)) { - return true; - } + if(_.trimEnd(line).match(/[ ]{3,}/)) { + return true; + } - return false; + return false; } +// :TODO: rename to containsAnsi() function isAnsi(input) { - // - // * ANSI found - limited, just colors - // * Full ANSI art - // * - // - // FULL ANSI art: - // * SAUCE present & reports as ANSI art - // * ANSI clear screen within first 2-3 codes - // * ANSI movement codes (goto, right, left, etc.) - // - // * - /* - readSAUCE(input, (err, sauce) => { - if(!err && ('ANSi' === sauce.fileType || 'ANSiMation' === sauce.fileType)) { - return cb(null, 'ansi'); - } - }); - */ + if(!input || 0 === input.length) { + return false; + } - // :TODO: if a similar method is kept, use exec() until threshold - const ANSI_DET_REGEXP = /(?:\x1b\x5b)[\?=;0-9]*?[ABCDEFGHJKLMSTfhlmnprsu]/g; // eslint-disable-line no-control-regex - const m = input.match(ANSI_DET_REGEXP) || []; - return m.length >= 4; // :TODO: do this reasonably, e.g. a percent or soemthing + // + // * ANSI found - limited, just colors + // * Full ANSI art + // * + // + // FULL ANSI art: + // * SAUCE present & reports as ANSI art + // * ANSI clear screen within first 2-3 codes + // * ANSI movement codes (goto, right, left, etc.) + // + // * + /* + readSAUCE(input, (err, sauce) => { + if(!err && ('ANSi' === sauce.fileType || 'ANSiMation' === sauce.fileType)) { + return cb(null, 'ansi'); + } + }); + */ + + // :TODO: if a similar method is kept, use exec() until threshold + const ANSI_DET_REGEXP = /(?:\x1b\x5b)[?=;0-9]*?[ABCDEFGHJKLMSTfhlmnprsu]/g; // eslint-disable-line no-control-regex + const m = input.match(ANSI_DET_REGEXP) || []; + return m.length >= 4; // :TODO: do this reasonably, e.g. a percent or soemthing } function splitTextAtTerms(s) { - return s.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g); + return s.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g); } + +function wildcardMatch(input, rule) { + const escapeRegex = (s) => s.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); + return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$").test(input); +} \ No newline at end of file diff --git a/core/sys_event_user_log.js b/core/sys_event_user_log.js new file mode 100644 index 00000000..63ae0e55 --- /dev/null +++ b/core/sys_event_user_log.js @@ -0,0 +1,73 @@ +/* jslint node: true */ +'use strict'; + +const Events = require('./events.js'); +const LogNames = require('./user_log_name.js'); + +const DefaultKeepForDays = 365; + +module.exports = function systemEventUserLogInit(statLog) { + const systemEvents = Events.getSystemEvents(); + + const interestedEvents = [ + systemEvents.NewUser, + systemEvents.UserLogin, systemEvents.UserLogoff, + systemEvents.UserUpload, systemEvents.UserDownload, + systemEvents.UserPostMessage, systemEvents.UserSendMail, + systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg, + systemEvents.UserAchievementEarned, + ]; + + const append = (e, n, v) => { + statLog.appendUserLogEntry(e.user, n, v, DefaultKeepForDays); + }; + + Events.addMultipleEventListener(interestedEvents, (event, eventName) => { + const detailHandler = { + [ systemEvents.NewUser ] : (e) => { + append(e, LogNames.NewUser, 1); + }, + [ systemEvents.UserLogin ] : (e) => { + append(e, LogNames.Login, 1); + }, + [ systemEvents.UserLogoff ] : (e) => { + append(e, LogNames.Logoff, e.minutesOnline); + }, + [ systemEvents.UserUpload ] : (e) => { + if(e.files.length) { // we can get here for dupe uploads + append(e, LogNames.UlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0); + append(e, LogNames.UlFileBytes, totalBytes); + } + }, + [ systemEvents.UserDownload ] : (e) => { + if(e.files.length) { + append(e, LogNames.DlFiles, e.files.length); + const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.byteSize, 0); + append(e, LogNames.DlFileBytes, totalBytes); + } + }, + [ systemEvents.UserPostMessage ] : (e) => { + append(e, LogNames.PostMessage, e.areaTag); + }, + [ systemEvents.UserSendMail ] : (e) => { + append(e, LogNames.SendMail, 1); + }, + [ systemEvents.UserRunDoor ] : (e) => { + append(e, LogNames.RunDoor, e.doorTag); + append(e, LogNames.RunDoorMinutes, e.runTimeMinutes); + }, + [ systemEvents.UserSendNodeMsg ] : (e) => { + append(e, LogNames.SendNodeMsg, e.global ? 'global' : 'direct'); + }, + [ systemEvents.UserAchievementEarned ] : (e) => { + append(e, LogNames.AchievementEarned, e.achievementTag); + append(e, LogNames.AchievementPointsEarned, e.points); + } + }[eventName]; + + if(detailHandler) { + detailHandler(event); + } + }); +}; diff --git a/core/system_events.js b/core/system_events.js new file mode 100644 index 00000000..129dacfd --- /dev/null +++ b/core/system_events.js @@ -0,0 +1,27 @@ +/* jslint node: true */ +'use strict'; + +module.exports = { + ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount } + ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount } + TermDetected : 'codes.l33t.enigma.system.term_detected', // { client } + + ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId } + ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson) + MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson) + PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson) + + // User - includes { user, ...} + NewUser : 'codes.l33t.enigma.system.user_new', // { ... } + UserLogin : 'codes.l33t.enigma.system.user_login', // { ... } + UserLogoff : 'codes.l33t.enigma.system.user_logoff', // { ... } + UserUpload : 'codes.l33t.enigma.system.user_upload', // { ..., files[ fileEntry, ...] } + UserDownload : 'codes.l33t.enigma.system.user_download', // { ..., files[ fileEntry, ...] } + UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { ..., areaTag } + UserSendMail : 'codes.l33t.enigma.system.user_send_mail', // { ... } + UserRunDoor : 'codes.l33t.enigma.system.user_run_door', // { ..., runTimeMinutes, doorTag|unknown } + UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', // { ..., global } + UserStatSet : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue } + UserStatIncrement : 'codes.l33t.enigma.system.user_stat_increment', // { ..., statName, statIncrementBy, statValue } + UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // { ..., achievementTag, points, title, text } +}; diff --git a/core/system_log.js b/core/system_log.js new file mode 100644 index 00000000..e753c68b --- /dev/null +++ b/core/system_log.js @@ -0,0 +1,11 @@ +/* jslint node: true */ +'use strict'; + +// +// Common SYSTEM/global log keys +// +module.exports = { + UserAddedRumorz : 'system_rumorz', + UserLoginHistory : 'user_login_history', +}; + diff --git a/core/system_menu_method.js b/core/system_menu_method.js index f968a493..ba9bb699 100644 --- a/core/system_menu_method.js +++ b/core/system_menu_method.js @@ -1,173 +1,207 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const removeClient = require('./client_connections.js').removeClient; -const ansiNormal = require('./ansi_term.js').normal; -const userLogin = require('./user_login.js').userLogin; -const messageArea = require('./message_area.js'); +// ENiGMA½ +const { removeClient } = require('./client_connections.js'); +const ansiNormal = require('./ansi_term.js').normal; +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'); +// deps +const _ = require('lodash'); +const iconv = require('iconv-lite'); -exports.login = login; -exports.logoff = logoff; -exports.prevMenu = prevMenu; -exports.nextMenu = nextMenu; -exports.prevConf = prevConf; -exports.nextConf = nextConf; -exports.prevArea = prevArea; -exports.nextArea = nextArea; -exports.sendForgotPasswordEmail = sendForgotPasswordEmail; +exports.login = login; +exports.login2FA_OTP = login2FA_OTP; +exports.logoff = logoff; +exports.prevMenu = prevMenu; +exports.nextMenu = nextMenu; +exports.prevConf = prevConf; +exports.nextConf = nextConf; +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) { - // login failure - if(err.existingConn && _.has(callingMenu, 'menuConfig.config.tooNodeMenu')) { - return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb); - } else { - // Other error - return callingMenu.prevMenu(cb); - } - } - - // success! - return callingMenu.nextMenu(cb); - }); + userLogin(callingMenu.client, formData.value.username, formData.value.password, err => { + if(err) { + return handleAuthFailures(callingMenu, err, cb); + } + + // success! + return callingMenu.nextMenu(cb); + }); +} + +function login2FA_OTP(callingMenu, formData, extraArgs, cb) { + loginFactor2_OTP(callingMenu.client, formData.value.token, err => { + if(err) { + return handleAuthFailures(callingMenu, err, cb); + } + + // success! + return callingMenu.nextMenu(cb); + }); } function logoff(callingMenu, formData, extraArgs, cb) { - // - // Simple logoff. Note that recording of @ logoff properties/stats - // occurs elsewhere! - // - const client = callingMenu.client; + // + // Simple logoff. Note that recording of @ logoff properties/stats + // occurs elsewhere! + // + const client = callingMenu.client; - setTimeout( () => { - // - // For giggles... - // - client.term.write( - ansiNormal() + '\n' + - iconv.decode(require('crypto').randomBytes(Math.floor(Math.random() * 65) + 20), client.term.outputEncoding) + - 'NO CARRIER', null, () => { + setTimeout( () => { + // + // For giggles... + // + client.term.write( + ansiNormal() + '\n' + + iconv.decode(require('crypto').randomBytes(Math.floor(Math.random() * 65) + 20), client.term.outputEncoding) + + 'NO CARRIER', null, () => { - // after data is written, disconnect & remove the client - removeClient(client); - return cb(null); - } - ); - }, 500); + // after data is written, disconnect & remove the client + removeClient(client); + return cb(null); + } + ); + }, 500); } function prevMenu(callingMenu, formData, extraArgs, cb) { - // :TODO: this is a pretty big hack -- need the whole key map concep there like other places - if(formData.key && 'return' === formData.key.name) { - callingMenu.submitFormData = formData; - } + // :TODO: this is a pretty big hack -- need the whole key map concep there like other places + if(formData.key && 'return' === formData.key.name) { + callingMenu.submitFormData = formData; + } - callingMenu.prevMenu( err => { - if(err) { - callingMenu.client.log.error( { error : err.message }, 'Error attempting to fallback!'); - } - return cb(err); - }); + callingMenu.prevMenu( err => { + if(err) { + callingMenu.client.log.error( { error : err.message }, 'Error attempting to fallback!'); + } + return cb(err); + }); } function nextMenu(callingMenu, formData, extraArgs, cb) { - callingMenu.nextMenu( err => { - if(err) { - callingMenu.client.log.error( { error : err.message}, 'Error attempting to go to next menu!'); - } - return cb(err); - }); + callingMenu.nextMenu( err => { + if(err) { + callingMenu.client.log.error( { error : err.message}, 'Error attempting to go to next menu!'); + } + return cb(err); + }); } -// :TODO: prev/nextConf, prev/nextArea should use a NYI MenuModule.redraw() or such -- avoid pop/goto() hack! +// :TODO: need redrawMenu() and MenuModule.redraw() function reloadMenu(menu, cb) { - const prevMenu = menu.client.menuStack.pop(); - prevMenu.instance.leave(); - menu.client.menuStack.goto(prevMenu.name, cb); + return menu.reload(cb); } function prevConf(callingMenu, formData, extraArgs, cb) { - const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client); - const currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties.message_conf_tag) || confs.length; + const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client); + const currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties[UserProps.MessageConfTag]) || confs.length; - messageArea.changeMessageConference(callingMenu.client, confs[currIndex - 1].confTag, err => { - if(err) { - return cb(err); // logged within changeMessageConference() - } + messageArea.changeMessageConference(callingMenu.client, confs[currIndex - 1].confTag, err => { + if(err) { + return cb(err); // logged within changeMessageConference() + } - return reloadMenu(callingMenu, cb); - }); + return reloadMenu(callingMenu, cb); + }); } function nextConf(callingMenu, formData, extraArgs, cb) { - const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client); - let currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties.message_conf_tag); + const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client); + let currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties[UserProps.MessageConfTag]); - if(currIndex === confs.length - 1) { - currIndex = -1; - } + if(currIndex === confs.length - 1) { + currIndex = -1; + } - messageArea.changeMessageConference(callingMenu.client, confs[currIndex + 1].confTag, err => { - if(err) { - return cb(err); // logged within changeMessageConference() - } - - return reloadMenu(callingMenu, cb); - }); + messageArea.changeMessageConference(callingMenu.client, confs[currIndex + 1].confTag, err => { + if(err) { + return cb(err); // logged within changeMessageConference() + } + + return reloadMenu(callingMenu, cb); + }); } function prevArea(callingMenu, formData, extraArgs, cb) { - const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties.message_conf_tag); - const currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties.message_area_tag) || areas.length; + const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties[UserProps.MessageConfTag]); + const currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties[UserProps.MessageAreaTag]) || areas.length; - messageArea.changeMessageArea(callingMenu.client, areas[currIndex - 1].areaTag, err => { - if(err) { - return cb(err); // logged within changeMessageArea() - } - - return reloadMenu(callingMenu, cb); - }); + messageArea.changeMessageArea(callingMenu.client, areas[currIndex - 1].areaTag, err => { + if(err) { + return cb(err); // logged within changeMessageArea() + } + + return reloadMenu(callingMenu, cb); + }); } function nextArea(callingMenu, formData, extraArgs, cb) { - const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties.message_conf_tag); - let currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties.message_area_tag); + const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties[UserProps.MessageConfTag]); + let currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties[UserProps.MessageAreaTag]); - if(currIndex === areas.length - 1) { - currIndex = -1; - } + if(currIndex === areas.length - 1) { + currIndex = -1; + } - messageArea.changeMessageArea(callingMenu.client, areas[currIndex + 1].areaTag, err => { - if(err) { - return cb(err); // logged within changeMessageArea() - } + messageArea.changeMessageArea(callingMenu.client, areas[currIndex + 1].areaTag, err => { + if(err) { + return cb(err); // logged within changeMessageArea() + } - return reloadMenu(callingMenu, cb); - }); + return reloadMenu(callingMenu, cb); + }); } function sendForgotPasswordEmail(callingMenu, formData, extraArgs, cb) { - const username = formData.value.username || callingMenu.client.user.username; + const username = formData.value.username || callingMenu.client.user.username; - const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset; - - WebPasswordReset.sendForgotPasswordEmail(username, err => { - if(err) { - callingMenu.client.log.warn( { err : err.message }, 'Failed sending forgot password email'); - } + const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset; - if(extraArgs.next) { - return callingMenu.gotoMenu(extraArgs.next, cb); - } - - return logoff(callingMenu, formData, extraArgs, cb); - }); + WebPasswordReset.sendForgotPasswordEmail(username, err => { + if(err) { + callingMenu.client.log.warn( { err : err.message }, 'Failed sending forgot password email'); + } + + if(extraArgs.next) { + return callingMenu.gotoMenu(extraArgs.next, cb); + } + + return logoff(callingMenu, formData, extraArgs, cb); + }); } diff --git a/core/system_property.js b/core/system_property.js new file mode 100644 index 00000000..ca3cf7cd --- /dev/null +++ b/core/system_property.js @@ -0,0 +1,33 @@ +/* jslint node: true */ +'use strict'; + +// +// Common SYSTEM/global properties/stats used throughout the system. +// +// This IS NOT a full list. Custom modules & the like can create +// their own! +// +module.exports = { + LoginCount : 'login_count', + LoginsToday : 'logins_today', // non-persistent + + FileBaseAreaStats : 'file_base_area_stats', // object - see file_base_area.js::getAreaStats + FileUlTotalCount : 'ul_total_count', + FileUlTotalBytes : 'ul_total_bytes', + FileDlTotalCount : 'dl_total_count', + FileDlTotalBytes : 'dl_total_bytes', + + MessageTotalCount : 'message_post_total_count', // total non-private messages on the system; non-persistent + MessagesToday : 'message_post_today', // non-private messages posted/imported today; non-persistent + + // begin +op non-persistent... + SysOpUsername : 'sysop_username', + SysOpRealName : 'sysop_real_name', + SysOpLocation : 'sysop_location', + SysOpAffiliations : 'sysop_affiliation', + SysOpSex : 'sysop_sex', + SysOpEmailAddress : 'sysop_email_address', + // end +op non-persistent + + NextRandomRumor : 'random_rumor', +}; diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 63c7b2bc..5eb0fc4a 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -1,123 +1,156 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const User = require('./user.js'); -const Config = require('./config.js').config; -const Log = require('./logger.js').log; +// ENiGMA½ +const User = require('./user.js'); +const Config = require('./config.js').get; +const Log = require('./logger.js').log; +const { getAddressedToInfo } = require('./mail_util.js'); +const Message = require('./message.js'); -// deps -const fs = require('graceful-fs'); +// deps +const fs = require('graceful-fs'); -exports.validateNonEmpty = validateNonEmpty; -exports.validateMessageSubject = validateMessageSubject; -exports.validateUserNameAvail = validateUserNameAvail; -exports.validateUserNameExists = validateUserNameExists; -exports.validateEmailAvail = validateEmailAvail; -exports.validateBirthdate = validateBirthdate; -exports.validatePasswordSpec = validatePasswordSpec; +exports.validateNonEmpty = validateNonEmpty; +exports.validateMessageSubject = validateMessageSubject; +exports.validateUserNameAvail = validateUserNameAvail; +exports.validateUserNameExists = validateUserNameExists; +exports.validateUserNameOrRealNameExists = validateUserNameOrRealNameExists; +exports.validateGeneralMailAddressedTo = validateGeneralMailAddressedTo; +exports.validateEmailAvail = validateEmailAvail; +exports.validateBirthdate = validateBirthdate; +exports.validatePasswordSpec = validatePasswordSpec; function validateNonEmpty(data, cb) { - return cb(data && data.length > 0 ? null : new Error('Field cannot be empty')); + return cb(data && data.length > 0 ? null : new Error('Field cannot be empty')); } function validateMessageSubject(data, cb) { - return cb(data && data.length > 1 ? null : new Error('Subject too short')); + return cb(data && data.length > 1 ? null : new Error('Subject too short')); } function validateUserNameAvail(data, cb) { - if(!data || data.length < Config.users.usernameMin) { - cb(new Error('Username too short')); - } else if(data.length > Config.users.usernameMax) { - // generally should be unreached due to view restraints - return cb(new Error('Username too long')); - } else { - const usernameRegExp = new RegExp(Config.users.usernamePattern); - const invalidNames = Config.users.newUserNames + Config.users.badUserNames; + const config = Config(); + if(!data || data.length < config.users.usernameMin) { + cb(new Error('Username too short')); + } else if(data.length > config.users.usernameMax) { + // generally should be unreached due to view restraints + return cb(new Error('Username too long')); + } else { + const usernameRegExp = new RegExp(config.users.usernamePattern); + const invalidNames = config.users.newUserNames + config.users.badUserNames; - if(!usernameRegExp.test(data)) { - return cb(new Error('Username contains invalid characters')); - } else if(invalidNames.indexOf(data.toLowerCase()) > -1) { - return cb(new Error('Username is blacklisted')); - } else if(/^[0-9]+$/.test(data)) { - return cb(new Error('Username cannot be a number')); - } else { - User.getUserIdAndName(data, function userIdAndName(err) { - if(!err) { // err is null if we succeeded -- meaning this user exists already - return cb(new Error('Username unavailable')); - } - - return cb(null); - }); - } - } + if(!usernameRegExp.test(data)) { + return cb(new Error('Username contains invalid characters')); + } else if(invalidNames.indexOf(data.toLowerCase()) > -1) { + return cb(new Error('Username is blacklisted')); + } else if(/^[0-9]+$/.test(data)) { + return cb(new Error('Username cannot be a number')); + } else { + // a new user name cannot be an existing user name or an existing real name + User.getUserIdAndNameByLookup(data, function userIdAndName(err) { + if(!err) { // err is null if we succeeded -- meaning this user exists already + return cb(new Error('Username unavailable')); + } + + return cb(null); + }); + } + } } +const invalidUserNameError = () => new Error('Invalid username'); + function validateUserNameExists(data, cb) { - const invalidUserNameError = new Error('Invalid username'); + if(0 === data.length) { + return cb(invalidUserNameError()); + } - if(0 === data.length) { - return cb(invalidUserNameError); - } + User.getUserIdAndName(data, (err) => { + return cb(err ? invalidUserNameError() : null); + }); +} - User.getUserIdAndName(data, (err) => { - return cb(err ? invalidUserNameError : null); - }); +function validateUserNameOrRealNameExists(data, cb) { + if(0 === data.length) { + return cb(invalidUserNameError()); + } + + User.getUserIdAndNameByLookup(data, err => { + return cb(err ? invalidUserNameError() : null); + }); +} + +function validateGeneralMailAddressedTo(data, cb) { + // + // Allow any supported addressing: + // - Local username or real name + // - Supported remote flavors such as FTN, email, ... + // + // :TODO: remove hard-coded FTN check here. We need a decent way to register global supported flavors with modules. + const addressedToInfo = getAddressedToInfo(data); + + if(Message.AddressFlavor.FTN === addressedToInfo.flavor) { + return cb(null); + } + + return validateUserNameOrRealNameExists(data, cb); } function validateEmailAvail(data, cb) { - // - // This particular method allows empty data - e.g. no email entered - // - if(!data || 0 === data.length) { - return cb(null); - } + // + // This particular method allows empty data - e.g. no email entered + // + if(!data || 0 === data.length) { + return cb(null); + } - // - // Otherwise, it must be a valid email. We'll be pretty lose here, like - // the HTML5 spec. - // - // See http://stackoverflow.com/questions/7786058/find-the-regex-used-by-html5-forms-for-validation - // - const emailRegExp = /[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9-]+(.[a-z0-9-]+)*/; - if(!emailRegExp.test(data)) { - return cb(new Error('Invalid email address')); - } + // + // Otherwise, it must be a valid email. We'll be pretty lose here, like + // the HTML5 spec. + // + // See http://stackoverflow.com/questions/7786058/find-the-regex-used-by-html5-forms-for-validation + // + const emailRegExp = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(.[a-z0-9-]+)*/; + if(!emailRegExp.test(data)) { + return cb(new Error('Invalid email address')); + } - User.getUserIdsWithProperty('email_address', data, function userIdsWithEmail(err, uids) { - if(err) { - return cb(new Error('Internal system error')); - } else if(uids.length > 0) { - return cb(new Error('Email address not unique')); - } - - return cb(null); - }); + User.getUserIdsWithProperty('email_address', data, function userIdsWithEmail(err, uids) { + if(err) { + return cb(new Error('Internal system error')); + } else if(uids.length > 0) { + return cb(new Error('Email address not unique')); + } + + return cb(null); + }); } function validateBirthdate(data, cb) { - // :TODO: check for dates in the future, or > reasonable values - return cb(isNaN(Date.parse(data)) ? new Error('Invalid birthdate') : null); + // :TODO: check for dates in the future, or > reasonable values + return cb(isNaN(Date.parse(data)) ? new Error('Invalid birthdate') : null); } function validatePasswordSpec(data, cb) { - if(!data || data.length < Config.users.passwordMin) { - return cb(new Error('Password too short')); - } + const config = Config(); + if(!data || data.length < config.users.passwordMin) { + return cb(new Error('Password too short')); + } - // check badpass, if avail - fs.readFile(Config.users.badPassFile, 'utf8', (err, passwords) => { - if(err) { - Log.warn( { error : err.message }, 'Cannot read bad pass file'); - return cb(null); - } + // check badpass, if avail + fs.readFile(config.users.badPassFile, 'utf8', (err, passwords) => { + if(err) { + Log.warn( { error : err.message }, 'Cannot read bad pass file'); + return cb(null); + } - passwords = passwords.toString().split(/\r\n|\n/g); - if(passwords.includes(data)) { - return cb(new Error('Password is too common')); - } + passwords = passwords.toString().split(/\r\n|\n/g); + if(passwords.includes(data)) { + return cb(new Error('Password is too common')); + } - return cb(null); - }); + return cb(null); + }); } diff --git a/core/telnet_bridge.js b/core/telnet_bridge.js new file mode 100644 index 00000000..5c43ced6 --- /dev/null +++ b/core/telnet_bridge.js @@ -0,0 +1,225 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const resetScreen = require('./ansi_term.js').resetScreen; +const setSyncTermFontWithAlias = require('./ansi_term.js').setSyncTermFontWithAlias; + +// deps +const async = require('async'); +const _ = require('lodash'); +const net = require('net'); +const EventEmitter = require('events'); + +const { + TelnetSocket, + TelnetSpec : + { + Commands, + Options, + SubNegotiationCommands, + }, +} = require('telnet-socket'); + +/* + Expected configuration block: + + { + module: telnet_bridge + ... + config: { + host: somehost.net + port: 23 + } + } +*/ + +// :TODO: ENH: Support nodeMax and tooManyArt +exports.moduleInfo = { + name : 'Telnet Bridge', + desc : 'Connect to other Telnet Systems', + author : 'Andrew Pamment', +}; + +const IAC_DO_TERM_TYPE = TelnetSocket.commandBuffer( + Commands.DO, + Options.TTYPE, +); + +class TelnetClientConnection extends EventEmitter { + constructor(client) { + super(); + + this.client = client; + } + + + restorePipe() { + if(!this.pipeRestored) { + this.pipeRestored = true; + this.client.dataPassthrough = false; + + // client may have bailed + if(null !== _.get(this, 'client.term.output', null)) { + if(this.bridgeConnection) { + this.client.term.output.unpipe(this.bridgeConnection); + } + this.client.term.output.resume(); + } + } + } + + connect(connectOpts) { + this.bridgeConnection = net.createConnection(connectOpts, () => { + this.emit('connected'); + + this.pipeRestored = false; + this.client.dataPassthrough = true; + this.client.term.output.pipe(this.bridgeConnection); + }); + + this.bridgeConnection.on('data', data => { + this.client.term.rawWrite(data); + + // + // Wait for a terminal type request, and send it exactly once. + // This is enough (in additional to other negotiations handled in telnet.js) + // to get us in on most systems + // + if(!this.termSent && data.indexOf(IAC_DO_TERM_TYPE) > -1) { + this.termSent = true; + this.bridgeConnection.write(this.getTermTypeNegotiationBuffer()); + } + }); + + this.bridgeConnection.once('end', () => { + this.restorePipe(); + this.emit('end'); + }); + + this.bridgeConnection.once('error', err => { + this.restorePipe(); + this.emit('end', err); + }); + } + + disconnect() { + if(this.bridgeConnection) { + this.bridgeConnection.end(); + } + } + + destroy() { + if(this.bridgeConnection) { + this.bridgeConnection.destroy(); + this.bridgeConnection.removeAllListeners(); + this.restorePipe(); + this.emit('end'); + } + } + + getTermTypeNegotiationBuffer() { + // + // Create a TERMINAL-TYPE sub negotiation buffer using the + // actual/current terminal type. + // + const sendTermType = TelnetSocket.commandBuffer( + Commands.SB, + Options.TTYPE, + [ + SubNegotiationCommands.IS, + ...Buffer.from(this.client.term.termType), // e.g. "ansi" + Commands.IAC, + Commands.SE, + ] + ); + return sendTermType; + } +} + +exports.getModule = class TelnetBridgeModule extends MenuModule { + constructor(options) { + super(options); + + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), options.extraArgs); + this.config.port = this.config.port || 23; + } + + initSequence() { + let clientTerminated; + const self = this; + + async.series( + [ + function validateConfig(callback) { + if(_.isString(self.config.host) && + _.isNumber(self.config.port)) + { + callback(null); + } else { + callback(new Error('Configuration is missing required option(s)')); + } + }, + function createTelnetBridge(callback) { + const connectOpts = { + port : self.config.port, + host : self.config.host, + }; + + self.client.term.write(resetScreen()); + self.client.term.write( + ` Connecting to ${connectOpts.host}, please wait...\n (Press ESC to cancel)\n` + ); + + const telnetConnection = new TelnetClientConnection(self.client); + + const connectionKeyPressHandler = (ch, key) => { + if('escape' === key.name) { + self.client.removeListener('key press', connectionKeyPressHandler); + telnetConnection.destroy(); + } + }; + + self.client.on('key press', connectionKeyPressHandler); + + telnetConnection.on('connected', () => { + self.client.removeListener('key press', connectionKeyPressHandler); + self.client.log.info(connectOpts, 'Telnet bridge connection established'); + + if(self.config.font) { + self.client.term.rawWrite(setSyncTermFontWithAlias(self.config.font)); + } + + self.client.once('end', () => { + self.client.log.info('Connection ended. Terminating connection'); + clientTerminated = true; + telnetConnection.disconnect(); + }); + }); + + telnetConnection.on('end', err => { + self.client.removeListener('key press', connectionKeyPressHandler); + + if(err) { + self.client.log.info(`Telnet bridge connection error: ${err.message}`); + } + + callback(clientTerminated ? new Error('Client connection terminated') : null); + }); + + telnetConnection.connect(connectOpts); + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'Telnet connection error'); + } + + if(!clientTerminated) { + self.prevMenu(); + } + } + ); + } +}; diff --git a/core/text_view.js b/core/text_view.js index f1b3ee7e..2a5c93c5 100644 --- a/core/text_view.js +++ b/core/text_view.js @@ -1,262 +1,221 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const View = require('./view.js').View; -const miscUtil = require('./misc_util.js'); -const ansi = require('./ansi_term.js'); -const padStr = require('./string_util.js').pad; -const stylizeString = require('./string_util.js').stylizeString; -const renderSubstr = require('./string_util.js').renderSubstr; -const renderStringLength = require('./string_util.js').renderStringLength; -const pipeToAnsi = require('./color_codes.js').pipeToAnsi; -const stripAllLineFeeds = require('./string_util.js').stripAllLineFeeds; +// ENiGMA½ +const View = require('./view.js').View; +const miscUtil = require('./misc_util.js'); +const ansi = require('./ansi_term.js'); +const padStr = require('./string_util.js').pad; +const stylizeString = require('./string_util.js').stylizeString; +const renderSubstr = require('./string_util.js').renderSubstr; +const renderStringLength = require('./string_util.js').renderStringLength; +const pipeToAnsi = require('./color_codes.js').pipeToAnsi; +const stripAllLineFeeds = require('./string_util.js').stripAllLineFeeds; -// deps -const util = require('util'); -const _ = require('lodash'); +// deps +const util = require('util'); +const _ = require('lodash'); -exports.TextView = TextView; +exports.TextView = TextView; function TextView(options) { - if(options.dimens) { - options.dimens.height = 1; // force height of 1 for TextView's & sub classes - } + if(options.dimens) { + options.dimens.height = 1; // force height of 1 for TextView's & sub classes + } - View.call(this, options); + View.call(this, options); - if(options.maxLength) { - this.maxLength = options.maxLength; - } else { - this.maxLength = this.client.term.termWidth - this.position.col; - } + if(options.maxLength) { + this.maxLength = options.maxLength; + } else { + this.maxLength = this.client.term.termWidth - this.position.col; + } - this.fillChar = miscUtil.valueWithDefault(options.fillChar, ' ').substr(0, 1); - this.justify = options.justify || 'right'; - this.resizable = miscUtil.valueWithDefault(options.resizable, true); - this.horizScroll = miscUtil.valueWithDefault(options.horizScroll, true); - - if(_.isString(options.textOverflow)) { - this.textOverflow = options.textOverflow; - } + this.fillChar = renderSubstr(miscUtil.valueWithDefault(options.fillChar, ' '), 0, 1); + this.justify = options.justify || 'left'; + this.resizable = miscUtil.valueWithDefault(options.resizable, true); + this.horizScroll = miscUtil.valueWithDefault(options.horizScroll, true); - if(_.isString(options.textMaskChar) && 1 === options.textMaskChar.length) { - this.textMaskChar = options.textMaskChar; - } + if(_.isString(options.textOverflow)) { + this.textOverflow = options.textOverflow; + } -/* - this.drawText = function(s) { + if(_.isString(options.textMaskChar) && 1 === options.textMaskChar.length) { + this.textMaskChar = options.textMaskChar; + } - // - // |<- this.maxLength - // ABCDEFGHIJK - // |ABCDEFG| ^_ this.text.length - // ^-- this.dimens.width - // - let textToDraw = _.isString(this.textMaskChar) ? - new Array(s.length + 1).join(this.textMaskChar) : - stylizeString(s, this.hasFocus ? this.focusTextStyle : this.textStyle); - - if(textToDraw.length > this.dimens.width) { - if(this.hasFocus) { - if(this.horizScroll) { - textToDraw = textToDraw.substr(textToDraw.length - this.dimens.width, textToDraw.length); - } - } else { - if(textToDraw.length > this.dimens.width) { - if(this.textOverflow && - this.dimens.width > this.textOverflow.length && - textToDraw.length - this.textOverflow.length >= this.textOverflow.length) - { - textToDraw = textToDraw.substr(0, this.dimens.width - this.textOverflow.length) + this.textOverflow; - } else { - textToDraw = textToDraw.substr(0, this.dimens.width); - } - } - } - } + /* + this.drawText = function(s) { - this.client.term.write(padStr( - textToDraw, - this.dimens.width + 1, - this.fillChar, - this.justify, - this.hasFocus ? this.getFocusSGR() : this.getSGR(), - this.getStyleSGR(1) || this.getSGR() - ), false); - }; + // + // |<- this.maxLength + // ABCDEFGHIJK + // |ABCDEFG| ^_ this.text.length + // ^-- this.dimens.width + // + let textToDraw = _.isString(this.textMaskChar) ? + new Array(s.length + 1).join(this.textMaskChar) : + stylizeString(s, this.hasFocus ? this.focusTextStyle : this.textStyle); + + if(textToDraw.length > this.dimens.width) { + if(this.hasFocus) { + if(this.horizScroll) { + textToDraw = textToDraw.substr(textToDraw.length - this.dimens.width, textToDraw.length); + } + } else { + if(textToDraw.length > this.dimens.width) { + if(this.textOverflow && + this.dimens.width > this.textOverflow.length && + textToDraw.length - this.textOverflow.length >= this.textOverflow.length) + { + textToDraw = textToDraw.substr(0, this.dimens.width - this.textOverflow.length) + this.textOverflow; + } else { + textToDraw = textToDraw.substr(0, this.dimens.width); + } + } + } + } + + this.client.term.write(padStr( + textToDraw, + this.dimens.width + 1, + this.fillChar, + this.justify, + this.hasFocus ? this.getFocusSGR() : this.getSGR(), + this.getStyleSGR(1) || this.getSGR() + ), false); + }; */ - this.drawText = function(s) { + this.drawText = function(s) { - // - // |<- this.maxLength - // ABCDEFGHIJK - // |ABCDEFG| ^_ this.text.length - // ^-- this.dimens.width - // - let renderLength = renderStringLength(s); // initial; may be adjusted below: + // + // |<- this.maxLength + // ABCDEFGHIJK + // |ABCDEFG| ^_ this.text.length + // ^-- this.dimens.width + // + let renderLength = renderStringLength(s); // initial; may be adjusted below: - let textToDraw = _.isString(this.textMaskChar) ? - new Array(renderLength + 1).join(this.textMaskChar) : - stylizeString(s, this.hasFocus ? this.focusTextStyle : this.textStyle); - - renderLength = renderStringLength(textToDraw); - - if(renderLength >= this.dimens.width) { - if(this.hasFocus) { - if(this.horizScroll) { - textToDraw = renderSubstr(textToDraw, renderLength - this.dimens.width, renderLength); - } - } else { - if(this.textOverflow && - this.dimens.width > this.textOverflow.length && - renderLength - this.textOverflow.length >= this.textOverflow.length) - { - textToDraw = renderSubstr(textToDraw, 0, this.dimens.width - this.textOverflow.length) + this.textOverflow; - } else { - textToDraw = renderSubstr(textToDraw, 0, this.dimens.width); - } - } - } + let textToDraw = _.isString(this.textMaskChar) ? + new Array(renderLength + 1).join(this.textMaskChar) : + stylizeString(s, this.hasFocus ? this.focusTextStyle : this.textStyle); - this.client.term.write( - padStr( - textToDraw, - this.dimens.width + 1, - this.fillChar, - this.justify, - this.hasFocus ? this.getFocusSGR() : this.getSGR(), - this.getStyleSGR(1) || this.getSGR() - ), - false // no converting CRLF needed - ); - }; + renderLength = renderStringLength(textToDraw); + + if(renderLength >= this.dimens.width) { + if(this.hasFocus) { + if(this.horizScroll) { + textToDraw = renderSubstr(textToDraw, renderLength - this.dimens.width, renderLength); + } + } else { + if(this.textOverflow && + this.dimens.width > this.textOverflow.length && + renderLength - this.textOverflow.length >= this.textOverflow.length) + { + textToDraw = renderSubstr(textToDraw, 0, this.dimens.width - this.textOverflow.length) + this.textOverflow; + } else { + textToDraw = renderSubstr(textToDraw, 0, this.dimens.width); + } + } + } + + const renderedFillChar = pipeToAnsi(this.fillChar); + + this.client.term.write( + padStr( + textToDraw, + this.dimens.width + 1, + renderedFillChar, //this.fillChar, + this.justify, + this.hasFocus ? this.getFocusSGR() : this.getSGR(), + this.getStyleSGR(1) || this.getSGR(), + true // use render len + ), + false // no converting CRLF needed + ); + }; - this.getEndOfTextColumn = function() { - var offset = Math.min(this.text.length, this.dimens.width); - return this.position.col + offset; - }; + this.getEndOfTextColumn = function() { + var offset = Math.min(this.text.length, this.dimens.width); + return this.position.col + offset; + }; - this.setText(options.text || '', false); // false=do not redraw now + this.setText(options.text || '', false); // false=do not redraw now } util.inherits(TextView, View); TextView.prototype.redraw = function() { - // - // A lot of views will get an initial redraw() with empty text (''). We can short - // circuit this by NOT doing any of the work if this is the initial drawText - // and there is no actual text (e.g. save SGR's and processing) - // - if(!this.hasDrawnOnce) { - if(_.isUndefined(this.text)) { - return; - } - } - this.hasDrawnOnce = true; + // + // A lot of views will get an initial redraw() with empty text (''). We can short + // circuit this by NOT doing any of the work if this is the initial drawText + // and there is no actual text (e.g. save SGR's and processing) + // + if(!this.hasDrawnOnce) { + if(_.isUndefined(this.text)) { + return; + } + } + this.hasDrawnOnce = true; - TextView.super_.prototype.redraw.call(this); + TextView.super_.prototype.redraw.call(this); - if(_.isString(this.text)) { - this.drawText(this.text); - } + if(_.isString(this.text)) { + this.drawText(this.text); + } }; TextView.prototype.setFocus = function(focused) { - TextView.super_.prototype.setFocus.call(this, focused); + TextView.super_.prototype.setFocus.call(this, focused); - this.redraw(); - - this.client.term.write(ansi.goto(this.position.row, this.getEndOfTextColumn())); - this.client.term.write(this.getFocusSGR()); + this.redraw(); + + this.client.term.write(ansi.goto(this.position.row, this.getEndOfTextColumn())); + this.client.term.write(this.getFocusSGR()); }; TextView.prototype.getData = function() { - return this.text; + return this.text; }; TextView.prototype.setText = function(text, redraw) { - redraw = _.isBoolean(redraw) ? redraw : true; + redraw = _.isBoolean(redraw) ? redraw : true; - if(!_.isString(text)) { - text = text.toString(); - } + if(!_.isString(text)) { // allow |text| to be numbers/etc. + text = text.toString(); + } - text = pipeToAnsi(stripAllLineFeeds(text), this.client); // expand MCI/etc. + this.text = pipeToAnsi(stripAllLineFeeds(text), this.client); // expand MCI/etc. + if(this.maxLength > 0) { + this.text = renderSubstr(this.text, 0, this.maxLength); + } - var widthDelta = 0; - if(this.text && this.text !== text) { - widthDelta = Math.abs(renderStringLength(this.text) - renderStringLength(text)); - } + // :TODO: it would be nice to be able to stylize strings with MCI and {special} MCI syntax, e.g. "|BN {UN!toUpper}" + this.text = stylizeString(this.text, this.hasFocus ? this.focusTextStyle : this.textStyle); - this.text = text; - - if(this.maxLength > 0) { - this.text = renderSubstr(this.text, 0, this.maxLength); - //this.text = this.text.substr(0, this.maxLength); - } - - // :TODO: it would be nice to be able to stylize strings with MCI and {special} MCI syntax, e.g. "|BN {UN!toUpper}" - this.text = stylizeString(this.text, this.hasFocus ? this.focusTextStyle : this.textStyle); - - if(this.autoScale.width) { - this.dimens.width = renderStringLength(this.text) + widthDelta; - } - - if(redraw) { - this.redraw(); - } + if(redraw) { + this.redraw(); + } }; -/* -TextView.prototype.setText = function(text) { - if(!_.isString(text)) { - text = text.toString(); - } - - var widthDelta = 0; - if(this.text && this.text !== text) { - widthDelta = Math.abs(this.text.length - text.length); - } - - this.text = text; - - if(this.maxLength > 0) { - this.text = this.text.substr(0, this.maxLength); - } - - this.text = stylizeString(this.text, this.hasFocus ? this.focusTextStyle : this.textStyle); - - //if(this.resizable) { - // this.dimens.width = this.text.length + widthDelta; - //} - - if(this.autoScale.width) { - this.dimens.width = this.text.length + widthDelta; - } - - this.redraw(); -}; -*/ - TextView.prototype.clearText = function() { - this.setText(''); + this.setText(''); }; TextView.prototype.setPropertyValue = function(propName, value) { - switch(propName) { - case 'textMaskChar' : this.textMaskChar = value.substr(0, 1); break; - case 'textOverflow' : this.textOverflow = value; break; - case 'maxLength' : this.maxLength = parseInt(value, 10); break; - case 'password' : - if(true === value) { - this.textMaskChar = this.client.currentTheme.helpers.getPasswordChar(); - } - break; - } - + switch(propName) { + case 'textMaskChar' : this.textMaskChar = value.substr(0, 1); break; + case 'textOverflow' : this.textOverflow = value; break; + case 'maxLength' : this.maxLength = parseInt(value, 10); break; + case 'password' : + if(true === value) { + this.textMaskChar = this.client.currentTheme.helpers.getPasswordChar(); + } + break; + } - TextView.super_.prototype.setPropertyValue.call(this, propName, value); + + TextView.super_.prototype.setPropertyValue.call(this, propName, value); }; diff --git a/core/theme.js b/core/theme.js index c5b90233..a2d31df1 100644 --- a/core/theme.js +++ b/core/theme.js @@ -1,168 +1,174 @@ /* jslint node: true */ 'use strict'; -const Config = require('./config.js').config; -const art = require('./art.js'); -const ansi = require('./ansi_term.js'); -const Log = require('./logger.js').log; -const configCache = require('./config_cache.js'); -const getFullConfig = require('./config_util.js').getFullConfig; -const asset = require('./asset.js'); -const ViewController = require('./view_controller.js').ViewController; -const Errors = require('./enig_error.js').Errors; -const ErrorReasons = require('./enig_error.js').ErrorReasons; +const Config = require('./config.js').get; +const art = require('./art.js'); +const ansi = require('./ansi_term.js'); +const Log = require('./logger.js').log; +const ConfigCache = require('./config_cache.js'); +const getFullConfig = require('./config_util.js').getFullConfig; +const asset = require('./asset.js'); +const ViewController = require('./view_controller.js').ViewController; +const Errors = require('./enig_error.js').Errors; +const ErrorReasons = require('./enig_error.js').ErrorReasons; +const Events = require('./events.js'); +const AnsiPrep = require('./ansi_prep.js'); +const UserProps = require('./user_property.js'); -const fs = require('graceful-fs'); -const paths = require('path'); -const async = require('async'); -const _ = require('lodash'); -const assert = require('assert'); +// deps +const fs = require('graceful-fs'); +const paths = require('path'); +const async = require('async'); +const _ = require('lodash'); +const assert = require('assert'); -exports.getThemeArt = getThemeArt; -exports.getAvailableThemes = getAvailableThemes; -exports.getRandomTheme = getRandomTheme; +exports.getThemeArt = getThemeArt; +exports.getAvailableThemes = getAvailableThemes; +exports.getRandomTheme = getRandomTheme; exports.setClientTheme = setClientTheme; -exports.initAvailableThemes = initAvailableThemes; -exports.displayThemeArt = displayThemeArt; -exports.displayThemedPause = displayThemedPause; -exports.displayThemedPrompt = displayThemedPrompt; -exports.displayThemedAsset = displayThemedAsset; +exports.initAvailableThemes = initAvailableThemes; +exports.displayPreparedArt = displayPreparedArt; +exports.displayThemeArt = displayThemeArt; +exports.displayThemedPause = displayThemedPause; +exports.displayThemedPrompt = displayThemedPrompt; +exports.displayThemedAsset = displayThemedAsset; function refreshThemeHelpers(theme) { - // - // Create some handy helpers - // - theme.helpers = { - getPasswordChar : function() { - var pwChar = Config.defaults.passwordChar; - if(_.has(theme, 'customization.defaults.general')) { - var themePasswordChar = theme.customization.defaults.general.passwordChar; - if(_.isString(themePasswordChar)) { - pwChar = themePasswordChar.substr(0, 1); - } else if(_.isNumber(themePasswordChar)) { - pwChar = String.fromCharCode(themePasswordChar); - } - } - return pwChar; - }, - getDateFormat : function(style) { - style = style || 'short'; + // + // Create some handy helpers + // + theme.helpers = { + getPasswordChar : function() { + let pwChar = _.get( + theme, + 'customization.defaults.passwordChar', + Config().theme.passwordChar + ); - var format = Config.defaults.dateFormat[style] || 'MM/DD/YYYY'; + if(_.isString(pwChar)) { + pwChar = pwChar.substr(0, 1); + } else if(_.isNumber(pwChar)) { + pwChar = String.fromCharCode(pwChar); + } - if(_.has(theme, 'customization.defaults.dateFormat')) { - return theme.customization.defaults.dateFormat[style] || format; - } - return format; - }, - getTimeFormat : function(style) { - style = style || 'short'; - - var format = Config.defaults.timeFormat[style] || 'h:mm a'; - - if(_.has(theme, 'customization.defaults.timeFormat')) { - return theme.customization.defaults.timeFormat[style] || format; - } - return format; - }, - getDateTimeFormat : function(style) { - style = style || 'short'; - - var format = Config.defaults.dateTimeFormat[style] || 'MM/DD/YYYY h:mm a'; - - if(_.has(theme, 'customization.defaults.dateTimeFormat')) { - return theme.customization.defaults.dateTimeFormat[style] || format; - } - - return format; - } - }; + return pwChar; + }, + getDateFormat : function(style = 'short') { + const format = Config().theme.dateFormat[style] || 'MM/DD/YYYY'; + return _.get(theme, `customization.defaults.dateFormat.${style}`, format); + }, + getTimeFormat : function(style = 'short') { + const format = Config().theme.timeFormat[style] || 'h:mm a'; + return _.get(theme, `customization.defaults.timeFormat.${style}`, format); + }, + getDateTimeFormat : function(style = 'short') { + const format = Config().theme.dateTimeFormat[style] || 'MM/DD/YYYY h:mm a'; + return _.get(theme, `customization.defaults.dateTimeFormat.${style}`, format); + } + }; } -function loadTheme(themeID, cb) { +function loadTheme(themeId, cb) { + const path = paths.join(Config().paths.themes, themeId, 'theme.hjson'); - const path = paths.join(Config.paths.themes, themeID, 'theme.hjson'); + const changed = ( { fileName, fileRoot } ) => { + const reCachedPath = paths.join(fileRoot, fileName); + if(reCachedPath === path) { + reloadTheme(themeId); + } + }; - configCache.getConfigWithOptions( { filePath : path, forceReCache : true }, (err, theme) => { - if(err) { - return cb(err); - } - - if(!_.isObject(theme.info) || - !_.isString(theme.info.name) || - !_.isString(theme.info.author)) - { - return cb(Errors.Invalid('Invalid or missing "info" section')); - } + const getOpts = { + filePath : path, + forceReCache : true, + callback : changed, + }; - if(false === _.get(theme, 'info.enabled')) { - return cb(Errors.General('Theme is not enalbed', ErrorReasons.ErrNotEnabled)); - } + ConfigCache.getConfigWithOptions(getOpts, (err, theme) => { + if(err) { + return cb(err); + } - refreshThemeHelpers(theme); + if(!_.isObject(theme.info) || + !_.isString(theme.info.name) || + !_.isString(theme.info.author)) + { + return cb(Errors.Invalid('Invalid or missing "info" section')); + } - return cb(null, theme, path); - }); + if(false === _.get(theme, 'info.enabled')) { + return cb(Errors.General('Theme is not enabled', ErrorReasons.ErrNotEnabled)); + } + + refreshThemeHelpers(theme); + + return cb(null, theme, path); + }); } -const availableThemes = {}; +const availableThemes = new Map(); const IMMUTABLE_MCI_PROPERTIES = [ - 'maxLength', 'argName', 'submit', 'validate' + 'maxLength', 'argName', 'submit', 'validate' ]; function getMergedTheme(menuConfig, promptConfig, theme) { - assert(_.isObject(menuConfig)); - assert(_.isObject(theme)); - + assert(_.isObject(menuConfig)); + assert(_.isObject(theme)); + // :TODO: merge in defaults (customization.defaults{} ) - // :TODO: apply generic stuff, e.g. "VM" (vs "VM1") - + // :TODO: apply generic stuff, e.g. "VM" (vs "VM1") + // // Create a *clone* of menuConfig (menu.hjson) then bring in // promptConfig (prompt.hjson) // - var mergedTheme = _.cloneDeep(menuConfig); - - if(_.isObject(promptConfig.prompts)) { - mergedTheme.prompts = _.cloneDeep(promptConfig.prompts); - } + const mergedTheme = _.cloneDeep(menuConfig); - // - // Add in data we won't be altering directly from the theme - // - mergedTheme.info = theme.info; - mergedTheme.helpers = theme.helpers; + if(_.isObject(promptConfig.prompts)) { + mergedTheme.prompts = _.cloneDeep(promptConfig.prompts); + } - // - // merge customizer to disallow immutable MCI properties - // - var mciCustomizer = function(objVal, srcVal, key) { - return IMMUTABLE_MCI_PROPERTIES.indexOf(key) > -1 ? objVal : srcVal; - }; + // + // Add in data we won't be altering directly from the theme + // + mergedTheme.info = theme.info; + mergedTheme.helpers = theme.helpers; + mergedTheme.achievements = _.get(theme, 'customization.achievements'); - function getFormKeys(fromObj) { - return _.remove(_.keys(fromObj), function pred(k) { - return !isNaN(k); // remove all non-numbers - }); - } + // + // merge customizer to disallow immutable MCI properties + // + const mciCustomizer = function(objVal, srcVal, key) { + return IMMUTABLE_MCI_PROPERTIES.indexOf(key) > -1 ? objVal : srcVal; + }; - function mergeMciProperties(dest, src) { - Object.keys(src).forEach(function mciEntry(mci) { - _.mergeWith(dest[mci], src[mci], mciCustomizer); - }); - } + function getFormKeys(fromObj) { + // remove all non-numbers + return _.remove(_.keys(fromObj), k => !isNaN(k)); + } + + function mergeMciProperties(dest, src) { + Object.keys(src).forEach(mci => { + if(dest[mci]) { + _.mergeWith(dest[mci], src[mci], mciCustomizer); + } else { + // theme contains MCI not in menu; bring in as-is + dest[mci] = src[mci]; + } + }); + } + + function applyThemeMciBlock(dest, src, formKey) { + if(_.isObject(src.mci)) { + mergeMciProperties(dest, src.mci); + } else { + if(_.has(src, [ formKey, 'mci' ])) { + mergeMciProperties(dest, src[formKey].mci); + } + } + } - function applyThemeMciBlock(dest, src, formKey) { - if(_.isObject(src.mci)) { - mergeMciProperties(dest, src.mci); - } else { - if(_.has(src, [ formKey, 'mci' ])) { - mergeMciProperties(dest, src[formKey].mci); - } - } - } - // // menu.hjson can have a couple different structures: // 1) Explicit declaration of expected MCI code(s) under 'form:' before a 'mci' block @@ -176,536 +182,543 @@ function getMergedTheme(menuConfig, promptConfig, theme) { // 2) Non-explicit: 'mci' directly under an entry // // Additionally, #1 or #2 may be under an explicit key of MCI code(s) to match up - // with menu.hjson in #1. + // with menu.hjson in #1. // // * When theming an explicit menu.hjson entry (1), we will use a matching explicit // entry with a matching MCI code(s) key in theme.hjson (e.g. menu="ETVM"/theme="ETVM" - // and fall back to generic if a match is not found. + // and fall back to generic if a match is not found. // // * If theme.hjson provides form ID's, use them. Otherwise, we'll apply directly assuming // there is a generic 'mci' block. - // - function applyToForm(form, menuTheme, formKey) { - if(_.isObject(form.mci)) { - // non-explicit: no MCI code(s) key assumed since we found 'mci' directly under form ID - applyThemeMciBlock(form.mci, menuTheme, formKey); - - } else { - var menuMciCodeKeys = _.remove(_.keys(form), function pred(k) { - return k === k.toUpperCase(); // remove anything not uppercase - }); - - menuMciCodeKeys.forEach(function mciKeyEntry(mciKey) { - var applyFrom; - if(_.has(menuTheme, [ mciKey, 'mci' ])) { - applyFrom = menuTheme[mciKey]; - } else { - applyFrom = menuTheme; - } - - applyThemeMciBlock(form[mciKey].mci, applyFrom); - }); - } - } - - [ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) { - _.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) { - var createdFormSection = false; - var mergedThemeMenu = mergedTheme[sectionName][menuName]; - - if(_.has(theme, [ 'customization', sectionName, menuName ])) { - var menuTheme = theme.customization[sectionName][menuName]; - - // config block is direct assign/overwrite - // :TODO: should probably be _.merge() - if(menuTheme.config) { - mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config); - } - - if('menus' === sectionName) { - if(_.isObject(mergedThemeMenu.form)) { - getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) { - applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey); - }); - } else { - if(_.isObject(menuTheme.mci)) { - // - // Not specified at menu level means we apply anything from the - // theme to form.0.mci{} - // - mergedThemeMenu.form = { 0 : { mci : { } } }; - mergeMciProperties(mergedThemeMenu.form[0], menuTheme); - createdFormSection = true; - } - } - } else if('prompts' === sectionName) { - // no 'form' or form keys for prompts -- direct to mci - applyToForm(mergedThemeMenu, menuTheme); - } - } - - // - // Finished merging for this menu/prompt - // - // If the following conditions are true, set runtime.autoNext to true: - // * This is a menu - // * There is/was no explicit 'form' section - // * There is no 'prompt' specified - // - if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) && - (createdFormSection || !_.isObject(mergedThemeMenu.form))) - { - mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } ); - } - }); - }); - + // + function applyToForm(form, menuTheme, formKey) { + if(_.isObject(form.mci)) { + // non-explicit: no MCI code(s) key assumed since we found 'mci' directly under form ID + applyThemeMciBlock(form.mci, menuTheme, formKey); + } else { + // remove anything not uppercase + const menuMciCodeKeys = _.remove(_.keys(form), k => k === k.toUpperCase()); - return mergedTheme; + menuMciCodeKeys.forEach(function mciKeyEntry(mciKey) { + let applyFrom; + if(_.has(menuTheme, [ mciKey, 'mci' ])) { + applyFrom = menuTheme[mciKey]; + } else { + applyFrom = menuTheme; + } + + applyThemeMciBlock(form[mciKey].mci, applyFrom, formKey); + }); + } + } + + [ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) { + _.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) { + let createdFormSection = false; + const mergedThemeMenu = mergedTheme[sectionName][menuName]; + + if(_.has(theme, [ 'customization', sectionName, menuName ])) { + const menuTheme = theme.customization[sectionName][menuName]; + + // config block is direct assign/overwrite + // :TODO: should probably be _.merge() + if(menuTheme.config) { + mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config); + } + + if('menus' === sectionName) { + if(_.isObject(mergedThemeMenu.form)) { + getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) { + applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey); + }); + } else { + if(_.isObject(menuTheme.mci)) { + // + // Not specified at menu level means we apply anything from the + // theme to form.0.mci{} + // + mergedThemeMenu.form = { 0 : { mci : { } } }; + mergeMciProperties(mergedThemeMenu.form[0], menuTheme); + createdFormSection = true; + } + } + } else if('prompts' === sectionName) { + // no 'form' or form keys for prompts -- direct to mci + applyToForm(mergedThemeMenu, menuTheme); + } + } + + // + // Finished merging for this menu/prompt + // + // If the following conditions are true, set runtime.autoNext to true: + // * This is a menu + // * There is/was no explicit 'form' section + // * There is no 'prompt' specified + // + if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) && + (createdFormSection || !_.isObject(mergedThemeMenu.form))) + { + mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } ); + } + }); + }); + + + return mergedTheme; +} + +function reloadTheme(themeId) { + const config = Config(); + async.waterfall( + [ + function loadMenuConfig(callback) { + getFullConfig(config.general.menuFile, (err, menuConfig) => { + return callback(err, menuConfig); + }); + }, + function loadPromptConfig(menuConfig, callback) { + getFullConfig(config.general.promptFile, (err, promptConfig) => { + return callback(err, menuConfig, promptConfig); + }); + }, + function loadIt(menuConfig, promptConfig, callback) { + loadTheme(themeId, (err, theme) => { + if(err) { + if(ErrorReasons.NotEnabled !== err.reasonCode) { + Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme'); + return; + } + return callback(err); + } + + Object.assign(theme.info, { themeId } ); + availableThemes.set(themeId, getMergedTheme(menuConfig, promptConfig, theme)); + + Events.emit( + Events.getSystemEvents().ThemeChanged, + { themeId } + ); + + return callback(null, theme); + }); + } + ], + (err, theme) => { + if(err) { + Log.warn( { themeId, error : err.message }, 'Failed to reload theme'); + } else { + Log.debug( { info : theme.info }, 'Theme recached' ); + } + } + ); +} + +function reloadAllThemes() +{ + async.each([ ...availableThemes.keys() ], themeId => reloadTheme(themeId)); } function initAvailableThemes(cb) { - - async.waterfall( - [ - function loadMenuConfig(callback) { - getFullConfig(Config.general.menuFile, (err, menuConfig) => { - return callback(err, menuConfig); - }); - }, - function loadPromptConfig(menuConfig, callback) { - getFullConfig(Config.general.promptFile, (err, promptConfig) => { - return callback(err, menuConfig, promptConfig); - }); - }, - function getThemeDirectories(menuConfig, promptConfig, callback) { - fs.readdir(Config.paths.themes, (err, files) => { - if(err) { - return callback(err); - } + const config = Config(); + async.waterfall( + [ + function loadMenuConfig(callback) { + getFullConfig(config.general.menuFile, (err, menuConfig) => { + return callback(err, menuConfig); + }); + }, + function loadPromptConfig(menuConfig, callback) { + getFullConfig(config.general.promptFile, (err, promptConfig) => { + return callback(err, menuConfig, promptConfig); + }); + }, + function getThemeDirectories(menuConfig, promptConfig, callback) { + fs.readdir(config.paths.themes, (err, files) => { + if(err) { + return callback(err); + } - return callback( - null, - menuConfig, - promptConfig, - files.filter( f => { - // sync normally not allowed -- initAvailableThemes() is a startup-only method, however - return fs.statSync(paths.join(Config.paths.themes, f)).isDirectory(); - }) - ); - }); - }, - function populateAvailable(menuConfig, promptConfig, themeDirectories, callback) { - async.each(themeDirectories, (themeId, nextThemeDir) => { // theme dir = theme ID - loadTheme(themeId, (err, theme, themePath) => { - if(err) { - if(ErrorReasons.NotEnabled !== err.reasonCode) { - Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme'); - } + return callback( + null, + menuConfig, + promptConfig, + files.filter( f => { + // sync normally not allowed -- initAvailableThemes() is a startup-only method, however + return fs.statSync(paths.join(config.paths.themes, f)).isDirectory(); + }) + ); + }); + }, + function populateAvailable(menuConfig, promptConfig, themeDirectories, callback) { + async.each(themeDirectories, (themeId, nextThemeDir) => { // theme dir = theme ID + loadTheme(themeId, (err, theme) => { + if(err) { + if(ErrorReasons.NotEnabled !== err.reasonCode) { + Log.warn( { themeId : themeId, err : err.message }, 'Failed loading theme'); + } - return nextThemeDir(null); // try next - } + return nextThemeDir(null); // try next + } - availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme); + Object.assign(theme.info, { themeId } ); + availableThemes.set(themeId, getMergedTheme(menuConfig, promptConfig, theme)); + return nextThemeDir(null); + }); + }, err => { + return callback(err); + }); + }, + function initEvents(callback) { + Events.on(Events.getSystemEvents().MenusChanged, () => { + return reloadAllThemes(); + }); + Events.on(Events.getSystemEvents().PromptsChanged, () => { + return reloadAllThemes(); + }); - configCache.on('recached', recachedPath => { - if(themePath === recachedPath) { - loadTheme(themeId, (err, reloadedTheme) => { - if(!err) { - // :TODO: This is still broken - Need to reapply *latest* menu config and prompt configs to theme at very least - Log.debug( { info : theme.info }, 'Theme recached' ); - availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, reloadedTheme); - } else if(ErrorReasons.NotEnabled === err.reasonCode) { - // :TODO: we need to disable this theme -- users may be using it! We'll need to re-assign them if so - } - }); - } - }); - - return nextThemeDir(null); - }); - }, err => { - return callback(err); - }); - } - ], - err => { - return cb(err, availableThemes ? availableThemes.length : 0); - } - ); + return callback(null); + } + ], + err => { + return cb(err, availableThemes.size); + } + ); } function getAvailableThemes() { - return availableThemes; + return availableThemes; } function getRandomTheme() { - if(Object.getOwnPropertyNames(availableThemes).length > 0) { - var themeIds = Object.keys(availableThemes); - return themeIds[Math.floor(Math.random() * themeIds.length)]; - } + if(availableThemes.size > 0) { + const themeIds = [ ...availableThemes.keys() ]; + return themeIds[Math.floor(Math.random() * themeIds.length)]; + } } function setClientTheme(client, themeId) { - let logMsg; + const availThemes = getAvailableThemes(); - const availThemes = getAvailableThemes(); + let msg; + let setThemeId; + const config = Config(); + if(availThemes.has(themeId)) { + msg = 'Set client theme'; + setThemeId = themeId; + } else if(availThemes.has(config.theme.default)) { + msg = 'Failed setting theme by supplied ID; Using default'; + setThemeId = config.theme.default; + } else { + msg = 'Failed setting theme by system default ID; Using the first one we can find'; + setThemeId = availThemes.keys().next().value; + } - client.currentTheme = availThemes[themeId]; - if(client.currentTheme) { - logMsg = 'Set client theme'; - } else { - client.currentTheme = availThemes[Config.defaults.theme]; - if(client.currentTheme) { - logMsg = 'Failed setting theme by supplied ID; Using default'; - } else { - client.currentTheme = availThemes[Object.keys(availThemes)[0]]; - logMsg = 'Failed setting theme by system default ID; Using the first one we can find'; - } - } - - client.log.debug( { themeId : themeId, info : client.currentTheme.info }, logMsg); + client.currentTheme = availThemes.get(setThemeId); + client.log.debug( { setThemeId, requestedThemeId : themeId, info : client.currentTheme.info }, msg); } function getThemeArt(options, cb) { - // - // options - required: - // name - // - // options - optional - // client - needed for user's theme/etc. - // themeId - // asAnsi - // readSauce - // random - // - if(!options.themeId && _.has(options, 'client.user.properties.theme_id')) { - options.themeId = options.client.user.properties.theme_id; - } else { - options.themeId = Config.defaults.theme; - } + // + // options - required: + // name + // + // options - optional + // client - needed for user's theme/etc. + // themeId + // asAnsi + // readSauce + // random + // + const config = Config(); + if(!options.themeId && _.has(options, [ 'client', 'user', 'properties', UserProps.ThemeId ])) { + options.themeId = options.client.user.properties[UserProps.ThemeId]; + } else { + options.themeId = config.theme.default; + } - // :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.random = _.get(options, 'random', true); // FILENAME.EXT support + // :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 = _.get(options, 'readSauce', true); // read SAUCE, if avail + options.random = _.get(options, 'random', true); // FILENAME.EXT support - // - // We look for themed art in the following order: - // 1) Direct/relative path - // 2) Via theme supplied by |themeId| - // 3) Via default theme - // 4) General art directory - // - async.waterfall( - [ - function fromPath(callback) { - // - // We allow relative (to enigma-bbs) or full paths - // - if('/' === options.name.charAt(0)) { - // just take the path as-is - options.basePath = paths.dirname(options.name); - } else if(options.name.indexOf('/') > -1) { - // make relative to base BBS dir - options.basePath = paths.join(__dirname, '../', paths.dirname(options.name)); - } else { - return callback(null, null); - } + // + // We look for themed art in the following order: + // 1) Direct/relative path + // 2) Via theme supplied by |themeId| + // 3) Via default theme + // 4) General art directory + // + async.waterfall( + [ + function fromPath(callback) { + // + // We allow relative (to enigma-bbs) or full paths + // + if('/' === options.name.charAt(0)) { + // just take the path as-is + options.basePath = paths.dirname(options.name); + } else if(options.name.indexOf('/') > -1) { + // make relative to base BBS dir + options.basePath = paths.join(__dirname, '../', paths.dirname(options.name)); + } else { + return callback(null, null); + } - art.getArt(options.name, options, (err, artInfo) => { - return callback(null, artInfo); - }); - }, - function fromSuppliedTheme(artInfo, callback) { - if(artInfo) { - return callback(null, artInfo); - } + art.getArt(options.name, options, (err, artInfo) => { + return callback(null, artInfo); + }); + }, + function fromSuppliedTheme(artInfo, callback) { + if(artInfo) { + return callback(null, artInfo); + } - options.basePath = paths.join(Config.paths.themes, options.themeId); - art.getArt(options.name, options, (err, artInfo) => { - return callback(null, artInfo); - }); - }, - function fromDefaultTheme(artInfo, callback) { - if(artInfo || Config.defaults.theme === options.themeId) { - return callback(null, artInfo); - } - - options.basePath = paths.join(Config.paths.themes, Config.defaults.theme); - art.getArt(options.name, options, (err, artInfo) => { - return callback(null, artInfo); - }); - }, - function fromGeneralArtDir(artInfo, callback) { - if(artInfo) { - return callback(null, artInfo); - } - - options.basePath = Config.paths.art; - art.getArt(options.name, options, (err, artInfo) => { - return callback(err, artInfo); - }); - } - ], - function complete(err, artInfo) { - if(err) { - const logger = _.get(options, 'client.log') || Log; - logger.debug( { reason : err.message }, 'Cannot find theme art'); - } - return cb(err, artInfo); - } - ); + options.basePath = paths.join(config.paths.themes, options.themeId); + art.getArt(options.name, options, (err, artInfo) => { + return callback(null, artInfo); + }); + }, + function fromDefaultTheme(artInfo, callback) { + if(artInfo || config.theme.default === options.themeId) { + return callback(null, artInfo); + } + + options.basePath = paths.join(config.paths.themes, config.theme.default); + art.getArt(options.name, options, (err, artInfo) => { + return callback(null, artInfo); + }); + }, + function fromGeneralArtDir(artInfo, callback) { + if(artInfo) { + return callback(null, artInfo); + } + + options.basePath = config.paths.art; + art.getArt(options.name, options, (err, artInfo) => { + return callback(err, artInfo); + }); + } + ], + function complete(err, artInfo) { + if(err) { + const logger = _.get(options, 'client.log') || Log; + logger.debug( { reason : err.message }, 'Cannot find theme art'); + } + return cb(err, artInfo); + } + ); +} + +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)); - assert(_.isString(options.name)); + assert(_.isObject(options)); + assert(_.isObject(options.client)); + assert(_.isString(options.name)); - getThemeArt(options, (err, artInfo) => { - if(err) { - return cb(err); - } - // :TODO: just use simple merge of options -> displayOptions - 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 } ); - }); - }); + async.waterfall( + [ + function getArt(callback) { + return getThemeArt(options, callback); + }, + function prepWork(artInfo, callback) { + if(_.isObject(options.ansiPrepOptions)) { + AnsiPrep( + artInfo.data, + options.ansiPrepOptions, + (err, prepped) => { + if(!err && prepped) { + artInfo.data = prepped; + return callback(null, artInfo); + } + } + ); + } else { + return callback(null, artInfo); + } + }, + function disp(artInfo, callback) { + return displayPreparedArt(options, artInfo, callback); + } + ], + (err, artData) => { + return cb(err, artData); + } + ); } -/* -function displayThemedPrompt(name, client, options, cb) { - - async.waterfall( - [ - function loadConfig(callback) { - configCache.getModConfig('prompt.hjson', (err, promptJson) => { - if(err) { - return callback(err); - } - - if(_.has(promptJson, [ 'prompts', name ] )) { - return callback(Errors.DoesNotExist(`Prompt "${name}" does not exist`)); - } - - const promptConfig = promptJson.prompts[name]; - if(!_.isObject(promptConfig)) { - return callback(Errors.Invalid(`Prompt "${name} is invalid`)); - } - - return callback(null, promptConfig); - }); - }, - function display(promptConfig, callback) { - if(options.clearScreen) { - client.term.rawWrite(ansi.clearScreen()); - } - - // - // If we did not clear the screen, don't let the font change - // - const dispOptions = Object.assign( {}, promptConfig.options ); - if(!options.clearScreen) { - dispOptions.font = 'not_really_a_font!'; - } - - displayThemedAsset( - promptConfig.art, - client, - dispOptions, - (err, artData) => { - if(err) { - return callback(err); - } - - return callback(null, promptConfig, artData.mciMap); - } - ); - }, - function prepViews(promptConfig, mciMap, callback) { - vc = new ViewController( { client : client } ); - - const loadOpts = { - promptName : name, - mciMap : mciMap, - config : promptConfig, - }; - - vc.loadFromPromptConfig(loadOpts, err => { - callback(null); - }); - } - ] - ); -} -*/ - function displayThemedPrompt(name, client, options, cb) { - const useTempViewController = _.isUndefined(options.viewController); + const usingTempViewController = _.isUndefined(options.viewController); - async.waterfall( - [ - function display(callback) { - const promptConfig = client.currentTheme.prompts[name]; - if(!promptConfig) { - return callback(Errors.DoesNotExist(`Missing "${name}" prompt configuration!`)); - } + async.waterfall( + [ + function display(callback) { + const promptConfig = client.currentTheme.prompts[name]; + if(!promptConfig) { + return callback(Errors.DoesNotExist(`Missing "${name}" prompt configuration!`)); + } - if(options.clearScreen) { - client.term.rawWrite(ansi.resetScreen()); - } + if(options.clearScreen) { + client.term.rawWrite(ansi.resetScreen()); + } - // - // If we did *not* clear the screen, don't let the font change - // doing so messes things up -- most terminals that support font - // changing can only display a single font at at time. - // - // :TODO: We can use term detection to do nifty things like avoid this kind of kludge: - const dispOptions = Object.assign( {}, promptConfig.options ); - if(!options.clearScreen) { - dispOptions.font = 'not_really_a_font!'; // kludge :) - } + // + // If we did *not* clear the screen, don't let the font change + // doing so messes things up -- most terminals that support font + // changing can only display a single font at at time. + // + const dispOptions = Object.assign( {}, options, promptConfig.config ); + // :TODO: We can use term detection to do nifty things like avoid this kind of kludge: + if(!options.clearScreen) { + dispOptions.font = 'not_really_a_font!'; // kludge :) + } - displayThemedAsset( - promptConfig.art, - client, - dispOptions, - (err, artInfo) => { - return callback(err, promptConfig, artInfo); - } - ); - }, - function discoverCursorPosition(promptConfig, artInfo, callback) { - if(!options.clearPrompt) { - // no need to query cursor - we're not gonna use it - return callback(null, promptConfig, artInfo); - } - - client.once('cursor position report', pos => { - artInfo.startRow = pos[0] - artInfo.height; - return callback(null, promptConfig, artInfo); - }); + displayThemedAsset( + promptConfig.art, + client, + dispOptions, + (err, artInfo) => { + if(err) { + return callback(err); + } - client.term.rawWrite(ansi.queryPos()); - }, - function createMCIViews(promptConfig, artInfo, callback) { - const tempViewController = useTempViewController ? new ViewController( { client : client } ) : options.viewController; + return callback(null, promptConfig, artInfo); + } + ); + }, + function discoverCursorPosition(promptConfig, artInfo, callback) { + if(!options.clearPrompt) { + // no need to query cursor - we're not gonna use it + return callback(null, promptConfig, artInfo); + } - const loadOpts = { - promptName : name, - mciMap : artInfo.mciMap, - config : promptConfig, - }; + client.once('cursor position report', pos => { + artInfo.startRow = pos[0] - artInfo.height; + return callback(null, promptConfig, artInfo); + }); - tempViewController.loadFromPromptConfig(loadOpts, () => { - return callback(null, artInfo, tempViewController); - }); - }, - function pauseForUserInput(artInfo, tempViewController, callback) { - if(!options.pause) { - return callback(null, artInfo, tempViewController); - } + client.term.rawWrite(ansi.queryPos()); + }, + function createMCIViews(promptConfig, artInfo, callback) { + const assocViewController = usingTempViewController ? new ViewController( { client : client } ) : options.viewController; - client.waitForKeyPress( () => { - return callback(null, artInfo, tempViewController); - }); - }, - function clearPauseArt(artInfo, tempViewController, callback) { - if(options.clearPrompt) { - if(artInfo.startRow && artInfo.height) { - client.term.rawWrite(ansi.goto(artInfo.startRow, 1)); - - // Note: Does not work properly in NetRunner < 2.0b17: - client.term.rawWrite(ansi.deleteLine(artInfo.height)); - } else { - client.term.rawWrite(ansi.eraseLine(1)); - } - } + const loadOpts = { + promptName : name, + mciMap : artInfo.mciMap, + config : promptConfig, + submitNotify : options.submitNotify, + }; - return callback(null, tempViewController); - } - ], - (err, tempViewController) => { - if(err) { - client.log.warn( { error : err.message }, `Failed displaying "${name}" prompt` ); - } + assocViewController.loadFromPromptConfig(loadOpts, () => { + return callback(null, artInfo, assocViewController); + }); + }, + function pauseForUserInput(artInfo, assocViewController, callback) { + if(!options.pause) { + return callback(null, artInfo, assocViewController); + } - if(tempViewController && useTempViewController) { - tempViewController.detachClientEvents(); - } + client.waitForKeyPress( () => { + return callback(null, artInfo, assocViewController); + }); + }, + function clearPauseArt(artInfo, assocViewController, callback) { + if(options.clearPrompt) { + if(artInfo.startRow && artInfo.height) { + client.term.rawWrite(ansi.goto(artInfo.startRow, 1)); - return cb(null); - } - ); + // Note: Does not work properly in NetRunner < 2.0b17: + client.term.rawWrite(ansi.deleteLine(artInfo.height)); + } else { + client.term.rawWrite(ansi.eraseLine(1)); + } + } + + return callback(null, assocViewController, artInfo); + } + ], + (err, assocViewController, artInfo) => { + if(err) { + client.log.warn( { error : err.message }, `Failed displaying "${name}" prompt` ); + } + + if(assocViewController && usingTempViewController) { + assocViewController.detachClientEvents(); + } + + return cb(null, artInfo); + } + ); } // -// Pause prompts are a special prompt by the name 'pause'. -// +// Pause prompts are a special prompt by the name 'pause'. +// function displayThemedPause(client, options, cb) { - if(!cb && _.isFunction(options)) { - cb = options; - options = {}; - } + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } - if(!_.isBoolean(options.clearPrompt)) { - options.clearPrompt = true; - } + if(!_.isBoolean(options.clearPrompt)) { + options.clearPrompt = true; + } - const promptOptions = Object.assign( {}, options, { pause : true } ); - return displayThemedPrompt('pause', client, promptOptions, cb); + const promptOptions = Object.assign( {}, options, { pause : true } ); + return displayThemedPrompt('pause', client, promptOptions, cb); } function displayThemedAsset(assetSpec, client, options, cb) { - assert(_.isObject(client)); + assert(_.isObject(client)); - // options are... optional - if(3 === arguments.length) { - cb = options; - options = {}; - } + // options are... optional + if(3 === arguments.length) { + cb = options; + options = {}; + } - const artAsset = asset.getArtAsset(assetSpec); - if(!artAsset) { - return cb(new Error('Asset not found: ' + assetSpec)); - } + if(Array.isArray(assetSpec)) { + const acsCondMember = options.acsCondMember || 'art'; + assetSpec = client.acs.getConditionalValue(assetSpec, acsCondMember); + } - // :TODO: just use simple merge of options -> displayOptions - var dispOpts = { - name : artAsset.asset, - client : client, - font : options.font, - trailingLF : options.trailingLF, - }; + const artAsset = asset.getArtAsset(assetSpec); + if(!artAsset) { + return cb(new Error('Asset not found: ' + assetSpec)); + } - switch(artAsset.type) { - case 'art' : - displayThemeArt(dispOpts, function displayed(err, artData) { - return cb(err, err ? null : { mciMap : artData.mciMap, height : artData.extraInfo.height } ); - }); - break; + const dispOpts = Object.assign( {}, options, { client, name : artAsset.asset } ); + switch(artAsset.type) { + case 'art' : + displayThemeArt(dispOpts, function displayed(err, artData) { + return cb(err, err ? null : { mciMap : artData.mciMap, height : artData.extraInfo.height } ); + }); + break; - case 'method' : - // :TODO: fetch & render via method - break; + case 'method' : + // :TODO: fetch & render via method + break; - case 'inline ' : - // :TODO: think about this more in relation to themes, etc. How can this come - // from a theme (with override from menu.json) ??? - // look @ client.currentTheme.inlineArt[name] -> menu/prompt[name] - break; + case 'inline ' : + // :TODO: think about this more in relation to themes, etc. How can this come + // from a theme (with override from menu.json) ??? + // look @ client.currentTheme.inlineArt[name] -> menu/prompt[name] + break; - default : - return cb(new Error('Unsupported art asset type: ' + artAsset.type)); - } + default : + return cb(new Error('Unsupported art asset type: ' + artAsset.type)); + } } \ No newline at end of file diff --git a/core/tic_file_info.js b/core/tic_file_info.js index d2216d66..fd3c7572 100644 --- a/core/tic_file_info.js +++ b/core/tic_file_info.js @@ -1,280 +1,285 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Address = require('./ftn_address.js'); -const Errors = require('./enig_error.js').Errors; -const EnigAssert = require('./enigma_assert.js'); +// ENiGMA½ +const Address = require('./ftn_address.js'); +const Errors = require('./enig_error.js').Errors; +const EnigAssert = require('./enigma_assert.js'); -// deps -const fs = require('graceful-fs'); -const CRC32 = require('./crc.js').CRC32; -const _ = require('lodash'); -const async = require('async'); -const paths = require('path'); -const crypto = require('crypto'); +// deps +const fs = require('graceful-fs'); +const CRC32 = require('./crc.js').CRC32; +const _ = require('lodash'); +const async = require('async'); +const paths = require('path'); +const crypto = require('crypto'); // -// Class to read and hold information from a TIC file +// Class to read and hold information from a TIC file // -// * FTS-5006.001 @ http://www.filegate.net/ftsc/FTS-5006.001 -// * FSP-1039.001 @ http://ftsc.org/docs/old/fsp-1039.001 -// * FSC-0087.001 @ http://ftsc.org/docs/fsc-0087.001 +// * FTS-5006.001 @ http://www.filegate.net/ftsc/FTS-5006.001 +// * FSP-1039.001 @ http://ftsc.org/docs/old/fsp-1039.001 +// * FSC-0087.001 @ http://ftsc.org/docs/fsc-0087.001 // module.exports = class TicFileInfo { - constructor() { - this.entries = new Map(); - } + constructor() { + this.entries = new Map(); + } - static get requiredFields() { - return [ - 'Area', 'Origin', 'From', 'File', 'Crc', - // :TODO: validate this: - //'Path', 'Seenby' // these two are questionable; some systems don't send them? - ]; - } + static get requiredFields() { + return [ + 'Area', 'Origin', 'From', 'File', 'Crc', + // :TODO: validate this: + //'Path', 'Seenby' // these two are questionable; some systems don't send them? + ]; + } - get(key) { - return this.entries.get(key.toLowerCase()); - } + get(key) { + return this.entries.get(key.toLowerCase()); + } - getAsString(key, joinWith) { - const value = this.get(key); - if(value) { - // - // We call toString() on values to ensure numbers, addresses, etc. are converted - // - joinWith = joinWith || ''; - if(Array.isArray(value)) { - return value.map(v => v.toString() ).join(joinWith); - } - - return value.toString(); - } - } - - get filePath() { - return paths.join(paths.dirname(this.path), this.getAsString('File')); - } + getAsString(key, joinWith) { + const value = this.get(key); + if(value) { + // + // We call toString() on values to ensure numbers, addresses, etc. are converted + // + joinWith = joinWith || ''; + if(Array.isArray(value)) { + return value.map(v => v.toString() ).join(joinWith); + } - get longFileName() { - return this.getAsString('Lfile') || this.getAsString('Fullname') || this.getAsString('File'); - } + return value.toString(); + } + } - hasRequiredFields() { - const req = TicFileInfo.requiredFields; - return req.every( f => this.get(f) ); - } + get filePath() { + return paths.join(paths.dirname(this.path), this.getAsString('File')); + } - validate(config, cb) { - // config.nodes - // config.defaultPassword (optional) - // config.localAreaTags - EnigAssert(config.nodes && config.localAreaTags); + get longFileName() { + return this.getAsString('Lfile') || this.getAsString('Fullname') || this.getAsString('File'); + } - const self = this; + hasRequiredFields() { + const req = TicFileInfo.requiredFields; + return req.every( f => this.get(f) ); + } - async.waterfall( - [ - function initial(callback) { - if(!self.hasRequiredFields()) { - return callback(Errors.Invalid('One or more required fields missing from TIC')); - } + validate(config, cb) { + // config.nodes + // config.defaultPassword (optional) + // config.localAreaTags + EnigAssert(config.nodes && config.localAreaTags); - const area = self.getAsString('Area').toUpperCase(); + const self = this; - const localInfo = { - areaTag : config.localAreaTags.find( areaTag => areaTag.toUpperCase() === area ), - }; - - if(!localInfo.areaTag) { - return callback(Errors.Invalid(`No local area for "Area" of ${area}`)); - } + async.waterfall( + [ + function initial(callback) { + if(!self.hasRequiredFields()) { + return callback(Errors.Invalid('One or more required fields missing from TIC')); + } - const from = self.getAsString('From'); - localInfo.node = Object.keys(config.nodes).find( nodeAddr => Address.fromString(nodeAddr).isPatternMatch(from) ); + const area = self.getAsString('Area').toUpperCase(); - if(!localInfo.node) { - return callback(Errors.Invalid('TIC is not from a known node')); - } + const localInfo = { + areaTag : config.localAreaTags.find( areaTag => areaTag.toUpperCase() === area ), + }; - // if we require a password, "PW" must match - const passActual = _.get(config.nodes, [ localInfo.node, 'tic', 'password' ] ) || config.defaultPassword; - if(!passActual) { - return callback(null, localInfo); // no pw validation - } + if(!localInfo.areaTag) { + return callback(Errors.Invalid(`No local area for "Area" of ${area}`)); + } - const passTic = self.getAsString('Pw'); - if(passTic !== passActual) { - return callback(Errors.Invalid('Bad TIC password')); - } + const from = Address.fromString(self.getAsString('From')); + if(!from.isValid()) { + return callback(Errors.Invalid(`Invalid "From" address: ${self.getAsString('From')}`)); + } - return callback(null, localInfo); - }, - function checksumAndSize(localInfo, callback) { - const crcTic = self.get('Crc'); - const stream = fs.createReadStream(self.filePath); - const crc = new CRC32(); - let sizeActual = 0; + // note that our config may have wildcards, such as "80:774/*" + localInfo.node = Object.keys(config.nodes).find( nodeAddrWildcard => from.isPatternMatch(nodeAddrWildcard) ); - let sha256Tic = self.getAsString('Sha256'); - let sha256; - if(sha256Tic) { - sha256Tic = sha256Tic.toLowerCase(); - sha256 = crypto.createHash('sha256'); - } + if(!localInfo.node) { + return callback(Errors.Invalid('TIC is not from a known node')); + } - stream.on('data', data => { - sizeActual += data.length; + // if we require a password, "PW" must match + const passActual = _.get(config.nodes, [ localInfo.node, 'tic', 'password' ] ) || config.defaultPassword; + if(!passActual) { + return callback(null, localInfo); // no pw validation + } - // sha256 if possible, else crc32 - if(sha256) { - sha256.update(data); - } else { - crc.update(data); - } - }); + const passTic = self.getAsString('Pw'); + if(passTic !== passActual) { + return callback(Errors.Invalid('Bad TIC password')); + } - stream.on('end', () => { - // again, use sha256 if possible - if(sha256) { - const sha256Actual = sha256.digest('hex'); - if(sha256Tic != sha256Actual) { - return callback(Errors.Invalid(`TIC "Sha256" of ${sha256Tic} does not match actual SHA-256 of ${sha256Actual}`)); - } + return callback(null, localInfo); + }, + function checksumAndSize(localInfo, callback) { + const crcTic = self.get('Crc'); + const stream = fs.createReadStream(self.filePath); + const crc = new CRC32(); + let sizeActual = 0; - localInfo.sha256 = sha256Actual; - } else { - const crcActual = crc.finalize(); - if(crcActual !== crcTic) { - return callback(Errors.Invalid(`TIC "Crc" of ${crcTic} does not match actual CRC-32 of ${crcActual}`)); - } - localInfo.crc32 = crcActual; - } + let sha256Tic = self.getAsString('Sha256'); + let sha256; + if(sha256Tic) { + sha256Tic = sha256Tic.toLowerCase(); + sha256 = crypto.createHash('sha256'); + } - const sizeTic = self.get('Size'); - if(_.isUndefined(sizeTic)) { - return callback(null, localInfo); - } + stream.on('data', data => { + sizeActual += data.length; - if(sizeTic !== sizeActual) { - return callback(Errors.Invalid(`TIC "Size" of ${sizeTic} does not match actual size of ${sizeActual}`)); - } + // sha256 if possible, else crc32 + if(sha256) { + sha256.update(data); + } else { + crc.update(data); + } + }); - return callback(null, localInfo); - }); + stream.on('end', () => { + // again, use sha256 if possible + if(sha256) { + const sha256Actual = sha256.digest('hex'); + if(sha256Tic != sha256Actual) { + return callback(Errors.Invalid(`TIC "Sha256" of ${sha256Tic} does not match actual SHA-256 of ${sha256Actual}`)); + } - stream.on('error', err => { - return callback(err); - }); - } - ], - (err, localInfo) => { - return cb(err, localInfo); - } - ); - } + localInfo.sha256 = sha256Actual; + } else { + const crcActual = crc.finalize(); + if(crcActual !== crcTic) { + return callback(Errors.Invalid(`TIC "Crc" of ${crcTic} does not match actual CRC-32 of ${crcActual}`)); + } + localInfo.crc32 = crcActual; + } - isToAddress(address, allowNonExplicit) { - // - // FSP-1039.001: - // "This keyword specifies the FTN address of the system where to - // send the file to be distributed and the accompanying TIC file. - // Some File processors (Allfix) only insert a line with this - // keyword when the file and the associated TIC file are to be - // file routed through a third sysem instead of being processed - // by a file processor on that system. Others always insert it. - // Note that the To keyword may cause problems when the TIC file - // is proecessed by software that does not recognise it and - // passes the line "as is" to other systems. - // - // Example: To 292/854 - // - // This is an optional keyword." - // - const to = this.get('To'); - - if(!to) { - return allowNonExplicit; - } + const sizeTic = self.get('Size'); + if(_.isUndefined(sizeTic)) { + return callback(null, localInfo); + } - return address.isEqual(to); - } + if(sizeTic !== sizeActual) { + return callback(Errors.Invalid(`TIC "Size" of ${sizeTic} does not match actual size of ${sizeActual}`)); + } - static createFromFile(path, cb) { - fs.readFile(path, 'utf8', (err, ticData) => { - if(err) { - return cb(err); - } + return callback(null, localInfo); + }); - const ticFileInfo = new TicFileInfo(); - ticFileInfo.path = path; + stream.on('error', err => { + return callback(err); + }); + } + ], + (err, localInfo) => { + return cb(err, localInfo); + } + ); + } - // - // Lines in a TIC file should be separated by CRLF (DOS) - // may be separated by LF (UNIX) - // - const lines = ticData.split(/\r\n|\n/g); - let keyEnd; - let key; - let value; - let entry; - - lines.forEach(line => { - keyEnd = line.search(/\s/); - - if(keyEnd < 0) { - keyEnd = line.length; - } + isToAddress(address, allowNonExplicit) { + // + // FSP-1039.001: + // "This keyword specifies the FTN address of the system where to + // send the file to be distributed and the accompanying TIC file. + // Some File processors (Allfix) only insert a line with this + // keyword when the file and the associated TIC file are to be + // file routed through a third system instead of being processed + // by a file processor on that system. Others always insert it. + // Note that the To keyword may cause problems when the TIC file + // is processed by software that does not recognize it and + // passes the line "as is" to other systems. + // + // Example: To 292/854 + // + // This is an optional keyword." + // + const to = this.get('To'); - key = line.substr(0, keyEnd).toLowerCase(); + if(!to) { + return allowNonExplicit; + } - if(0 === key.length) { - return; - } + return address.isEqual(to); + } - value = line.substr(keyEnd + 1); + static createFromFile(path, cb) { + fs.readFile(path, 'utf8', (err, ticData) => { + if(err) { + return cb(err); + } - // don't trim Ldesc; may mess with FILE_ID.DIZ type descriptions - if('ldesc' !== key) { - value = value.trim(); - } + const ticFileInfo = new TicFileInfo(); + ticFileInfo.path = path; - // convert well known keys to a more reasonable format - switch(key) { - case 'origin' : - case 'from' : - case 'seenby' : - case 'to' : - value = Address.fromString(value); - break; + // + // Lines in a TIC file should be separated by CRLF (DOS) + // may be separated by LF (UNIX) + // + const lines = ticData.split(/\r\n|\n/g); + let keyEnd; + let key; + let value; + let entry; - case 'crc' : - value = parseInt(value, 16); - break; + lines.forEach(line => { + keyEnd = line.search(/\s/); - case 'size' : - value = parseInt(value, 10); - break; + if(keyEnd < 0) { + keyEnd = line.length; + } - default : - break; - } + key = line.substr(0, keyEnd).toLowerCase(); - entry = ticFileInfo.entries.get(key); + if(0 === key.length) { + return; + } - if(entry) { - if(!Array.isArray(entry)) { - entry = [ entry ]; - ticFileInfo.entries.set(key, entry); - } - entry.push(value); - } else { - ticFileInfo.entries.set(key, value); - } - }); + value = line.substr(keyEnd + 1); - return cb(null, ticFileInfo); - }); - } + // don't trim Ldesc; may mess with FILE_ID.DIZ type descriptions + if('ldesc' !== key) { + value = value.trim(); + } + + // convert well known keys to a more reasonable format + switch(key) { + case 'origin' : + case 'from' : + case 'seenby' : + case 'to' : + value = Address.fromString(value); + break; + + case 'crc' : + value = parseInt(value, 16); + break; + + case 'size' : + value = parseInt(value, 10); + break; + + default : + break; + } + + entry = ticFileInfo.entries.get(key); + + if(entry) { + if(!Array.isArray(entry)) { + entry = [ entry ]; + ticFileInfo.entries.set(key, entry); + } + entry.push(value); + } else { + ticFileInfo.entries.set(key, value); + } + }); + + return cb(null, ticFileInfo); + }); + } }; diff --git a/core/ticker_text_view.js b/core/ticker_text_view.js deleted file mode 100644 index 6574880b..00000000 --- a/core/ticker_text_view.js +++ /dev/null @@ -1,94 +0,0 @@ -/* jslint node: true */ -'use strict'; - -var View = require('./view.js').View; -var miscUtil = require('./misc_util.js'); -var strUtil = require('./string_util.js'); -var ansi = require('./ansi_term.js'); -var util = require('util'); -var assert = require('assert'); - -exports.TickerTextView = TickerTextView; - -function TickerTextView(options) { - View.call(this, options); - - var self = this; - - this.text = options.text || ''; - this.tickerStyle = options.tickerStyle || 'rightToLeft'; - assert(this.tickerStyle in TickerTextView.TickerStyles); - - // :TODO: Ticker |text| should have ANSI stripped before calculating any lengths/etc. - // strUtil.ansiTextLength(s) - // strUtil.pad(..., ignoreAnsi) - // strUtil.stylizeString(..., ignoreAnsi) - - this.tickerState = {}; - switch(this.tickerStyle) { - case 'rightToLeft' : - this.tickerState.pos = this.position.row + this.dimens.width; - break; - } - - - self.onTickerInterval = function() { - switch(self.tickerStyle) { - case 'rightToLeft' : self.updateRightToLeftTicker(); break; - } - }; - - self.updateRightToLeftTicker = function() { - // if pos < start - // drawRemain() - // if pos + remain > end - // drawRemain(0, spaceFor) - // else - // drawString() + remainPading - }; - -} - -util.inherits(TickerTextView, View); - -TickerTextView.TickerStyles = { - leftToRight : 1, - rightToLeft : 2, - bounce : 3, - slamLeft : 4, - slamRight : 5, - slamBounce : 6, - decrypt : 7, - typewriter : 8, -}; -Object.freeze(TickerTextView.TickerStyles); - -/* -TickerTextView.TICKER_STYLES = [ - 'leftToRight', - 'rightToLeft', - 'bounce', - 'slamLeft', - 'slamRight', - 'slamBounce', - 'decrypt', - 'typewriter', -]; -*/ - -TickerTextView.prototype.controllerAttached = function() { - // :TODO: call super -}; - -TickerTextView.prototype.controllerDetached = function() { - // :TODO: call super - -}; - -TickerTextView.prototype.setText = function(text) { - this.text = strUtil.stylizeString(text, this.textStyle); - - if(!this.dimens || !this.dimens.width) { - this.dimens.width = Math.ceil(this.text.length / 2); - } -}; \ No newline at end of file diff --git a/core/toggle_menu_view.js b/core/toggle_menu_view.js index 35676193..c06297b2 100644 --- a/core/toggle_menu_view.js +++ b/core/toggle_menu_view.js @@ -1,123 +1,128 @@ /* jslint node: true */ 'use strict'; -var MenuView = require('./menu_view.js').MenuView; -var ansi = require('./ansi_term.js'); -var strUtil = require('./string_util.js'); +const MenuView = require('./menu_view.js').MenuView; +const strUtil = require('./string_util.js'); -var util = require('util'); -var assert = require('assert'); -var _ = require('lodash'); +const util = require('util'); +const assert = require('assert'); -exports.ToggleMenuView = ToggleMenuView; +exports.ToggleMenuView = ToggleMenuView; function ToggleMenuView (options) { - options.cursor = options.cursor || 'hide'; + options.cursor = options.cursor || 'hide'; - MenuView.call(this, options); + MenuView.call(this, options); - var self = this; + this.initDefaultWidth(); - /* - this.cachePositions = function() { - self.positionCacheExpired = false; - }; - */ + var self = this; - this.updateSelection = function() { - assert(this.focusedItemIndex >= 0 && this.focusedItemIndex <= self.items.length); - self.redraw(); - }; + /* + this.cachePositions = function() { + self.positionCacheExpired = false; + }; + */ + + this.updateSelection = function() { + assert(this.focusedItemIndex >= 0 && this.focusedItemIndex <= self.items.length); + self.redraw(); + }; } util.inherits(ToggleMenuView, MenuView); ToggleMenuView.prototype.redraw = function() { - ToggleMenuView.super_.prototype.redraw.call(this); + ToggleMenuView.super_.prototype.redraw.call(this); - //this.cachePositions(); + if(0 === this.items.length) { + return; + } - this.client.term.write(this.hasFocus ? this.getFocusSGR() : this.getSGR()); + //this.cachePositions(); - assert(this.items.length === 2); - for(var i = 0; i < 2; i++) { - var item = this.items[i]; - var text = strUtil.stylizeString( - item.text, i === this.focusedItemIndex && this.hasFocus ? this.focusTextStyle : this.textStyle); - - if(1 === i) { - //console.log(this.styleColor1) - //var sepColor = this.getANSIColor(this.styleColor1 || this.getColor()); - //console.log(sepColor.substr(1)) - //var sepColor = '\u001b[0m\u001b[1;30m'; // :TODO: FIX ME!!! - // :TODO: sepChar needs to be configurable!!! - this.client.term.write(this.styleSGR1 + ' / '); - //this.client.term.write(sepColor + ' / '); - } + this.client.term.write(this.hasFocus ? this.getFocusSGR() : this.getSGR()); - this.client.term.write(i === this.focusedItemIndex ? this.getFocusSGR() : this.getSGR()); - this.client.term.write(text); - } + 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( + item.text, i === this.focusedItemIndex && this.hasFocus ? this.focusTextStyle : this.textStyle); + + if(1 === i) { + //console.log(this.styleColor1) + //var sepColor = this.getANSIColor(this.styleColor1 || this.getColor()); + //console.log(sepColor.substr(1)) + //var sepColor = '\u001b[0m\u001b[1;30m'; // :TODO: FIX ME!!! + // :TODO: sepChar needs to be configurable!!! + this.client.term.write(this.styleSGR1 + ' / '); + //this.client.term.write(sepColor + ' / '); + } + + this.client.term.write(i === this.focusedItemIndex ? this.getFocusSGR() : this.getSGR()); + this.client.term.write(text); + } }; ToggleMenuView.prototype.setFocusItemIndex = function(index) { - ToggleMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + ToggleMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex - this.updateSelection(); + this.updateSelection(); }; ToggleMenuView.prototype.setFocus = function(focused) { - ToggleMenuView.super_.prototype.setFocus.call(this, focused); + ToggleMenuView.super_.prototype.setFocus.call(this, focused); - this.redraw(); + this.redraw(); }; ToggleMenuView.prototype.focusNext = function() { - if(this.items.length - 1 === this.focusedItemIndex) { - this.focusedItemIndex = 0; - } else { - this.focusedItemIndex++; - } + if(this.items.length - 1 === this.focusedItemIndex) { + this.focusedItemIndex = 0; + } else { + this.focusedItemIndex++; + } - this.updateSelection(); + this.updateSelection(); - ToggleMenuView.super_.prototype.focusNext.call(this); + ToggleMenuView.super_.prototype.focusNext.call(this); }; ToggleMenuView.prototype.focusPrevious = function() { - if(0 === this.focusedItemIndex) { - this.focusedItemIndex = this.items.length - 1; - } else { - this.focusedItemIndex--; - } + if(0 === this.focusedItemIndex) { + this.focusedItemIndex = this.items.length - 1; + } else { + this.focusedItemIndex--; + } - this.updateSelection(); + this.updateSelection(); - ToggleMenuView.super_.prototype.focusPrevious.call(this); + ToggleMenuView.super_.prototype.focusPrevious.call(this); }; 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)) { - this.focusPrevious(); - } - } + 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.name)) { + this.focusPrevious(); + } + } - ToggleMenuView.super_.prototype.onKeyPress.call(this, ch, key); + ToggleMenuView.super_.prototype.onKeyPress.call(this, ch, key); }; ToggleMenuView.prototype.getData = function() { - return this.focusedItemIndex; + return this.focusedItemIndex; }; ToggleMenuView.prototype.setItems = function(items) { - ToggleMenuView.super_.prototype.setItems.call(this, items); + items = items.slice(0, 2); // switch/toggle only works with two elements - this.items = this.items.splice(0, 2); // switch/toggle only works with two elements + ToggleMenuView.super_.prototype.setItems.call(this, items); - this.dimens.width = this.items.join(' / ').length; // :TODO: allow configurable seperator... string & color, e.g. styleColor1 (same as fillChar color) + this.dimens.width = items.join(' / ').length; // :TODO: allow configurable seperator... string & color, e.g. styleColor1 (same as fillChar color) }; diff --git a/core/top_x.js b/core/top_x.js new file mode 100644 index 00000000..2403c380 --- /dev/null +++ b/core/top_x.js @@ -0,0 +1,235 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const UserProps = require('./user_property.js'); +const UserLogNames = require('./user_log_name.js'); +const { Errors } = require('./enig_error.js'); +const UserDb = require('./database.js').dbs.user; +const SysDb = require('./database.js').dbs.system; +const User = require('./user.js'); + +// deps +const _ = require('lodash'); +const async = require('async'); + +exports.moduleInfo = { + name : 'TopX', + desc : 'Displays users top X stats', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.topx', +}; + +const FormIds = { + menu : 0, +}; + +exports.getModule = class TopXModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + const userPropValues = _.values(UserProps); + const userLogValues = _.values(UserLogNames); + + const hasMci = (c, t) => { + if(!Array.isArray(t)) { + t = [ t ]; + } + return t.some(t => _.isObject(mciData, [ 'menu', `${t}${c}` ])); + }; + + return this.validateConfigFields( + { + mciMap : (key, config) => { + const mciCodes = Object.keys(config.mciMap).map(mci => { + return parseInt(mci); + }).filter(mci => !isNaN(mci)); + if(0 === mciCodes.length) { + return false; + } + return mciCodes.every(mci => { + const o = config.mciMap[mci]; + if(!_.isObject(o)) { + return false; + } + const type = o.type; + switch(type) { + case 'userProp' : + if(!userPropValues.includes(o.value)) { + return false; + } + // VM# must exist for this mci + if(!_.isObject(mciData, [ 'menu', `VM${mci}` ])) { + return false; + } + break; + + case 'userEventLog' : + if(!userLogValues.includes(o.value)) { + return false; + } + // VM# must exist for this mci + if(!hasMci(mci, ['VM'])) { + return false; + } + break; + + default : + return false; + } + return true; + }); + } + }, + callback + ); + }, + (callback) => { + return this.prepViewController('menu', FormIds.menu, mciData.menu, callback); + }, + (callback) => { + async.forEachSeries(Object.keys(this.config.mciMap), (mciCode, nextMciCode) => { + return this.populateTopXList(mciCode, nextMciCode); + }, + err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + }); + } + + populateTopXList(mciCode, cb) { + const listView = this.viewControllers.menu.getView(mciCode); + if(!listView) { + return cb(Errors.UnexpectedState(`Failed to get view for MCI ${mciCode}`)); + } + + const type = this.config.mciMap[mciCode].type; + switch(type) { + case 'userProp' : return this.populateTopXUserProp(listView, mciCode, cb); + case 'userEventLog' : return this.populateTopXUserEventLog(listView, mciCode, cb); + + // we should not hit here; validation happens up front + default : return cb(Errors.UnexpectedState(`Unexpected type: ${type}`)); + } + } + + rowsToItems(rows, cb) { + let position = 1; + async.mapSeries(rows, (row, nextRow) => { + this.loadUserInfo(row.user_id, (err, userInfo) => { + if(err) { + return nextRow(err); + } + return nextRow(null, Object.assign(userInfo, { position : position++, value : row.value })); + }); + }, + (err, items) => { + return cb(err, items); + }); + } + + populateTopXUserEventLog(listView, mciCode, cb) { + const mciMap = this.config.mciMap[mciCode]; + const count = listView.dimens.height || 1; + const daysBack = mciMap.daysBack; + const shouldSum = _.get(mciMap, 'sum', true); + + const valueSql = shouldSum ? 'SUM(CAST(log_value AS INTEGER))' : 'COUNT()'; + const dateSql = daysBack ? `AND DATETIME(timestamp) >= DATETIME('now', '-${daysBack} days')` : ''; + + SysDb.all( + `SELECT user_id, ${valueSql} AS value + FROM user_event_log + WHERE log_name = ? ${dateSql} + GROUP BY user_id + ORDER BY value DESC + LIMIT ${count};`, + [ mciMap.value ], + (err, rows) => { + if(err) { + return cb(err); + } + + this.rowsToItems(rows, (err, items) => { + if(err) { + return cb(err); + } + listView.setItems(items); + listView.redraw(); + return cb(null); + }); + } + ); + } + + populateTopXUserProp(listView, mciCode, cb) { + const count = listView.dimens.height || 1; + UserDb.all( + `SELECT user_id, CAST(prop_value AS INTEGER) AS value + FROM user_property + WHERE prop_name = ? + ORDER BY value DESC + LIMIT ${count};`, + [ this.config.mciMap[mciCode].value ], + (err, rows) => { + if(err) { + return cb(err); + } + + this.rowsToItems(rows, (err, items) => { + if(err) { + return cb(err); + } + listView.setItems(items); + listView.redraw(); + return cb(null); + }); + } + ); + } + + loadUserInfo(userId, cb) { + const getPropOpts = { + names : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations ] + }; + + const userInfo = { userId }; + User.getUserName(userId, (err, userName) => { + if(err) { + return cb(err); + } + + userInfo.userName = userName; + + User.loadProperties(userId, getPropOpts, (err, props) => { + if(err) { + return cb(err); + } + + userInfo.location = props[UserProps.Location] || ''; + userInfo.affils = userInfo.affiliation = props[UserProps.Affiliations] || ''; + userInfo.realName = props[UserProps.RealName] || ''; + + return cb(null, userInfo); + }); + }); + } +}; diff --git a/core/upload.js b/core/upload.js new file mode 100644 index 00000000..b451ac9a --- /dev/null +++ b/core/upload.js @@ -0,0 +1,738 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const MenuModule = require('./menu_module.js').MenuModule; +const stringFormat = require('./string_format.js'); +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const getAreaDefaultStorageDirectory = require('./file_base_area.js').getAreaDefaultStorageDirectory; +const scanFile = require('./file_base_area.js').scanFile; +const getFileAreaByTag = require('./file_base_area.js').getFileAreaByTag; +const getDescFromFileName = require('./file_base_area.js').getDescFromFileName; +const ansiGoto = require('./ansi_term.js').goto; +const moveFileWithCollisionHandling = require('./file_util.js').moveFileWithCollisionHandling; +const pathWithTerminatingSeparator = require('./file_util.js').pathWithTerminatingSeparator; +const Log = require('./logger.js').log; +const Errors = require('./enig_error.js').Errors; +const FileEntry = require('./file_entry.js'); +const isAnsi = require('./string_util.js').isAnsi; +const Events = require('./events.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); +const temptmp = require('temptmp').createTrackedSession('upload'); +const paths = require('path'); +const sanatizeFilename = require('sanitize-filename'); + +exports.moduleInfo = { + name : 'Upload', + desc : 'Module for classic file uploads', + author : 'NuSkooler', +}; + +const FormIds = { + options : 0, + processing : 1, + fileDetails : 2, + dupes : 3, +}; + +const MciViewIds = { + options : { + area : 1, // area selection + uploadType : 2, // blind vs specify filename + fileName : 3, // for non-blind; not editable for blind + navMenu : 4, // next/cancel/etc. + errMsg : 5, // errors (e.g. filename cannot be blank) + }, + + processing : { + calcHashIndicator : 1, + archiveListIndicator : 2, + descFileIndicator : 3, + logStep : 4, + customRangeStart : 10, // 10+ = customs + }, + + fileDetails : { + desc : 1, // defaults to 'desc' (e.g. from FILE_ID.DIZ) + tags : 2, // tag(s) for item + estYear : 3, + accept : 4, // accept fields & continue + customRangeStart : 10, // 10+ = customs + }, + + dupes : { + dupeList : 1, + } +}; + +exports.getModule = class UploadModule extends MenuModule { + + constructor(options) { + super(options); + + this.interrupt = MenuModule.InterruptTypes.Never; + + if(_.has(options, 'lastMenuResult.recvFilePaths')) { + this.recvFilePaths = options.lastMenuResult.recvFilePaths; + } + + this.availAreas = getSortedAvailableFileAreas(this.client, { writeAcs : true } ); + + this.menuMethods = { + optionsNavContinue : (formData, extraArgs, cb) => { + return this.performUpload(cb); + }, + + fileDetailsContinue : (formData, extraArgs, cb) => { + // see displayFileDetailsPageForUploadEntry() for this hackery: + cb(null); + return this.fileDetailsCurrentEntrySubmitCallback(null, formData.value); // move on to the next entry, if any + }, + + // validation + validateNonBlindFileName : (fileName, cb) => { + if(0 === fileName.length) { + return cb(new Error('Filename cannot be empty')); + } + + fileName = sanatizeFilename(fileName); // remove unsafe chars, path info, etc. + if(0 === fileName.length) { // sanatize nuked everything? + return cb(new Error('Invalid filename')); + } + + // At least SEXYZ doesn't like non-blind names that start with a number - it becomes confused ;-( + if(/^[0-9].*$/.test(fileName)) { + return cb(new Error('Invalid filename')); + } + + return cb(null); + }, + viewValidationListener : (err, cb) => { + const errView = this.viewControllers.options.getView(MciViewIds.options.errMsg); + if(errView) { + if(err) { + errView.setText(err.message); + } else { + errView.clearText(); + } + } + + return cb(null); + } + }; + } + + getSaveState() { + // if no areas, we're falling back due to lack of access/areas avail to upload to + if(this.availAreas.length > 0) { + return { + uploadType : this.uploadType, + tempRecvDirectory : this.tempRecvDirectory, + areaInfo : this.availAreas[ this.viewControllers.options.getView(MciViewIds.options.area).getData() ], + }; + } + } + + restoreSavedState(savedState) { + if(savedState.areaInfo) { + this.uploadType = savedState.uploadType; + this.areaInfo = savedState.areaInfo; + this.tempRecvDirectory = savedState.tempRecvDirectory; + } + } + + isBlindUpload() { return 'blind' === this.uploadType; } + isFileTransferComplete() { return !_.isUndefined(this.recvFilePaths); } + + initSequence() { + const self = this; + + if(0 === this.availAreas.length) { + // + return this.gotoMenu(this.menuConfig.config.noUploadAreasAvailMenu || 'fileBaseNoUploadAreasAvail'); + } + + async.series( + [ + function before(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + if(self.isFileTransferComplete()) { + return self.displayProcessingPage(callback); + } else { + return self.displayOptionsPage(callback); + } + } + ], + () => { + return self.finishedLoading(); + } + ); + } + + finishedLoading() { + if(this.isFileTransferComplete()) { + return this.processUploadedFiles(); + } + } + + performUpload(cb) { + temptmp.mkdir( { prefix : 'enigul-' }, (err, tempRecvDirectory) => { + if(err) { + return cb(err); + } + + // need a terminator for various external protocols + this.tempRecvDirectory = pathWithTerminatingSeparator(tempRecvDirectory); + + const modOpts = { + extraArgs : { + recvDirectory : this.tempRecvDirectory, // we'll move files from here to their area container once processed/confirmed + direction : 'recv', + } + }; + + if(!this.isBlindUpload()) { + // data has been sanatized at this point + modOpts.extraArgs.recvFileName = this.viewControllers.options.getView(MciViewIds.options.fileName).getData(); + } + + // + // Move along to protocol selection -> file transfer + // Upon completion, we'll re-enter the module with some file paths handed to us + // + return this.gotoMenu( + this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', + modOpts, + cb + ); + }); + } + + continueNonBlindUpload(cb) { + return cb(null); + } + + updateScanStepInfoViews(stepInfo) { + // :TODO: add some blinking (e.g. toggle items) indicators - see OBV.DOC + + const fmtObj = Object.assign( {}, stepInfo); + let stepIndicatorFmt = ''; + let logStepFmt; + + const fmtConfig = this.menuConfig.config; + + const indicatorStates = fmtConfig.indicatorStates || [ '|', '/', '-', '\\' ]; + const indicatorFinished = fmtConfig.indicatorFinished || '√'; + + const indicator = { }; + const self = this; + + function updateIndicator(mci, isFinished) { + indicator.mci = mci; + + if(isFinished) { + indicator.text = indicatorFinished; + } else { + self.scanStatus.indicatorPos += 1; + if(self.scanStatus.indicatorPos >= indicatorStates.length) { + self.scanStatus.indicatorPos = 0; + } + indicator.text = indicatorStates[self.scanStatus.indicatorPos]; + } + } + + switch(stepInfo.step) { + case 'start' : + logStepFmt = stepIndicatorFmt = fmtConfig.scanningStartFormat || 'Scanning {fileName}'; + break; + + case 'hash_update' : + stepIndicatorFmt = fmtConfig.calcHashFormat || 'Calculating hash/checksums: {calcHashPercent}%'; + updateIndicator(MciViewIds.processing.calcHashIndicator); + break; + + case 'hash_finish' : + stepIndicatorFmt = fmtConfig.calcHashCompleteFormat || 'Finished calculating hash/checksums'; + updateIndicator(MciViewIds.processing.calcHashIndicator, true); + break; + + case 'archive_list_start' : + stepIndicatorFmt = fmtConfig.extractArchiveListFormat || 'Extracting archive list'; + updateIndicator(MciViewIds.processing.archiveListIndicator); + break; + + case 'archive_list_finish' : + fmtObj.archivedFileCount = stepInfo.archiveEntries.length; + stepIndicatorFmt = fmtConfig.extractArchiveListFinishFormat || 'Archive list extracted ({archivedFileCount} files)'; + updateIndicator(MciViewIds.processing.archiveListIndicator, true); + break; + + case 'archive_list_failed' : + stepIndicatorFmt = fmtConfig.extractArchiveListFailedFormat || 'Archive list extraction failed'; + break; + + case 'desc_files_start' : + stepIndicatorFmt = fmtConfig.processingDescFilesFormat || 'Processing description files'; + updateIndicator(MciViewIds.processing.descFileIndicator); + break; + + case 'desc_files_finish' : + stepIndicatorFmt = fmtConfig.processingDescFilesFinishFormat || 'Finished processing description files'; + updateIndicator(MciViewIds.processing.descFileIndicator, true); + break; + + case 'finished' : + logStepFmt = stepIndicatorFmt = fmtConfig.scanningStartFormat || 'Finished'; + break; + } + + fmtObj.stepIndicatorText = stringFormat(stepIndicatorFmt, fmtObj); + + if(this.hasProcessingArt) { + this.updateCustomViewTextsWithFilter('processing', MciViewIds.processing.customRangeStart, fmtObj, { appendMultiLine : true } ); + + if(indicator.mci && indicator.text) { + this.setViewText('processing', indicator.mci, indicator.text); + } + + if(logStepFmt) { + this.setViewText('processing', MciViewIds.processing.logStep, stringFormat(logStepFmt, fmtObj), { appendMultiLine : true } ); + } + } else { + this.client.term.pipeWrite(fmtObj.stepIndicatorText); + } + } + + scanFiles(cb) { + const self = this; + + const results = { + newEntries : [], + dupes : [], + }; + + self.client.log.debug('Scanning upload(s)', { paths : this.recvFilePaths } ); + + let currentFileNum = 0; + + async.eachSeries(this.recvFilePaths, (filePath, nextFilePath) => { + // :TODO: virus scanning/etc. should occur around here + + currentFileNum += 1; + + self.scanStatus = { + indicatorPos : 0, + }; + + const scanOpts = { + areaTag : self.areaInfo.areaTag, + storageTag : self.areaInfo.storageTags[0], + }; + + function handleScanStep(stepInfo, nextScanStep) { + stepInfo.totalFileNum = self.recvFilePaths.length; + stepInfo.currentFileNum = currentFileNum; + + self.updateScanStepInfoViews(stepInfo); + return nextScanStep(null); + } + + self.client.log.debug('Scanning file', { filePath : filePath } ); + + scanFile(filePath, scanOpts, handleScanStep, (err, fileEntry, dupeEntries) => { + if(err) { + return nextFilePath(err); + } + + // new or dupe? + if(dupeEntries.length > 0) { + // 1:n dupes found + self.client.log.debug('Duplicate file(s) found', { dupeEntries : dupeEntries } ); + + results.dupes = results.dupes.concat(dupeEntries); + } else { + // new one + results.newEntries.push(fileEntry); + } + + return nextFilePath(null); + }); + }, err => { + return cb(err, results); + }); + } + + cleanupTempFiles() { + temptmp.cleanup( paths => { + Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' ); + }); + } + + moveAndPersistUploadsToDatabase(newEntries) { + + const areaStorageDir = getAreaDefaultStorageDirectory(this.areaInfo); + const self = this; + + async.eachSeries(newEntries, (newEntry, nextEntry) => { + const src = paths.join(self.tempRecvDirectory, newEntry.fileName); + const dst = paths.join(areaStorageDir, newEntry.fileName); + + moveFileWithCollisionHandling(src, dst, (err, finalPath) => { + if(err) { + self.client.log.error( + 'Failed moving physical upload file', { error : err.message, fileName : newEntry.fileName, source : src, dest : dst } + ); + 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 } ); + + // persist to DB + newEntry.persist(err => { + if(err) { + self.client.log.error('Failed persisting upload to database', { path : finalPath, error : err.message } ); + } + + return nextEntry(null); // still try next file + }); + }); + }, () => { + // + // Finally, we can remove any temp files that we may have created + // + self.cleanupTempFiles(); + }); + } + + prepDetailsForUpload(scanResults, cb) { + async.eachSeries(scanResults.newEntries, (newEntry, nextEntry) => { + newEntry.meta.upload_by_username = this.client.user.username; + newEntry.meta.upload_by_user_id = this.client.user.userId; + + this.displayFileDetailsPageForUploadEntry(newEntry, (err, newValues) => { + if(err) { + return nextEntry(err); + } + + if(!newEntry.descIsAnsi) { + newEntry.desc = _.trimEnd(newValues.shortDesc); + } + + if(newValues.estYear.length > 0) { + newEntry.meta.est_release_year = newValues.estYear; + } + + if(newValues.tags.length > 0) { + newEntry.setHashTags(newValues.tags); + } + + return nextEntry(err); + }); + }, err => { + delete this.fileDetailsCurrentEntrySubmitCallback; + return cb(err, scanResults); + }); + } + + displayDupesPage(dupes, cb) { + // + // If we have custom art to show, use it - else just dump basic info. + // Pause at the end in either case. + // + const self = this; + + async.waterfall( + [ + function prepArtAndViewController(callback) { + self.prepViewControllerWithArt( + 'dupes', + FormIds.dupes, + { clearScreen : true, trailingLF : false }, + err => { + if(err) { + self.client.term.pipeWrite('|00|07Duplicate upload(s) found:\n'); + return callback(null, null); + } + + const dupeListView = self.viewControllers.dupes.getView(MciViewIds.dupes.dupeList); + return callback(null, dupeListView); + } + ); + }, + function prepDupeObjects(dupeListView, callback) { + // update dupe objects with additional info that can be used for formatString() and the like + async.each(dupes, (dupe, nextDupe) => { + FileEntry.loadBasicEntry(dupe.fileId, dupe, err => { + if(err) { + return nextDupe(err); + } + + const areaInfo = getFileAreaByTag(dupe.areaTag); + if(areaInfo) { + dupe.areaName = areaInfo.name; + dupe.areaDesc = areaInfo.desc; + } + return nextDupe(null); + }); + }, err => { + return callback(err, dupeListView); + }); + }, + function populateDupeInfo(dupeListView, callback) { + const dupeInfoFormat = self.menuConfig.config.dupeInfoFormat || '{fileName} @ {areaName}'; + + if(dupeListView) { + dupeListView.setItems(dupes.map(dupe => stringFormat(dupeInfoFormat, dupe) ) ); + dupeListView.redraw(); + } else { + dupes.forEach(dupe => { + self.client.term.pipeWrite(`${stringFormat(dupeInfoFormat, dupe)}\n`); + }); + } + + return callback(null); + }, + function pause(callback) { + return self.pausePrompt( { row : self.client.term.termHeight }, callback); + } + ], + err => { + return cb(err); + } + ); + } + + processUploadedFiles() { + // + // For each file uploaded, we need to process & gather information + // + const self = this; + + async.waterfall( + [ + function prepNonBlind(callback) { + if(self.isBlindUpload()) { + return callback(null); + } + + // + // For non-blind uploads, batch is not supported, we expect a single file + // in |recvFilePaths|. If not, it's an error (we don't want to process the wrong thing) + // + if(self.recvFilePaths.length > 1) { + self.client.log.warn( { recvFilePaths : self.recvFilePaths }, 'Non-blind upload received 2:n files' ); + return callback(Errors.UnexpectedState(`Non-blind upload expected single file but got received ${self.recvFilePaths.length}`)); + } + + return callback(null); + }, + function scan(callback) { + return self.scanFiles(callback); + }, + function pause(scanResults, callback) { + if(self.hasProcessingArt) { + self.client.term.rawWrite(ansiGoto(self.client.term.termHeight, 1)); + } else { + self.client.term.write('\n'); + } + + self.pausePrompt( () => { + return callback(null, scanResults); + }); + }, + function displayDupes(scanResults, callback) { + if(0 === scanResults.dupes.length) { + return callback(null, scanResults); + } + + return self.displayDupesPage(scanResults.dupes, () => { + return callback(null, scanResults); + }); + }, + function prepDetails(scanResults, callback) { + return self.prepDetailsForUpload(scanResults, callback); + }, + function startMovingAndPersistingToDatabase(scanResults, callback) { + // + // *Start* the process of moving files from their current |tempRecvDirectory| + // locations -> their final area destinations. Don't make the user wait + // here as I/O can take quite a bit of time. Log any failures. + // + self.moveAndPersistUploadsToDatabase(scanResults.newEntries); + return callback(null, scanResults.newEntries); + }, + function sendEvent(uploadedEntries, callback) { + Events.emit( + Events.getSystemEvents().UserUpload, + { + user : self.client.user, + files : uploadedEntries, + } + ); + return callback(null); + } + ], + err => { + if(err) { + self.client.log.warn('File upload error encountered', { error : err.message } ); + self.cleanupTempFiles(); // normally called after moveAndPersistUploadsToDatabase() is completed. + } + + return self.prevMenu(); + } + ); + } + + displayOptionsPage(cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.prepViewControllerWithArt( + 'options', + FormIds.options, + { clearScreen : true, trailingLF : false }, + callback + ); + }, + function populateViews(callback) { + const areaSelectView = self.viewControllers.options.getView(MciViewIds.options.area); + areaSelectView.setItems( self.availAreas.map(areaInfo => areaInfo.name ) ); + + const uploadTypeView = self.viewControllers.options.getView(MciViewIds.options.uploadType); + const fileNameView = self.viewControllers.options.getView(MciViewIds.options.fileName); + + const blindFileNameText = self.menuConfig.config.blindFileNameText || '(blind - filename ignored)'; + + uploadTypeView.on('index update', idx => { + self.uploadType = (0 === idx) ? 'blind' : 'non-blind'; + + if(self.isBlindUpload()) { + fileNameView.setText(blindFileNameText); + fileNameView.acceptsFocus = false; + } else { + fileNameView.clearText(); + fileNameView.acceptsFocus = true; + } + }); + + // sanatize filename for display when leaving the view + self.viewControllers.options.on('leave', prevView => { + if(prevView.id === MciViewIds.options.fileName) { + fileNameView.setText(sanatizeFilename(fileNameView.getData())); + } + }); + + self.uploadType = 'blind'; + uploadTypeView.setFocusItemIndex(0); // default to blind + fileNameView.setText(blindFileNameText); + areaSelectView.redraw(); + + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayProcessingPage(cb) { + return this.prepViewControllerWithArt( + 'processing', + FormIds.processing, + { clearScreen : true, trailingLF : false }, + err => { + // note: this art is not required + this.hasProcessingArt = !err; + + return cb(null); + } + ); + } + + fileEntryHasDetectedDesc(fileEntry) { + return (fileEntry.desc && fileEntry.desc.length > 0); + } + + displayFileDetailsPageForUploadEntry(fileEntry, cb) { + const self = this; + + async.waterfall( + [ + function prepArtAndViewController(callback) { + return self.prepViewControllerWithArt( + 'fileDetails', + FormIds.fileDetails, + { clearScreen : true, trailingLF : false }, + err => { + return callback(err); + } + ); + }, + function populateViews(callback) { + const descView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.desc); + const tagsView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.tags); + const yearView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.estYear); + + self.updateCustomViewTextsWithFilter('fileDetails', MciViewIds.fileDetails.customRangeStart, fileEntry ); + + tagsView.setText( Array.from(fileEntry.hashTags).join(',') ); // :TODO: optional 'hashTagsSep' like file list/browse + yearView.setText(fileEntry.meta.est_release_year || ''); + + if(isAnsi(fileEntry.desc)) { + fileEntry.descIsAnsi = true; + + return descView.setAnsi( + fileEntry.desc, + { + prepped : false, + forceLineTerm : true, + }, + () => { + return callback(null, descView, 'preview', MciViewIds.fileDetails.tags); + } + ); + } else { + const hasDesc = self.fileEntryHasDetectedDesc(fileEntry); + descView.setText( + hasDesc ? fileEntry.desc : getDescFromFileName(fileEntry.fileName), + { scrollMode : 'top' } // override scroll mode; we want to be @ top + ); + return callback(null, descView, 'edit', hasDesc ? MciViewIds.fileDetails.tags : MciViewIds.fileDetails.desc); + } + }, + function finalizeViews(descView, descViewMode, focusId, callback) { + descView.setPropertyValue('mode', descViewMode); + descView.acceptsFocus = 'preview' === descViewMode ? false : true; + self.viewControllers.fileDetails.switchFocus(focusId); + return callback(null); + } + ], + err => { + // + // we only call |cb| here if there is an error + // else, wait for the current from to be submit - then call - + // this way we'll move on to the next file entry when ready + // + if(err) { + return cb(err); + } + + self.fileDetailsCurrentEntrySubmitCallback = cb; // stash for moduleMethods.fileDetailsContinue + } + ); + } +}; diff --git a/core/user.js b/core/user.js index 15e5a844..439d32c8 100644 --- a/core/user.js +++ b/core/user.js @@ -1,598 +1,846 @@ /* jslint node: true */ 'use strict'; -const userDb = require('./database.js').dbs.user; -const Config = require('./config.js').config; -const userGroup = require('./user_group.js'); -const Errors = require('./enig_error.js').Errors; +// ENiGMA½ +const userDb = require('./database.js').dbs.user; +const Config = require('./config.js').get; +const userGroup = require('./user_group.js'); +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); +const Events = require('./events.js'); +const UserProps = require('./user_property.js'); +const Log = require('./logger.js').log; +const StatLog = require('./stat_log.js'); -// deps -const crypto = require('crypto'); -const assert = require('assert'); -const async = require('async'); -const _ = require('lodash'); -const moment = require('moment'); +// deps +const crypto = require('crypto'); +const assert = require('assert'); +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) - } + constructor() { + this.userId = 0; + this.username = ''; + this.properties = {}; // name:value + this.groups = []; // group membership(s) + this.authFactor = User.AuthFactors.None; + } - // static property accessors - static get RootUserID() { - return 1; - } + // static property accessors + static get RootUserID() { + return 1; + } - static get PBKDF2() { - return { - iterations : 1000, - keyLen : 128, - saltLen : 32, - }; - } + 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 StandardPropertyGroups() { - return { - password : [ 'pw_pbkdf2_salt', 'pw_pbkdf2_dk' ], - }; - } + static get PBKDF2() { + return { + iterations : 1000, + keyLen : 128, + saltLen : 32, + }; + } - static get AccountStatus() { - return { - disabled : 0, - inactive : 1, - active : 2, - }; - } - - isAuthenticated() { - return true === this.authenticated; - } + static get StandardPropertyGroups() { + return { + auth : [ + UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk, + UserProps.AuthPubKey, + ], + }; + } - isValid() { - if(this.userId <= 0 || this.username.length < Config.users.usernameMin) { - return false; - } + static get AccountStatus() { + return { + disabled : 0, // +op disabled + inactive : 1, // inactive, aka requires +op approval/activation + active : 2, // standard, active + locked : 3, // locked out (too many bad login attempts, etc.) + }; + } - return this.hasValidPassword(); - } + isAuthenticated() { + return true === this.authenticated; + } - hasValidPassword() { - if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) { - return false; - } + isValid() { + if(this.userId <= 0 || this.username.length < Config().users.usernameMin) { + return false; + } - return this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2 && this.prop_name.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2; - } + return this.hasValidPasswordProperties(); + } - isRoot() { - return User.isRootUserId(this.userId); - } + hasValidPasswordProperties() { + const salt = this.getProperty(UserProps.PassPbkdf2Salt); + const dk = this.getProperty(UserProps.PassPbkdf2Dk); - isSysOp() { // alias to isRoot() - return this.isRoot(); - } + if(!salt || !dk || + (salt.length !== User.PBKDF2.saltLen * 2) || + (dk.length !== User.PBKDF2.keyLen * 2)) + { + return false; + } - isGroupMember(groupNames) { - if(_.isString(groupNames)) { - groupNames = [ groupNames ]; - } + return true; + } - const isMember = groupNames.some(gn => (-1 !== this.groups.indexOf(gn))); - return isMember; - } + isRoot() { + return User.isRootUserId(this.userId); + } - getLegacySecurityLevel() { - if(this.isRoot() || this.isGroupMember('sysops')) { - return 100; - } - - if(this.isGroupMember('users')) { - return 30; - } - - return 10; // :TODO: Is this what we want? - } + isSysOp() { // alias to isRoot() + return this.isRoot(); + } - authenticate(username, password, cb) { - const self = this; - const cachedInfo = {}; + isGroupMember(groupNames) { + if(_.isString(groupNames)) { + groupNames = [ groupNames ]; + } - async.waterfall( - [ - function fetchUserId(callback) { - // get user ID - User.getUserIdAndName(username, (err, uid, un) => { - cachedInfo.userId = uid; - cachedInfo.username = un; + const isMember = groupNames.some(gn => (-1 !== this.groups.indexOf(gn))); + return isMember; + } - return callback(err); - }); - }, - function getRequiredAuthProperties(callback) { - // fetch properties required for authentication - User.loadProperties(cachedInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => { - return callback(err, props); - }); - }, - function getDkWithSalt(props, callback) { - // get DK from stored salt and password provided - User.generatePasswordDerivedKey(password, props.pw_pbkdf2_salt, (err, dk) => { - return callback(err, dk, props.pw_pbkdf2_dk); - }); - }, - function validateAuth(passDk, propsDk, callback) { - // - // Use constant time comparison here for security feel-goods - // - const passDkBuf = new Buffer(passDk, 'hex'); - const propsDkBuf = new Buffer(propsDk, 'hex'); + getSanitizedName(type='username') { + const name = 'real' === type ? this.getProperty(UserProps.RealName) : this.username; + return sanatizeFilename(name) || `user${this.userId.toString()}`; + } - if(passDkBuf.length !== propsDkBuf.length) { - return callback(Errors.AccessDenied('Invalid password')); - } + getLegacySecurityLevel() { + if(this.isRoot() || this.isGroupMember('sysops')) { + return 100; + } - let c = 0; - for(let i = 0; i < passDkBuf.length; i++) { - c |= passDkBuf[i] ^ propsDkBuf[i]; - } + if(this.isGroupMember('users')) { + return 30; + } - return callback(0 === c ? null : Errors.AccessDenied('Invalid password')); - }, - function initProps(callback) { - User.loadProperties(cachedInfo.userId, (err, allProps) => { - if(!err) { - cachedInfo.properties = allProps; - } + return 10; // :TODO: Is this what we want? + } - return callback(err); - }); - }, - function initGroups(callback) { - userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => { - if(!err) { - cachedInfo.groups = groups; - } + processFailedLogin(userId, cb) { + async.waterfall( + [ + (callback) => { + return User.getUser(userId, callback); + }, + (tempUser, callback) => { + return StatLog.incrementUserStat( + tempUser, + UserProps.FailedLoginAttempts, + 1, + (err, failedAttempts) => { + return callback(null, tempUser, failedAttempts); + } + ); + }, + (tempUser, failedAttempts, callback) => { + const lockAccount = _.get(Config(), 'users.failedLogin.lockAccount'); + if(lockAccount > 0 && failedAttempts >= lockAccount) { + const props = { + [ UserProps.AccountStatus ] : User.AccountStatus.locked, + [ UserProps.AccountLockedTs ] : StatLog.now, + }; + if(!_.has(tempUser.properties, UserProps.AccountLockedPrevStatus)) { + props[UserProps.AccountLockedPrevStatus] = tempUser.getProperty(UserProps.AccountStatus); + } + Log.info( { userId, failedAttempts }, '(Re)setting account to locked due to failed logins'); + return tempUser.persistProperties(props, callback); + } - return callback(err); - }); - } - ], - err => { - if(!err) { - self.userId = cachedInfo.userId; - self.username = cachedInfo.username; - self.properties = cachedInfo.properties; - self.groups = cachedInfo.groups; - self.authenticated = true; - } + return cb(null); + } + ], + err => { + return cb(err); + } + ); + } - return cb(err); - } - ); - } + unlockAccount(cb) { + const prevStatus = this.getProperty(UserProps.AccountLockedPrevStatus); + if(!prevStatus) { + return cb(null); // nothing to do + } - create(password, cb) { - assert(0 === this.userId); + this.persistProperty(UserProps.AccountStatus, prevStatus, err => { + if(err) { + return cb(err); + } - if(this.username.length < Config.users.usernameMin || this.username.length > Config.users.usernameMax) { - return cb(Errors.Invalid('Invalid username length')); - } + return this.removeProperties( [ UserProps.AccountLockedPrevStatus, UserProps.AccountLockedTs ], cb); + }); + } - const self = this; + static get AuthFactor1Types() { + return { + SSHPubKey : 'sshPubKey', + Password : 'password', + TLSClient : 'tlsClientAuth', + }; + } - // :TODO: set various defaults, e.g. default activation status, etc. - self.properties.account_status = Config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active; + authenticateFactor1(authInfo, cb) { + const username = authInfo.username; + const self = this; + const tempAuthInfo = {}; - async.series( - [ - function beginTransaction(callback) { - userDb.run('BEGIN;', err => { - return callback(err); - }); - }, - function createUserRec(callback) { - userDb.run( - `INSERT INTO user (user_name) - VALUES (?);`, - [ self.username ], - function inserted(err) { // use classic function for |this| - if(err) { - return callback(err); - } - - self.userId = this.lastID; + const validatePassword = (props, callback) => { + User.generatePasswordDerivedKey(authInfo.password, props[UserProps.PassPbkdf2Salt], (err, dk) => { + if(err) { + return callback(err); + } - // Do not require activation for userId 1 (root/admin) - if(User.RootUserID === self.userId) { - self.properties.account_status = User.AccountStatus.active; - } - - return callback(null); - } - ); - }, - function genAuthCredentials(callback) { - User.generatePasswordDerivedKeyAndSalt(password, (err, info) => { - if(err) { - return callback(err); - } - - self.properties.pw_pbkdf2_salt = info.salt; - self.properties.pw_pbkdf2_dk = info.dk; - return callback(null); - }); - }, - function setInitialGroupMembership(callback) { - self.groups = Config.users.defaultGroups; + // + // Use constant time comparison here for security feel-goods + // + const passDkBuf = Buffer.from(dk, 'hex'); + const propsDkBuf = Buffer.from(props[UserProps.PassPbkdf2Dk], 'hex'); - if(User.RootUserID === self.userId) { // root/SysOp? - self.groups.push('sysops'); - } + return callback(crypto.timingSafeEqual(passDkBuf, propsDkBuf) ? + null : + Errors.AccessDenied('Invalid password') + ); + }); + }; - return callback(null); - }, - function saveAll(callback) { - self.persist(false, err => { - return callback(err); - }); - } - ], - err => { - if(err) { - const originalError = err; - userDb.run('ROLLBACK;', err => { - assert(!err); - return cb(originalError); - }); - } else { - userDb.run('COMMIT;', err => { - return cb(err); - }); - } - } - ); - } + const validatePubKey = (props, callback) => { + const pubKeyActual = ssh2.utils.parseKey(props[UserProps.AuthPubKey]); + if(!pubKeyActual) { + return callback(Errors.AccessDenied('Invalid public key')); + } - persist(useTransaction, cb) { - assert(this.userId > 0); + if(authInfo.pubKey.key.algo != pubKeyActual.type || + !crypto.timingSafeEqual(authInfo.pubKey.key.data, pubKeyActual.getPublicSSH())) + { + return callback(Errors.AccessDenied('Invalid public key')); + } - const self = this; + return callback(null); + }; - async.series( - [ - function beginTransaction(callback) { - if(useTransaction) { - userDb.run('BEGIN;', err => { - return callback(err); - }); - } else { - return callback(null); - } - }, - function saveProps(callback) { - self.persistProperties(self.properties, err => { - return callback(err); - }); - }, - function saveGroups(callback) { - userGroup.addUserToGroups(self.userId, self.groups, err => { - return callback(err); - }); - } - ], - err => { - if(err) { - if(useTransaction) { - userDb.run('ROLLBACK;', err => { - return cb(err); - }); - } else { - return cb(err); - } - } else { - if(useTransaction) { - userDb.run('COMMIT;', err => { - return cb(err); - }); - } else { - return cb(null); - } - } - } - ); - } + async.waterfall( + [ + function fetchUserId(callback) { + // get user ID + User.getUserIdAndName(username, (err, uid, un) => { + tempAuthInfo.userId = uid; + tempAuthInfo.username = un; - persistProperty(propName, propValue, cb) { - // update live props - this.properties[propName] = propValue; + return callback(err); + }); + }, + function getRequiredAuthProperties(callback) { + // fetch properties required for authentication + User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.auth }, (err, props) => { + return callback(err, props); + }); + }, + 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) => { + if(!err) { + tempAuthInfo.properties = allProps; + } - userDb.run( - `REPLACE INTO user_property (user_id, prop_name, prop_value) - VALUES (?, ?, ?);`, - [ this.userId, propName, propValue ], - err => { - if(cb) { - return cb(err); - } - } - ); - } + return callback(err); + }); + }, + function checkAccountStatus(callback) { + const accountStatus = parseInt(tempAuthInfo.properties[UserProps.AccountStatus], 10); + if(User.AccountStatus.disabled === accountStatus) { + return callback(Errors.AccessDenied('Account disabled', ErrorReasons.Disabled)); + } + if(User.AccountStatus.inactive === accountStatus) { + return callback(Errors.AccessDenied('Account inactive', ErrorReasons.Inactive)); + } - removeProperty(propName, cb) { - // update live - delete this.properties[propName]; + if(User.AccountStatus.locked === accountStatus) { + const autoUnlockMinutes = _.get(Config(), 'users.failedLogin.autoUnlockMinutes'); + const lockedTs = moment(tempAuthInfo.properties[UserProps.AccountLockedTs]); + if(autoUnlockMinutes && lockedTs.isValid()) { + const minutesSinceLocked = moment().diff(lockedTs, 'minutes'); + if(minutesSinceLocked >= autoUnlockMinutes) { + // allow the login - we will clear any lock there + Log.info( + { username, userId : tempAuthInfo.userId, lockedAt : lockedTs.format() }, + 'Locked account will now be unlocked due to auto-unlock minutes policy' + ); + return callback(null); + } + } + return callback(Errors.AccessDenied('Account is locked', ErrorReasons.Locked)); + } - userDb.run( - `DELETE FROM user_property - WHERE user_id = ? AND prop_name = ?;`, - [ this.userId, propName ], - err => { - if(cb) { - return cb(err); - } - } - ); - } + // anything else besides active is still not allowed + if(User.AccountStatus.active !== accountStatus) { + return callback(Errors.AccessDenied('Account is not active')); + } - persistProperties(properties, cb) { - const self = this; + return callback(null); + }, + function initGroups(callback) { + userGroup.getGroupsForUser(tempAuthInfo.userId, (err, groups) => { + if(!err) { + tempAuthInfo.groups = groups; + } - // update live props - _.merge(this.properties, properties); + return callback(err); + }); + } + ], + err => { + if(err) { + // + // If we failed login due to something besides an inactive or disabled account, + // we need to update failure status and possibly lock the account. + // + // If locked already, update the lock timestamp -- ie, extend the lockout period. + // + if(![ErrorReasons.Disabled, ErrorReasons.Inactive].includes(err.reasonCode) && tempAuthInfo.userId) { + self.processFailedLogin(tempAuthInfo.userId, persistErr => { + if(persistErr) { + Log.warn( { error : persistErr.message }, 'Failed to persist failed login information'); + } + return cb(err); // pass along original error + }); + } else { + return cb(err); + } + } else { + // everything checks out - load up info + self.userId = tempAuthInfo.userId; + self.username = tempAuthInfo.username; + self.properties = tempAuthInfo.properties; + self.groups = tempAuthInfo.groups; + self.authFactor = User.AuthFactors.Factor1; - const stmt = userDb.prepare( - `REPLACE INTO user_property (user_id, prop_name, prop_value) - VALUES (?, ?, ?);` - ); + // + // If 2FA/OTP is required, this user is not quite authenticated yet. + // + self.authenticated = !(self.getProperty(UserProps.AuthFactor2OTP) ? true : false); - async.each(Object.keys(properties), (propName, nextProp) => { - stmt.run(self.userId, propName, properties[propName], err => { - return nextProp(err); - }); - }, - err => { - if(err) { - return cb(err); - } - - stmt.finalize( () => { - return cb(null); - }); - }); - } + self.removeProperty(UserProps.FailedLoginAttempts); - setNewAuthCredentials(password, cb) { - User.generatePasswordDerivedKeyAndSalt(password, (err, info) => { - if(err) { - return cb(err); - } - - const newProperties = { - pw_pbkdf2_salt : info.salt, - pw_pbkdf2_dk : info.dk, - }; + // + // We need to *revert* any locked status back to + // the user's previous status & clean up props. + // + self.unlockAccount(unlockErr => { + if(unlockErr) { + Log.warn( { error : unlockErr.message }, 'Failed to unlock account'); + } + return cb(null); + }); + } + } + ); + } - this.persistProperties(newProperties, err => { - return cb(err); - }); - }); - } + create(createUserInfo , cb) { + assert(0 === this.userId); + const config = Config(); - getAge() { - if(_.has(this.properties, 'birthdate')) { - return moment().diff(this.properties.birthdate, 'years'); - } - } + if(this.username.length < config.users.usernameMin || this.username.length > config.users.usernameMax) { + return cb(Errors.Invalid('Invalid username length')); + } - static getUser(userId, cb) { - async.waterfall( - [ - function fetchUserId(callback) { - User.getUserName(userId, (err, userName) => { - return callback(null, userName); - }); - }, - function initProps(userName, callback) { - User.loadProperties(userId, (err, properties) => { - return callback(err, userName, properties); - }); - }, - function initGroups(userName, properties, callback) { - userGroup.getGroupsForUser(userId, (err, groups) => { - return callback(null, userName, properties, groups); - }); - } - ], - (err, userName, properties, groups) => { - const user = new User(); - user.userId = userId; - user.username = userName; - user.properties = properties; - user.groups = groups; - user.authenticated = false; // this is NOT an authenticated user! + const self = this; - return cb(err, user); - } - ); - } - - static isRootUserId(userId) { - return (User.RootUserID === userId); - } + // :TODO: set various defaults, e.g. default activation status, etc. + self.properties[UserProps.AccountStatus] = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active; - static getUserIdAndName(username, cb) { - userDb.get( - `SELECT id, user_name - FROM user - WHERE user_name LIKE ?;`, - [ username ], - (err, row) => { - if(err) { - return cb(err); - } + async.waterfall( + [ + function beginTransaction(callback) { + return userDb.beginTransaction(callback); + }, + function createUserRec(trans, callback) { + trans.run( + `INSERT INTO user (user_name) + VALUES (?);`, + [ self.username ], + function inserted(err) { // use classic function for |this| + if(err) { + return callback(err); + } - if(row) { - return cb(null, row.id, row.user_name); - } - - return cb(Errors.DoesNotExist('No matching username')); - } - ); - } + self.userId = this.lastID; - static getUserName(userId, cb) { - userDb.get( - `SELECT user_name - FROM user - WHERE id = ?;`, - [ userId ], - (err, row) => { - if(err) { - return cb(err); - } - - if(row) { - return cb(null, row.user_name); - } - - return cb(Errors.DoesNotExist('No matching user ID')); - } - ); - } + // Do not require activation for userId 1 (root/admin) + if(User.RootUserID === self.userId) { + self.properties[UserProps.AccountStatus] = User.AccountStatus.active; + } - static loadProperties(userId, options, cb) { - if(!cb && _.isFunction(options)) { - cb = options; - options = {}; - } + return callback(null, trans); + } + ); + }, + function genAuthCredentials(trans, callback) { + User.generatePasswordDerivedKeyAndSalt(createUserInfo.password, (err, info) => { + if(err) { + return callback(err); + } - let sql = - `SELECT prop_name, prop_value - FROM user_property - WHERE user_id = ?`; + self.properties[UserProps.PassPbkdf2Salt] = info.salt; + self.properties[UserProps.PassPbkdf2Dk] = info.dk; + return callback(null, trans); + }); + }, + function setInitialGroupMembership(trans, callback) { + // Assign initial groups. Must perform a clone: #235 - All users are sysops (and I can't un-sysop them) + self.groups = [...config.users.defaultGroups]; - if(options.names) { - sql += ` AND prop_name IN("${options.names.join('","')}");`; - } else { - sql += ';'; - } + if(User.RootUserID === self.userId) { // root/SysOp? + self.groups.push('sysops'); + } - let properties = {}; - userDb.each(sql, [ userId ], (err, row) => { - if(err) { - return cb(err); - } - properties[row.prop_name] = row.prop_value; - }, (err) => { - return cb(err, err ? null : properties); - }); - } + return callback(null, trans); + }, + function saveAll(trans, callback) { + self.persistWithTransaction(trans, err => { + return callback(err, trans); + }); + }, + function sendEvent(trans, callback) { + Events.emit( + Events.getSystemEvents().NewUser, + { + user : Object.assign({}, self, { sessionId : createUserInfo.sessionId } ) + } + ); + return callback(null, trans); + } + ], + (err, trans) => { + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr); + }); + } else { + return cb(err); + } + } + ); + } - // :TODO: make this much more flexible - propValue should allow for case-insensitive compare, etc. - static getUserIdsWithProperty(propName, propValue, cb) { - let userIds = []; + persistWithTransaction(trans, cb) { + assert(this.userId > 0); - userDb.each( - `SELECT user_id - FROM user_property - WHERE prop_name = ? AND prop_value = ?;`, - [ propName, propValue ], - (err, row) => { - if(row) { - userIds.push(row.user_id); - } - }, - () => { - return cb(null, userIds); - } - ); - } + const self = this; - static getUserList(options, cb) { - let userList = []; - let orderClause = 'ORDER BY ' + (options.order || 'user_name'); + async.series( + [ + function saveProps(callback) { + self.persistProperties(self.properties, trans, err => { + return callback(err); + }); + }, + function saveGroups(callback) { + userGroup.addUserToGroups(self.userId, self.groups, trans, err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } - userDb.each( - `SELECT id, user_name - FROM user - ${orderClause};`, - (err, row) => { - if(row) { - userList.push({ - userId : row.id, - userName : row.user_name, - }); - } - }, - () => { - options.properties = options.properties || []; - async.map(userList, (user, nextUser) => { - userDb.each( - `SELECT prop_name, prop_value - FROM user_property - WHERE user_id = ? AND prop_name IN ("${options.properties.join('","')}");`, - [ user.userId ], - (err, row) => { - if(row) { - user[row.prop_name] = row.prop_value; - } - }, - err => { - return nextUser(err, user); - } - ); - }, - (err, transformed) => { - return cb(err, transformed); - }); - } - ); - } + static persistPropertyByUserId(userId, propName, propValue, cb) { + userDb.run( + `REPLACE INTO user_property (user_id, prop_name, prop_value) + VALUES (?, ?, ?);`, + [ userId, propName, propValue ], + err => { + if(cb) { + return cb(err, propValue); + } + } + ); + } - static generatePasswordDerivedKeyAndSalt(password, cb) { - async.waterfall( - [ - function getSalt(callback) { - User.generatePasswordDerivedKeySalt( (err, salt) => { - return callback(err, salt); - }); - }, - function getDk(salt, callback) { - User.generatePasswordDerivedKey(password, salt, (err, dk) => { - return callback(err, salt, dk); - }); - } - ], - (err, salt, dk) => { - return cb(err, { salt : salt, dk : dk } ); - } - ); - } + setProperty(propName, propValue) { + this.properties[propName] = propValue; + } - static generatePasswordDerivedKeySalt(cb) { - crypto.randomBytes(User.PBKDF2.saltLen, (err, salt) => { - if(err) { - return cb(err); - } - return cb(null, salt.toString('hex')); - }); - } + incrementProperty(propName, incrementBy) { + incrementBy = incrementBy || 1; + let newValue = parseInt(this.getProperty(propName)); + if(newValue) { + newValue += incrementBy; + } else { + newValue = incrementBy; + } + this.setProperty(propName, newValue); + return newValue; + } - static generatePasswordDerivedKey(password, salt, cb) { - password = new Buffer(password).toString('hex'); + getProperty(propName) { + return this.properties[propName]; + } - crypto.pbkdf2(password, salt, User.PBKDF2.iterations, User.PBKDF2.keyLen, 'sha1', (err, dk) => { - if(err) { - return cb(err); - } - - return cb(null, dk.toString('hex')); - }); - } + getPropertyAsNumber(propName) { + return parseInt(this.getProperty(propName), 10); + } + + persistProperty(propName, propValue, cb) { + // update live props + this.properties[propName] = propValue; + + return User.persistPropertyByUserId(this.userId, propName, propValue, cb); + } + + removeProperty(propName, cb) { + // update live + delete this.properties[propName]; + + userDb.run( + `DELETE FROM user_property + WHERE user_id = ? AND prop_name = ?;`, + [ this.userId, propName ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + removeProperties(propNames, cb) { + async.each(propNames, (name, next) => { + return this.removeProperty(name, next); + }, + err => { + if(cb) { + return cb(err); + } + }); + } + + persistProperties(properties, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = userDb; + } + + const self = this; + + // update live props + _.merge(this.properties, properties); + + const stmt = transOrDb.prepare( + `REPLACE INTO user_property (user_id, prop_name, prop_value) + VALUES (?, ?, ?);` + ); + + async.each(Object.keys(properties), (propName, nextProp) => { + stmt.run(self.userId, propName, properties[propName], err => { + return nextProp(err); + }); + }, + err => { + if(err) { + return cb(err); + } + + stmt.finalize( () => { + return cb(null); + }); + }); + } + + setNewAuthCredentials(password, cb) { + User.generatePasswordDerivedKeyAndSalt(password, (err, info) => { + if(err) { + return cb(err); + } + + const newProperties = { + [ UserProps.PassPbkdf2Salt ] : info.salt, + [ UserProps.PassPbkdf2Dk ] : info.dk, + }; + + this.persistProperties(newProperties, err => { + return cb(err); + }); + }); + } + + getAge() { + const birthdate = this.getProperty(UserProps.Birthdate); + if(birthdate) { + return moment().diff(birthdate, 'years'); + } + } + + static getUser(userId, cb) { + async.waterfall( + [ + function fetchUserId(callback) { + User.getUserName(userId, (err, userName) => { + return callback(null, userName); + }); + }, + function initProps(userName, callback) { + User.loadProperties(userId, (err, properties) => { + return callback(err, userName, properties); + }); + }, + function initGroups(userName, properties, callback) { + userGroup.getGroupsForUser(userId, (err, groups) => { + return callback(null, userName, properties, groups); + }); + } + ], + (err, userName, properties, groups) => { + const user = new User(); + user.userId = userId; + user.username = userName; + user.properties = properties; + user.groups = groups; + + // explicitly NOT an authenticated user! + user.authenticated = false; + user.authFactor = User.AuthFactors.None; + + return cb(err, user); + } + ); + } + + static isRootUserId(userId) { + return (User.RootUserID === userId); + } + + static getUserIdAndName(username, cb) { + userDb.get( + `SELECT id, user_name + FROM user + WHERE user_name LIKE ?;`, + [ username ], + (err, row) => { + if(err) { + return cb(err); + } + + if(row) { + return cb(null, row.id, row.user_name); + } + + return cb(Errors.DoesNotExist('No matching username')); + } + ); + } + + static getUserIdAndNameByRealName(realName, cb) { + userDb.get( + `SELECT id, user_name + FROM user + WHERE id = ( + SELECT user_id + FROM user_property + WHERE prop_name='${UserProps.RealName}' AND prop_value LIKE ? + );`, + [ realName ], + (err, row) => { + if(err) { + return cb(err); + } + + if(row) { + return cb(null, row.id, row.user_name); + } + + return cb(Errors.DoesNotExist('No matching real name')); + } + ); + } + + static getUserIdAndNameByLookup(lookup, cb) { + User.getUserIdAndName(lookup, (err, userId, userName) => { + if(err) { + User.getUserIdAndNameByRealName(lookup, (err, userId, userName) => { + return cb(err, userId, userName); + }); + } else { + return cb(null, userId, userName); + } + }); + } + + static getUserName(userId, cb) { + userDb.get( + `SELECT user_name + FROM user + WHERE id = ?;`, + [ userId ], + (err, row) => { + if(err) { + return cb(err); + } + + if(row) { + return cb(null, row.user_name); + } + + return cb(Errors.DoesNotExist('No matching user ID')); + } + ); + } + + static loadProperties(userId, options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + + let sql = + `SELECT prop_name, prop_value + FROM user_property + WHERE user_id = ?`; + + if(options.names) { + sql += ` AND prop_name IN("${options.names.join('","')}");`; + } else { + sql += ';'; + } + + let properties = {}; + userDb.each(sql, [ userId ], (err, row) => { + if(err) { + return cb(err); + } + properties[row.prop_name] = row.prop_value; + }, (err) => { + return cb(err, err ? null : properties); + }); + } + + // :TODO: make this much more flexible - propValue should allow for case-insensitive compare, etc. + static getUserIdsWithProperty(propName, propValue, cb) { + let userIds = []; + + userDb.each( + `SELECT user_id + FROM user_property + WHERE prop_name = ? AND prop_value = ?;`, + [ propName, propValue ], + (err, row) => { + if(row) { + userIds.push(row.user_id); + } + }, + () => { + return cb(null, userIds); + } + ); + } + + static getUserList(options, cb) { + const userList = []; + const orderClause = 'ORDER BY ' + (options.order || 'user_name'); + + userDb.each( + `SELECT id, user_name + FROM user + ${orderClause};`, + (err, row) => { + if(row) { + userList.push({ + userId : row.id, + userName : row.user_name, + }); + } + }, + () => { + options.properties = options.properties || []; + async.map(userList, (user, nextUser) => { + userDb.each( + `SELECT prop_name, prop_value + FROM user_property + WHERE user_id = ? AND prop_name IN ("${options.properties.join('","')}");`, + [ user.userId ], + (err, row) => { + if(row) { + if(options.propsCamelCase) { + user[_.camelCase(row.prop_name)] = row.prop_value; + } else { + user[row.prop_name] = row.prop_value; + } + } + }, + err => { + return nextUser(err, user); + } + ); + }, + (err, transformed) => { + return cb(err, transformed); + }); + } + ); + } + + static generatePasswordDerivedKeyAndSalt(password, cb) { + async.waterfall( + [ + function getSalt(callback) { + User.generatePasswordDerivedKeySalt( (err, salt) => { + return callback(err, salt); + }); + }, + function getDk(salt, callback) { + User.generatePasswordDerivedKey(password, salt, (err, dk) => { + return callback(err, salt, dk); + }); + } + ], + (err, salt, dk) => { + return cb(err, { salt : salt, dk : dk } ); + } + ); + } + + static generatePasswordDerivedKeySalt(cb) { + crypto.randomBytes(User.PBKDF2.saltLen, (err, salt) => { + if(err) { + return cb(err); + } + return cb(null, salt.toString('hex')); + }); + } + + static generatePasswordDerivedKey(password, salt, cb) { + password = Buffer.from(password).toString('hex'); + + crypto.pbkdf2(password, salt, User.PBKDF2.iterations, User.PBKDF2.keyLen, 'sha1', (err, dk) => { + if(err) { + return cb(err); + } + + return cb(null, dk.toString('hex')); + }); + } }; diff --git a/core/user_2fa_otp.js b/core/user_2fa_otp.js new file mode 100644 index 00000000..c51d1282 --- /dev/null +++ b/core/user_2fa_otp.js @@ -0,0 +1,187 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const UserProps = require('./user_property.js'); +const { + Errors, + ErrorReasons, +} = require('./enig_error.js'); +const User = require('./user.js'); +const { + recordLogin, + transformLoginError, +} = require('./user_login.js'); +const Config = require('./config.js').get; + +// deps +const _ = require('lodash'); +const crypto = require('crypto'); +const qrGen = require('qrcode-generator'); + +exports.prepareOTP = prepareOTP; +exports.createBackupCodes = createBackupCodes; +exports.createQRCode = createQRCode; +exports.otpFromType = otpFromType; +exports.loginFactor2_OTP = loginFactor2_OTP; + +const OTPTypes = exports.OTPTypes = { + RFC6238_TOTP : 'rfc6238_TOTP', // Time-Based, SHA-512 + RFC4266_HOTP : 'rfc4266_HOTP', // HMAC-Based, SHA-512 + GoogleAuthenticator : 'googleAuth', // Google Authenticator is basically TOTP + quirks +}; + +function otpFromType(otpType) { + try { + return { + [ OTPTypes.RFC6238_TOTP ] : () => { + const totp = require('otplib/totp'); + totp.options = { crypto, algorithm : 'sha256' }; + return totp; + }, + [ OTPTypes.RFC4266_HOTP ] : () => { + const hotp = require('otplib/hotp'); + hotp.options = { crypto, algorithm : 'sha256' }; + return hotp; + }, + [ OTPTypes.GoogleAuthenticator ] : () => { + const googleAuth = require('otplib/authenticator'); + googleAuth.options = { crypto }; + return googleAuth; + }, + }[otpType](); + } catch(e) { + // nothing + } +} + +function generateOTPBackupCode() { + const consonants = 'bdfghjklmnprstvz'.split(''); + const vowels = 'aiou'.split(''); + + const bits = []; + const rng = crypto.randomBytes(4); + + for(let i = 0; i < rng.length / 2; ++i) { + const n = rng.readUInt16BE(i * 2); + + const c1 = n & 0x0f; + const v1 = (n >> 4) & 0x03; + const c2 = (n >> 6) & 0x0f; + const v2 = (n >> 10) & 0x03; + const c3 = (n >> 12) & 0x0f; + + bits.push([ + consonants[c1], + vowels[v1], + consonants[c2], + vowels[v2], + consonants[c3], + ].join('')); + } + + return bits.join('-'); +} + +function createBackupCodes() { + const codes = [...Array(6)].map(() => generateOTPBackupCode()); + return codes; +} + +function validateAndConsumeBackupCode(user, token, cb) { + try + { + let validCodes = JSON.parse(user.getProperty(UserProps.AuthFactor2OTPBackupCodes)); + const matchingCode = validCodes.find(c => c === token); + if(!matchingCode) { + return cb(Errors.BadLogin('Invalid OTP value supplied', ErrorReasons.Invalid2FA)); + } + + // We're consuming a match - remove it from available backup codes + validCodes = validCodes.filter(c => c !== matchingCode); + validCodes = JSON.stringify(validCodes); + user.persistProperty(UserProps.AuthFactor2OTPBackupCodes, validCodes, err => { + return cb(err); + }); + } catch(e) { + return cb(e); + } +} + +function createQRCode(otp, options, secret) { + try { + const uri = otp.keyuri(options.username || 'user', Config().general.boardName, secret); + const qrCode = qrGen(0, 'L'); + qrCode.addData(uri); + qrCode.make(); + + options.qrType = options.qrType || 'ascii'; + return { + ascii : qrCode.createASCII, + data : qrCode.createDataURL, + img : qrCode.createImgTag, + svg : qrCode.createSvgTag, + }[options.qrType](options.cellSize); + } catch(e) { + return ''; + } +} + +function prepareOTP(otpType, options, cb) { + if(!_.isFunction(cb)) { + cb = options; + options = {}; + } + + const otp = otpFromType(otpType); + if(!otp) { + return cb(Errors.Invalid(`Unknown OTP type: ${otpType}`)); + } + + const secret = OTPTypes.GoogleAuthenticator === otpType ? + otp.generateSecret() : + crypto.randomBytes(64).toString('base64').substr(0, 32); + + const qr = createQRCode(otp, options, secret); + + return cb(null, { secret, qr } ); +} + +function loginFactor2_OTP(client, token, cb) { + if(client.user.authFactor < User.AuthFactors.Factor1) { + return cb(Errors.AccessDenied('OTP requires prior authentication factor 1')); + } + + const otpType = client.user.getProperty(UserProps.AuthFactor2OTP); + const otp = otpFromType(otpType); + + if(!otp) { + return cb(Errors.Invalid(`Unknown OTP type: ${otpType}`)); + } + + const secret = client.user.getProperty(UserProps.AuthFactor2OTPSecret); + if(!secret) { + return cb(Errors.Invalid('Missing OTP secret')); + } + + const valid = otp.verify( { token, secret } ); + + const allowLogin = () => { + client.user.authFactor = User.AuthFactors.Factor2; + client.user.authenticated = true; + return recordLogin(client, cb); + }; + + if(valid) { + return allowLogin(); + } + + // maybe they punched in a backup code? + validateAndConsumeBackupCode(client.user, token, err => { + if(err) { + return cb(transformLoginError(err, client, client.user.username)); + } + + return allowLogin(); + }); +} diff --git a/core/user_2fa_otp_config.js b/core/user_2fa_otp_config.js new file mode 100644 index 00000000..1a7bf0c6 --- /dev/null +++ b/core/user_2fa_otp_config.js @@ -0,0 +1,338 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const UserProps = require('./user_property.js'); +const { + OTPTypes, + otpFromType, + createQRCode, + createBackupCodes, +} = require('./user_2fa_otp.js'); +const { Errors } = require('./enig_error.js'); +const { getServer } = require('./listening_server.js'); +const WebServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; +const WebRegister = require('./user_2fa_otp_web_register.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); +const iconv = require('iconv-lite'); + +exports.moduleInfo = { + name : 'User 2FA/OTP Configuration', + desc : 'Module for user 2FA/OTP configuration', + author : 'NuSkooler', +}; + +const FormIds = { + menu : 0, +}; + +const MciViewIds = { + enableToggle : 1, + otpType : 2, + submit : 3, + infoText : 4, + + customRangeStart : 10, // 10+ = customs +}; + +const DefaultMsg = { + infoText: { + disabled : 'Enabling 2-factor authentication can greatly increase account security.', + enabled : 'A valid email address set in user config is required to enable 2-Factor Authentication.', + rfc6238_TOTP : 'Time-Based One-Time-Password (TOTP, RFC-6238).', + rfc4266_HOTP : 'HMAC-Based One-Time-Password (HOTP, RFC-4266).', + googleAuth : 'Google Authenticator.', + }, + statusText : { + otpNotEnabled : '2FA/OTP is not currently enabled for this account.', + noBackupCodes : 'No backup codes remaining or set.', + saveDisabled : '2FA/OTP is now disabled for this account.', + saveEmailSent : 'An 2FA/OTP registration email has been sent with further instructions.', + saveError : 'Failed to send email. Please contact the system operator.', + qrNotAvail : 'QR code not available for this OTP type.', + emailRequired : 'Your account must have a valid email address set to use this feature.', + } +}; + +exports.getModule = class User2FA_OTPConfigModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.menuMethods = { + showQRCode : (formData, extraArgs, cb) => { + return this.showQRCode(cb); + }, + showSecret : (formData, extraArgs, cb) => { + return this.showSecret(cb); + }, + showBackupCodes : (formData, extraArgs, cb) => { + return this.showBackupCodes(cb); + }, + generateNewBackupCodes : (formData, extraArgs, cb) => { + return this.generateNewBackupCodes(cb); + }, + saveChanges : (formData, extraArgs, cb) => { + return this.saveChanges(formData, cb); + } + }; + } + + initSequence() { + const webServer = getServer(WebServerPackageName); + if(!webServer || !webServer.instance.isEnabled()) { + this.client.log.warn('User 2FA/OTP configuration requires the web server to be enabled!'); + return this.prevMenu( () => { /* dummy */ } ); + } + return super.initSequence(); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + return this.prepViewController('menu', FormIds.menu, mciData.menu, callback); + }, + (callback) => { + const requiredCodes = [ + MciViewIds.enableToggle, + MciViewIds.otpType, + MciViewIds.submit, + ]; + return this.validateMCIByViewIds('menu', requiredCodes, callback); + }, + (callback) => { + const enableToggleView = this.getView('menu', MciViewIds.enableToggle); + let initialIndex = this.isOTPEnabledForUser() ? 1 : 0; + enableToggleView.setFocusItemIndex(initialIndex); + this.enableToggleUpdate(initialIndex); + + enableToggleView.on('index update', idx => { + return this.enableToggleUpdate(idx); + }); + + const otpTypeView = this.getView('menu', MciViewIds.otpType); + initialIndex = this.otpTypeIndexFromUserOTPType(); + otpTypeView.setFocusItemIndex(initialIndex); + + otpTypeView.on('index update', idx => { + return this.otpTypeUpdate(idx); + }); + + this.viewControllers.menu.on('return', view => { + if(view === enableToggleView) { + return this.enableToggleUpdate(enableToggleView.focusedItemIndex); + } else if (view === otpTypeView) { + return this.otpTypeUpdate(otpTypeView.focusedItemIndex); + } + }); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + displayDetails(details, cb) { + const modOpts = { + extraArgs : { + artData : iconv.encode(`${details}\r\n`, 'cp437'), + } + }; + this.gotoMenu( + this.menuConfig.config.userTwoFactorAuthOTPConfigShowDetails || 'userTwoFactorAuthOTPConfigShowDetails', + modOpts, + cb + ); + } + + showQRCode(cb) { + const otp = otpFromType(this.client.user.getProperty(UserProps.AuthFactor2OTP)); + + let qrCode; + if(!otp) { + qrCode = this.getStatusText('otpNotEnabled'); + } else { + const qrOptions = { + username : this.client.user.username, + qrType : 'ascii', + }; + + qrCode = createQRCode( + otp, + qrOptions, + this.client.user.getProperty(UserProps.AuthFactor2OTPSecret) + ); + + if(qrCode) { + qrCode = qrCode.replace(/\n/g, '\r\n'); + } else { + qrCode = this.getStatusText('qrNotAvail'); + } + } + + return this.displayDetails(qrCode, cb); + } + + showSecret(cb) { + const info = + this.client.user.getProperty(UserProps.AuthFactor2OTPSecret) || + this.getStatusText('otpNotEnabled'); + return this.displayDetails(info, cb); + } + + showBackupCodes(cb) { + let info; + const noBackupCodes = this.getStatusText('noBackupCodes'); + if(!this.isOTPEnabledForUser()) { + info = this.getStatusText('otpNotEnabled'); + } else { + try { + info = JSON.parse(this.client.user.getProperty(UserProps.AuthFactor2OTPBackupCodes) || '[]').join(', '); + info = info || noBackupCodes; + } catch(e) { + info = noBackupCodes; + } + } + return this.displayDetails(info, cb); + } + + generateNewBackupCodes(cb) { + if(!this.isOTPEnabledForUser()) { + const info = this.getStatusText('otpNotEnabled'); + return this.displayDetails(info, cb); + } + + const backupCodes = createBackupCodes(); + this.client.user.persistProperty( + UserProps.AuthFactor2OTPBackupCodes, + JSON.stringify(backupCodes), + err => { + if(err) { + return cb(err); + } + const info = backupCodes.join(', '); + return this.displayDetails(info, cb); + } + ); + } + + saveChanges(formData, cb) { + const enabled = 1 === _.get(formData, 'value.enableToggle', 0); + return enabled ? this.saveChangesEnable(formData, cb) : this.saveChangesDisable(cb); + } + + saveChangesEnable(formData, cb) { + // User must have an email address set to save + const emailRegExp = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(.[a-z0-9-]+)*/; + const emailAddr = this.client.user.getProperty(UserProps.EmailAddress); + if(!emailAddr || !emailRegExp.test(emailAddr)) { + const info = this.getStatusText('emailRequired'); + return this.displayDetails(info, cb); + } + + const otpTypeProp = this.otpTypeFromOTPTypeIndex(_.get(formData, 'value.otpType')); + + const saveFailedError = (err) => { + const info = this.getStatusText('saveError'); + this.displayDetails(info, () => { + return cb(err); + }); + }; + + // sanity check + if(!otpFromType(otpTypeProp)) { + return saveFailedError(Errors.Invalid('Cannot convert selected index to valid OTP type')); + } + + this.removeUserOTPProperties(err => { + if(err) { + return saveFailedError(err); + } + WebRegister.sendRegisterEmail(this.client.user, otpTypeProp, err => { + if(err) { + return saveFailedError(err); + } + + const info = this.getStatusText('saveEmailSent'); + return this.displayDetails(info, cb); + }); + }); + } + + removeUserOTPProperties(cb) { + const props = [ + UserProps.AuthFactor2OTP, + UserProps.AuthFactor2OTPSecret, + UserProps.AuthFactor2OTPBackupCodes, + ]; + return this.client.user.removeProperties(props, cb); + } + + saveChangesDisable(cb) { + this.removeUserOTPProperties(err => { + if(err) { + return cb(err); + } + + const info = this.getStatusText('saveDisabled'); + return this.displayDetails(info, cb); + }); + } + + isOTPEnabledForUser() { + return this.client.user.getProperty(UserProps.AuthFactor2OTP) ? true : false; + } + + getInfoText(key) { + return _.get(this.config, [ 'infoText', key ], DefaultMsg.infoText[key]); + } + + getStatusText(key) { + return _.get(this.config, [ 'statusText', key ], DefaultMsg.statusText[key]); + } + + enableToggleUpdate(idx) { + const key = { + 0 : 'disabled', + 1 : 'enabled', + }[idx]; + this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } ); + } + + otpTypeIndexFromUserOTPType(defaultIndex = 0) { + const type = this.client.user.getProperty(UserProps.AuthFactor2OTP); + return { + [ OTPTypes.RFC6238_TOTP ] : 0, + [ OTPTypes.RFC4266_HOTP ] : 1, + [ OTPTypes.GoogleAuthenticator ] : 2, + }[type] || defaultIndex; + } + + otpTypeFromOTPTypeIndex(idx) { + return { + 0 : OTPTypes.RFC6238_TOTP, + 1 : OTPTypes.RFC4266_HOTP, + 2 : OTPTypes.GoogleAuthenticator, + }[idx]; + } + + otpTypeUpdate(idx) { + const key = this.otpTypeFromOTPTypeIndex(idx); + this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } ); + } +}; + diff --git a/core/user_2fa_otp_web_register.js b/core/user_2fa_otp_web_register.js new file mode 100644 index 00000000..2b7d294c --- /dev/null +++ b/core/user_2fa_otp_web_register.js @@ -0,0 +1,292 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Config = require('./config.js').get; +const getServer = require('./listening_server.js').getServer; +const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; +const { + createToken, + deleteToken, + getTokenInfo, + WellKnownTokenTypes, +} = require('./user_temp_token.js'); +const { + prepareOTP, + createBackupCodes, + otpFromType, +} = require('./user_2fa_otp.js'); +const { sendMail } = require('./email.js'); +const UserProps = require('./user_property.js'); +const Log = require('./logger.js').log; +const { + getConnectionByUserId +} = require('./client_connections.js'); + +// deps +const async = require('async'); +const fs = require('fs-extra'); +const _ = require('lodash'); +const url = require('url'); +const querystring = require('querystring'); + +function getWebServer() { + return getServer(webServerPackageName); +} + +const DefaultEmailTextTemplate = + `%USERNAME%: +You have requested to enable 2-Factor Authentication via One-Time-Password +for your account on %BOARDNAME%. + + * If this was not you, please ignore this email and change your password. + * Otherwise, please follow the link below: + + %REGISTER_URL% +`; + +module.exports = class User2FA_OTPWebRegister +{ + static startup(cb) { + return User2FA_OTPWebRegister.registerRoutes(cb); + } + + static sendRegisterEmail(user, otpType, cb) { + async.waterfall( + [ + (callback) => { + return createToken( + user.userId, + WellKnownTokenTypes.AuthFactor2OTPRegister, + { bits : 128 }, + callback + ); + }, + (token, callback) => { + const config = Config(); + const txtTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailText'); + const htmlTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailHtml'); + + fs.readFile(txtTemplateFile, 'utf8', (err, textTemplate) => { + textTemplate = textTemplate || DefaultEmailTextTemplate; + fs.readFile(htmlTemplateFile, 'utf8', (err, htmlTemplate) => { + htmlTemplate = htmlTemplate || null; // be explicit for waterfall + return callback(null, token, textTemplate, htmlTemplate); + }); + }); + }, + (token, textTemplate, htmlTemplate, callback) => { + const webServer = getWebServer(); + const registerUrl = webServer.instance.buildUrl( + `/enable_2fa_otp?token=${token}&otpType=${otpType}` + ); + + const replaceTokens = (s) => { + return s + .replace(/%BOARDNAME%/g, Config().general.boardName) + .replace(/%USERNAME%/g, user.username) + .replace(/%TOKEN%/g, token) + .replace(/%REGISTER_URL%/g, registerUrl) + ; + }; + + textTemplate = replaceTokens(textTemplate); + if(htmlTemplate) { + htmlTemplate = replaceTokens(htmlTemplate); + } + + const message = { + to : `${user.getProperty(UserProps.RealName) || user.username} <${user.getProperty(UserProps.EmailAddress)}>`, + // from will be filled in + subject : '2-Factor Authentication Registration', + text : textTemplate, + html : htmlTemplate, + }; + + sendMail(message, (err, info) => { + if(err) { + Log.warn({ error : err.message }, 'Failed sending 2FA/OTP register email'); + } else { + Log.info({ info }, 'Successfully sent 2FA/OTP register email'); + } + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + + static fileNotFound(webServer, resp) { + return webServer.instance.fileNotFound(resp); + } + + static accessDenied(webServer, resp) { + return webServer.instance.accessDenied(resp); + } + + static routeRegisterGet(req, resp) { + const webServer = getWebServer(); // must be valid, we just got a req! + + const urlParts = url.parse(req.url, true); + const token = urlParts.query && urlParts.query.token; + const otpType = urlParts.query && urlParts.query.otpType; + + if(!token || !otpType) { + return User2FA_OTPWebRegister.accessDenied(webServer, resp); + } + + getTokenInfo(token, (err, tokenInfo) => { + if(err) { + // assume expired + return webServer.instance.respondWithError( + resp, + 410, + 'Invalid or expired registration link.', 'Expired Link' + ); + } + + if(tokenInfo.tokenType !== WellKnownTokenTypes.AuthFactor2OTPRegister) { + return User2FA_OTPWebRegister.accessDenied(webServer, resp); + } + + const prepareOptions = { + qrType : 'data', + cellSize : 8, + username : tokenInfo.user.username, + }; + + prepareOTP(otpType, prepareOptions, (err, otpInfo) => { + if(err) { + Log.error({ error : err.message }, 'Failed to prepare OTP'); + return User2FA_OTPWebRegister.accessDenied(webServer, resp); + } + + const postUrl = webServer.instance.buildUrl('/enable_2fa_otp'); + const config = Config(); + return webServer.instance.routeTemplateFilePage( + _.get(config, 'users.twoFactorAuth.otp.registerPageTemplate'), + (templateData, next) => { + const finalPage = templateData + .replace(/%BOARDNAME%/g, config.general.boardName) + .replace(/%USERNAME%/g, tokenInfo.user.username) + .replace(/%TOKEN%/g, token) + .replace(/%OTP_TYPE%/g, otpType) + .replace(/%POST_URL%/g, postUrl) + .replace(/%QR_IMG_DATA%/g, otpInfo.qr || '') + .replace(/%SECRET%/g, otpInfo.secret) + ; + return next(null, finalPage); + }, + resp + ); + }); + }); + } + + static routeRegisterPost(req, resp) { + const webServer = getWebServer(); // must be valid, we just got a req! + + const badRequest = () => { + return webServer.instance.respondWithError(resp, 400, 'Bad Request.', 'Bad Request'); + }; + + let bodyData = ''; + req.on('data', data => { + bodyData += data; + }); + + req.on('end', () => { + const formData = querystring.parse(bodyData); + + if(!formData.token || !formData.otpType || !formData.otp || + !formData.secret) + { + return badRequest(); + } + + const otp = otpFromType(formData.otpType); + if(!otp) { + return badRequest(); + } + + const valid = otp.verify( { token : formData.otp, secret : formData.secret } ); + if(!valid) { + return User2FA_OTPWebRegister.accessDenied(webServer, resp); + } + + getTokenInfo(formData.token, (err, tokenInfo) => { + if(err) { + return User2FA_OTPWebRegister.accessDenied(webServer, resp); + } + + const backupCodes = createBackupCodes(); + + const props = { + [ UserProps.AuthFactor2OTP ] : formData.otpType, + [ UserProps.AuthFactor2OTPSecret ] : formData.secret, + [ UserProps.AuthFactor2OTPBackupCodes ] : JSON.stringify(backupCodes), + }; + + tokenInfo.user.persistProperties(props, err => { + if(err) { + return webServer.instance.respondWithError(resp, 500, 'Internal Server Error', 'Internal Server Error'); + } + + // + // User may be online still - find account & update it if so + // + const clientConn = getConnectionByUserId(tokenInfo.user.userId); + if(clientConn && clientConn.user) { + // just update live props, we've already persisted them. + _.each(props, (v, n) => { + clientConn.user.setProperty(n, v); + }); + } + + // we can now remove the token - no need to wait + deleteToken(formData.token, err => { + if(err) { + Log.error({error : err.message, token : formData.token}, 'Failed to delete temporary token'); + } + }); + + // :TODO: use a html template here too, if provided + resp.writeHead(200); + return resp.end( + `2-Factor Authentication via One-Time-Password has been enabled for this account. Please write down your backup codes and store them in safe place: + +${backupCodes} + ` + ); + }); + }); + }); + } + + static registerRoutes(cb) { + const webServer = getWebServer(); + if(!webServer || !webServer.instance.isEnabled()) { + return cb(null); // no webserver enabled + } + + [ + { + method : 'GET', + path : '^\\/enable_2fa_otp\\?token\\=[a-f0-9]+&otpType\\=[a-zA-Z0-9_]+$', + handler : User2FA_OTPWebRegister.routeRegisterGet, + }, + { + method : 'POST', + path : '^\\/enable_2fa_otp$', + handler : User2FA_OTPWebRegister.routeRegisterPost, + } + ].forEach(r => { + webServer.instance.addRoute(r); + }); + + return cb(null); + } +}; diff --git a/core/user_achievements_earned.js b/core/user_achievements_earned.js new file mode 100644 index 00000000..1004a9d0 --- /dev/null +++ b/core/user_achievements_earned.js @@ -0,0 +1,102 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { + getAchievementsEarnedByUser +} = require('./achievement.js'); +const UserProps = require('./user_property.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'User Achievements Earned', + desc : 'Lists achievements earned by a user', + author : 'NuSkooler', +}; + +const MciViewIds = { + achievementList : 1, + customRangeStart : 10, // updated @ index update +}; + +exports.getModule = class UserAchievementsEarned extends MenuModule { + constructor(options) { + super(options); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.waterfall( + [ + (callback) => { + this.prepViewController('achievements', 0, mciData.menu, err => { + return callback(err); + }); + }, + (callback) => { + return this.validateMCIByViewIds('achievements', MciViewIds.achievementList, callback); + }, + (callback) => { + return getAchievementsEarnedByUser(this.client.user.userId, callback); + }, + (achievementsEarned, callback) => { + this.achievementsEarned = achievementsEarned; + + const achievementListView = this.viewControllers.achievements.getView(MciViewIds.achievementList); + + achievementListView.on('index update', idx => { + this.selectionIndexUpdate(idx); + }); + + const dateTimeFormat = _.get( + this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateFormat('short')); + + achievementListView.setItems(achievementsEarned.map(achiev => Object.assign( + achiev, + this.getUserInfo(), + { + ts : achiev.timestamp.format(dateTimeFormat), + } + ))); + achievementListView.redraw(); + this.selectionIndexUpdate(0); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + getUserInfo() { + // :TODO: allow args to pass in a different user - ie from user list -> press A for achievs, so on... + return { + userId : this.client.user.userId, + userName : this.client.user.username, + realName : this.client.user.getProperty(UserProps.RealName), + location : this.client.user.getProperty(UserProps.Location), + affils : this.client.user.getProperty(UserProps.Affiliations), + totalCount : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalCount), + totalPoints : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalPoints), + }; + } + + selectionIndexUpdate(index) { + const achiev = this.achievementsEarned[index]; + if(!achiev) { + return; + } + this.updateCustomViewTextsWithFilter('achievements', MciViewIds.customRangeStart, achiev); + } +}; diff --git a/core/user_config.js b/core/user_config.js index 432cdade..ca3b20ff 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -1,221 +1,228 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('./menu_module.js').MenuModule; -const ViewController = require('./view_controller.js').ViewController; -const theme = require('./theme.js'); -const sysValidate = require('./system_view_validate.js'); +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const theme = require('./theme.js'); +const sysValidate = require('./system_view_validate.js'); +const UserProps = require('./user_property.js'); +const { + getISOTimestampString +} = require('./database.js'); -const async = require('async'); -const assert = require('assert'); -const _ = require('lodash'); -const moment = require('moment'); +// deps +const async = require('async'); +const assert = require('assert'); +const _ = require('lodash'); +const moment = require('moment'); exports.moduleInfo = { - name : 'User Configuration', - desc : 'Module for user configuration', - author : 'NuSkooler', + name : 'User Configuration', + desc : 'Module for user configuration', + author : 'NuSkooler', }; const MciCodeIds = { - RealName : 1, - BirthDate : 2, - Sex : 3, - Loc : 4, - Affils : 5, - Email : 6, - Web : 7, - TermHeight : 8, - Theme : 9, - Password : 10, - PassConfirm : 11, - ThemeInfo : 20, - ErrorMsg : 21, - - SaveCancel : 25, + RealName : 1, + BirthDate : 2, + Sex : 3, + Loc : 4, + Affils : 5, + Email : 6, + Web : 7, + TermHeight : 8, + Theme : 9, + Password : 10, + PassConfirm : 11, + ThemeInfo : 20, + ErrorMsg : 21, + + SaveCancel : 25, }; exports.getModule = class UserConfigModule extends MenuModule { - constructor(options) { - super(options); + constructor(options) { + super(options); - const self = this; + const self = this; - this.menuMethods = { - // - // Validation support - // - validateEmailAvail : function(data, cb) { - // - // If nothing changed, we know it's OK - // - if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) { - return cb(null); - } - - // Otherwise we can use the standard system method - return sysValidate.validateEmailAvail(data, cb); - }, - - validatePassword : function(data, cb) { - // - // Blank is OK - this means we won't be changing it - // - if(!data || 0 === data.length) { - return cb(null); - } - - // Otherwise we can use the standard system method - return sysValidate.validatePasswordSpec(data, cb); - }, - - validatePassConfirmMatch : function(data, cb) { - var passwordView = self.getView(MciCodeIds.Password); - cb(passwordView.getData() === data ? null : new Error('Passwords do not match')); - }, - - viewValidationListener : function(err, cb) { - var errMsgView = self.getView(MciCodeIds.ErrorMsg); - var newFocusId; - if(errMsgView) { - if(err) { - errMsgView.setText(err.message); - - if(err.view.getId() === MciCodeIds.PassConfirm) { - newFocusId = MciCodeIds.Password; - var passwordView = self.getView(MciCodeIds.Password); - passwordView.clearText(); - err.view.clearText(); - } - } else { - errMsgView.clearText(); - } - } - cb(newFocusId); - }, - - // - // Handlers - // - saveChanges : function(formData, extraArgs, cb) { - assert(formData.value.password === formData.value.passwordConfirm); - - const newProperties = { - real_name : formData.value.realName, - birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), - sex : formData.value.sex, - location : formData.value.location, - affiliation : formData.value.affils, - email_address : formData.value.email, - web_address : formData.value.web, - term_height : formData.value.termHeight.toString(), - theme_id : self.availThemeInfo[formData.value.theme].themeId, - }; - - // runtime set theme - theme.setClientTheme(self.client, newProperties.theme_id); - - // persist all changes - self.client.user.persistProperties(newProperties, err => { - if(err) { - self.client.log.warn( { error : err.toString() }, 'Failed persisting updated properties'); - // :TODO: warn end user! - return self.prevMenu(cb); - } - // - // New password if it's not empty - // - self.client.log.info('User updated properties'); - - if(formData.value.password.length > 0) { - self.client.user.setNewAuthCredentials(formData.value.password, err => { - if(err) { - self.client.log.error( { err : err }, 'Failed storing new authentication credentials'); - } else { - self.client.log.info('User changed authentication credentials'); - } - return self.prevMenu(cb); - }); - } else { - return self.prevMenu(cb); - } - }); - }, - }; - } + this.menuMethods = { + // + // Validation support + // + validateEmailAvail : function(data, cb) { + // + // If nothing changed, we know it's OK + // + if(self.client.user.properties[UserProps.EmailAddress].toLowerCase() === data.toLowerCase()) { + return cb(null); + } - getView(viewId) { - return this.viewControllers.menu.getView(viewId); - } + // Otherwise we can use the standard system method + return sysValidate.validateEmailAvail(data, cb); + }, - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } + validatePassword : function(data, cb) { + // + // Blank is OK - this means we won't be changing it + // + if(!data || 0 === data.length) { + return cb(null); + } - const self = this; - const vc = self.viewControllers.menu = new ViewController( { client : self.client} ); - let currentThemeIdIndex = 0; + // Otherwise we can use the standard system method + return sysValidate.validatePasswordSpec(data, cb); + }, - async.series( - [ - function loadFromConfig(callback) { - vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); - }, - function prepareAvailableThemes(callback) { - self.availThemeInfo = _.sortBy(_.map(theme.getAvailableThemes(), function makeThemeInfo(t, themeId) { - return { - themeId : themeId, - name : t.info.name, - author : t.info.author, - desc : _.isString(t.info.desc) ? t.info.desc : '', - group : _.isString(t.info.group) ? t.info.group : '', - }; - }), 'name'); - - currentThemeIdIndex = _.findIndex(self.availThemeInfo, function cmp(ti) { - return ti.themeId === self.client.user.properties.theme_id; - }); - - callback(null); - }, - function populateViews(callback) { - var user = self.client.user; + validatePassConfirmMatch : function(data, cb) { + var passwordView = self.getMenuView(MciCodeIds.Password); + cb(passwordView.getData() === data ? null : new Error('Passwords do not match')); + }, - self.setViewText('menu', MciCodeIds.RealName, user.properties.real_name); - self.setViewText('menu', MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD')); - self.setViewText('menu', MciCodeIds.Sex, user.properties.sex); - self.setViewText('menu', MciCodeIds.Loc, user.properties.location); - self.setViewText('menu', MciCodeIds.Affils, user.properties.affiliation); - self.setViewText('menu', MciCodeIds.Email, user.properties.email_address); - self.setViewText('menu', MciCodeIds.Web, user.properties.web_address); - self.setViewText('menu', MciCodeIds.TermHeight, user.properties.term_height.toString()); - - - var themeView = self.getView(MciCodeIds.Theme); - if(themeView) { - themeView.setItems(_.map(self.availThemeInfo, 'name')); - themeView.setFocusItemIndex(currentThemeIdIndex); - } - - var realNameView = self.getView(MciCodeIds.RealName); - if(realNameView) { - realNameView.setFocus(true); // :TODO: HACK! menu.hjson sets focus, but manual population above breaks this. Needs a real fix! - } - - callback(null); - } - ], - function complete(err) { - if(err) { - self.client.log.warn( { error : err.toString() }, 'User configuration failed to init'); - self.prevMenu(); - } else { - cb(null); - } - } - ); - }); - } + viewValidationListener : function(err, cb) { + var errMsgView = self.getMenuView(MciCodeIds.ErrorMsg); + var newFocusId; + if(errMsgView) { + if(err) { + errMsgView.setText(err.message); + + if(err.view.getId() === MciCodeIds.PassConfirm) { + newFocusId = MciCodeIds.Password; + var passwordView = self.getMenuView(MciCodeIds.Password); + passwordView.clearText(); + err.view.clearText(); + } + } else { + errMsgView.clearText(); + } + } + cb(newFocusId); + }, + + // + // Handlers + // + saveChanges : function(formData, extraArgs, cb) { + assert(formData.value.password === formData.value.passwordConfirm); + + const newProperties = { + [ UserProps.RealName ] : formData.value.realName, + [ UserProps.Birthdate ] : getISOTimestampString(formData.value.birthdate), + [ UserProps.Sex ] : formData.value.sex, + [ UserProps.Location ] : formData.value.location, + [ UserProps.Affiliations ] : formData.value.affils, + [ UserProps.EmailAddress ] : formData.value.email, + [ UserProps.WebAddress ] : formData.value.web, + [ UserProps.TermHeight ] : formData.value.termHeight.toString(), + [ UserProps.ThemeId ] : self.availThemeInfo[formData.value.theme].themeId, + }; + + // runtime set theme + theme.setClientTheme(self.client, newProperties.theme_id); + + // persist all changes + self.client.user.persistProperties(newProperties, err => { + if(err) { + self.client.log.warn( { error : err.toString() }, 'Failed persisting updated properties'); + // :TODO: warn end user! + return self.prevMenu(cb); + } + // + // New password if it's not empty + // + self.client.log.info('User updated properties'); + + if(formData.value.password.length > 0) { + self.client.user.setNewAuthCredentials(formData.value.password, err => { + if(err) { + self.client.log.error( { err : err }, 'Failed storing new authentication credentials'); + } else { + self.client.log.info('User changed authentication credentials'); + } + return self.prevMenu(cb); + }); + } else { + return self.prevMenu(cb); + } + }); + }, + }; + } + + getMenuView(viewId) { + return this.viewControllers.menu.getView(viewId); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.viewControllers.menu = new ViewController( { client : self.client} ); + let currentThemeIdIndex = 0; + + async.series( + [ + function loadFromConfig(callback) { + vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function prepareAvailableThemes(callback) { + self.availThemeInfo = _.sortBy([...theme.getAvailableThemes()].map(entry => { + const theme = entry[1]; + return { + themeId : theme.info.themeId, + name : theme.info.name, + author : theme.info.author, + desc : _.isString(theme.info.desc) ? theme.info.desc : '', + group : _.isString(theme.info.group) ? theme.info.group : '', + }; + }), 'name'); + + currentThemeIdIndex = Math.max(0, _.findIndex(self.availThemeInfo, function cmp(ti) { + return ti.themeId === self.client.user.properties[UserProps.ThemeId]; + })); + + callback(null); + }, + function populateViews(callback) { + const user = self.client.user; + + self.setViewText('menu', MciCodeIds.RealName, user.properties[UserProps.RealName]); + self.setViewText('menu', MciCodeIds.BirthDate, moment(user.properties[UserProps.Birthdate]).format('YYYYMMDD')); + self.setViewText('menu', MciCodeIds.Sex, user.properties[UserProps.Sex]); + self.setViewText('menu', MciCodeIds.Loc, user.properties[UserProps.Location]); + self.setViewText('menu', MciCodeIds.Affils, user.properties[UserProps.Affiliations]); + self.setViewText('menu', MciCodeIds.Email, user.properties[UserProps.EmailAddress]); + self.setViewText('menu', MciCodeIds.Web, user.properties[UserProps.WebAddress]); + self.setViewText('menu', MciCodeIds.TermHeight, user.properties[UserProps.TermHeight].toString()); + + + var themeView = self.getMenuView(MciCodeIds.Theme); + if(themeView) { + themeView.setItems(_.map(self.availThemeInfo, 'name')); + themeView.setFocusItemIndex(currentThemeIdIndex); + } + + 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! + } + + callback(null); + } + ], + function complete(err) { + if(err) { + self.client.log.warn( { error : err.toString() }, 'User configuration failed to init'); + self.prevMenu(); + } else { + cb(null); + } + } + ); + }); + } }; diff --git a/core/user_group.js b/core/user_group.js index 2fcaacf3..4b1548b8 100644 --- a/core/user_group.js +++ b/core/user_group.js @@ -1,65 +1,68 @@ /* jslint node: true */ 'use strict'; -var userDb = require('./database.js').dbs.user; -var Config = require('./config.js').config; +const userDb = require('./database.js').dbs.user; -var async = require('async'); -var _ = require('lodash'); +const async = require('async'); +const _ = require('lodash'); -exports.getGroupsForUser = getGroupsForUser; -exports.addUserToGroup = addUserToGroup; -exports.addUserToGroups = addUserToGroups; -exports.removeUserFromGroup = removeUserFromGroup; +exports.getGroupsForUser = getGroupsForUser; +exports.addUserToGroup = addUserToGroup; +exports.addUserToGroups = addUserToGroups; +exports.removeUserFromGroup = removeUserFromGroup; function getGroupsForUser(userId, cb) { - var sql = - 'SELECT group_name ' + - 'FROM user_group_member ' + - 'WHERE user_id=?;'; + const sql = + `SELECT group_name + FROM user_group_member + WHERE user_id=?;`; - var groups = []; + const groups = []; - userDb.each(sql, [ userId ], function rowData(err, row) { - if(err) { - cb(err); - return; - } else { - groups.push(row.group_name); - } - }, - function complete() { - cb(null, groups); - }); + userDb.each(sql, [ userId ], (err, row) => { + if(err) { + return cb(err); + } + + groups.push(row.group_name); + }, + () => { + return cb(null, groups); + }); } -function addUserToGroup(userId, groupName, cb) { - userDb.run( - 'REPLACE INTO user_group_member (group_name, user_id) ' + - 'VALUES(?, ?);', - [ groupName, userId ], - function complete(err) { - cb(err); - } - ); +function addUserToGroup(userId, groupName, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = userDb; + } + + transOrDb.run( + `REPLACE INTO user_group_member (group_name, user_id) + VALUES(?, ?);`, + [ groupName, userId ], + err => { + return cb(err); + } + ); } -function addUserToGroups(userId, groups, cb) { +function addUserToGroups(userId, groups, transOrDb, cb) { - async.each(groups, function item(groupName, next) { - addUserToGroup(userId, groupName, next); - }, function complete(err) { - cb(err); - }); + async.each(groups, (groupName, nextGroupName) => { + return addUserToGroup(userId, groupName, transOrDb, nextGroupName); + }, err => { + return cb(err); + }); } function removeUserFromGroup(userId, groupName, cb) { - userDb.run( - 'DELETE FROM user_group_member ' + - 'WHERE group_name=? AND user_id=?;', - [ groupName, userId ], - function complete(err) { - cb(err); - } - ); + userDb.run( + `DELETE FROM user_group_member + WHERE group_name=? AND user_id=?;`, + [ groupName, userId ], + err => { + return cb(err); + } + ); } diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js new file mode 100644 index 00000000..67b880c8 --- /dev/null +++ b/core/user_interrupt_queue.js @@ -0,0 +1,112 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Art = require('./art.js'); +const { + getActiveConnections +} = require('./client_connections.js'); +const ANSI = require('./ansi_term.js'); +const { pipeToAnsi } = require('./color_codes.js'); + +// deps +const _ = require('lodash'); + +module.exports = class UserInterruptQueue +{ + constructor(client) { + this.client = client; + this.queue = []; + } + + static queue(interruptItem, opts) { + opts = opts || {}; + if(!opts.clients) { + let omitNodes = []; + if(Array.isArray(opts.omit)) { + omitNodes = opts.omit; + } else if(opts.omit) { + omitNodes = [ opts.omit ]; + } + omitNodes = omitNodes.map(n => _.isNumber(n) ? n : n.node); + opts.clients = getActiveConnections(true).filter(ac => !omitNodes.includes(ac.node)); + } + if(!Array.isArray(opts.clients)) { + opts.clients = [ opts.clients ]; + } + opts.clients.forEach(c => { + c.interruptQueue.queueItem(interruptItem); + }); + } + + queueItem(interruptItem) { + if(!_.isString(interruptItem.contents) && !_.isString(interruptItem.text)) { + return; + } + + // pause defaulted on + interruptItem.pause = _.get(interruptItem, 'pause', true); + + try { + this.client.currentMenuModule.attemptInterruptNow(interruptItem, (err, ateIt) => { + if(err) { + // :TODO: Log me + } else if(true !== ateIt) { + this.queue.push(interruptItem); + } + }); + } catch(e) { + this.queue.push(interruptItem); + } + } + + hasItems() { + return this.queue.length > 0; + } + + displayNext(options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + const interruptItem = this.queue.pop(); + if(!interruptItem) { + return cb(null); + } + + Object.assign(interruptItem, options); + return interruptItem ? this.displayWithItem(interruptItem, cb) : cb(null); + } + + displayWithItem(interruptItem, cb) { + if(interruptItem.cls) { + this.client.term.rawWrite(ANSI.resetScreen()); + } else { + this.client.term.rawWrite('\r\n\r\n'); + } + + const maybePauseAndFinish = () => { + if(interruptItem.pause) { + this.client.currentMenuModule.pausePrompt( () => { + return cb(null); + }); + } else { + return cb(null); + } + }; + + if(interruptItem.contents) { + Art.display(this.client, interruptItem.contents, err => { + if(err) { + return cb(err); + } + //this.client.term.rawWrite('\r\n\r\n'); // :TODO: Prob optional based on contents vs text + maybePauseAndFinish(); + }); + } else { + this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), true, () => { + maybePauseAndFinish(); + }); + } + } +}; \ No newline at end of file diff --git a/core/user_list.js b/core/user_list.js new file mode 100644 index 00000000..3b342fab --- /dev/null +++ b/core/user_list.js @@ -0,0 +1,85 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { getUserList } = require('./user.js'); +const { Errors } = require('./enig_error.js'); +const UserProps = require('./user_property.js'); + +// deps +const moment = require('moment'); +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'User List', + desc : 'Lists all system users', + author : 'NuSkooler', +}; + +const MciViewIds = { + userList : 1, +}; + +exports.getModule = class UserListModule extends MenuModule { + constructor(options) { + super(options); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (next) => { + return this.prepViewController('userList', 0, mciData.menu, next); + }, + (next) => { + const userListView = this.viewControllers.userList.getView(MciViewIds.userList); + if(!userListView) { + return cb(Errors.MissingMci(`Missing user list MCI ${MciViewIds.userList}`)); + } + + const fetchOpts = { + properties : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations, UserProps.LastLoginTs ], + propsCamelCase : true, // e.g. real_name -> realName + }; + getUserList(fetchOpts, (err, userList) => { + if(err) { + return next(err); + } + + const dateTimeFormat = _.get( + this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateTimeFormat('short')); + + userList = userList.map(entry => { + return Object.assign( + entry, + { + text : entry.userName, + affils : entry.affiliation, + lastLoginTs : moment(entry.lastLoginTimestamp).format(dateTimeFormat), + } + ); + }); + + userListView.setItems(userList); + userListView.redraw(); + return next(null); + }); + } + ], + err => { + if(err) { + this.client.log.error( { error : err.message }, 'Error loading user list'); + } + return cb(err); + } + ); + }); + } +}; diff --git a/core/user_log_name.js b/core/user_log_name.js new file mode 100644 index 00000000..77fa996c --- /dev/null +++ b/core/user_log_name.js @@ -0,0 +1,22 @@ +/* jslint node: true */ +'use strict'; + +// +// Common (but not all!) user log names +// +module.exports = { + NewUser : 'new_user', + Login : 'login', + Logoff : 'logoff', + UlFiles : 'ul_files', // value=count + UlFileBytes : 'ul_file_bytes', // value=total bytes + DlFiles : 'dl_files', // value=count + DlFileBytes : 'dl_file_bytes', // value=total bytes + PostMessage : 'post_msg', // value=areaTag + SendMail : 'send_mail', + RunDoor : 'run_door', // value=doorTag|unknown + RunDoorMinutes : 'run_door_minutes', // value=minutes ran + SendNodeMsg : 'send_node_msg', // value=global|direct + AchievementEarned : 'achievement_earned', // value=achievementTag + AchievementPointsEarned : 'achievement_pts_earned', // value=points earned +}; diff --git a/core/user_login.js b/core/user_login.js index 4bd9176c..3db6b5cc 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -1,87 +1,222 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const setClientTheme = require('./theme.js').setClientTheme; -const clientConnections = require('./client_connections.js').clientConnections; -const StatLog = require('./stat_log.js'); -const logger = require('./logger.js'); +// ENiGMA½ +const setClientTheme = require('./theme.js').setClientTheme; +const clientConnections = require('./client_connections.js').clientConnections; +const StatLog = require('./stat_log.js'); +const logger = require('./logger.js'); +const Events = require('./events.js'); +const Config = require('./config.js').get; +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); +const UserProps = require('./user_property.js'); +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'); +// 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, cb) { - client.user.authenticate(username, password, function authenticated(err) { - if(err) { - client.log.info( { username : username, error : err.message }, 'Failed login attempt'); +function userLogin(client, username, password, options, cb) { + if(!cb && _.isFunction(options)) { + cb = options; + options = {}; + } - // :TODO: if username exists, record failed login attempt to properties - // :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true + const config = Config(); - return cb(err); - } - const user = client.user; + if(config.users.badUserNames.includes(username.toLowerCase())) { + client.log.info( { username, ip : client.remoteAddress }, 'Attempt to login with banned username'); - // - // Ensure this user is not already logged in. - // Loop through active connections -- which includes the current -- - // and check for matching user ID. If the count is > 1, disallow. - // - let existingClientConnection; - clientConnections.forEach(function connEntry(cc) { - if(cc.user !== user && cc.user.userId === user.userId) { - existingClientConnection = cc; - } - }); + // slow down a bit to thwart brute force attacks + return setTimeout( () => { + return cb(Errors.BadLogin('Disallowed username', ErrorReasons.NotAllowed)); + }, 2000); + } - if(existingClientConnection) { - client.log.info( - { - existingClientId : existingClientConnection.session.id, - username : user.username, - userId : user.userId - }, - 'Already logged in' - ); + const authInfo = { + username, + password, + }; - const existingConnError = new Error('Already logged in as supplied user'); - existingConnError.existingConn = true; + authInfo.type = options.authType || User.AuthFactor1Types.Password; + authInfo.pubKey = options.ctx; - // :TODO: We should use EnigError & pass existing connection as second param + client.user.authenticateFactor1(authInfo, err => { + if(err) { + return cb(transformLoginError(err, client, username)); + } - return cb(existingConnError); - } + const user = client.user; + // Good login; reset any failed attempts + delete client.sessionFailedLoginAttempts; - // update client logger with addition of username - client.log = logger.log.child( { clientId : client.log.fields.clientId, username : user.username }); - client.log.info('Successful login'); + // + // Ensure this user is not already logged in. + // + const existingClientConnection = clientConnections.find(cc => { + return user !== cc.user && // not current connection + user.userId === cc.user.userId; // ...but same user + }); - async.parallel( - [ - function setTheme(callback) { - setClientTheme(client, user.properties.theme_id); - return callback(null); - }, - function updateSystemLoginCount(callback) { - return StatLog.incrementSystemStat('login_count', 1, callback); - }, - function recordLastLogin(callback) { - return StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback); - }, - function updateUserLoginCount(callback) { - return StatLog.incrementUserStat(user, 'login_count', 1, callback); - }, - function recordLoginHistory(callback) { - const LOGIN_HISTORY_MAX = 200; // history of up to last 200 callers - return StatLog.appendSystemLogEntry('user_login_history', user.userId, LOGIN_HISTORY_MAX, StatLog.KeepType.Max, callback); - } - ], - err => { - return cb(err); - } - ); - }); + if(existingClientConnection) { + client.log.info( + { + existingNodeId : existingClientConnection.node, + username : user.username, + userId : user.userId + }, + 'Already logged in' + ); + + return cb(Errors.BadLogin( + `User ${user.username} already logged in.`, + ErrorReasons.AlreadyLoggedIn + )); + } + + // update client logger with addition of username + client.log = logger.log.child( + { + nodeId : client.log.fields.nodeId, + sessionId : client.log.fields.sessionId, + username : user.username, + } + ); + + client.log.info('Successful login'); + + // User's unique session identifier is the same as the connection itself + user.sessionId = client.session.uniqueId; // convenience + + Events.emit(Events.getSystemEvents().UserLogin, { user } ); + + setClientTheme(client, user.properties[UserProps.ThemeId]); + + postLoginPrep(client, err => { + if(err) { + return cb(err); + } + + if(user.authenticated) { + return recordLogin(client, cb); + } + + // recordLogin() must happen after 2FA! + return cb(null); + }); + }); +} + +function postLoginPrep(client, cb) { + async.series( + [ + (callback) => { + // + // User may (no longer) have read (view) rights to their current + // message, conferences and/or areas. Move them out if so. + // + const confTag = client.user.getProperty(UserProps.MessageConfTag); + const conf = getMessageConferenceByTag(confTag) || {}; + const area = getMessageAreaByTag(client.user.getProperty(UserProps.MessageAreaTag), confTag) || {}; + + if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(area)) { + // move them out of both area and possibly conf to something suitable, hopefully. + const [newConfTag, newAreaTag] = getSuitableMessageConfAndAreaTags(client); + client.user.persistProperties({ + [ UserProps.MessageConfTag ] : newConfTag, + [ UserProps.MessageAreaTag ] : newAreaTag, + }, + err => { + return callback(err); + }); + } else { + return callback(null); + } + }, + (callback) => { + // Likewise for file areas + const area = getFileAreaByTag(client.user.getProperty(UserProps.FileAreaTag)) || {}; + if(!client.acs.hasFileAreaRead(area)) { + const areaTag = getDefaultFileAreaTag(client) || ''; + client.user.persistProperty(UserProps.FileAreaTag, areaTag, err => { + return callback(err); + }); + } else { + return callback(null); + } + } + ], + err => { + return cb(err); + } + ); +} + +function recordLogin(client, cb) { + assert(client.user.authenticated); // don't get in situations where this isn't true + + const user = client.user; + async.parallel( + [ + (callback) => { + StatLog.incrementNonPersistentSystemStat(SysProps.LoginsToday, 1); + return StatLog.incrementSystemStat(SysProps.LoginCount, 1, callback); + }, + (callback) => { + return StatLog.setUserStat(user, UserProps.LastLoginTs, StatLog.now, callback); + }, + (callback) => { + return StatLog.incrementUserStat(user, UserProps.LoginCount, 1, callback); + }, + (callback) => { + const loginHistoryMax = Config().statLog.systemEvents.loginHistoryMax; + const historyItem = JSON.stringify({ + userId : user.userId, + sessionId : user.sessionId, + }); + + return StatLog.appendSystemLogEntry( + SystemLogKeys.UserLoginHistory, + historyItem, + loginHistoryMax, + StatLog.KeepType.Max, + callback + ); + } + ], + err => { + return cb(err); + } + ); +} + +function transformLoginError(err, client, username) { + client.sessionFailedLoginAttempts = _.get(client, 'sessionFailedLoginAttempts', 0) + 1; + const disconnect = Config().users.failedLogin.disconnect; + if(disconnect > 0 && client.sessionFailedLoginAttempts >= disconnect) { + err = Errors.BadLogin('To many failed login attempts', ErrorReasons.TooMany); + } + + client.log.info( { username, ip : client.remoteAddress, reason : err.message }, 'Failed login attempt'); + return err; } \ No newline at end of file diff --git a/core/user_property.js b/core/user_property.js new file mode 100644 index 00000000..d55a0ebe --- /dev/null +++ b/core/user_property.js @@ -0,0 +1,69 @@ +/* jslint node: true */ +'use strict'; + +// +// Common user properties used throughout the system. +// +// This IS NOT a full list. For example, custom modules +// can utilize their own properties as well! +// +module.exports = { + PassPbkdf2Salt : 'pw_pbkdf2_salt', + PassPbkdf2Dk : 'pw_pbkdf2_dk', + + AccountStatus : 'account_status', // See User.AccountStatus enum + + RealName : 'real_name', + Sex : 'sex', + Birthdate : 'birthdate', + Location : 'location', + Affiliations : 'affiliation', + EmailAddress : 'email_address', + WebAddress : 'web_address', + TermHeight : 'term_height', + TermWidth : 'term_width', + ThemeId : 'theme_id', + AccountCreated : 'account_created', + LastLoginTs : 'last_login_timestamp', + LoginCount : 'login_count', + UserComment : 'user_comment', // NYI + AutoSignature : 'auto_signature', + + DownloadQueue : 'dl_queue', // see download_queue.js + + FailedLoginAttempts : 'failed_login_attempts', + AccountLockedTs : 'account_locked_timestamp', + AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out + + EmailPwResetToken : 'email_password_reset_token', + EmailPwResetTokenTs : 'email_password_reset_token_ts', + + FileAreaTag : 'file_area_tag', + FileBaseFilters : 'file_base_filters', + FileBaseFilterActiveUuid : 'file_base_filter_active_uuid', + FileBaseLastViewedId : 'user_file_base_last_viewed', + FileDlTotalCount : 'dl_total_count', + FileUlTotalCount : 'ul_total_count', + FileDlTotalBytes : 'dl_total_bytes', + FileUlTotalBytes : 'ul_total_bytes', + + MessageConfTag : 'message_conf_tag', + MessageAreaTag : 'message_area_tag', + MessagePostCount : 'post_count', + + DoorRunTotalCount : 'door_run_total_count', + DoorRunTotalMinutes : 'door_run_total_minutes', + + AchievementTotalCount : 'achievement_total_count', + AchievementTotalPoints : 'achievement_total_points', + + MinutesOnlineTotalCount : 'minutes_online_total_count', + + SSHPubKey : 'ssh_public_key', // OpenSSH format (ssh-keygen, etc.) + AuthFactor1Types : 'auth_factor1_types', // List of User.AuthFactor1Types value(s) + AuthFactor2OTP : 'auth_factor2_otp', // If present, OTP type for 2FA. See OTPTypes + AuthFactor2OTPSecret : 'auth_factor2_otp_secret', // Secret used in conjunction with OTP 2FA + AuthFactor2OTPBackupCodes : 'auth_factor2_otp_backup', // JSON array of backup codes + +}; + diff --git a/core/user_temp_token.js b/core/user_temp_token.js new file mode 100644 index 00000000..89c060d6 --- /dev/null +++ b/core/user_temp_token.js @@ -0,0 +1,140 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const UserDb = require('./database.js').dbs.user; +const { + getISOTimestampString +} = require('./database.js'); +const { Errors } = require('./enig_error.js'); +const User = require('./user.js'); +const Log = require('./logger.js').log; + +// deps +const crypto = require('crypto'); +const async = require('async'); +const moment = require('moment'); + +exports.createToken = createToken; +exports.deleteToken = deleteToken; +exports.deleteTokenByUserAndType = deleteTokenByUserAndType; +exports.getTokenInfo = getTokenInfo; +exports.temporaryTokenMaintenanceTask = temporaryTokenMaintenanceTask; + +exports.WellKnownTokenTypes = { + AuthFactor2OTPRegister : 'auth_factor2_otp_register', +}; + +function createToken(userId, tokenType, options = { bits : 128 }, cb) { + async.waterfall( + [ + (callback) => { + return crypto.randomBytes(options.bits, callback); + }, + (token, callback) => { + token = token.toString('hex'); + + UserDb.run( + `INSERT OR REPLACE INTO user_temporary_token (user_id, token, token_type, timestamp) + VALUES (?, ?, ?, ?);`, + [ userId, token, tokenType, getISOTimestampString() ], + err => { + return callback(err, token); + } + ); + } + ], + (err, token) => { + return cb(err, token); + } + ); +} + +function deleteToken(token, cb) { + UserDb.run( + `DELETE FROM user_temporary_token + WHERE token = ?;`, + [ token ], + err => { + return cb(err); + } + ); +} + +function deleteTokenByUserAndType(userId, tokenType, cb) { + UserDb.run( + `DELETE FROM user_temporary_token + WHERE user_id = ? AND token_type = ?;`, + [ userId, tokenType ], + err => { + return cb(err); + } + ); +} + +function getTokenInfo(token, cb) { + async.waterfall( + [ + (callback) => { + UserDb.get( + `SELECT user_id, token_type, timestamp + FROM user_temporary_token + WHERE token = ?;`, + [ token ], + (err, row) => { + if(err) { + return callback(err); + } + + if(!row) { + return callback(Errors.DoesNotExist('No entry found for token')); + } + + const info = { + userId : row.user_id, + tokenType : row.token_type, + timestamp : moment(row.timestamp), + }; + return callback(null, info); + } + ); + }, + (info, callback) => { + User.getUser(info.userId, (err, user) => { + info.user = user; + return callback(err, info); + }); + } + ], + (err, info) => { + return cb(err, info); + } + ); +} + +function temporaryTokenMaintenanceTask(args, cb) { + const tokenType = args[0]; + + if(!tokenType) { + return Log.error('Cannot run temporary token maintenance task with out specifying "tokenType" as argument 0'); + } + + const expTime = args[1] || '24 hours'; + + UserDb.run( + `DELETE FROM user_temporary_token + WHERE token IN ( + SELECT token + FROM user_temporary_token + WHERE token_type = ? + AND DATETIME("now") >= DATETIME(timestamp, "+${expTime}") + );`, + [ tokenType ], + err => { + if(err) { + Log.warn( { error : err.message, tokenType }, 'Failed deleting user temporary token'); + } + return cb(err); + } + ); +} diff --git a/core/uuid_util.js b/core/uuid_util.js index d8023f95..f731ecc0 100644 --- a/core/uuid_util.js +++ b/core/uuid_util.js @@ -1,38 +1,38 @@ /* jslint node: true */ 'use strict'; -const createHash = require('crypto').createHash; +const createHash = require('crypto').createHash; -exports.createNamedUUID = createNamedUUID; +exports.createNamedUUID = createNamedUUID; function createNamedUUID(namespaceUuid, key) { - // - // v5 UUID generation code based on the work here: - // https://github.com/download13/uuidv5/blob/master/uuid.js - // - if(!Buffer.isBuffer(namespaceUuid)) { - namespaceUuid = new Buffer(namespaceUuid); - } - - if(!Buffer.isBuffer(key)) { - key = new Buffer(key); - } - - let digest = createHash('sha1').update( - Buffer.concat( [ namespaceUuid, key ] )).digest(); + // + // v5 UUID generation code based on the work here: + // https://github.com/download13/uuidv5/blob/master/uuid.js + // + if(!Buffer.isBuffer(namespaceUuid)) { + namespaceUuid = Buffer.from(namespaceUuid); + } - let u = new Buffer(16); + if(!Buffer.isBuffer(key)) { + key = Buffer.from(key); + } - // bbbb - bb - bb - bb - bbbbbb - digest.copy(u, 0, 0, 4); // time_low - digest.copy(u, 4, 4, 6); // time_mid - digest.copy(u, 6, 6, 8); // time_hi_and_version + let digest = createHash('sha1').update( + Buffer.concat( [ namespaceUuid, key ] )).digest(); - u[6] = (u[6] & 0x0f) | 0x50; // version, 4 most significant bits are set to version 5 (0101) - u[8] = (digest[8] & 0x3f) | 0x80; // clock_seq_hi_and_reserved, 2msb are set to 10 - u[9] = digest[9]; - - digest.copy(u, 10, 10, 16); - - return u; + let u = Buffer.alloc(16); + + // bbbb - bb - bb - bb - bbbbbb + digest.copy(u, 0, 0, 4); // time_low + digest.copy(u, 4, 4, 6); // time_mid + digest.copy(u, 6, 6, 8); // time_hi_and_version + + u[6] = (u[6] & 0x0f) | 0x50; // version, 4 most significant bits are set to version 5 (0101) + u[8] = (digest[8] & 0x3f) | 0x80; // clock_seq_hi_and_reserved, 2msb are set to 10 + u[9] = digest[9]; + + digest.copy(u, 10, 10, 16); + + return u; } \ No newline at end of file diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index 2cc2ad7a..1837b718 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -1,317 +1,346 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const MenuView = require('./menu_view.js').MenuView; -const ansi = require('./ansi_term.js'); -const strUtil = require('./string_util.js'); +// ENiGMA½ +const MenuView = require('./menu_view.js').MenuView; +const ansi = require('./ansi_term.js'); +const strUtil = require('./string_util.js'); +const formatString = require('./string_format'); +const pipeToAnsi = require('./color_codes.js').pipeToAnsi; -// deps -const util = require('util'); -const _ = require('lodash'); +// deps +const util = require('util'); +const _ = require('lodash'); -exports.VerticalMenuView = VerticalMenuView; +exports.VerticalMenuView = VerticalMenuView; function VerticalMenuView(options) { - options.cursor = options.cursor || 'hide'; - options.justify = options.justify || 'right'; // :TODO: default to center - - MenuView.call(this, options); + options.cursor = options.cursor || 'hide'; + options.justify = options.justify || 'left'; - const self = this; + MenuView.call(this, options); - // we want page up/page down by default - if(!_.isObject(options.specialKeyMap)) { - Object.assign(this.specialKeyMap, { - 'page up' : [ 'page up' ], - 'page down' : [ 'page down' ], - }); - } + this.initDefaultWidth(); - this.performAutoScale = function() { - if(this.autoScale.height) { - this.dimens.height = (self.items.length * (self.itemSpacing + 1)) - (self.itemSpacing); - this.dimens.height = Math.min(self.dimens.height, self.client.term.termHeight - self.position.row); - } + const self = this; - if(self.autoScale.width) { - let maxLen = 0; - self.items.forEach( item => { - if(item.text.length > maxLen) { - maxLen = Math.min(item.text.length, self.client.term.termWidth - self.position.col); - } - }); - self.dimens.width = maxLen + 1; - } - }; + // we want page up/page down by default + if(!_.isObject(options.specialKeyMap)) { + Object.assign(this.specialKeyMap, { + 'page up' : [ 'page up' ], + 'page down' : [ 'page down' ], + }); + } - this.performAutoScale(); + this.autoAdjustHeightIfEnabled = function() { + if(this.autoAdjustHeight) { + this.dimens.height = (this.items.length * (this.itemSpacing + 1)) - (this.itemSpacing); + this.dimens.height = Math.min(this.dimens.height, this.client.term.termHeight - this.position.row); + } + }; - this.updateViewVisibleItems = function() { - self.maxVisibleItems = Math.ceil(self.dimens.height / (self.itemSpacing + 1)); + this.autoAdjustHeightIfEnabled(); - self.viewWindow = { - top : self.focusedItemIndex, - bottom : Math.min(self.focusedItemIndex + self.maxVisibleItems, self.items.length) - 1, - }; - }; + this.updateViewVisibleItems = function() { + self.maxVisibleItems = Math.ceil(self.dimens.height / (self.itemSpacing + 1)); - this.drawItem = function(index) { - const item = self.items[index]; - if(!item) { - return; - } + self.viewWindow = { + top : self.focusedItemIndex, + bottom : Math.min(self.focusedItemIndex + self.maxVisibleItems, self.items.length) - 1, + }; + }; - let text; - let sgr; - if(item.focused && self.hasFocusItems()) { - const focusItem = self.focusItems[index]; - text = strUtil.stylizeString( - focusItem ? focusItem.text : item.text, - self.textStyle - ); - sgr = ''; - } else { - text = strUtil.stylizeString(item.text, self.textStyle); - sgr = (index === self.focusedItemIndex ? self.getFocusSGR() : self.getSGR()); - } + this.drawItem = function(index) { + const item = self.items[index]; + if(!item) { + return; + } - text += self.getSGR(); + const cached = this.getRenderCacheItem(index, item.focused); + if(cached) { + return self.client.term.write(`${ansi.goto(item.row, self.position.col)}${cached}`); + } - self.client.term.write( - ansi.goto(item.row, self.position.col) + - sgr + - strUtil.pad(text, this.dimens.width, this.fillChar, this.justify) - ); - }; + let text; + let sgr; + if(item.focused && self.hasFocusItems()) { + const focusItem = self.focusItems[index]; + text = focusItem ? focusItem.text : item.text; + sgr = ''; + } else if(this.complexItems) { + text = pipeToAnsi(formatString(item.focused && this.focusItemFormat ? this.focusItemFormat : this.itemFormat, item)); + sgr = this.focusItemFormat ? '' : (index === self.focusedItemIndex ? self.getFocusSGR() : self.getSGR()); + } else { + text = strUtil.stylizeString(item.text, item.focused ? self.focusTextStyle : self.textStyle); + sgr = (index === self.focusedItemIndex ? self.getFocusSGR() : self.getSGR()); + } + + text = `${sgr}${strUtil.pad(text, this.dimens.width, this.fillChar, this.justify)}`; + self.client.term.write(`${ansi.goto(item.row, self.position.col)}${text}`); + this.setRenderCacheItem(index, text, item.focused); + }; } util.inherits(VerticalMenuView, MenuView); VerticalMenuView.prototype.redraw = function() { - VerticalMenuView.super_.prototype.redraw.call(this); + VerticalMenuView.super_.prototype.redraw.call(this); - // :TODO: rename positionCacheExpired to something that makese sense; combine methods for such - if(this.positionCacheExpired) { - this.performAutoScale(); - this.updateViewVisibleItems(); + // :TODO: rename positionCacheExpired to something that makese sense; combine methods for such + if(this.positionCacheExpired) { + this.autoAdjustHeightIfEnabled(); + this.updateViewVisibleItems(); - this.positionCacheExpired = false; - } + this.positionCacheExpired = false; + } - // erase old items - // :TODO: optimize this: only needed if a item is removed or new max width < old. - if(this.oldDimens) { - const blank = new Array(Math.max(this.oldDimens.width, this.dimens.width)).join(' '); - let seq = ansi.goto(this.position.row, this.position.col) + this.getSGR() + blank; - let row = this.position.row + 1; - const endRow = (row + this.oldDimens.height) - 2; - - while(row <= endRow) { - seq += ansi.goto(row, this.position.col) + blank; - row += 1; - } - this.client.term.write(seq); - delete this.oldDimens; - } + // erase old items + // :TODO: optimize this: only needed if a item is removed or new max width < old. + if(this.oldDimens) { + const blank = new Array(Math.max(this.oldDimens.width, this.dimens.width)).join(' '); + let seq = ansi.goto(this.position.row, this.position.col) + this.getSGR() + blank; + let row = this.position.row + 1; + const endRow = (row + this.oldDimens.height) - 2; - if(this.items.length) { - let row = this.position.row; - for(let i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) { - this.items[i].row = row; - row += this.itemSpacing + 1; - this.items[i].focused = this.focusedItemIndex === i; - this.drawItem(i); - } - } + while(row <= endRow) { + seq += ansi.goto(row, this.position.col) + blank; + row += 1; + } + this.client.term.write(seq); + delete this.oldDimens; + } + + if(this.items.length) { + let row = this.position.row; + for(let i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) { + this.items[i].row = row; + row += this.itemSpacing + 1; + this.items[i].focused = this.focusedItemIndex === i; + this.drawItem(i); + } + } }; VerticalMenuView.prototype.setHeight = function(height) { - VerticalMenuView.super_.prototype.setHeight.call(this, height); + VerticalMenuView.super_.prototype.setHeight.call(this, height); - this.positionCacheExpired = true; + this.positionCacheExpired = true; + this.autoAdjustHeight = false; }; VerticalMenuView.prototype.setPosition = function(pos) { - VerticalMenuView.super_.prototype.setPosition.call(this, pos); + VerticalMenuView.super_.prototype.setPosition.call(this, pos); - this.positionCacheExpired = true; + this.positionCacheExpired = true; }; VerticalMenuView.prototype.setFocus = function(focused) { - VerticalMenuView.super_.prototype.setFocus.call(this, focused); + VerticalMenuView.super_.prototype.setFocus.call(this, focused); - this.redraw(); + this.redraw(); }; VerticalMenuView.prototype.setFocusItemIndex = function(index) { - VerticalMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + VerticalMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex - const remainAfterFocus = this.items.length - index; - if(remainAfterFocus >= this.maxVisibleItems) { - this.viewWindow = { - top : this.focusedItemIndex, - bottom : Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length) - 1 - }; + const remainAfterFocus = this.items.length - index; + if(remainAfterFocus >= this.maxVisibleItems) { + this.viewWindow = { + top : this.focusedItemIndex, + bottom : Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length) - 1 + }; - this.positionCacheExpired = false; // skip standard behavior - this.performAutoScale(); - } + this.positionCacheExpired = false; // skip standard behavior + this.autoAdjustHeightIfEnabled(); + } - this.redraw(); + this.redraw(); }; VerticalMenuView.prototype.onKeyPress = function(ch, key) { + if(key) { + if(this.isKeyMapped('up', key.name)) { + this.focusPrevious(); + } else if(this.isKeyMapped('down', key.name)) { + this.focusNext(); + } else if(this.isKeyMapped('page up', key.name)) { + this.focusPreviousPageItem(); + } else if(this.isKeyMapped('page down', key.name)) { + this.focusNextPageItem(); + } else if(this.isKeyMapped('home', key.name)) { + this.focusFirst(); + } else if(this.isKeyMapped('end', key.name)) { + this.focusLast(); + } + } - if(key) { - if(this.isKeyMapped('up', key.name)) { - this.focusPrevious(); - } else if(this.isKeyMapped('down', key.name)) { - this.focusNext(); - } else if(this.isKeyMapped('page up', key.name)) { - this.focusPreviousPageItem(); - } else if( this.isKeyMapped('page down', key.name)) { - this.focusNextPageItem(); - } - } - - VerticalMenuView.super_.prototype.onKeyPress.call(this, ch, key); + VerticalMenuView.super_.prototype.onKeyPress.call(this, ch, key); }; VerticalMenuView.prototype.getData = function() { - return this.focusedItemIndex; + const item = this.getItem(this.focusedItemIndex); + return _.isString(item.data) ? item.data : this.focusedItemIndex; }; VerticalMenuView.prototype.setItems = function(items) { - // if we have items already, save off their drawing area so we don't leave fragments at redraw - if(this.items && this.items.length) { - this.oldDimens = Object.assign({}, this.dimens); - } + // if we have items already, save off their drawing area so we don't leave fragments at redraw + if(this.items && this.items.length) { + this.oldDimens = Object.assign({}, this.dimens); + } - VerticalMenuView.super_.prototype.setItems.call(this, items); + VerticalMenuView.super_.prototype.setItems.call(this, items); - this.positionCacheExpired = true; + this.positionCacheExpired = true; }; VerticalMenuView.prototype.removeItem = function(index) { - if(this.items && this.items.length) { - this.oldDimens = Object.assign({}, this.dimens); - } + if(this.items && this.items.length) { + this.oldDimens = Object.assign({}, this.dimens); + } - VerticalMenuView.super_.prototype.removeItem.call(this, index); + VerticalMenuView.super_.prototype.removeItem.call(this, index); }; -// :TODO: Apply draw optimizaitons when only two items need drawn vs entire view! +// :TODO: Apply draw optimizaitons when only two items need drawn vs entire view! VerticalMenuView.prototype.focusNext = function() { - if(this.items.length - 1 === this.focusedItemIndex) { - this.focusedItemIndex = 0; - - this.viewWindow = { - top : 0, - bottom : Math.min(this.maxVisibleItems, this.items.length) - 1 - }; - } else { - this.focusedItemIndex++; + if(this.items.length - 1 === this.focusedItemIndex) { + this.focusedItemIndex = 0; - if(this.focusedItemIndex > this.viewWindow.bottom) { - this.viewWindow.top++; - this.viewWindow.bottom++; - } - } + this.viewWindow = { + top : 0, + bottom : Math.min(this.maxVisibleItems, this.items.length) - 1 + }; + } else { + this.focusedItemIndex++; - this.redraw(); + if(this.focusedItemIndex > this.viewWindow.bottom) { + this.viewWindow.top++; + this.viewWindow.bottom++; + } + } - VerticalMenuView.super_.prototype.focusNext.call(this); + this.redraw(); + + VerticalMenuView.super_.prototype.focusNext.call(this); }; VerticalMenuView.prototype.focusPrevious = function() { - if(0 === this.focusedItemIndex) { - this.focusedItemIndex = this.items.length - 1; - - this.viewWindow = { - //top : this.items.length - this.maxVisibleItems, - top : Math.max(this.items.length - this.maxVisibleItems, 0), - bottom : this.items.length - 1 - }; + if(0 === this.focusedItemIndex) { + this.focusedItemIndex = this.items.length - 1; - } else { - this.focusedItemIndex--; + this.viewWindow = { + //top : this.items.length - this.maxVisibleItems, + top : Math.max(this.items.length - this.maxVisibleItems, 0), + bottom : this.items.length - 1 + }; - if(this.focusedItemIndex < this.viewWindow.top) { - this.viewWindow.top--; - this.viewWindow.bottom--; + } else { + this.focusedItemIndex--; - // adjust for focus index being set & window needing expansion as we scroll up - const rem = (this.viewWindow.bottom - this.viewWindow.top) + 1; - if(rem < this.maxVisibleItems && (this.items.length - 1) > this.focusedItemIndex) { - this.viewWindow.bottom = this.items.length - 1; - } - } - } + if(this.focusedItemIndex < this.viewWindow.top) { + this.viewWindow.top--; + this.viewWindow.bottom--; - this.redraw(); + // adjust for focus index being set & window needing expansion as we scroll up + const rem = (this.viewWindow.bottom - this.viewWindow.top) + 1; + if(rem < this.maxVisibleItems && (this.items.length - 1) > this.focusedItemIndex) { + this.viewWindow.bottom = this.items.length - 1; + } + } + } - VerticalMenuView.super_.prototype.focusPrevious.call(this); + this.redraw(); + + VerticalMenuView.super_.prototype.focusPrevious.call(this); }; VerticalMenuView.prototype.focusPreviousPageItem = function() { - // - // Jump to current - up to page size or top - // If already at the top, jump to bottom - // - if(0 === this.focusedItemIndex) { - return this.focusPrevious(); // will jump to bottom - } + // + // Jump to current - up to page size or top + // If already at the top, jump to bottom + // + if(0 === this.focusedItemIndex) { + return this.focusPrevious(); // will jump to bottom + } - const index = Math.max(this.focusedItemIndex - this.dimens.height, 0); + const index = Math.max(this.focusedItemIndex - this.dimens.height, 0); - if(index < this.viewWindow.top) { - this.oldDimens = Object.assign({}, this.dimens); - } + if(index < this.viewWindow.top) { + this.oldDimens = Object.assign({}, this.dimens); + } - this.setFocusItemIndex(index); + this.setFocusItemIndex(index); - return VerticalMenuView.super_.prototype.focusPreviousPageItem.call(this); + return VerticalMenuView.super_.prototype.focusPreviousPageItem.call(this); }; VerticalMenuView.prototype.focusNextPageItem = function() { - // - // Jump to current + up to page size or bottom - // If already at the bottom, jump to top - // - if(this.items.length - 1 === this.focusedItemIndex) { - return this.focusNext(); // will jump to top - } + // + // Jump to current + up to page size or bottom + // If already at the bottom, jump to top + // + if(this.items.length - 1 === this.focusedItemIndex) { + return this.focusNext(); // will jump to top + } - const index = Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length - 1); + const index = Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length - 1); - if(index > this.viewWindow.bottom) { - this.oldDimens = Object.assign({}, this.dimens); + if(index > this.viewWindow.bottom) { + this.oldDimens = Object.assign({}, this.dimens); - this.focusedItemIndex = index; + this.focusedItemIndex = index; - this.viewWindow = { - top : this.focusedItemIndex, - bottom : Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length) - 1 - }; + this.viewWindow = { + top : this.focusedItemIndex, + bottom : Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length) - 1 + }; - this.redraw(); - } else { - this.setFocusItemIndex(index); - } + this.redraw(); + } else { + this.setFocusItemIndex(index); + } - return VerticalMenuView.super_.prototype.focusNextPageItem.call(this); + return VerticalMenuView.super_.prototype.focusNextPageItem.call(this); +}; + +VerticalMenuView.prototype.focusFirst = function() { + if(0 < this.viewWindow.top) { + this.oldDimens = Object.assign({}, this.dimens); + } + this.setFocusItemIndex(0); + return VerticalMenuView.super_.prototype.focusFirst.call(this); +}; + +VerticalMenuView.prototype.focusLast = function() { + const index = this.items.length - 1; + + if(index > this.viewWindow.bottom) { + this.oldDimens = Object.assign({}, this.dimens); + + this.focusedItemIndex = index; + + this.viewWindow = { + top : this.focusedItemIndex, + bottom : Math.min(this.focusedItemIndex + this.maxVisibleItems, this.items.length) - 1 + }; + + this.redraw(); + } else { + this.setFocusItemIndex(index); + } + + return VerticalMenuView.super_.prototype.focusLast.call(this); }; VerticalMenuView.prototype.setFocusItems = function(items) { - VerticalMenuView.super_.prototype.setFocusItems.call(this, items); + VerticalMenuView.super_.prototype.setFocusItems.call(this, items); - this.positionCacheExpired = true; + this.positionCacheExpired = true; }; VerticalMenuView.prototype.setItemSpacing = function(itemSpacing) { - VerticalMenuView.super_.prototype.setItemSpacing.call(this, itemSpacing); + VerticalMenuView.super_.prototype.setItemSpacing.call(this, itemSpacing); - this.positionCacheExpired = true; + this.positionCacheExpired = true; }; \ No newline at end of file diff --git a/core/view.js b/core/view.js index fccca541..fdf78916 100644 --- a/core/view.js +++ b/core/view.js @@ -1,276 +1,287 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const events = require('events'); -const util = require('util'); -const ansi = require('./ansi_term.js'); -const colorCodes = require('./color_codes.js'); -const enigAssert = require('./enigma_assert.js'); +// ENiGMA½ +const events = require('events'); +const util = require('util'); +const ansi = require('./ansi_term.js'); +const colorCodes = require('./color_codes.js'); +const enigAssert = require('./enigma_assert.js'); +const { renderSubstr } = require('./string_util.js'); -// deps -const _ = require('lodash'); +// deps +const _ = require('lodash'); -exports.View = View; +exports.View = View; const VIEW_SPECIAL_KEY_MAP_DEFAULT = { - accept : [ 'return' ], - exit : [ 'esc' ], - backspace : [ 'backspace', 'del' ], - del : [ 'del' ], - next : [ 'tab' ], - up : [ 'up arrow' ], - down : [ 'down arrow' ], - end : [ 'end' ], - home : [ 'home' ], - left : [ 'left arrow' ], - right : [ 'right arrow' ], - clearLine : [ 'ctrl + y' ], + accept : [ 'return' ], + exit : [ 'esc' ], + backspace : [ 'backspace', 'del' ], + del : [ 'del' ], + next : [ 'tab' ], + up : [ 'up arrow' ], + down : [ 'down arrow' ], + end : [ 'end' ], + home : [ 'home' ], + left : [ 'left arrow' ], + right : [ 'right arrow' ], + clearLine : [ 'ctrl + y' ], }; -exports.VIEW_SPECIAL_KEY_MAP_DEFAULT = VIEW_SPECIAL_KEY_MAP_DEFAULT; +exports.VIEW_SPECIAL_KEY_MAP_DEFAULT = VIEW_SPECIAL_KEY_MAP_DEFAULT; function View(options) { - events.EventEmitter.call(this); + events.EventEmitter.call(this); - enigAssert(_.isObject(options)); - enigAssert(_.isObject(options.client)); + enigAssert(_.isObject(options)); + enigAssert(_.isObject(options.client)); - var self = this; + this.client = options.client; + this.cursor = options.cursor || 'show'; + this.cursorStyle = options.cursorStyle || 'default'; - this.client = options.client; - - this.cursor = options.cursor || 'show'; - this.cursorStyle = options.cursorStyle || 'default'; + this.acceptsFocus = options.acceptsFocus || false; + this.acceptsInput = options.acceptsInput || false; + this.autoAdjustHeight = _.get(options, 'dimens.height') ? false : _.get(options, 'autoAdjustHeight', true); + this.position = { x : 0, y : 0 }; + this.textStyle = options.textStyle || 'normal'; + this.focusTextStyle = options.focusTextStyle || this.textStyle; - this.acceptsFocus = options.acceptsFocus || false; - this.acceptsInput = options.acceptsInput || false; + if(options.id) { + this.setId(options.id); + } - this.position = { x : 0, y : 0 }; - this.dimens = { height : 1, width : 0 }; + if(options.position) { + this.setPosition(options.position); + } - this.textStyle = options.textStyle || 'normal'; - this.focusTextStyle = options.focusTextStyle || this.textStyle; + if(options.dimens) { + this.setDimension(options.dimens); + } else { + this.dimens = { + width : options.width || 0, + height : 0 + }; + } - if(options.id) { - this.setId(options.id); - } + // :TODO: Just use styleSGRx for these, e.g. styleSGR0, styleSGR1 = norm/focus + this.ansiSGR = options.ansiSGR || ansi.getSGRFromGraphicRendition( { fg : 39, bg : 49 }, true); + this.ansiFocusSGR = options.ansiFocusSGR || this.ansiSGR; - if(options.position) { - this.setPosition(options.position); - } + this.styleSGR1 = options.styleSGR1 || this.ansiSGR; + this.styleSGR2 = options.styleSGR2 || this.ansiFocusSGR; - if(_.isObject(options.autoScale)) { - this.autoScale = options.autoScale; - } else { - this.autoScale = { height : true, width : true }; - } + if(this.acceptsInput) { + this.specialKeyMap = options.specialKeyMap || VIEW_SPECIAL_KEY_MAP_DEFAULT; - if(options.dimens) { - this.setDimension(options.dimens); - this.autoScale = { height : false, width : false }; - } else { - this.dimens = { - width : options.width || 0, - height : 0 - }; - } + if(_.isObject(options.specialKeyMapOverride)) { + this.setSpecialKeyMapOverride(options.specialKeyMapOverride); + } + } - // :TODO: Just use styleSGRx for these, e.g. styleSGR0, styleSGR1 = norm/focus - this.ansiSGR = options.ansiSGR || ansi.getSGRFromGraphicRendition( { fg : 39, bg : 49 }, true); - this.ansiFocusSGR = options.ansiFocusSGR || this.ansiSGR; + this.isKeyMapped = function(keySet, keyName) { + return _.has(this.specialKeyMap, keySet) && this.specialKeyMap[keySet].indexOf(keyName) > -1; + }; - this.styleSGR1 = options.styleSGR1 || this.ansiSGR; - this.styleSGR2 = options.styleSGR2 || this.ansiFocusSGR; + this.getANSIColor = function(color) { + var sgr = [ color.flags, color.fg ]; + if(color.bg !== color.flags) { + sgr.push(color.bg); + } + return ansi.sgr(sgr); + }; - if(this.acceptsInput) { - this.specialKeyMap = options.specialKeyMap || VIEW_SPECIAL_KEY_MAP_DEFAULT; - } + this.hideCusor = function() { + this.client.term.rawWrite(ansi.hideCursor()); + }; - this.isKeyMapped = function(keySet, keyName) { - return _.has(this.specialKeyMap, keySet) && this.specialKeyMap[keySet].indexOf(keyName) > -1; - }; + this.restoreCursor = function() { + //this.client.term.write(ansi.setCursorStyle(this.cursorStyle)); + this.client.term.rawWrite('show' === this.cursor ? ansi.showCursor() : ansi.hideCursor()); + }; - this.getANSIColor = function(color) { - var sgr = [ color.flags, color.fg ]; - if(color.bg !== color.flags) { - sgr.push(color.bg); - } - return ansi.sgr(sgr); - }; - - this.hideCusor = function() { - self.client.term.rawWrite(ansi.hideCursor()); - }; - - this.restoreCursor = function() { - //this.client.term.write(ansi.setCursorStyle(this.cursorStyle)); - this.client.term.rawWrite('show' === this.cursor ? ansi.showCursor() : ansi.hideCursor()); - }; + this.initDefaultWidth = function(width = 15) { + this.dimens.width = this.dimens.width || Math.min(width, this.client.term.termWidth - this.position.col); + }; } util.inherits(View, events.EventEmitter); View.prototype.setId = function(id) { - this.id = id; + this.id = id; }; View.prototype.getId = function() { - return this.id; + return this.id; }; View.prototype.setPosition = function(pos) { - // - // Allow the following forms: [row, col], { row : r, col : c }, or (row, col) - // - if(util.isArray(pos)) { - this.position.row = pos[0]; - this.position.col = pos[1]; - } else if(_.isNumber(pos.row) && _.isNumber(pos.col)) { - this.position.row = pos.row; - this.position.col = pos.col; - } else if(2 === arguments.length) { - this.position.row = parseInt(arguments[0], 10); - this.position.col = parseInt(arguments[1], 10); - } + // + // Allow the following forms: [row, col], { row : r, col : c }, or (row, col) + // + if(util.isArray(pos)) { + this.position.row = pos[0]; + this.position.col = pos[1]; + } else if(_.isNumber(pos.row) && _.isNumber(pos.col)) { + this.position.row = pos.row; + this.position.col = pos.col; + } else if(2 === arguments.length) { + this.position.row = parseInt(arguments[0], 10); + this.position.col = parseInt(arguments[1], 10); + } - // sanatize - this.position.row = Math.max(this.position.row, 1); - this.position.col = Math.max(this.position.col, 1); - this.position.row = Math.min(this.position.row, this.client.term.termHeight); - this.position.col = Math.min(this.position.col, this.client.term.termWidth); + // sanatize + this.position.row = Math.max(this.position.row, 1); + this.position.col = Math.max(this.position.col, 1); + this.position.row = Math.min(this.position.row, this.client.term.termHeight); + this.position.col = Math.min(this.position.col, this.client.term.termWidth); }; View.prototype.setDimension = function(dimens) { - enigAssert(_.isObject(dimens) && _.isNumber(dimens.height) && _.isNumber(dimens.width)); - - this.dimens = dimens; - this.autoScale = { height : false, width : false }; + enigAssert(_.isObject(dimens) && _.isNumber(dimens.height) && _.isNumber(dimens.width)); + this.dimens = dimens; + this.autoAdjustHeight = false; }; View.prototype.setHeight = function(height) { - height = parseInt(height) || 1; - height = Math.min(height, this.client.term.termHeight); + height = parseInt(height) || 1; + height = Math.min(height, this.client.term.termHeight); - this.dimens.height = height; - this.autoScale.height = false; + this.dimens.height = height; + this.autoAdjustHeight = false; }; View.prototype.setWidth = function(width) { - width = parseInt(width) || 1; - width = Math.min(width, this.client.term.termWidth); + width = parseInt(width) || 1; + width = Math.min(width, this.client.term.termWidth); - this.dimens.width = width; - this.autoScale.width = false; + this.dimens.width = width; }; View.prototype.getSGR = function() { - return this.ansiSGR; + return this.ansiSGR; }; View.prototype.getStyleSGR = function(n) { - n = parseInt(n) || 0; - return this['styleSGR' + n]; + n = parseInt(n) || 0; + return this['styleSGR' + n]; }; View.prototype.getFocusSGR = function() { - return this.ansiFocusSGR; + return this.ansiFocusSGR; +}; + +View.prototype.setSpecialKeyMapOverride = function(specialKeyMapOverride) { + this.specialKeyMap = Object.assign(this.specialKeyMap, specialKeyMapOverride); }; View.prototype.setPropertyValue = function(propName, value) { - switch(propName) { - case 'height' : this.setHeight(value); break; - case 'width' : this.setWidth(value); break; - case 'focus' : this.setFocus(value); break; - - case 'text' : - if('setText' in this) { - this.setText(value); - } - break; + switch(propName) { + case 'acceptsFocus' : + if (_.isBoolean(value)) { + this.acceptsFocus = value; + } + break; - case 'textStyle' : this.textStyle = value; break; - case 'focusTextStyle' : this.focusTextStyle = value; break; + case 'height' : this.setHeight(value); break; + case 'width' : this.setWidth(value); break; + case 'focus' : this.setFocus(value); break; - case 'justify' : this.justify = value; break; + case 'text' : + if('setText' in this) { + this.setText(value); + } + break; - case 'fillChar' : - if('fillChar' in this) { - if(_.isNumber(value)) { - this.fillChar = String.fromCharCode(value); - } else if(_.isString(value)) { - this.fillChar = value.substr(0, 1); - } - } - break; + case 'textStyle' : this.textStyle = value; break; + case 'focusTextStyle' : this.focusTextStyle = value; break; - case 'submit' : - if(_.isBoolean(value)) { - this.submit = value; - }/* else { - this.submit = _.isArray(value) && value.length > 0; - } - */ - break; + case 'justify' : this.justify = value; break; - case 'resizable' : - if(_.isBoolean(value)) { - this.resizable = value; - } - break; + case 'fillChar' : + if('fillChar' in this) { + if(_.isNumber(value)) { + this.fillChar = String.fromCharCode(value); + } else if(_.isString(value)) { + this.fillChar = renderSubstr(value, 0, 1); + } + } + break; - case 'argName' : this.submitArgName = value; break; + case 'submit' : + if(_.isBoolean(value)) { + this.submit = value; + }/* else { + this.submit = _.isArray(value) && value.length > 0; + } + */ + break; - case 'validate' : - if(_.isFunction(value)) { - this.validate = value; - } - break; - } + case 'resizable' : + if(_.isBoolean(value)) { + this.resizable = value; + } + break; - if(/styleSGR[0-9]{1,2}/.test(propName)) { - if(_.isObject(value)) { - this[propName] = ansi.getSGRFromGraphicRendition(value, true); - } else if(_.isString(value)) { - this[propName] = colorCodes.pipeToAnsi(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; + } + break; + } + + if(/styleSGR[0-9]{1,2}/.test(propName)) { + if(_.isObject(value)) { + this[propName] = ansi.getSGRFromGraphicRendition(value, true); + } else if(_.isString(value)) { + this[propName] = colorCodes.pipeToAnsi(value); + } + } }; View.prototype.redraw = function() { - this.client.term.write(ansi.goto(this.position.row, this.position.col)); + this.client.term.write(ansi.goto(this.position.row, this.position.col)); }; View.prototype.setFocus = function(focused) { - enigAssert(this.acceptsFocus, 'View does not accept focus'); + enigAssert(this.acceptsFocus, 'View does not accept focus'); - this.hasFocus = focused; - this.restoreCursor(); + this.hasFocus = focused; + this.restoreCursor(); }; -View.prototype.onKeyPress = function(ch, key) { - enigAssert(this.hasFocus, 'View does not have focus'); - enigAssert(this.acceptsInput, 'View does not accept input'); +View.prototype.onKeyPress = function(ch, key) { + enigAssert(this.hasFocus, 'View does not have focus'); + enigAssert(this.acceptsInput, 'View does not accept input'); - if(!this.hasFocus || !this.acceptsInput) { - return; - } + if(!this.hasFocus || !this.acceptsInput) { + return; + } - if(key) { - enigAssert(this.specialKeyMap, 'No special key map defined'); + if(key) { + enigAssert(this.specialKeyMap, 'No special key map defined'); - if(this.isKeyMapped('accept', key.name)) { - this.emit('action', 'accept', key); - } else if(this.isKeyMapped('next', key.name)) { - this.emit('action', 'next', key); - } - } + if(this.isKeyMapped('accept', key.name)) { + this.emit('action', 'accept', key); + } else if(this.isKeyMapped('next', key.name)) { + this.emit('action', 'next', key); + } + } - if(ch) { - enigAssert(1 === ch.length); - } + if(ch) { + enigAssert(1 === ch.length); + } - this.emit('key press', ch, key); + this.emit('key press', ch, key); }; View.prototype.getData = function() { diff --git a/core/view_controller.js b/core/view_controller.js index ecf36be5..f51c307f 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -1,864 +1,880 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory; -var menuUtil = require('./menu_util.js'); -var asset = require('./asset.js'); -var ansi = require('./ansi_term.js'); +// ENiGMA½ +var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory; +var menuUtil = require('./menu_util.js'); +var asset = require('./asset.js'); +var ansi = require('./ansi_term.js'); -// deps -var events = require('events'); -var util = require('util'); -var assert = require('assert'); -var async = require('async'); -var _ = require('lodash'); -var paths = require('path'); +// deps +var events = require('events'); +var util = require('util'); +var assert = require('assert'); +var async = require('async'); +var _ = require('lodash'); +var paths = require('path'); -exports.ViewController = ViewController; +exports.ViewController = ViewController; -var MCI_REGEXP = /([A-Z]{2})([0-9]{1,2})/; +var MCI_REGEXP = /([A-Z]{2})([0-9]{1,2})/; function ViewController(options) { - assert(_.isObject(options)); - assert(_.isObject(options.client)); - - events.EventEmitter.call(this); + assert(_.isObject(options)); + assert(_.isObject(options.client)); - var self = this; + events.EventEmitter.call(this); - this.client = options.client; - this.views = {}; // map of ID -> view - this.formId = options.formId || 0; - this.mciViewFactory = new MCIViewFactory(this.client); // :TODO: can this not be a singleton? - this.noInput = _.isBoolean(options.noInput) ? options.noInput : false; + var self = this; - this.actionKeyMap = {}; + this.client = options.client; + this.views = {}; // map of ID -> view + this.formId = options.formId || 0; + this.mciViewFactory = new MCIViewFactory(this.client); // :TODO: can this not be a singleton? + this.noInput = _.isBoolean(options.noInput) ? options.noInput : false; + this.actionKeyMap = {}; - // - // Small wrapper/proxy around handleAction() to ensure we do not allow - // input/additional actions queued while performing an action - // - this.handleActionWrapper = function(formData, actionBlock) { - if(self.waitActionCompletion) { - return; // ignore until this is finished! - } + // + // Small wrapper/proxy around handleAction() to ensure we do not allow + // input/additional actions queued while performing an action + // + this.handleActionWrapper = function(formData, actionBlock, cb) { + if(self.waitActionCompletion) { + if(cb) { + return cb(null); + } + return; // ignore until this is finished! + } - self.waitActionCompletion = true; - menuUtil.handleAction(self.client, formData, actionBlock, (err) => { - if(err) { - // :TODO: What can we really do here? - if('ALREADYTHERE' === err.reasonCode) { - self.client.log.trace( err.reason ); - } else { - self.client.log.warn( { err : err }, 'Error during handleAction()'); - } - } - - self.waitActionCompletion = false; - }); - }; + self.client.log.trace( { actionBlock }, 'Action match' ); - this.clientKeyPressHandler = function(ch, key) { - // - // Process key presses treating form submit mapped keys special. - // Everything else is forwarded on to the focused View, if any. - // - var actionForKey = key ? self.actionKeyMap[key.name] : self.actionKeyMap[ch]; - if(actionForKey) { - if(_.isNumber(actionForKey.viewId)) { - // - // Key works on behalf of a view -- switch focus & submit - // - self.switchFocus(actionForKey.viewId); - self.submitForm(key); - } else if(_.isString(actionForKey.action)) { - const formData = self.getFocusedView() ? self.getFormData() : { }; - self.handleActionWrapper( - Object.assign( { ch : ch, key : key }, formData ), // formData + key info - actionForKey); // actionBlock - } - } else { - if(self.focusedView && self.focusedView.acceptsInput) { - self.focusedView.onKeyPress(ch, key); - } - } - }; + self.waitActionCompletion = true; + menuUtil.handleAction(self.client, formData, actionBlock, (err) => { + if(err) { + // :TODO: What can we really do here? + if('ALREADYTHERE' === err.reasonCode) { + self.client.log.trace( err.reason ); + } else { + self.client.log.warn( { err : err }, 'Error during handleAction()'); + } + } - this.viewActionListener = function(action, key) { - switch(action) { - case 'next' : - self.emit('action', { view : this, action : action, key : key }); - self.nextFocus(); - break; + self.waitActionCompletion = false; + if(cb) { + return cb(null); + } + }); + }; - case 'accept' : - if(self.focusedView && self.focusedView.submit) { - // :TODO: need to do validation here!!! - var focusedView = self.focusedView; - self.validateView(focusedView, function validated(err, newFocusedViewId) { - if(err) { - var newFocusedView = self.getView(newFocusedViewId) || focusedView; - self.setViewFocusWithEvents(newFocusedView, true); - } else { - self.submitForm(key); - } - }); - //self.submitForm(key); - } else { - self.nextFocus(); - } - break; - } - }; + this.clientKeyPressHandler = function(ch, key) { + // + // Process key presses treating form submit mapped keys special. + // Everything else is forwarded on to the focused View, if any. + // + var actionForKey = key ? self.actionKeyMap[key.name] : self.actionKeyMap[ch]; + if(actionForKey) { + if(_.isNumber(actionForKey.viewId)) { + // + // Key works on behalf of a view -- switch focus & submit + // + self.switchFocus(actionForKey.viewId); + self.submitForm(key); + } else if(_.isString(actionForKey.action)) { + const formData = self.getFocusedView() ? self.getFormData() : { }; + self.handleActionWrapper( + Object.assign( { ch : ch, key : key }, formData ), // formData + key info + actionForKey); // actionBlock + } + } else { + if(self.focusedView && self.focusedView.acceptsInput) { + self.focusedView.onKeyPress(ch, key); + } + } + }; - this.submitForm = function(key) { - self.emit('submit', this.getFormData(key)); - }; + this.viewActionListener = function(action, key) { + switch(action) { + case 'next' : + self.emit('action', { view : this, action : action, key : key }); + self.nextFocus(); + break; - // :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 = '*****'; - } - if(safeFormData.value.passwordConfirm) { - safeFormData.value.passwordConfirm = '*****'; - } - return safeFormData; - }; + case 'accept' : + if(self.focusedView && self.focusedView.submit) { + // :TODO: need to do validation here!!! + var focusedView = self.focusedView; + self.validateView(focusedView, function validated(err, newFocusedViewId) { + if(err) { + var newFocusedView = self.getView(newFocusedViewId) || focusedView; + self.setViewFocusWithEvents(newFocusedView, true); + } else { + self.submitForm(key); + } + }); + //self.submitForm(key); + } else { + self.nextFocus(); + } + break; + } + }; - this.switchFocusEvent = function(event, view) { - if(self.emitSwitchFocus) { - return; - } + this.submitForm = function(key) { + self.emit('submit', this.getFormData(key)); + }; - self.emitSwitchFocus = true; - self.emit(event, view); - self.emitSwitchFocus = false; - }; + this.getLogFriendlyFormData = function(formData) { + var safeFormData = _.cloneDeep(formData); + if(safeFormData.value.password) { + safeFormData.value.password = '*****'; + } + if(safeFormData.value.passwordConfirm) { + safeFormData.value.passwordConfirm = '*****'; + } + return safeFormData; + }; - this.createViewsFromMCI = function(mciMap, cb) { - async.each(Object.keys(mciMap), function entry(name, nextItem) { - var mci = mciMap[name]; - var view = self.mciViewFactory.createFromMCI(mci); + this.switchFocusEvent = function(event, view) { + if(self.emitSwitchFocus) { + return; + } - if(view) { - if(false === self.noInput) { - view.on('action', self.viewActionListener); - } + self.emitSwitchFocus = true; + self.emit(event, view); + self.emitSwitchFocus = false; + }; - self.addView(view); - } + this.createViewsFromMCI = function(mciMap, cb) { + async.each(Object.keys(mciMap), (name, nextItem) => { + const mci = mciMap[name]; + const view = self.mciViewFactory.createFromMCI(mci); - nextItem(null); - }, - function complete(err) { - self.setViewOrder(); - cb(err); - }); - }; + if(view) { + if(false === self.noInput) { + view.on('action', self.viewActionListener); + } - // :TODO: move this elsewhere - this.setViewPropertiesFromMCIConf = function(view, conf) { + self.addView(view); + } - var propAsset; - var propValue; + return nextItem(null); + }, + err => { + self.setViewOrder(); + return cb(err); + }); + }; - function callModuleMethod(path) { - if('' === paths.extname(path)) { - path += '.js'; - } + // :TODO: move this elsewhere + this.setViewPropertiesFromMCIConf = function(view, conf) { - try { - var methodMod = require(path); - // :TODO: fix formData & extraArgs - return methodMod[propAsset.asset](self.client.currentMenuModule, {}, {} ); - } catch(e) { - self.client.log.error( { error : e.toString(), methodName : propAsset.asset }, 'Failed to execute asset method'); - } - } + var propAsset; + var propValue; - for(var propName in conf) { - propAsset = asset.getViewPropertyAsset(conf[propName]); - if(propAsset) { - switch(propAsset.type) { - case 'config' : - propValue = asset.resolveConfigAsset(conf[propName]); - break; - - case 'sysStat' : - propValue = asset.resolveSystemStatAsset(conf[propName]); - break; + for(var propName in conf) { + propAsset = asset.getViewPropertyAsset(conf[propName]); + if(propAsset) { + switch(propAsset.type) { + case 'config' : + propValue = asset.resolveConfigAsset(conf[propName]); + break; - // :TODO: handle @art (e.g. text : @art ...) + case 'sysStat' : + propValue = asset.resolveSystemStatAsset(conf[propName]); + break; - case 'method' : - case 'systemMethod' : - if('validate' === propName) { - // :TODO: handle propAsset.location for @method script specification - if('systemMethod' === propAsset.type) { - // :TODO: implementation validation @systemMethod handling! - var methodModule = require(paths.join(__dirname, 'system_view_validate.js')); - if(_.isFunction(methodModule[propAsset.asset])) { - propValue = methodModule[propAsset.asset]; - } - } else { - if(_.isFunction(self.client.currentMenuModule.menuMethods[propAsset.asset])) { - propValue = self.client.currentMenuModule.menuMethods[propAsset.asset]; - } - } - } else { - if(_.isString(propAsset.location)) { + // :TODO: handle @art (e.g. text : @art ...) - } else { - if('systemMethod' === propAsset.type) { - // :TODO: - } else { - // local to current module - var currentModule = self.client.currentMenuModule; - if(_.isFunction(currentModule.menuMethods[propAsset.asset])) { - // :TODO: Fix formData & extraArgs... this all needs general processing - propValue = currentModule.menuMethods[propAsset.asset]({}, {});//formData, conf.extraArgs); - } - } - } - } - break; + case 'method' : + case 'systemMethod' : + if('validate' === propName) { + // :TODO: handle propAsset.location for @method script specification + if('systemMethod' === propAsset.type) { + // :TODO: implementation validation @systemMethod handling! + var methodModule = require(paths.join(__dirname, 'system_view_validate.js')); + if(_.isFunction(methodModule[propAsset.asset])) { + propValue = methodModule[propAsset.asset]; + } + } else { + if(_.isFunction(self.client.currentMenuModule.menuMethods[propAsset.asset])) { + propValue = self.client.currentMenuModule.menuMethods[propAsset.asset]; + } + } + } else { + if(_.isString(propAsset.location)) { + // :TODO: clean this code up! + } else { + if('systemMethod' === propAsset.type) { + // :TODO: + } else { + // local to current module + var currentModule = self.client.currentMenuModule; + if(_.isFunction(currentModule.menuMethods[propAsset.asset])) { + // :TODO: Fix formData & extraArgs... this all needs general processing + propValue = currentModule.menuMethods[propAsset.asset]({}, {});//formData, conf.extraArgs); + } + } + } + } + break; - default : - propValue = propValue = conf[propName]; - break; - } - } else { - propValue = conf[propName]; - } + default : + propValue = conf[propName]; + break; + } + } else { + propValue = conf[propName]; + } - if(!_.isUndefined(propValue)) { - view.setPropertyValue(propName, propValue); - } - } - }; + if(!_.isUndefined(propValue)) { + view.setPropertyValue(propName, propValue); + } + } + }; - this.applyViewConfig = function(config, cb) { - var highestId = 1; - var submitId; - var initialFocusId = 1; + this.applyViewConfig = function(config, cb) { + let highestId = 1; + let submitId; + let initialFocusId = 1; - async.each(Object.keys(config.mci || {}), function entry(mci, nextItem) { - var mciMatch = mci.match(MCI_REGEXP); // :TODO: How to handle auto-generated IDs???? - if(null === mciMatch) { - self.client.log.warn( { mci : mci }, 'Unable to parse MCI code'); - return; - } + async.each(Object.keys(config.mci || {}), function entry(mci, nextItem) { + const mciMatch = mci.match(MCI_REGEXP); // :TODO: How to handle auto-generated IDs???? + if(null === mciMatch) { + self.client.log.warn( { mci : mci }, 'Unable to parse MCI code'); + return; + } - var viewId = parseInt(mciMatch[2]); - assert(!isNaN(viewId), 'Cannot parse view ID: ' + mciMatch[2]); // shouldn't be possible with RegExp used + const viewId = parseInt(mciMatch[2]); + assert(!isNaN(viewId), 'Cannot parse view ID: ' + mciMatch[2]); // shouldn't be possible with RegExp used - if(viewId > highestId) { - highestId = viewId; - } + if(viewId > highestId) { + highestId = viewId; + } - var view = self.getView(viewId); - - if(!view) { - self.client.log.warn( { viewId : viewId }, 'Cannot find view'); - nextItem(null); - return; - } + const view = self.getView(viewId); - var mciConf = config.mci[mci]; + if(!view) { + self.client.log.warn( { viewId : viewId }, 'Cannot find view'); + nextItem(null); + return; + } - self.setViewPropertiesFromMCIConf(view, mciConf); + const mciConf = config.mci[mci]; - if(mciConf.focus) { - initialFocusId = viewId; - } + self.setViewPropertiesFromMCIConf(view, mciConf); - nextItem(null); - }, - function complete(err) { - // default to highest ID if no 'submit' entry present - if(!submitId) { - var highestIdView = self.getView(highestId); - if(highestIdView) { - highestIdView.submit = true; - } else { - self.client.log.warn( { highestId : highestId }, 'View does not exist'); - } - } + if(mciConf.focus) { + initialFocusId = viewId; + } - cb(err, { initialFocusId : initialFocusId } ); - }); - }; + if(true === view.submit) { + submitId = viewId; + } - // method for comparing submitted form data to configuration entries - this.actionBlockValueComparator = function(formValue, actionValue) { - // - // For a match to occur, one of the following must be true: - // - // * actionValue is a Object: - // a) All key/values must exactly match - // b) value is null; The key (view ID or "argName") must be present - // in formValue. This is a wildcard/any match. - // * actionValue is a Number: This represents a view ID that - // must be present in formValue. - // * actionValue is a string: This represents a view with - // "argName" set that must be present in formValue. - // - if(_.isUndefined(actionValue)) { - return false; - } - - if(_.isNumber(actionValue) || _.isString(actionValue)) { - if(_.isUndefined(formValue[actionValue])) { - return false; - } - } else { - /* - :TODO: support: - value: { - someArgName: [ "key1", "key2", ... ], - someOtherArg: [ "key1, ... ] - } - */ - var actionValueKeys = Object.keys(actionValue); - for(var i = 0; i < actionValueKeys.length; ++i) { - var viewId = actionValueKeys[i]; - if(!_.has(formValue, viewId)) { - return false; - } + nextItem(null); + }, + err => { + // default to highest ID if no 'submit' entry present + if(!submitId) { + var highestIdView = self.getView(highestId); + if(highestIdView) { + highestIdView.submit = true; + } else { + self.client.log.warn( { highestId : highestId }, 'View does not exist'); + } + } - if(null !== actionValue[viewId] && actionValue[viewId] !== formValue[viewId]) { - return false; - } - } - } + return cb(err, { initialFocusId : initialFocusId } ); + }); + }; - self.client.log.trace( - { - formValue : formValue, - actionValue : actionValue - }, - 'Action match' - ); + // method for comparing submitted form data to configuration entries + this.actionBlockValueComparator = function(formValue, actionValue) { + // + // For a match to occur, one of the following must be true: + // + // * actionValue is a Object: + // a) All key/values must exactly match + // b) value is null; The key (view ID or "argName") must be present + // in formValue. This is a wildcard/any match. + // * actionValue is a Number: This represents a view ID that + // must be present in formValue. + // * actionValue is a string: This represents a view with + // "argName" set that must be present in formValue. + // + if(_.isUndefined(actionValue)) { + return false; + } - return true; - }; + if(_.isNumber(actionValue) || _.isString(actionValue)) { + if(_.isUndefined(formValue[actionValue])) { + return false; + } + } else { + /* + :TODO: support: + value: { + someArgName: [ "key1", "key2", ... ], + someOtherArg: [ "key1, ... ] + } + */ + var actionValueKeys = Object.keys(actionValue); + for(var i = 0; i < actionValueKeys.length; ++i) { + var viewId = actionValueKeys[i]; + if(!_.has(formValue, viewId)) { + return false; + } - if(!options.detached) { - this.attachClientEvents(); - } + if(null !== actionValue[viewId] && actionValue[viewId] !== formValue[viewId]) { + return false; + } + } + } + return true; + }; - this.setViewFocusWithEvents = function(view, focused) { - if(!view || !view.acceptsFocus) { - return; - } + if(!options.detached) { + this.attachClientEvents(); + } - if(focused) { - self.switchFocusEvent('return', view); - self.focusedView = view; - } else { - self.switchFocusEvent('leave', view); - } + this.setViewFocusWithEvents = function(view, focused) { + if(!view || !view.acceptsFocus) { + return; + } - view.setFocus(focused); - }; + if(focused) { + self.switchFocusEvent('return', view); + self.focusedView = view; + } else { + self.switchFocusEvent('leave', view); + } - this.validateView = function(view, cb) { - if(view && _.isFunction(view.validate)) { - view.validate(view.getData(), function validateResult(err) { - var viewValidationListener = self.client.currentMenuModule.menuMethods.viewValidationListener; - if(_.isFunction(viewValidationListener)) { - if(err) { - err.view = view; // pass along the view that failed - } + view.setFocus(focused); + }; - viewValidationListener(err, function validationComplete(newViewFocusId) { - cb(err, newViewFocusId); - }); - } else { - cb(err); - } - }); - } else { - cb(null); - } - }; + this.validateView = function(view, cb) { + if(view && _.isFunction(view.validate)) { + view.validate(view.getData(), function validateResult(err) { + var viewValidationListener = self.client.currentMenuModule.menuMethods.viewValidationListener; + if(_.isFunction(viewValidationListener)) { + if(err) { + err.view = view; // pass along the view that failed + } + + viewValidationListener(err, function validationComplete(newViewFocusId) { + cb(err, newViewFocusId); + }); + } else { + cb(err); + } + }); + } else { + cb(null); + } + }; } util.inherits(ViewController, events.EventEmitter); ViewController.prototype.attachClientEvents = function() { - if(this.attached) { - return; - } + if(this.attached) { + return; + } - var self = this; + var self = this; - this.client.on('key press', this.clientKeyPressHandler); + this.client.on('key press', this.clientKeyPressHandler); - Object.keys(this.views).forEach(function vid(i) { - // remove, then add to ensure we only have one listener - self.views[i].removeListener('action', self.viewActionListener); - self.views[i].on('action', self.viewActionListener); - }); + Object.keys(this.views).forEach(function vid(i) { + // remove, then add to ensure we only have one listener + self.views[i].removeListener('action', self.viewActionListener); + self.views[i].on('action', self.viewActionListener); + }); - this.attached = true; + this.attached = true; }; ViewController.prototype.detachClientEvents = function() { - if(!this.attached) { - return; - } - - this.client.removeListener('key press', this.clientKeyPressHandler); + if(!this.attached) { + return; + } - for(var id in this.views) { - this.views[id].removeAllListeners(); - } + this.client.removeListener('key press', this.clientKeyPressHandler); - this.attached = false; + for(var id in this.views) { + this.views[id].removeAllListeners(); + } + + this.attached = false; }; ViewController.prototype.viewExists = function(id) { - return id in this.views; + return id in this.views; }; ViewController.prototype.addView = function(view) { - assert(!this.viewExists(view.id), 'View with ID ' + view.id + ' already exists'); + assert(!this.viewExists(view.id), 'View with ID ' + view.id + ' already exists'); - this.views[view.id] = view; + this.views[view.id] = view; }; ViewController.prototype.getView = function(id) { - return this.views[id]; + return this.views[id]; +}; + +ViewController.prototype.hasView = function(id) { + return this.getView(id) ? true : false; +}; + +ViewController.prototype.getViewsByMciCode = function(mciCode) { + if(!Array.isArray(mciCode)) { + mciCode = [ mciCode ]; + } + + const views = []; + _.each(this.views, v => { + if(mciCode.includes(v.mciCode)) { + views.push(v); + } + }); + return views; }; ViewController.prototype.getFocusedView = function() { - return this.focusedView; + return this.focusedView; }; ViewController.prototype.setFocus = function(focused) { - if(focused) { - this.attachClientEvents(); - } else { - this.detachClientEvents(); - } + if(focused) { + this.attachClientEvents(); + } else { + this.detachClientEvents(); + } - this.setViewFocusWithEvents(this.focusedView, focused); + this.setViewFocusWithEvents(this.focusedView, focused); }; ViewController.prototype.resetInitialFocus = function() { - if(this.formInitialFocusId) { - return this.switchFocus(this.formInitialFocusId); - } + if(this.formInitialFocusId) { + return this.switchFocus(this.formInitialFocusId); + } }; ViewController.prototype.switchFocus = function(id) { - // - // Perform focus switching validation now - // - var self = this; - var focusedView = self.focusedView; + // + // Perform focus switching validation now + // + var self = this; + var focusedView = self.focusedView; - self.validateView(focusedView, function validated(err, newFocusedViewId) { - if(err) { - var newFocusedView = self.getView(newFocusedViewId) || focusedView; - self.setViewFocusWithEvents(newFocusedView, true); - } else { - self.attachClientEvents(); + self.validateView(focusedView, function validated(err, newFocusedViewId) { + if(err) { + var newFocusedView = self.getView(newFocusedViewId) || focusedView; + self.setViewFocusWithEvents(newFocusedView, true); + } else { + self.attachClientEvents(); - // remove from old - self.setViewFocusWithEvents(focusedView, false); + // remove from old + self.setViewFocusWithEvents(focusedView, false); - // set to new - self.setViewFocusWithEvents(self.getView(id), true); - } - }); + // set to new + self.setViewFocusWithEvents(self.getView(id), true); + } + }); }; ViewController.prototype.nextFocus = function() { - let nextFocusView = this.focusedView ? this.focusedView : this.views[this.firstId]; + let nextFocusView = this.focusedView ? this.focusedView : this.views[this.firstId]; - // find the next view that accepts focus - while(nextFocusView && nextFocusView.nextId) { - nextFocusView = this.getView(nextFocusView.nextId); - if(!nextFocusView || nextFocusView.acceptsFocus) { - break; - } - } + // find the next view that accepts focus + while(nextFocusView && nextFocusView.nextId) { + nextFocusView = this.getView(nextFocusView.nextId); + if(!nextFocusView || nextFocusView.acceptsFocus) { + break; + } + } - if(nextFocusView && this.focusedView !== nextFocusView) { - this.switchFocus(nextFocusView.id); - } + if(nextFocusView && this.focusedView !== nextFocusView) { + this.switchFocus(nextFocusView.id); + } }; ViewController.prototype.setViewOrder = function(order) { - var viewIdOrder = order || []; + var viewIdOrder = order || []; - if(0 === viewIdOrder.length) { - for(var id in this.views) { - if(this.views[id].acceptsFocus) { - viewIdOrder.push(id); - } - } + if(0 === viewIdOrder.length) { + for(var id in this.views) { + if(this.views[id].acceptsFocus) { + viewIdOrder.push(id); + } + } - viewIdOrder.sort(function intSort(a, b) { - return a - b; - }); - } + viewIdOrder.sort(function intSort(a, b) { + return a - b; + }); + } - if(viewIdOrder.length > 0) { - var count = viewIdOrder.length - 1; - for(var i = 0; i < count; ++i) { - this.views[viewIdOrder[i]].nextId = viewIdOrder[i + 1]; - } + if(viewIdOrder.length > 0) { + var count = viewIdOrder.length - 1; + for(var i = 0; i < count; ++i) { + this.views[viewIdOrder[i]].nextId = viewIdOrder[i + 1]; + } - this.firstId = viewIdOrder[0]; - var lastId = viewIdOrder.length > 1 ? viewIdOrder[viewIdOrder.length - 1] : this.firstId; - this.views[lastId].nextId = this.firstId; - } + this.firstId = viewIdOrder[0]; + var lastId = viewIdOrder.length > 1 ? viewIdOrder[viewIdOrder.length - 1] : this.firstId; + this.views[lastId].nextId = this.firstId; + } }; ViewController.prototype.redrawAll = function(initialFocusId) { - this.client.term.rawWrite(ansi.hideCursor()); - - for(var id in this.views) { - if(initialFocusId === id) { - continue; // will draw @ focus - } - this.views[id].redraw(); - } + this.client.term.rawWrite(ansi.hideCursor()); - this.client.term.rawWrite(ansi.showCursor()); + for(var id in this.views) { + if(initialFocusId === id) { + continue; // will draw @ focus + } + this.views[id].redraw(); + } + + this.client.term.rawWrite(ansi.showCursor()); }; ViewController.prototype.loadFromPromptConfig = function(options, cb) { - assert(_.isObject(options)); - assert(_.isObject(options.mciMap)); - - var self = this; - var promptConfig = _.isObject(options.config) ? options.config : self.client.currentMenuModule.menuConfig.promptConfig; - var initialFocusId = 1; // default to first + assert(_.isObject(options)); + assert(_.isObject(options.mciMap)); - async.waterfall( - [ - function createViewsFromMCI(callback) { - self.createViewsFromMCI(options.mciMap, function viewsCreated(err) { - callback(err); - }); - }, - function applyViewConfiguration(callback) { - if(_.isObject(promptConfig.mci)) { - self.applyViewConfig(promptConfig, function configApplied(err, info) { - initialFocusId = info.initialFocusId; - callback(err); - }); - } else { - callback(null); - } - }, - function prepareFormSubmission(callback) { - if(false === self.noInput) { + var self = this; + var promptConfig = _.isObject(options.config) ? options.config : self.client.currentMenuModule.menuConfig.promptConfig; + var initialFocusId = 1; // default to first - self.on('submit', function promptSubmit(formData) { - self.client.log.trace( { formData : self.getLogFriendlyFormData(formData) }, 'Prompt submit'); + async.waterfall( + [ + function createViewsFromMCI(callback) { + self.createViewsFromMCI(options.mciMap, function viewsCreated(err) { + callback(err); + }); + }, + function applyViewConfiguration(callback) { + if(_.isObject(promptConfig.mci)) { + self.applyViewConfig(promptConfig, function configApplied(err, info) { + initialFocusId = info.initialFocusId; + callback(err); + }); + } else { + callback(null); + } + }, + function prepareFormSubmission(callback) { + if(false === self.noInput) { - if(_.isString(self.client.currentMenuModule.menuConfig.action)) { - self.handleActionWrapper(formData, self.client.currentMenuModule.menuConfig); - } else { - // - // Menus that reference prompts can have a sepcial "submit" block without the - // hassle of by-form-id configurations, etc. - // - // "submit" : [ - // { ... } - // ] - // - var menuSubmit = self.client.currentMenuModule.menuConfig.submit; - if(!_.isArray(menuSubmit)) { - self.client.log.debug('No configuration to handle submit'); - return; - } + self.on('submit', function promptSubmit(formData) { + self.client.log.trace( { formData }, 'Prompt submit'); - // - // Locate matching action block - // - // :TODO: this is basically the same as for menus -- DRY it up! - for(var c = 0; c < menuSubmit.length; ++c) { - var actionBlock = menuSubmit[c]; + const doSubmitNotify = () => { + if(options.submitNotify) { + options.submitNotify(); + } + }; - if(_.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)) { - self.handleActionWrapper(formData, actionBlock); - break; // there an only be one... - } - } - } - }); - } + const handleIt = (fd, conf) => { + self.handleActionWrapper(fd, conf, () => { + doSubmitNotify(); + }); + }; - callback(null); - }, - function loadActionKeys(callback) { - if(!_.isObject(promptConfig) || !_.isArray(promptConfig.actionKeys)) { - return callback(null); - } + if(_.isString(self.client.currentMenuModule.menuConfig.action)) { + handleIt(formData, self.client.currentMenuModule.menuConfig); + } else { + // + // Menus that reference prompts can have a special "submit" block without the + // hassle of by-form-id configurations, etc. + // + // "submit" : [ + // { ... } + // ] + // + const menuConfig = self.client.currentMenuModule.menuConfig; + let submitConf; + if(Array.isArray(menuConfig.submit)) { // standalone prompts)) { + submitConf = menuConfig.submit; + } else { + // look for embedded prompt configurations - using their own form ID within the menu + submitConf = + _.get(menuConfig, [ 'form', formData.id, 'submit', formData.submitId ]) || + _.get(menuConfig, [ 'form', formData.id, 'submit', '*' ]); + } - promptConfig.actionKeys.forEach(ak => { - // - // * 'keys' must be present and be an array of key names - // * If 'viewId' is present, key(s) will focus & submit on behalf - // of the specified view. - // * If 'action' is present, that action will be procesed when - // triggered by key(s) - // - // Ultimately, create a map of key -> { action block } - // - if(!_.isArray(ak.keys)) { - return; - } + if(!Array.isArray(submitConf)) { + doSubmitNotify(); + return self.client.log.debug('No configuration to handle submit'); + } - ak.keys.forEach(kn => { - self.actionKeyMap[kn] = ak; - }); + // locate any matching action block + const actionBlock = submitConf.find(actionBlock => _.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)); + if(actionBlock) { + handleIt(formData, actionBlock); + } else { + doSubmitNotify(); + } + } + }); + } - }); + callback(null); + }, + function loadActionKeys(callback) { + if(!_.isObject(promptConfig) || !_.isArray(promptConfig.actionKeys)) { + return callback(null); + } - return callback(null); - }, - function drawAllViews(callback) { - self.redrawAll(initialFocusId); - callback(null); - }, - function setInitialViewFocus(callback) { - if(initialFocusId) { - self.switchFocus(initialFocusId); - } - callback(null); - } - ], - function complete(err) { - cb(err); - } - ); + promptConfig.actionKeys.forEach(ak => { + // + // * 'keys' must be present and be an array of key names + // * If 'viewId' is present, key(s) will focus & submit on behalf + // of the specified view. + // * If 'action' is present, that action will be procesed when + // triggered by key(s) + // + // Ultimately, create a map of key -> { action block } + // + if(!_.isArray(ak.keys)) { + return; + } + + ak.keys.forEach(kn => { + self.actionKeyMap[kn] = ak; + }); + + }); + + return callback(null); + }, + function drawAllViews(callback) { + self.redrawAll(initialFocusId); + callback(null); + }, + function setInitialViewFocus(callback) { + if(initialFocusId) { + self.switchFocus(initialFocusId); + } + callback(null); + } + ], + function complete(err) { + cb(err); + } + ); }; ViewController.prototype.loadFromMenuConfig = function(options, cb) { - assert(_.isObject(options)); + assert(_.isObject(options)); - if(!_.isObject(options.mciMap)) { - cb(new Error('Missing option: mciMap')); - return; - } + if(!_.isObject(options.mciMap)) { + cb(new Error('Missing option: mciMap')); + return; + } - var self = this; - var formIdKey = options.formId ? options.formId.toString() : '0'; - this.formInitialFocusId = 1; // default to first - var formConfig; + var self = this; + var formIdKey = options.formId ? options.formId.toString() : '0'; + this.formInitialFocusId = 1; // default to first + var formConfig; - // :TODO: honor options.withoutForm + // :TODO: honor options.withoutForm - async.waterfall( - [ - function findMatchingFormConfig(callback) { - menuUtil.getFormConfigByIDAndMap(self.client.currentMenuModule.menuConfig, formIdKey, options.mciMap, function matchingConfig(err, fc) { - formConfig = fc; + async.waterfall( + [ + function findMatchingFormConfig(callback) { + menuUtil.getFormConfigByIDAndMap(self.client.currentMenuModule.menuConfig, formIdKey, options.mciMap, function matchingConfig(err, fc) { + formConfig = fc; - if(err) { - // non-fatal - self.client.log.trace( - { reason : err.message, mci : Object.keys(options.mciMap), formId : formIdKey }, - 'Unable to find matching form configuration'); - } + if(err) { + // non-fatal + self.client.log.trace( + { reason : err.message, mci : Object.keys(options.mciMap), formId : formIdKey }, + 'Unable to find matching form configuration'); + } - callback(null); - }); - }, - function createViews(callback) { - self.createViewsFromMCI(options.mciMap, function viewsCreated(err) { - callback(err); - }); - }, + callback(null); + }); + }, + function createViews(callback) { + self.createViewsFromMCI(options.mciMap, function viewsCreated(err) { + callback(err); + }); + }, /* - function applyThemeCustomization(callback) { - formConfig = formConfig || {}; - formConfig.mci = formConfig.mci || {}; - //self.client.currentMenuModule.menuConfig.config = self.client.currentMenuModule.menuConfig.config || {}; + function applyThemeCustomization(callback) { + formConfig = formConfig || {}; + formConfig.mci = formConfig.mci || {}; + //self.client.currentMenuModule.menuConfig.config = self.client.currentMenuModule.menuConfig.config || {}; - //console.log('menu config.....'); - //console.log(self.client.currentMenuModule.menuConfig) + //console.log('menu config.....'); + //console.log(self.client.currentMenuModule.menuConfig) - menuUtil.applyMciThemeCustomization({ - name : self.client.currentMenuModule.menuName, - type : 'menus', - client : self.client, - mci : formConfig.mci, - //config : self.client.currentMenuModule.menuConfig.config, - formId : formIdKey, - }); + menuUtil.applyMciThemeCustomization({ + name : self.client.currentMenuModule.menuName, + type : 'menus', + client : self.client, + mci : formConfig.mci, + //config : self.client.currentMenuModule.menuConfig.config, + formId : formIdKey, + }); - //console.log('after theme...') - //console.log(self.client.currentMenuModule.menuConfig.config) - - callback(null); - }, + //console.log('after theme...') + //console.log(self.client.currentMenuModule.menuConfig.config) + + callback(null); + }, */ - function applyViewConfiguration(callback) { - if(_.isObject(formConfig)) { - self.applyViewConfig(formConfig, function configApplied(err, info) { - self.formInitialFocusId = info.initialFocusId; - callback(err); - }); - } else { - callback(null); - } - }, - function prepareFormSubmission(callback) { - if(!_.isObject(formConfig) || !_.isObject(formConfig.submit)) { - callback(null); - return; - } + function applyViewConfiguration(callback) { + if(_.isObject(formConfig)) { + self.applyViewConfig(formConfig, function configApplied(err, info) { + self.formInitialFocusId = info.initialFocusId; + callback(err); + }); + } else { + callback(null); + } + }, + function prepareFormSubmission(callback) { + if(!_.isObject(formConfig) || !_.isObject(formConfig.submit)) { + callback(null); + return; + } - self.on('submit', function formSubmit(formData) { + self.on('submit', function formSubmit(formData) { + self.client.log.trace( { formData }, 'Form submit'); - self.client.log.trace( { formData : self.getLogFriendlyFormData(formData) }, 'Form submit'); + // + // Locate configuration for this form ID + // + const confForFormId = + _.get(formConfig, [ 'submit', formData.submitId ]) || + _.get(formConfig, [ 'submit', '*' ]); - // - // Locate configuration for this form ID - // - var confForFormId; - if(_.isObject(formConfig.submit[formData.submitId])) { - confForFormId = formConfig.submit[formData.submitId]; - } else if(_.isObject(formConfig.submit['*'])) { - confForFormId = formConfig.submit['*']; - } else { - // no configuration for this submitId - self.client.log.debug( { formId : formData.submitId }, 'No configuration for form ID'); - return; - } + if(!Array.isArray(confForFormId)) { + return self.client.log.debug( { formId : formData.submitId }, 'No configuration for form ID'); + } - // - // Locate a matching action block based on the submitted data - // - for(var c = 0; c < confForFormId.length; ++c) { - var actionBlock = confForFormId[c]; + // locate a matching action block, if any + const actionBlock = confForFormId.find(actionBlock => _.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)); + if(actionBlock) { + self.handleActionWrapper(formData, actionBlock); + } + }); - if(_.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)) { - self.handleActionWrapper(formData, actionBlock); - break; // there an only be one... - } - } - }); + callback(null); + }, + function loadActionKeys(callback) { + if(!_.isObject(formConfig) || !_.isArray(formConfig.actionKeys)) { + callback(null); + return; + } - callback(null); - }, - function loadActionKeys(callback) { - if(!_.isObject(formConfig) || !_.isArray(formConfig.actionKeys)) { - callback(null); - return; - } + formConfig.actionKeys.forEach(function akEntry(ak) { + // + // * 'keys' must be present and be an array of key names + // * If 'viewId' is present, key(s) will focus & submit on behalf + // of the specified view. + // * If 'action' is present, that action will be procesed when + // triggered by key(s) + // + // Ultimately, create a map of key -> { action block } + // + if(!_.isArray(ak.keys)) { + return; + } - formConfig.actionKeys.forEach(function akEntry(ak) { - // - // * 'keys' must be present and be an array of key names - // * If 'viewId' is present, key(s) will focus & submit on behalf - // of the specified view. - // * If 'action' is present, that action will be procesed when - // triggered by key(s) - // - // Ultimately, create a map of key -> { action block } - // - if(!_.isArray(ak.keys)) { - return; - } + ak.keys.forEach(function actionKeyName(kn) { + self.actionKeyMap[kn] = ak; + }); - ak.keys.forEach(function actionKeyName(kn) { - self.actionKeyMap[kn] = ak; - }); + }); - }); - - callback(null); - }, - function drawAllViews(callback) { - self.redrawAll(self.formInitialFocusId); - callback(null); - }, - function setInitialViewFocus(callback) { - if(self.formInitialFocusId) { - self.switchFocus(self.formInitialFocusId); - } - callback(null); - } - ], - function complete(err) { - if(_.isFunction(cb)) { - cb(err); - } - } - ); + callback(null); + }, + function drawAllViews(callback) { + self.redrawAll(self.formInitialFocusId); + callback(null); + }, + function setInitialViewFocus(callback) { + if(self.formInitialFocusId) { + self.switchFocus(self.formInitialFocusId); + } + callback(null); + } + ], + function complete(err) { + if(_.isFunction(cb)) { + cb(err); + } + } + ); }; ViewController.prototype.formatMCIString = function(format) { - var self = this; - var view; + var self = this; + var view; - return format.replace(/{(\d+)}/g, function replacer(match, number) { - view = self.getView(number); - - if(!view) { - return match; - } + return format.replace(/{(\d+)}/g, function replacer(match, number) { + view = self.getView(number); - return view.getData(); - }); + if(!view) { + return match; + } + + return view.getData(); + }); }; ViewController.prototype.getFormData = function(key) { - /* - Example form data: - { - id : 0, - submitId : 1, - value : { - "1" : "hurp", - "2" : [ 'a', 'b', ... ], - "3" 2, - "pants" : "no way" - } + /* + Example form data: + { + id : 0, + submitId : 1, + value : { + "1" : "hurp", + "2" : [ 'a', 'b', ... ], + "3" 2, + "pants" : "no way" + } - } - */ - const formData = { - id : this.formId, - submitId : this.focusedView.id, - value : {}, - }; + } + */ + const formData = { + id : this.formId, + submitId : this.focusedView.id, + value : {}, + }; - if(key) { - formData.key = key; - } + if(key) { + formData.key = key; + } - let viewData; - _.each(this.views, view => { - try { - // don't fill forms with static, non user-editable data data - if(!view.acceptsInput) { - return; - } + let viewData; + _.each(this.views, view => { + try { + // don't fill forms with static, non user-editable data data + if(!view.acceptsInput) { + return; + } - viewData = view.getData(); - if(_.isUndefined(viewData)) { - return; - } + // some form values may be omitted from submission all together + if(view.omitFromSubmission) { + return; + } - formData.value[ view.submitArgName ? view.submitArgName : view.id ] = viewData; - } catch(e) { - this.client.log.error( { error : e.message }, 'Exception caught gathering form data' ); - } - }); + viewData = view.getData(); + if(_.isUndefined(viewData)) { + return; + } - return formData; + formData.value[ view.submitArgName ? view.submitArgName : view.id ] = viewData; + } catch(e) { + this.client.log.error( { error : e.message }, 'Exception caught gathering form data' ); + } + }); + + return formData; }; diff --git a/core/web_password_reset.js b/core/web_password_reset.js index c2d6852d..89c3fd33 100644 --- a/core/web_password_reset.js +++ b/core/web_password_reset.js @@ -1,314 +1,329 @@ /* jslint node: true */ 'use strict'; -// ENiGMA½ -const Config = require('./config.js').config; -const Errors = require('./enig_error.js').Errors; -const getServer = require('./listening_server.js').getServer; -const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; -const User = require('./user.js'); -const userDb = require('./database.js').dbs.user; -const getISOTimestampString = require('./database.js').getISOTimestampString; -const Log = require('./logger.js').log; +// ENiGMA½ +const Config = require('./config.js').get; +const Errors = require('./enig_error.js').Errors; +const getServer = require('./listening_server.js').getServer; +const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; +const User = require('./user.js'); +const userDb = require('./database.js').dbs.user; +const getISOTimestampString = require('./database.js').getISOTimestampString; +const Log = require('./logger.js').log; +const UserProps = require('./user_property.js'); -// deps -const async = require('async'); -const _ = require('lodash'); -const crypto = require('crypto'); -const fs = require('graceful-fs'); -const url = require('url'); -const querystring = require('querystring'); +// deps +const async = require('async'); +const crypto = require('crypto'); +const fs = require('graceful-fs'); +const url = require('url'); +const querystring = require('querystring'); +const _ = require('lodash'); -const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT = - `%USERNAME%: -a password reset has been requested for your account on %BOARDNAME%. +const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT = + `%USERNAME%: +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% + * If this was not you, please ignore this email. + * Otherwise, follow this link: %RESET_URL% `; function getWebServer() { - return getServer(webServerPackageName); + return getServer(webServerPackageName); } class WebPasswordReset { - static startup(cb) { - WebPasswordReset.registerRoutes( err => { - return cb(err); - }); - } + static startup(cb) { + WebPasswordReset.registerRoutes( err => { + return cb(err); + }); + } - static sendForgotPasswordEmail(username, cb) { - const webServer = getServer(webServerPackageName); - if(!webServer || !webServer.instance.isEnabled()) { - return cb(Errors.General('Web server is not enabled')); - } + static sendForgotPasswordEmail(username, cb) { + const webServer = getServer(webServerPackageName); + if(!webServer || !webServer.instance.isEnabled()) { + return cb(Errors.General('Web server is not enabled')); + } - async.waterfall( - [ - function getEmailAddress(callback) { - if(!username) { - return callback(Errors.MissingParam('Missing "username"')); - } + async.waterfall( + [ + function getEmailAddress(callback) { + if(!username) { + return callback(Errors.MissingParam('Missing "username"')); + } - User.getUserIdAndName(username, (err, userId) => { - if(err) { - return callback(err); - } + User.getUserIdAndName(username, (err, userId) => { + if(err) { + return callback(err); + } - User.getUser(userId, (err, user) => { - if(err || !user.properties.email_address) { - return callback(Errors.DoesNotExist('No email address associated with this user')); - } + User.getUser(userId, (err, user) => { + if(err || !user.properties[UserProps.EmailAddress]) { + return callback(Errors.DoesNotExist('No email address associated with this user')); + } - return callback(null, user); - }); - }); - }, - function generateAndStoreResetToken(user, callback) { - // - // Reset "token" is simply HEX encoded cryptographically generated bytes - // - crypto.randomBytes(256, (err, token) => { - if(err) { - return callback(err); - } + return callback(null, user); + }); + }); + }, + function generateAndStoreResetToken(user, callback) { + // + // Reset "token" is simply HEX encoded cryptographically generated bytes + // + crypto.randomBytes(256, (err, token) => { + if(err) { + return callback(err); + } - token = token.toString('hex'); + token = token.toString('hex'); - const newProperties = { - email_password_reset_token : token, - email_password_reset_token_ts : getISOTimestampString(), - }; - - // we simply place the reset token in the user's properties - user.persistProperties(newProperties, err => { - return callback(err, user); - }); - }); + const newProperties = { + [ UserProps.EmailPwResetToken ] : token, + [ UserProps.EmailPwResetTokenTs ] : getISOTimestampString(), + }; - }, - function getEmailTemplates(user, callback) { - fs.readFile(Config.contentServers.web.resetPassword.resetPassEmailText, 'utf8', (err, textTemplate) => { - if(err) { - textTemplate = PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT; - } + // we simply place the reset token in the user's properties + user.persistProperties(newProperties, err => { + return callback(err, user); + }); + }); - fs.readFile(Config.contentServers.web.resetPassword.resetPassEmailHtml, 'utf8', (err, htmlTemplate) => { - return callback(null, user, textTemplate, htmlTemplate); - }); - }); - }, - function buildAndSendEmail(user, textTemplate, htmlTemplate, callback) { - const sendMail = require('./email.js').sendMail; + }, + function getEmailTemplates(user, callback) { + const config = Config(); + fs.readFile(config.contentServers.web.resetPassword.resetPassEmailText, 'utf8', (err, textTemplate) => { + if(err) { + textTemplate = PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT; + } - const resetUrl = webServer.instance.buildUrl(`/reset_password?token=${user.properties.email_password_reset_token}`); + fs.readFile(config.contentServers.web.resetPassword.resetPassEmailHtml, 'utf8', (err, htmlTemplate) => { + return callback(null, user, textTemplate, htmlTemplate); + }); + }); + }, + function buildAndSendEmail(user, textTemplate, htmlTemplate, callback) { + const sendMail = require('./email.js').sendMail; - function replaceTokens(s) { - return s - .replace(/%BOARDNAME%/g, Config.general.boardName) - .replace(/%USERNAME%/g, user.username) - .replace(/%TOKEN%/g, user.properties.email_password_reset_token) - .replace(/%RESET_URL%/g, resetUrl) - ; - } + const resetUrl = webServer.instance.buildUrl(`/reset_password?token=${user.properties[UserProps.EmailPwResetToken]}`); - textTemplate = replaceTokens(textTemplate); - if(htmlTemplate) { - htmlTemplate = replaceTokens(htmlTemplate); - } + function replaceTokens(s) { + return s + .replace(/%BOARDNAME%/g, Config().general.boardName) + .replace(/%USERNAME%/g, user.username) + .replace(/%TOKEN%/g, user.properties[UserProps.EmailPwResetToken]) + .replace(/%RESET_URL%/g, resetUrl) + ; + } - const message = { - to : `${user.properties.display_name||user.username} <${user.properties.email_address}>`, - // from will be filled in - subject : 'Forgot Password', - text : textTemplate, - html : htmlTemplate, - }; + textTemplate = replaceTokens(textTemplate); + if(htmlTemplate) { + htmlTemplate = replaceTokens(htmlTemplate); + } - sendMail(message, (err, info) => { - // :TODO: Log me! + const message = { + to : `${user.properties[UserProps.RealName]||user.username} <${user.properties[UserProps.EmailAddress]}>`, + // from will be filled in + subject : 'Forgot Password', + text : textTemplate, + html : htmlTemplate, + }; - return callback(err); - }); - } - ], - err => { - return cb(err); - } - ); - } + sendMail(message, (err, info) => { + if(err) { + Log.warn( { error : err.message }, 'Failed sending password reset email' ); + } else { + Log.info( { info : info }, 'Successfully sent password reset email'); + } - static scheduleEvents(cb) { - // :TODO: schedule ~daily cleanup task - return cb(null); - } + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } - static registerRoutes(cb) { - const webServer = getWebServer(); - if(!webServer) { - return cb(null); // no webserver enabled - } + static scheduleEvents(cb) { + // :TODO: schedule ~daily cleanup task + return cb(null); + } - if(!webServer.instance.isEnabled()) { - return cb(null); // no error, but we're not serving web stuff - } + static registerRoutes(cb) { + const webServer = getWebServer(); + if(!webServer) { + return cb(null); // no webserver enabled + } - [ - { - // this is the page displayed to user when they GET it - method : 'GET', - path : '^\\/reset_password\\?token\\=[a-f0-9]+$', // Config.contentServers.web.forgotPasswordPageTemplate - handler : WebPasswordReset.routeResetPasswordGet, - }, - // POST handler for performing the actual reset - { - method : 'POST', - path : '^\\/reset_password$', - handler : WebPasswordReset.routeResetPasswordPost, - } - ].forEach(r => { - webServer.instance.addRoute(r); - }); + if(!webServer.instance.isEnabled()) { + return cb(null); // no error, but we're not serving web stuff + } - return cb(null); - } + [ + { + // this is the page displayed to user when they GET it + method : 'GET', + path : '^\\/reset_password\\?token\\=[a-f0-9]+$', // Config.contentServers.web.forgotPasswordPageTemplate + handler : WebPasswordReset.routeResetPasswordGet, + }, + // POST handler for performing the actual reset + { + method : 'POST', + path : '^\\/reset_password$', + handler : WebPasswordReset.routeResetPasswordPost, + } + ].forEach(r => { + webServer.instance.addRoute(r); + }); + + return cb(null); + } - static fileNotFound(webServer, resp) { - return webServer.instance.fileNotFound(resp); - } + static fileNotFound(webServer, resp) { + return webServer.instance.fileNotFound(resp); + } - static accessDenied(webServer, resp) { - return webServer.instance.accessDenied(resp); - } + static accessDenied(webServer, resp) { + return webServer.instance.accessDenied(resp); + } - static getUserByToken(token, cb) { - async.waterfall( - [ - function validateToken(callback) { - User.getUserIdsWithProperty('email_password_reset_token', token, (err, userIds) => { - if(userIds && userIds.length === 1) { - return callback(null, userIds[0]); - } + static getUserByToken(token, cb) { + async.waterfall( + [ + function validateToken(callback) { + User.getUserIdsWithProperty('email_password_reset_token', token, (err, userIds) => { + if(userIds && userIds.length === 1) { + return callback(null, userIds[0]); + } - return callback(Errors.Invalid('Invalid password reset token')); - }); - }, - function getUser(userId, callback) { - User.getUser(userId, (err, user) => { - return callback(null, user); - }); - }, - ], - (err, user) => { - return cb(err, user); - } - ); - } + return callback(Errors.Invalid('Invalid password reset token')); + }); + }, + function getUser(userId, callback) { + User.getUser(userId, (err, user) => { + return callback(null, user); + }); + }, + ], + (err, user) => { + return cb(err, user); + } + ); + } - static routeResetPasswordGet(req, resp) { - const webServer = getWebServer(); // must be valid, we just got a req! + static routeResetPasswordGet(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 urlParts = url.parse(req.url, true); + const token = urlParts.query && urlParts.query.token; - if(!token) { - return WebPasswordReset.accessDenied(webServer, resp); - } + if(!token) { + return WebPasswordReset.accessDenied(webServer, resp); + } - WebPasswordReset.getUserByToken(token, (err, user) => { - if(err) { - // assume it's expired - return webServer.instance.respondWithError(resp, 410, 'Invalid or expired reset link.', 'Expired Link'); - } + WebPasswordReset.getUserByToken(token, (err, user) => { + if(err) { + // assume it's expired + return webServer.instance.respondWithError(resp, 410, 'Invalid or expired reset link.', 'Expired Link'); + } - const postResetUrl = webServer.instance.buildUrl('/reset_password'); + const postResetUrl = webServer.instance.buildUrl('/reset_password'); - return webServer.instance.routeTemplateFilePage( - Config.contentServers.web.resetPassword.resetPageTemplate, - (templateData, preprocessFinished) => { + const config = Config(); + return webServer.instance.routeTemplateFilePage( + config.contentServers.web.resetPassword.resetPageTemplate, + (templateData, preprocessFinished) => { - const finalPage = templateData - .replace(/%BOARDNAME%/g, Config.general.boardName) - .replace(/%USERNAME%/g, user.username) - .replace(/%TOKEN%/g, token) - .replace(/%RESET_URL%/g, postResetUrl) - ; + const finalPage = templateData + .replace(/%BOARDNAME%/g, config.general.boardName) + .replace(/%USERNAME%/g, user.username) + .replace(/%TOKEN%/g, token) + .replace(/%RESET_URL%/g, postResetUrl) + ; - return preprocessFinished(null, finalPage); - }, - resp - ); - }); - } + return preprocessFinished(null, finalPage); + }, + resp + ); + }); + } - static routeResetPasswordPost(req, resp) { - const webServer = getWebServer(); // must be valid, we just got a req! + static routeResetPasswordPost(req, resp) { + const webServer = getWebServer(); // must be valid, we just got a req! - let bodyData = ''; - req.on('data', data => { - bodyData += data; - }); + let bodyData = ''; + req.on('data', data => { + bodyData += data; + }); - function badRequest() { - return webServer.instance.respondWithError(resp, 400, 'Bad Request.', 'Bad Request'); - } + function badRequest() { + return webServer.instance.respondWithError(resp, 400, 'Bad Request.', 'Bad Request'); + } - req.on('end', () => { - const formData = querystring.parse(bodyData); + req.on('end', () => { + const formData = querystring.parse(bodyData); - if(!formData.token || !formData.password || !formData.confirm_password || - formData.password !== formData.confirm_password || - formData.password.length < Config.users.passwordMin || formData.password.length > Config.users.passwordMax) - { - return badRequest(); - } + const config = Config(); + if(!formData.token || !formData.password || !formData.confirm_password || + formData.password !== formData.confirm_password || + formData.password.length < config.users.passwordMin || formData.password.length > config.users.passwordMax) + { + return badRequest(); + } - WebPasswordReset.getUserByToken(formData.token, (err, user) => { - if(err) { - return badRequest(); - } + WebPasswordReset.getUserByToken(formData.token, (err, user) => { + if(err) { + return badRequest(); + } - user.setNewAuthCredentials(formData.password, err => { - if(err) { - return badRequest(); - } + user.setNewAuthCredentials(formData.password, err => { + if(err) { + return badRequest(); + } - // delete assoc properties - no need to wait for completion - user.removeProperty('email_password_reset_token'); - user.removeProperty('email_password_reset_token_ts'); + // delete assoc properties - no need to wait for completion + user.removeProperties([ UserProps.EmailPwResetToken, UserProps.EmailPwResetTokenTs ]); - resp.writeHead(200); - return resp.end('Password changed successfully'); - }); - }); - }); - } + if(true === _.get(config, 'users.unlockAtEmailPwReset')) { + Log.info( + { username : user.username, userId : user.userId }, + 'Remove any lock on account due to password reset policy' + ); + user.unlockAccount( () => { /* dummy */ } ); + } + + resp.writeHead(200); + return resp.end('Password changed successfully'); + }); + }); + }); + } } function performMaintenanceTask(args, cb) { - const forgotPassExpireTime = args[0] || '24 hours'; + const forgotPassExpireTime = args[0] || '24 hours'; - // remove all reset token associated properties older than |forgotPassExpireTime| - userDb.run( - `DELETE FROM user_property - WHERE user_id IN ( - SELECT user_id - FROM user_property - WHERE prop_name = "email_password_reset_token_ts" - AND DATETIME("now") >= DATETIME(prop_value, "+${forgotPassExpireTime}") - ) AND prop_name IN ("email_password_reset_token_ts", "email_password_reset_token");`, - err => { - if(err) { - Log.warn( { error : err.message }, 'Failed deleting old email reset tokens'); - } - return cb(err); - } - ); + // remove all reset token associated properties older than |forgotPassExpireTime| + userDb.run( + `DELETE FROM user_property + WHERE user_id IN ( + SELECT user_id + FROM user_property + WHERE prop_name = "email_password_reset_token_ts" + AND DATETIME("now") >= DATETIME(prop_value, "+${forgotPassExpireTime}") + ) AND prop_name IN ("email_password_reset_token_ts", "email_password_reset_token");`, + err => { + if(err) { + Log.warn( { error : err.message }, 'Failed deleting old email reset tokens'); + } + return cb(err); + } + ); } -exports.WebPasswordReset = WebPasswordReset; -exports.performMaintenanceTask = performMaintenanceTask; \ No newline at end of file +exports.WebPasswordReset = WebPasswordReset; +exports.performMaintenanceTask = performMaintenanceTask; \ No newline at end of file diff --git a/core/whos_online.js b/core/whos_online.js new file mode 100644 index 00000000..5910bd29 --- /dev/null +++ b/core/whos_online.js @@ -0,0 +1,64 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const { getActiveConnectionList } = require('./client_connections.js'); +const { Errors } = require('./enig_error.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Who\'s Online', + desc : 'Who is currently online', + author : 'NuSkooler', + packageName : 'codes.l33t.enigma.whosonline' +}; + +const MciViewIds = { + onlineList : 1, +}; + +exports.getModule = class WhosOnlineModule extends MenuModule { + constructor(options) { + super(options); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (next) => { + return this.prepViewController('online', 0, mciData.menu, next); + }, + (next) => { + const onlineListView = this.viewControllers.online.getView(MciViewIds.onlineList); + if(!onlineListView) { + return cb(Errors.MissingMci(`Missing online list MCI ${MciViewIds.onlineList}`)); + } + + const onlineList = getActiveConnectionList(true).slice(0, onlineListView.height).map( + oe => Object.assign(oe, { text : oe.userName, timeOn : _.upperFirst(oe.timeOn.humanize()) }) + ); + + onlineListView.setItems(onlineList); + onlineListView.redraw(); + return next(null); + } + ], + err => { + if(err) { + this.client.log.error( { error : err.message }, 'Error loading who\'s online'); + } + return cb(err); + } + ); + }); + } +}; diff --git a/core/word_wrap.js b/core/word_wrap.js index 0a4b122d..94773283 100644 --- a/core/word_wrap.js +++ b/core/word_wrap.js @@ -1,217 +1,103 @@ /* jslint node: true */ 'use strict'; -var assert = require('assert'); -var _ = require('lodash'); -const renderStringLength = require('./string_util.js').renderStringLength; +const renderStringLength = require('./string_util.js').renderStringLength; -exports.wordWrapText = wordWrapText2; +// deps +const assert = require('assert'); +const _ = require('lodash'); -const SPACE_CHARS = [ - ' ', '\f', '\n', '\r', '\v', - '​\u00a0', '\u1680', '​\u180e', '\u2000​', '\u2001', '\u2002', '​\u2003', '\u2004', - '\u2005', '\u2006​', '\u2007', '\u2008​', '\u2009', '\u200a​', '\u2028', '\u2029​', - '\u202f', '\u205f​', '\u3000', +exports.wordWrapText = wordWrapText; + +const SPACE_CHARS = [ + ' ', '\f', '\n', '\r', '\v', + '​\u00a0', '\u1680', '​\u180e', '\u2000​', '\u2001', '\u2002', '​\u2003', '\u2004', + '\u2005', '\u2006​', '\u2007', '\u2008​', '\u2009', '\u200a​', '\u2028', '\u2029​', + '\u202f', '\u205f​', '\u3000', ]; -const REGEXP_WORD_WRAP = new RegExp(`\t|[${SPACE_CHARS.join('')}]`, 'g'); - -function wordWrapText2(text, options) { - assert(_.isObject(options)); - assert(_.isNumber(options.width)); - - options.tabHandling = options.tabHandling || 'expand'; - options.tabWidth = options.tabWidth || 4; - options.tabChar = options.tabChar || ' '; - - //const REGEXP_GOBBLE = new RegExp(`.{0,${options.width}}`, 'g'); - // - // For a given word, match 0->options.width chars -- alwasy include a full trailing ESC - // sequence if present! - // - // :TODO: Need to create ansi.getMatchRegex or something - this is used all over - const REGEXP_GOBBLE = new RegExp(`.{0,${options.width}}\\x1b\\[[\\?=;0-9]*[ABCDEFGHJKLMSTfhlmnprsu]|.{0,${options.width}}`, 'g'); - - let m; - let word; - let c; - let renderLen; - let i = 0; - let wordStart = 0; - let result = { wrapped : [ '' ], renderLen : [] }; - - function expandTab(column) { - const remainWidth = options.tabWidth - (column % options.tabWidth); - return new Array(remainWidth).join(options.tabChar); - } - - function appendWord() { - word.match(REGEXP_GOBBLE).forEach( w => { - renderLen = renderStringLength(w); - - if(result.renderLen[i] + renderLen > options.width) { - if(0 === i) { - result.firstWrapRange = { start : wordStart, end : wordStart + w.length }; - } - - result.wrapped[++i] = w; - result.renderLen[i] = renderLen; - } else { - result.wrapped[i] += w; - result.renderLen[i] = (result.renderLen[i] || 0) + renderLen; - } - }); - } - - // - // Some of the way we word wrap is modeled after Sublime Test 3: - // - // * Sublime Text 3 for example considers spaces after a word - // part of said word. For example, "word " would be wraped - // in it's entirity. - // - // * Tabs in Sublime Text 3 are also treated as a word, so, e.g. - // "\t" may resolve to " " and must fit within the space. - // - // * If a word is ultimately too long to fit, break it up until it does. - // - while(null !== (m = REGEXP_WORD_WRAP.exec(text))) { - word = text.substring(wordStart, REGEXP_WORD_WRAP.lastIndex - 1); - - c = m[0].charAt(0); - if(SPACE_CHARS.indexOf(c) > -1) { - word += m[0]; - } else if('\t' === c) { - if('expand' === options.tabHandling) { - // Good info here: http://c-for-dummies.com/blog/?p=424 - word += expandTab(result.wrapped[i].length + word.length) + options.tabChar; - } else { - word += m[0]; - } - } - - appendWord(); - wordStart = REGEXP_WORD_WRAP.lastIndex + m[0].length - 1; - } - - word = text.substring(wordStart); - appendWord(); - - return result; -} +const REGEXP_WORD_WRAP = new RegExp(`\t|[${SPACE_CHARS.join('')}]`, 'g'); function wordWrapText(text, options) { - // - // options.*: - // width : word wrap width - // tabHandling : expand (default=expand) - // tabWidth : tab width if tabHandling is 'expand' (default=4) - // tabChar : character to use for tab expansion - // - assert(_.isObject(options), 'Missing options!'); - assert(_.isNumber(options.width), 'Missing options.width!'); + assert(_.isObject(options)); + assert(_.isNumber(options.width)); - options.tabHandling = options.tabHandling || 'expand'; - - if(!_.isNumber(options.tabWidth)) { - options.tabWidth = 4; - } + options.tabHandling = options.tabHandling || 'expand'; + options.tabWidth = options.tabWidth || 4; + options.tabChar = options.tabChar || ' '; - options.tabChar = options.tabChar || ' '; + //const REGEXP_GOBBLE = new RegExp(`.{0,${options.width}}`, 'g'); + // + // For a given word, match 0->options.width chars -- alwasy include a full trailing ESC + // sequence if present! + // + // :TODO: Need to create ansi.getMatchRegex or something - this is used all over + const REGEXP_GOBBLE = new RegExp(`.{0,${options.width}}\\x1b\\[[\\?=;0-9]*[ABCDEFGHJKLMSTfhlmnprsu]|.{0,${options.width}}`, 'g'); - // - // Notes - // * Sublime Text 3 for example considers spaces after a word - // part of said word. For example, "word " would be wraped - // in it's entirity. - // - // * Tabs in Sublime Text 3 are also treated as a word, so, e.g. - // "\t" may resolve to " " and must fit within the space. - // - // * If a word is ultimately too long to fit, break it up until it does. - // - // RegExp below is JavaScript '\s' minus the '\t' - // - var re = new RegExp( - '\t|[ \f\n\r\v​\u00a0\u1680​\u180e\u2000​\u2001\u2002​\u2003\u2004\u2005\u2006​' + - '\u2007\u2008​\u2009\u200a​\u2028\u2029​\u202f\u205f​\u3000]', 'g'); - var m; - var wordStart = 0; - var results = { wrapped : [ '' ] }; - var i = 0; - var word; - var wordLen; + let m; + let word; + let c; + let renderLen; + let i = 0; + let wordStart = 0; + let result = { wrapped : [ '' ], renderLen : [ 0 ] }; - function expandTab(col) { - var remainWidth = options.tabWidth - (col % options.tabWidth); - return new Array(remainWidth).join(options.tabChar); - } + function expandTab(column) { + const remainWidth = options.tabWidth - (column % options.tabWidth); + return new Array(remainWidth).join(options.tabChar); + } - // :TODO: support wrapping pipe code text (e.g. ignore color codes, expand MCI codes) + function appendWord() { + word.match(REGEXP_GOBBLE).forEach( w => { + renderLen = renderStringLength(w); - function addWord() { - word.match(new RegExp('.{0,' + options.width + '}', 'g')).forEach(function wrd(w) { - //wordLen = self.getStringLength(w); + if(result.renderLen[i] + renderLen > options.width) { + if(0 === i) { + result.firstWrapRange = { start : wordStart, end : wordStart + w.length }; + } - if(results.wrapped[i].length + w.length > options.width) { - //if(results.wrapped[i].length + wordLen > width) { - if(0 === i) { - results.firstWrapRange = { start : wordStart, end : wordStart + w.length }; - //results.firstWrapRange = { start : wordStart, end : wordStart + wordLen }; - } - // :TODO: Must handle len of |w| itself > options.width & split how ever many times required (e.g. handle paste) - results.wrapped[++i] = w; - } else { - results.wrapped[i] += w; - } - }); - } + result.wrapped[++i] = w; + result.renderLen[i] = renderLen; + } else { + result.wrapped[i] += w; + result.renderLen[i] = (result.renderLen[i] || 0) + renderLen; + } + }); + } - while((m = re.exec(text)) !== null) { - word = text.substring(wordStart, re.lastIndex - 1); + // + // Some of the way we word wrap is modeled after Sublime Test 3: + // + // * Sublime Text 3 for example considers spaces after a word + // part of said word. For example, "word " would be wraped + // in it's entirity. + // + // * Tabs in Sublime Text 3 are also treated as a word, so, e.g. + // "\t" may resolve to " " and must fit within the space. + // + // * If a word is ultimately too long to fit, break it up until it does. + // + while(null !== (m = REGEXP_WORD_WRAP.exec(text))) { + word = text.substring(wordStart, REGEXP_WORD_WRAP.lastIndex - 1); - switch(m[0].charAt(0)) { - case ' ' : - word += m[0]; - break; + c = m[0].charAt(0); + if(SPACE_CHARS.indexOf(c) > -1) { + word += m[0]; + } else if('\t' === c) { + if('expand' === options.tabHandling) { + // Good info here: http://c-for-dummies.com/blog/?p=424 + word += expandTab(result.wrapped[i].length + word.length) + options.tabChar; + } else { + word += m[0]; + } + } - case '\t' : - // - // Expand tab given position - // - // Nice info here: http://c-for-dummies.com/blog/?p=424 - // - if('expand' === options.tabHandling) { - word += expandTab(results.wrapped[i].length + word.length) + options.tabChar; - } else { - word += m[0]; - } - break; - } + appendWord(); + wordStart = REGEXP_WORD_WRAP.lastIndex + m[0].length - 1; + } - addWord(); - wordStart = re.lastIndex + m[0].length - 1; - } + word = text.substring(wordStart); + appendWord(); - // - // Remainder - // - word = text.substring(wordStart); - addWord(); - - return results; + return result; } - -//const input = 'Hello, |04World! This |08i|02s a test it is \x1b[20Conly a test of the emergency broadcast system. What you see is not a joke!'; -//const input = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five enturies, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."; - -/* -const iconv = require('iconv-lite'); -const input = iconv.decode(require('graceful-fs').readFileSync('/home/nuskooler/Downloads/msg_out.txt'), 'cp437'); - -const opts = { - width : 80, -}; - -console.log(wordWrapText2(input, opts).wrapped, 'utf8') -*/ \ No newline at end of file diff --git a/docs/404.html b/docs/404.html new file mode 100644 index 00000000..c472b4ea --- /dev/null +++ b/docs/404.html @@ -0,0 +1,24 @@ +--- +layout: default +--- + + + +
+

404

+ +

Page not found :(

+

The requested page could not be found.

+
diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 00000000..1a0104dd --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,31 @@ +source "https://rubygems.org" + +# Hello! This is where you manage which Jekyll version is used to run. +# When you want to use a different version, change it below, save the +# file and run `bundle install`. Run Jekyll with `bundle exec`, like so: +# +# bundle exec jekyll serve +# +# This will help ensure the proper Jekyll version is running. +# Happy Jekylling! +gem "jekyll", "~> 3.7.0" + +# This is the default theme for new Jekyll sites. You may change this to anything you like. +gem "hacker" + +# If you want to use GitHub Pages, remove the "gem "jekyll"" above and +# uncomment the line below. To upgrade, run `bundle update github-pages`. +# gem "github-pages", group: :jekyll_plugins + +# If you have any plugins, put them here! +group :jekyll_plugins do + gem "jekyll-feed", "~> 0.6" + gem 'jekyll-seo-tag' + gem 'jekyll-theme-hacker' + gem 'jekyll-sitemap' + gem 'jemoji' +end + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby] + diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 00000000..5303d96f --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,101 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (4.2.9) + i18n (~> 0.7) + minitest (~> 5.1) + thread_safe (~> 0.3, >= 0.3.4) + tzinfo (~> 1.1) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) + colorator (1.1.0) + concurrent-ruby (1.0.5) + em-websocket (0.5.1) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0.6.0) + eventmachine (1.2.5) + ffi (1.9.24) + forwardable-extended (2.6.0) + gemoji (3.0.0) + hacker (0.0.1) + html-pipeline (2.7.1) + activesupport (>= 2) + nokogiri (>= 1.8.5) + http_parser.rb (0.6.0) + i18n (0.9.1) + concurrent-ruby (~> 1.0) + jekyll (3.7.4) + addressable (~> 2.4) + colorator (~> 1.0) + em-websocket (~> 0.5) + i18n (~> 0.7) + jekyll-sass-converter (~> 1.0) + jekyll-watch (~> 2.0) + kramdown (~> 1.14) + liquid (~> 4.0) + mercenary (~> 0.3.3) + pathutil (~> 0.9) + rouge (>= 1.7, < 4) + safe_yaml (~> 1.0) + jekyll-feed (0.9.2) + jekyll (~> 3.3) + jekyll-sass-converter (1.5.1) + sass (~> 3.4) + jekyll-seo-tag (2.4.0) + jekyll (~> 3.3) + jekyll-sitemap (1.1.1) + jekyll (~> 3.3) + jekyll-theme-hacker (0.1.0) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-watch (2.0.0) + listen (~> 3.0) + jemoji (0.8.1) + activesupport (~> 4.0, >= 4.2.9) + gemoji (~> 3.0) + html-pipeline (~> 2.2) + jekyll (>= 3.0) + kramdown (1.16.2) + liquid (4.0.0) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) + mercenary (0.3.6) + mini_portile2 (2.4.0) + minitest (5.11.1) + nokogiri (1.10.4) + mini_portile2 (~> 2.4.0) + pathutil (0.16.1) + forwardable-extended (~> 2.6) + public_suffix (3.0.1) + rb-fsevent (0.10.2) + rb-inotify (0.9.10) + ffi (>= 1.9.24, < 2) + rouge (3.1.0) + ruby_dep (1.5.0) + safe_yaml (1.0.4) + sass (3.5.5) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + thread_safe (0.3.6) + tzinfo (1.2.4) + thread_safe (~> 0.1) + +PLATFORMS + ruby + +DEPENDENCIES + hacker + jekyll (~> 3.7.0) + jekyll-feed (~> 0.6) + jekyll-seo-tag + jekyll-sitemap + jekyll-theme-hacker + jemoji + tzinfo-data + +BUNDLED WITH + 1.16.1 diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000..c41690bf --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,30 @@ +title: ENiGMA½ BBS Software +email: your-email@example.com +description: >- # this means to ignore newlines until "baseurl:" + ENiGMA½ BBS is modern open source BBS software with a nostalgic flair, written in Node.js. +url: +logo: /assets/images/enigma-logo.png + +# Build settings +markdown: kramdown +theme: jekyll-theme-hacker +plugins: + - jekyll-feed + - jekyll-seo-tag + - jekyll-sitemap + - jemoji + +baseurl: /enigma-bbs + +# Exclude from processing. +# The following items will not be processed, by default. Create a custom list +# to override the default setting. +exclude: + - Gemfile + - Gemfile.lock + - node_modules + - vendor/bundle/ + - vendor/cache/ + - vendor/gems/ + - vendor/ruby/ + - .idea diff --git a/docs/_includes/nav.md b/docs/_includes/nav.md new file mode 100644 index 00000000..6b335617 --- /dev/null +++ b/docs/_includes/nav.md @@ -0,0 +1,97 @@ + - Installation + - [Installation Methods]({{ site.baseurl }}{% link installation/installation-methods.md %}) + - [Install script]({{ site.baseurl }}{% link installation/install-script.md %}) + - [Docker]({{ site.baseurl }}{% link installation/docker.md %}) + - [Manual installation]({{ site.baseurl }}{% link installation/manual.md %}) + - [OS / Hardware Specific]({{ site.baseurl }}{% link installation/os-hardware.md %}) + - [Raspberry Pi]({{ site.baseurl }}{% link installation/rpi.md %}) + - [Windows]({{ site.baseurl }}{% link installation/windows.md %}) + - [Your Network Setup]({{ site.baseurl }}{% link installation/network.md %}) + - [Testing Your Installation]({{ site.baseurl }}{% link installation/testing.md %}) + - [Production Installation]({{ site.baseurl }}{% link installation/production.md %}) + + - Configuration + - [Creating Config Files]({{ site.baseurl }}{% link configuration/creating-config.md %}) + - [SysOp Setup]({{ site.baseurl }}{% link configuration/sysop-setup.md %}) + - [System Configuration]({{ site.baseurl }}{% link configuration/config-hjson.md %}) + - [HJSON Config Files]({{ site.baseurl }}{% link configuration/hjson.md %}) + - [Menus]({{ site.baseurl }}{% link configuration/menu-hjson.md %}) + - [Prompts]({{ site.baseurl }}{% link configuration/prompt-hjson.md %}) + - [Directory Structure]({{ site.baseurl }}{% link configuration/directory-structure.md %}) + - [Archivers]({{ site.baseurl }}{% link configuration/archivers.md %}) + - [File Transfer Protocols]({{ site.baseurl }}{% link configuration/file-transfer-protocols.md %}) + - [Email]({{ site.baseurl }}{% link configuration/email.md %}) + - [Colour Codes]({{ site.baseurl }}{% link configuration/colour-codes.md %}) + - [Access Condition System (ACS)]({{ site.baseurl }}{% link configuration/acs.md %}) + - [Event Scheduler]({{ site.baseurl }}{% link configuration/event-scheduler.md %}) + - [Security]({{ site.baseurl }}{% link configuration/security.md %}) + + - File Base + - [About]({{ site.baseurl }}{% link filebase/index.md %}) + - [Configuring a File Area]({{ site.baseurl }}{% link filebase/first-file-area.md %}) + - [ACS model]({{ site.baseurl }}{% link filebase/acs.md %}) + - [Uploads]({{ site.baseurl }}{% link filebase/uploads.md %}) + - [Web Access]({{ site.baseurl }}{% link filebase/web-access.md %}) + - [TIC Support]({{ site.baseurl }}{% link filebase/tic-support.md %}) (Importing from FTN networks) + - Tips and tricks + - [Network mounts and symlinks]({{ site.baseurl }}{% link filebase/network-mounts-and-symlinks.md %}) + + - Message Areas + - [Configuring a Message Area]({{ site.baseurl }}{% link messageareas/configuring-a-message-area.md %}) + - [Message networks]({{ site.baseurl }}{% link messageareas/message-networks.md %}) + - [BSO Import & Export]({{ site.baseurl }}{% link messageareas/bso-import-export.md %}) + - [Netmail]({{ site.baseurl }}{% link messageareas/netmail.md %}) + - [QWK]({{ site.baseurl }}{% link messageareas/qwk.md %}) + - [FTN]({{ site.baseurl }}{% link messageareas/ftn.md %}) + + - Art + - [General]({{ site.baseurl }}{% link art/general.md %}) + - [Themes]({{ site.baseurl }}{% link art/themes.md %}) + - [MCI Codes]({{ site.baseurl }}{% link art/mci.md %}) + + - Servers + - Login Servers + - [Telnet]({{ site.baseurl }}{% link servers/telnet.md %}) + - [SSH]({{ site.baseurl }}{% link servers/ssh.md %}) + - [WebSocket]({{ site.baseurl }}{% link servers/websocket.md %}) + - Build your own + - Content Servers + - [Web]({{ site.baseurl }}{% link servers/web-server.md %}) + - [Gopher]({{ site.baseurl }}{% link servers/gopher.md %}) + - [NNTP]({{ site.baseurl }}{% link servers/nntp.md %}) + + - Modding + - [Local Doors]({{ site.baseurl }}{% link modding/local-doors.md %}) + - [Door Servers]({{ site.baseurl }}{% link modding/door-servers.md %}) + - DoorParty + - BBSLink + - Combatnet + - Exodus + - [Telnet Bridge]({{ site.baseurl }}{% link modding/telnet-bridge.md %}) + - [Existing Mods]({{ site.baseurl }}{% link modding/existing-mods.md %}) + - [File Area List]({{ site.baseurl }}{% link modding/file-area-list.md %}) + - [Last Callers]({{ site.baseurl }}{% link modding/last-callers.md %}) + - [Who's Online]({{ site.baseurl }}{% link modding/whos-online.md %}) + - [User List]({{ site.baseurl }}{% link modding/user-list.md %}) + - [Message Conference List]({{ site.baseurl }}{% link modding/msg-conf-list.md %}) + - [Message Area List]({{ site.baseurl }}{% link modding/msg-area-list.md %}) + - [BBS List]({{ site.baseurl }}{% link modding/bbs-list.md %}) + - [Rumorz]({{ site.baseurl }}{% link modding/rumorz.md %}) + - [File Transfer Protocol Select]({{ site.baseurl }}{% link modding/file-transfer-protocol-select.md %}) + - [Onelinerz]({{ site.baseurl }}{% link modding/onelinerz.md %}) + - [Show Art]({{ site.baseurl }}{% link modding/show-art.md %}) + - [Download Manager]({{ site.baseurl }}{% link modding/file-base-download-manager.md %}) + - [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %}) + - [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %}) + - [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 + - [Administration]({{ site.baseurl }}{% link admin/administration.md %}) + - [oputil]({{ site.baseurl }}{% link admin/oputil.md %}) + - [Updating]({{ site.baseurl }}{% link admin/updating.md %}) + + - Troubleshooting + - [Monitoring Logs]({{ site.baseurl }}{% link troubleshooting/monitoring-logs.md %}) diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 00000000..60f87db8 --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,39 @@ + + + + + + + + {% seo %} + + + + Fork me on GitHub + +
+
+
+ {{ content }} +
+
+
+ + {% if site.google_analytics %} + + {% endif %} + + diff --git a/docs/_layouts/page.html b/docs/_layouts/page.html new file mode 100644 index 00000000..404d540a --- /dev/null +++ b/docs/_layouts/page.html @@ -0,0 +1,8 @@ +--- +layout: default +--- + +
+

{{ page.title }}

+ {{ content }} +
\ No newline at end of file diff --git a/docs/_layouts/post.html b/docs/_layouts/post.html new file mode 100644 index 00000000..f3def1e6 --- /dev/null +++ b/docs/_layouts/post.html @@ -0,0 +1,25 @@ +--- +layout: default +--- + +
+

{{ page.title }}

+ + {{ content }} +
+ + \ No newline at end of file diff --git a/docs/_sass/_default_colors.scss b/docs/_sass/_default_colors.scss new file mode 100644 index 00000000..4227975a --- /dev/null +++ b/docs/_sass/_default_colors.scss @@ -0,0 +1,16 @@ +$apple-blossom: #ac4142; +$alto: #d0d0d0; +$bouquet: #aa759f; +$enigma-purple: #8900aa; +$chelsea-cucumber: #90a959; +$cod-grey: #151515; +$conifer: #b5e853; +$dove-grey: #666; +$gallery: #eaeaea; +$grey: #888; +$gulf-stream: #75b5aa; +$hippie-blue: #6a9fb5; +$potters-clay: #8f5536; +$rajah: #f4bf75; +$raw-sienna: #d28445; +$silver-chalice: #aaa; diff --git a/docs/_sass/jekyll-theme-hacker.scss b/docs/_sass/jekyll-theme-hacker.scss new file mode 100644 index 00000000..e02b5aad --- /dev/null +++ b/docs/_sass/jekyll-theme-hacker.scss @@ -0,0 +1,326 @@ +@import "rouge-base16-dark"; +@import "default_colors"; + +$body-background: $cod-grey !default; +$body-foreground: $gallery !default; +$header: $conifer !default; +$blockquote-color: $silver-chalice !default; +$blockquote-border: $dove-grey !default; + +body { + margin: 0; + padding: 0; + background: $body-background url("../images/bkg.png") 0 0; + color: $body-foreground; + font-size: 16px; + line-height: 1.5; + font-family: Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal, monospace; +} + +/* General & 'Reset' Stuff */ + +.container { + width: 90%; + /*max-width: 1000px;*/ + margin: 0 auto; +} + +section { + display: block; + margin: 0 0 20px 0; +} + +h1, h2, h3, h4, h5, h6 { + margin: 0 0 20px; +} + +li { + line-height: 1.4 ; +} + +/* Header,
+ header - container + h1 - project name + h2 - project description +*/ + +header { + background: rgba(0, 0, 0, 0.1); + width: 100%; + border-bottom: 1px dashed $conifer; //header; + padding: 20px 0; + margin: 0 0 40px 0; + text-align: center; +} + +header h1 { + font-size: 30px; + line-height: 1.5; + margin: 0 0 0 -40px; + font-weight: bold; + font-family: Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal, monospace; + color: $conifer;//$header; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1), + 0 0 5px rgba(181, 232, 83, 0.1), + 0 0 10px rgba(181, 232, 83, 0.1); + letter-spacing: -1px; + -webkit-font-smoothing: antialiased; +} + +header h1:before { + content: "./ "; + font-size: 24px; +} + +header h2 { + font-size: 18px; + font-weight: 300; + color: #666; +} + +header img { + padding: 0px; + margin: 0px; + max-width: 100%; +} + +#downloads .btn { + display: inline-block; + text-align: center; + margin: 0; +} + +/* Main Content +*/ +.sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 22rem; + height: 100%; + text-align: left; + border-right: 1px dashed $conifer; //header; + overflow-y: scroll; + display: block; + float: left; + + .logo { + padding-top: 20px; + max-width: 100%; + } + + ul { + padding-bottom: 0px; + } + ul li { + margin-bottom: 0px; + padding-top: 5px; + } +} + +.main_area { + padding-left: 22em; + padding-top: 20px; +} + +#main_content { + + -webkit-font-smoothing: antialiased; +} +section img { + max-width: 100% +} + +h1, h2, h3, h4, h5, h6 { + font-weight: normal; + font-family: Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal, monospace; + color: $header; + letter-spacing: -0.03em; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1), + 0 0 5px rgba(181, 232, 83, 0.1), + 0 0 10px rgba(181, 232, 83, 0.1); +} + +#main_content h1 { + font-size: 30px; +} + +#main_content h2 { + font-size: 24px; +} + +#main_content h3 { + font-size: 18px; +} + +#main_content h4 { + font-size: 14px; +} + +#main_content h5 { + font-size: 12px; + text-transform: uppercase; + margin: 0 0 5px 0; +} + +#main_content h6 { + font-size: 12px; + text-transform: uppercase; + color: #999; + margin: 0 0 5px 0; +} + +dt { + font-style: italic; + font-weight: bold; +} + +ul { + padding-bottom: 5px; +} + +ul li { + list-style-image:url('../images/bullet.png'); + margin-bottom: 10px; + padding-top: 10px; +} + +nav { + text-align: left; + + ul { + list-style: none; + margin-left: 0; + padding-left: 0; + margin-top: 20; + margin-bottom: 0; + padding-bottom: 0; + + li { + display: inline-block; + margin-left: 0.5em; + padding-right: 20px; + + a { + color: $enigma-purple; + text-decoration: none; + } + + a:hover { + color: $bouquet; + } + } + } +} + + +blockquote { + color: $blockquote-color; + padding-left: 10px; + border-left: 1px dotted $blockquote-border; +} + +pre { + background: rgba(0, 0, 0, 0.9); + border: 1px solid rgba(255, 255, 255, 0.15); + padding: 10px; + font-size: 16px; + color: #b5e853; + border-radius: 2px; + text-wrap: normal; + overflow: auto; + overflow-y: hidden; +} + +code.highlighter-rouge { + background: rgba(0,0,0,0.9); + border: 1px solid rgba(255, 255, 255, 0.15); + padding: 0px 3px; + margin: 0px -3px; + color: #aa759f; + border-radius: 2px; +} + +table { + width: 100%; + margin: 0 0 20px 0; +} + +th { + text-align: left; + border-bottom: 1px dashed #b5e853; + padding: 5px 10px; +} + +td { + padding: 5px 10px; +} + +hr { + height: 0; + border: 0; + border-bottom: 1px dashed #b5e853; + color: #b5e853; +} + +/* Buttons +*/ + +.btn { + display: inline-block; + background: -webkit-linear-gradient(top, rgba(40, 40, 40, 0.3), rgba(35, 35, 35, 0.3) 50%, rgba(10, 10, 10, 0.3) 50%, rgba(0, 0, 0, 0.3)); + padding: 8px 18px; + border-radius: 50px; + border: 2px solid rgba(0, 0, 0, 0.7); + border-bottom: 2px solid rgba(0, 0, 0, 0.7); + border-top: 2px solid rgba(0, 0, 0, 1); + color: rgba(255, 255, 255, 0.8); + font-family: Helvetica, Arial, sans-serif; + font-weight: bold; + font-size: 13px; + text-decoration: none; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.75); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.btn:hover { + background: -webkit-linear-gradient(top, rgba(40, 40, 40, 0.6), rgba(35, 35, 35, 0.6) 50%, rgba(10, 10, 10, 0.8) 50%, rgba(0, 0, 0, 0.8)); +} + +.btn .icon { + display: inline-block; + width: 16px; + height: 16px; + margin: 1px 8px 0 0; + float: left; +} + +.btn-github .icon { + opacity: 0.6; + background: url("../images/blacktocat.png") 0 0 no-repeat; +} + +/* Links + a, a:hover, a:visited +*/ + +a { + color: #63c0f5; + text-shadow: 0 0 5px rgba(104, 182, 255, 0.5); +} + +/* Clearfix */ + +.cf:before, .cf:after { + content:""; + display:table; +} + +.cf:after { + clear:both; +} + +.cf { + zoom:1; +} diff --git a/docs/_sass/rouge-base16-dark.scss b/docs/_sass/rouge-base16-dark.scss new file mode 100644 index 00000000..7f839e96 --- /dev/null +++ b/docs/_sass/rouge-base16-dark.scss @@ -0,0 +1,87 @@ +/* + generated by rouge http://rouge.jneen.net/ + original base16 by Chris Kempson (https://github.com/chriskempson/base16) +*/ + +@import "default_colors"; + +.highlight { + + $plaintext: $alto !default; + $string: $chelsea-cucumber !default; + $literal: $chelsea-cucumber !default; + $keyword: $bouquet !default; + $error-foreground: $cod-grey !default; + $error-background: $apple-blossom !default; + $comment: $grey !default; + $preprocessor: $rajah !default; + $name-space: $rajah !default; + $name-attribute: $hippie-blue !default; + $operator: $rajah !default; + $keyword-type: $raw-sienna !default; + $regex: $gulf-stream !default; + $string-escape: $potters-clay !default; + $deleted: $apple-blossom !default; + $header: $hippie-blue !default; + + color: $plaintext; + + table td { padding: 5px; } + table pre { margin: 0; } + .w { + color: $plaintext; + } + .err { + color: $error-foreground; + background-color: $error-background; + } + .c, .cd, .cm, .c1, .cs { + color: $comment; + } + .cp { + color: $preprocessor; + } + .o, .ow { + color: $operator; + } + .p, .pi { + color: $plaintext; + } + .gi { + color: $string; + } + .gd { + color: $deleted; + } + .gh { + color: $header; + font-weight: bold; + } + .k, .kn, .kp, .kr, .kv { + color: $keyword; + } + .kc, .kt, .kd { + color: $keyword-type; + } + .s, .sb, .sc, .sd, .s2, .sh, .sx, .s1 { + color: $string; + } + .sr { + color: $regex; + } + .si, .se { + color: $string-escape; + } + .nt, .nn, .nc, .no{ + color: $name-space; + } + .na { + color: $name-attribute; + } + .m, .mf, .mh, .mi, .il, .mo, .mb, .mx { + color: $literal; + } + .ss { + color: $string; + } +} diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index 50656011..00000000 --- a/docs/about.md +++ /dev/null @@ -1,19 +0,0 @@ -# About ENiGMA½ - -## High Level Feature Overview - * Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows) - * Unlimited multi node support (for all those BBS "callers"!) - * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods - * MCI support for lightbars, toggles, input areas, and so on plus many other other bells and whistles - * Telnet & **SSH** access built in. Additional servers are easy to implement - * [CP437](http://www.ascii-codes.com/) and UTF-8 output - * [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior - * [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support - * Renegade style pipe color codes - * [SQLite](http://sqlite.org/) storage of users, message areas, and so on - * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption - * [Door support](doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), and [DoorParty](http://forums.throwbackbbs.com/) support! - * [Bunyan](https://github.com/trentm/node-bunyan) logging - * [Message networks](msg_networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export - * [Gazelle](https://github.com/WhatCD/Gazelle) inspirted File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](web_server.md). Legacy X/Y/Z modem also supported! - * Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more! \ No newline at end of file diff --git a/docs/admin/administration.md b/docs/admin/administration.md new file mode 100644 index 00000000..8d15fba5 --- /dev/null +++ b/docs/admin/administration.md @@ -0,0 +1,43 @@ +--- +layout: page +title: Administration +--- + +# Administration + +## Keeping Up to Date +See [Updating](updating.md). + +## Viewing Logs +See [Monitoring Logs](/docs/troubleshooting/monitoring-logs.md). + +## Managing Users +User management is currently handled via the [oputil CLI](oputil.md). + +## Backing Up Your System +It is *highly* recommended to perform **regular backups** of your system. Nothing is worse than spending a lot of time setting up a system only to have to go away unexpectedly! + +In general, simply creating a copy/archive of your system is enough for the default configuration. If you have changed default paths to point outside of your main ENiGMA½ installation take special care to ensure these are preserved as well. Database files may be in a state of flux when simply copying files. See **Database Backups** below for details on consistent backups. + +### Database Backups +[SQLite's CLI backup command](https://sqlite.org/cli.html#special_commands_to_sqlite3_dot_commands_) can be used for creating database backup files. This can be performed as an additional step to a full backup to ensure the database is backed up in a consistent state (whereas simply copying the files does not make any guarantees). + +As an example, consider the following Bash script that creates foo.sqlite3.backup files: + +```bash +for dbfile in /path/to/enigma-bbs/db/*.sqlite3; do + sqlite3 $dbfile ".backup '/path/to/db_backup/$(basename $dbfile).backup'" +done +``` + +### Backup Tools +There are many backup solutions available across all platforms. Configuration of such tools is outside the scope of this documentation. With that said, the author has had great success with [Borg](https://www.borgbackup.org/). + +## General Maintenance Tasks +### Vacuuming Database Files +SQLite database files become less performant over time and waste space. It is recommended to periodically vacuum your databases. Before proceeding, you should make a backup! + +Example: +```bash +sqlite3 ./db/message.sqlite3 "vacuum;" +``` \ No newline at end of file diff --git a/docs/admin/oputil.md b/docs/admin/oputil.md new file mode 100644 index 00000000..e591de9f --- /dev/null +++ b/docs/admin/oputil.md @@ -0,0 +1,322 @@ +--- +layout: page +title: oputil +--- +## The oputil CLI +ENiGMA½ comes with `oputil.js` henceforth known as `oputil`, a command line interface (CLI) tool for sysops to perform general system and user administration. You likely used oputil to do the initial ENiGMA configuration. + +Let's look the main help output as per this writing: + +``` +usage: oputil.js [--version] [--help] + [] + +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 management + config Configuration management + fb File base management + mb Message base management +``` + +Commands break up operations by groups: + +| Command | Description | +|-----------|---------------| +| `user` | User management | +| `config` | System configuration and maintenance | +| `fb` | File base configuration and management | +| `mb` | Message base configuration and management | + +Global arguments apply to most commands and actions: +* `--config`: Specify configuration directory if it is not the default of `./config/`. +* `--no-prompt`: Assume defaults and do not prompt when posisible. + +Type `./oputil.js --help` for additional help on a particular command. The following sections will describe them. + +## User +The `user` command covers various user operations. + +``` +usage: oputil.js user [] + +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 | +|-----------|-------------------|---------------------------------------|-----------| +| `info` | Display user information| `./oputil.js user info joeuser` | N/A | +| `pw` | Set password | `./oputil.js user pw joeuser s3cr37` | `passwd`, `password` | +| `rm` | Removes user | `./oputil.js user del joeuser` | `remove`, `del`, `delete` | +| `rename` | Renames a user | `./oputil.js user rename joeuser joe` | `mv` | +| `2fa-otp` | Manage 2FA/OTP for a user | `./oputil.js user 2fa-otp joeuser googleAuth` | `otp` +| `activate` | Activates user | `./oputil.js user activate joeuser` | N/A | +| `deactivate` | Deactivates user | `./oputil.js user deactivate joeuser` | N/A | +| `disable` | Disables user (user will not be able to login) | `./oputil.js user disable joeuser` | N/A | +| `lock` | Locks the user account (prevents logins) | `./oputil.js user lock joeuser` | N/A | +| `group` | Modifies users group membership | Add to group: `./oputil.js user group joeuser +derp`
Remove from group: `./oputil.js user group joeuser -derp` | N/A | + +#### Manage 2FA/OTP +While `oputil.js` can be used to manage a user's 2FA/OTP, it is highly recommended to require users to opt-in themselves. See [Security](/docs/configuration/security.md) for details. + +## Configuration +The `config` command allows sysops to perform various system configuration and maintenance tasks. + +``` +usage: oputil.js config [] + +Actions: + new Generate a new / default configuration + + cat Write current configuration to stdout + +cat arguments: + --no-color Disable color + --no-comments Strip any comments +``` + +| Action | Description | Examples | +|-----------|-------------------|---------------------------------------| +| `new` | Generates a new/initial configuration | `./oputil.js config new` (follow the prompts) | +| `cat` | Pretty prints current `config.hjson` configuration to stdout. | `./oputil.js config cat` | + +## File Base Management +The `fb` command provides a powerful file base management interface. + +``` +usage: oputil.js fb [] + +Actions: + scan AREA_TAG[@STORAGE_TAG] Scan specified area + + May contain optional GLOB as last parameter. + Example: ./oputil.js fb scan d0pew4r3z *.zip + + info CRITERIA Display information about areas and/or files + + mv SRC [SRC...] DST Move matching entry(s) + (move) + + 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 + + rm SRC [SRC...] Remove entry(s) from the system + (del|delete|remove) + + Source may be any of the following: + - Filename including '*' wildcards + - SHA-1 + - File ID + - Area tag with optional @storageTag suffix + + desc CRITERIA Updates an file base entry's description + + 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 + +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 +The `scan` action can (re)scan a file area for new entries as well as update (`--update`) existing entry records (description, etc.). When scanning, a valid area tag must be specified. Optionally, storage tag may also be supplied in order to scan a specific filesystem location using the `@the_storage_tag` syntax. If a [GLOB](http://man7.org/linux/man-pages/man7/glob.7.html) is supplied as the last argument, only file entries with filenames matching will be processed. + +##### Examples +Performing a quick scan of a specific area's storage location ("retro_warez", "retro_warez_games) matching only *.zip extensions: +```bash +# 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". +```bash +$ ./oputil.js fb scan --update --quick --tags artscene,textmode artscene` +``` + +Scan "oldschoolbbs" area using the description file at "/path/to/DESCRIPT.ION": +``` +$ ./oputil.js fb scan --desc-file /path/to/DESCRIPT.ION oldschoolbbs +``` + +#### Retrieve Information +The `info` action can retrieve information about an area or file entry(s). + +##### Examples +Information about a particular area: +```bash +./oputil.js fb info retro_pc +areaTag: retro_pc +name: Retro PC +desc: Oldschool / retro PC +storageTag: retro_pc_tdc_1990 => /file_base/dos/tdc/1990 +storageTag: retro_pc_tdc_1991 => /file_base/dos/tdc/1991 +storageTag: retro_pc_tdc_1992 => /file_base/dos/tdc/1992 +storageTag: retro_pc_tdc_1993 => /file_base/dos/tdc/1993 +``` + +Perhaps we want to fetch some information about a file in which we know piece of the filename: +```bash +./oputil.js fb info "impulse*" +file_id: 143 +sha_256: 547299301254ccd73eba4c0ec9cd6ab8c5929fbb655e72c4cc842f11332792d4 +area_tag: impulse_project +storage_tag: impulse_project +path: /file_base/impulse_project/impulseproject01.tar.gz +hashTags: impulse.project,8bit.music,cid +uploaded: 2018-03-10T11:36:41-07:00 +dl_count: 23 +archive_type: application/gzip +byte_size: 114313 +est_release_year: 2015 +file_crc32: fc6655d +file_md5: 3455f74bbbf9539e69bd38f45e039a4e +file_sha1: 558fab3b49a8ac302486e023a3c2a86bd4e4b948 +``` + +### Importing FileGate RAID Style Areas +Given a FileGate "RAID" style `FILEGATE.ZXX` file, one can import areas. This format also often comes in FTN-style info packs in the form of a `.NA` file i.e.: `FILEBONE.NA`. + +#### Example +```bash +./oputil.js fb import-areas FILEGATE.ZXX --create-dirs +``` + +-or- + +```bash +# fsxNet info packs contain a FSX_FILE.NA file +./oputil.js fb import-areas FSX_FILE.NA --create-dirs --type NA +``` + +The above command will process FILEGATE.ZXX creating areas and backing directories. Directories created are relative to the `fileBase.areaStoragePrefix` `config.hjson` setting. + +## Message Base Management +The `mb` command provides various Message Base related tools: + +``` +usage: oputil.js mb [] + +Actions: + areafix CMD1 CMD2 ... ADDR Sends an AreaFix NetMail + + 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 + + qwk-dump PATH Dumps a QWK packet to stdout. + qwk-export [AREA_TAGS] PATH Exports one or more configured message area to a QWK + packet in the directory specified by PATH. The QWK + BBS ID will be obtained by the final component of PATH. + +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". + +qwk-export arguments: + --user USER User in which to export for. Defaults to the SysOp. + --after TIMESTAMP Export only messages with a timestamp later than + TIMESTAMP. + --no-qwke Disable QWKE extensions. + --no-synchronet Disable Synchronet style extensions. +``` + +| Action | Description | Examples | +|-----------|-------------------|---------------------------------------| +| `import-areas` | Imports areas using a FidoNet style *.NA or AREAS.BBS formatted file. Optionally maps areas to FTN networks. | `./oputil.js config import-areas /some/path/l33tnet.na` | +| `areafix` | Utility for sending AreaFix mails without logging into the system | | +| `qwk-dump` | Dump a QWK packet to stdout | `./oputil.js mb qwk-dump /path/to/XIBALBA.QWK` | +| `qwk-export` | Export messages to a QWK packet | `./oputil.js mb qwk-export /path/to/XIBALBA.QWK` | + +When using the `import-areas` action, you will be prompted for any missing additional arguments described in "import-areas args". diff --git a/docs/admin/updating.md b/docs/admin/updating.md new file mode 100644 index 00000000..3cf93768 --- /dev/null +++ b/docs/admin/updating.md @@ -0,0 +1,31 @@ +--- +layout: page +title: Updating +--- +# Updating +Keeping your system up to date ensures you have the latest fixes, features, and general improvements. Updating ENiGMA½ can be a bit of a learning curve compared to traditional binary-release systems you may be used to, especially when running from Git cloned source. + +## Updating From Source +If you have installed using Git source (if you used the `install.sh` script) follow these general steps to update your system: + +1. **Back up your system**! +2. Pull down the latest source: +```bash +cd /path/to/enigma-bbs +git pull +``` +3. :bulb: Review `WHATSNEW.md` and `UPDATE.md` for any specific instructions or changes to be aware of. +4. Update your dependencies: +```bash +npm install # or 'yarn' +``` +4. Merge updates from `config/menu_template.hjson` to your `config/yourbbsname-menu.hjson` file (or simply use the template as a reference to spot any newly added default menus that you may wish to have on your system as well!). +5. If there are updates to the `art/themes/luciano_blocktronics/theme.hjson` file and you have a custom theme, you may want to look at them as well. +6. Finally, restart your running ENiGMA½ instance. + +:information_source: Visual diff tools such as [DiffMerge](https://www.sourcegear.com/diffmerge/downloads.php) (free, works on all major platforms) can be very helpful for the tasks outlined above! + +:information_source: It is recommended to tail the logs and poke around a bit after an update. + + + diff --git a/docs/art/general.md b/docs/art/general.md new file mode 100644 index 00000000..18c438ec --- /dev/null +++ b/docs/art/general.md @@ -0,0 +1,156 @@ +--- +layout: page +title: General Art Information +--- +## General Art Information +One of the most basic elements of BBS customization is through it's artwork. ENiGMA½ supports a variety of ways to select, display, and manage art. + +As a general rule, art files live in one of two places: + +1. The `art/general` directory. This is where you place common/non-themed art files. +2. Within a _theme_ such as `art/themes/super_fancy_theme`. + +### Art in Menus +While art can be displayed programmatically such as from a custom module, the most basic and common form is via `menu.hjson` entries. This usually falls into one of two forms. + +**Form 1**: A "standard" entry where a single `art` spec is utilized: +```hjson +{ + mainMenu: { + art: main_menu.ans + } +} +``` + +**Form 2**: An entry for a custom module where multiple pieces are declared and used. The second style usually takes the form of a `config.art` block with two or more entries: +```hjson +{ + nodeMessage: { + config: { + art: { + header: node_msg_header + footer: node_msg_footer + } + } + } +} +``` + +A menu entry has a few elements that control how art is selected and displayed. First, the `art` *spec* tells the system how to look for the art asset. Second, the `config` block can further control aspects of lookup and display. The following table describes such entries: + +| Item | Description| +|------|------------| +| `font` | Sets the [SyncTERM](http://syncterm.bbsdev.net/) style font to use when displaying this art. If unset, the system will use the art's embedded [SAUCE](http://www.acid.org/info/sauce/sauce.htm) record if present or simply use the current font. See Fonts below. | +| `pause` | If set to `true`, pause after displaying. | +| `baudRate` | Set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate when displaying this art. In other words, slow down the display. | +| `cls` | Clear the screen before display if set to `true`. | +| `random` | Set to `false` to explicitly disable random lookup. | +| `types` | An optional array of types (aka file extensions) to consider for lookup. For example : `[ '.ans', '.asc' ]` | +| `readSauce` | May be set to `false` if you need to explicitly disable SAUCE support. | + +#### Art Spec +In the section above it is mentioned that the `art` member is a *spec*. The value of a `art` spec controls how the system looks for an asset. The following forms are supported: + +* `FOO`: The system will look for `FOO.ANS`, `FOO.ASC`, `FOO.TXT`, etc. using the default search path. Unless otherwise specified if `FOO1.ANS`, `FOO2.ANS`, and so on exist, a random selection will be made. +* `FOO.ANS`: By specifying an extension, only the exact match will be searched for. +* `rel/path/to/BAR.ANS`: Only match a path (relative to the system's `art` directory). +* `/path/to/BAZ.ANS`: Exact path only. + +ENiGMA½ uses a fallback system for art selection. When a menu entry calls for a piece of art, the following search is made: + +1. If a direct or relative path is supplied, look there first. +2. In the users current theme directory. +3. In the system default theme directory. +4. In the `art/general` directory. + +#### ACS-Driven Conditionals +The [ACS](/docs/configuration/acs.md) system can be used to make conditional art selection choices. To do this, provide an array of possible values in your art spec. As an example: +```hjson +{ + fancyMenu: { + art: [ + { + acs: GM[l33t] + art: leet_art.ans + } + { + // default + art: newb.asc + } + ] + } +} +``` + +#### SyncTERM Style Fonts +ENiGMA½ can set a [SyncTERM](http://syncterm.bbsdev.net/) style font for art display. This is supported by many other popular BBS terminals as well. A common usage is for displaying Amiga style fonts for example. The system will use the `font` specifier or look for a font declared in an artworks SAUCE record (unless `readSauce` is `false`). + +The most common fonts are probably as follows: + +* `cp437` +* `c64_upper` +* `c64_lower` +* `c128_upper` +* `c128_lower` +* `atari` +* `pot_noodle` +* `mo_soul` +* `microknight_plus` +* `topaz_plus` +* `microknight` +* `topaz` + +Other fonts fonts also available: +* `cp1251` +* `koi8_r` +* `iso8859_2` +* `iso8859_4` +* `cp866` +* `iso8859_9` +* `haik8` +* `iso8859_8` +* `koi8_u` +* `iso8859_15` +* `iso8859_4` +* `koi8_r_b` +* `iso8859_4` +* `iso8859_5` +* `ARMSCII_8` +* `iso8859_15` +* `cp850` +* `cp850` +* `cp885` +* `cp1251` +* `iso8859_7` +* `koi8-r_c` +* `iso8859_4` +* `iso8859_1` +* `cp866` +* `cp437` +* `cp866` +* `cp885` +* `cp866_u` +* `iso8859_1` +* `cp1131` + +See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. + +#### SyncTERM Style Baud Rates +The `baudRate` member can set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate. May be `300`, `600`, `1200`, `2400`, `4800`, `9600`, `19200`, `38400`, `57600`, `76800`, or `115200`. A value of `ulimited`, `off`, or `0` resets (disables) the rate. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. + +### Common Example +```hjson +fullLogoffSequenceRandomBoardAd: { + art: OTHRBBS + desc: Logging Off + next: logoff + config: { + baudRate: 57600 + pause: true + cls: true + } +} +``` + +### See Also +See also the [Show Art Module](/docs/modding/show-art.md) for more advanced art display! \ No newline at end of file diff --git a/docs/art/mci.md b/docs/art/mci.md new file mode 100644 index 00000000..42091d98 --- /dev/null +++ b/docs/art/mci.md @@ -0,0 +1,195 @@ +--- +layout: page +title: MCI Codes +--- +ENiGMA½ supports a variety of MCI codes. Some **predefined** codes produce information about the current user, system, +or other statistics while others are used to instantiate a **View**. MCI codes are two characters in length and are +prefixed with a percent (%) symbol. Some MCI codes have additional options that may be set directly from the code itself +while others -- and more advanced options -- are controlled via the current theme. Standard (non-focus) and focus colors +are set by placing duplicate codes back to back in art files. + +## Predefined MCI Codes +There are many predefined MCI codes that can be used anywhere on the system (placed in any art file). More are added all +the time so also check out [core/predefined_mci.js](https://github.com/NuSkooler/enigma-bbs/blob/master/core/mci_view_factory.js) +for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, etc. + +| Code | Description | +|------|--------------| +| `BN` | Board Name | +| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.11-beta" | +| `VN` | Version *number*, eg.. "0.0.11-beta" | +| `SN` | SysOp username | +| `SR` | SysOp real name | +| `SL` | SysOp location | +| `SA` | SysOp affiliations | +| `SS` | SysOp sex | +| `SE` | SysOp email address | +| `UN` | Current user's username | +| `UI` | Current user's user ID | +| `UG` | Current user's group membership(s) | +| `UR` | Current user's real name | +| `LO` | Current user's location | +| `UA` | Current user's age | +| `BD` | Current user's birthday (using theme date format) | +| `US` | Current user's sex | +| `UE` | Current user's email address | +| `UW` | Current user's web address | +| `UF` | Current user's affiliations | +| `UT` | Current user's theme name | +| `UD` | Current user's *theme ID* (e.g. "luciano_blocktronics") | +| `UC` | Current user's login/call count | +| `ND` | Current user's connected node number | +| `IP` | Current user's IP address | +| `ST` | Current user's connected server name (e.g. "Telnet" or "SSH") | +| `FN` | Current user's active file base filter name | +| `DN` | Current user's number of downloads | +| `DK` | Current user's download amount (formatted to appropriate bytes/megs/etc.) | +| `UP` | Current user's number of uploads | +| `UK` | Current user's upload amount (formatted to appropriate bytes/megs/etc.) | +| `NR` | Current user's upload/download ratio | +| `KR` | Current user's upload/download *bytes* ratio | +| `MS` | Current user's account creation date (using theme date format) | +| `PS` | Current user's post count | +| `PC` | Current user's post/call ratio | +| `MD` | Current user's status/viewing menu/activity | +| `MA` | Current user's active message area name | +| `MC` | Current user's active message conference name | +| `ML` | Current user's active message area description | +| `CM` | Current user's active message conference description | +| `SH` | Current user's term height | +| `SW` | Current user's term width | +| `AC` | Current user's total achievements | +| `AP` | Current user's total achievement points | +| `DR` | Current user's number of door runs | +| `DM` | Current user's total amount of time spent in doors | +| `DT` | Current date (using theme date format) | +| `CT` | Current time (using theme time format) | +| `OS` | System OS (Linux, Windows, etc.) | +| `OA` | System architecture (x86, x86_64, arm, etc.) | +| `SC` | System CPU model | +| `NV` | System underlying Node.js version | +| `AN` | Current active node count | +| `TC` | Total login/calls to the system *ever* | +| `TT` | Total login/calls to the system *today* | +| `RR` | Displays a random rumor | +| `SD` | Total downloads, system wide | +| `SO` | Total downloaded amount, system wide (formatted to appropriate bytes/megs/etc.) | +| `SU` | Total uploads, system wide | +| `SP` | Total uploaded amount, system wide (formatted to appropriate bytes/megs/etc.) | +| `TF` | Total number of files on the system | +| `TB` | Total amount of files on the system (formatted to appropriate bytes/megs/gigs/etc.) | +| `TP` | Total messages posted/imported to the system *currently* | +| `PT` | Total messages posted/imported to the system *today* | + +Some additional special case codes also exist: + +| Code | Description | +|--------|--------------| +| `CF##` | Moves the cursor position forward _##_ characters | +| `CB##` | Moves the cursor position back _##_ characters | +| `CU##` | Moves the cursor position up _##_ characters | +| `CD##` | Moves the cursor position down _##_ characters | +| `XY` | A special code that may be utilized for placement identification when creating menus or to extend an otherwise empty space in an art file down the screen. | + + +## Views +A **View** is a control placed on a **form** that can display variable data or collect input. One example of a View is +a Vertical Menu (`%VM`): Old-school BBSers may recognize this as a lightbar menu. + +| Code | Name | Description | +|------|----------------------|------------------| +| `TL` | Text Label | Displays text | +| `ET` | Edit Text | Collect user input | +| `ME` | Masked Edit Text | Collect user input using a *mask* | +| `MT` | Multi Line Text Edit | Multi line edit control | +| `BT` | Button | A button | +| `VM` | Vertical Menu | A vertical menu aka a vertical lightbar | +| `HM` | Horizontal Menu | A horizontal menu aka a horizontal lightbar | +| `SM` | Spinner Menu | A spinner input control | +| `TM` | Toggle Menu | A toggle menu commonly used for Yes/No style input | +| `KE` | Key Entry | A *single* key input control | + + +Peek at [/core/mci_view_factory.js](https://github.com/NuSkooler/enigma-bbs/blob/master/core/mci_view_factory.js) to +see additional information. + + +## Properties & Theming +Predefined MCI codes and other Views can have properties set via `menu.hjson` and further *themed* via `theme.hjson`. See [Themes](themes.md) for more information on this subject. + +### Common Properties + +| Property | Description | +|-------------|--------------| +| `textStyle` | Sets the standard (non-focus) text style. See **Text Styles** below | +| `focusTextStyle` | Sets focus text style. See **Text Styles** below. | +| `itemSpacing` | Used to separate items in menus such as Vertical Menu and Horizontal Menu Views. | +| `height` | Sets the height of views such as menus that may be > 1 character in height | +| `width` | Sets the width of a view | +| `focus` | If set to `true`, establishes initial focus | +| `text` | (initial) text of a view | +| `submit` | If set to `true` any `accept` action upon this view will submit the encompassing **form** | +| `itemFormat` | Sets the format for a list entry. See **Entry Formatting** below | +| `focusItemFormat` | Sets the format for a focused list entry. See **Entry Formatting** below | + +These are just a few of the properties set on various views. *Use the source Luke*, as well as taking a look at the default +`menu.hjson` and `theme.hjson` files! + +### Custom Properties +Often a module will provide custom properties that receive format objects (See **Entry Formatting** below). Custom property formatting can be declared in the `config` block. For example, `browseInfoFormat10`..._N_ (where _N_ is up to 99) in the `file_area_list` module received a fairly extensive format object that contains `{fileName}`, `{estReleaseYear}`, etc. + +### Text Styles + +Standard style types available for `textStyle` and `focusTextStyle`: + +| Style | Description | +|----------|--------------| +| `normal` | Leaves text as-is. This is the default. | +| `upper` | ENIGMA BULLETIN BOARD SOFTWARE | +| `lower` | enigma bulletin board software | +| `title` | Enigma Bulletin Board Software | +| `first lower` | eNIGMA bULLETIN bOARD sOFTWARE | +| `small vowels` | eNiGMa BuLLeTiN BoaRD SoFTWaRe | +| `big vowels` | EniGMa bUllEtIn bOArd sOftwArE | +| `small i` | ENiGMA BULLETiN BOARD SOFTWARE | +| `mixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) | +| `l33t` | 3n1gm4 bull371n b04rd 50f7w4r3 | + +### Entry Formatting +Various strings can be formatted using a syntax that allows width & precision specifiers, text styling, etc. Depending on the context, various elements can be referenced by `{name}`. Additional text styles can be supplied as well. The syntax is largely modeled after Python's [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language). + +### Additional Text Styles +Some of the text styles mentioned above are also available in the mini format language: + +| Style | Description | +|-------|-------------| +| `normal` | Leaves text as-is. This is the default. | +| `toUpperCase` or `styleUpper` | ENIGMA BULLETIN BOARD SOFTWARE | +| `toLowerCase` or `styleLower` | enigma bulletin board software | +| `styleTitle` | Enigma Bulletin Board Software | +| `styleFirstLower` | eNIGMA bULLETIN bOARD sOFTWARE | +| `styleSmallVowels` | eNiGMa BuLLeTiN BoaRD SoFTWaRe | +| `styleBigVowels` | EniGMa bUllEtIn bOArd sOftwArE | +| `styleSmallI` | ENiGMA BULLETiN BOARD SOFTWARE | +| `styleMixed` | EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) | +| `styleL33t` | 3n1gm4 bull371n b04rd 50f7w4r3 | + +Additional text styles are available for numbers: + +| Style | Description | +|-------------------|---------------| +| `sizeWithAbbr` | File size (converted from bytes) with abbreviation such as `1 MB`, `2.2 GB`, `34 KB`, etc. | +| `sizeWithoutAbbr` | Just the file size (converted from bytes) without the abbreviation. For example: 1024 becomes 1. | +| `sizeAbbr` | Just the abbreviation given a file size (converted from bytes) such as `MB` or `GB`. | +| `countWithAbbr` | Count with abbreviation such as `100 K`, `4.3 B`, etc. | +| `countWithoutAbbr` | Just the count | +| `countAbbr` | Just the abbreviation such as `M` for millions. | +| `durationHours` | Converts the provided *hours* value to something friendly such as `4 hours`, or `4 days`. | +| `durationMinutes` | Converts the provided *minutes* to something friendly such as `10 minutes` or `2 hours` | +| `durationSeconds` | Converts the provided *seconds* to something friendly such as `23 seconds` or `2 minutes` | + + +#### Examples +Suppose a format object contains the following elements: `userName` and `affils`. We could create a `itemFormat` entry that builds a item to our specifications: `|04{userName!styleFirstLower} |08- |13{affils}`. This may produce a string such as "eVIL cURRENT - Razor 1911". + +Remember that a Python [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language) style syntax is available for widths, alignment, number prevision, etc. as well. A number can be made to be more human readable for example: `{byteSize:,}` may yield "1,123,456". \ No newline at end of file diff --git a/docs/art/themes.md b/docs/art/themes.md new file mode 100644 index 00000000..577a95fe --- /dev/null +++ b/docs/art/themes.md @@ -0,0 +1,132 @@ +--- +layout: page +title: Themes +--- +## Themes +ENiGMA½ comes with an advanced theming system allowing system operators to highly customize the look and feel of their boards. A given installation can have as many themes as you like for your users to choose from. + +## General Information +Themes live in `art/themes/`. Each theme (and thus it's *theme ID*) is a directory within the `themes` directory. The theme itself is simply a collection of art files, and a `theme.hjson` file that further defines layout, colors & formatting, etc. ENiGMA½ comes with a default theme by [Luciano Ayres](http://blocktronics.org/tag/luciano-ayres/) of [Blocktronics](http://blocktronics.org/) called Mystery Skull. This theme is in `art/themes/luciano_blocktronics`, and thus it's *theme ID* is `luciano_blocktronics`. + +## Art +For information on art files, see [General Art Information](general.md). TL;DR: In general, to theme a piece of art, create a version of it in your themes directory. + +:information_source: Remember that by default, the system will allow for randomly selecting art (in one of the directories mentioned above) by numbering it: `FOO1.ANS`, `FOO2.ANS`, etc.! + +## Theme Sections +Themes are some important sections to be aware of: + +| Config Item | Description | +|-------------|----------------------------------------------------------| +| `info` | This section describes the theme. | +| `customization` | The beef! | + +### Info Block +The `info` configuration block describes the theme itself. + +| Item | Required | Description | +|-------------|----------|----------------------------------------------------------| +| `name` | :+1: | Name of the theme. Be creative! | +| `author` | :+1: | Author of the theme/artwork. | +| `group` | :-1: | Group/affils of author. | +| `enabled` | :-1: | Boolean of enabled state. If set to `false`, this theme will not be available to your users. If a user currently has this theme selected, the system default will be selected for them at next login. | + +### Customization Block +The `customization` block in is itself broken up into major parts: + +| Item | Description | +|-------------|---------------------------------------------------| +| `defaults` | Default values to use when this theme is active. These values override system defaults, but can still be overridden themselves in specific areas of your theme. | +| `menus` | The bulk of what you theme in the system will be here. Any menu (that is, anything you find in `menu.hjson`) can be tweaked. | +| `prompts` | Similar to `menus`, this file themes prompts found in `prompts.hjson`. | + +#### Defaults +| Item | Description | +|-------------|---------------------------------------------------| +| `passwordChar` | Character to display in password fields. Defaults to `*` | +| `dateFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for dates. | +| `timeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for times. | +| `dateTimeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for date/time combinations. | + +Example: +```hjson +defaults: { + dateTimeFormat: { + short: MMM Do h:mm a + } +} +``` + +#### Menus Block +Each *key* in the `menus` block matches up with a key found in your `menu.hjson`. For example, consider a `matrix` menu defined in `menu.hjson`. In addition to perhaps providing a `MATRIX.ANS` in your themes directory, you can also theme other parts of the menu via a `matrix` entry in `theme.hjson`. + +Major areas to override/theme: +* `config`: Override and/or provide additional theme information over that found in the `menu.hjson`'s entry. Common entries here are for further overriding date/time formats, and custom range info formats (`InfoFormat`). See Entry Formatting in [MCI Codes](mci.md) and Custom Range Info Formatting below. +* `mci`: Set per-MCI code properties such as `height`, `width`, text styles, etc. See [MCI Codes](mci.md) for a more information. + +Two formats for `mci` blocks are allowed: +* Verbose where a form ID(s) are supplied. +* Shorthand if only a single/first form is needed. + +Example: Verbose `mci` with form IDs: +```hjson +newUserFeedbackToSysOp: { + 0: { + mci: { + TL1: { width: 19, textOverflow: "..." } + ET2: { width: 19, textOverflow: "..." } + ET3: { width: 19, textOverflow: "..." } + } + } + 1: { + mci: { + MT1: { height: 14 } + } + } +} +``` + +Example: Shorthand `mci` format: +```hjson +matrix: { + mci: { + VM1: { + itemFormat: "|03{text}" + focusItemFormat: "|11{text!styleFirstLower}" + } + } +} +``` + +##### Custom Range Info Formatting +Many modules support "custom range" MCI items. These are MCI codes that are left to the user to define using a format object specific to the module. For example, consider the `msg_area_list` module: This module sets MCI codes 10+ (`%TL10`, `%TL11`, etc.) as "custom range". When theming you can place these MCI codes in your artwork then define the format in `theme.hjson`: + +```hjson +messageAreaChangeCurrentArea: { + config: { + areaListInfoFormat10: "|15{name}|07: |03{desc}" + } +} +``` + +## Creating Your Own +:warning: ***IMPORTANT!*** It is recommended you don't make any customisations to the included `luciano_blocktronics' theme. Instead, create your own and make changes to that instead: + +1. Copy `/art/themes/luciano_blocktronics` to `art/themes/your_board_theme` +2. Update the `info` block at the top of the theme.hjson file: +``` hjson + info: { + name: Awesome Theme + author: Cool Artist + group: Sick Group + enabled: true // default + } +``` + +3. If desired, you may make this the default system theme in `config.hjson` via `theme.default`. `theme.preLogin` may be set if you want this theme used for pre-authenticated users. Both of these values also accept `*` if you want the system to radomly pick. +``` hjson + theme: { + default: your_board_theme + preLogin: * + } +``` diff --git a/docs/assets/css/style.scss b/docs/assets/css/style.scss new file mode 100644 index 00000000..5f1392bd --- /dev/null +++ b/docs/assets/css/style.scss @@ -0,0 +1,4 @@ +--- +--- + +@import 'jekyll-theme-hacker'; diff --git a/docs/assets/images/bkg.png b/docs/assets/images/bkg.png new file mode 100644 index 00000000..d10e5caf Binary files /dev/null and b/docs/assets/images/bkg.png differ diff --git a/docs/assets/images/bullet.png b/docs/assets/images/bullet.png new file mode 100644 index 00000000..c8f8de1a Binary files /dev/null and b/docs/assets/images/bullet.png differ diff --git a/docs/assets/images/colour-codes.png b/docs/assets/images/colour-codes.png new file mode 100644 index 00000000..83267a62 Binary files /dev/null and b/docs/assets/images/colour-codes.png differ diff --git a/docs/images/enigma-bbs.png b/docs/assets/images/enigma-bbs.png similarity index 100% rename from docs/images/enigma-bbs.png rename to docs/assets/images/enigma-bbs.png diff --git a/docs/assets/images/enigma-logo.png b/docs/assets/images/enigma-logo.png new file mode 100644 index 00000000..94f652ae Binary files /dev/null and b/docs/assets/images/enigma-logo.png differ diff --git a/docs/assets/images/vtxclient.png b/docs/assets/images/vtxclient.png new file mode 100644 index 00000000..99261ced Binary files /dev/null and b/docs/assets/images/vtxclient.png differ diff --git a/docs/config.md b/docs/config.md deleted file mode 100644 index ca760676..00000000 --- a/docs/config.md +++ /dev/null @@ -1,129 +0,0 @@ -# Configuration -Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error. - -## System Configuration -The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `~/.config/enigma-bbs/config.hjson` though you can override this with the `--config` parameter when invoking `main.js`. Values found in core/config.js may be overridden by simply providing the object members you wish replace. - -**Windows note**: **~** resolves to *C:\Users\YOURLOGINNAME\* on modern Windows installations, e.g. `C:\Users\NuSkooler\.config\enigma-bbs\config.hjson` - -### oputil.js -Please see `oputil.js config` for configuration generation options. - -### Example: System Name -`core/config.js` provides the default system name as follows: -```javascript -general : { - boardName : 'Another Fine ENiGMA½ System' -} -``` - -To override this for your own board, in `config.hjson`: -```hjson -general: { - boardName: Super Fancy BBS -} -``` - -(Note the very slightly different syntax. **You can use standard JSON if you wish**) - -### Specific Areas of Interest -* [Menu System](menu_system.md) -* [Message Conferences](msg_conf_area.md) -* [Message Networks](msg_networks.md) -* [File Base](file_base.md) -* [File Archives & Archivers](archives.md) -* [Doors](doors.md) -* [MCI Codes](mci.md) -* [Web Server](web_server.md) -...and other stuff [in the /docs directory](./) - - -### A Sample Configuration -Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked. - -**This is for illustration purposes! Do not cut & paste this configuration!** - - -```hjson -{ - general: { - boardName: A Sample BBS - } - - defaults: { - theme: super-fancy-theme - } - - preLoginTheme: luciano_blocktronics - - messageConferences: { - local_general: { - name: Local - desc: Local Discussions - default: true - - areas: { - local_enigma_dev: { - name: ENiGMA 1/2 Development - desc: Discussion related to development and features of ENiGMA 1/2! - default: true - } - } - } - - agoranet: { - name: Agoranet - desc: This network is for blatant exploitation of the greatest BBS scene art group ever.. ACiD. - - areas: { - agoranet_bbs: { - name: BBS Discussion - desc: Discussion related to BBSs - } - } - } - } - - messageNetworks: { - ftn: { - areas: { - agoranet_bbs: { /* hey kids, this matches above! */ - - // oh oh oh, and this one pairs up with a network below - network: agoranet - tag: AGN_BBS - uplinks: "46:1/100" - } - } - - networks: { - agoranet: { - localAddress: "46:3/102" - } - } - } - } - - scannerTossers: { - ftn_bso: { - schedule: { - import: every 1 hours or @watch:/home/enigma/bink/watchfile.txt - export: every 1 hours or @immediate - } - - defaultZone: 46 - defaultNetwork: agoranet - - nodes: { - "46:*": { - archiveType: ZIP - encoding: utf8 - } - } - } - } -} -``` - -## Menus -See [the menu system docs](menu_system.md) \ No newline at end of file diff --git a/docs/configuration/acs.md b/docs/configuration/acs.md new file mode 100644 index 00000000..dd57ce22 --- /dev/null +++ b/docs/configuration/acs.md @@ -0,0 +1,79 @@ +--- +layout: page +title: Access Condition System (ACS) +--- + +## Access Condition System (ACS) +ENiGMA½ uses an Access Condition System (ACS) that is both familiar to oldschool BBS operators and has it's own style. With ACS, SysOp's are able to control access to various areas of the system based on various conditions such as group membership, connection type, etc. Various touch points in the system are configured to allow for `acs` checks. In some cases ACS is a simple boolean check while others (via ACS blocks) allow to define what conditions must be true for certain _rights_ such as `read` and `write` (though others exist as well). + +## ACS Codes +The following are ACS codes available as of this writing: + +| Code | Condition | +|------|-------------| +| LC | Connection is local | +| AGage | User's age is >= _age_ | +| ASstatus, AS[_status_,...] | User's account status is _group_ or one of [_group_,...] | +| ECencoding | Terminal encoding is set to _encoding_ where `0` is `CP437` and `1` is `UTF-8` | +| GM[_group_,...] | User belongs to one of [_group_,...] | +| NNnode, NN[_node_,...] | Current node is _node_ or one of [_node_,...] | +| NPposts | User's number of message posts is >= _posts_ | +| NCcalls | User's number of calls is >= _calls_ | +| SC | Connection is considered secure (SSL, secure WebSockets, etc.) | +| THheight | Terminal height is >= _height_ | +| TWwidth | Terminal width is >= _width_ | +| TM[_themeId_,...] | User's current theme ID is one of [_themeId_,...] (e.g. `luciano_blocktronics`) | +| TT[_termType_,...] | User's current terminal type is one of [_termType_,...] (`ANSI-BBS`, `utf8`, `xterm`, etc.) | +| IDid, ID[_id_,...] | User's ID is _id_ or oen of [_id_,...] | +| WDweekDay, WD[_weekDay_,...] | Current day of week is _weekDay_ or one of [_weekDay_,...] where `0` is Sunday, `1` is Monday, and so on. | +| AAdays | Account is >= _days_ old | +| BUbytes | User has uploaded >= _bytes_ | +| UPuploads | User has uploaded >= _uploads_ files | +| BDbytes | User has downloaded >= _bytes_ | +| DLdownloads | User has downloaded >= _downloads_ files | +| NRratio | User has upload/download count ratio >= _ratio_ | +| KRratio | User has a upload/download byte ratio >= _ratio_ | +| PCratio | User has a post/call ratio >= _ratio_ | +| MMminutes | It is currently >= _minutes_ past midnight (system time) | +| ACachievementCount | User has >= _achievementCount_ achievements | +| APachievementPoints | User has >= _achievementPoints_ achievement points | +| AFauthFactor | User's current *Authentication Factor* is >= _authFactor_. Authentication factor 1 refers to username + password (or PubKey) while factor 2 refers to 2FA such as One-Time-Password authentication. | +| ARauthFactorReq | Current user **requires** an Authentication Factor >= _authFactorReq_ | + +## ACS Strings +ACS strings are one or more ACS codes in addition to some basic language semantics. + +The following logical operators are supported: +* `!` NOT +* `|` OR +* `&` AND (this is the default) + +ENiGMA½ also supports groupings using `(` and `)`. Lastly, some ACS codes allow for lists of acceptable values using `[` and `]` — for example, `GM[users,sysops]`. + +### Example ACS Strings +* `NC2`: User must have called two more more times for the check to return true (to pass) +* `ID1`: User must be ID 1 (the +op) +* `GM[elite,power]`: User must be a member of the `elite` or `power` user group (they could be both) +* `ID1|GM[co-op]`: User must be ID 1 (SysOp!) or belong to the `co-op` group +* `!TH24`: Terminal height must NOT be 24 + +## ACS Blocks +Some areas of the system require more than a single ACS string. In these situations an *ACS block* is used to allow for finer grain control. As an example, consider the following file area `acs` block: +```hjson +acs: { + read: GM[users] + write: GM[sysops,co-ops] + download: GM[elite-users] +} +``` + +All `users` can read (see) the area, `sysops` and `co-ops` can write (upload), and only members of the `elite-users` group can download. + +## ACS Touch Points +The following touch points exist in the system. Many more are planned: + +* [Message conferences and areas](/docs/messageareas/configuring-a-message-area.md) +* [File base areas](/docs/filebase/first-file-area.md) and [Uploads](/docs/filebase/uploads.md) +* Menus within [Menu HJSON (menu.hjson)](menu-hjson.md) + +See the specific areas documentation for information on available ACS checks. diff --git a/docs/archive.md b/docs/configuration/archivers.md similarity index 70% rename from docs/archive.md rename to docs/configuration/archivers.md index 68453098..3f5d941e 100644 --- a/docs/archive.md +++ b/docs/configuration/archivers.md @@ -1,4 +1,7 @@ -# File Archives & Archivers +--- +layout: page +title: Archivers +--- ENiGMA½ can detect and process various archive formats such as zip and arj for a variety of tasks from file upload processing to EchoMail bundle compress/decompression. The `archives` section of `config.hjson` is used to override defaults, add new handlers, and so on. ## Archivers @@ -8,24 +11,35 @@ 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 *nix environments. See http://p7zip.sourceforge.net/ for details. +* Homepage/package: [7-zip.org](http://www.7-zip.org/). Generally obtained from a `p7zip` package in UNIX-like environments. See http://p7zip.sourceforge.net/ for details. +* Notes: Versions previous to 0.0.10-alpha defaulted to using 7zip for .zip files as well, but newer versions of the package give "volume" errors at times. See InfoZip below. + +#### InfoZip +* Formats: .zip +* Key: InfoZip +* Homepage/package: http://infozip.sourceforge.net/. Often already available in Linux. You will need `zip` and `unzip` in ENiGMA's path. #### Lha * Formats: LHA files such as .lzh. * Key: `Lha` -* Homepage/package: `lhasa` on most *nix environments. See also https://fragglet.github.io/lhasa/ and http://www2m.biglobe.ne.jp/~dolphin/lha/lha-unix.htm +* Homepage/package: `lhasa` on most UNIX-like environments. See also https://fragglet.github.io/lhasa/ and http://www2m.biglobe.ne.jp/~dolphin/lha/lha-unix.htm + +#### Lzx +* Formats: Amiga LZX +* Key: `Lzx` +* Homepage/package: `unlzx` under most UNIX-like platforms ([Debian/Ubuntu](https://launchpad.net/~rzr/+archive/ubuntu/ppa/+build/2486127), [RedHat](https://fedora.pkgs.org/28/rpm-sphere/unlzx-1.1-4.1.x86_64.rpm.html), [Source](http://xavprods.free.fr/lzx/)) #### Arj * Formats: .arj * Key: `Arj` -* Homepage/package: `arj` on most *nix environments. +* Homepage/package: `arj` on most UNIX-like environments. #### Rar * Formats: .Rar * Key: `Rar` -* Homepage/package: `unrar` on most *nix environments. See also https://blog.hostonnet.com/unrar +* Homepage/package: `unrar` on most UNIX-like environments. See also https://blog.hostonnet.com/unrar ### Archiver Configuration Archiver entries in `config.hjson` are mostly self explanatory with the exception of `list` commands that require some additional information. The `args` member for an entry is an array of arguments to pass to `cmd`. Some variables are available to `args` that will be expanded by the system: diff --git a/docs/configuration/colour-codes.md b/docs/configuration/colour-codes.md new file mode 100644 index 00000000..9ad9abdd --- /dev/null +++ b/docs/configuration/colour-codes.md @@ -0,0 +1,25 @@ +--- +layout: page +title: Colour Codes +--- +ENiGMA½ supports Renegade-style pipe colour codes for formatting strings. You'll see them used in [`config.hjson`](config-hjson), +[`prompt.hjson`](prompt-hjson), [`menu.hjson`](menu-hjson), and can also be used in places like the oneliner, rumour mod, +full screen editor etc. + +## Usage +When ENiGMA½ encounters colour codes in strings, they'll be processed in order and combined where possible. + +For example: + +`|15|17Example` - white text on a blue background + +`|10|23Example` - light green text on a light grey background + + +## Colour Code Reference + +:warning: Colour codes |24 to |31 are considered "blinking" or "iCE" colour codes. On terminals that support them they'll +be shown as the correct colours - for terminals that don't, or are that are set to "blinking" mode - they'll blink! + +![Regegade style colour codes](../assets/images/colour-codes.png "Colour Codes") + diff --git a/docs/configuration/config-hjson.md b/docs/configuration/config-hjson.md new file mode 100644 index 00000000..38392943 --- /dev/null +++ b/docs/configuration/config-hjson.md @@ -0,0 +1,48 @@ +--- +layout: page +title: System Configuration +--- +## System Configuration +The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace. + +See also [HJSON General Information](hjson.md) for more information on the HJSON format. + +### Creating a Configuration +Your initial configuration skeleton should be created using the `oputil.js` command line utility. From your enigma-bbs root directory: +``` +./oputil.js config new +``` + +You will be asked a series of questions to create an initial configuration. + +### Overriding Defaults +The file `core/config.js` provides various defaults to the system that you can override via `config.hjson`. For example, the default system name is defined as follows: +```javascript +general : { + boardName : 'Another Fine ENiGMA½ System' +} +``` + +To override this for your own board, in `config.hjson`: +```hjson +general: { + boardName: Super Fancy BBS +} +``` + +(Note the very slightly [HJSON](hjson.md) different syntax. **You can use standard JSON if you wish!**) + +While not everything that is available in your `config.hjson` file can be found defaulted in `core/config.js`, a lot is. [Poke around and see what you can find](https://github.com/NuSkooler/enigma-bbs/blob/master/core/config.js)! + +### Configuration Sections +Below is a list of various configuration sections. There are many more, but this should get you started: + +* [ACS](acs.md) +* [Archivers](archivers.md): Set up external archive utilities for handling things like ZIP, ARJ, RAR, and so on. +* [Email](email.md): System email support. +* [Event Scheduler](event-scheduler.md): Set up events as you see fit! +* [File Base](/docs/filebase/index.md) +* [File Transfer Protocols](file-transfer-protocols.md): Oldschool file transfer protocols such as X/Y/Z-Modem! +* [Message Areas](/docs/messageareas/configuring-a-message-area.md), [Networks](/docs/messageareas/message-networks.md), [NetMail](/docs/messageareas/netmail.md), etc. +* ...and a **lot** more! Explore the docs! If you can't find something, please contact us! + diff --git a/docs/configuration/creating-config.md b/docs/configuration/creating-config.md new file mode 100644 index 00000000..5d845d5e --- /dev/null +++ b/docs/configuration/creating-config.md @@ -0,0 +1,14 @@ +--- +layout: page +title: Creating Initial Config Files +--- +Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error. + +## Initial Configuration +Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory: +```bash +./oputil.js config new +``` + +You will be asked a series of questions to create an initial configuration, which will be saved to `/enigma-bbs-install-path/config/config.hjson`. This will also produce `config/-menu.hjson` and `config/-prompt.hjson` files (where `` is replaced by the name you provided in the steps above). See [Menu HJSON](menu-hjson.md) and [Prompt HJSON](prompt-hjson.md) for more information. + diff --git a/docs/configuration/directory-structure.md b/docs/configuration/directory-structure.md new file mode 100644 index 00000000..d968df17 --- /dev/null +++ b/docs/configuration/directory-structure.md @@ -0,0 +1,20 @@ +--- +layout: page +title: Directory Structure +--- +All paths mentioned here are relative to the ENiGMA½ checkout directory. + +| Directory | Description | +|---------------------|-----------------------------------------------------------------------------------------------------------| +| `/art/general` | Non-theme art - welcome ANSI, logoff ANSI, etc. See [General Art]({{ site.baseurl }}{% link art/general.md %}). +| `/art/themes` | Theme art. Themes should be in their own subdirectory and contain a theme.hjson. See [Themes]({{ site.baseurl }}{% link art/themes.md %}). +| `/config` | config.hjson, [menu.hjson]({{ site.baseurl }}{% link configuration/menu-hjson.md %}) and prompt.hjson storage. Also default path for SSL certs and public/private keys +| `/db` | All ENiGMA½ databases in Sqlite3 format +| `/docs` | These docs ;-) +| `/dropfiles` | Dropfiles created for [local doors]({{ site.baseurl }}{% link modding/local-doors.md %}) +| `/logs` | Logs. See [Monitoring Logs]({{ site.baseurl }}{% link troubleshooting/monitoring-logs.md %}) +| `/misc` | Stuff with no other home; reset password templates, common password lists, other random bits +| `/mods` | User mods. See [Modding]({{ site.baseurl }}{% link modding/existing-mods.md %}) +| `/node_modules` | External libraries required by ENiGMA½, installed when you run `npm install` +| `/util` | Various tools used in running/debugging ENiGMA½ +| `/www` | ENiGMA½'s built in webserver root directory \ No newline at end of file diff --git a/docs/configuration/email.md b/docs/configuration/email.md new file mode 100644 index 00000000..eb13ef71 --- /dev/null +++ b/docs/configuration/email.md @@ -0,0 +1,49 @@ +--- +layout: page +title: Email +--- +## Email Support +ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid [Nodemailer](https://nodemailer.com/about/) compatible `email` block in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %}). Nodemailer supports SMTP in addition to many pre-defined services for ease of use. The `transport` block within `email` must be Nodemailer compatible. + +Additional email support will come in the near future. + +## Services + +If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) and [Zoho](https://www.zoho.com/mail/) both provide reliable and free services. + +## Example Configurations + +Example 1 - SMTP: +```hjson +email: { + defaultFrom: sysop@bbs.awesome.com + + transport: { + host: smtp.awesomeserver.com + port: 587 + secure: false + auth: { + user: leisuresuitlarry + pass: sierra123 + } + } + } +``` + +Example 2 - Zoho +```hjson +email: { + defaultFrom: sysop@bbs.awesome.com + + transport: { + service: Zoho + auth: { + user: noreply@bbs.awesome.com + pass: yuspymypass + } + } +} +``` + +## Lockout Reset +If email is available on your system and you allow email-driven password resets, you may elect to allow unlocking accounts at the time of a password reset. This is controlled by the `users.unlockAtEmailPwReset` configuration option. If an account is locked due to too many failed login attempts, a user may reset their password to remedy the situation themselves. diff --git a/docs/configuration/event-scheduler.md b/docs/configuration/event-scheduler.md new file mode 100644 index 00000000..77b56f15 --- /dev/null +++ b/docs/configuration/event-scheduler.md @@ -0,0 +1,79 @@ +--- +layout: page +title: Event Scheduler +--- +## Event Scheduler +The ENiGMA½ scheduler allows system operators to configure arbitrary events that can can fire based on date and/or time, or by watching for changes in a file. Events can kick off internal handlers, custom modules, or binaries & scripts. + +## Scheduling Events +To create a scheduled event, create a new configuration block in `config.hjson` under `eventScheduler.events`. + +Events can have the following members: + +| Item | Required | Description | +|------|----------|-------------| +| `schedule` | :+1: | A [Later style](https://bunkat.github.io/later/parsers.html#text) parsable schedule string such as `at 4:00 am`, or `every 24 hours`. Can also be (or contain) an `@watch` clause. See **Schedules** below for details. | +| `action` | :+1: | Action to perform when the schedule is triggered. May be an `@method` or `@execute` spec. See **Actions** below. | +| `args` | :-1: | An array of arguments to pass along to the method or binary specified in `action`. | + +### Schedules +As mentioned above, `schedule` may contain a [Later style](https://bunkat.github.io/later/parsers.html#text) parsable schedule string and/or an `@watch` clause. + +`schedule` examples: +* `every 2 hours` +* `on the last day of the week` +* `after 12th hour` + +An `@watch` clause monitors a specified file for changes and takes the following form: `@watch:` where `` is a fully qualified path. + +:information_source: If you would like to have a schedule **and** watch a file for changes, place the `@watch` clause second and seperated with the word `or`. For example: `every 24 hours or @watch:/path/to/somefile.txt`. + +### Actions +Events can kick off actions by calling a method (function) provided by the system or custom module in addition to executing arbritary binaries or scripts. + +#### Methods +An action with a `@method` can take the following forms: + +* `@method:/full/path/to/module.js:methodName`: Executes `methodName` at `/full/path/to/module.js`. +* `@method:rel/path/to/module.js:methodName`: Executes `methodName` using the *relative* path `rel/path/to/module.js`. Paths for `@method` are relative to the ENiGMA½ installation directory. + +Methods are passed any supplied `args` in the order they are provided. + +##### Method Signature +To create your own method, simply `export` a method with the following signature: `(args, callback)`. Methods are executed asynchronously. + +Example: +```javascript +// my_custom_mod.js +exports.myCustomMethod = (args, cb) => { + console.log(`Hello, ${args[0]}!`); + return cb(null); +} +``` + +#### Executables +When using the `@execute` action, a binary or script can be executed. A full path or just the binary name is acceptable. If using the form without a path, the binary much be in ENiGMA½'s `PATH`. + +Examples: +* `@execute:/usr/bin/foo` +* `@execute:foo` + +Just like with methods, any supplied `args` will be passed along. + +## Example Entries + +Post a message to supplied networks every Monday night using the message post mod (see modding): +```hjson +eventScheduler: { + events: { + enigmaAdToNetworks: { + schedule: at 10:35 pm on Mon + action: @method:mods/message_post_evt/message_post_evt.js:messagePostEvent + args: [ + "fsx_bot" + "/home/enigma-bbs/ad.asc" + ] + } + } +} +``` \ No newline at end of file diff --git a/docs/configuration/file-transfer-protocols.md b/docs/configuration/file-transfer-protocols.md new file mode 100644 index 00000000..2f7d48ac --- /dev/null +++ b/docs/configuration/file-transfer-protocols.md @@ -0,0 +1,52 @@ +--- +layout: page +title: File Transfer Protocols +--- +ENiGMA½ currently relies on external executable binaries for "legacy" file transfer protocols such as X, Y, and ZModem. Remember that ENiGMA½ also support modern web (HTTP/HTTPS) downloads! + +## File Transfer Protocols +File transfer protocols are managed via the `fileTransferProtocols` configuration block of `config.hjson`. Each entry defines an **external** protocol handler that can be used for uploads (recv), downloads (send), or both. Depending on the protocol and handler, batch receiving of files (uploads) may also be available. + +### Predefined File Transfer Protocols +The following file transfer protocols are pre-configured in ENiGMA½ as of this writing. System operators may override or extend this list. PRs are welcome for pre-configured additions! + +#### SEXYZ +[SEXYZ from Synchronet](http://wiki.synchro.net/util:sexyz) offers a nice X, Y, and ZModem implementation including ZModem-8k & works under *nix and Windows based systems. As of this writing, ENiGMA½ is pre-configured to support ZModem-8k, XModem, and YModem using SEXYZ. An x86_64 Linux binary, and hopefully more in the future, [can be downloaded here](https://l33t.codes/bbs-linux-binaries/). + +#### sz/rz +ZModem-8k is configured using the standard Linux [sz(1)](https://linux.die.net/man/1/sz) and [rz(1)](https://linux.die.net/man/1/rz) binaries. Note that these binaries also support XModem and YModem, and as such adding the configurations to your system should be fairly straight forward. + +Generally available as `lrzsz` under Apt or Yum type packaging. + +### File Transfer Protocol Configuration +The following top-level members are available to an external protocol configuration: +* `name`: Required; Display name of the protocol +* `type`: Required; Currently must be `external`. This will be expanded upon in the future with built in protocols. +* `sort`: Optional; Sort key. If not provided, `name` will be used for sorting. + +For protocols of type `external` the following members may be defined: +* `sendCmd`: Required for protocols that can send (allow user downloads); The command/binary to execute. +* `sendArgs`: Required if using `sendCmd`; An array of arguments. A placeholder of `{fileListPath}` may be used to supply a path to a **file containing** a list of files to send, or `{filePaths}` to supply *1:n* individual file paths to send. +* `recvCmd`: Required for protocols that can receive (allow user uploads); The command/binary to execute. +* `recvArgs`: Required if using `recvCmd` and supporting **batch** uploads; An array of arguments. A placeholder of `{uploadDir}` may be used to supply the system provided upload directory. If `{uploadDir}` is not present, the system expects uploaded files to be placed in CWD which will be set to the upload directory. +* `recvArgsNonBatch`: Required if using `recvCmd` and supporting non-batch (single file) uploads; A placeholder of `{fileName}` may be supplied to indicate to the protocol what the uploaded file should be named (this will be collected from the user before the upload starts). +* `escapeTelnet`: Optional; If set to `true`, escape all internal Telnet related codes such as IAC's. This option is required for external protocol handlers such as `sz` and `rz` that do not escape themselves. + +### Adding Your Own +Take a look a the example below as well as [core/config.js](/core/config.js). + +#### Example File Transfer Protocol Configuration +``` +zmodem8kSexyz : { + name : 'ZModem 8k (SEXYZ)', + type : 'external', + sort : 1, + external : { + sendCmd : 'sexyz', + sendArgs : [ '-telnet', '-8', 'sz', '@{fileListPath}' ], + recvCmd : 'sexyz', + recvArgs : [ '-telnet', '-8', 'rz', '{uploadDir}' ], + recvArgsNonBatch : [ '-telnet', '-8', 'rz', '{fileName}' ], + } +} +``` diff --git a/docs/configuration/hjson.md b/docs/configuration/hjson.md new file mode 100644 index 00000000..74b6eb01 --- /dev/null +++ b/docs/configuration/hjson.md @@ -0,0 +1,69 @@ +--- +layout: page +title: HJSON Config Files +--- +## JSON for Humans! +HJSON is the configuration file format used by ENiGMA½ for [System Configuration](config-hjson.md), [Menus](menu-hjson.md), [Prompts](prompt-hjson.md), etc. [HJSON](https://hjson.org/) is is [JSON](https://json.org/) for humans! + +For those completely unfamiliar, JSON stands for JavaScript Object Notation. But don't let that scare you! JSON is simply a text file format with a bit of structure ― kind of like a fancier INI file. HJSON on the other hand as mentioned previously, is JSON for humans. That is, it has the following features and more: + +* More resilient to syntax errors such as missing a comma +* Strings generally do not need to be quoted. Multi-line strings are also supported! +* Comments are supported (JSON doesn't allow this!): `#`, `//` and `/* ... */` style comments are allowed. +* Keys never need to be quoted +* ...much more! See [the official HJSON website](https://hjson.org/). + +## Terminology +Through the documentation, some terms regarding HJSON and configuration files will be used: + +* `config.hjson`: Refers to `/path/to/enigma-bbs/config/config.hjson`. See [System Configuration](config-hjson.md). +* `menu.hjson`: Refers to `/path/to/enigma-bbs/config/-menu.hjson`. See [Menus](menu-hjson.md). +* `prompt.hjson`: Refers to `/path/to/enigma-bbs/config/-prompt.hjson`. See [Prompts](prompt-hjson.md). +* Configuration *key*: Elements in HJSON are name-value pairs where the name is the *key*. For example, provided `foo: bar`, `foo` is the key. +* Configuration *section* or *block* (also commonly called an "Object" in code): This is referring to a section in a HJSON file that starts with a *key*. For example: +```hjson +someSection: { + foo: bar +} +``` +Note that `someSection` is the configuration *section* (or *block*) and `foo: bar` is within it. + +## Editing HJSON +HJSON is a text file format, and ENiGMA½ configuration files **should always be saved as UTF-8**. + +It is **highly** recommended to use a text editor that has HJSON support. A few (but not all!) examples include: +* [Sublime Text](https://www.sublimetext.com/) via the `sublime-hjson` package. +* [Visual Studio Code](https://code.visualstudio.com/) via the `vscode-hjson` plugin. +* [Notepad++](https://notepad-plus-plus.org) via the `npp-hjson` plugin. + +See https://hjson.org/users.html for more more editors & plugins. + +### Hot-Reload A.K.A. Live Editing +ENiGMA½'s configuration, menu, and theme files can edited while your BBS is running. When a file is saved, it is hot-reloaded into the running system. If users are currently connected and you change a menu for example, the next reload of that menu will show the changes. + +### CaSe SeNsiTiVE +Configuration keys are **case sensitive**. That means if a configuration key is `boardName` for example, `boardname`, or `BOARDNAME` **will not work**. + +### Escaping +Some values need escaped. This is especially important to remember on Windows machines where file paths contain backslashes (`\`). To specify a path to `C:\foo\bar\baz.exe` for example, an entry may look like this in your configuration file: +```hjson +something: { + path: "C:\\foo\\bar\\baz.exe" // note the extra \'s! +} +``` + +## Tips & Tricks +### JSON Compatibility +Remember that standard JSON is fully compatible with HJSON. If you are more comfortable with JSON (or have an editor that works with JSON that you prefer) simply convert your config file(s) to JSON and use that instead! + +HJSON can be converted to JSON with the `hjson` CLI: +```bash +cd /path/to/enigma-bbs +cp ./config/config.hjson ./config/config.hjson.backup +./node_modules/hjson/bin/hjson ./config/config.hjson.backup -j > ./config/config.hjson +``` + +You can always convert back to HJSON by omitting `-j` in the command above. + +### oputil +You can easily dump out your current configuration in a pretty-printed style using oputil: ```./oputil.js config cat``` diff --git a/docs/configuration/menu-hjson.md b/docs/configuration/menu-hjson.md new file mode 100644 index 00000000..8ddda2d8 --- /dev/null +++ b/docs/configuration/menu-hjson.md @@ -0,0 +1,252 @@ +--- +layout: page +title: Menu HSJON +--- +## Menu HJSON +The core of a ENiGMA½ based BBS is `menu.hjson`. Note that when `menu.hjson` is referenced, we're actually talking about `config/yourboardname-menu.hjson` or similar. This file determines the menus (or screens) a user can see, the order they come in and how they interact with each other, ACS configuration, etc. Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format. See [HJSON General Information](hjson.md) for more information. + +Entries in `menu.hjson` are often referred to as *blocks* or *sections*. Each entry defines a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to: + +* Classical Main, Messages, and File menus +* Art file display +* Module driven menus such as door launchers and other custom mods + +Menu entries live under the `menus` section of `menu.hjson`. The *key* for a menu is it's name that can be referenced by other menus and areas of the system. + +## Common Menu Entry Members +Below is a table of **common** menu entry members. These members apply to most entries, though entries that are backed by a specialized module (ie: `module: bbs_list`) may differ. See documentation for the module in question for particulars. + +| Item | Description | +|--------|--------------| +| `desc` | A friendly description that can be found in places such as "Who's Online" or wherever the `%MD` MCI code is used. | +| `art` | An art file *spec*. See [General Art Information](/docs/art/general.md). | +| `next` | Specifies the next menu entry to go to next. Can be explicit or an array of possibilities dependent on ACS. See **Flow Control** in the **ACS Checks** section below. If `next` is not supplied, the next menu is this menus parent. | +| `prompt` | Specifies a prompt, by name, to use along with this menu. Prompts are configured in `prompt.hjson`. | +| `submit` | Defines a submit handler when using `prompt`. +| `form` | An object defining one or more *forms* available on this menu. | +| `module` | Sets the module name to use for this menu. See **Menu Modules** below. | +| `config` | An object containing additional configuration. See **Config Block** below. | + +### Menu Modules +A given menu entry is backed by a *menu module*. That is, the code behind it. Menus are considered "standard" if the `module` member is not specified (and therefore backed by `core/standard_menu.js`). + +See [Menu Modules](/docs/modding/menu-modules.md) for more information. + +### Config Block +The `config` block for a menu entry can contain common members as well as a per-module (when `module` is used) settings. + +| Item | Description | +|------|-------------| +| `cls` | If `true` the screen will be cleared before showing this menu. | +| `pause` | If `true` a pause will occur after showing this menu. Useful for simple menus such as displaying art or status screens. | +| `nextTimeout` | Sets the number of **milliseconds** before the system will automatically advanced to the `next` menu. | +| `baudRate` | See baud rate information in [General Art Information](/docs/art/general.md). | +| `font` | Sets a SyncTERM style font to use when displaying this menus `art`. See font listing in [General Art Information](/docs/art/general.md). | +| `menuFlags` | An array of menu flag(s) controlling menu behavior. See **Menu Flags** below. + +#### Menu Flags +The `menuFlags` field of a `config` block can change default behavior of a particular menu. + +| Flag | Description | +|------|-------------| +| `noHistory` | Prevents the menu from remaining in the menu stack / history. When this flag is set, when the **next** menu falls back, this menu will be skipped and the previous menu again displayed instead. Example: menuA -> menuB(noHistory) -> menuC: Exiting menuC returns the user to menuA. | +| `popParent` | When *this* menu is exited, fall back beyond the parent as well. Often used in combination with `noHistory`. | +| `forwardArgs` | If set, when the next menu is entered, forward any `extraArgs` arguments to *this* menu on to it. | + + +## Forms +ENiGMA½ uses a concept of *forms* in menus. A form is a collection of associated *views*. Consider a New User Application using the `nua` module: The default implementation utilizes a single form with multiple EditTextView views, a submit button, etc. Forms are identified by number starting with `0`. A given menu may have mutiple forms (often associated with different states or screens within the menu). + +Menus may also support more than one layout type by using a *MCI key*. A MCI key is a alpha-numerically sorted key made from 1:n MCI codes. This lets the system choose the appropriate set of form(s) based on theme or random art. An example of this may be a matrix menu: Perhaps one style of your matrix uses a vertical light bar (`VM` key) while another uses a horizontal (`HM` key). The system can discover the correct form to use by matching MCI codes found in the art to that of the available forms defined in `menu.hjson`. + +For more information on views and associated MCI codes, see [MCI Codes](/docs/art/mci.md). + +## Submit Handlers +When a form is submitted, it's data is matched against a *submit handler*. When a match is found, it's *action* is performed. + +### Submit Actions +Submit actions are declared using the `action` member of a submit handler block. Actions can be kick off system/global or local-to-module methods, launch other menus, etc. + +| Action | Description | +|--------|-------------| +| `@menu:menuName` | Takes the user to the *menuName* menu | +| `@systemMethod:methodName` | Executes the system/global method *methodName*. See **System Methods** below. | +| `@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: +``` +(callingMenu, formData, extraArgs, callback) +``` + +#### System Methods +Many built in global/system methods exist. Below are a few. See [system_menu_method](/core/system_menu_method.js) for more information. + +| 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`) | +| `prevConf` | Sets the users message conference to the previous available. | +| `nextConf` | Sets the users message conference to the next available. | +| `prevArea` | Sets the users message area to the previous available. | +| `nextArea` | Sets the users message area to the next available. | + +## Example +Let's look a couple basic menu entries: + +```hjson +telnetConnected: { + art: CONNECT + next: matrix + config: { nextTimeout: 1500 } +} +``` + +The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config). The entry sets up a few things: +* A `art` spec of `CONNECT`. (See [General Art Information](/docs/art/general.md)). +* A `next` entry up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`. +* An `config` block containing a single `nextTimeout` field telling the system to proceed to the `next` (`matrix`) entry automatically after 1500ms. + +Now let's look at `matrix`, the `next` entry from `telnetConnected`: + +```hjson +matrix: { + art: MATRIX + desc: Login Matrix + form: { + 0: { + // + // Here we have a MCI key of "VM". In this case we could + // omit this level since no other keys are present. + // + VM: { + mci: { + VM1: { + submit: true + focus: true + items: [ "login", "apply", "log off" ] + argName: matrixSubmit + } + } + submit: { + *: [ + { + value: { matrixSubmit: 0 } + action: @menu:login + } + { + value: { matrixSubmit: 1 }, + action: @menu:newUserApplication + } + { + value: { matrixSubmit: 2 }, + action: @menu:logoff + } + ] + } + } + + // + // If we wanted, we could declare a "HM" MCI key block here. + // This would allow a horizontal matrix style when the matrix art + // loaded contained a %HM code. + // + } + } +} +``` + +In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. Some other bits about the form: + +* `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` of `matrixSubmit` for this element view. +* The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). +* Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ matrixSubmit: 0 }` will match (due to 0 being the first index in the list and `matrixSubmit` being the arg name in question) causing `action` of `@menu:login` to be executed (go to `login` menu). + +## ACS Checks +Menu modules can check user ACS in order to restrict areas and perform flow control. See [ACS](acs.md) for available ACS syntax. + +### Menu Access +To restrict menu access add an `acs` key to `config`. Example: +``` +opOnlyMenu: { + desc: Ops Only! + config: { + acs: ID1 + } +} +``` + +### 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: +``` +login: { + desc: Logging In + next: [ + { + // >= 2 calls else you get the full login + acs: NC2 + next: loginSequenceLoginFlavorSelect + } + { + next: fullLoginSequenceLoginArt + } + ] +} +``` + +### Art Asset Selection +Another area in which you can apply ACS in a menu is art asset specs. + +```hjson +someMenu: { + desc: Neato Dorito + art: [ + { + acs: GM[couriers] + art: COURIERINFO + } + { + // show ie: EVERYONEELSE.ANS to everyone else + art: EVERYONEELSE + } + ] +} +``` diff --git a/docs/configuration/prompt-hjson.md b/docs/configuration/prompt-hjson.md new file mode 100644 index 00000000..993e5b8e --- /dev/null +++ b/docs/configuration/prompt-hjson.md @@ -0,0 +1,8 @@ +--- +layout: page +title: prompt.hjson +--- +:zap: This page is to describe general information the `prompt.hjson` file. It +needs fleshing out, please submit a PR if you'd like to help! + +See [HJSON General Information](hjson.md) for more information. diff --git a/docs/configuration/security.md b/docs/configuration/security.md new file mode 100644 index 00000000..afc583aa --- /dev/null +++ b/docs/configuration/security.md @@ -0,0 +1,64 @@ +--- +layout: page +title: Security +--- +## Security +Unlike in the golden era of BBSing, modern Internet-connected systems are prone to hacking attempts, eavesdropping, etc. While plain-text passwords, insecure data over [Plain Old Telephone Service (POTS)](https://en.wikipedia.org/wiki/Plain_old_telephone_service), and so on was good enough then, modern systems must employ protections against attacks. ENiGMA½ comes with many security features that help keep the system and your users secure — not limited to: +* Passwords are **never** stored in plain-text, but instead are stored using [Password-Based Key Derivation Function 2 (PBKDF2)](https://en.wikipedia.org/wiki/PBKDF2). Even the system operator can _never_ know your password! +* Alternatives to insecure Telnet logins are built in: [SSH](https://en.wikipedia.org/wiki/Secure_Shell) and secure [WebSockets](https://en.wikipedia.org/wiki/WebSocket) for example. +* A built in web server with [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) support (aka HTTPS). +* Optional [Two-Factor Authentication (2FA)](https://en.wikipedia.org/wiki/Multi-factor_authentication) via [One-Time-Password (OTP)](https://en.wikipedia.org/wiki/One-time_password) for users, supporting [Google Authenticator](http://google-authenticator.com/), [Time-Based One-Time Password Algorithm (TOTP, RFC-6238)](https://tools.ietf.org/html/rfc6238), and [HMAC-Based One-Time Password Algorithm (HOTP, RFC-4266)](https://tools.ietf.org/html/rfc4226). + +## Two-Factor Authentication via One-Time Password +Enabling Two-Factor Authentication via One-Time-Password (2FA/OTP) on an account adds an extra layer of security ("_something a user has_") in addition to their password ("_something a user knows_"). Providing 2FA/OTP to your users has some prerequisites: +* [A configured email gateway](/docs/configuration/email.md) such that the system can send out emails. +* One or more secure servers enabled such as [SSH](/docs/servers/ssh.md) or secure [WebSockets](/docs/servers/websocket.md) (that is, WebSockets over a secure connection such as TLS). +* The [web server](/docs/servers/web-server.md) enabled and exposed over TLS (HTTPS). + +:information_source: For WebSockets and the web server, ENiGMA½ _may_ listen on insecure channels if behind a secure web proxy. + +### User Registration Flow +Due to the nature of 2FA/OTP, even if enabled on your system, users must opt-in and enable this feature on their account. Users must also have a valid email address such that a registration link can be sent to them. To opt-in, users must enable the option, which will cause the system to email them a registration link. Following the link provides the following: + +1. A secret for manual entry into a OTP device. +2. If applicable, a scannable QR code for easy device entry (e.g. Google Authenticator) +3. A confirmation prompt in which the user must enter a OTP code. If entered correctly, this validates everything is set up properly and 2FA/OTP will be enabled for the account. Backup codes will also be provided at this time. Future logins will now prompt the user for their OTP after they enter their standard password. + +:warning: Serving 2FA/OTP registration links over insecure (HTTP) can expose secrets intended for the user and is **highly** discouraged! + +:information_source: +ops can also manually enable or disable 2FA/OTP for a user using [oputil](/docs/admin/oputil.md), but this is generally discouraged. + +#### Recovery +In the situation that a user loses their 2FA/OTP device (such as a lost phone with Google Auth), there are some options: +* Utilize one of their backup codes. +* Contact the SysOp. + +:warning: There is no way for a user to disable 2FA/OTP without first fully logging in! This is by design as a security measure. + +### ACS Checks +Various places throughout the system that implement [ACS](/docs/configuration/acs.md) can make 2FA specific checks: +* `AR#`: Current users **required** authentication factor. `AR2` for example means 2FA/OTP is required for this user. +* `AF#`: Current users **active** authentication factor. `AF2` means the user is authenticated with some sort of 2FA (such as One-Time-Password). + +See [ACS](/docs/configuration/acs.md) for more information. + +#### Example +The following example illustrates using an `AR` ACS check to require applicable users to go through an additional 2FA/OTP process during login: + +```hjson +login: { + art: USERLOG + next: [ + { + // users with AR2+ must first pass 2FA/OTP + acs: AR2 + next: loginTwoFactorAuthOTP + } + { + // everyone else skips ahead + next: fullLoginSequenceLoginArt + } + ] + // ... +} +``` \ No newline at end of file diff --git a/docs/configuration/sysop-setup.md b/docs/configuration/sysop-setup.md new file mode 100644 index 00000000..502f412e --- /dev/null +++ b/docs/configuration/sysop-setup.md @@ -0,0 +1,5 @@ +--- +layout: page +title: SysOp Setup +--- +SySop privileges will be granted to the first user to log into a fresh ENiGMA½ installation. +ops belong to the `sysop` user group by default. \ No newline at end of file diff --git a/docs/doors.md b/docs/doors.md deleted file mode 100644 index 702d75d9..00000000 --- a/docs/doors.md +++ /dev/null @@ -1,208 +0,0 @@ -# Doors -ENiGMA½ supports a variety of methods for interacting with doors — not limited to: -* `abracadabra` module: Standard in/out (stdio) capture or temporary socket server that can be used with [DOSEMU](http://www.dosemu.org/), [DOSBox](http://www.dosbox.com/), [QEMU](http://wiki.qemu.org/Main_Page), etc. -* `bbs_link` module for interaction with [BBSLink](http://www.bbslink.net/) - -## The abracadabra Module -The `abracadabra` module provides a generic and flexible solution for many door types. Through this module you can execute native processes & scripts directly, and process I/O through stdio or a temporary TCP server. - -The `abracadabra` `config` block can contain the following: -* `name`: Used as a key for tracking number of clients using a particular door -* `dropFileType`: Specifies the type of drop file to generate (See table below) -* `cmd`: Path to executable to launch -* `args`: Array of argument(s) to pass to `cmd`. See below for information on variables that can be used here. -* `nodeMax`: Max number of nodes that can access this door at once. Uses `name` as a mapping key -* `tooManyArt`: Art file spec to display if too many instances are already in use -* `io`: Where to process I/O. Can be `stdio` or `socket` - -Drop file types specified by `dropFileType`: -* `DOOR`: [DOOR.SYS](http://goldfndr.home.mindspring.com/dropfile/doorsys.htm) -* `DOOR32`: [DOOR32.SYS](http://wiki.bbses.info/index.php/DOOR32.SYS) -* `DORINFO`: [DORINFOx.DEF](http://goldfndr.home.mindspring.com/dropfile/dorinfo.htm) - -Variables for use in `args`: -* `{node}`: Current node number -* `{dropFile}`: Path to generated drop file -* `{userId}`: Current user ID -* `{srvPort}`: Tempoary server port when `io` is `socket` - - -### DOSEMU with abracadabra -[DOSEMU](http://www.dosemu.org/) can provide a good solution for running legacy DOS doors when running on Linux systems. For this, we will create a virtual serial port (COM1) that communicates via stdio. - -As an example, here are the steps for setting up Pimp Wars: - -First, create a `dosemu.conf` file with the following contents: -``` -$_cpu = "80486" -$_cpu_emu = "vm86" -$_external_char_set = "utf8" -$_internal_char_set = "cp437" -$_term_updfreq = (8) -$_layout = "us" -$_rawkeyboard = (0) -$_com1 = "virtual" -``` - -The line `$_com1 = "virtual"` tells DOSEMU to use `stdio` as a virtual serial port on COM1. - -Next, we create a virtual **X** drive for Pimp Wars to live such as `/enigma-bbs/DOS/X/PW` and map it with a custom `autoexec.bat` file within DOSEMU: -``` -@echo off -path d:\bin;d:\gnu;d:\dosemu -set TEMP=c:\tmp -prompt $P$G -REM http://www.pcmicro.com/bnu/ -C:\BNU\BNU.COM /L0:57600,8N1 /F -lredir.com x: linux\fs\enigma-bbs\DOS\X -unix -e -``` - -Note that we also have the [BNU](http://www.pcmicro.com/bnu/) FOSSIL driver installed at `C:\BNU\\`. Another option would be to install this to X: somewhere as well. - -Finally, let's create a `menu.hjson` entry to launch the game: -```hjson -doorPimpWars: { - desc: Playing PimpWars - module: abracadabra - config: { - name: PimpWars - dropFileType: DORINFO - cmd: /usr/bin/dosemu - args: [ - "-quiet", - "-f", - "/path/to/dosemu.conf", - "X:\\PW\\START.BAT {dropFile} {node}" - ], - nodeMax: 1 - tooManyArt: DOORMANY - io: stdio - } -} - -``` - -### QEMU with abracadabra -[QEMU](http://wiki.qemu.org/Main_Page) provides a robust, cross platform solution for launching doors under many platforms (likely anwywhere Node.js is supported and ENiGMA½ can run). Note however that there is an important and major caveat: **Multiple instances of a particular door/OS image should not be run at once!** Being more flexible means being a bit more complex. Let's look at an example for running L.O.R.D. under a UNIX like system such as Linux or FreeBSD. - -Basically we'll be creating a bootstrap shell script that generates a temporary node specific `go.bat` to launch our door. This will be called from `autoexec.bat` within our QEMU FreeDOS partition. - -#### Step 1: Create a FreeDOS image -[FreeDOS](http://www.freedos.org/) is a free mostly MS-DOS compatible DOS package that works well for running 16bit doors. Follow the [QEMU/FreeDOS](https://en.wikibooks.org/wiki/QEMU/FreeDOS) guide for creating an `freedos_c.img`. This will contain FreeDOS itself and installed BBS doors. - -After this is complete, copy LORD to C:\DOORS\LORD within FreeDOS. An easy way to tranfer files from host to DOS is to use QEMU's vfat as a drive. For example: -```bash -qemu-system-i386 -localtime /home/enigma/dos/images/freedos_c.img -hdb fat:/path/to/downloads -``` - -With the above you can now copy files from D: to C: within FreeDOS and add the following to it's `autoexec.bat`: -```batch -CALL E:\GO.BAT -``` - -#### Step 2: Create a bootstrap script -Our bootstrap script will prepare `GO.BAT` and launch FreeDOS. Below is an example: - - -```bash -#!/bin/bash - -NODE=$1 -DROPFILE=D:\\$2 -SRVPORT=$3 - -mkdir -p /home/enigma/dos/go/node$NODE - -cat > /home/enigma/dos/go/node$NODE/GO.BAT < ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + +------------------------------------------------------------------------------- + +System started! +``` +Grab your favourite telnet client, connect to localhost:8888 and test out your installation. + +To shut down the server, press Ctrl-C. + +## Points of Interest + +* The default port for Telnet is 8888 and 8889 for SSH. + * Note that on *nix systems port such as telnet/23 are privileged (e.g. require root). See + [this SO article](http://stackoverflow.com/questions/16573668/best-practices-when-running-node-js-with-port-80-ubuntu-linode) for some tips on using these ports on your system if desired. +* The first user you create when logging in will be automatically be added to the `sysops` group. + +## Telnet Software + +If you don't have any telnet software, these are compatible with ENiGMA½: + +* [SyncTERM](http://syncterm.bbsdev.net/) +* [EtherTerm](https://github.com/M-griffin/EtherTerm) +* [NetRunner](http://mysticbbs.com/downloads.html) +* [MagiTerm](https://magickabbs.com/index.php/magiterm/) +* [VTX](https://github.com/codewar65/VTX_ClientServer) (Browser based) +* [fTelnet](https://www.ftelnet.ca/) (Browser based) diff --git a/docs/installation/windows.md b/docs/installation/windows.md new file mode 100644 index 00000000..a9fcf060 --- /dev/null +++ b/docs/installation/windows.md @@ -0,0 +1,71 @@ +--- +layout: page +title: Installation Under Windows +--- +## Installation Under Windows + +ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit doors natively then you should use a 32bit Windows. + +### Basic Instructions + +1. Download and Install [Node.JS](https://nodejs.org/). + + 1. Upgrade NPM : At this time node comes with NPM 5.6 preinstalled. To upgrade to a newer version now or in the future on windows follow this method. `*Run PowerShell as Administrator` + + `*Initial Install` + ```Powershell + Set-ExecutionPolicy Unrestricted -Scope CurrentUser -Force + npm install -g npm-windows-upgrade + ``` + `*Upgrade` + ```Powershell + npm-windows-upgrade + ``` + + Note: Do not run `npm i -g npm`. Instead use `npm-windows-upgrade` to update npm going forward. + Also if you run the NodeJS installer, it will replace the node version. + + 2. Install [windows-build-tools for npm](https://www.npmjs.com/package/windows-build-tools) + `*This will also install python 2.7` + ```Powershell + npm install --global --production windows-build-tools + ``` + + +2. Install [7zip](https://www.7-zip.org/download.html). + + *Add 7zip to your path so `7z` can be called from the console + 1. Right click `This PC` and Select `Properties` + 2. Go to the `Advanced` Tab and click on `Environment Variables` + 3. Select `Path` under `System Variables` and click `Edit` + 4. Click `New` and paste the path to 7zip + 5. Close your console window and reopen. You can type `7z` to make sure it's working. + +(Please see [Archivers](/docs/archivers.md) for additional archive utilities!) + +3. Install [Git](https://git-scm.com/downloads) and optionally [TortoiseGit](https://tortoisegit.org/download/). + +4. Clone ENiGMA½ - browse to the directory you want and run + ```Powershell + git clone "https://github.com/NuSkooler/enigma-bbs.git" + ``` + Optionally use the TortoiseGit by right clicking the directory and selecting `Git Clone`. + + +5. Install ENiGMA½. + 1. In the enigma directory run + ```Powershell + npm install + ``` + 2. Generate your initial configuration: `Follow the prompts!` + ```Powershell + node .\oputil.js config new + ``` + 3. Edit your configuration files in `enigma-bbs\config` with [Notepad++](https://notepad-plus-plus.org/download/) or [Visual Studio Code](https://code.visualstudio.com/Download) + 4. Run ENiGMA½ + ```Powershell + node .\main.js + ``` + + +6. Look at [Production Installation](/installation/production) for maintaining ENiGMA½ when you are ready to go live. diff --git a/docs/mci.md b/docs/mci.md deleted file mode 100644 index 61ffee04..00000000 --- a/docs/mci.md +++ /dev/null @@ -1,113 +0,0 @@ -# MCI Codes - -## Introduction -ENiGMA½ supports a variety of MCI codes. Some **predefined** codes produce information about the current user, system, or other statistics while others are used to instanciate a **View**. MCI codes are two characters in length and are prefixed with a percent (%) symbol. Some MCI codes have additional options that may be set directly from the code itself while others -- and more advanced options -- are controlled via the current theme. Standard (non-focus) and focus colors are set by placing duplicate codes back to back in art files. - -## Views -A **View** is a control placed on a **form** that can display variable data or collect input. One example of a View is a Vertical Menu (`%VM`): Oldschool BBSers may recognize this as a lightbar menu. - -### Available Views -* Text Label (`TL`): Displays text -* Edit Text (`ET`): Collect user input -* Masked Edit Text (`ME`): Collect user input using a *mask* -* Multi Line Text Edit (`MT`): Multi line edit control -* Button (`BT`): A button -* Vertical Menu (`VM`): A vertical menu aka a vertical lightbar -* Horizontal Menu (`HM`): A horizontal menu aka a horizontal lightbar -* Spinner Menu (`SM`): A spinner input control -* Toggle Menu (`TM`): A toggle menu commonly used for Yes/No style input -* Key Entry (`KE`): A *single* key input control - -(Peek at `core/mci_view_factory.js` to see additional information on these) - -## Predefined -There are many predefined MCI codes that can be used anywhere on the system (placed in any art file). More are added all the time so also check out `core/predefined_mci.js` for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, etc. - -* `BN`: Board Name -* `VL`: Version *label*, e.g. "ENiGMA½ v0.0.3-alpha" -* `VN`: Version *number*, eg.. "0.0.3-alpha" -* `SN`: SysOp username -* `SR`: SysOp real name -* `SL`: SysOp location -* `SA`: SysOp affiliations -* `SS`: SysOp sex -* `SE`: SysOp email address -* `UN`: Current user's username -* `UI`: Current user's user ID -* `UG`: Current user's group membership(s) -* `UR`: Current user's real name -* `LO`: Current user's location -* `UA`: Current user's age -* `BD`: Current user's birthdate (using theme date format) -* `US`: Current user's sex -* `UE`: Current user's email address -* `UW`: Current user's web address -* `UF`: Current user's affiliations -* `UT`: Current user's *theme ID* (e.g. "luciano_blocktronics") -* `UC`: Current user's login/call count -* `ND`: Current user's connected node number -* `IP`: Current user's IP address -* `ST`: Current user's connected server name (e.g. "Telnet" or "SSH") -* `FN`: Current user's active file base filter name -* `DN`: Current user's number of downloads -* `DK`: Current user's download amount (formatted to appropriate bytes/megs/etc.) -* `UP`: Current user's number of uploads -* `UK`: Current user's upload amount (formatted to appropriate bytes/megs/etc.) -* `NR`: Current user's upload/download ratio -* `KR`: Current user's upload/download *bytes* ratio -* `MS`: Current user's account creation date (using theme date format) -* `PS`: Current user's post count -* `PC`: Current user's post/call ratio -* `MD`: Current user's status/viewing menu/activity -* `MA`: Current user's active message area name -* `MC`: Current user's active message conference name -* `ML`: Current user's active message area description -* `CM`: Current user's active message conference description -* `SH`: Current user's term height -* `SW`: Current user's term width -* `DT`: Current date (using theme date format) -* `CT`: Current time (using theme time format) -* `OS`: System OS (Linux, Windows, etc.) -* `OA`: System architecture (x86, x86_64, arm, etc.) -* `SC`: System CPU model -* `NV`: System underlying Node.js version -* `AN`: Current active node count -* `TC`: Total login/calls to system -* `RR`: Displays a random rumor -* `SD`: Total downloads, system wide -* `SO`: Total downloaded amount, system wide (formatted to appropriate bytes/megs/etc.) -* `SU`: Total uploads, system wide -* `SP`: Total uploaded amount, system wide (formatted to appropriate bytes/megs/etc.) - - -A special `XY` MCI code may also be utilized for placement identification when creating menus. - -## Properties & Theming -Predefined MCI codes and other Views can have properties set via `menu.hjson` and further *themed* via `theme.hjson`. - -### Common Properties -* `textStyle`: Sets the standard (non-focus) text style. See **Text Styles** below -* `focusTextStyle`: Sets focus text style. See **Text Styles** below. -* `itemSpacing`: Used to separate items in menus such as Vertical Menu and Horizontal Menu Views. -* `height`: Sets the height of views such as menus that may be > 1 character in height -* `width`: Sets the width of a view -* `focus`: If set to `true`, establishes initial focus -* `text`: (initial) text of a view -* `submit`: If set to `true` any `accept` action upon this view will submit the encompassing **form** - -These are just a few of the properties set on various views. *Use the source Luke*, as well as taking a look at the default `menu.hjson` and `theme.hjson` files! - - -#### Text Styles -Standard style types available for `textStyle` and `focusTextStyle`: - -* `normal`: Leaves text as-is. This is the default. -* `upper`: ENIGMA BULLETIN BOARD SOFTWARE -* `lower`: enigma bulletin board software -* `title`: Enigma Bulletin Board Software -* `first lower`: eNIGMA bULLETIN bOARD sOFTWARE -* `small vowels`: eNiGMa BuLLeTiN BoaRD SoFTWaRe -* `big vowels`: EniGMa bUllEtIn bOArd sOftwArE -* `small i`: ENiGMA BULLETiN BOARD SOFTWARE -* `mixed`: EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned) -* `l33t`: 3n1gm4 bull371n b04rd 50f7w4r3 \ No newline at end of file diff --git a/docs/menu_system.md b/docs/menu_system.md deleted file mode 100644 index 1dea637c..00000000 --- a/docs/menu_system.md +++ /dev/null @@ -1,76 +0,0 @@ -# Menu System -ENiGMA½'s menu system is highly flexible and moddable. The possibilities are almost endless! By modifying your `menu.hjson` you will be able to create a custom look and feel unique to your board. - -The default `menu.hjson` file lives within the `mods` directory. It is **highly recommended** to specify another file by setting the `menuFile` property in your `config.hjson` file: -```hjson -general: { - /* Can also specify a full path */ - menuFile: mybbs.hjson -} -``` -(You can start by copying the default `menu.hjson` to `mybbs.hjson`) - -## The Basics -Like all configuration within ENiGMA½, menu configuration is done via a HJSON file. This file is located in the `mods` directory: `mods/menu.hjson`. - -Each entry in `menu.hjson` defines an object that represents a menu. These objects live within the `menus` parent object. Each object's *key* is a menu name you can reference within other menus in the system. - -## Example -Let's look a couple basic menu entries: - -```hjson -telnetConnected: { - art: CONNECT - next: matrix - options: { nextTimeout: 1500 } -} -``` - -The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the server's config). - -An art pattern of `CONNECT` is set telling the system to look for `CONNECT` in the current theme location, then in the common `mods/art` directory where `` represents a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. You can be explicit here if desired, by specifying a file extension. - -The entry `next` sets up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`. - -Finally, an `options` object may contain various common options for menus. In this case, `nextTimeout` tells the system to proceed to the `next` entry automatically after 1500ms. - -Now let's look at `matrix`, the `next` entry from `telnetConnected`: -```hjson -matrix: { - art: matrix - desc: Login Matrix - form: { - 0: { - VM: { - mci: { - VM1: { - submit: true - focus: true - items: [ "login", "apply", "log off" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @menu:login - } - { - value: { 1: 1 }, - action: @menu:newUserApplication - } - { - value: { 1: 2 }, - action: @menu:logoff - } - ] - } - } - } - } -} -``` - -In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. - -The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ 1: 0 }` or view ID 1, value 0 will match causing `action` of `@menu:login` to be executed (go to `login` menu). \ No newline at end of file diff --git a/docs/messageareas/bso-import-export.md b/docs/messageareas/bso-import-export.md new file mode 100644 index 00000000..6fc6298e --- /dev/null +++ b/docs/messageareas/bso-import-export.md @@ -0,0 +1,166 @@ +--- +layout: page +title: BSO Import / Export +--- +## BSO Import / Export +The scanner/tosser module `ftn_bso` provides **B**inkley **S**tyle **O**utbound (BSO) import/toss and scan/export of messages EchoMail and NetMail messages. Configuration is supplied in `config.hjson` under `scannerTossers.ftn_bso`. + +:information_source: ENiGMA½'s `ftn_bso` module is not a mailer and **makes no attempts to perform packet transport**! An external [mailer](http://www.filegate.net/bbsmailers.htm) such as [Binkd](https://github.com/pgul/binkd) is required for this task. + +### Configuration +Let's look at some of the basic configuration: + +| Config Item | Required | Description | +|-------------|----------|----------------------------------------------------------| +| `schedule` | :+1: | Sets `import` and `export` schedules. [Later style text parsing](https://bunkat.github.io/later/parsers.html#text) supported. `import` also can utilize a `@watch:` syntax while `export` additionally supports `@immediate`. | +| `packetMsgEncoding` | :-1: | Override default `utf8` encoding. +| `defaultNetwork` | :-1: | Explicitly set default network (by tag found within `messageNetworks.ftn.networks`). If not set, the first found is used. | +| `nodes` | :+1: | Per-node settings. Entries (keys) here support wildcards for a portion of the FTN-style address (e.g.: `21:1/*`). See **Nodes** below. +| `paths` | :-1: | An optional configuration block that can set a additional paths or override defaults. See **Paths** below. | +| `packetTargetByteSize` | :-1: | Overrides the system *target* packet (.pkt) size of 512000 bytes (512k) | +| `bundleTargetByteSize` | :-1: | Overrides the system *target* ArcMail bundle size of 2048000 bytes (2M) | + +#### Nodes +The `nodes` section defines how to export messages for one or more uplinks. + +A node entry starts with a [FTN address](http://ftsc.org/docs/old/fsp-1028.001) (up to 5D) **as a key** in `config.hjson`. This key may contain wildcard(s) for net/zone/node/point/domain. + +| Config Item | Required | Description | +|------------------|----------|---------------------------------------------------------------------------------| +| `packetType` | :-1: | `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability. | +| `packetPassword` | :-1: | Optional password for the packet | +| `encoding` | :-1: | Encoding to use for message bodies; Defaults to `utf-8`. | +| `archiveType` | :-1: | Specifies the archive type (by extension or MIME type) for ArcMail bundles. This should be `zip` (or `application/zip`) for most setups. Other valid examples include `arc`, `arj`, `lhz`, `pak`, `sqz`, or `zoo`. See [Archivers](docs/configuration/archivers.md) for more information. | + +**Example**: +```hjson +{ + scannerTossers: { + ftn_bso: { + nodes: { + "21:*": { // wildcard address + packetType: 2+ + packetPassword: D@TP4SS + encoding: cp437 + archiveType: zip + } + } + } + } +} +``` + +#### Paths +Paths for packet files work out of the box and are relative to your install directory. If you want to configure `reject` or `retain` to keep rejected/imported packet files respectively, set those values. You may override defaults as well. + +| Key | Description | Default | +|-----|-------------|---------| +| `outbound` | *Base* path to write outbound (exported) packet files and bundles. | `enigma-bbs/mail/ftn_out/` | +| `inbound` | *Base* path to write inbound (ie: those written by an external mailer) packet files an bundles. | `enigma-bbs/mail/ftn_in/` | +| `secInbound` | *Base* path to write **secure** inbound packet files and bundles. | `enigma-bbs/mail/ftn_secin/` | +| `reject` | Path in which to write rejected packet files. | No default | +| `retain` | Path in which to write imported packet files. Useful for debugging or if you wish to archive the raw .pkt files. | No default | + +### Scheduling +Schedules can be defined for importing and exporting via `import` and `export` under `schedule`. Each entry is allowed a "free form" text and/or special indicators for immediate export or watch file triggers. + +* `@immediate`: A message will be immediately exported if this trigger is defined in a schedule. Only used for `export`. +* `@watch:/path/to/file`: This trigger watches the path specified for changes and will trigger an import or export when such events occur. Only used for `import`. +* Free form [Later style](https://bunkat.github.io/later/parsers.html#text) text — can be things like `at 5:00 pm` or `every 2 hours`. + +See [Later text parsing documentation](http://bunkat.github.io/later/parsers.html#text) for more information. + +#### Example Schedule Configuration + +```hjson +{ + scannerTossers: { + ftn_bso: { + schedule: { + import: every 1 hours or @watch:/path/to/watchfile.ext + export: every 1 hours or @immediate + } + } + } +} +``` + +### A More Complete Example +Below is a more complete example showing the sections described above. + +```hjson +scannerTossers: { + ftn_bso: { + schedule: { + // Check every 30m, or whenever the "toss!.now" file is touched (ie: by Binkd) + import: every 30 minutes or @watch:/enigma-bbs/mail/ftn_in/toss!.now + + // Export immediately, but also check every 15m to be sure + export: every 15 minutes or @immediate + } + + // optional + paths: { + reject: /path/to/store/bad/packets/ + retain: /path/to/store/good/packets/ + } + + // Override default FTN/BSO packet encoding. Defaults to 'utf8' + packetMsgEncoding: utf8 + + defaultNetwork: fsxnet + + nodes: { + "21:1/100" : { // May also contain wildcards, ie: "21:1/*" + archiveType: ZIP // By-ext archive type: ZIP, ARJ, ..., optional. + encoding: utf8 // Encoding for exported messages + packetPassword: MUHPA55 // FTN .PKT password, optional + + tic: { + // See TIC docs + } + } + } + + netMail: { + // See NetMail docs + } + + ticAreas: { + // See TIC docs + } + } +} +``` + +## Binkd +Since Binkd is a very common mailer, a few tips on integrating it with ENiGMA½: + +### Scheduling Polls +Binkd does not have it's own scheduler. Instead, you'll need to set up an Event Scheduler entry or perhaps a cron job: + +First, create a script that runs through all of your uplinks. For example: +```bash +#!/bin/bash +UPLINKS=("21:1/100@fsxnet" "80:774/1@retronet" "10:101/0@araknet") +for uplink in "${UPLINKS[@]}" +do + /usr/local/sbin/binkd -p -P $uplink /home/enigma/xibalba/misc/binkd_xibalba.conf +done +``` + +Now, create an Event Scheduler entry in your `config.hjson`. As an example: +```hjson +eventScheduler: { + events: { + pollWithBink: { + // execute the script above very 1 hours + schedule: every 1 hours + action: @execute:/path/to/poll_bink.sh + } + } +} +``` + +## Additional Resources +[Blog entry on setting up ENiGMA + Binkd on CentOS7](https://l33t.codes/enigma-12-binkd-on-centos-7/). Note that this references an **older version**, so be wary of the `config.hjson` references! diff --git a/docs/messageareas/configuring-a-message-area.md b/docs/messageareas/configuring-a-message-area.md new file mode 100644 index 00000000..011e9497 --- /dev/null +++ b/docs/messageareas/configuring-a-message-area.md @@ -0,0 +1,85 @@ +--- +layout: page +title: Configuring a Message Area +--- +## Message Conferences +**Message Conferences** and **Areas** allow for grouping of message base topics. + +## Conferences +Message Conferences are the top level container for *1:n* Message *Areas* via the `messageConferences` block in `config.hjson`. A common setup may include a local conference and one or more conferences each dedicated to a particular message network such as fsxNet, ArakNet, etc. + +Each conference is represented by a entry under `messageConferences`. Each entries top level key is it's *conference tag*. + +| Config Item | Required | Description | +|-------------|----------|-------------| +| `name` | :+1: | Friendly conference name | +| `desc` | :+1: | Friendly conference description. | +| `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 conference (e.g. assigned to new users) | +| `areas` | :+1: | Container of 1:n areas described below | +| `acs` | :-1: | A standard [ACS](/docs/configuration/acs.md) block. See **ACS** below. | + +### ACS +An optional standard [ACS](/docs/configuration/acs.md) block can be supplied with the following rules: +* `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 + +```hjson +{ + messageConferences: { + local: { // conference tag + name: Local + desc: Local discussion + sort: 1 + default: true + acs: { + read: GM[users] // default + } + } + } +} +``` + +## Message Areas +Message Areas are topic specific containers for messages that live within a particular conference. The top level key for an area sets it's *area tag*. For example, "General Discussion" may live under a Local conference while an fsxNet conference may contain "BBS Discussion". + +| Config Item | Required | Description | +|-------------|----------|---------------------------------------------------------------------------------| +| `name` | :+1: | Friendly area name. | +| `desc` | :+1: | Friendly area description. | +| `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 required to read (see) this area. Defaults to `GM[users]`. +* `write`: ACS required to write (post) to this area. Defaults to `GM[users]`. + +### Example + +```hjson +messageConferences: { + local: { + // ... see above ... + areas: { + enigma_dev: { // Area tag - required elsewhere! + name: ENiGMA 1/2 Development + desc: ENiGMA 1/2 development and discussion! + sort: 1 + default: true + acs: { + read: GM[users] // default + write: GM[l33t] // super elite ENiGMA 1/2 users! + } + } + } + } +} +``` + +## Importing +FidoNet style `.na` files as well as legacy `AREAS.BBS` files in common formats can be imported using `oputil.js mb import-areas`. See [The oputil CLI](/docs/admin/oputil.md) for more information and usage. diff --git a/docs/messageareas/ftn.md b/docs/messageareas/ftn.md new file mode 100644 index 00000000..34f90862 --- /dev/null +++ b/docs/messageareas/ftn.md @@ -0,0 +1,105 @@ +--- +layout: page +title: FidoNet-Style Networks (FTN) +--- + +## FidoNet-Style Networks (FTN) +[FidoNet](https://en.wikipedia.org/wiki/FidoNet) proper and other FidoNet-Style networks are supported by ENiGMA½. A bit of configuration and you'll be up and running in no time! + +### Configuration +Getting a fully running FTN enabled system requires a few configuration points: + +1. `messageNetworks.ftn.networks`: Declares available networks. That is, networks you wish to sync up with. +2. `messageNetworks.ftn.areas`: Establishes local area mappings (ENiGMA½ to/from FTN area tags) and per-area specific configurations. +3. `scannerTossers.ftn_bso`: General configuration for the scanner/tosser (import/export) process. This is also where we configure per-node (uplink) settings. + +:information_source: ENiGMA½'s `ftn_bso` module is **not a mailer** and makes **no attempts** to perform packet transport! An external utility such as Binkd is required for this task. + +#### Networks +The `networks` block is a per-network configuration where each entry's ID (or "key") may be referenced elsewhere in `config.hjson`. For example, consider two networks: ArakNet (`araknet`) and fsxNet (`fsxnet`): + +```hjson +{ + messageNetworks: { + ftn: { + networks: { + // it is recommended to use lowercase network tags + fsxnet: { + defaultZone: 21 + localAddress: "21:1/121" + } + + araknet: { + defaultZone: 10 + localAddress: "10:101/9" + } + } + } + } +} +``` + +#### Areas +The `areas` section describes a mapping of local **area tags** configured in your `messageConferences` (see [Configuring a Message Area](configuring-a-message-area.md)) to a message network (described above), a FTN specific area tag, and remote uplink address(s). This section can be thought of similar to the *AREAS.BBS* file used by other BBS packages. + +When ENiGMA½ imports messages, they will be placed in the local area that matches key under `areas` while exported messages will be sent to the relevant `network`. + +| Config Item | Required | Description | +|-------------|----------|----------------------------------------------------------| +| `network` | :+1: | Associated network from the `networks` section above | +| `tag` | :+1: | FTN area tag (ie: `FSX_GEN`) | +| `uplinks` | :+1: | An array of FTN address uplink(s) for this network | + +Example: +```hjson +{ + messageNetworks: { + ftn: { + areas: { + // it is recommended to use lowercase area tags + fsx_general: // *local* tag found within messageConferences + network: fsxnet // that we are mapping to this network + tag: FSX_GEN // ...and this remote FTN-specific tag + uplinks: [ "21:1/100" ] // a single string also allowed here + } + } + } + } +} +``` + +:information_source: You can import `AREAS.BBS` or FTN style `.NA` files using [oputil](/docs/admin/oputil.md)! + +#### A More Complete Example +Below is a more complete *example* illustrating some of the concepts above: + +```hjson +{ + messageNetworks: { + ftn: { + networks: { + fsxnet: { + defaultZone: 21 + localAddress: "21:1/121" + } + } + + areas: { + fsx_general: { + network: fsxnet + + // ie as found in your info packs .NA file + tag: FSX_GEN + + uplinks: [ "21:1/100" ] + } + } + } + } +} +``` + +:information_source: Remember for a complete FTN experience, you'll probably also want to configure [FTN/BSO scanner/tosser](bso-import-export.md) settings. + +#### FTN/BSO Scanner Tosser +Please see the [FTN/BSO Scanner/Tosser](bso-import-export.md) documentation for information on this area. \ No newline at end of file diff --git a/docs/messageareas/message-networks.md b/docs/messageareas/message-networks.md new file mode 100644 index 00000000..52a4227e --- /dev/null +++ b/docs/messageareas/message-networks.md @@ -0,0 +1,24 @@ +--- +layout: page +title: Message Networks +--- +## Message Networks +ENiGMA½ supports external networks such as FidoNet-Style (FTN) and QWK by the way of importing and exporting to/from it's own internal format. This allows for a very flexible system that can easily be extended by creating new network modules. + +All message network configuration occurs under the `messageNetworks.` block in `config.hjson` (where name is something such as `ftn` or `qwk`). The most basic of external message network configurations generally comprises of two sections: + +1. `messageNetworks..networks`: Global/general configuration for a particular network where `` is for example `ftn` or `qwk`. +2. `messageNetworks..areas`: Provides mapping of ENiGMA½ **area tags** to their external counterparts. + +:information_source: A related section under `scannerTossers.` may provide configuration for scanning (importing) and tossing (exporting) messages for a particular network type. As an example, FidoNet-Style networks often work with BinkleyTerm Style Outbound (BSO) and thus the [FTN/BSO scanner/tosser](bso-import-export.md) (`ftn_bso`) module. + +### Currently Supported Networks +The following networks are supported out of the box. Remember that you can create modules to add others if desired! + +#### FidoNet-Style (FTN) +FidoNet and FidoNet style (FTN) networks as well as a [FTN/BSO scanner/tosser](bso-import-export.md) (`ftn_bso` module) are configured via the `messageNetworks.ftn` and `scannerTossers.ftn_bso` blocks in `config.hjson`. + +See [FidoNet-Style Networks](ftn.md) for more information. + +#### QWK +See [QWK and QWK-Net Style Networks](qwk.md) for more information. diff --git a/docs/messageareas/netmail.md b/docs/messageareas/netmail.md new file mode 100644 index 00000000..f43c2a4b --- /dev/null +++ b/docs/messageareas/netmail.md @@ -0,0 +1,35 @@ +--- +layout: page +title: Netmail +--- +ENiGMA support import and export of Netmail from the Private Mail area. `RiPuk @ 21:1/136` and `RiPuk <21:1/136>` 'To' address formats are supported. + +## Netmail Routing + +A configuration block must be added to the `scannerTossers::ftn_bso` `config.hjson` section to tell the ENiGMA½ tosser where to route NetMail. + +The following configuration would tell ENiGMA½ to route all netmail addressed to 21:* through 21:1/100, and all 46:* netmail through 46:1/100: + +````hjson + +scannerTossers: { + + /* other scannerTosser config removed for clarity */ + + ftn_bso: { + netMail: { + routes: { + "21:*" : { + address: "21:1/100" + network: fsxnet + } + "46:*" : { + address: "46:1/100" + network: agoranet + } + } + } + } +} +```` +The `network` tag must match the networks defined in `messageNetworks::ftn::networks` within `config.hjson`. \ No newline at end of file diff --git a/docs/messageareas/qwk.md b/docs/messageareas/qwk.md new file mode 100644 index 00000000..964551a3 --- /dev/null +++ b/docs/messageareas/qwk.md @@ -0,0 +1,47 @@ +--- +layout: page +title: QWK Support +--- + +## QWK and QWK-Net Style Networks +As like all other networks such as FidoNet-Style (FTN) networks, ENiGMA½ considers QWK external to the system but can import and export the format. + +### Supported Standards +QWK must be considered a semi-standard as there are many implementations. What follows is a short & incomplete list of such standards ENiGMA½ supports: +* The basic [QWK packet format](http://fileformats.archiveteam.org/wiki/QWK). +* [QWKE extensions](https://github.com/wwivbbs/wwiv/blob/master/specs/qwk/qwke.txt). +* [Synchronet BBS style extensions](http://wiki.synchro.net/ref:qwk) such as `HEADERS.DAT`, `@` kludges, and UTF-8 handling. + + +### Configuration +QWK configuration occurs in the `messageNetworks.qwk` config block of `config.hjson`. As QWK wants to deal with conference numbers and ENiGMA½ uses area tags (conferences and conference tags are only used for logical grouping), a mapping can be made. + +:information_source: During a regular, non QWK-Net exports, conference numbers can be auto-generated. Note that for QWK-Net style networks, you will need to create mappings however. + +Example: +```hjson +{ + messageNetworks: { + qwk: { + areas: { + general: { // local ENiGMA½ area tag + conference: 1 // conference number to map to + } + } + } + } +} +``` + +### oputil +The `oputil.js` utility can export packet files, dump the messages of a packet to stdout, etc. See [the oputil documentation](/docs/admin/oputil.md) for more information. + +### Offline Readers +A few of the offline readers that have been tested with QWK packet files produced by ENiGMA½: + +| Software | Status | Notes | +|----------|--------|-------| +| MultiMail/Win v0.52 | Supported | Private mail seems to break even with bundles from other systems | +| SkyReader/W32 v1.00 | Supported | Works well. No QWKE or HEADERS.DAT support. Gets confused with low conference numbers. | + +There are also [many other readers](https://www.softwolves.pp.se/old/2000/faq/bwprod) for various systems. \ No newline at end of file diff --git a/docs/misc/user-interrupt.md b/docs/misc/user-interrupt.md new file mode 100644 index 00000000..fe20fdd9 --- /dev/null +++ b/docs/misc/user-interrupt.md @@ -0,0 +1,17 @@ +--- +layout: page +title: User Interruptions +--- +## User Interruptions +ENiGMA½ provides functionality to "interrupt" a user for various purposes such as a [node-to-node message](/docs/modding/node-msg.md). User interruptions can be queued and displayed at the next opportune time such as when switching to a new menu, or realtime if appropriate. + +## Standard Menu Behavior +Standard menus control interruption by the `interrupt` config block option, which may be set to one of the following values: +* `never`: Never interrupt the user when on this menu. +* `queued`: Queue interrupts for the next opportune time. Any queued message(s) will then be shown. This is the default. +* `realtime`: If possible, display messages in realtime. That is, show them right away. Standard menus that do not override default behavior will show the message then reload. + + +## See Also +See [user_interrupt_queue.js](/core/user_interrupt_queue.js) as well as usage within [menu_module.js](/core/menu_module.js). + diff --git a/docs/modding/autosig-edit.md b/docs/modding/autosig-edit.md new file mode 100644 index 00000000..f0da9b18 --- /dev/null +++ b/docs/modding/autosig-edit.md @@ -0,0 +1,22 @@ +--- +layout: page +title: Auto Signature Editor +--- +## The Auto Signature Editor +The built in `autosig_edit` module allows users to edit their auto signatures (AKA "autosig"). + +### Theming +The following MCI codes are available: +* MCI 1 (ie: `MT1`): Editor +* MCI 2 (ie: `BT2`): Save button + +### Disabling Auto Signatures +Auto Signature support can be disabled for a particular message area by setting `autoSignatures` to false in the area's configuration block. + +Example: +```hjson +my_area: { + name: My Area + autoSignatures: false +} +``` diff --git a/docs/modding/bbs-list.md b/docs/modding/bbs-list.md new file mode 100644 index 00000000..4b61e616 --- /dev/null +++ b/docs/modding/bbs-list.md @@ -0,0 +1,24 @@ +--- +layout: page +title: BBS List +--- +## The BBS List Module +The built in `bbs_list` module provides the ability for users to manage entries to other Bulletin Board Systems. + +## Configuration +### Config Block +Available `config` block entries: +* `youSubmittedFormat`: Provides a format for entries that were submitted (and therefor ediable) by the current user. Defaults to `'{submitter} (You!)'`. Utilizes the same `itemFormat` object as entries described below. + +### Theming +The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) (the BBS list): +* `id`: Row ID +* `bbsName`: System name. Note that `{text}` also contains this value. +* `sysOp`: System Operator +* `telnet`: Telnet address +* `www`: Web address +* `location`: System location +* `software`: System's software +* `submitter`: Username of entry submitter +* `submitterUserId`: User ID of submitter +* `notes`: Any additional notes about the system diff --git a/docs/modding/door-servers.md b/docs/modding/door-servers.md new file mode 100644 index 00000000..5deaf89b --- /dev/null +++ b/docs/modding/door-servers.md @@ -0,0 +1,61 @@ +--- +layout: page +title: Door Servers +--- +## The bbs_link Module +Native support for [BBSLink](http://www.bbslink.net/) doors is provided via the `bbs_link` module. + +Configuration for a BBSLink door is straight forward. Take a look at the following example for launching Tradewars 2002: + +```hjson +doorTradeWars2002BBSLink: { + desc: Playing TW 2002 (BBSLink) + module: bbs_link + config: { + sysCode: XXXXXXXX + authCode: XXXXXXXX + schemeCode: XXXXXXXX + door: tw + } +} + +``` + +Fill in your credentials in `sysCode`, `authCode`, and `schemeCode` and that's it! + +## The door_party Module +The module `door_party` provides native support for [DoorParty!](http://www.throwbackbbs.com/) Configuration is quite easy: + +```hjson +doorParty: { + desc: Using DoorParty! + module: door_party + config: { + username: XXXXXXXX + password: XXXXXXXX + bbsTag: XX + } +} +``` + +Fill in `username`, `password`, and `bbsTag` with credentials provided to you and you should be in business! + +## The CombatNet Module +The `combatnet` module provides native support for [CombatNet](http://combatnet.us/). Add the following to your menu config: + +````hjson +combatNet: { + desc: Using CombatNet + module: combatnet + config: { + bbsTag: CBNxxx + password: XXXXXXXXX + } +} +```` +Update `bbsTag` (in the format CBNxxx) and `password` with the details provided when you register, then +you should be ready to rock! + +## The Exodus Module + +TBC \ No newline at end of file diff --git a/docs/modding/existing-mods.md b/docs/modding/existing-mods.md new file mode 100644 index 00000000..c64469d4 --- /dev/null +++ b/docs/modding/existing-mods.md @@ -0,0 +1,13 @@ +--- +layout: page +title: Existing Mods +--- +Many "addon" modules exist and have been released. Below are a few: + +| Name | Author | Description | +|-----------------------------|-------------|-------------| +| Married Bob Fetch Event | NuSkooler | An event for fetching the latest Married Bob ANSI's for display on you board. ACiDic release [ACD-MB4E.ZIP](https://l33t.codes/outgoing/ACD/ACD-MB4E.ZIP). Can also be [found on GitHub](https://github.com/NuSkooler/enigma-bbs-married_bob_evt) | +| Latest Files Announcement | NuSkooler | An event for posting the latest file arrivals of your board to message areas such as FTN style networks. ACiDic release [ACD-LFA1.ZIP](https://l33t.codes/outgoing/ACD/ACD-LFA1.ZIP). Also [found on GitHub](https://github.com/NuSkooler/enigma-bbs-latest_files_announce_evt) | +| Message Post Event | NuSkooler | An event for posting messages/ads to networks. ACiDic release [ACD-MP4E.ZIP](https://l33t.codes/outgoing/ACD/ACD-MP4E.ZIP) | + +See also [ACiDic BBS Mods by NuSkooler](https://l33t.codes/acidic-mods-by-myself/) \ No newline at end of file diff --git a/docs/modding/file-area-list.md b/docs/modding/file-area-list.md new file mode 100644 index 00000000..dcc0b958 --- /dev/null +++ b/docs/modding/file-area-list.md @@ -0,0 +1,95 @@ +--- +layout: page +title: File Area List +--- +## The File Area List Module +The built in `file_area_list` module provides a very flexible file listing UI. + +## Configuration +### Config Block +Available `config` block entries: +* `art`: Sub-configuration block used to establish art files used for file browsing: + * `browse`: The main browse screen. + * `details`: The main file details screen. + * `detailsGeneral`: The "general" tab of the details page. + * `detailsNfo`: The "NFO" viewer tab of the detials page. + * `detailsFileList`: The file listing tab of the details page (ie: used for listing archive contents). + * `help`: The help page. +* `hashTagsSep`: Separator for hash entries. Defaults to ", ". +* `isQueuedIndicator`: Indicator for items that are in the users download queue. Defaults to "Y". +* `isNotQueuedIndicator`: Indicator for items that are _not_ in the users download queue. Defaults to "N". +* `userRatingTicked`: Indicator for a items current _n_/5 "star" rating. Defaults to "\*". `userRatingTicked` and `userRatingUnticked` are combined to build strings such as "***--" for 3/5 rating. +* `userRatingUnticked`: Indicator for missing "stars" in a items _n_/5 rating. Defaults to "-". `userRatingTicked` and `userRatingUnticked` are combined to build strings such as "***--" for 3/5 rating. +* `webDlExpireTimeFormat`: Presents the expiration time of a web download URL. Defaults to current theme → system `short` date/time format. +* `webDlLinkNeedsGenerated`: Text to present when no web download link is yet generated. Defaults to "Not yet generated". +* `webDlLinkNoWebserver`: Text to present when no web download is available (ie: webserver not enabled). Defaults to "Web server is not enabled". +* `notAnArchiveFormat`: Presents text for the "archive type" field for non-archives. Defaults to "Not an archive". +* `uploadTimestampFormat`: Timestamp format for `xxxxxxInfoFormat##`. Defaults to current theme → system `short` date format. See also **Custom Info Formats** below. + +Remember that entries such as `isQueuedIndicator` and `userRatingTicked` may contain pipe color codes! + +## Custom Info Formats +Additional `config` block entries can set `xxxxxxInfoFormat##` formatting (where xxxxxx is the page name and ## is 10...99 such as `browseInfoFormat10`) for the various available pages: +* `browseInfoFormat##` for the `browse` page. See **Browse Page** below. +* `detailsInfoFormat##` for the `details` page. See **Details Page** below. +* `detailsGeneralInfoFormat##` for the `detailsGeneral` tab. See **Details Page - General Tab** below. +* `detailsNfoInfoFormat##` for the `detialsNfo` tab. See **Details Page - NFO/README Viewer Tab** below. +* `detailsFileListInfoFormat##` for the `detailsFileList` tab. See **Details Page - Archive/File Listing Tab** below. + +## Theming +### Browse Page +The browse page uses the `browse` art described above. The following MCI codes are available: +* MCI 1 (ie: `%MT1`): File's short description (user entered, FILE_ID.DIZ, etc.). +* MCI 2 (ie: `%HM2`): Navigation menu. +* MCI 10...99: Custom entires with the following format members: + * `{fileId}`: File identifier. + * `{fileName}`: File name (long). + * `{desc}`: File short description (user entered, FILE_ID.DIZ, etc.). + * `{descLong}`: File's long description (README.TXT, SOMEGROUP.NFO, etc.). + * `{uploadByUserName}`: User name of user that uploaded this file, or "N/A". + * `{uploadByUserId}`: User ID of user that uploaded this file, or "N/A". + * `{userRating}`: User rating of file as a number. + * `{userRatingString}`: User rating of this file as a string formatted with `userRatingTicked` and `userRatingUnticked` described above. + * `{areaTag}`: Area tag. + * `{areaName}`: Area name or "N/A". + * `{areaDesc}`: Area description or "N/A". + * `{fileSha256}`: File's SHA-256 value in hex. + * `{fileMd5}`: File's MD5 value in hex. + * `{fileSha1}`: File's SHA1 value in hex. + * `{fileCrc32}`: File's CRC-32 value in hex. + * `{estReleaseYear}`: Estimated release year of this file. + * `{dlCount}`: Number of times this file has been downloaded. + * `{byteSize}`: Size of this file in bytes. + * `{archiveType}`: Archive type of this file determined by system mappings, or "N/A". + * `{archiveTypeDesc}`: A more descriptive archive type based on system mappings, file extention, etc. or "N/A" if it cannot be determined. + * `{shortFileName}`: Short DOS style 8.3 name available for some scenarios such as TIC import, or "N/A". + * `{ticOrigin}`: Origin from TIC imported files "Origin" field, or "N/A". + * `{ticDesc}`: Description from TIC imported files "Desc" field, or "N/A". + * `{ticLDesc}`: Long description from TIC imported files "LDesc" field joined by a line feed, or "N/A". + * `{uploadTimestamp}`: Upload timestamp formatted with `browseUploadTimestampFormat`. + * `{hashTags}`: A string of hash tags(s) separated by `hashTagsSep` described above. "(none)" if there are no tags. + * `{isQueued}`: Indicates if a item is currently in the user's download queue presented as `isQueuedIndicator` or `isNotQueuedIndicator` described above. + * `{webDlLink}`: Web download link if generated else `webDlLinkNeedsGenerated` or `webDlLinkNoWebserver` described above. + * `{webDlExpire}`: Web download link expiration using `webDlExpireTimeFormat` described above. + +### Details Page +The details page uses the `details` art described above. The following MCI codes are available: +* MCI 1 (ie: `%HM1`): Navigation menu +* `%XY2`: Info area's top X,Y position. +* `%XY3`: Info area's bottom X,Y position. +* MCI 10...99: Custom entries with the format options described above in **Browse Page** via the `detailsInfoFormat##` `config` block entry. + +### Details Page - General Tab +The details page general tab uses the `detailsGeneral` art described above. The following MCI codes are available: +* MCI 10...99: Custom entries with the format options described above in **Browse Page** via the `detailsGeneralInfoFormat##` `config` block entry. + +### Details Page - NFO/README Viewer Tab +The details page nfo tab uses the `detailsNfo` art described above. The following MCI codes are available: +* MCI 1 (ie: `%MT1`): NFO/README viewer using the entries `longDesc`. +* MCI 10...99: Custom entries with the format options described above in **Browse Page** via the `detailsNfoInfoFormat##` `config` block entry. + +### Details Page - Archive/File Listing Tab +The details page file list tab uses the `detailsFileList` art described above. The following MCI codes are available: +* MCI 1 (ie: `%VM1`): List of entries in archive. Entries are formatted using the standard `itemFormat` and `focusItemFormat` properties of the view and have all of the format options described above in **Browse Page**. +* MCI 10...99: Custom entries with the format options described above in **Browse Page** via the `detailsFileListInfoFormat##` `config` block entry. + diff --git a/docs/modding/file-base-download-manager.md b/docs/modding/file-base-download-manager.md new file mode 100644 index 00000000..023ae478 --- /dev/null +++ b/docs/modding/file-base-download-manager.md @@ -0,0 +1,23 @@ +--- +layout: page +title: File Base Download Manager +--- +## File Base Download Manager Module +The `file_base_download_manager` module provides a download queue manager for "legacy" (X/Y/Z-Modem, etc.) downloads. Web (HTTP/HTTPS) download functionality can be optionally available when the web content server is enabled. + +## Configuration +### Configuration Block +Available `config` block entries: +* `webDlExpireTimeFormat`: Sets the moment.js style format for web download expiration date/time. +* `fileTransferProtocolSelection`: Overrides the default `fileTransferProtocolSelection` target for a protocol selection menu. +* `emptyQueueMenu`: Overrides the default `fileBaseDownloadManagerEmptyQueue` target for menu to show when the users D/L queue is empty. + +### Theming +The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) and MCI 10+ custom fields: +* `fileId`: File ID. +* `areaTag`: Area tag. +* `fileName`: Entry filename. +* `path`: Full file path. +* `byteSize`: Size in bytes of file. +* `webDlLink`: Web download link including [VTX style ANSI ESC sequences](https://raw.githubusercontent.com/codewar65/VTX_ClientServer/master/vtx.txt). +* `webDlExpire`: Expiration date/time for this link. Formatted using `webDlExpireTimeFormat`. \ No newline at end of file diff --git a/docs/modding/file-base-web-download-manager.md b/docs/modding/file-base-web-download-manager.md new file mode 100644 index 00000000..1dadca00 --- /dev/null +++ b/docs/modding/file-base-web-download-manager.md @@ -0,0 +1,26 @@ +--- +layout: page +title: File Base Web Download Manager +--- +## File Base Web Download Manager Module +The `file_base_web_download_manager` module provides a download queue manager for web (HTTP/HTTPS) based downloads. This module relies on having the web server enabled at a minimum. + +Web downloads can be a convienent way for users to download larger (100+ MiB) files where legacy protocols often have trouble. Additionally, batch downloads can be streamed to users in a single zip archive. + +## Configuration +### Configuration Block +Available `config` block entries: +* `webDlExpireTimeFormat`: Sets the moment.js style format for web download expiration date/time. +* `emptyQueueMenu`: Overrides the default `fileBaseDownloadManagerEmptyQueue` target for menu to show when the users D/L queue is empty. + +### Theming +The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) and custom range MCI 10+ custom fields: +* `fileId`: File ID. +* `areaTag`: Area tag. +* `fileName`: Entry filename. +* `path`: Full file path. +* `byteSize`: Size in bytes of file. +* `webDlLinkRaw`: Web download link. +* `webDlLink`: Web download link including [VTX style ANSI ESC sequences](https://raw.githubusercontent.com/codewar65/VTX_ClientServer/master/vtx.txt). +* `webDlExpire`: Expiration date/time for this link. Formatted using `webDlExpireTimeFormat`. + diff --git a/docs/modding/file-transfer-protocol-select.md b/docs/modding/file-transfer-protocol-select.md new file mode 100644 index 00000000..72b8d124 --- /dev/null +++ b/docs/modding/file-transfer-protocol-select.md @@ -0,0 +1,13 @@ +--- +layout: page +title: File Transfer Protocol Select +--- +## The Rumorz Module +The built in `file_transfer_protocol_select` module provides a way to select a legacy file transfer protocol (X/Y/Z-Modem, etc.) for upload/downloads. + +## Configuration + +### Theming +The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) (the protocol list): +* `name`: The name of the protocol. Each entry is +op defined in `config.hjson` with defaults found in `config.js`. Note that the standard `{text}` field also contains this value. + diff --git a/docs/modding/last-callers.md b/docs/modding/last-callers.md new file mode 100644 index 00000000..0e15b4f8 --- /dev/null +++ b/docs/modding/last-callers.md @@ -0,0 +1,40 @@ +--- +layout: page +title: Last Callers +--- +## The Last Callers Module +The built in `last_callers` module provides flexible retro last callers mod. + +## Configuration +### Config Block +Available `config` block entries: +* `dateTimeFormat`: [moment.js](https://momentjs.com) style format. Defaults to current theme → system `short` format. +* `user`: User options: + * `collapse`: Collapse or roll up entries that fall within the period specified. May be a string in the form of `30 minutes`, `3 weeks`, `1 hour`, etc. +* `sysop`: Sysop options: + * `collapse`: Collapse or roll up entries that fall within the period specified. May be a string in the form of `30 minutes`, `3 weeks`, `1 hour`, etc. + * `hide`: Hide all +op logins +* `actionIndicators`: Maps user events/actions to indicators. For example: `userDownload` to "D". Available indicators: + * `newUser`: User is new. + * `dlFiles`: User downloaded file(s). + * `ulFiles`: User uploaded file(s). + * `postMsg`: User posted message(s) to the message base, EchoMail, etc. + * `sendMail`: User sent _private_ mail. + * `runDoor`: User ran door(s). + * `sendNodeMsg`: User sent a node message(s). + * `achievementEarned`: User earned an achievement(s). +* `actionIndicatorDefault`: Default indicator when an action is not set. Defaults to "-". + +Remember that entries such as `actionIndicators` and `actionIndicatorDefault` may contain pipe color codes! + +### Theming +The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`): +* `userId`: User ID. +* `userName`: Login username. +* `realName`: User's real name. +* `ts`: Timestamp in `dateTimeFormat` format. +* `location`: User's location. +* `affiliation` or `affils`: Users affiliations. +* `actions`: A string built by concatenating action indicators for a users logged in session. For example, given a indicator of `userDownload` mapped to "D", the string may be "-D----". The format was made popular on Amiga style boards. + + diff --git a/docs/modding/local-doors.md b/docs/modding/local-doors.md new file mode 100644 index 00000000..2a4a1338 --- /dev/null +++ b/docs/modding/local-doors.md @@ -0,0 +1,241 @@ +--- +layout: page +title: Local Doors +--- +## Local Doors +ENiGMA½ has many ways to add doors to your system. In addition to the [many built in door server modules](door-servers.md), local doors are of course also supported using the ! The `abracadabra` module! + +## The abracadabra Module +The `abracadabra` module provides a generic and flexible solution for many door types. Through this module you can execute native processes & scripts directly, and perform I/O through standard I/O (stdio) or a temporary TCP server. + +### Configuration +The `abracadabra` `config` block can contain the following members: + +| Item | Required | Description | +|------|----------|-------------| +| `name` | :+1: | Used as a key for tracking number of clients using a particular door. | +| `dropFileType` | :+1: | Specifies the type of dropfile to generate (See **Dropfile Types** below). | +| `cmd` | :+1: | Path to executable to launch. | +| `args` | :-1: | Array of argument(s) to pass to `cmd`. See **Argument Variables** below for information on variables that can be used here. +| `cwd` | :-1: | Sets the Current Working Directory (CWD) for `cmd`. Defaults to the directory of `cmd`. | +| `nodeMax` | :-1: | Max number of nodes that can access this door at once. Uses `name` as a tracking key. | +| `tooManyArt` | :-1: | Art spec to display if too many instances are already in use. | +| `io` | :-1: | How to process input/output (I/O). Can be `stdio` or `socket`. When using `stdio`, I/O is handled via standard stdin/stdout. When using `socket` a temporary socket server is spawned that can be connected back to. The server listens on localhost on `{srvPort}` (See **Argument Variables** below for more information). Default value is `stdio`. | +| `encoding` | :-1: | Sets the **door's** encoding. Defaults to `cp437`. Linux binaries often produce `utf8`. | + +#### Dropfile Types +Dropfile types specified by `dropFileType`: + +| Value | Description | +|-------|-------------| +| `DOOR` | [DOOR.SYS](http://goldfndr.home.mindspring.com/dropfile/doorsys.htm) +| `DOOR32` | [DOOR32.SYS](https://raw.githubusercontent.com/NuSkooler/ansi-bbs/master/docs/dropfile_formats/door32_sys.txt) +| `DORINFO` | [DORINFOx.DEF](http://goldfndr.home.mindspring.com/dropfile/dorinfo.htm) + +#### Argument Variables +The following variables may be used in `args` entries: + +| Variable | Description | Example | +|----------|-------------|---------| +| `{node}` | Current node number. | `1` | +| `{dropFile}` | Dropfile _filename_ only. | `DOOR.SYS` | +| `{dropFilePath}` | Full path to generated dropfile. The system places dropfiles in the path set by `paths.dropFiles` in `config.hjson`. | `C:\enigma-bbs\drop\node1\DOOR.SYS` | +| `{userId}` | Current user ID. | `420` | +| `{userName}` | [Sanitized](https://www.npmjs.com/package/sanitize-filename) username. Safe for filenames, etc. If the full username is sanitized away, this will resolve to something like "user_1234". | `izard` | +| `{userNameRaw}` | _Raw_ username. May not be safe for filenames! | `\/\/izard` | +| `{srvPort}` | Temporary server port when `io` is set to `socket`. | `1234` | +| `{cwd}` | Current Working Directory. | `/home/enigma-bbs/doors/foo/` | + +Example `args` member using some variables described above: +```hjson +args: [ + "-D", "{dropFilePath}", + "-N", "{node}" + "-U", "{userId}" +] +``` + +### DOSEMU with abracadabra +[DOSEMU](http://www.dosemu.org/) can provide a good solution for running legacy DOS doors when running on Linux systems. For this, we will create a virtual serial port (COM1) that communicates via stdio. + +As an example, here are the steps for setting up Pimp Wars: + +First, create a `dosemu.conf` file with the following contents: +``` +$_cpu = "80486" +$_cpu_emu = "vm86" +$_external_char_set = "utf8" +$_internal_char_set = "cp437" +$_term_updfreq = (8) +$_layout = "us" +$_rawkeyboard = (0) +$_com1 = "virtual" +``` + +The line `$_com1 = "virtual"` tells DOSEMU to use `stdio` as a virtual serial port on COM1. + +Next, we create a virtual **X** drive for Pimp Wars to live such as `/enigma-bbs/DOS/X/PW` and map it with a custom `AUTOEXEC.BAT` file within DOSEMU: +``` +@echo off +path d:\bin;d:\gnu;d:\dosemu +set TEMP=c:\tmp +prompt $P$G +REM http://www.pcmicro.com/bnu/ +C:\BNU\BNU.COM /L0:57600,8N1 /F +lredir.com x: linux\fs\enigma-bbs\DOS\X +unix -e +``` + +Note that we also have the [BNU](http://www.pcmicro.com/bnu/) FOSSIL driver installed at `C:\BNU\\`. Another option would be to install this to X: somewhere as well. + +Finally, let's create a `menu.hjson` entry to launch the game: +```hjson +doorPimpWars: { + desc: Playing PimpWars + module: abracadabra + config: { + name: PimpWars + dropFileType: DORINFO + cmd: /usr/bin/dosemu + args: [ + "-quiet", + "-f", + "/path/to/dosemu.conf", + "X:\\PW\\START.BAT {dropFile} {node}" + ], + nodeMax: 1 + tooManyArt: DOORMANY + io: stdio + } +} +``` + +### Shared Socket Descriptors +Due to Node.js limitations, ENiGMA½ does not _directly_ support `DOOR32.SYS` style socket descriptor sharing (other `DOOR32.SYS` features are fully supported). However, a separate binary called [bivrost!](https://github.com/NuSkooler/bivrost) can be used. bivrost! is available for Windows and Linux x86/i686 and x86_64/AMD64. Other platforms where [Rust](https://www.rust-lang.org/) builds are likely to work as well. + +#### Example configuration +Below is an example `menu.hjson` entry using bivrost! to launch a door: + +```hjson +doorWithBivrost: { + desc: Bivrost Example + module: abracadabra + config: { + name: BivrostExample + dropFileType: DOOR32 + cmd: "C:\\enigma-bbs\\utils\\bivrost.exe" + args: [ + "--port", "{srvPort}", // bivrost! will connect this port on localhost + "--dropfile", "{dropFilePath}", // ...and read this DOOR32.SYS produced by ENiGMA½ + "--out", "C:\\doors\\jezebel", // ...and produce a NEW DOOR32.SYS here. + + // + // Note that the final params bivrost! will use to + // launch the door are grouped here. The {fd} variable could + // also be supplied here if needed. + // + "C:\\door\\door.exe C:\\door\\door32.sys" + ], + nodeMax: 1 + tooManyArt: DOORMANY + io: socket + } +} +``` + +Please see the [bivrost!](https://github.com/NuSkooler/bivrost) documentation for more information. + +#### Phenom Productions Releases +Pre-built binaries of bivrost! have been released under [Phenom Productions](https://www.phenomprod.com/) and can be found on various boards. + +#### Alternative Workarounds +Alternative workarounds include [Telnet Bridge module](telnet-bridge.md) to hook up Telnet-accessible (including local) door servers -- It may also be possible bridge via [NET2BBS](http://pcmicro.com/netfoss/guide/net2bbs.html). + +### QEMU with abracadabra +[QEMU](http://wiki.qemu.org/Main_Page) provides a robust, cross platform solution for launching doors under many platforms (likely anywhere Node.js is supported and ENiGMA½ can run). Note however that there is an important and major caveat: **Multiple instances of a particular door/OS image should not be run at once!** Being more flexible means being a bit more complex. Let's look at an example for running L.O.R.D. under a UNIX like system such as Linux or FreeBSD. + +Basically we'll be creating a bootstrap shell script that generates a temporary node specific `GO.BAT` to launch our door. This will be called from `AUTOEXEC.BAT` within our QEMU FreeDOS partition. + +#### Step 1: Create a FreeDOS image +[FreeDOS](http://www.freedos.org/) is a free mostly MS-DOS compatible DOS package that works well for running 16bit doors. Follow the [QEMU/FreeDOS](https://en.wikibooks.org/wiki/QEMU/FreeDOS) guide for creating an `freedos_c.img`. This will contain FreeDOS itself and installed BBS doors. + +After this is complete, copy LORD to C:\DOORS\LORD within FreeDOS. An easy way to tranfer files from host to DOS is to use QEMU's vfat as a drive. For example: +```bash +qemu-system-i386 -localtime /home/enigma/dos/images/freedos_c.img -hdb fat:/path/to/downloads +``` + +With the above you can now copy files from D: to C: within FreeDOS and add the following to it's `autoexec.bat`: +```bat +CALL E:\GO.BAT +``` + +#### Step 2: Create a bootstrap script +Our bootstrap script will prepare `GO.BAT` and launch FreeDOS. Below is an example: + + +```bash +#!/bin/bash + +NODE=$1 +DROPFILE=D:\\$2 +SRVPORT=$3 + +mkdir -p /home/enigma/dos/go/node$NODE + +cat > /home/enigma/dos/go/node$NODE/GO.BAT <= 16GB recommended. -2. After booting Minibian, expand your file system. See http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi for information. -3. Update & upgrade: `apt-get update && apt-get upgrade` -4. It is recommended that you install `sudo` and create an admin user: `apt-get install sudo`, `adduser `, `adduser sudo` (reboot & login as the user your just created) -5. We want to build dependencies with a updated version of GCC. The following works to install GCC 4.9 on Minibian "wheezy": -a. Update */etc/apt/sources.list* replacing all "wheezy" with "jessie" -b. `sudo apt-get update` -c. `sudo apt-get install gcc-4.9 g++-4.9` -d. Update */etc/apt/sources.list* reverting all "jessie" back to "wheezy" -e. `sudo apt-get update` -f. Update alternatives: `sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.9 60 --slave /usr/bin/g++ g++ /usr/bin/g++-4.9` -6. Install dependencies: `sudo apt-get install make python libicu-dev libssl-dev git` -7. Install the latest Node.js from here: http://node-arm.herokuapp.com/ (**only download the .dep and dpkg install it!**) -8. The RPi A has very low memory, we'll need a swap file: -a. `sudo dd if=/dev/zero of=tmpswap bs=1024 count=1M` -b. `sudo mkswap tmpswap` -c. `sudo swapon tmpswap` -9. Clone enigma-bbs.git -10. Install dependencies. Here we will force GCC 4.9 for compilation: `CC=gcc-4.9 npm install` -11. Follow generic setup for creating a config.hjson, etc. and you should be ready to go! - diff --git a/docs/servers/gopher.md b/docs/servers/gopher.md new file mode 100644 index 00000000..343c7d80 --- /dev/null +++ b/docs/servers/gopher.md @@ -0,0 +1,40 @@ +--- +layout: page +title: Gopher Server +--- +## The Gopher Content Server +The Gopher *content server* provides access to publicly exposed message conferences and areas over Gopher (gopher://). + +## Configuration +Gopher configuration is found in `contentServers.gopher` in `config.hjson`. + +| Item | Required | Description | +|------|----------|-------------| +| `enabled` | :+1: | Set to `true` to enable Gopher | +| `port` | :-1: | Override the default port of `8070` | +| `publicHostName` | :+1: | Set the **public** hostname/domain that Gopher will serve to the outside world. Example: `myfancybbs.com` | +| `publicPort` | :+1: | Set the **public** port that Gopher will serve to the outside world. | +| `messageConferences` | :+1: | An map of *conference tags* to *area tags* that are publicly exposed via Gopher. See example below. | + +Notes on `publicHostName` and `publicPort`: +The Gopher protocol serves content that contains host/domain and port even when referencing it's own documents. Due to this, these members must be set to your publicly addressable Gopher server! + +### Example +Let's suppose you are serving Gopher for your BBS at `myfancybbs.com`. Your ENiGMA½ system is listening on the default Gopher `port` of 8070 but you're behind a firewall and want port 70 exposed to the public. Lastly, you want to expose some fsxNet areas: + +```hjson +contentServers: { + gopher: { + enabled: true + publicHostName: myfancybbs.com + publicPort: 70 + + messageConferences: { + fsxnet: { // fsxNet's conf tag + // Areas of fsxNet we want to expose: + "fsx_gen", "fsx_bbs" + } + } + } +} +``` diff --git a/docs/servers/nntp.md b/docs/servers/nntp.md new file mode 100644 index 00000000..c6ceaf2e --- /dev/null +++ b/docs/servers/nntp.md @@ -0,0 +1,66 @@ +--- +layout: page +title: NNTP Server +--- +## The NNTP Content Server +The NNTP *content server* provides access to publicly exposed message conferences and areas over either **secure** NNTPS (NNTP over TLS or nttps://) and/or non-secure NNTP (nntp://). + +## Configuration +| Item | Required | Description | +|------|----------|-------------| +| `nntp` | :-1: | Configuration block for non-secure NNTP. See Non-Secure NNTP Configuration below. | +| `nntps` | :-1: | Configuration block for secure NNTP. See Secure NNTPS Configuration below. | +| `publicMessageConferences` | :+1: | A map of *conference tags* to *area tags* that are publicly exposed over NNTP. Anonymous users will get read-only access to these areas. | + +### See Non-Secure NNTP Configuration +Under `contentServers.nntp.nntp` the following configuration is allowed: + +| Item | Required | Description | +|------|----------|-------------| +| `enabled` | :+1: | Set to `true` to enable non-secure NNTP access. | +| `port` | :-1: | Override the default port of `8119`. | + +### Secure NNTPS Configuration +Under `contentServers.nntp.nntps` the following configuration is allowed: + +| Item | Required | Description | +|------|----------|-------------| +| `enabled` | :+1: | Set to `true` to enable secure NNTPS access. | +| `port` | :-1: | Override the default port of `8565`. | +| `certPem` | :-1: | Override the default certificate file path of `./config/nntps_cert.pem` | +| `keyPem` | :-1: | Override the default certificate key file path of `./config/nntps_key.pem` | + +#### Certificates and Keys +In order to use secure NNTPS, a TLS certificate and key pair must be provided. You may generate your own but most clients **will not trust** them. A certificate and key from a trusted Certificate Authority is recommended. [Let's Encrypt](https://letsencrypt.org/) provides free TLS certificates. Certificates and private keys must be in [PEM format](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail). + +##### Generating Your Own +An example of generating your own cert/key pair: +```bash +openssl req -newkey rsa:2048 -nodes -keyout ./config/nntps_key.pem -x509 -days 3050 -out ./config/nntps_cert.pem +``` + +### Example Configuration +```hjson +contentServers: { + nntp: { + publicMessageConferences: { + fsxnet: [ + // Expose these areas of fsxNet + "fsx_gen", "fsx_bbs" + ] + } + + nntp: { + enabled: true + } + + nntps: { + enabled: true + + // These could point to Let's Encrypt provided pairs for example: + certPem: /path/to/some/tls_cert.pem + keyPem: /path/to/some/tls_private_key.pem + } + } +} +``` diff --git a/docs/servers/ssh.md b/docs/servers/ssh.md new file mode 100644 index 00000000..2f8b7769 --- /dev/null +++ b/docs/servers/ssh.md @@ -0,0 +1,52 @@ +--- +layout: page +title: SSH Server +--- +## SSH Login Server +The ENiGMA½ SSH *login server* allows secure user logins over SSH (ssh://). + +## Configuration +Entries available under `config.loginServers.ssh`: + +| Item | Required | Description | +|------|----------|-------------| +| `privateKeyPem` | :-1: | Path to private key file. If not set, defaults to `./config/ssh_private_key.pem` | +| `privateKeyPass` | :+1: | Password to private key file. +| `firstMenu` | :-1: | First menu an SSH connected user is presented with. Defaults to `sshConnected`. | +| `firstMenuNewUser` | :-1: | Menu presented to user when logging in with one of the usernames found within `users.newUserNames` in your `config.hjson`. Examples include `new` and `apply`. | +| `enabled` | :+1: | Set to `true` to enable the SSH server. | +| `port` | :-1: | Override the default port of `8443`. | +| `address` | :-1: | Sets an explicit bind address. | +| `algorithms` | :-1: | Configuration block for SSH algorithms. Includes keys of `kex`, `cipher`, `hmac`, and `compress`. See the algorithms section in the [ssh2-streams](https://github.com/mscdex/ssh2-streams#ssh2stream-methods) documentation for details. For defaults set by ENiGMA½, see `core/config.js`. +| `traceConnections` | :-1: | Set to `true` to enable full trace-level information on SSH connections. + +### Example Configuration + +```hjson +{ + loginServers: { + ssh: { + enabled: true + port: 8889 + privateKeyPem: /path/to/ssh_private_key.pem + privateKeyPass: sup3rs3kr3tpa55 + } + } +} +``` + +## Generate a SSH Private Key +To utilize the SSH server, an SSH Private Key (PK) will need generated. OpenSSL can be used for this task: + +### Modern OpenSSL +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa -out ./config/ssh_private_key.pem -aes128 +``` + +### Legacy OpenSSL +```bash +openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048 +``` + +Note that you may need `-3des` for every old implementations or SSH clients! + diff --git a/docs/servers/telnet.md b/docs/servers/telnet.md new file mode 100644 index 00000000..fb4baddb --- /dev/null +++ b/docs/servers/telnet.md @@ -0,0 +1,30 @@ +--- +layout: page +title: Telnet Server +--- +## Telnet Login Server +The Telnet *login server* provides a standard **non-secure** Telnet login experience. + +## Configuration +The following configuration can be made in `config.hjson` under the `loginServers.telnet` block: + +| Key | Required | Description | +|------|----------|-------------| +| `enabled` | :-1: Defaults to `true`. Set to `false` to disable Telnet | +| `port` | :-1: | Override the default port of `8888`. | +| `address` | :-1: | Sets an explicit bind address. | +| `firstMenu` | :-1: | First menu a telnet connected user is presented with. Defaults to `telnetConnected`. | + +### Example Configuration +```hjson +{ + loginServers: { + telnet: { + enabled: true + port: 8888 + } + } +} +``` + + diff --git a/docs/servers/web-server.md b/docs/servers/web-server.md new file mode 100644 index 00000000..5675c4c6 --- /dev/null +++ b/docs/servers/web-server.md @@ -0,0 +1,57 @@ +--- +layout: page +title: Web Server +--- +ENiGMA½ comes with a built in *content server* for supporting both HTTP and HTTPS. Currently the [File Bases](file_base.md) registers routes for file downloads, password reset email links are handled via the server, and static files can also be served for your BBS. Other features will likely come in the future or you can easily write your own! + +# Configuration +By default the web server is not enabled. To enable it, you will need to at a minimum configure two keys in the `contentServers.web` section of `config.hjson`: + +```hjson +contentServers: { + web: { + domain: bbs.yourdomain.com + + http: { + enabled: true + port: 8080 + } + } +} +``` + +The following is a table of all configuration keys available under `contentServers.web`: +| Key | Required | Description | +|------|----------|-------------| +| `domain` | :+1: | Sets the domain, e.g. `bbs.yourdomain.com`. | +| `http` | :-1: | Sub configuration for HTTP (non-secure) connections. See **HTTP Configuration** below. | +| `overrideUrlPrefix` | :-1: | Instructs the system to be explicit when handing out URLs. Useful if your server is behind a transparent proxy. | + +### HTTP Configuration +Entries available under `contentServers.web.http`: + +| Key | Required | Description | +|------|----------|-------------| +| `enable` | :+1: | Set to `true` to enable this server. +| `port` | :-1: | Override the default port of `8080`. | +| `address` | :-1: | Sets an explicit bind address. | + +### HTTPS Configuration +Entries available under `contentServers.web.https`: + +| Key | Required | Description | +|------|----------|-------------| +| `enable` | :+1: | Set to `true` to enable this server. +| `port` | :-1: | Override the default port of `8080`. | +| `address` | :-1: | Sets an explicit bind address. | +| `certPem` | :+1: | Overrides the default certificate path of `/config/https_cert.pem`. Certificate must be in PEM format. See **Certificates** below. | +| `keyPem` | :+1: | Overrides the default certificate key path of `/config/https_cert_key.pem`. Key must be in PEM format. See **Certificates** below. | + +#### Certificates +If you don't have a TLS certificate for your domain, a good source for a certificate can be [LetsEncrypt](https://letsencrypt.org/) who supplies free and trusted TLS certificates. + +## Static Routes +Static files live relative to the `contentServers.web.staticRoot` path which defaults to `enigma-bbs/www`. + +## Custom Error Pages +Customized error pages can be created for [HTTP error codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_Client_Error) by providing a `.html` file in the *static routes* area. For example: `404.html`. diff --git a/docs/servers/websocket.md b/docs/servers/websocket.md new file mode 100644 index 00000000..98f867e7 --- /dev/null +++ b/docs/servers/websocket.md @@ -0,0 +1,105 @@ +--- +layout: page +title: Web Socket / Web Interface Server +--- +## WebSocket Login Server +The WebSocket Login Server provides **secure** (wss://) as well as non-secure (ws://) WebSocket login access. This is often combined with a browser based WebSocket client such as VTX or fTelnet. + +# VTX Web Client +ENiGMA supports the VTX websocket client for connecting to your BBS from a web page. Example usage can be found at +[Xibalba](https://l33t.codes/vtx/xibalba.html) and [fORCE9](https://bbs.force9.org/vtx/force9.html). + +## Before You Start + +There are a few things out of scope of this document: + + - You'll need a web server for hosting the files - this can be anywhere, but it obviously makes sense to host it + somewhere with a hostname relevant to your BBS! + + - It's not required, but you should use SSL certificates to secure your website, and for supplying to ENiGMA to + secure the websocket connections. [Let's Encrypt](https://letsencrypt.org/) provide a free well-respected service. + + - How you make the websocket service available on the internet is up to you, but it'll likely by forwarding ports on + your router to the box hosting ENiGMA. Use the same method you did for forwarding the telnet port. + +## Setup + +1. Enable the websocket in ENiGMA, by adding `webSocket` configuration to the `loginServers` block in `config.hjson` (create it if you +don't already have it defined). + + ````hjson + loginServers: { + webSocket : { + ws: { + // non-secure ws:// + port: 8810 + enabled: true + } + wss: { + // secure-over-tls wss:// + port: 8811 + enabled: true + certPem: /path/to/https_cert.pem + keyPem: /path/to/https_cert_key.pem + } + // set proxied to true to allow TLS-terminated proxied connections + // containing the "X-Forwarded-Proto: https" header to be treated + // as secure + proxied: true + } + } + ```` + +2. Restart ENiGMA and check the logs to ensure the websocket service starts successfully, you'll see something like the +following: + + ```` + [2017-10-29T12:13:30.668Z] INFO: ENiGMA½ BBS/30978 on force9: Listening for connections (server="WebSocket (insecure)", port=8810) + [2017-10-29T12:13:30.669Z] INFO: ENiGMA½ BBS/30978 on force9: Listening for connections (server="WebSocket (secure)", port=8811) + ```` + +3. Download the [VTX_ClientServer](https://github.com/codewar65/VTX_ClientServer/archive/master.zip) to your +webserver, and unpack it to a temporary directory. + +4. Download the example [VTX client HTML file](/misc/vtx/vtx.html) and save it to your webserver root. + +5. Create an `assets/vtx` directory within your webserver root, so you have a structure like the following: + + ````text + ├── assets + │   └── vtx + └── vtx.html + ```` + +6. From the VTX_ClientServer package unpacked earlier, copy the contents of the `www` directory into `assets/vtx` directory. + +7. Create a vtxdata.js file, and save it to `assets/vtx`: + + ````javascript + var vtxdata = { + sysName: "Your Awesome BBS", + wsConnect: "wss://your-hostname.here:8811", + term: "ansi-bbs", + codePage: "CP437", + fontName: "UVGA16", + fontSize: "24px", + crtCols: 80, + crtRows: 25, + crtHistory: 500, + xScale: 1, + initStr: "", + defPageAttr: 0x1010, + defCrsrAttr: 0x0207, + defCellAttr: 0x0007, + telnet: 1, + autoConnect: 0 + }; + ```` + +8. Update `sysName` and `wsConnect` accordingly. Use `wss://` if you set up the websocket service with SSL, `ws://` +otherwise. + +9. If you navigate to http://your-hostname.here/vtx.html, you should see a splash screen like the following: + ![VTXClient](../assets/images/vtxclient.png "VTXClient") + + diff --git a/docs/troubleshooting/monitoring-logs.md b/docs/troubleshooting/monitoring-logs.md new file mode 100644 index 00000000..4fa27a5c --- /dev/null +++ b/docs/troubleshooting/monitoring-logs.md @@ -0,0 +1,52 @@ +--- +layout: page +title: Monitoring Logs +--- +## Monitoring Logs +ENiGMA½ does not produce much to stdout. Logs are produced by [Bunyan](https://github.com/trentm/node-bunyan) which outputs each entry as a JSON object. + +Start by installing bunyan and making it available on your path: + +```bash +npm install bunyan -g +``` + +or via Yarn: +```bash +yarn global add bunyan +``` + +To tail logs in a colorized and pretty format, issue the following command: +```bash +tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan +``` + +See `bunyan --help` for more information on what you can do! + +### Example +Logs _without_ Bunyan: +```bash +tail -F /path/to/enigma-bbs/logs/enigma-bbs.log +{"name":"ENiGMA½ BBS","hostname":"nu-dev","pid":25002,"level":30,"eventName":"updateFileAreaStats","action":{"type":"method","location":"core/file_base_area.js","what":"updateAreaStatsScheduledEvent","args":[]},"reason":"Schedule","msg":"Executing scheduled event action...","time":"2018-12-15T16:00:00.001Z","v":0} +{"name":"ENiGMA½ BBS","hostname":"nu-dev","pid":25002,"level":30,"module":"FTN BSO","msg":"Performing scheduled message import/toss...","time":"2018-12-15T16:00:00.002Z","v":0} +{"name":"ENiGMA½ BBS","hostname":"nu-dev","pid":25002,"level":30,"module":"FTN BSO","msg":"Performing scheduled message import/toss...","time":"2018-12-15T16:30:00.008Z","v":0} +``` + +Oof! + +Logs _with_ Bunyan: +```bash +tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan +[2018-12-15T16:00:00.001Z] INFO: ENiGMA½ BBS/25002 on nu-dev: Executing scheduled event action... (eventName=updateFileAreaStats, reason=Schedule) + action: { + "type": "method", + "location": "core/file_base_area.js", + "what": "updateAreaStatsScheduledEvent", + "args": [] + } +[2018-12-15T16:00:00.002Z] INFO: ENiGMA½ BBS/25002 on nu-dev: Performing scheduled message import/toss... (module="FTN BSO") +[2018-12-15T16:30:00.008Z] INFO: ENiGMA½ BBS/25002 on nu-dev: Performing scheduled message import/toss... (module="FTN BSO") +``` + +Much better! + diff --git a/docs/web_server.md b/docs/web_server.md deleted file mode 100644 index 8c341191..00000000 --- a/docs/web_server.md +++ /dev/null @@ -1,40 +0,0 @@ -# Web Server -ENiGMA½ comes with a built in *content server* for supporting both HTTP and HTTPS. Currently the [File Bases](file_base.md) registers routes for file downloads, and static files can also be served for your BBS. Other features will likely come in the future or you can easily write your own! - -## Configuration -By default the web server is not enabled. To enable it, you will need to at a minimum configure two keys in the `contentServers::web` section of `config.hjson`: - -```hjson -contentServers: { - web: { - domain: bbs.yourdomain.com - - http: { - enabled: true - } - } -} -``` - -This will configure HTTP for port 8080 (override with `port`). To additionally enable HTTPS, you will need a PEM encoded SSL certificate and private key. Once obtained, simply enable the HTTPS server: -```hjson -contentServers: { - web: { - domain: bbs.yourdomain.com - // set 'overrideUrlPrefix' if for example, you use a transparent proxy in front of ENiGMA and need to be explicit about URLs the system hands out - overrideUrlPrefix: https://bbs.yourdomain.com - https: { - enabled: true - port: 8443 - certPem: /path/to/your/cert.pem - keyPem: /path/to/your/cert_private_key.pem - } - } -} -``` - -### Static Routes -Static files live relative to the `contentServers::web::staticRoot` path which defaults to `enigma-bbs/www`. - -### Custom Error Pages -Customized error pages can be created for [HTTP error codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_Client_Error) by providing a `.html` file in the *static routes* area. For example: `404.html`. diff --git a/main.js b/main.js index 0bc7bee9..53a320f6 100755 --- a/main.js +++ b/main.js @@ -7,6 +7,6 @@ ENiGMA½ entry point If this file does not run directly, ensure it's executable: - > chmod u+x main.js + > chmod u+x main.js */ require('./core/bbs.js').main(); \ No newline at end of file diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs index b381ccef..25e5853a 100644 --- a/misc/acs_parser.pegjs +++ b/misc/acs_parser.pegjs @@ -1,106 +1,222 @@ { - var client = options.client; - var user = options.client.user; + const UserProps = require('./user_property.js'); + const Log = require('./logger.js').log; + const User = require('./user.js'); - var _ = require('lodash'); - var assert = require('assert'); + const _ = require('lodash'); + const moment = require('moment'); + + const client = _.get(options, 'subject.client'); + const user = _.get(options, 'subject.user'); function checkAccess(acsCode, value) { try { return { LC : function isLocalConnection() { - return client.isLocal(); + return client && client.isLocal(); }, AG : function ageGreaterOrEqualThan() { - return !isNaN(value) && user.getAge() >= value; + return !isNaN(value) && user && user.getAge() >= value; }, AS : function accountStatus() { - if(!_.isArray(value)) { + if(!user) { + return false; + } + if(!Array.isArray(value)) { value = [ value ]; } - - const userAccountStatus = parseInt(user.properties.account_status, 10); - value = value.map(n => parseInt(n, 10)); // ensure we have integers - return value.indexOf(userAccountStatus) > -1; + const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus); + return value.map(n => parseInt(n, 10)).includes(userAccountStatus); }, EC : function isEncoding() { + const encoding = _.get(client, 'term.outputEncoding', '').toLowerCase(); switch(value) { - case 0 : return 'cp437' === client.term.outputEncoding.toLowerCase(); - case 1 : return 'utf-8' === client.term.outputEncoding.toLowerCase(); + case 0 : return 'cp437' === encoding; + case 1 : return 'utf-8' === encoding; default : return false; } }, GM : function isOneOfGroups() { - if(!_.isArray(value)) { + if(!user) { return false; } - - return _.findIndex(value, function cmp(groupName) { - return user.isGroupMember(groupName); - }) > - 1; + if(!Array.isArray(value)) { + return false; + } + return value.some(groupName => user.isGroupMember(groupName)); }, NN : function isNode() { - return client.node === value; + if(!client) { + return false; + } + if(!Array.isArray(value)) { + value = [ value ]; + } + return value.map(n => parseInt(n, 10)).includes(client.node); }, NP : function numberOfPosts() { - const postCount = parseInt(user.properties.post_count, 10); + if(!user) { + return false; + } + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; return !isNaN(value) && postCount >= value; }, NC : function numberOfCalls() { - const loginCount = parseInt(user.properties.login_count, 10); + if(!user) { + return false; + } + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount); return !isNaN(value) && loginCount >= value; }, + AA : function accountAge() { + if(!user) { + return false; + } + const accountCreated = moment(user.getProperty(UserProps.AccountCreated)); + const now = moment(); + const daysOld = accountCreated.diff(moment(), 'days'); + return !isNaN(value) && + accountCreated.isValid() && + now.isAfter(accountCreated) && + daysOld >= value; + }, + BU : function bytesUploaded() { + if(!user) { + return false; + } + const bytesUp = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; + return !isNaN(value) && bytesUp >= value; + }, + UP : function uploads() { + if(!user) { + return false; + } + const uls = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; + return !isNaN(value) && uls >= value; + }, + BD : function bytesDownloaded() { + if(!user) { + return false; + } + const bytesDown = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; + return !isNaN(value) && bytesDown >= value; + }, + DL : function downloads() { + if(!user) { + return false; + } + const dls = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; + return !isNaN(value) && dls >= value; + }, + NR : function uploadDownloadRatioGreaterThan() { + if(!user) { + return false; + } + const ulCount = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; + const dlCount = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; + const ratio = ~~((ulCount / dlCount) * 100); + return !isNaN(value) && ratio >= value; + }, + KR : function uploadDownloadByteRatioGreaterThan() { + if(!user) { + return false; + } + const ulBytes = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; + const dlBytes = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; + const ratio = ~~((ulBytes / dlBytes) * 100); + return !isNaN(value) && ratio >= value; + }, + PC : function postCallRatio() { + if(!user) { + return false; + } + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount) || 0; + const ratio = ~~((postCount / loginCount) * 100); + return !isNaN(value) && ratio >= value; + }, SC : function isSecureConnection() { - return client.session.isSecure; + 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; }, TH : function termHeight() { - return !isNaN(value) && client.term.termHeight >= value; + return !isNaN(value) && _.get(client, 'term.termHeight', 0) >= value; }, TM : function isOneOfThemes() { - if(!_.isArray(value)) { + if(!Array.isArray(value)) { return false; } - - return value.indexOf(client.currentTheme.name) > -1; + return value.includes(_.get(client, 'currentTheme.name')); }, TT : function isOneOfTermTypes() { - if(!_.isArray(value)) { + if(!Array.isArray(value)) { return false; } - - return value.indexOf(client.term.termType) > -1; + return value.includes(_.get(client, 'term.termType')); }, TW : function termWidth() { - return !isNaN(value) && client.term.termWidth >= value; + return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value; }, - ID : function isUserId(value) { - if(!_.isArray(value)) { + ID : function isUserId() { + if(!user) { + return false; + } + if(!Array.isArray(value)) { value = [ value ]; } - - value = value.map(n => parseInt(n, 10)); // ensure we have integers - return value.indexOf(user.userId) > -1; + return value.map(n => parseInt(n, 10)).includes(user.userId); }, WD : function isOneOfDayOfWeek() { - if(!_.isArray(value)) { + if(!Array.isArray(value)) { value = [ value ]; } - - value = value.map(n => parseInt(n, 10)); // ensure we have integers - return value.indexOf(new Date().getDay()) > -1; + return value.map(n => parseInt(n, 10)).includes(new Date().getDay()); }, MM : function isMinutesPastMidnight() { - // :TODO: return true if value is >= minutes past midnight sys time - return false; + const now = moment(); + const midnight = now.clone().startOf('day') + const minutesPastMidnight = now.diff(midnight, 'minutes'); + return !isNaN(value) && minutesPastMidnight >= value; + }, + AC : function achievementCount() { + if(!user) { + return false; + } + const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0; + return !isNan(value) && points >= value; + }, + AP : function achievementPoints() { + if(!user) { + return false; + } + const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0; + return !isNan(value) && points >= value; } }[acsCode](value); } catch (e) { - client.log.warn( { acsCode : acsCode, value : value }, 'Invalid ACS string!'); + const logger = _.get(client, 'log', Log); + logger.warn( { acsCode : acsCode, value : value }, 'Invalid ACS string!'); + return false; } } diff --git a/misc/10_million_password_list_top_10000.txt b/misc/bad_passwords.txt similarity index 53% rename from misc/10_million_password_list_top_10000.txt rename to misc/bad_passwords.txt index 7404c69b..864276a9 100644 --- a/misc/10_million_password_list_top_10000.txt +++ b/misc/bad_passwords.txt @@ -1,10000 +1,12645 @@ 123456 password -12345678 -qwerty 123456789 +12345678 12345 -1234 +qwerty +123123 111111 +abc123 1234567 dragon -123123 -baseball -abc123 -football -monkey -letmein -696969 -shadow -master -666666 -qwertyuiop -123321 -mustang -1234567890 -michael -654321 -pussy -superman -1qaz2wsx -7777777 -fuckyou -121212 -000000 -qazwsx -123qwe -killer -trustno1 -jordan -jennifer -zxcvbnm -asdfgh -hunter -buster -soccer -harley -batman -andrew -tigger +1q2w3e4r sunshine -iloveyou -fuckme -2000 -charlie -robert -thomas -hockey -ranger -daniel -starwars -klaster -112233 -george -asshole +654321 +master +1234 +football +1234567890 +000000 computer -michelle +666666 +superman +michael +internet +iloveyou +daniel +1qaz2wsx +monkey +shadow jessica -pepper -1111 -zxcvbn -555555 -11111111 -131313 -freedom -777777 -pass -fuck -maggie -159753 -aaaaaa -ginger +letmein +baseball +whatever princess -joshua -cheese -amanda -summer -love -ashley -6969 -nicole -chelsea -biteme -matthew -access -yankees -987654321 -dallas -austin -thunder -taylor -matrix -william -corvette -hello -martin -heather -secret -fucker -merlin +abcd1234 +123321 +starwars +121212 +thomas +zxcvbnm +trustno1 +killer +welcome +jordan +aaaaaa +123qwe +freedom +password1 +charlie +batman +jennifer +7777777 +michelle diamond -1234qwer -gfhjkm -hammer +oliver +mercedes +benjamin +11111111 +snoopy +samantha +victoria +matrix +george +alexander +secret +cookie +asdfgh +987654321 +123abc +orange +fuckyou +asdf1234 +pepper +hunter silver -222222 +joshua +banana +1q2w3e +chelsea +1234qwer +summer +qwertyuiop +phoenix +andrew +q1w2e3r4 +elephant +rainbow +mustang +merlin +london +garfield +robert +chocolate +112233 +samsung +qazwsx +matthew +buster +jonathan +ginger +flower +555555 +test +caroline +amanda +maverick +midnight +martin +junior 88888888 anthony -justin -test -bailey -q1w2e3r4t5 -patrick -internet -scooter -orange -11111 -golfer -cookie -richard -samantha -bigdog -guitar -jackson -whatever -mickey -chicken -sparky -snoopy -maverick -phoenix -camaro -sexy -peanut -morgan -welcome -falcon -cowboy -ferrari -samsung -andrea -smokey -steelers -joseph -mercedes -dakota -arsenal -eagles -melissa -boomer -booboo -spider -nascar -monster -tigers -yellow -xxxxxx -123123123 -gateway -marina -diablo -bulldog -qwer1234 -compaq -purple -hardcore -banana -junior -hannah -123654 -porsche -lakers -iceman -money -cowboys -987654 -london -tennis -999999 -ncc1701 -coffee -scooby -0000 -miller -boston -q1w2e3r4 -fuckoff -brandon -yamaha -chester -mother -forever -johnny -edward -333333 -oliver -redsox -player -nikita -knight -fender -barney -midnight -please -brandy -chicago -badboy -iwantu -slayer -rangers -charles -angel -flower -bigdaddy -rabbit -wizard -bigdick -jasper -enter -rachel -chris -steven -winner -adidas -victoria -natasha -1q2w3e4r jasmine -winter -prince -panties -marine -ghbdtn -fishing +creative +patrick +mickey +123 +qwerty123 cocacola -casper -james -232323 -raiders -888888 -marlboro -gandalf -asdfasdf -crystal -87654321 -12344321 -sexsex -golden -blowme -bigtits -8675309 -panther -lauren +chicken +passw0rd +forever +william +nicole +hello +yellow +nirvana +justin +friends +cheese +tigger +mother +liverpool +blink182 +asdfghjkl +andrea +spider +scooter +richard +soccer +rachel +purple +morgan +melissa +jackson +arsenal +222222 +qwe123 +gabriel +ferrari +jasper +danielle +bandit angela -bitch -spanky -thx1138 +scorpion +prince +maggie +austin +veronica +nicholas +monster +dexter +carlos +thunder +success +hannah +ashley +131313 +stella +brandon +pokemon +joseph +asdfasdf +999999 +metallica +december +chester +taylor +sophie +samuel +rabbit +crystal +barney +xxxxxx +steven +ranger +patricia +christian +asshole +spiderman +sandra +hockey angels -madison -winston -shannon -mike -toyota -blowjob +security +parker +heather +888888 +victor +harley +333333 +system +slipknot +november jordan23 canada -sophie -Password -apples -dick -tiger -razz -123abc -pokemon -qazxsw -55555 -qwaszx -muffin -johnson -murphy -cooper -jonathan -liverpoo -david -danielle -159357 -jackie -1990 -123456a -789456 -turtle -horny -abcd1234 -scorpion -qazwsxedc -101010 -butter -carlos -password1 -dennis -slipknot -qwerty123 -booger -asdf -1991 -black -startrek -12341234 -cameron -newyork -rainbow -nathan -john -1992 -rocket -viking -redskins -butthead -asdfghjkl -1212 -sierra -peaches -gemini -doctor -wilson -sandra -helpme +tennis qwertyui -victor -florida -dolphin -pookie -captain -tucker -blue -liverpool -theman -bandit -dolphins -maddog -packers -jaguar -lovers -nicholas -united -tiffany -maxwell -zzzzzz -nirvana -jeremy -suckit -stupid -porn -monica -elephant -giants -jackass -hotdog -rosebud -success -debbie -mountain -444444 -xxxxxxxx -warrior -1q2w3e4r5t -q1w2e3 -123456q -albert -metallic -lucky -azerty -7777 -shithead -alex -bond007 -alexis -1111111 -samson -5150 -willie -scorpio -bonnie -gators -benjamin -voodoo -driver -dexter -2112 -jason -calvin -freddy -212121 -creative -12345a -sydney -rush2112 -1989 -asdfghjk -red123 -bubba -4815162342 -passw0rd -trouble -gunner -happy -fucking -gordon -legend -jessie -stella -qwert -eminem -arthur -apple -nissan -bullshit -bear -america -1qazxsw2 -nothing -parker -4444 -rebecca -qweqwe -garfield -01012011 -beavis -69696969 -jack -asdasd -december -2222 -102030 -252525 -11223344 -magic -apollo -skippy -315475 -girls -kitten -golf -copper -braves -shelby -godzilla -beaver -fred -tomcat -august -buddy -airborne -1993 -1988 -lifehack -qqqqqq -brooklyn -animal -platinum -phantom -online -xavier -darkness -blink182 -power -fish -green -789456123 -voyager -police -travis -12qwaszx -heaven -snowball -lover -abcdef -00000 -pakistan -007007 -walter -playboy -blazer -cricket -sniper -hooters -donkey -willow -loveme -saturn -therock -redwings -bigboy -pumpkin -trinity -williams -tits -nintendo -digital -destiny -topgun -runner -marvin -guinness -chance -bubbles -testing -fire -november -minecraft -asdf1234 -lasvegas -sergey -broncos -cartman -private -celtic -birdie -little -cassie -babygirl -donald -beatles -1313 -dickhead -family -12121212 -school -louise -gabriel -eclipse -fluffy -147258369 -lol123 -explorer -beer -nelson -flyers -spencer -scott -lovely -gibson -doggie -cherry -andrey -snickers -buffalo -pantera -metallica -member -carter -qwertyu -peter -alexande -steve -bronco -paradise -goober -5555 -samuel -montana -mexico -dreams -michigan -cock -carolina -yankee -friends -magnum -surfer -poopoo -maximus -genius -cool -vampire -lacrosse +casper +gemini asd123 -aaaa -christin -kimberly -speedy -sharon -carmen -111222 -kristina -sammy -racing -ou812 -sabrina -horses -0987654321 -qwerty1 -pimpin -baby -stalker -enigma -147147 -star -poohbear -boobies -147258 -simple -bollocks -12345q -marcus -brian -1987 -qweasdzxc -drowssap -hahaha -caroline -barbara -dave -viper -drummer -action -einstein -bitches -genesis -hello1 -scotty -friend -forest -010203 -hotrod -google -vanessa -spitfire -badger -maryjane -friday -alaska -1232323q -tester -jester -jake -champion -billy -147852 -rock -hawaii -badass -chevy -420420 -walker -stephen -eagle1 -bill -1986 -october -gregory -svetlana -pamela -1984 -music -shorty -westside -stanley -diesel -courtney -242424 -kevin -porno -hitman -boobs -mark -12345qwert -reddog -frank -qwe123 +winter +hammer +cooper +america +albert +777777 +winner +charles +butterfly +swordfish popcorn -patricia -aaaaaaaa -1969 -teresa -mozart -buddha -anderson -paul -melanie -abcdefg -security -lucky1 -lizard -denise -3333 -a12345 -123789 -ruslan -stargate -simpsons -scarface -eagle -123456789a -thumper +penguin +dolphin +carolina +access +987654 +hardcore +corvette +apples +12341234 +sabrina +remember +qwer1234 +edward +dennis +cherry +sparky +natasha +arthur +vanessa +marina +leonardo +johnny +dallas +antonio +winston +snickers olivia -naruto -1234554321 -general -cherokee -a123456 +nothing +iceman +destiny +coffee +apollo +696969 +windows +williams +school +madison +dakota +angelina +anderson +159753 +1111 +yamaha +trinity +rebecca +nathan +guitar +compaq +123123123 +toyota +shannon +playboy +peanut +pakistan +diablo +abcdef +maxwell +golden +asdasd +123654 +murphy +monica +marlboro +kimberly +gateway +bailey +00000000 +snowball +scooby +nikita +falcon +august +test123 +sebastian +panther +love +johnson +godzilla +genesis +brandy +adidas +zxcvbn +wizard +porsche +online +hello123 +fuckoff +eagles +champion +bubbles +boston +smokey +precious +mercury +lauren +einstein +cricket +cameron +angel +admin +napoleon +mountain +lovely +friend +flowers +dolphins +david +chicago +sierra +knight +yankees +wilson +warrior +simple +nelson +muffin +charlotte +calvin +spencer +newyork +florida +fernando +claudia +basketball +barcelona +87654321 +willow +stupid +samson +police +paradise +motorola +manager +jaguar +jackie +family +doctor +bullshit +brooklyn +tigers +stephanie +slayer +peaches +miller +heaven +elizabeth +bulldog +animal +789456 +scorpio +rosebud +qwerty12 +franklin +claire +american vincent -Usuckballz1 -spooky -qweasd -cumshot -free -frankie -douglas -death -1980 +testing +pumpkin +platinum +louise +kitten +general +united +turtle +marine +icecream +hacker +darkness +cristina +colorado +boomer +alexandra +steelers +serenity +please +montana +mitchell +marcus +lollipop +jessie +happy +cowboy +102030 +marshall +jupiter +jeremy +gibson +fucker +barbara +adrian +1qazxsw2 +12344321 +11111 +startrek +fishing +digital +christine +business +abcdefg +nintendo +genius +12qwaszx +walker +q1w2e3 +player +legend +carmen +booboo +tomcat +ronaldo +people +pamela +marvin +jackass +google +fender +asdfghjk +Password +1q2w3e4r5t +zaq12wsx +scotland +phantom +hercules +fluffy +explorer +alexis +walter +trouble +tester +qwerty1 +melanie +manchester +gordon +firebird +engineer +azerty +147258 +virginia +tiger +simpsons +passion +lakers +james +angelica +55555 +vampire +tiffany +september +private +maximus +loveme +isabelle +isabella +eclipse +dreamer +changeme +cassie +badboy +123456a +stanley +sniper +rocket +passport +pandora +justice +infinity +cookies +barbie +xavier +unicorn +superstar +stephen +rangers +orlando +money +domino +courtney +viking +tucker +travis +scarface +pavilion +nicolas +natalie +gandalf +freddy +donald +captain +abcdefgh +a1b2c3d4 +speedy +peter +nissan loveyou +harrison +friday +francis +dancer +159357 +101010 +spitfire +saturn +nemesis +little +dreams +catherine +brother +birthday +1111111 +wolverine +victory +student +france +fantasy +enigma +copper +bonnie +teresa +mexico +guinness +georgia +california +sweety +logitech +julian +hotdog +emmanuel +butter +beatles +11223344 +tristan +sydney +spirit +october +mozart +lolita +ireland +goldfish +eminem +douglas +cowboys +control +cheyenne +alex +testtest +stargate +raiders +microsoft +diesel +debbie +danger +chance +asdf +anything +aaaaaaaa +welcome1 +qwert +hahaha +forest +eternity +disney +denise +carter +alaska +zzzzzz +titanic +shorty +shelby +pookie +pantera +england +chris +zachary +westside +tamara +password123 +pass +maryjane +lincoln +willie +teacher +pierre +michael1 +leslie +lawrence +kristina +kawasaki +drowssap +college +blahblah +babygirl +avatar +alicia +regina +qqqqqq +poohbear +miranda +madonna +florence +sapphire +norman +hamilton +greenday +galaxy +frankie +black +awesome +suzuki +spring +qazwsxedc +magnum +lovers +liberty +gregory +232323 +twilight +timothy +swimming +super +stardust +sophia +sharon +robbie +predator +penelope +michigan +margaret +jesus +hawaii +green +brittany +brenda +badger +a1b2c3 +444444 +winnie +wesley +voodoo +skippy +shithead +redskins +qwertyu +pussycat +houston +horses +gunner +fireball +donkey +cherokee +australia +arizona +1234abcd +skyline +power +perfect +lovelove +kermit +kenneth +katrina +eugene +christ +thailand +support +special +runner +lasvegas +jason +fuckme +butthead +blizzard +athena +abigail +8675309 +violet +tweety +spanky +shamrock +red123 +rascal +melody +joanna +hello1 +driver +bluebird +biteme +atlantis +arnold +apple +alison +taurus +random +pirate +monitor +maria +lizard +kevin +hummer +holland +buffalo +147258369 +007007 +valentine +roberto +potter +magnolia +juventus +indigo +indian +harvey +duncan +diamonds +daniela +christopher +bradley +bananas +warcraft +sunset +simone +renegade +redsox +philip +monday +mohammed +indiana +energy +bond007 +avalon +terminator +skipper +shopping +scotty +savannah +raymond +morris +mnbvcxz +michele +lucky +lucifer +kingdom +karina +giovanni +cynthia +a123456 +147852 +12121212 +wildcats +ronald +portugal +mike +helpme +froggy +dragons +cancer +bullet +beautiful +alabama +212121 +unknown +sunflower +sports +siemens +santiago +kathleen +hotmail +hamster +golfer +future +father +enterprise +clifford +christina +camille +camaro +beauty +55555555 +vision +tornado +something +rosemary +qweasd +patches +magic +helena +denver +cracker +beaver +basket +atlanta +vacation +smiles +ricardo +pascal +newton +jeffrey +jasmin +january +honey +hollywood +holiday +gloria +element +chandler +booger +angelo +allison +action +99999999 +target +snowman +miguel +marley +lorraine +howard +harmony +children +celtic +beatrice +airborne +wicked +voyager +valentin +thx1138 +thumper +samurai +moonlight +mmmmmm +karate +kamikaze +jamaica +emerald +bubble +brooke +zombie +strawberry +spooky +software +simpson +service +sarah +racing +qazxsw +philips +oscar +minnie +lalala +ironman +goddess +extreme +empire +elaine +drummer +classic +carrie +berlin +asdfg +22222222 +valerie +tintin +therock +sunday +skywalker +salvador +pegasus +panthers +packers +network +mission +mark +legolas +lacrosse kitty kelly -veronica -suzuki -semperfi -penguin -mercury -liberty -spirit -scotland -natalie -marley -vikings -system -sucker -king -allison -marshall -1979 -098765 -qwerty12 -hummer -adrian -1985 -vfhbyf -sandman -rocky -leslie -antonio -98765432 -4321 -softball -passion -mnbvcxz -bastard -passport -horney -rascal -howard -franklin -bigred -assman -alexander -homer -redrum -jupiter -claudia -55555555 -141414 -zaq12wsx -shit -patches -nigger -cunt -raider -infinity -andre -54321 -galore -college -russia -kawasaki -bishop -77777777 -vladimir -money1 -freeuser -wildcats -francis -disney -budlight -brittany -1994 -00000000 -sweet -oksana -honda -domino -bulldogs -brutus -swordfis -norman -monday -jimmy -ironman -ford -fantasy -9999 -7654321 -PASSWORD -hentai -duncan -cougar -1977 -jeffrey -house -dancer -brooke -timothy -super -marines -justice -digger -connor -patriots -karina -202020 -molly -everton -tinker -alicia -rasdzv3 -poop -pearljam -stinky -naughty -colorado -123123a -water -test123 -ncc1701d -motorola -ireland -asdfg -slut -matt -houston -boogie -zombie -accord -vision -bradley -reggie -kermit -froggy -ducati -avalon -6666 -9379992 -sarah -saints -logitech -chopper -852456 -simpson -madonna -juventus -claire -159951 -zachary -yfnfif -wolverin -warcraft -hello123 -extreme -penis -peekaboo -fireman -eugene -brenda -123654789 -russell -panthers -georgia -smith -skyline -jesus -elizabet -spiderma -smooth -pirate -empire -bullet -8888 -virginia -valentin -psycho -predator -arizona -134679 -mitchell -alyssa -vegeta -titanic -christ -goblue -fylhtq -wolf -mmmmmm -kirill -indian -hiphop -baxter -awesome -people -danger -roland -mookie -741852963 -1111111111 -dreamer -bambam -arnold -1981 -skipper -serega -rolltide -elvis -changeme -simon -1q2w3e -lovelove -fktrcfylh -denver -tommy -mine -loverboy -hobbes -happy1 -alison -nemesis -chevelle -cardinal -burton -wanker -picard -151515 -tweety -michael1 -147852369 -12312 -xxxx -windows -turkey -456789 -1974 -vfrcbv -sublime -1975 -galina -bobby -newport -manutd -daddy -american -alexandr -1966 -victory -rooster -qqq111 -madmax -electric -bigcock -a1b2c3 -wolfpack -spring -phpbb -lalala -suckme -spiderman -eric -darkside -classic -raptor -123456789q -hendrix -1982 -wombat -avatar -alpha -zxc123 -crazy -hard -england -brazil -1978 -01011980 -wildcat -polina -freepass -carrie -99999999 -qaz123 -holiday -fyfcnfcbz -brother -taurus -shaggy -raymond -maksim -gundam -admin -vagina -pretty -pickle -good -chronic -alabama -airplane -22222222 -1976 -1029384756 -01011 -time -sports -ronaldo -pandora -cheyenne -caesar -billybob -bigman -1968 -124578 -snowman -lawrence -kenneth -horse -france -bondage -perfect -kristen -devils -alpha1 -pussycat -kodiak -flowers -1973 -01012000 -leather -amber -gracie -chocolat -bubba1 -catch22 -business -2323 -1983 -cjkysirj -1972 -123qweasd -ytrewq -wolves -stingray -ssssss -serenity -ronald -greenday -135790 -010101 -tiger1 -sunset -charlie1 -berlin -bbbbbb -171717 -panzer -lincoln -katana -firebird -blizzard -a1b2c3d4 -white -sterling -redhead -password123 -candy -anna -142536 -sasha -pyramid -outlaw -hercules -garcia -454545 -trevor -teens -maria -kramer -girl -popeye -pontiac -hardon -dude -aaaaa -323232 -tarheels -honey -cobra -buddy1 -remember -lickme -detroit -clinton -basketball -zeppelin -whynot -swimming -strike -service -pavilion -michele -engineer -dodgers -britney -bobafett -adam -741852 -21122112 -xxxxx -robbie -miranda -456123 -future -darkstar -icecream -connie -1970 -jones -hellfire -fisher -fireball -apache -fuckit -blonde -bigmac -abcd -morris -angel1 -666999 -321321 -simone -rockstar -flash -defender -1967 -wallace -trooper -oscar -norton -casino -cancer -beauty -weasel -savage -raven -harvey -bowling -246810 -wutang -theone -swordfish -stewart -airforce -abcdefgh -nipples -nastya -jenny -hacker -753951 -amateur -viktor -srinivas -maxima -lennon -freddie -bluebird -qazqaz -presario -pimp -packard -mouse -looking -lesbian -jeff -cheryl -2001 -wrangler -sandy -machine -lights -eatme -control -tattoo -precious -harrison -duke -beach -tornado -tanner -goldfish -catfish -openup -manager -1971 -street -Soso123aljg -roscoe -paris -natali -light -julian -jerry -dilbert -dbrnjhbz -chris1 -atlanta -xfiles -thailand -sailor -pussies -pervert -lucifer -longhorn -enjoy -dragons -young -target -elaine -dustin -123qweasdzxc -student -madman -lisa -integra -wordpass -prelude -newton -lolita -ladies -hawkeye -corona -bubble -31415926 -trigger -spike -katie -iloveu -herman -design -cannon -999999999 -video -stealth -shooter -nfnmzyf -hottie -browns -314159 -trucks -malibu -bruins -bobcat -barbie -1964 -orlando -letmein1 -freaky -foobar -cthutq -baller -unicorn -scully -pussy1 -potter -cookies -pppppp -philip -gogogo -elena -country -assassin -1010 -zaqwsx -testtest -peewee -moose -microsoft -teacher -sweety -stefan -stacey -shotgun -random -laura -hooker -dfvgbh -devildog -chipper -athena -winnie -valentina -pegasus -kristin -fetish -butterfly -woody -swinger -seattle -lonewolf -joker -booty -babydoll -atlantis -tony -powers -polaris -montreal -angelina -77777 -tickle -regina -pepsi -gizmo -express -dollar -squirt -shamrock -knicks -hotstuff -balls -transam -stinger -smiley -ryan -redneck -mistress -hjvfirf -cessna -bunny -toshiba -single -piglet -fucked -father -deftones -coyote -castle -cadillac -blaster -valerie -samurai -oicu812 -lindsay -jasmin -james1 -ficken -blahblah -birthday -1234abcd -01011990 -sunday -manson -flipper -asdfghj -181818 -wicked -great -daisy -babes -skeeter -reaper -maddie -cavalier -veronika -trucker -qazwsx123 -mustang1 -goldberg -escort -12345678910 -wolfgang -rocks -mylove -mememe -lancer -ibanez -travel -sugar -snake -sister -siemens -savannah -minnie -leonardo -basketba -1963 -trumpet -texas -rocky1 -galaxy -cristina -aardvark -shelly -hotsex -goldie -fatboy -benson -321654 -141627 -sweetpea -ronnie -indigo -13131313 -spartan -roberto -hesoyam -freeman -freedom1 -fredfred -pizza -manchester -lestat -kathleen -hamilton -erotic -blabla -22222 -1995 -skater -pencil -passwor -larisa -hornet -hamlet -gambit -fuckyou2 -alfred -456456 -sweetie -marino -lollol -565656 -techno -special -renegade -insane -indiana -farmer -drpepper -blondie -bigboobs -272727 -1a2b3c -valera -storm -seven -rose -nick -mister -karate -casey -1qaz2wsx3edc -1478963 -maiden -julie -curtis -colors -christia -buckeyes -13579 -0123456789 -toronto -stephani -pioneer -kissme -jungle -jerome -holland -harry -garden -enterpri -dragon1 -diamonds -chrissy -bigone -343434 -wonder -wetpussy -subaru -smitty -racecar -pascal -morpheus -joanne -irina -indians -impala -hamster -charger -change -bigfoot -babylon -66666666 -timber -redman -pornstar -bernie -tomtom -thuglife -millie -buckeye -aaron -virgin -tristan -stormy -rusty -pierre -napoleon -monkey1 -highland -chiefs -chandler -catdog -aurora -1965 -trfnthbyf -sampson -nipple -dudley -cream -consumer -burger -brandi -welcome1 -triumph -joejoe -hunting -dirty -caserta -brown -aragorn -363636 -mariah -element -chichi -2121 -123qwe123 -wrinkle1 -smoke -omega -monika -leonard -justme -hobbit -gloria -doggy -chicks -bass -audrey -951753 -51505150 -11235813 -sakura -philips -griffin -butterfl -artist -66666 -island -goforit -emerald -elizabeth -anakin -watson -poison -none +jester italia -callie -bobbob -autumn -andreas -123 -sherlock -q12345 -pitbull -marathon -kelsey -inside -german -blackie -access14 -123asd -zipper -overlord -nadine -marie -basket -trombone -stones -sammie -nugget -naked -kaiser -isabelle -huskers -bomber -barcelona -babylon5 -babe -alpine -weed -ultimate -pebbles -nicolas -marion -loser -linda -eddie -wesley -warlock -tyler -goddess -fatcat -energy -david1 -bassman -yankees1 -whore -trojan -trixie -superfly -kkkkkk -ybrbnf -warren -sophia -sidney -pussys -nicola -campbell -vfvjxrf -singer -shirley -qawsed -paladin -martha -karen -help -harold -geronimo -forget -concrete -191919 -westham -soldier -q1w2e3r4t5y6 -poiuyt -nikki -mario -juice -jessica1 -global -dodger -123454321 -webster -titans -tintin -tarzan -sexual -sammy1 -portugal -onelove -marcel -manuel -madness -jjjjjj -holly -christy -424242 -yvonne -sundance -sex4me -pleasure -logan -danny -wwwwww -truck -spartak -smile -michel -history -Exigen -65432 -1234321 -sherry -sherman -seminole -rommel -network -ladybug -isabella -holden -harris -germany -fktrctq -cotton -angelo -14789632 -sergio -qazxswedc -moon -jesus1 -trunks -snakes -sluts -kingkong -bluesky -archie -adgjmptw -911911 -112358 -sunny -suck -snatch -planet -panama -ncc1701e -mongoose -head -hansolo -desire -alejandr -1123581321 -whiskey -waters -teen -party -martina -margaret -january -connect +hiphop +freeman +charlie1 +cardinal bluemoon -bianca -andrei -5555555 -smiles -nolimit -long -assass -abigail -555666 -yomama -rocker -plastic -katrina -ghbdtnbr -ferret -emily -bonehead -blessed -beagle -asasas -abgrtyu -sticky -olga -japan -jamaica -home -hector -dddddd -1961 -turbo -stallion -personal -peace -movie -morrison -joanna -geheim -finger -cactus -7895123 -susan -super123 -spyder -mission -anything -aleksandr -zxcvb -shalom -rhbcnbyf -pickles -passat -natalia -moomoo -jumper -inferno -dietcoke -cumming -cooldude -chuck -christop -million -lollipop -fernando -christian -blue22 -bernard -apple1 -unreal -spunky -ripper -open -niners -letmein2 -flatron -faster -deedee -bertha -april -4128 -01012010 -werewolf -rubber -punkrock -orion -mulder -missy -larry -giovanni -gggggg -cdtnkfyf -yoyoyo -tottenha -shaved -newman -lindsey -joey -hongkong -freak -daniela -camera -brianna -blackcat -a1234567 -1q1q1q -zzzzzzzz -stars -pentium -patton -jamie -hollywoo -florence -biscuit -beetle -andy -always -speed -sailing -phillip -legion -gn56gn56 -909090 -martini -dream -darren -clifford -2002 -stocking -solomon -silvia -pirates -office -monitor -monique -milton -matthew1 -maniac -loulou -jackoff -immortal -fossil -dodge -delta -44444444 -121314 -sylvia -sprite -shadow1 -salmon -diana -shasta -patriot -palmer -oxford -nylons -molly1 -irish -holmes -curious -asdzxc -1999 -makaveli -kiki -kennedy -groovy -foster -drizzt -twister -snapper -sebastia -philly -pacific -jersey -ilovesex -dominic -charlott -carrot -anthony1 -africa -111222333 -sharks -serena -satan666 -maxmax -maurice -jacob -gerald -cosmos -columbia -colleen -cjkywt -cantona -brooks -99999 -787878 -rodney -nasty -keeper -infantry -frog -french -eternity -dillon -coolio -condor -anton -waterloo -velvet -vanhalen -teddy -skywalke -sheila -sesame -seinfeld -funtime -012345 -standard -squirrel -qazwsxed -ninja -kingdom -grendel -ghost -fuckfuck -damien -crimson -boeing -bird -biggie -090909 -zaq123 -wolverine -wolfman -trains -sweets -sunrise -maxine -legolas -jericho -isabel -foxtrot -anal -shogun -search -robinson -rfrfirf -ravens -privet -penny -musicman -memphis -megadeth -dogs -butt -brownie -oldman -graham -grace -505050 -verbatim -support -safety -review -newlife -muscle -herbert -colt45 -bottom -2525 -1q2w3e4r5t6y -1960 -159159 -western -twilight -thanks -suzanne -potato -pikachu -murray -master1 -marlin -gilbert -getsome -fuckyou1 -dima -denis -789789 -456852 -stone -stardust -seven7 -peanuts -obiwan -mollie -licker -kansas -frosty -ball -262626 -tarheel -showtime -roman -markus -maestro -lobster -darwin -cindy -chubby -2468 -147896325 -tanker -surfing +bbbbbb +bastard +alyssa +0123456789 +zeppelin +tinker +surfer +smile +rockstar +operator +naruto +freddie +dragonfly +dickhead +connor +anaconda +amsterdam +alfred +a12345 +789456123 +77777777 +trooper skittles -showme -shaney14 -qwerty12345 -magic1 -goblin -fusion -blades -banshee -alberto -123321123 -123098 -powder -malcolm -intrepid -garrett -delete -chaos -bruno -1701 -tequila -short -sandiego -python -punisher -newpass -iverson -clayton -amadeus -1234567a -stimpy -sooners -preston -poopie -photos -neptune -mirage -harmony -gold -fighter -dingdong -cats -whitney -sucks -slick -rick -ricardo -princes -liquid -helena -daytona -clover -blues -anubis -1996 -192837465 -starcraft -roxanne -pepsi1 -mushroom -eatshit -dagger -cracker -capital -brendan -blackdog -25802580 -strider -slapshot -porter -pink -jason1 -hershey -gothic -flight -ekaterina -cody -buffy -boss -bananas -aaaaaaa -123698745 -1234512345 -tracey -miami -kolobok -danni -chargers -cccccc -blue123 -bigguy -33333333 -0.0.000 -warriors -walnut -raistlin -ping -miguel -latino -griffey -green1 -gangster -felix -engine -doodle -coltrane -byteme -buck -asdf123 -123456z -0007 -vertigo -tacobell -shark -portland -penelope -osiris -nymets -nookie -mary -lucky7 -lucas -lester -ledzep -gorilla -coco -bugger -bruce -blood -bentley -battle -1a2b3c4d -19841984 -12369874 -weezer -turner -thegame -stranger -sally -Mailcreated5240 -knights -halflife -ffffff -dorothy -dookie -damian -258456 -women -trance -qwerasdf -playtime -paradox -monroe -kangaroo -henry -dumbass -dublin -charly -butler -brasil -blade -blackman -bender -baggins -wisdom -tazman -swallow -stuart -scruffy -phoebe -panasonic -Michael -masters -ghjcnj -firefly -derrick -christine -beautiful -auburn -archer -aliens -161616 -1122 -woody1 -wheels -test1 -spanking -robin -redred -racerx -postal -parrot -nimrod -meridian -madrid -lonestar -kittycat -hell -goodluck -gangsta -formula -devil -cassidy -camille -buttons -bonjour -bingo -barcelon -allen -98765 -898989 -303030 -2020 -0000000 -tttttt -tamara -scoobydo -samsam -rjntyjr -richie -qwertz -megaman -luther -jazz -crusader -bollox -123qaz -12312312 -102938 -window -sprint -sinner -sadie -rulez -quality -pooper -pass123 -oakland -misty -lvbnhbq -lady -hannibal -guardian -grizzly -fuckface -finish -discover -collins -catalina -carson -black1 -bang -annie -123987 -1122334455 -wookie -volume -tina -rockon -qwer -molson -marco -californ -angelica -2424 -world -william1 -stonecol -shemale -shazam -picasso +shalom +raptor +pioneer +personal +ncc1701 +nascar +music +kristen +kingkong +global +geronimo +germany +country +christmas +bernard +benson +wrestling +warren +techno +sunrise +stefan +sister +savage +russell +robinson oracle -moscow -luke -lorenzo -kitkat -johnjohn -janice -gerard -flames -duck -dark -celica -445566 -234567 -yourmom -topper -stevie -septembe -scarlett -santiago -milano -lowrider -loving -incubus -dogdog -anastasia -1962 -123zxc -vacation -tempest -sithlord -scarlet -rebels -ragnarok -prodigy -mobile +millie +maddog +lightning +kingston +kennedy +hannibal +garcia +download +dollar +darkstar +brutus +bobby +autumn +webster +vanilla +undertaker +tinkerbell +sweetpea +ssssss +softball +rafael +panasonic +pa55word keyboard -golfing -english -carlo -anime -545454 -19921992 -11112222 -vfhecz -sobaka -shiloh -penguins -nuttertools -mystery -lorraine -llllll -lawyer -kiss -jeep -gizmodo -elwood -dkflbvbh -987456 -6751520 -12121 -titleist -tardis -tacoma -smoker -shaman -rootbeer -magnolia -julia -juan -hoover -gotcha -dodgeram -creampie -buffett -bridge -aspirine -456654 -socrates -photo -parola -nopass -megan -lucy -kenwood -kenny -imagine -forgot -cynthia -blondes -ashton -aezakmi -1234567q -viper1 -terry -sabine -redalert -qqqqqqqq -munchkin -monkeys -mersedes -melvin -mallard -lizzie -imperial -honda1 -gremlin -gillian -elliott -defiant -dadada -cooler -bond -blueeyes -birdman -bigballs -analsex -753159 -zaq1xsw2 -xanadu -weather -violet -sergei -sebastian -romeo -research -putter -oooooo +isabel +hector +fisher +dominic +darkside +cleopatra +blue +assassin +amelia +vladimir +roland +nigger national -lexmark -hotboy -greg -garbage -colombia -chucky -carpet -bobo -bobbie -assfuck -88888 -01012001 -smokin -shaolin -roger -rammstein -pussy69 -katerina -hearts -frogger -freckles -dogg -dixie -claude -caliente -amazon -abcde -1221 -wright -willis -spidey -sleepy -sirius -santos -rrrrrr -randy -picture -payton -mason -dusty -director -celeste -broken -trebor -sheena -qazwsxedcrfv -polo +monique +molly +matthew1 +godfather +frank +curtis +change +central +cartman +brothers +boogie +archie +warriors +universe +turkey +topgun +solomon +sherry +sakura +rush2112 +qwaszx +office +mushroom +monika +marion +lorenzo +john +herman +connect +chopper +burton +blondie +bitch +bigdaddy +amber +456789 +1a2b3c4d +ultimate +tequila +tanner +sweetie +scott +rocky +popeye +peterpan +packard +loverboy +leonard +jimmy +harry +griffin +design +buddha +1 +wallace +truelove +trombone +toronto +tarzan +shirley +sammy +pebbles +natalia +marcel +malcolm +madeline +jerome +gilbert +gangster +dingdong +catalina +buddy +blazer +billy +bianca +alejandro +54321 +252525 +111222 +0000 +water +sucker +rooster +potato +norton +lucky1 +loving +lol123 +ladybug +kittycat +fuck +forget +flipper +fireman +digger +bonjour +baxter +audrey +aquarius +1111111111 +pppppp +planet +pencil +patriots +oxford +million +martha +lindsay +laura +jamesbond +ihateyou +goober +giants +garden +diana +cecilia +brazil +blessing +bishop +bigdog +airplane +Password1 +tomtom +stingray +psycho +pickle +outlaw +number1 +mylove +maurice +madman +maddie +lester +hendrix +hellfire +happy1 +guardian +flamingo +enter +chichi +0987654321 +western +twister +trumpet +trixie +socrates +singer +sergio +sandman +richmond +piglet +pass123 +osiris +monkey1 +martina +justine +english +electric +church +castle +caesar +birdie +aurora +artist +amadeus +alberto +246810 +whitney +thankyou +sterling +star +ronnie +pussy +printer +picasso +munchkin +morpheus +madmax +kaiser +julius +imperial +happiness +goodluck +counter +columbia +campbell +blessed +blackjack +alpha +999999999 +142536 +wombat +wildcat +trevor +telephone +smiley +saints +pretty oblivion -mustangs +newcastle +mariana +janice +israel +imagine +freedom1 +detroit +deedee +darren +catfish +adriana +washington +warlock +valentina +valencia +thebest +spectrum +skater +sheila +shaggy +poiuyt +member +jessica1 +jeremiah +jack +insane +iloveu +handsome +goldberg +gabriela +elijah +damien +daisy +buttons +blabla +bigboy +apache +anthony1 +a1234567 +xxxxxxxx +toshiba +tommy +sailor +peekaboo +motherfucker +montreal +manuel +madrid +kramer +katherine +kangaroo +jenny +immortal +harris +hamlet +gracie +fucking +firefly +chocolat +bentley +account +321321 +2222 +1a2b3c +thompson +theman +strike +stacey +science +running +research +polaris +oklahoma +mariposa +marie +leader +julia +island +idontknow +hitman +german +felipe +fatcat +fatboy +defender +applepie +annette +010203 +watson +travel +sublime +stewart +steve +squirrel +simon +sexy +pineapple +phoebe +paris +panzer +nadine +master1 +mario +kelsey +joker +hongkong +gorilla +dinosaur +connie +bowling +bambam +babydoll +aragorn +andreas +456123 +151515 +wolves +wolfgang +turner +semperfi +reaper +patience +marilyn +fletcher +drpepper +dorothy +creation +brian +bluesky +andre +yankee +wordpass +sweet +spunky +sidney +serena +preston +pauline +passwort +original +nightmare +miriam +martinez +labrador +kristin +kissme +henry +gerald +garrett +flash +excalibur +discovery +dddddd +danny +collins +casino +broncos +brendan +brasil +apple123 +yvonne +wonder +window +tomato +sundance +sasha +reggie +redwings +poison +mypassword +monopoly +mariah margarita -letsgo -josh -jimbob -jimbo +lionking +king +football1 +director +darling +bubba +biscuit +44444444 +wisdom +vivian +virgin +sylvester +street +stones +sprite +spike +single +sherlock +sandy +rocker +robin +matt +marianne +linda +lancelot +jeanette +hobbes +fred +ferret +dodger +cotton +corona +clayton +celine +cannabis +bella +andromeda +7654321 +4444 +werewolf +starcraft +sampson +redrum +pyramid +prodigy +paul +michel +martini +marathon +longhorn +leopard +judith +joanne +jesus1 +inferno +holly +harold +happy123 +esther +dudley +dragon1 +darwin +clinton +celeste +catdog +brucelee +argentina +alpine +147852369 +wrangler +william1 +vikings +trigger +stranger +silvia +shotgun +scarlett +scarlet +redhead +raider +qweasdzxc +playstation +mystery +morrison +honda +february +fantasia +designer +coyote +cool +bulldogs +bernie +baby +asdfghj +angel1 +always +adam +202020 +wanker +sullivan +stealth +skeeter +saturday +rodney +prelude +pingpong +phillip +peewee +peanuts +peace +nugget +newport +myself +mouse +memphis +lover +lancer +kristine +james1 +hobbit +halloween +fuckyou1 +finger +fearless +dodgers +delete +cougar +charmed +cassandra +caitlin +bismillah +believe +alice +airforce +7777 +viper +tony +theodore +sylvia +suzanne +starfish +sparkle +server +samsam +qweqwe +public +pass1234 +neptune +marian +krishna +kkkkkk +jungle +cinnamon +bitches +741852 +trojan +theresa +sweetheart +speaker +salmon +powers +pizza +overlord +michaela +meredith +masters +lindsey +history +farmer +express +escape +cuddles +carson +candy +buttercup +brownie +broken +abc12345 +aardvark +Passw0rd +141414 +124578 +123789 +12345678910 +00000 +universal +trinidad +tobias +thursday +surfing +stuart +stinky +standard +roller +porter +pearljam +mobile +mirage +markus +loulou +jjjjjj +herbert +grace +goldie +frosty +fighter +fatima +evelyn +eagle +desire +crimson +coconut +cheryl +beavis +anonymous +andres +africa +134679 +whiskey +velvet +stormy +springer +soldier +ragnarok +portland +oranges +nobody +nathalie +malibu +looking +lemonade +lavender +hitler +hearts +gotohell +gladiator +gggggg +freckles +fashion +david1 +crusader +cosmos +commando +clover +clarence +center +cadillac +brooks +bronco +bonita +babylon +archer +alexandre +123654789 +verbatim +umbrella +thanks +sunny +stalker +splinter +sparrow +selena +russia +roberts +register +qwert123 +penguins +panda +ncc1701d +miracle +melvin +lonely +lexmark +kitkat +julie +graham +frances +estrella +downtown +doodle +deborah +cooler +colombia +chemistry +cactus +bridge +bollocks +beetle +anastasia +741852963 +69696969 +unique +sweets +station +showtime +sheena +santos +rock +revolution +reading +qwerasdf +password2 +mongoose +marlene +maiden +machine +juliet +illusion +hayden +fabian +derrick +crazy +cooldude +chipper +bomber +blonde +bigred +amazing +aliens +abracadabra +123qweasd +wwwwww +treasure +timber +smith +shelly +sesame +pirates +pinkfloyd +passwords +nature +marlin +marines +linkinpark +larissa +laptop +hotrod +gambit +elvis +education +dustin +devils +damian +christy +braves +baller +anarchy +white +valeria +underground +strong +poopoo +monalisa +memory +lizzie +keeper +justdoit +house +homer +gerard +ericsson +emily +divine +colleen +chelsea1 +cccccc +camera +bonbon +billie +bigfoot +badass +asterix +anna +animals +andy +achilles +a1s2d3f4 +violin +veronika +vegeta +tyler +test1234 +teddybear +tatiana +sporting +spartan +shelley +sharks +respect +raven +pentium +papillon +nevermind +marketing +manson +madness +juliette +jericho +gabrielle +fuckyou2 +forgot +firewall +faith +evolution +eric +eduardo +dagger +cristian +cavalier +canadian +bruno +blowjob +blackie +beagle +admin123 +010101 +together +spongebob +snakes +sherman +reddog +reality +ramona +puppies +pedro +pacific +pa55w0rd +omega +noodle +murray +mollie +mister +halflife +franco +foster +formula1 +felix +dragonball +desiree +default +chris1 +bunny +bobcat +asdf123 +951753 +5555 +242424 +thirteen +tattoo +stonecold +stinger +shiloh +seattle +santana +roger +roberta +rastaman +pickles +orion +mustang1 +felicia +dracula +doggie +cucumber +cassidy +britney +brianna +blaster +belinda +apple1 +753951 +teddy +striker +stevie +soleil +snake +skateboard +sheridan +sexsex +roxanne +redman +qqqqqqqq +punisher +panama +paladin +none +lovelife +lights +jerry +iverson +inside +hornet +holden +groovy +gretchen +grandma +gangsta +faster +eddie +chevelle +chester1 +carrot +cannon +button +administrator +a +1212 +zxc123 +wireless +volleyball +vietnam +twinkle +terror +sandiego +rose +pokemon1 +picture +parrot +movies +moose +mirror +milton +mayday +maestro +lollypop +katana +johanna +hunting +hudson +grizzly +gorgeous +garbage +fish +ernest +dolores +conrad +chickens +charity +casey +blueberry +blackman +blackbird +bill +beckham +battle +atlantic +wildfire +weasel +waterloo +trance +storm +singapore +shooter +rocknroll +richie +poop +pitbull +mississippi +kisses +karen +juliana +james123 +iguana +homework +highland +fire +elliot +eldorado +ducati +discover +computer1 +buddy1 +antonia +alphabet +159951 +123456789a +1123581321 +0123456 +zaq1xsw2 +webmaster +vagina +unreal +university +tropical +swimmer +sugar +southpark +silence +sammie +ravens +question +presario +poiuytrewq +palmer +notebook +newman +nebraska +manutd +lucas +hermes +gators +dave +dalton +cheetah +cedric +camilla +bullseye +bridget +bingo +ashton +123asd +yahoo +volume +valhalla +tomorrow +starlight +scruffy +roscoe +richard1 +positive +plymouth +pepsi +patrick1 +paradox +milano +maxima +loser +lestat +gizmo +ghetto +faithful +emerson +elliott +dominique +doberman +dillon +criminal +crackers +converse +chrissy +casanova +blowme +attitude +66666666 +181818 +12345a +098765 +zipper +xfiles +wonderful +weather +utopia +tsunami +stars +shogun +shit +seven +scooter1 +scoobydoo +rochelle +qazqaz +qaz123 +punkrock +onelove +nokia +nicola +moomoo +monkeys +messenger +marco +lobster +kentucky +john316 +jake +insomnia +hooligan +hawkeye +gertrude +freaky +eleanor +capricorn +blueeyes +blackberry +blablabla +balance +anita +allen +aaron +6969 +tiger1 +texas +terminal +snowflake +sirius +sanders +safety +revenge +raphael +poseidon +paranoid +noodles +money1 +minerva +mastermind +light +library +laurence +jersey +istanbul +guest +ghost +games +frederic +forrest +ffffff +doomsday +dancing +courage +chronic +chanel +bradford +bonehead +blacky +apollo13 +answer +alessandro +accord +aaaaaaa +westwood +warning +supernova +strider +satan666 +reynolds +qazwsx123 +q1w2e3r4t5 +penis +number +mookie +monroe +megaman +mckenzie +magician +larry +kipper +jellybean +jayjay +jamie +innocent +hotstuff +hooters +hershey +gremlin +fusion +fountain +foobar +flyers +flames +firefox +death +deadman +daddy +cupcake +concrete +charly +charger +chaos +chacha +cartoon +capslock +boobies +bloody +aussie +april +abcd +tracey +susan +sultan +snuggles +rommel +promise +professor +pontiac +nellie +misty +mermaid +megadeth +medicine +lisa +lionheart +lennon +laurie +kelvin +jackson1 +intrepid +horizon +highlander +hassan +green123 +goodman +geoffrey +francisco +fossil +exodus +dynamite +delta +columbus +cobra +cinderella +chemical +chargers +burger +blues +blossom +bigmac +banshee +amazon +aaaa +13579 +young +vertigo +username +tootsie +theone +tabitha +superman1 +subaru +stone +sherwood +shark +secure +sailing +pisces +picard +nick +natural +moonbeam +meowmeow +maxine +matthias +matilda +llllll +kickass +kenny +kansas +josephine +jeff +jacob +jackson5 +incubus +honolulu +free +eileen +edwards +dream +diamond1 +desmond +crawford +claude +carina +brown +broadway +benny +bear +backspace +assman +asdfjkl +asdasdasd +alpha1 +555666 +zzzzzzzz +woody +whocares +whisper +watermelon +svetlana +southern +sommer +someone +rocky1 +qwertz +president +pleasure +pimpin +painter +nikki +nguyen +myname +missy +mellon +makaveli +journey +jeanne +honeybee +gothic +goodbye +francois +eureka +cindy +chicken1 +bryant +bright +bookworm +bob +PASSWORD +456456 +33333333 +woodstock +wendy +tuesday +trunks +titans +sunlight +stallion +smoke +seven7 +sally +redneck +randy +quality +naughty +mohamed +katie +kathryn +katerina +jefferson +jackpot +international +hidden +hellokitty +hedgehog +happyday +grumpy +frederick +fortune +fallen +demon +davidson +dangerous +clement +cerberus +carol +candle +blackcat +biology +beloved +arsenal1 +annie +angel123 +abraham +aaaaa +171717 +10101010 +0000000 +zigzag +yolanda +typhoon +turbo +training +smooth +rodrigo +roadrunner +republic +recovery +patriot +pacman +molly1 +maradona +lollol +legion +keith +jennie +javier +intruder +hermione +health +hastings +granny +goldstar +fredfred +fiesta +federico +everton +escort +eleven +deftones +cyclone +commander +chuck +chevrolet +butler +blackout +billabong +bigtits +bennett +alexia +abc +789789 +454545 +1234567a +1234554321 +yoyoyo +yesterday +wolfpack +thunder1 +tacobell +sweetness +spyder +solution +shanghai +satellite +sabine +rusty +rootbeer +romance +pikachu +phillips +parola +oakley +nancy +mystic +mulder +morning +monsters +melinda +megan +maximum +mary +marissa +love123 +lorena +lonewolf +krista +kirsten +keystone +kendall +johannes janine jackal -iforgot -hallo -fatass -deadhead -abc12 -zxcv1234 -willy -stud -slappy -roberts -rescue -porkchop -noodles -nellie -mypass -mikey -marvel -laurie -grateful -fuck_inside -formula1 -Dragon -cxfcnmt -bridget -aussie -asterix -a1s2d3f4 -23232323 -123321q -veritas -spankme -shopping -roller -rogers -queen -peterpan -palace -melinda -martinez -lonely -kristi -justdoit -goodtime -frances -camel -beckham -atomic -alexandra -active -223344 -vanilla -thankyou -springer -sommer -Software -sapphire -richmond -printer -ohyeah -massive -lemons -kingston -granny -funfun -evelyn -donnie -deanna -brucelee -bosco -aggies -313131 -wayne -thunder1 -throat -temple -smudge -qqqq -qawsedrf -plymouth -pacman -myself -mariners -israel -hitler -heather1 -faith -Exigent -clancy -chelsea1 -353535 -282828 -123456qwerty -tobias -tatyana -stuff -spectrum -sooner -shitty -sasha1 -pooh -pineappl -mandy -labrador -kisses -katrin -kasper -kaktus -harder -eduard -dylan -dead -chloe -astros -1234567890q -10101010 -stephanie -satan -hudson -commando -bones -bangkok -amsterdam -1959 -webmaster -valley -space -southern -rusty1 -punkin -napass -marian -magnus -lesbians -krishna -hungry -hhhhhh -fuckers -fletcher -content -account -906090 -thompson -simba -scream -q1q1q1 -primus -Passw0rd -mature -ivanov -husker -homerun -esther -ernest -champs -celtics -candyman -bush -boner -asian -aquarius -33333 -zxcv -starfish -pics -peugeot -painter -monopoly -lick -infiniti -goodbye -gangbang -fatman -darling -celine -camelot -boat -blackjac -barkley -area51 -8J4yE3Uz -789654 -19871987 -0000000000 -vader -shelley -scrappy -sarah1 -sailboat -richard1 -moloko -method -mama -kyle -kicker -keith -judith -john316 -horndog -godsmack -flyboy -emmanuel -drago -cosworth -blake -19891989 -writer -usa123 -topdog -timmy -speaker -rosemary -pancho -night -melody -lightnin -life -hidden -gator -farside -falcons -desert -chevrole -catherin -carolyn -bowler -anders -666777 -369369 -yesyes -sabbath -qwerty123456 -power1 -pete -oscar1 -ludwig -jammer -frontier -fallen -dance -bryan -asshole1 -amber1 -aaa111 -123457 -01011991 -terror -telefon -strong -spartans -sara -odessa -luckydog -frank1 -elijah -chang -center -bull -blacks -15426378 -132435 -vivian -tanya -swingers -stick -snuggles -sanchez -redbull -reality -qwertyuio -qwert123 -mandingo -ihateyou -hayden -goose -franco -forrest -double -carol -bohica -bell -beefcake -beatrice -avenger -andrew1 -anarchy -963852 -1366613 -111111111 -whocares -scooter1 -rbhbkk -matilda -labtec -kevin1 -jojo -jesse -hermes -fitness -doberman -dawg -clitoris -camels -5555555555 -1957 -vulcan -vectra -topcat -theking -skiing -nokia -muppet -moocow -leopard -kelley -ivan -grover -gjkbyf -filter -elvis1 -delta1 -dannyboy -conrad -children -catcat -bossman -bacon -amelia -alice -2222222 -viktoria -valhalla -tricky -terminator -soccer1 -ramona -puppy -popopo -oklahoma -ncc1701a -mystic -loveit -looker -latin -laptop -laguna -keystone -iguana -herbie -cupcake -clarence -bunghole -blacky -bennett -bart -19751975 -12332 -000007 -vette -trojans -today -romashka -puppies -possum -pa55word -oakley -moneys -kingpin -golfball -funny -doughboy -dalton -crash -charlotte -carlton -breeze -billie -beast -achilles -tatiana -studio -sterlin -plumber -patrick1 -miles -kotenok -homers -gbpltw -gateway1 -franky -durango -drake -deeznuts -cowboys1 -ccbill -brando -9876543210 -zzzz -zxczxc -vkontakte -tyrone -skinny -rookie -qwqwqw -phillies -lespaul -juliet -jeremiah -igor -homer1 -dilligaf -caitlin -budman -atlantic -989898 -362436 -19851985 -vfrcbvrf -verona -technics -svetik -stripper -soleil -september -pinkfloy -noodle -metal -maynard -maryland -kentucky -hastings -gang -frederic -engage -eileen -butthole -bone -azsxdc -agent007 -474747 -19911991 -01011985 -triton -tractor -somethin -snow -shane -sassy -sabina -russian -porsche9 -pistol -justine -hurrican -gopher -deadman -cutter -coolman -command -chase -california -boris -bicycle -bethany -bearbear -babyboy -73501505 -123456k -zvezda -vortex -vipers -tuesday -traffic -toto -star69 -server -ready -rafael -omega1 -nathalie -microlab -killme -jrcfyf -gizmo1 -function -freaks -flamingo -enterprise -eleven -doobie -deskjet -cuddles -church -breast -19941994 -19781978 -1225 -01011970 -vladik -unknown -truelove -sweden -striker -stoner -sony -SaUn -ranger1 -qqqqq -pauline -nebraska -meatball -marilyn -jethro -hammers -gustav -escape -elliot -dogman -chair -brothers -boots -blow -bella -belinda -babies -1414 -titties -syracuse -river -polska -pilot -oilers -nofear -military -macdaddy -hawk -diamond1 -dddd -danila -central -annette -128500 -zxcasd -warhammer -universe -splash -smut -sentinel -rayray -randall -Password1 -panda -nevada -mighty -meghan -mayday -manchest -madden -kamikaze -jennie -iloveyo -hustler -hunter1 -horny1 -handsome -dthjybrf -designer -demon -cheers -cash -cancel -blueblue -bigger -australia -asdfjkl -321654987 -1qaz1qaz -1955 -1234qwe -01011981 -zaphod -ultima -tolkien -Thomas -thekid -tdutybq -summit -select -saint -rockets -rhonda -retard -rebel -ralph -poncho -pokemon1 -play -pantyhos -nina -momoney -market -lickit -leader -kong -jenna -jayjay -javier -eatpussy -dracula -dawson -daniil -cartoon -capone -bubbas -789123 -19861986 -01011986 -zxzxzx -wendy -tree -superstar -super1 -ssssssss -sonic -sinatra -scottie -sasasa -rush -robert1 -rjirfrgbde -reagan -meatloaf -lifetime -jimmy1 -jamesbon -houses -hilton -gofish -charmed -bowser -betty -525252 -123456789z -1066 -woofwoof -Turkey50 -santana -rugby -rfnthbyf -miracle -mailman -lansing -kathryn -Jennifer -giant -front242 -firefox -check -boxing -bogdan -bizkit -azamat -apollo13 -alan -zidane -tracy -tinman -terminal -starbuck -redhot -oregon -memory -lewis -lancelot -illini -grandma -govols -gordon24 -giorgi -feet -fatima -crunch -creamy -coke -cabbage -bryant -brandon1 -bigmoney -azsxdcfv -3333333 -321123 -warlord -station -sayang -rotten -rightnow -mojo -models -maradona -lololo -lionking -jarhead -hehehe -gary -fast -exodus -crazybab -conner -charlton -catman -casey1 -bonita -arjay -19931993 -19901990 -1001 -100000 -sticks -poiuytrewq -peters -passwort -orioles -oranges -marissa -japanese -holyshit -hohoho -gogo -fabian -donna -cutlass -cthulhu -chewie -chacha -bradford -bigtime -aikido -4runner -21212121 -150781 -wildfire -utopia -sport -sexygirl -rereirf -reebok -raven1 -poontang -poodle -movies -microsof -grumpy -eeyore -down -dong -chocolate -chickens -butch -arsenal1 -adult -adriana -19831983 -zzzzz -volley -tootsie -sparkle -software -sexx -scotch -science -rovers -nnnnnn -mellon -legacy -julius -helen -happyday -fubar -danie -cancun -br0d3r -beverly -beaner -aberdeen -44444 -19951995 -13243546 -123456aa -wilbur -treasure -tomato -theodore -shania -raiders1 -natural -kume -kathy -hamburg -gretchen -frisco -ericsson -daddy1 -cosmo -condom -comics -coconut -cocks -Check -camilla -bikini -albatros -1Passwor -1958 -1919 -143143 -0.0.0.000 -zxcasdqwe -zaqxsw -whisper -vfvekz -tyler1 -Sojdlg123aljg -sixers -sexsexsex -rfhbyf -profit -okokok -nancy -mikemike -michaela -memorex -marlene -kristy -jose -jackson1 -hope -hailey -fugazi -fright -figaro -excalibu -elvira -dildo -denali -cruise -cooter -cheng -candle -bitch1 -attack -armani -anhyeuem -78945612 -222333 -zenith -walleye -tsunami -trinidad -thomas1 -temp -tammy -sultan -steve1 -slacker -selena -samiam -revenge -pooppoop -pillow -nobody -kitty1 -killer1 -jojojo -huskies -greens -greenbay -greatone -fuckin -fortuna -fordf150 -first -fashion -fart -emerson -davis -cloud9 -china -boob -applepie -alien -963852741 -321456 -292929 -1998 -1956 -18436572 -tasha -stocks -rustam -rfrnec -piccolo -orgasm -milana -marisa -marcos -malaka -lisalisa -kelly1 -hithere -harley1 -hardrock -flying -fernand -dinosaur -corrado -coleman -clapton -chief -bloody -anfield -636363 -420247 -332211 -voyeur -toby -texas1 -surf -steele -running -rastaman -pa55w0rd -oleg -number1 -maxell -madeline -keywest -junebug -ingrid -hollywood -hellyeah -hayley -goku -felicia -eeeeee -dicks -dfkthbz -dana -daisy1 -columbus -charli -bonsai -billy1 -aspire -9999999 -987987 -50cent -000001 -xxxxxxx -wolfie -viagra -vfksirf -vernon -tang -swimmer -subway -stolen -sparta -slutty -skywalker -sean -sausage -rockhard -ricky -positive -nyjets -miriam -melissa1 -krista -kipper -kcj9wx5n -jedi -jazzman -hyperion -happy123 -gotohell -garage -football1 -fingers -february -faggot -easy -dragoon -crazy1 -clemson -chanel -canon -bootie -balloon -abc12345 -609609609 -456321 -404040 -162534 -yosemite -slider -shado -sandro -roadkill -quincy -pedro -mayhem -lion -knopka -kingfish -jerkoff +horse hopper -everest -ddddddd -damnit -cunts -chevy1 -cheetah -chaser -billyboy -bigbird -bbbb -789987 -1qa2ws3ed -1954 -135246 -123789456 -122333 -1000 -050505 -wibble -valeria -tunafish -trident -thor -tekken -tara -starship -slave -saratoga -romance -robotech -rich -rasputin -rangers1 -powell -poppop -passwords -p0015123 -nwo4life -murder -milena -midget -megapass -lucky13 -lolipop -koshka -kenworth -jonjon -jenny1 -irish1 -hedgehog -guiness -gmoney -ghetto -fortune -emily1 -duster -ding -davidson -davids -dammit -dale -crysis -bogart -anaconda -alibaba -airbus -7753191 -515151 -20102010 -200000 -123123q -12131415 -10203 -work -wood -vladislav -vfczyz -tundra -Translator -torres -splinter -spears -richards -rachael -pussie -phoenix1 -pearl -monty -lolo -lkjhgf -leelee -karolina -johanna -jensen -helloo -harper -hal9000 -fletch -feather -fang -dfkthf -depeche -barsik -789789789 -757575 -727272 -zorro -xtreme -woman -vitalik -vermont -train -theboss -sword -shearer -sanders -railroad -qwer123 -pupsik -pornos -pippen -pingpong -nikola -nguyen -music1 -magicman -killbill -kickass -kenshin -katie1 -juggalo -jayhawk -java -grapes -fritz -drew -divine -cyclops -critter -coucou -cecilia -bristol -bigsexy -allsop -9876 -1230 -01011989 -wrestlin -twisted -trout -tommyboy -stefano -song -skydive -sherwood -passpass -pass1234 -onlyme -malina -majestic -macross -lillian -heart -guest -gabrie -fuckthis -freeporn -dinamo -deborah -crawford -clipper -city -better -bears -bangbang -asdasdasd -artemis -angie -admiral -2003 -020202 -yousuck -xbox360 -werner -vector -usmc -umbrella -tool -strange -sparks -spank -smelly -small -salvador -sabres -rupert -ramses -presto -pompey -operator -nudist -ne1469 -minime -matador -love69 -kendall -jordan1 -jeanette -hooter -hansen -gunners -gonzo -gggggggg -fktrcfylhf -facial -deepthroat -daniel1 -dang -cruiser -cinnamon -cigars -chico -chester1 -carl -caramel -calico -broadway -batman1 -baddog -778899 -2128506 -123456r -0420 -01011988 -z1x2c3 -wassup -wally -vh5150 -underdog -thesims -thecat -sunnyday -snoopdog -sandy1 -pooter -multiplelo -magick -library -kungfu -kirsten -kimber -jean -jasmine1 -hotshot -gringo -fowler -emma -duchess -damage -cyclone -Computer -chong -chemical -chainsaw -caveman -catherine -carrera -canadian -buster1 -brighton -back -australi -animals -alliance -albion -969696 -555777 -19721972 -19691969 -1024 -trisha -theresa -supersta -steph -static -snowboar -sex123 -scratch -retired -rambler -r2d2c3po -quantum -passme -over -newbie -mybaby -musica -misfit -mechanic -mattie -mathew -mamapapa -looser -jabroni -isaiah -heyhey -hank -hang -golfgolf -ghjcnjnfr -frozen -forfun -fffff -downtown -coolguy -cohiba -christopher -chivas -chicken1 -bullseye -boys -bottle -bob123 -blueboy -believe -becky -beanie -20002000 -yzerman -west -village -vietnam -trader -summer1 -stereo -spurs -solnce -smegma -skorpion -saturday -samara -safari -renault -rctybz -peterson -paper -meredith -marc -louis -lkjhgfdsa -ktyjxrf -kill -kids -jjjj -ivanova -hotred -goalie -fishes -eastside -cypress -cyber -credit -brad -blackhaw -beastie -banker -backdoor -again -192837 -112211 -westwood -venus -steeler -spawn -sneakers -snapple -snake1 -sims -sharky -sexxxx -seeker -scania -sapper -route66 -Robert -q123456 -Passwor1 -mnbvcx -mirror -maureen -marino13 -jamesbond -jade -horizon -haha -getmoney -flounder -fiesta -europa -direct -dean -compute -chrono -chad -boomboom -bobby1 -bing -beerbeer -apple123 -andres -8888888 -777888 -333666 -1357 -12345z -030303 -01011987 -01011984 -wolf359 -whitey -undertaker -topher -tommy1 -tabitha -stroke -staples -sinclair -silence -scout -scanner -samsung1 -rain -poetry -pisces -phil -peter1 -packer -outkast -nike -moneyman -mmmmmmmm -ming -marianne -magpie -love123 -kahuna -jokers -jjjjjjjj -groucho -goodman -gargoyle -fuckher -florian -federico -droopy -dorian -donuts -ddddd -cinder -buttman -benny -barry -amsterda -alfa -656565 -1x2zkg8w -19881988 -19741974 -zerocool -walrus -walmart -vfvfgfgf -user -typhoon -test1234 -studly -Shadow -sexy69 -sadie1 -rtyuehe -rosie -qwert1 -nipper -maximum -klingon -jess -idontknow -heidi -hahahaha -gggg -fucku2 -floppy -flash1 -fghtkm -erotica -erik -doodoo -dharma -deniska -deacon -daphne -daewoo -dada -charley -cambiami -bimmer -bike -bigbear -alucard -absolut -a123456789 -4121 -19731973 -070707 -03082006 -02071986 -vfhufhbnf -sinbad -secret1 -second -seamus -renee -redfish -rabota -pudding -pppppppp -patty -paint -ocean -number -nature -motherlode -micron -maxx -massimo -losers -lokomotiv -ling -kristine -kostya -korn -goldstar -gegcbr -floyd -fallout -dawn -custom -christina -chrisbln -button -bonkers -bogey -belle -bbbbb -barber -audia4 -america1 -abraham -585858 -414141 -336699 -20012001 -12345678q -0123 -whitesox -whatsup -usnavy -tuan -titty -titanium -thursday -thirteen -tazmania -steel -starfire -sparrow -skidoo -senior -reading -qwerqwer -qazwsx12 -peyton -panasoni -paintbal -newcastl -marius -italian -hotpussy -holly1 -goliath -giuseppe -frodo -fresh -buckshot -bounce -babyblue -attitude -answer -90210 -575757 -10203040 -1012 -01011910 -ybrjkfq -wasser -tyson -Superman -sunflowe -steam -ssss -sound -solution -snoop -shou -shawn -sasuke -rules -royals -rivers -respect -poppy -phillips -olivier -moose1 -mondeo -mmmm -knickers -hoosier -greece -grant -godfather -freeze -europe -erica -doogie -danzig -dalejr -contact -clarinet -champ -briana -bluedog -backup -assholes -allmine -aaliyah -12345679 -100100 -zigzag -whisky -weaver -truman -tomorrow -tight -theend -start -southpark -sersolution -roberta -rhfcjnrf -qwerty1234 -quartz -premier -paintball -montgom240 -mommy -mittens -micheal -maggot -loco -laurel -lamont -karma -journey -johannes -intruder -insert -hairy -hacked -groove -gesperrt -francois -focus -felipe -eternal -edwards -doug -dollars -dkflbckfd -dfktynbyf -demons -deejay -cubbies -christie -celeron -cat123 -carbon -callaway -bucket -albina -2004 -19821982 -19811981 -1515 -12qw34er -123qwerty -123aaa -10101 -1007 -080808 -zeus -warthog -tights -simona -shun -salamander -resident -reefer -racer -quattro -public -poseidon -pianoman -nonono -michell -mellow -luis -jillian -havefun -gunnar -goofy -futbol -fucku -eduardo -diehard -dian -chuckles -carla -carina -avalanch -artur -allstar -abc1234 -abby -4545 -1q2w3e4r5 -125125 -123451 -ziggy -yumyum -working -what -wang -wagner -volvo -ufkbyf -twinkle -susanne -superman1 -sunshin -strip -searay -rockford -radio -qwertyqwerty -proxy -prophet -ou8122 -oasis -mylife -monke -monaco -meowmeow -meathead -Master -leanne -kang -joyjoy -joker1 -filthy -emmitt -craig -cornell -changed -cbr600 -builder -budweise -boobie -bobobo -biggles -bigass -bertie -amanda1 -a1s2d3 -784512 -767676 -235689 -1953 -19411945 -14725836 -11223 -01091989 -01011992 -zero -vegas -twins -turbo1 -triangle -thongs -thanatos -sting -starman -spike1 -smokes -shai -sexyman -sex -scuba -runescape -phish -pepper1 -padres -nitram -nickel -napster -lord -jewels -jeanne -gretzky -great1 -gladiator -crjhgbjy -chuang -chou -blossom -bean -barefoot -alina -787898 -567890 -5551212 -25252525 -02071982 -zxcvbnm1 -zhong -woohoo -welder -viewsonic -venice -usarmy -trial -traveler -together -team -tango -swords -starter -sputnik -spongebob -slinky -rover -ripken -rasta -prissy -pinhead -papa -pants -original -mustard -more -mohammed -mian -medicine -mazafaka -lance -juliette -james007 -hawkeyes -goodboy -gong -footbal -feng -derek -deeznutz -dante -combat -cicero -chun -cerberus -beretta -bengals -beaches -3232 -135792468 -12345qwe -01234567 -01011975 -zxasqw12 -xxx123 -xander -will -watcher -thedog -terrapin -stoney -stacy -something -shang -secure -rooney -rodman -redwing -quan -pony -pobeda -pissing -philippe -overkill -monalisa -mishka -lions -lionel -leonid -krystal -kosmos -jessic -jane -illusion -hoosiers -hayabusa -greene -gfhjkm123 -games -francesc -enter1 +gustavo +grateful +figaro +easter +dublin +donovan +continue confused -cobra1 -clevelan -cedric -carole -busted -bonbon -barrett -banane -badgirl +condor +chubby +chase +caramel +bubba1 +brighton +blades +bethany +asdzxc antoine -7779311 -311311 -2345 -187187 -123456s -123456654321 -1005 -0987 -01011993 -zippy -zhei -vinnie -tttttttt -stunner -stoned -smoking -smeghead -sacred -redwood -Pussy1 -moonlight -momomo -mimi -megatron -massage -looney -johnboy -janet -jagger -jacob1 -hurley -hong -hihihi -helmet -heckfy -hambone -gollum -gaston -f**k -death1 -Charlie -chao -cfitymrf -casanova -brent -boricua -blackjack -blablabla -bigmike -bermuda -bbbbbbbb -bayern -amazing -aleksey -717171 -12301230 -zheng -yoyo -wildman -tracker -syncmaster -sascha -rhiannon -reader -queens -qing -purdue -pool -poochie -poker -petra +135790 +0000000000 +yankees1 +triangle +shaman +shadow1 +sander +romeo +pippin +peterson person -orchid -nuts -nice -lola -lightning -leng -lang -lambert -kashmir -jill -idiot -honey1 -fisting -fester -eraser -diao -delphi -dddddddd -cubswin -cong -claudio -clark -chip -buzzard -buzz -butts -brewster -bravo -bookworm -blessing -benfica -because -babybaby -aleksandra -6666666 -1997 -19961996 -19791979 -1717 -1213 -02091987 -02021987 -xiao -wild -valencia -trapper -tongue -thegreat -sancho -really -rainman -piper -peng -peach -passwd -packers1 -newpass6 -neng -mouse1 -motley -morning -midway -Michelle -miao -maste -marin -kaylee -justin1 -hokies -health -glory -five -dutchess -dogfood -comet -clouds -cloud -charles1 -buddah -bacardi -astrid -alphabet -adams -19801980 -147369 -12qwas -02081988 -02051986 -02041986 -02011985 -01011977 -xuan -vedder -valeri -teng -stumpy -squash -snapon -site -ruan -roadrunn -rjycnfynby -rhtdtlrj -rambo -pizzas -paula -novell -mortgage -misha -menace -maxim -lori -kool -hanna -gsxr750 -goldwing -frisky -famous -dodge1 -dbrnjh -christmas -cheese1 -century -candice -booker -beamer -assword -army -angus -andromeda -adrienne -676767 -543210 -2010 -1369 -12345678a -12011987 -02101985 -02031986 -02021988 -zhuang -zhou -wrestling -tinkerbell -thumbs -thedude -teddybea -sssss -sonics -sinister -shannon1 -satana -sang -salomon -remote -qazzaq -playing -piao -pacers -onetime -nong -nikolay -motherfucker -mortimer -misery -madison1 -luan -lovesex -look -Jessica -handyman -hampton -gromit -ghostrider -doghouse -deluxe -clown -chunky -chuai -cgfhnfr -brewer -boxster -balloons -adults -a1a1a1 -794613 -654123 -24682468 -2005 -1492 -1020 -1017 -02061985 -02011987 -***** -zhun -ying -yang -windsor -wedding -wareagle -svoboda -supreme -stalin -sponge -simon1 -roadking -ripple -realmadrid -qiao -PolniyPizdec0211 -pissoff -peacock -norway -nokia6300 -ninjas -misty1 -medusa -medical -maryann -marika -madina -logan1 -lilly -laser -killers -jiang -jaybird -jammin -intel -idontkno -huai -harry1 -goaway -gameover -dino -destroy -deng +moon +marianna +maniac +mandrake +isaiah +inuyasha +home +hardware +goblin +french +freebird +florian +ferguson +dorian +dominick +dick +carolyn +bullfrog +bruce +babylon5 +avenger +13131313 +zanzibar +tricky +transfer +television +sparkles +space +silent +shepherd +search +resident +puppy +property +pictures +piccolo +oooooo +mischief +me +mathew +marcelo +magical +macintosh +logan +lionel +laguna +kristian +kissmyass +jones +iforgot +hurricane +hoover +herbie +heineken +hahahaha +goforit +fuckit +eastside +dude +daffodil collin -claymore -chicago1 -cheater -chai -bunny1 -blackbir -bigbutt -bcfields -athens -antoni -abcd123 -686868 -369963 -1357924680 -12qw12 -1236987 -111333 -02091986 -02021986 -01011983 -000111 -zhuai -yoda -xiang -wrestle +charming +billybob +bigman +attila +aspire +artemis +armstrong +adventure +adelaide +zenith +underdog +time +temple +technics +sweden +subway +sinner +sara +samsung1 +sabina +rooney +remote +qwerty1234 +python +pillow +phoenix1 +passwd +musicman +murder +metal +market +marjorie +linkin +letmein1 +kingpin +jesse +jerusalem +ingrid +information +iloveyou1 +hospital +handball +gopher +gonzales +fortuna +flying +fitness +dylan +dilbert +desert +darkangel +clouds +cascade +camelot +budapest +brandon1 +boss +batista +armando +angie +alliance +alibaba +adrienne +aberdeen +abc123456 +1234512345 +zephyr +wonderland +willis +ultima +triton +thuglife +studio +squirt +splash +sentinel +richards +redrose +rammstein +quincy +queen +project +penny +pearl +oakland +newyork1 +mortimer +micheal +marcello +magazine +luther +jumper +josh +infantry +impala +hopeless +holmes +harrypotter +glitter +fandango +falcons +edison +eagle1 +donna +deadhead +clarissa +christie +chico +charlene +blade +billyboy +bangbang +bamboo +asasas +ariana +absolute +50cent +waters +trucker +titanium +tiger123 +supreme +superior +stefanie +sparks +spaceman +somebody +smudge +sleepy +sinclair +secrets +scrappy +rubber +ricky +pppppppp +poland +pink +paintball +ninja +newlife +nevada +mmmmmmmm +military +medical +marijuana +mackenzie +loveless +louis +lolipop +lilian +lighthouse +lewis +lassie +kristy +knights +karolina +jillian +jesuschrist +jensen +heart +grover +fernanda +felicity +dietcoke +coolio +cleveland +chevy +callie +bryan +brewster +biggie +bessie +bertha +babyblue +ashleigh +andrei +abcde +8888 +852456 +321654 +1q2w3e4r5t6y +14789632 +012345 +willy whiskers valkyrie -toon -tong -ting -talisman -starcraf -sporting -spaceman -southpar -smiths -skate -shell -seng -saleen -ruby -reng -redline -rancid -pepe -optimus -nova -mohamed -meister -marcia -lipstick -kittykat -jktymrf -jenn -jayden -inuyasha -higgins -guai -gonavy -face -eureka -dutch -darkman -courage -cocaine -circus -cheeks -camper -br549 -bagira -babyface -7uGd5HIp2J -5050 -1qaz2ws -123321a -02081987 -02081984 -02061986 -02021984 -01011982 -zhai -xiong -willia -vvvvvv -venera -unique -tian -sveta +triumph +tracker +superfly strength -stories -squall -secrets -seahawks -sauron -ripley -riley -recovery -qweqweqwe -qiong -puddin -playstation -pinky -phone -penny1 -nude -mitch -milkman -mermaid -max123 -maria1 -lust -loaded -lighter -lexus -leavemealone -just4me -jiong -jing -jamie1 -india -hardcock -gobucks -gawker -fytxrf -fuzzy -florida1 -flexible -eleanor -dragonball -doudou -cinema -checkers -charlene -ceng -buffy1 -brian1 -beautifu -baseball1 -ashlee -adonis -adam12 -434343 -02031984 -02021985 -xxxpass -toledo -thedoors -templar -sullivan -stanford -shei -sander -rolling -qqqqqqq -pussey -pothead -pippin -nimbus -niao -mustafa -monte -mollydog -modena -mmmmm -michae -meng -mango -mamama -lynn -love12 -kissing -keegan -jockey -illinois -ib6ub9 -hotbox -hippie -hill -ghblehjr -gamecube -ferris -diggler -crow -circle -chuo -chinook -charity -carmel -caravan -cannabis -cameltoe -buddie -bright -bitchass -bert -beowulf -bartman -asia -armagedon -ariana -alexalex -alenka -ABC123 -987456321 -373737 -2580 -21031988 -123qq123 -12345t -1234567890a -123455 -02081989 -02011986 -01020304 -01011999 -xyz123 -xerxes -wraith -wishbone -warning -todd -ticket -three -subzero -shuang -rong -rider -quest -qiang -pppp -pian -petrov -otto -nuan -ning -myname -matthews -martine -mandarin -magical -latinas -lalalala -kotaku -jjjjj -jeffery -jameson -iamgod -hellos -hassan -Harley -godfathe -geng -gabriela -foryou -ffffffff -divorce -darius -chui -breasts -bluefish -binladen -bigtit -anne -alexia -2727 -19771977 -19761976 -02061989 -02041984 -zhui -zappa -yfnfkmz -weng -tricia -tottenham -tiberius -teddybear -spinner -spice -spectre -solo -silverad -silly -shuo -sherri -samtron -poland -poiuy -pickup -pdtplf -paloma -ntktajy -northern -nasty1 -musashi -missy1 -microphone -meat -manman -lucille -lotus -letter -kendra -iomega -hootie -forward -elite -electron -electra -duan -DRAGON -dotcom -dirtbike -dianne -desiree -deadpool -darrell -cosmic -common -chrome -cathy -carpedie -bilbo -bella1 -beemer -bearcat -bank -ashley1 -asdfzxcv -amateurs -allan -absolute -50spanks -147963 -120676 -1123 -02021983 -zang -virtual -vampires -vadim -tulips -sweet1 -suan -spread -spanish -some -slapper -skylar -shiner -sheng -shanghai -sanfran -ramones -property -pheonix -password2 -pablo -othello -orange1 -nuggets -netscape -ludmila -lost -liang -kakashka -kaitlyn -iscool -huang -hillary -high -hhhh -heater -hawaiian -guang -grease -gfhjkmgfhjkm -gfhjkm1 -fyutkbyf -finance -farley -dogshit -digital1 -crack -counter -corsair -company -colonel -claudi -carolin -caprice -caligula -bulls -blackout -beatle -beans -banzai -banner -artem -9562876 -5656 -1945 -159632 -15151515 -123456qw -1234567891 -02051983 -02041983 -02031987 -02021989 -z1x2c3v4 -xing -vSjasnel12 -twenty -toolman -thing -testpass -stretch -stonecold -soulmate -sonny -snuffy -shutup -shuai -shao -rhino -q2w3e4r5 -polly -poipoi -pierce -piano -pavlov -pang -nicole1 -millions -marsha -lineage2 -liao -lemon -kuai -keller -jimmie -jiao -gregor -ggggg -game -fuckyo -fuckoff1 -friendly -fgtkmcby -evan -edgar -dolores -doitnow -dfcbkbq -criminal -coldbeer -chuckie -chimera -chan -ccccc -cccc -cards -capslock -cang -bullfrog -bonjovi -bobdylan -beth -berger -barker -balance -badman -bacchus -babylove -argentina -annabell -akira -646464 -15975 -1223 -11221122 -1022 -02081986 -02041988 -02041987 -02041982 -02011988 -zong -zhang -yummy -yeahbaby -vasilisa -temp123 -tank -slim -skyler -silent -sergeant -reynolds -qazwsx1 -PUSSY -pasword -nomore -noelle -nicol -newyork1 -mullet -monarch -merlot -mantis -mancity -magazine -llllllll -kinder -kilroy -katherine -jayhawks -jackpot -ipswich -hack -fishing1 -fight -ebony -dragon12 -dog123 -dipshit -crusher -chippy -canyon -bigbig -bamboo -athlon -alisha -abnormal -a11111 -2469 -12365 -1011 -09876543 -02101984 -02081985 -02071984 -02011980 -010180 -01011979 -zhuo -zaraza -wg8e3wjf -triple -tototo -theater -teddy1 -syzygy -susana -sonoma -slavik -shitface -sheba -sexyboy -screen -salasana -rufus -Richard -reds -rebecca1 -pussyman -pringles -preacher -park -oceans -niang -momo -misfits -mikey1 -media -manowar -mack -kayla -jump -jorda -hondas -hollow -here -heineken -halifax -gatorade -gabriell -ferrari1 -fergie -female -eldorado -eagles1 -cygnus -coolness -colton -ciccio -cheech -card -boom -blaze -bhbirf -BASEBALL -barton -655321 -1818 -14141414 -123465 -1224 -1211 -111111a -02021982 -zhao -wings -warner -vsegda -tripod -tiao -thunderb -telephon -tdutybz -talon -speedo -specialk -shepherd -shadows -samsun -redbird -race -promise -persik -patience -paranoid -orient -monster1 -missouri -mets -mazda -masamune -martin1 -marker -march -manning -mamamama -licking -lesley -laurence -jezebel -jetski -hopeless -hooper -homeboy -hole -heynow -forum -foot -ffff -farscape -estrella -entropy -eastwood -dwight -dragonba -door -dododo -deutsch -crystal1 -corleone -cobalt -chopin -chevrolet -cattle -carlitos -buttercu -butcher -bushido -buddyboy -blond -bingo1 -becker -baron -augusta -alex123 -998877 -24242424 -12365478 -02061988 -02031985 -?????? -zuan -yfcntymrf -wowwow -winston1 -vfibyf -ventura -titten -tiburon -thoma -thelma -stroker -snooker -smokie -slippery -shui -shock -seadoo -sandwich -records -rang -puffy -piramida -orion1 -napoli -nang -mouth -monkey12 -millwall -mexican -meme -maxxxx -magician -leon -lala -lakota -jenkins -jackson5 -insomnia -harvard -HARLEY -hardware -giorgio -ginger1 -george1 -gator1 -fountain -fastball -exotic -elizaveta -dialog -davide -channel -castro -bunnies -borussia -asddsa -andromed -alfredo -alejandro -7007 -69696 -4417 -3131 -258852 -1952 -147741 -1234asdf -02081982 -02051982 -zzzzzzz -zeng -zalupa -yong -windsurf -wildcard -weird -violin -universal -sunflower -suicide -strawberry -stepan -sphinx -someone -sassy1 -romano -reddevil -raquel -rachel1 -pornporn -polopolo -pluto -plasma -pinkfloyd -panther1 -north -milo -maxime -matteo -malone -major -mail -lulu -ltybcrf -lena -lassie -july -jiggaman -jelly -islander -inspiron -hopeful -heng -hans -green123 -gore -gooner -goirish -gadget -freeway -fergus -eeeee -diego -dickie -deep -danny1 -cuan -cristian -conover -civic -Buster -bombers -bird33 -bigfish -bigblue -bian -beng -beacon -barnes -astro -artemka -annika -anita -Andrew -747474 -484848 -464646 -369258 -225588 -1z2x3c -1a2s3d4f -123456qwe -02061980 -02031982 -02011984 -zaqxswcde -wrench -washington -violetta -tuning -trainer -tootie -store -spurs1 -sporty -sowhat -sophi -smashing -sleeper -slave1 -sexysexy -seeking -sam123 -robotics -rjhjktdf -reckless -pulsar -project -placebo -paddle -oooo -nightmare -nanook -married -linda1 -lilian -lazarus -kuang -knockers -killkill -keng -katherin -Jordan -jellybea -jayson -iloveme -hunt -hothot -homerj -hhhhhhhh -helene -haggis -goat -ganesh -gandalf1 -fulham -force -dynasty -drakon -download -doomsday -dieter -devil666 -desmond -darklord -daemon -dabears -cramps -cougars -clowns -classics -citizen -cigar -chrysler -carlito -candace -bruno1 -browning -brodie -bolton -biao -barbados -aubrey -arlene -arcadia -amigo -abstr -9293709b13 -737373 -4444444 -4242 -369852 -20202020 -1qa2ws -1Pussy -1947 -1234560 -1112 -1000000 -02091983 -02061987 -01081989 -zephyr -yugioh -yjdsqgfhjkm -woofer -wanted -volcom -verizon -tripper -toaster -tipper -tigger1 -tartar -superb -stiffy -spock -soprano -snowboard -sexxxy -senator -scrabble -santafe -sally1 -sahara -romero -rhjrjlbk -reload -ramsey -rainbow6 -qazwsxedc123 -poopy -pharmacy -obelix -normal -nevermind -mordor -mclaren -mariposa -mari -manuela -mallory -magelan -lovebug -lips -kokoko -jakejake -insanity -iceberg -hughes -hookup -hockey1 -hamish -graphics -geoffrey -firewall -fandango -ernie -dottie -doofus -donovan -domain -digimon -darryl -darlene -dancing -county -chloe1 -chantal -burrito -bummer -bubba69 -brett -bounty -bigcat -bessie -basset -augustus -ashleigh -878787 -3434 -321321321 -12051988 -111qqq -1023 -1013 -05051987 -02101989 -02101987 -02071987 -02071980 -02041985 -titan -thong -sweetnes -stanislav -sssssss -snappy -shanti -shanna -shan -script -scorpio1 -RuleZ -rochelle -rebel1 -radiohea -q1q2q3 -puss -pumpkins -puffin -onetwo -oatmeal -nutmeg -ninja1 -nichole -mobydick -marine1 -mang -lover1 -longjohn -lindros -killjoy -kfhbcf -karen1 -jingle -jacques -iverson3 -istanbul -iiiiii -howdy -hover -hjccbz -highheel -happiness -guitar1 -ghosts -georg -geneva -gamecock -fraser -faithful -dundee -dell -creature -creation -corey -concorde -cleo -cdtnbr -carmex2 -budapest -bronze -brains -blue12 -battery -attila -arrow -anthrax -aloha -383838 -19711971 -1948 -134679852 -123qw -123000 -02091984 -02091981 -02091980 -02061983 -02041981 -01011900 -zhjckfd -zazaza -wingman -windmill -wifey -webhompas -watch -thisisit -tech -submit -stress -spongebo -silver1 -senators -scott1 -sausages -radical -qwer12 -ppppp -pixies -pineapple -piazza -patrice -officer -nygiants -nikitos -nigga -nextel -moses -moonbeam -mihail -MICHAEL -meagan -marcello -maksimka -loveless -lottie -lollypop -laurent -latina -kris -kleopatra -kkkk -kirsty -katarina -kamila -jets -iiii -icehouse -hooligan -gertrude -fullmoon -fuckinside -fishin -everett -erin -dynamite -dupont -dogcat -dogboy -diane -corolla -citadel -buttfuck -bulldog1 -broker -brittney -boozer -banger -aviation -almond -aaron1 -78945 -616161 -426hemi -333777 -22041987 -2008 -20022002 -153624 -1121 -111111q -05051985 -02081977 -02071988 -02051988 -02051987 -02041979 -zander -wwww -webmaste -webber -taylor1 -taxman -sucking -stylus -spoon -spiker -simmons -sergi -sairam -royal -ramrod -radiohead -popper -platypus -pippo -pepito -pavel -monkeybo -Michael1 -master12 -marty -kjkszpj -kidrock -judy -juanita -joshua1 -jacobs -idunno -icu812 -hubert -heritage -guyver -gunther -Good123654 -ghost1 -getout -gameboy -format -festival -evolution -epsilon -enrico -electro -dynamo -duckie -drive -dolphin1 -ctrhtn -cthtuf -cobain -club -chilly -charter -celeb -cccccccc -caught -cascade -carnage -bunker -boxers -boxer -bombay -bigboss -bigben -beerman -baggio -asdf12 -arrows -aptiva -a1a2a3 -a12345678 -626262 -26061987 -1616 -15051981 -08031986 -060606 -02061984 -02061982 -02051989 -02051984 -02031981 -woodland -whiteout -visa -vanguard -towers -tiny -tigger2 -temppass -super12 -stop -stevens -softail -sheriff -robot -reddwarf -pussy123 -praise -pistons -patric -partner -niceguy -morgan1 -model -mars -mariana -manolo -mankind -lumber -krusty -kittens -kirby -june -johann -jared -imation -henry1 -heat -gobears -forsaken -Football -fiction -ferguson -edison -earnhard -dwayne -dogger -diver -delight -dandan -dalshe -cross -cottage -coolcool -coach -camila -callum -busty -british -biology -beta -beardog -baldwin -alone -albany -airwolf -9876543 -987123 -7894561230 -786786 -535353 -21031987 -1949 -13041988 -1234qw -123456l -1215 -111000 -11051987 -10011986 -06061986 -02091985 -02021981 -02021979 -01031988 -vjcrdf -uranus -tiger123 -summer99 -state -starstar -squeeze -spikes -snowflak -slamdunk -sinned -shocker -season -santa -sanity -salome -saiyan -renata -redrose -queenie -puppet -popo -playboy1 -pecker -paulie -oliver1 -ohshit -norwich -news -namaste -muscles -mortal -michael2 -mephisto -mandy1 -magnet -longbow -llll -living -lithium -komodo -kkkkkkkk -kjrjvjnbd -killer12 -kellie -julie1 -jarvis -iloveyou2 -holidays -highway -havana -harvest -harrypotter -gorgeous -giraffe -garion -frost -fishman -erika -earth -dusty1 -dudedude -demo -deer -concord -colnago -clit -choice -chillin -bumper -blam -bitter -bdsm -basebal -barron -baker -arturo -annie1 -andersen -amerika -aladin -abbott -81fukkc -5678 -135791 -1002 -02101986 -02081983 -02041989 -02011989 -01011978 -zzzxxx -zxcvbnm123 -yyyyyy -yuan -yolanda -winners -welcom -volkswag -vera -ursula -ultra -toffee -toejam -theatre -switch -superma -Stone55 -solitude -sissy -sharp -scoobydoo -romans -roadster -punk -presiden -pool6123 -playstat -pipeline -pinball -peepee -paulina -ozzy -nutter -nights -niceass -mypassword -mydick -milan -medic -mazdarx7 -mason1 -marlon -mama123 -lemonade -krasotka -koroleva -karin -jennife -itsme -isaac -irishman -hookem -hewlett -hawaii50 -habibi -guitars -grande -glacier -gagging -gabriel1 -freefree -francesco -food -flyfish -fabric -edward1 -dolly -destin -delilah -defense -codered -cobras -climber -cindy1 -christma -chipmunk -chef -brigitte -bowwow -bigblock -bergkamp -bearcats -baba -altima -74108520 -45M2DO5BS -30051985 -258258 -24061986 -22021989 -21011989 -20061988 -1z2x3c4v -14061991 -13041987 -123456m -12021988 -11081989 -03041991 -02071981 -02031979 -02021976 -01061990 -01011960 -yvette -yankees2 -wireless -werder -wasted -visual -trust -tiffany1 -stratus -steffi -stasik -starligh -sigma -rubble -ROBERT -register -reflex -redfox -record -qwerty7 -premium -prayer -players -pallmall -nurses -nikki1 -nascar24 -mudvayne -moritz -moreno -moondog -monsters -micro -mickey1 -mckenzie -mazda626 -manila -madcat -louie -loud -krypton -kitchen -kisskiss -kate -jubilee -impact -Horny -hellboy -groups -goten -gonzalez -gilles -gidget -gene -gbhfvblf -freebird -federal -fantasia -dogbert -deeper -dayton -comanche -cocker -choochoo -chambers -borabora -bmw325 -blast -ballin -asdfgh01 -alissa -alessandro -airport -abrakadabra -7777777777 -635241 -494949 -420000 -23456789 -23041987 -19701970 -1951 -18011987 -172839 -1235 -123456789s -1125 -1102 -1031 -07071987 -02091989 -02071989 -02071983 -02021973 -02011981 -01121986 -01071986 -0101 -zodiac -yogibear -word -water1 -wasabi -wapbbs -wanderer -vintage -viktoriya -varvara -upyours -undertak -underground -undead -umpire -tropical -tiger2 -threesom -there -sunfire -sparky1 -snoopy1 -smart -slowhand -sheridan -sensei -savanna -rudy -redsox1 -ramirez -prowler -postman -porno1 -pocket -pelican -nfytxrf -nation -mykids -mygirl -moskva -mike123 -Master1 -marianna -maggie1 -maggi -live -landon -lamer -kissmyass -keenan -just4fun -julien -juicy -JORDAN -jimjim -hornets -hammond -hallie -glenn -ghjcnjgfhjkm -gasman -FOOTBALL -flanker -fishhead -firefire -fidelio -fatty -excalibur -enterme -emilia -ellie -eeee -diving -dindom -descent -daniele -dallas1 -customer -contest -compass -comfort -comedy -cocksuck -close -clay -chriss -chiara -cameron1 -calgary -cabron -bologna -berkeley -andyod22 -alexey -achtung -45678 -3636 -28041987 -25081988 -24011985 -20111986 -19651965 -1941 -19101987 -19061987 -1812 -14111986 -13031987 -123ewq -123456123 -12121990 -112112 -10071987 -10031988 -02101988 -02081980 -02021990 -01091987 -01041985 -01011995 -zebra -zanzibar -waffle -training -teenage -sweetness -sutton -sushi -suckers -spam -south -sneaky -sisters -shinobi -shibby -sexy1 -rockies -presley -president -pizza1 -piggy -password12 -olesya -nitro -motion -milk -medion -markiz -lovelife -longdong -lenny -larry1 -kirk -johndeer -jefferso -james123 -jackjack -ijrjkfl -hotone -heroes -gypsy -foxy -fishbone -fischer -fenway -eddie1 -eastern -easter -drummer1 -Dragon1 -Daniel -coventry -corndog -compton -chilli -chase1 -catwoman -booster -avenue -armada -987321 -818181 -606060 -5454 -28021992 -25800852 -22011988 -19971997 -1776 -17051988 -14021985 -13061986 -12121985 -11061985 -10101986 -10051987 -10011990 -09051945 -08121986 -04041991 -03041986 -02101983 -02101981 -02031989 -02031980 -01121988 -wwwwwww -virgil -troy -torpedo -toilet -tatarin -survivor -sundevil -stubby -straight -spotty -slater -skip -sheba1 -runaway -revolver -qwerty11 -qweasd123 -parol -paradigm -older -nudes -nonenone -moore -mildred -michaels -lowell -knock -klaste -junkie -jimbo1 -hotties -hollie -gryphon -gravity -grandpa -ghjuhfvvf -frogman -freesex -foreve -felix1 -fairlane -everlast -ethan -eggman -easton -denmark -deadly -cyborg -create -corinne -cisco -chick -chestnut -bruiser -broncos1 -bobdole -azazaz -antelope -anastasiya -456456456 -415263 -30041986 -29071983 -29051989 -29011985 -28021990 -28011987 -27061988 -25121987 -25031987 -24680 -22021986 -21031990 -20091991 -20031987 -196969 -19681968 -1946 -17061988 -16051989 -16051987 -1210 -11051990 -100500 -08051990 -05051989 -04041988 -02051980 -02051976 -02041980 -02031977 -02011983 -01061986 -01041988 -01011994 -0000007 -zxcasdqwe123 -washburn -vfitymrf -troll -tranny -tonight -thecure -studman -spikey -soccer12 -soccer10 -smirnoff -slick1 -skyhawk -skinner -shrimp -shakira -sekret -seagull -score -sasha_007 -rrrrrrrr -ross -rollins -reptile -razor -qwert12345 -pumpkin1 -porsche1 -playa -notused -noname123 -newcastle -never -nana -MUSTANG -minerva -megan1 -marseille -marjorie -mamamia -malachi -lilith -letmei -lane -lambda -krissy -kojak -kimball -keepout -karachi -kalina -justus -joel -joe123 -jerry1 -irinka -hurricane -honolulu -holycow -hitachi -highbury -hhhhh -hannah1 -hall -guess -glass -gilligan -giggles -flores -fabie -eeeeeeee -dungeon -drifter -dogface -dimas -dentist -death666 -costello -castor -bronson -brain -bolitas -boating -benben -baritone -bailey1 -badgers -austin1 -astra -asimov -asdqwe -armand -anthon -amorcit -797979 -4200 -31011987 -3030 -30031988 -3000gt -224466 -22071986 -21101986 -21051991 -20091988 -2009 -20051988 -19661966 -18091985 -18061990 -15101986 -15051990 -15011987 -13121985 -12qw12qw -1234123 -1204 -12031987 -12031985 -11121986 -1025 -1003 -08081988 -08031985 -03031986 -02101979 -02071979 -02071978 -02051985 -02051978 -02051973 -02041975 -02041974 -02031988 -02011982 -01031989 -01011974 -zoloto -zippo -wwwwwwww -w_pass -wildwood -wildbill -transit -superior -styles -stryker -string -stream -stefanie -slugger -skillet -sidekick -show -shawna -sf49ers -Salsero -rosario -remingto -redeye -redbaron -question -quasar -ppppppp -popova -physics -papers -palermo -options -mothers -moonligh -mischief -ministry -minemine -messiah -mentor -megane -mazda6 -marti -marble -leroy -laura1 -lantern -Kordell1 -koko -knuckles -khan -kerouac -kelvin -jorge -joebob -jewel -iforget -Hunter -house1 -horace -hilary -grand -gordo -glock -georgie -George -fuckhead -freefall -films -fantomas -extra -ellen -elcamino -doors -diaper -datsun -coldplay -clippers -chandra -carpente -carman -capricorn -calimero -boytoy -boiler -bluesman -bluebell -bitchy -bigpimp -bigbang -biatch -Baseball -audi -astral -armstron -angelika -angel123 -abcabc -999666 -868686 -3x7PxR -357357 -30041987 -27081990 -26031988 -258369 -25091987 -25041988 -24111989 -23021986 -22041988 -22031984 -21051988 -17011987 -16121987 -15021985 -142857 -14021986 -13021990 -12345qw -123456ru -1124 -10101990 -10041986 -07091990 -02051981 -01031985 -01021990 -****** -zildjian -yfnfkb -yeah -WP2003WP -vitamin -villa -valentine -trinitro -torino -tigge -thewho -thethe -tbone -swinging -sonia -sonata -smoke1 -sluggo -sleep -simba1 -shamus -sexxy -sevens -rober -rfvfcenhf -redhat -quentin -qazws -pufunga7782 -priest -pizdec -pigeon -pebble -palmtree -oxygen -nostromo -nikolai -mmmmmmm -mahler -lorena -lopez -lineage -korova -kokomo -kinky -kimmie -kieran -jsbach -johngalt -isabell -impreza -iloveyou1 -iiiii -huge -fuck123 -franc -foxylady -fishfish -fearless -evil -entry -enforcer -emilie -duffman -ducks -dominik -david123 -cutiepie -coolcat -cookie1 -conway -citroen -chinese -cheshire -cherries -chapman -changes -carver -capricor -book -blueball -blowfish -benoit -Beast1 -aramis -anchor -741963 -654654 -57chevy -5252 -357159 -345678 -31031988 -25091990 -25011990 -24111987 -23031990 -22061988 -21011991 -21011988 -1942 -19283746 -19031985 -19011989 -18091986 -17111985 -16051988 -15071987 -145236 -14081985 -132456 -13071984 -1231 -12081985 -1201 -11021985 -10071988 -09021988 -05061990 -02051972 -02041978 -02031983 -01091985 -01031984 -010191 -01012009 -yamahar1 -wormix -whistler -wertyu -warez -vjqgfhjkm -versace -universa -taco -sugar1 -strawber -stacie -sprinter -spencer1 -sonyfuck -smokey1 -slimshady -skibum -series -screamer -sales -roswell -roses -report -rampage -qwedsa -q11111 -program -Princess -petrova -patrol -papito -papillon -paco -oooooooo -mother1 -mick -Maverick -marcius2 -magneto -macman -luck -lalakers -lakeside -krolik -kings -kille -kernel -kent -junior1 -jules -jermaine -jaguars -honeybee -hola -highlander -helper -hejsan -hate -hardone -gustavo -grinch -gratis -goth -glamour -ghbywtccf -ghbdtn123 -elefant -earthlink -draven -dmitriy -dkflbr -dimples -cygnusx1 -cold -cococo -clyde -cleopatr -choke -chelse -cecile -casper1 -carnival -cardiff -buddy123 -bruce1 -bootys -bookie -birddog -bigbob -bestbuy -assasin -arkansas -anastasi -alberta -addict -acmilan -7896321 -30081984 -258963 -25101988 -23051985 -23041986 -23021989 -22121987 -22091988 -22071987 -22021988 -2006 -20052005 -19051987 -15041988 -15011985 -14021990 -14011986 -13051987 -13011988 -13011987 -12345s -12061988 -12041988 -12041986 -11111q -11071988 -11031988 -10081989 -08081986 -07071990 -07071977 -05071984 -04041983 -03021986 -02091988 -02081976 -02051977 -02031978 -01071987 -01041987 -01011976 -zack -zachary1 -yoyoma -wrestler -weston -wealth -wallet -vjkjrj -vendetta -twiggy -twelve -turnip -tribal -tommie -tkbpfdtnf -thecrow -test12 -terminat -telephone -synergy -style -spud -smackdow -slammer -sexgod -seabee -schalke -sanford -sandrine -salope -rusty2 -right -repair -referee -ratman -radar -qwert40 -qwe123qwe -prozac -portal -polish -Patrick -passes -otis -oreo -option -opendoor -nuclear -navy -nautilus -nancy1 -mustang6 -murzik -mopar -monty1 -Misfit99 -mental -medved -marseill -magpies -magellan -limited -Letmein1 -lemmein -leedsutd -larissa -kikiki -jumbo -jonny -jamess -jackass1 -install -hounddog -holes -hetfield -heidi1 -harlem -gymnast -gtnhjdbx -godlike -glow -gideon -ghhh47hj7649 -flip -flame -fkbyjxrf -fenris -excite -espresso -ernesto -dontknow -dogpound -dinner -diablo2 -dejavu -conan -complete -cole -chocha -chips -chevys -cayman -breanna -borders -blue32 -blanco -bismillah -biker -bennie -benito -azazel -ashle -arianna -argentin -antonia -alanis -advent -acura -858585 -4040 -333444 -30041985 -29071985 -29061990 -27071987 -27061985 -27041990 -26031990 -24031988 -23051990 -2211 -22011986 -21061986 -20121989 -20092009 -20091986 -20081991 -20041988 -20041986 -1qwerty -19671967 -1950 -19121989 -19061990 -18101987 -18051988 -18041986 -18021984 -17101986 -17061989 -17041991 -16021990 -15071988 -15071986 -14101987 -135798642 -13061987 -1234zxcv -12321 -1214 -12071989 -1129 -11121985 -11061991 -10121987 -101101 -10101985 -10031987 -100200 -09041987 -09031988 -06041988 -05071988 -03081989 -02071985 -02071975 -0123456 -01051989 -01041992 -01041990 -zarina -woodie -whiteboy -white1 -waterboy -volkov -vlad -virus -vikings1 -viewsoni -vbkfirf -trans -terefon -swedish -squeak -spanner -spanker -sixpack -seymour -sexxx -serpent -samira -roma -rogue -robocop -robins -real -Qwerty1 -qazxcv -q2w3e4 -punch -pinky1 -perry -peppe -penguin1 -Password123 -pain -optimist -onion -noway -nomad -nine -morton -moonshin -money12 -modern -mcdonald -mario1 -maple -loveya -love1 -loretta -lookout -loki -lllll -llamas -limewire -konstantin -k.lvbkf -keisha -jones1 -jonathon -johndoe -johncena -john123 -janelle -intercourse -hugo -hopkins -harddick -glasgow -gladiato -gambler -galant -gagged -fortress -factory -expert -emperor -eight -django -dinara -devo -daniels -crusty -cowgirl -clutch -clarissa -cevthrb -ccccccc -capetown -candy1 -camero -camaross -callisto -butters -bigpoppa -bigones -bigdawg -best -beater -asgard -angelus -amigos -amand -alexandre -9999999999 -8989 -875421 -30011985 -29051985 -2626 -26061985 -25111987 -25071990 -22081986 -22061989 -21061985 -20082008 -20021988 -1a2s3d -19981998 -16051985 -15111988 -15051985 -15021990 -147896 -14041988 -123567 -12345qwerty -12121988 -12051990 -12051986 -12041990 -11091989 -11051986 -11051984 -1008 -10061986 -0815 -06081987 -06021987 -04041990 -02081981 -02061977 -02041977 -02031975 -01121987 -01061988 -01031986 -01021989 -01021988 -wolfpac -wert -vienna -venture -vehpbr -vampir -university -tuna -trucking -trip -trees -transfer -tower -tophat -tomahawk -timosha -timeout -tenchi -tabasco -sunny1 -suckmydick -suburban -stratfor -steaua -spiral -simsim -shadow12 -screw -schmidt -rough -rockie -reilly -reggae -quebec -private1 -printing -pentagon -pearson -peachy -notebook -noname -nokian73 -myrtle -munch -moron -matthias -mariya -marijuan -mandrake -mamacita -malice -links -lekker -lback -larkin -ksusha -kkkkk -kestrel -kayleigh -inter -insight -hotgirls -hoops -hellokitty -hallo123 -gotmilk -googoo -funstuff -fredrick -firefigh -finland -fanny -eggplant -eating -dogwood -doggies -dfktynby -derparol -data -damon -cvthnm -cuervo -coming -clock -cleopatra -clarke -cheddar -cbr900rr -carroll -canucks -buste -bukkake -boyboy -bowman -bimbo -bighead -bball -barselona -aspen -asdqwe123 -around -aries -americ -almighty -adgjmp -addison -absolutely -aaasss -4ever -357951 -29061989 -28051987 -27081986 -25061985 -25011986 -24091986 -24061988 -24031990 -21081987 -21041992 -20031991 -2001112 -19061985 -18111987 -18021988 -17071989 -17031987 -16051990 -15021986 -14031988 -14021987 -14011989 -1220 -1205 -120120 -111999 -111777 -1115 -1114 -11011990 -1027 -10011983 -09021989 -07051990 -06051986 -05091988 -05081988 -04061986 -04041985 -03041980 -02101976 -02071976 -02061976 -02011975 -01031983 -zasada -wyoming -wendy1 -washingt -warrior1 -vickie -vader1 -uuuuuu -username -tupac -Trustno1 -tinkerbe -suckdick -streets -strap -storm1 -stinker -sterva -southpaw -solaris -sloppy -sexylady -sandie -roofer -rocknrol -rico -rfhnjirf -QWERTY -qqqqq1 -punker +speed +seventeen +senior +scottie +sam +ryan +rogers +rhonda progress -platon -Phoenix -Phoeni -peeper -pastor -paolo -page -obsidian -nirvana1 -nineinch -nbvjatq -navigator -native -money123 -modelsne -minimoni -millenium -max333 -maveric -matthe -marriage -marquis -markie -marines1 -marijuana -margie -little1 -lfybbk -klizma -kimkim -kfgjxrf -joshu -jktxrf -jennaj -irishka -irene -ilove -hunte -htubcnhfwbz -hottest -heinrich -happy2 -hanson -handball -greedy -goodie -golfer1 -gocubs -gerrard -gabber -fktyrf -facebook -eskimo -elway7 -dylan1 -dominion -domingo -dogbone -default -darkangel -cumslut -cumcum -cricket1 -coral -coors +polska +plastic +pinky +muhammad +medusa +maryland +married +lololo +login +lillian +leanne +knicks +jewels +hithere +giraffe +gillian +frozen +frogger +foxtrot +evergreen +emilio +duchess +dragoon +devil +deanna +daughter +daemon +command +claudio +clarinet +chucky +chuckles +chloe +carlton +beverly +beethoven +beach +babies +arlene +anakin +almighty +aaaaaaaaaa +9876543210 +1qaz1qaz +1313 +wilbur +waterfall +tttttt +tina +theking +suckit +sparta +sneakers +smelly +saratoga +root +reebok +raquel +quantum +qawsedrf +qawsed +motocross +maxmax +majestic +kingfish +kasper +japanese +integra +hhhhhh +help +harper +graphics +golf +flounder +erika +dundee +daphne +dance +corinne +coltrane chris123 -charon -challeng -canuck -call -calibra -buceta -bubba123 -bricks -bozo -blues1 -bluejays -berry -beech -awful -april1 -antonina +checkers +carbon +brandi +boxing +better +barbados +augustus +angelika +12345qwert +washburn +veritas +tottenham +tempest +survivor +strange +stanford +spanish +soulmate +snapper +shawn +robert1 +rasputin +rambo +rachael +queenie +pallmall +overkill +nimrod +mustard +mittens +medina +meatloaf +maureen +lowrider +katarina +ilovegod +heather1 +hamburg +hallo123 +grandpa +gogogo +giuseppe +georgie +fingers +europe +enrique +eastwood +duke +dominion +destroyer +dawson +chiquita +chipmunk +castillo +bugger +buffy +bobbie +berkeley +beast +antony +alexandria +9999 +2000 +121314 +1122334455 +1029384756 +zander +yasmin +world +trebor +toledo +thinking +tarheels +skiing +simona +sheldon +shanti +seminole +select +rookie +radiohead +priscilla +pornstar +platypus +peacock +nirvana1 +mephisto +marvel +mama +magnus +lancaster +knowledge +johnjohn +hubert +hackers +grant +gameover +fuckface +david123 +darklord +cutiepie +create +contact +company +carnival +candyman +cancel +camper +booker +blowfish +black1 +bigboss +bender +alien +active +abc1234 +zidane +wright +working +wedding +vortex +ursula +twisted +terry +ssssssss +squash +sponge +snowboard +smoking +shasta +shadows +seeker +sausage +sandwich +sailboat +rupert +romano +ripper +rebel +rancid +pudding +prophet +powder +philly +olivier +nutmeg +mandarin +knuckles +jimbob +jasmine1 +japan +helene +hardrock +greece +gold +forum +floppy +elwood +dominik +dimitri +daredevil +bristol +boomboom +benedict +babyface +anders +albatros +963852741 +565656 +323232 +262626 +whynot +whisky +valentino +trident +theboss +tanya +sprinter +soccer1 +shocker +shakira +scream +sammy1 +samara +salvation +rolltide +rodriguez +r2d2c3po +qwer +poetry +plasma +password12 +pancake +mustangs +moonshine +missouri +minimum +mikey +meridian +melina +meatball +marino +mango +mandy +malaysia +kinder +killbill +justin1 +jason1 +illinois +hottie +gringo +green1 +gonzalez +georgina +gargoyle +flores +evangelion +engine +emilie +disaster +depeche +daniel1 +coolman +compton +complete +coco +claymore +cheesecake +chainsaw +cat +cabbage +bluebell +blake +98765432 +yvette +wolfman +wishbone +warhammer +viewsonic +vampires +uranus +thunderbird +tammy +susanne +smashing +sales +sabbath +rrrrrr +rhiannon +reagan +rachelle +playtime +petunia +offspring +octopus +marius +marcia +marcella +maggot +lonestar +lawyer +jenifer +hooker +heritage +hehehe +hayabusa +harvard +freestyle +forward +forsaken +ferrari1 +fatman +emperor +elvira +dusty +double +darius +cypress +cruise +crash +china +charley +challenger +carole +beer +beanie +battery +backdoor +asshole1 +angelus +4321 +22222 +147896325 +11235813 +yosemite +yogibear +xxxxx +wolf +venus +user +talisman +taekwondo +syracuse +supersonic +scully +sasuke +redline +red +randolph +ramones +raistlin +preacher +peyton +peugeot +patty +party +papa +orchid +musica +millions +metallic +max +matador +marcos +mailman +madison1 +ludwig +lucy +losangeles +loretta +lazarus +kevin1 +isaac +indians +iloveme +hewlett +hernandez +hayley +gunners +girls +franky +flight +eternal +eeyore +dontknow +coolcool +charisma +cessna +bigbird +xanadu +werner +wednesday +village +topper +susana +starwars1 +start +sonic +sinister +sharky +scout +scotch +scanner +salomon +roman +program +polo +pistol +paulina +passpass +pancho +outside +open +mohammad +mcdonald +mayhem +laurent +lambda +kodiak +jacques +hilary +helen +goldeneye +geheim +frontier +francesca +flipflop +fisherman +famous +fallout +eraser +emilia +eggplant +diego +deejay +dannyboy +daniella +cosmic +conner +coleman +chrysler +catch22 +cameron1 +cambridge +buckshot +bounty +arkansas +archangel +america1 +12345679 +zorro +yomama +xxx +wutang +woohoo +walrus +vermont +twins +tom +tanker +sprint +skyler +shuttle +romantic +robotics +redalert +rebels +really +punkin +prayer +newpass +moocow +mine +mememe +megatron +marty +marker +mamapapa +mail +liquid +lilith +ladies +kristi +jojo +install +hyperion +honesty +hamburger +gundam +good +goliath +gladys +gadget +gabriel1 +fuckfuck +friendship +friendly +florida1 +first +expert +erica +eatshit +dreaming +dollars +doghouse +dog +disturbed +dianne +citizen +christin +celtics +candice +bubblegum +brigitte +banner +anubis +addicted +abcd123 +778899 +xxxxxxx +xander +valley +underworld +slacker +shane +shadow12 +rosie +presto +porkchop +pierce +passat +negative +mistress +melissa1 +massimo +living +letter +lance +jethro +jermaine +james007 +impact +hanson +great +garage +gabriella +francine +fletch +everest +dumbass +dookie +deskjet +delphine +cyclops +crystal1 +computers +common +chestnut +capital +booster +blood +blah +baseball1 +barber +auckland +attack +arturo +alfredo +aaa111 +321654987 +191919 +writer +wanderer +virtual +venice +vancouver +tomahawk +toffee +thanatos +tango +syncmaster +snow +snoopdog +skinny +sinbad +sassy +sanchez +roderick +ripple +princesa +porno +popopo +poodle +poncho +pentagon +paula +nathaniel +money123 +millenium +mildred +mighty +mechanic +liverpool1 +lesbian +kenshin +julien +joejoe +greg +francesco +fishes +europa +esmeralda +demons +darrell +dante +creature +cornwall +chadwick +celeron +carpediem +camila +calendar +breeze +bottom +blue123 +betty +barry +auburn +assass +ariel antares another -andrea1 -amore -alena -aileen -a1234 -996633 -556677 -5329 -5201314 -3006 -28051986 -28021985 -27031989 -26021987 -25101989 -25061986 -25041985 -25011985 -24061987 -23021985 -23011985 -223322 -22121986 -22121983 -22081983 -22071989 -22061987 -22061941 -22041986 -22021985 -21021985 -2007 -20031988 -1qaz -199999 -19101990 -19071988 -19071986 -18061985 -18051990 -17071985 -16111990 -16061986 -16011989 -15081991 -15051987 -14071987 -13031986 -123qwer -1235789 -123459 -1227 -1226 -12101988 -12081984 -12071987 -1200 -11121987 -11081987 -11071985 -11011991 -1101 -1004 -08071987 -08061987 -05061986 -04061991 -03111987 -03071987 -02091976 -02081979 -02041976 -02031973 -02021991 -02021980 -02021971 -zouzou -yaya -wxcvbn -wolfen -wives -wingnut -whatwhat -Welcome1 -wanking -VQsaBLPzLa -truth -tracer -trace -theforce -terrell -sylveste -susanna +airbus +abdullah +Michael +112358 +zodiac +xbox360 +wayne +wassup +video +vendetta +vector +tyrone +twenty +timmy +telecom +switch +supervisor stephane -stephan -spoons -spence -sixty -sheepdog -services -sawyer -sandr -saigon -rudolf -rodeo -roadrunner -rimmer -ricard -republic -redskin -Ranger -ranch -proton -post -pigpen -peggy -paris1 -paramedi -ou8123 -nevets -nazgul -mizzou -midnite -metroid -Matthew -masterbate -margarit -loser1 -lolol -lloyd -kronos -kiteboy -junk -joyce -jomama -joemama -ilikepie -hung -homework -hattrick -hardball -guido -goodgirl -globus -funky -friendster -flipflop -flicks -fender1 -falcon1 -f00tball -evolutio -dukeduke -disco -devon -derf -decker -davies -cucumber -cnfybckfd -clifton -chiquita -castillo -cars -capecod -cafc91 -brown1 -brand -bomb -boater -bledsoe -bigdicks -bbbbbbb -barley -barfly -ballet -azzer -azert -asians -angelic -ambers -alcohol -6996 -5424 -393939 -31121990 -30121987 -29121987 -29111989 -29081990 -29081985 -29051990 -27272727 -27091985 -27031987 -26031987 -26031984 -24051990 -23061990 -22061990 -22041985 -22031991 -22021990 -21111985 -21041985 -20021986 -19071990 -19051986 -19011987 -17171717 -17061986 -17041987 -16101987 -16031990 -159357a -15091987 -15081988 -15071985 -15011986 -14101988 -14071988 -14051990 -14021983 -132465 -13111990 -12121987 -12121982 -12061986 -12011989 -11111987 -11081990 -10111986 -10031991 -09090909 -08051987 -08041986 -05051990 -04081987 -04051988 -03061987 -03031993 -03031988 -02101980 -02101977 -02091977 -02091975 -02061979 -02051975 -01081990 -01061987 -01011971 -wiseguy -weed420 -tosser -toriamos -toolbox -toocool -tomas -thedon -tender -taekwondo -starwar -start1 -sprout -sonyericsson -slimshad -skateboard -shonuf -shoes -sheep -shag -ring -riccardo -rfntymrf -redcar -qwe321 -qqqwww -proview -prospect -persona -penetration -peaches1 -peace1 -olympus -oberon -nokia6233 -nightwish -munich -morales -mone -mohawk -merlin1 -Mercedes -mega -maxwell1 -mash4077 -marcelo -mann -mad -macbeth -LOVE -loren -longer -lobo -leeds -lakewood -kurt -krokodil -kolbasa -kerstin -jenifer -hott -hello12 -hairball -gthcbr -grin -grandam -gotribe -ghbrjk -ggggggg -FUCKYOU -fuck69 -footjob -flasher -females -fellow -explore -evangelion -egghead -dudeman -doubled -doris -dolemite -dirty1 -devin -delmar -delfin -David -daddyo -cromwell -cowboy1 -closer -cheeky -ceasar -cassandr -camden -cabernet -burns -bugs -budweiser -boxcar -boulder -biggun -beloved -belmont -beezer -beaker -Batman -bastards -bahamut -azertyui -awnyce -auggie -aolsucks -allegro -963963 -852852 -515000 -45454545 -31011990 -29011987 -28071986 -28021986 -27051987 -27011988 -26051988 -26041991 -26041986 -25011993 -24121986 -24061992 -24021991 -24011990 -23051986 -23021988 -23011990 -21121986 -21111990 -21071989 -20071986 -20051985 -20011989 -1943 -19111987 -19091988 -18041990 -18021986 -18011986 -17101987 -17091987 -17021985 -17011990 -16061985 -1598753 -15051986 -14881488 -14121989 -14081988 -14071986 -13111984 -122112 -12121989 -12101985 -12051985 -111213 -11071986 -1103 -11011987 -10293847 -101112 -10081985 -10061987 -10041983 -0911 -07091982 -07081986 -06061987 -06041987 -06031983 -04091986 -03071986 -03051987 -03051986 -03031990 -03011987 -02101978 -02091973 -02081974 -02071977 -02071971 -0192837465 -01051988 -01051986 -01011973 -????? -zxcv123 -zxasqw -yyyy -yessir -wordup -wizards -werty -watford -Victoria -vauxhall -vancouve -tuscl -trailer -touching -tokiohotel -suslik -supernov -steffen -spider1 -speakers -spartan1 -sofia -signal -sigmachi -shen -sheeba -sexo -sambo -salami -roger1 -rocknroll -rockin -road -reserve -rated -rainyday -q123456789 -purpl -puppydog -power123 -poiuytre -pointer -pimping -phialpha -penthous -pavement -outside -odyssey -nthvbyfnjh -norbert -nnnnnnnn -mutant -Mustang -mulligan -mississippi -mingus -Merlin -magic32 -lonesome -liliana -lighting -lara -ksenia -koolaid -kolokol -klondike -kkkkkkk -kiwi -kazantip -junio -jewish -jajaja -jaime -jaeger -irving -ironmaiden -iriska -homemade -herewego -helmut -hatred -harald -gonzales -goldfing -gohome -gerbil -genesis1 -fyfnjkbq -freee -forgetit -foolish -flamengo -finally -favorite6 -exchange -enternow -emilio -eeeeeee -dougie -dodgers1 -deniro -delaware -deaths -darkange -commande -comein -cement -catcher -cashmone -burn -buffet -breaker -brandy1 -bordeaux -books -bongo -blue99 -blaine -birgit -billabon -benessere -banan -awesome1 -asdffdsa -archange -annmarie -ambrosia -ambrose -alleycat -all4one -alchemy -aceace -aaaaaaaaaa -777999 -43214321 -369258147 -31121988 -31121987 -30061987 -30011986 -2fast4u -29041985 -28121984 -28061986 -28041992 -28031982 -27111985 -27021991 -26111985 -26101986 -26091986 -26031986 -25021988 -24111990 -24101986 -24071987 -24011987 -23051991 -23051987 -23031987 -222777 -22071983 -22051986 -21101989 -21071987 -21051986 -20081986 -20061986 -20031986 -20021985 -20011988 -19641964 -19111986 -19101986 -19021990 -18051987 -18031991 -18021987 -16111982 -16011987 -15111984 -15091988 -15061988 -15031988 -15021983 -14021989 -14011988 -14011987 -12348765 -12345qaz -1234566 -12111990 -12091988 -12051989 -12051987 -12031988 -12021985 -12011985 -11111986 -11091984 -1109 -11071989 -1016 -10071985 -10061984 -10041990 -10031989 -10011988 -06071983 -05021988 -03041987 -02091982 -02091971 -02061974 -02051990 -02051979 -02011990 -01051990 -010390 -01021985 -youtube -yasmin -woodstoc -wonderful -wildone -widget -whiplash -ukraine -tyson1 -twinkie -trouble1 -treetop -tigers1 -their -testing1 -tarpon -tantra -summer69 -stickman -stafford -spooge -spliff -speedway -somerset -smoothie -siobhan -shuttle -shodan -SHADOW +skylar +simba selina -segblue2 -sebring -scheisse -Samantha -rrrr -roll -riders -revolution -redbone -reason -rasmus -randy1 -rainbows -pumper -pornking -point -ploppy -pimpdadd -payday -pasadena -p0o9i8u7 -opennow -nittany -newark -navyseal -nautica -monic -mikael -metall -Marlboro -manfred -macleod -luna -luca -longhair -lokiloki -lkjhgfds -lefty -lakers1 -kittys -killa -kenobi -karine -kamasutra -juliana -joseph1 -jenjen -jello -interne -houdini -gsxr1000 -grass -gotham -goodday -gianni -getting -gannibal -gamma -flower2 -fishon -Fabie -evgeniy -drums -dingo -daylight -dabomb -cornwall -cocksucker -climax -catnip -carebear -camber -butkus -bootsy -blue42 -auto -austin31 -auditt -ariel -alice1 -algebra -advance -adrenalin -888999 -789654123 -777333 -5Wr2i7H8 -4567 -3ip76k2 -32167 -31031987 -30111987 -30071986 -30061983 -30051989 -30041991 -28071987 -28051990 -28051985 -27041985 -26071987 -26061986 -26051986 -25121985 -25051985 -24081988 -24041988 -24031987 -24021988 -23skidoo -23121986 -23091987 -23071985 -23061992 -22111985 -22091986 -22081991 -22071990 -22061985 -21081985 -21071992 -21021987 -20101988 -20061984 -20051989 -20041990 -1Dragon -19091990 -19031987 -18121984 -18081988 -18061991 -18041991 -18011988 -17061991 -17021987 -16031988 -16021987 -15091989 -15081990 -15071983 -15041987 -14091990 -14081990 -14041992 -14041987 -14031989 -13081985 -13021987 -123qwert -12345qwer -12345abc -123456t -123456789m -1212121212 -12081983 -12021991 -111112 -11101986 -11081988 -11061989 -11041991 -11011989 -1018 -1015 -10121986 -10121985 -10101989 -10041991 -09091986 -09081988 -09051986 -08071988 -08011986 -07101987 -07071985 -0660 -06061985 -06011988 -05031991 -05021987 -04061984 -04051985 -02101973 -02061981 -02061972 -02041973 -02011979 -01101987 -01051985 -01021987 -workout -wonderboy -winter1 -wetter -werdna -vvvv -voyager1 -vagabond -trustme -toonarmy -timtim -Tigger -thrasher -terra -swoosh -supra -stigmata -stayout -status -square -sperma -smackdown -sixty9 -sexybabe -sergbest -senna -scuba1 -scrapper -samoht -sammy123 -salem -rugger -royalty -rivera -ringo -restart -reginald -readers -raleigh -rainbow1 -rage -prosper -pitch -pictures -petunia -peterbil -perfect1 -patrici -pantera1 -pancake +rockets +revolver +reggae +railroad +qwerty12345 +placebo +paloma +pablo p4ssw0rd -outback -norris -normandy -nevermore -needles -nathan1 -nataly -narnia -musical -mooney -michal -maxdog -MASTER -madmad -m123456 -lumina -luckyone -luciano -linkin -lillie -leigh -kirkland -kahlua -junkmail -Joshua -josephin -Jordan23 -johnson1 -jocelyn -jeannie -javelin -inlove -honor -holein1 -harbor -grisha -gina -gatit -futurama -firenze -fireblad -fellatio -esquire -errors -emmett -elvisp -drum -driller -dragonfl -dragon69 -dingle +monaco +minnesota +marlon +mariners +manuela +leather +killers +insert +iloveyou2 +ibanez +holyshit +hollow +hallo +freeze +freeway +freak +elisabeth +donnie +demo +database +celica +cathy +calypso +bumblebee +bruins +bobafett +bernardo +barkley +ballet +astrid +amethyst +albatross +advanced +addison +987456 +272727 +zxcvb +whistler +wellington +weezer +weaver +warlord +wagner +volley +vernon +trisha +trapper +susanna +suicide +starter +sphinx +smitty +slamdunk +sisters +sheffield +scrabble +roadkill +retard +realmadrid +randall +rainbows +queens +profile +postal +polopolo +obsidian +northern +mortal +message +mathias +magic1 +magenta +looser +looney +legacy +learning +kashmir +independent +impossible +husband +hailey +elements +electron +diane +derek davinci -crackers -corwin -compaq1 -collie -christa -checker -cartoons -buttercup -bungle -budgie -boomer1 -body -blue1234 -biit -bigguns -barry1 -audio -atticus -atlas -Anthony -angus1 -Anai -alisa -alex12 -aikman -abacab -951357 -7894 -4711 -321678 -31101987 -31051985 -30121986 -30091989 -30031992 -30031986 -30011987 -29061988 -29061985 -29031988 -28061988 -27061983 -27031986 -27021990 -26101987 -26071989 -26071986 -25081986 -25061987 -25051987 -25041991 -24101989 -24071991 -23111987 -23091986 -23051983 -23031986 -2222222222 -22121989 -22071991 -22051991 -22011985 -21121985 -21031985 -20121988 -20121986 -20061990 -20051987 -1q2q3q -1944 -19091983 -19061992 -1905 -19021991 -18121987 -18121983 -18111986 -16121986 -16091987 -16071991 -16071987 -15111989 -15031990 -14041986 -13121983 -13101987 -13091984 -13071990 -1245 -12345m -1234568 -123456789qwe -1234567899 -1234561 -1228 -12211221 -12121991 -12121986 -12101990 -12101984 -12091991 -1209 -12081988 -12071990 -12071988 -115599 -11111a -11041990 -1028 -10081990 -10081983 -10071990 -10061989 -10011992 -09111987 -09081985 -08121987 -08111984 -08101986 -08051989 -07091988 -07081987 -07071988 -07071984 -07071982 -07051987 -06031992 -05111986 -05051991 -05031990 -05011987 -04111988 -04061987 -04041987 -040404 -02081973 -02061978 -02031991 -02031990 -02011976 -01071984 -01041980 -01021992 -zaqwsxcde -yyyyyyyy -worthy -woowoo -wind -William -warhamme -walton -vodka -venom +customer +corrado +concord +comfort +cinder +chopin +chantal +budweiser +brisbane +bogart +baritone +balloon +badman +asd +armageddon +andrey +amigos +amarillo +alonso +algebra +alexandr +aerosmith +adriano +123457 +12301230 +windmill +wheels +westham +visual +vintage +vanhalen +telefon +tardis +surprise +stefano +starfire +speakers +snatch +smoker +shazam +seymour +satan +sandro +salome +safari +sadie +river +radio +postman +poppy +palace +oregon +odessa +noname +ncc1701e +nation +mustafa +music1 +mimosa +method +lucille +luciano +lifetime +lambert +kittykat +keller +gideon +funny +fredrick +fidelity +fabulous +everyday +eastern +dixie +dentist +daytona +davids +darlene +craig +coolness +concorde +clancy +chapman +catwoman +casablanca +browns +boris +blackhawk +belle +barrett +babybaby +atomic +aladdin +aaa +147147 +will +vodafone +traveler +trader +tractor +tara +summer1 +stoner +stimpy +southside +sarah1 +santa +renault +rainbow1 +radical +princess1 +primus +potatoes +polly +pipeline +philippe +peter1 +payton +patton +pathfinder +openup +nofear +nigeria +monterey +maxime +marsha +madden +lipstick +lesley +lakeside +krystal +kendra +kelly1 +kelley +juice +joey +jakarta +italian +internet1 +insanity +hustler +hughes +hotshot +hihihi +harvest +gaston +fishbone +emma +elite +diehard +destroy +daisy1 +curious +critter +chihuahua +channel +bordeaux +boeing +biohazard +beatriz +beamer +bacchus +alfonso +21122112 +159159 +wookie +windsurf +windsor +wanted +walnut +vinnie velocity -treble -tralala -tigercat -tarakan -sunlight -streaming -starr -sonysony -smart1 -skylark -sites -shower -sheldon -seneca -sedona -scamper -sand -sabrina1 -romantic -rockwell -rabbits -q1234567 +vagabond +torres +topsecret +thegame +temp +stretch +stereo +seamus +scratch +saskia +sahara +rescue +reloaded +redred +raindrop +prudence +professional +praise +power1 +pilgrim +pharmacy +peaceful +patrice +nnnnnn +musical +multimedia +montgomery +midget +marseille +marisa +marietta +luke +lotus +letmein2 +ladybird +kaitlyn +jenny1 +janet +irish +internal +hyundai +hitachi +havana +gigabyte +gameboy +fourteen +feather +everett +ernesto +egghead +dynasty +dolphin1 +davis +damnit +chambers +castro +bushido +bunghole +buckeyes +buckeye +brodie +breaker +bluefish +bleach +beowulf +bedford +because +bartman +apocalypse +aphrodite +adonis +5555555 +4815162342 +23232323 +1988 +1980 +12369874 +111222333 +111 +zerocool +yyyyyy +ytrewq +wrestler +vicky +tracy +tortoise +sysadmin +sunshine1 +subzero +starship +sonia +sean +sawyer +redwood +redhot +reason +qwerty123456 +qwerty11 puzzle -protect -poker1 -plato -plastics -pinnacle -peppers -pathetic -patch +primrose +politics +pluto +paranoia pancakes -ottawa -ooooo -offshore -octopus -nounours -nokia1 -neville -ncc74656 -natasha1 -nastia -mynameis -motor -motocros -middle -met2002 -meow -meliss -medina -meadow -matty -masterp -manga -lucia -loose -linden -lhfrjy -letsdoit -leopold -lawson -larson -laddie -ladder -kristian -kittie -jughead -joecool -jimmys -iklo -honeys -hoffman -hiking -hello2 -heels -harrier -hansol -haley -granada -gofast -fyutkjxtr -frogs -francisc -four -fields -farm -faith1 -fabio -dreamcas -dragster -doggy1 -dirt -dicky +overload +opensesame +okokok +nikola +nevermore +moscow +melbourne +matthews +marriage +mallory +magdalena +macaroni +lespaul +lemons +laurel +kyle +kittens +kiss +kicker +justme +juanita +jonathon +jocelyn +jacqueline +jackjack +infinite +hope +heinrich +hansolo +hacked +greens +gratis +graduate +goodness +godspeed +feedback +domingo +dieter +cougars +corolla +cornelia +corleone +choochoo +chinese +challenge +chairman +canon +butthole +buddy123 +brennan +bouncer +bossman +bonsai +bonkers +barracuda +azsxdcfv +andrew1 +alisha +accounting +505050 +445566 +420420 +1478963 +102938 +woofer +warner +volcano +tyler1 +trucks +toby +slider +sleeping +serious +remington +quicksilver +pringles +premier +power123 +paradigm +nickolas +navigator +nautilus +muscle +moreno +milkshake +miles +menace +master123 +massage +marshal +killer1 +kathy +kate +jonas +jane +jammer +gravity +gerrard +geneva +ganesh +frog +formula +feathers +facebook +enrico +dragon12 +deluxe +damage +cruiser +condom +cinema +brittney +bones +bazooka +aviation +avalanche +anthrax +airport +admiral +666999 +19841984 +123qweasdzxc +10203040 +wolfie +wildwood +whatsup +thrasher +summit +stunner +staples +speedway +sonny +songbird +sinatra +sickness +shannon1 +senator +screamer +savior +sascha +samantha1 +riverside +riley +renata +redbull +rabbits +quentin +profit +princeton +powell +popper +poopie +pooper +peters +pepito +oscar1 +olympia +oldman +nopass +noelle +monster1 +milena +micron +mauricio +mattie +massive +marika +manhattan +manfred +love1234 +lithium +labtec +keegan +joyce +jojojo +jennifer1 +jeffery +jayson +janelle +intel +indonesia +iceberg +ibrahim +hungry +hell +hawk +hammond +filter +epsilon +email +elena +electra +doreen +dimples +devil666 +deacon +dandan +creator +cosmo +cooking +clipper +circle +chimera +caveman +bugsbunny +budlight +bowler +bottle +birdman +benfica +barton +android +ambrosia +adrianna +909090 +2222222 +2001 +zxcvbnm1 +zero +windows1 +wheeler +waffle +verona +ventura +toulouse +toto +topcat +tazmania +static +stacy +speedo +spears +spaghetti +slinky +slapshot +reptile +rebekah +pigeon +panties +monty +mitch +ministry +miami +mental +matteo +mathilde +magpie +lighting +lady +john123 +jenkins +huskers +houses +helsinki +heidi +hanuman +girlfriend +gateway1 +garnet +fussball +frisbee +frederik +flexible +finland +festival +federal +familia +eloise +dynamic +dwight +dungeon +doggy +dickens destiny1 -deputy -delpiero -dbnfkbr -dakota1 -daisydog -cyprus -cutie -cupoi -colonial -colin -clovis -cirrus -chewy -chessie -chelle -caster +daydream +coventry +constant +connection +charles1 +carpet +bicycle +becky +babyboy +area51 +angeline +alucard +a123456789 +99999 +1234321 +111111111 +zebra +woodland +wasser +vipers +trust +trains +theatre +tabasco +swinger +string +steffi +spectre +sooner +skinhead +signature +shutup +sandrine +sam123 +sacred +rufus +rockford +quartz +possum +pinball +nipper +nina +nichole +namaste +morton +merchant +ketchup +kenwood +jazz +hentai +hanna +haggis +greatest +grapes +fuckers +fritz +ford +everlast +eunice +espresso +encore +ellen +elizabet +eeeeee +drifter +dragon123 +dolly +dddddddd +darkman +community +chrome +chouchou +chiefs +charlton +champs +champagne +carlitos +camel +brad +boobs +bobbob +blueblue +beaner +beaches +balls +baldwin +awesome1 +athens +aspirine +anne +allstar +alcohol +963852 +77777 +3333 +1985 +12345abc +123098 +zaphod +weed +vvvvvv +vienna +thelma +theend +tekken +technology +stronger +stephan +starbuck +spotty +skeleton +second +scissors +rosario +rolling +rodman +rocks +reginald +redeemer +ralph +raleigh +polarbear +pheonix +pepsi1 +normandy +night +never +minime +mellow +media +maryann +mariam +manning +manman +luckydog +liliana +leon +laserjet +just4fun +johann +jarvis +impulse +idiot +hilton +heroes +haha +greenbay +granada +graffiti +giorgio +galileo +fiction +fantastic +durango +doughboy +dortmund +donuts +dodge +delphi +delilah +dazzle +daniele +dan +crunch +cheers +carrera +carnage +carmel +building +bruiser +bombay +blue22 +bennie +bbbbbbbb +bassman +banzai +armani +annabelle +annabell +alex123 +alchemist +absolut +aaliyah +223344 +2112 +zimbabwe +worship +wisconsin +winchester +weekend +tunafish +truman +tolkien +thisisit +sticks +stafford +sputnik +spalding +sometimes +solitude +sofia +snooker +sex +sancho +robotech +rich +reader +rainbow6 +qazwsx12 +pulsar +protect +pooppoop +pointer +oxygen +onlyme +officer +minister +man +lynn +lola +lilly +leonidas +lemon +kungfu +kirkland +jarrett +integral +incognito +ilovesex +ignatius +honda1 +helper +heavenly +gustav +goblue +gggggggg +ferdinand +female +faggot +exchange +droopy +dogman +dark +combat +carroll +busted +bulldog1 +bravo +blackdog +bearbear +bacon +alan +911911 +6666 +1990 +123454321 +zaqwsx +wings +winfield +westlife +turtles +tricia +trainer +thriller +tarheel +synergy +summertime +spartans +snapple +smiths +skidoo +shell +sausages +salvatore +salamander +romans +printing +premium +poster +photos +palmtree +opendoor +ocean +obiwan +normal +nestor +mypass +mybaby +mosquito +milkyway +mexican +mcdonalds +maynard +mason +magnet +lucky7 +laughter +klondike +kitchen +kingsley +kings +kaylee +josiah +joe +jesus123 +ivan +irving +invisible +humphrey +hillside +hattrick +hampton +hammerhead +grinch +function +forgotten +fighting +excellent +estelle +esteban +easton +delaware +darthvader +dale +costello +corey +colonel +cisco +chief +catalyst +carla +cardinals +caprice +bucket +bolton +bobmarley +blanca +bermuda +batman1 +babylove +assholes +annika +andersen +amerika +alexande +Daniel +989898 +369369 +19891989 +1234asdf +xyz123 +xxxx +whiplash +wasted +wasabi +wally +visitor +usa123 +traffic +toaster +tiffany1 +tiburon +teddy1 +steele +sooners +solutions +smart +smallville +slimshady +sixteen +sergei +sammy123 +saint +romero +rockwell +robinhood +ripley +reddevil +qwerty7 +piano +pelican +pastor +palermo +ophelia +odyssey +nuclear +nipple +nana +mouse1 +morgana +mommy +mimi +maxwell1 +margie +major +mailbox +madeleine +louisa +lolo +loaded +life +ledzep +latino +larisa +lansing +kahuna +jordan1 +harriet +grendel +grayson +gordon24 +glendale +giovanna +frodo +frisco +foxylady +fortress +ficken +favorite +endless +doughnut +domain +direct +delaney +daniels +cutter +cristal +coucou +comanche +clark +cheshire +cherries +cheddar +cheater +century +catarina +butch +brett +bob123 +bigger +bertrand +benoit +barefoot +aubrey +armada +arabella +alligator +alissa +advance +aaa123 +1qaz2wsx3edc +zxczxc +ziggy +vanguard +titan +swallow +super1 +stuttgart +stephen1 +square +skating +shampoo +rockon +rhapsody +renee +redwing +reckless +ramirez +puppet +pumpkin1 +problem +powerful +pooh +phone +pervert +partner +painting +othello +octavia +novell +nocturne +nickname +narnia +mynameis +mikemike +martin1 +maison +llllllll +limited +leighton +lacoste +koko +kkkkkkkk +kingfisher +juniper +jorge +jokers +johnston +jagger +jade +holidays +hohoho +highway +henderson +handyman +gregor +fuckoff1 +front242 +flamenco +escalade +doudou +doobie +dogdog +division +delfin +decker +custom +covenant +cornell +colors +circus +churchill +changes +chandra cannibal -candyass +bummer +boots +bobo +bobby1 +blanco +bird +bertie +bears +badminton +azsxdc +ashlee +annmarie +alexander1 +alcatraz +agatha +a1s2d3 +11112222 +wwwwwwww +wildcard +whitesox +vincent1 +unlock +tyson +tycoon +twiggy +trojans +thornton +thalia +temporary +survival +supernatural +sunny1 +sprocket +sony +sonata +somerset +smarty +skorpion +skinner +services +saxophone +sacrifice +rotten +romania +restless +renato +record +pumpkins +paprika +packer +operation +nosferatu +newpassword +moses +monkey123 +middle +michelle1 +michal +meathead +mankind +management +lucky123 +licorice +laser +language +kronos +kismet +julio +jean +jacob1 +jackass1 +irene +infiniti +icarus +horror +homers +groove +goose +goalie +generation +gary +gamecube +foolish +flanders +electro +edinburgh +duckie +disciple +diplomat +darryl +crescent +cowgirl +counterstrike +cocaine +cluster +clemson +chunky +chippy +cherie +catholic +caravan +capoeira +calculator +browning +biscuits +bikini +baker +ambrose +alexalex +P@ssw0rd +Jennifer +19861986 +123456abc +yousuck +winston1 +whitey +virus +virgil +violator +transam +train +torpedo +tinman +tangerine +super123 +straight +stalin +sporty +sorcerer +sidekick +shredder +schubert +savanna +sanjose +racecar +prestige +presley +peter123 +pasword +nonsense +news +naomi +mulligan +moneyman +misha +matchbox +mars +march +marcela +marble +marauder +losers +longhair +lisalisa +killme +kieran +kayleigh +kakashi +jayden +islander +india +homeboy +gunther +grasshopper +geraldine +genesis1 +generic +gardenia +gabriele +explore +everything +emanuel +edmonton +dwayne +downhill +digital1 +denali +defense +davide +dana +cromwell +corazon +chowchow +cats +catman +carebear +candy1 +burnout +boxer +bounce +bettyboop +benito +benben +beastie +beans +ass +ashley1 +363636 +1984 +161616 +wizards +walking +volcom +viktor +vanessa1 +twelve +terrapin +tennessee +tasha +swords +stockton +stitch +steph +spartacus +smoothie +shinobi +seahawks +russian +revelation +rebecca1 +rangers1 +qweqweqwe +qqqqqqq +puppydog +portal +popular +physics +pete +norbert +nipples +nimbus +nestle +milkman +midway +meghan +marigold +margot +malachi +louie +longbow +lion +krypton +krissy +info +hurley +homerun +hoffman +higgins +hansen +hacking +gregorio +gotcha +goldfinger +glamour +giggle +ghosts +gangbang +freaks +fowler +fischer +finance +dutchess +dirty +dean +dealer +daylight +dawn +constantine +colin +cobalt +clueless +cloud +clever +chilli +chaser +caution +catcat +capone +calamity +blaze +blanche +bigdick +beefcake +bayern +basil +banker +babe +aquarium +anathema +ambition +amanda1 +address +a12345678 +222333 +1986 +19821982 +wildlife +vince +undercover +truck +tribal +transit +today +timeout +snowbird +shaolin +shanna +serpent +secret1 +schneider +saffron +rosita +rain +qwert12345 +qwerqwer +prospect +porsche911 +pinhead +perkins +pendragon +north +nike +native +natalie1 +mutant +momo +mallard +lunatic +lol +lockdown +lkjhgfdsa +letsgo +lala +junebug +jose +jellyfish +jameson +italiano +irishman +inter +infamous +hydrogen +hooper +hippie +hellboy +hartford +hammers +guess +gryphon +goodyear +glacier +generals +garrison +galant +foxhound +entrance +eighteen +earth +drake +dimension +diamante +denis +daedalus +current +crack +colton +cocktail +champ +chameleon +celina +callum +caligula +borabora +bondage +bonanza +behemoth +becker +bass +bart +bangkok +bambino +balloons +bachelor +andrews +amelie +adeline +313131 +234567 +123698745 +xerxes +waterman +volvo +trenton +thomas1 +teenager +suckme +stumpy +stellar +spanking +south +soccer10 +sergeant +seashell +seahorse +scroll +scarecrow +ruben +royal +riffraff +rick +rapper +radar +prowler +privacy +pothead +possible +pittsburgh +pissoff +pinnacle +peachy +paulie +paper +optimus +oatmeal +nostromo +members +maximilian +marc +mantra +malone +malice +lulu +lord +letters +latitude +kevin123 +kellie +kamasutra +jehovah +jared +italy +invasion +hugo +houdini +hopkins +honey1 +hibiscus +heyhey +harman +hans +hallmark +granite +goodboy +glasses +glasgow +fuzzy +fuller +flyboy +firestorm +fernandez +envision +enjoy +engage +ellie +editor +ecuador +devon +desperado +dejavu +daddy1 +cody +cicero +charcoal +character +cardiff +canyon +candace +camels +caleb +bronze +bonjovi +blue1234 +bigguy +berger +aurelia +antelope +angus +alejandra +aircraft +abby +753159 +456852 +314159 +303030 +1978 +1969 +123456aa +123456123 +1234560 +west +viktoria +vectra +unlimited +tundra +transport +topher +stripper +stinker +stefania +spinner +spiders +snowwhite +smirnoff +silly +shearer +sexual +seraphim +sebastien +sample +ronaldo7 +rockman +rivers +reporter +redskin +razor +rayray +ramsey +ramses +raiders1 +plumber +peach +painkiller +numbers +nineteen +muppet +morena +monolith +moneys +moneymaker +mishka +messiah +memories +memorial +massacre +manila +lottie +leland +legends +lamborghini +kimber +josie +jimmie +jazzman +hussain +huskies +honduras +habibi +goofball +george1 +gareth +fullmoon +fraser +forever1 +fester +ethan +enter1 +engineering +elefante +eatme +duck +dragonballz +doorknob +dipstick +deadly +crusher +compact +commerce +cecile +carousel +callisto +calico +builder +brilliant +blubber +bettina +berenice +barbarian +banane +backup +augusta +asdfzxcv +ariane +angeles +alex1234 +alchemy +alberta +advent +Welcome1 +9999999 +343434 +336699 +332211 +1qa2ws3ed +19871987 +12345678a +123455 +zaq123 +wormwood +wood +weapon +watcher +volkswagen +tomas +tipper +tahiti +starstar +spiral +spidey +sonics +solaris +snuffy +shrimp +sheep +sheba +sexygirl +sephiroth +screen +schumacher +sasasa +samiam +salsa +rudolf +rosewood +roses +rochester +roadster +reload +rapunzel +putter +prisoner +prescott +pizza123 +phillies +phil +phantom1 +perfect1 +pasadena +papaya +orange1 +optimist +norway +nitram +nikolas +myrtle +monkeyboy +molson +mikael +metropolis +master12 +marquis +luna +locked +larson +lakota +kimberley +killjoy +karine +junkmail +jingle +jigsaw +jenna +inspiron +hillary +hhhhhhhh +hellohello +griffith +greenwood +golfball +gator +gambler +fucku +forester +fergus +euphoria +england1 +edwin +discus +denmark +dell +death666 +cornelius +coolcat +constance +conquest +confirm +colt45 +clitoris +chips +chelsey +cesar +cartoons +buzzard +butcher +buckaroo +buck +bologna +bluejays +ben +angelic +analog +Alexander +789123 +787878 +1991 +1977 +123465 +winners +weenie +waiting +volunteer +violence +undead +ultra +tree +titties +testpass +terrence +temporal +tech +teamwork +tadpole +stevens +sport +spencer1 +soprano +social +skate +silverado +shipping +serendipity +saigon +roosters +retired +reflex +referee +redeye +prophecy +popcorn1 +playmate +pistons +paragon +panorama +p0o9i8u7 +noway +nonono +motion +mordor +meadow +marcopolo +manolo +magneto +luis +looker +lioness +lighter +leticia +landmark +kill +khalid +johnson1 +jess +jacobs +iverson3 +instinct +infected +illuminati +iceland +hunter1 +horace +honeydew +golfing +gilles +gabby +foundation +force +forbidden +floyd +flame +fidelio +esperanza +dogs +document +dharma +deutsch +deadline +dead +dahlia +dadada +crocodile +credit +cowboys1 +coolguy +climbing +choice +chicks +chamber +castor +cassius camping -cable -bynthytn +buddies +bubbles1 +briana +bremen +bluestar +birmingham +beretta +bathroom +bastian +barker +baltimore +balboa +anamaria +amber1 +aloha +88888 +33333 +258456 +25802580 +24682468 +123451 +yoyo +wildman +whiteboy +webber +vader +trinitron +topdog +titleist +tiberius +testing123 +talent +superhero +stoned +skydive +silvana +sienna +sidewinder +shitty +salami +ruby +rosemarie +rosalie +retarded +requiem +qqqqq +primavera +players +peppermint +palomino +outsider +oooooooo +musician +monarch +misfit +michelin +maria1 +mafia +macbeth +m +lynette +lowell +kimmie +june +juggernaut +ironmaiden +hyacinth +hamish +grease +goaway +gerbil +gavin +gatorade +fuzzball +fujitsu +feline +falling +everyone +dottie +dictionary +development +delirium +daisy123 +cyber +cutie +critical +cradle +corner +cordelia +collection +chivas +chiara +cat123 +carl +capitals +caliente +burning +bunnies +bunker +brent +bobdylan +blackrose +birdhouse +bighead +beta +bassoon +author +asparagus +anton +allegro +albino +Michelle +Jessica +898989 +654123 +545454 +1a2s3d4f +1982 +19781978 +wiggles +weston +walleye +voltaire +vodka +valiant +thedoors +test1 +tender +submarine +stress +stonewall +special1 +southpaw +soledad +soccer12 +slasher +simmons +season +scamper +sauron +sandy1 +sanctuary +s +ruthless +rugby +rivera +reuben +redstar +recall +reaction +rasta +rapture +racerx +quebec +qazwsxed +prometheus +portable +poisson +pizzas +pimp +pilot +perry +pepper1 +password11 +passcode +oyster +otto +omar +olive +official +newbie +neverland +mullet +morales +monsoon +mojo +misery +mindless +micro +masamune +leopold +lenny +lennox +legendary +lalalala +laddie +kirsty +kiki +kerstin +joel +jimmy1 +incredible +icecube +horatio +holloway +helios +heartless +hazard +harley1 +hairball +gollum +girl +genevieve +game +format +fireworks +eskimo +entropy +drew +doogie +dirtbike +dinner +dinamo +dilligaf +defiant +daewoo +cunt +crossfire +colette +clippers +chicago1 +cheeky +cheech +cayman +caldwell +butters +butt +bernadette +apricot +allan +aggies +agent007 +addict +adams +abcabc +321123 +282828 +19831983 +19801980 +19751975 +123000 +1010 +zzzzz +yellow1 +word +widget +waterpolo +warthog +warrior1 +vulcan +vertical +venture +timeless +thomson +thegreat +superuser +steve1 +steel +sssss +squall +spelling +source +someday +solo +snoop +slippery +silicon +shine +salman +rusty1 +russel +rumble +rrrrrrrr +roxy +rovers +robot +robocop +ricochet +reefer +redemption +reborn +raspberry +protocol +producer +priest +photo +penguin1 +patterson +p455w0rd +olivetti +oliveira +oicu812 +neville +mona +mnbvcx +meteor +metalica +mentor +melisa +mclaren +max123 +matter +martins +mannheim +mandingo +magellan +machines +lovebird +link +linden +leonie +lara +killing +karma +jubilee +jonathan1 +jason123 +inflames +important +idunno +heretic +helloworld +headache +hancock +hal9000 +godbless +glenn +giggles +gemstone +funky +fucked +ffffffff +fatass +emily1 +duster +danilo +danica +cyclones +cristiano +crazy1 +color +colonial +collie +claudius +citadel +chinook +cheeks +carver +burrito +bulgaria +brunette +bradshaw +bowser +boobie +blazers +bitter +beth +bastards +basset +basement +baron +baboon +baba +azertyuiop +astro +arcadia +applesauce +angelique +alvin +alice1 +albany +admin1 +acapulco +abacus +Charlie +786786 +25252525 +1987 +123789456 +123456987 +12312312 +zachary1 +yourmom +yingyang +xtreme +workshop +work +what +vicious +ulysses +twinkie +trueblue +transformers +thierry +tarantula +sycamore +sunderland +stripes +stigmata +sticky +stargazer +staff +shopper +seneca +sabrina1 +rollin +riccardo +qazxswedc +playboy1 +peppers +password01 +override +ontario +nomore +nighthawk +nickel +napoli +music123 +motdepasse +mortgage +moment +mickeymouse +meandyou +maxim +mantis +macdaddy +lovebug +lorelei +listen +leicester +laura1 +knockers +kisskiss +keenan +katrin +jjjjjjjj +invader +hysteria +honest +hilltop +gonzo +godlike +god +gallery +frank1 +forgiven +factory +evanescence +eugenia +ernie +equinox +dutch +distance +destruction +denied +cyrus +cosworth +cortez +console +coke +coconuts +clifton +client +cash +carlo +carlisle +buster1 +burgess +breakfast +booty +blinky +blink +blaine +bitch1 +bengals +astros +aspen +asgard +asdfjkl; +antivirus +aikido +66666 +31415926 +21212121 +123321123 +100000 +yokohama +worker +unforgiven +triple +tommy123 +tictac +therapy +surrender +spikey +spiker +spike1 +smithy +sixers +shoes +shiner +sheriff +sheepdog +shawna +seinfeld +sayang +sabotage +ronaldinho +richter +redfish +reddragon +rampage +prissy +pressure +pinetree +peggy +pavement +oriental +offshore +nutter +nice +newzealand +netscape +modern +misfits +michaels +meow +memorex +mathieu +mash4077 +mallorca +madagascar +licker +lawson +landon +kokomo +koala +kestrel +junkyard +johncena +jewish +jakejake +invincible +intern +indira +hawthorn +hawaiian +hannah1 +halifax +greyhound +greene +glenda +futbol +fresh +frenchie +flyaway +fleming +fishing1 +finally +ferris +fastball +elisha +doggies +desktop +dental +delight +deathrow +ddddddd +cocker +chilly +chat +casey1 +carpenter +calimero +calgary +broker +breakout +bootsie +bonito +black123 +bismarck +bigtime +belmont +barnes +ball +baggins +arrow +alone +alkaline +adrenalin +abbott +987987 +3333333 +123qwerty +000111 +zxcv1234 +walton +vaughn +tryagain +trent +thatcher +templar +stratus +status +stampede +small +sinned +silver1 +signal +shakespeare +selene +scheisse +sayonara +santacruz +sanity +rover +roswell +reverse +redbird +poppop +pompom +pollux +pokerface +passions +papers +option +olympus +oliver1 +notorious +nothing1 +norris +nicole1 +necromancer +nameless +mysterio +mylife +muslim +monkey12 +mitsubishi +millwall +millennium +megabyte +mccarthy +malina +magister +magick +maggie1 +madhouse +lopez +liverpoo +leviathan +latina +laetitia +kurt +kernel +kayla +karachi +joshua1 +joaquin +jennings +janina +jaime +holstein +henrik +hellraiser +head +harder +granger +freefall +focus +flawless +finish +emergency +edmund +ebenezer +dougie +divinity +delpiero +cyborg +cream +comedy +clovis +chewie +chewbacca +chastity +charlott +carlotta +camden +bunny1 +bumble +buchanan +bradley1 +bombers +blacks +best +bella1 +bell +behappy +battlefield +aventura +astral +ashanti +asdffdsa +arctic +anchor +academy +525252 +456654 +1979 +19741974 +090909 +zildjian +zaqxsw +wyoming +wingman +welcome123 +wargames +vvvvvvvv +viper1 +unicorns +toilet +timberland +things +tenerife +tasmania +tania +symphony +sweet1 +superb +stolen +stan +sssssss +spoon +splendid +sonyvaio +snapshot +slick +sleeper +simon1 +shining +sherri +sensei +seagull +scott1 +schmidt +saunders +sarajevo +runaway +route66 +rockey +reverend +redfox +quattro +prototype +proton +pooter +polaroid +pixies +pixie +perfecto +passme +owen +nurse +nookie +nokia123 +nitro +nights +nebula +natasha1 +mystical +milan +melanie1 +material +mariner +mamamia +mamama +maddison +macross +lost +lloyd +landlord +kristal +kris +korean +kenzie +kaktus +juvenile +instant +hybrid +horny +hollie +hawkins +harry1 +gypsy +gunnar +goodwill +goldwing +gilberto +gandalf1 +fuckthis +froggie +frisky +flossy +flapjack +flamengo +finnegan +fabienne +error +erection +defence +danny1 +dammit +conway +content +concept +climber +clemente +christophe +christa +charon +cereal +caterpillar +caterina +capetown +cancan +bull +brains +bracken +bolero +biggles +berserk +bacardi +austria +austin316 +antonio1 +angelito +amigo +alvaro +accounts +abstract +Robert +19911991 +19761976 +1976 +020202 +01234567 +zxcvbnm123 +wilhelm +warwick +walmart +walkman +vincenzo +vesper +turnip +townsend +tonight +thought +theater +technical +tazman +stoney +soccer11 +smithers +smiling +slugger +slash +skyblue +shooting +shitshit +shadow123 +senators +schwarz +sairam +sacramento +royals +rowena +router +redbaron +raven1 +qwert1 +proview +programmer +prison +present +porn +poipoi +percival +painless +ou812 +oberon +oasis +northstar +newspaper +myfamily +mongolia +miroslav +marbles +macarena +lumberjack +lee +landrover +lakewood +klingon +kkkkkkk +killer12 +keisha +kareem +incoming +immanuel +images +hometown +homeless +hockey1 +hillbilly +helmet +hellothere +gunter +guillaume +goodnight +giulia +giordano +gina +genocide +gabber +funtime +fiona +fanatic +ezekiel +etoile +enforcer +eight +eduard +drizzt +dreamcast +doodles +dispatch +developer +crayon +corsair +copenhagen +codename +clowns +clockwork +class +clarke +chick +cccccccc +caramelo +callaway +calculus +buzz +bugatti +bronson +brian123 +boom +blessed1 +bismark +berry +benjamin1 +bartender +bambi +attorney +asteroid +arianna +ariadne +aramis +angeleyes +ananda +almond +alfalfa +alcatel +akira +academia +aa +a1b2c3d4e5 +784512 +1975 +1972 +12131415 +yamahar1 +wilder +whore +wealth +warehouse +violeta +versace +venom +tuning +tucson +tricolor +tracer +tim +thecure +terrance +summer99 +stocks +stirling +stamford +stairway +spooner +specialist +sorrow +soldiers +slater +singing +showme +shitface +scorpio1 +rotterdam +ross +rollins +ringo +right +records +real +rainer +quest +principe +pizzahut +pizza1 +pepperoni +patricio +passwerd +pacers +orient +orgasm +orchard +okinawa +oilers +nigga +nautica +nathan1 +nasty +mulberry +muffins +mistral +melrose +meister +meagan +maximo +manny +malcom +luscious +lifeline +legoland +leelee +leaves +kirby +kickflip +kennwort +kathrine +katelyn +junk +josefina +johnnie +johnathan +jimbo +jesus777 +hornets +hopeful +hollister +hellsing +gofish +gianni +getout +funfun +frogman +fragile +fishman +excelsior +easy +drummond +disneyland +deutschland +delldell +cupcakes +crybaby +cottage +corina +complex +claudine +ciaociao +christia +checkmate +checker +check +centurion +catcher +cashmere +carthage +bosco +bookmark +bobobo +boarder +bluejay +bartlett +b +armand +armagedon +animation +alphonse +alessandra +Benjamin +5201314 +51505150 +424242 +2004 +1992 +192837465 +yumyum +yasmine +xxxxxxxxxx +xxx123 +woodside +winona +willem +willard +werder +water1 +warcraft3 +vengeance +vaseline +trinity1 +toxicity +tommyboy +ticktock +thor +terence +teachers +submit +strategy +sting +stephens +spiffy +spanner +snowdrop +snappy +smeghead +shutdown +sexysexy +script +santafe +rider +riddle +rachel1 +prosper +princesse +pretender +popsicle +polish +pinkie +piggy +philadelphia +petersen +pearson +pasta +password3 +pandas +oscar123 +orioles +nova +niners +nelly +natali +moonstone +meggie +mckenna +masterkey +maryanne +manowar +magicman +kittie +kingking +kerry +justus +juan +jonjon +jeannie +jarrod +identity +icehouse +humble +hannover +greedy +goofy +glorious +gizmo1 +ginger1 +gfhjkm +gathering +gardner +furious +forgetit +fishtank +finalfantasy +fifteen +fetish +fernandes +epiphone +elevator +elegance +drumline +doodoo +devilman +delta1 +delivery +cross +cooter +compass +chuckie +chrissie +carnaval +carlito +caffeine byebye buzzer -burnout -burner -bumbum -bumble -briggs -brest -boyz -bowtie -bootsie -bmwbmw -blanche -blanca -bigbooty -baylor -base -azertyuiop -austria -asd222 -armando -ariane -amstel -amethyst -airman -afrika -adelina -acidburn -7734 -741741 -66613666 -44332211 -31071990 -31051993 -30051987 -30011990 -29091987 -29061986 -29011982 -2828 -28101986 -28081990 -28081986 -28011988 -27111989 -27031992 -27021992 -26081986 -25081985 -25031991 -25031983 -24121987 -24091991 -23111989 -23091989 -23091985 -23061989 -22091991 -22071985 -22071984 -22061984 -22051989 -22051987 -22031986 -22011992 -21061988 -21031984 -20071988 -20061983 -20041985 -1qazzaq1 -1qazxsw23edc -19991999 -19061991 -18101985 -18051989 -18031988 -18021992 -18011985 -17051990 -17051989 -17051987 -17021989 -16091988 -16081986 -16061988 -16061987 -15121987 -15091985 -15081986 -15061985 -15011983 -14101986 -1357911 -13071987 -13061985 -13021985 -123456qqq -123456789d -1234509876 -12131213 -12111991 -12111985 -12081990 -12081987 -12071991 -1207 -120689 -1120 -11071987 -11051988 -1104 -11031983 -10091984 -10071989 -10071986 -10061985 -10051990 -10041987 -10031993 -10031990 -09091988 -09051987 -09041986 -08081990 -08081989 -08021990 -07101984 -07071989 -07041987 -07031989 -07021991 -06061981 -06021986 -05121990 -05061988 -05031987 -04071988 -04071986 -04041986 -03101991 -03091983 -03051988 -03041983 -03031992 -02081970 -02061971 -02051970 -02041972 -02031974 -02021978 -0202 -02011977 -01121990 -01091992 -01081992 -01081985 -01011972 -007bond -zapper -vipergts -vfntvfnbrf -vfndtq -tujhrf -tripleh -track -THOMAS -thierry -thebear +bukowski +brownies +bond +blue12 +bearcats +badboys +architect +ankara +amalia +albion +akatsuki +987456321 +567890 +19941994 +135246 +111213 +000001 +you +woofwoof +virginie +untitled +ukraine +tuxedo +tttttttt +troy +tommy1 +tommie +timothy1 +ticket systems -supernova -stone1 -stephen1 -stang -stan -spot -sparkles -soul -snowbird -snicker -slonik -slayer1 -sixsix -singapor -shauna -scissors -savior -samm -rumble -rrrrr -robin1 -renato -redstar -raphael -q1w2e3r -pressure -poptart -playball -pizzaman -pinetree +sushi +summers +stickman +starlite +spawn +southwest +snoopy1 +smarties +sexyboy +seaside +sarita +sanfran +sailormoon +robins +report +pickup +penthouse +peanutbutter +oxymoron +options +onetime +oleander +ohmygod +ocelot +oceans +nightfall +nicky +newjersey +new +ncc1701a +musashi +mullen +muhammed +morphine +moritz +mohawk +mobydick +merlot +meltdown +medieval +martian +marlins +mahogany +magic123 +lucinda +lonnie +longshot +lockheed +lkjhgf +livewire +lister +lakeland +konrad +kokoko +kleenex +killian +kenworth +interpol +integrity +hunter12 +hibernia +hermann +helpdesk +havefun +harbor +gymnast +guatemala +gospel +godofwar +godiva +gidget +genuine +fruity +frost +fishhead +everybody +ethernet +erin +emmett +elemental +ecstasy +duracell +dogfood +dempsey +delicious +daniel123 +custard +cthulhu +crystals +cool123 +confidence +comet +comeon +colossus +cirrus +chappy +callofduty +burner +bulls +buffett +bowwow +besiktas +belladonna +backlash +asylum +asdf12 +asddsa +anime +alanis +airforce1 +academic +abnormal +Jordan +Andrew +5555555555 +19901990 +1989 +1973 +123qwe123 +123987 +1234566 +zeus +wrestle +wendell +watch +violetta +vineyard +truffle +tigger1 +three +thistle +therese +terrible +tamtam +tabatha +sverige +suburban +stocking +steven1 +starbucks +stanton +springfield +spider1 +snuffles +smalls +sideways +sharma +sensation +schwartz +scania +salasana +runescape +rubbish +rosalind +rocking +rockie +robots +ringer +rhubarb +radiation +q1w2e3r4t5y6 +pussy1 +purple1 +purchase +protection +practice +poiuytre +piramide phyllis -pathfind -papamama -panter -pandas -panda1 -pajero -pacino -orchard -olive -nightmar -nico -Mustang1 -mooses +patrol +panacea +ninjas +nashville +naked +muriel montrose -montecar -montag -melrose -masterbating -maserati -marshal -makaka +mondeo +molly123 +mercer +medion +maximus1 +maryam +martine +mammamia macmac -mackie -lockdown -liverpool1 -link -lemans -leinad -lagnaf -kingking +mac +lunchbox +lucky13 +lookout +lonesome +limerick +liberty1 +lexus +kitty1 +kissing killer123 -kaboom -jeter2 -jeremy1 -jeepster -jabber -itisme -italy -ilovegod -idefix -howell -hores -HIZIAD -hewitt -hellsing -Heather -gonzo1 -golden1 -GEORGE -generic -gatsby -fujitsu -frodo1 -frederik -forlife -fitter -feelgood -fallon -escalade -enters -emil -eleonora -earl -dummy -donner -dominiqu -dnsadm -dickens -deville -delldell -daughter +jill +jaybird +insight +imagination +ignition +homebrew +higher +hellos +helicopter +harry123 +guido +guadalupe +groucho +greenman +godsmack +glory +gilmore +gerardo +fucku2 +flossie +firefire +fergie +faisal +empress +electronic +economics +doug +doris +don +disco +dino +declan +dayton +danzig +daniel12 +damon +damned +cricket1 +correct +cookie1 contract contra -conquest -compact -christi -chill -chavez -chaos1 -chains -casio -carrots -building -buffalo1 -brennan -boubou -bonner -blubber -blacklab -behappy -barbar -bambi -babycake -aprilia -ANDREW -allgood -alive -adriano -808080 -7777777a -777666 -31121986 -31121985 -31051991 -31051987 -30121988 -30121985 -30101988 -30061988 -29041988 -27091991 -26121989 -26061989 -26031991 -25111991 -25031984 -25021986 -24121989 -24121988 -24101990 -24101984 -24071992 -24051989 -24041986 -23091991 -23061987 -23041988 -23021992 -23021983 -22111988 -22091990 -22091984 -22051988 -21111986 -21101988 -21101987 -21091989 -21051990 -21021989 -20101987 -20071984 -20051983 -20031990 -20031985 -20011983 -1passwor -19111985 -19081987 -19051983 -19041985 -18121990 -18121985 -18121812 -18091987 -17121985 -17111987 -17071987 -17071986 -17061987 -17041986 -17041985 -16121991 -16101986 -16041988 -16041985 -16031986 -16021988 -16011986 -15121983 -15101991 -15061984 -15011988 -14091987 -14061988 -14051983 -13101992 -13101988 -13101982 -13071989 -13071985 -13061991 -13051990 -13031989 -123456n -1234567890- -123450 -1216 -12101989 -1208 -12071984 -12061987 -12041991 -12031990 -12021984 -1117 -11091986 -11091985 -11081986 -1026 -10101988 -10101980 -10091986 -10091985 -10081987 -10051988 -10021987 -10021986 -09041985 -09031987 -08041985 -08031987 -07061988 -07041989 -07021980 -06011982 -05121988 -05061989 -05051986 -04031991 -03071985 -03061986 -03061985 -03031987 -03031984 -03011991 -02111987 -02061990 -02011971 -01091988 -01071990 -01061983 -01051980 -01022010 -000777 -000123 -young1 -yamato -winona -winner1 -whatthe -weiner -weekend -volleyba -volcano -virginie -videos -vegitto -uptown -tycoon -treefrog -trauma -town -toast -titts -these -therock1 -tetsuo -tennesse -tanya1 -success1 -stupid1 -stockton -stock -stellar -springs -spoiled -someday -skinhead -sick -shyshy -shojou -shampoo -sexman -sex69 -saskia -Sandra -s123456 -russel -rudeboy -rollin -ridge -ride -rfgecnf -qwqwqwqw -pushkin -puck -probes -pong -playmate -planes -piercing -phat -pearls -password9 -painting -nineball -navajo -napalm -mohammad -miller1 -matchbox -marie1 -mariam -mamas -malish -maison -logger -locks -lister -lfitymrf -legos -lander -laetitia -kenken -kane -johnny5 -jjjjjjj -jesper -jerk -jellybean -jeeper -jakarta -instant -ilikeit -icecube -hotass -hogtied -having -harman -hanuman -hair -hacking -gumby -gramma -GOLF -goldeneye -gladys -furball -fuckme2 -franks -fick -fduecn -farmboy -eunice -erection -entrance -elisabet -elements -eclipse1 -eatmenow -duane -dooley -dome -doktor -dimitri -dental -delaney -Dallas -cyrano -cubs -crappy -cloudy -clips -cliff -clemente -charlie2 -cassandra -cashmoney -camil -burning -buckley -booyah -boobear -bonanza -bobmarley -bleach -bedford -bathing -baracuda -antony -ananas -alinka -alcatraz -aisan -5000 -49ers -334455 -31051982 -30051988 -30051986 -29111988 -29051992 -29041989 -29031990 -28121989 -28071985 -28021983 -27111990 -27071988 -26071984 -26061991 -26021992 -26011990 -26011986 -25091991 -25091989 -25081989 -25071987 -25071985 -25071983 -25051988 -25051980 -25041987 -25021985 -24101991 -24101988 -24071990 -24061985 -24041985 -24041984 -23456 -23111986 -23101987 -23041991 -23031983 -22071992 -22071988 -21121989 -21111989 -21111983 -21101983 -21041991 -21041987 -21031986 -21021990 -21021988 -20081990 -20061991 -20061987 -20032003 -20031992 -1qw23er4 -1q1q1q1q -1Master -19121988 -19081986 -19071989 -19041986 -18111983 -18071990 -18071989 -18071986 -18031986 -17121987 -17091985 -17071990 -17051983 -16091990 -15081989 -15071990 -15051992 -15051989 -15031991 -15011990 -14031986 -13091988 -13091987 -13091986 -13081986 -13071982 -13051986 -13041989 -13021991 -1269 -123890 -1234rewq -12345r -1231234 -12111984 -12091986 -12081993 -12071992 -1206 -12021990 -111555 -11111991 -11091990 -11061987 -11061986 -11061984 -11041985 -11031986 -1030 -1029 -1014 -101091m -10041984 -10031980 -10011980 -09051984 -08071985 -07081984 -07041988 -06101989 -06061988 -06041984 -05091987 -05081992 -05081986 -05071985 -05041985 -04111991 -04071987 -04021990 -03091988 -03061988 -03041989 -03041984 -03031991 -02091978 -01071988 -01061992 -01041993 -01041983 -01031981 -0069 -zyjxrf -xian -wizard1 -winger -wilder -welkom -wearing -weare138 -vanessa1 -usmarine -unlock -thumb -this -tasha1 -talks -talbot -summers -sucked -storage -sqdwfe -socce -sniffing -smirnov -shovel -shopper -shady -semper -screwy -schatz -samanth -salman -rugby1 -rjhjkm -rita -rfhfylfi -retire -ratboy -rachelle -qwerasdfzxcv -purple1 -prince1 -pookey -picks -perkins -patches1 +conflict +comeback +coldplay +cocoa +coach +clock +clara +civic +cheeseburger +chachi +carmine +cantona +braveheart +bramble +boohoo +bongo +bingo1 +beyond +bert +believer +bedroom +beaumont +bangladesh +banger +athlon +arrowhead +anytime +angelita +amores +alternative +aileen +agent +Thomas +456321 +23456789 +2002 +1999 +1971 +135792468 +112211 +1122 +woodward +woodie +wolverin +whatever1 +werdna +wellness +webcam +vishnu +tripper +torrent +timberlake +terrorist +temptation +teapot +swingers +supergirl +style +starman +squeak +solstice +snake1 +smooch +skylark +sheryl +scratchy +salinas +ruth +roosevelt +rockport +return +reilly +redlight +quake +puppy123 +puddles +pretzel +post +pompey +poker +pocket +play +persona +perfection +penny1 +pavlov +paulette password99 -oyster -olenka -nympho -nikolas -neon -muslim -muhammad -morrowind -monk -missie -mierda -mercede -melina -maximo -matrix1 -Martin -mariner -mantle -mammoth -mallrats -madcow -macintos -macaroni -lunchbox -lucas1 -london1 -lilbit -leoleo -KILLER -kerry -kcchiefs -juniper -jonas -jazzy -istheman -implants -hyundai -hfytnrb -herring -grunt -grimace -granite -grace1 -gotenks -glasses -giggle -ghjcnbnenrf -garnet -gabriele -gabby -fosters -forever1 -fluff -Fktrcfylh -finder -experienced -dunlop -duffer -driven -dragonballz -draco -downer -douche -doom -discus -darina -daman -daisey -clement -chouchou -cheerleaers -Charles -charisma -celebrity -cardinals -captain1 +panther1 +paisley +overtime +outback +orbital +omega1 +ollie +nopassword +nikolai +neutron +nazareth +mudvayne +movement +mother1 +mmmmmmm +miracles +milo +mike1234 +mikado +maxell +matisse +maserati +marihuana +marbella +luciana +lily +lifestyle +leroy +lamont +kiwikiwi +jurassic +jules +jim +jacky +infernal +hereford +guiness +goodtime +goodlife +goodgirl +garlic +gamecock +galadriel +gabriell +friends1 +foofoo +flatron +firefighter +ferreira +fenerbahce +farley +fanny +ethiopia +elektra +edgar +dogface +dionysus +different +devin +debora +deadpool +crossroads +colgate +closer +clint +clapton +christos +chauncey +catalog +castaway +carling +carefree +byteme +burnside +brewer +boulder +borussia +border +boomerang +bohemian +blueboy +blackice +blackhole +billy1 +billion +bigmouth +benji +barley +baptiste +bahamas +augustin +atticus +asian +asdfg123 +arlington +ambassador +alistair +alias +agustin +agamemnon +advocate +adgjmptw +acoustic +Princess +7894561230 +6666666 +666 +235689 +1qwerty +19811981 +1981 +1968 +123456q +122333 +11221122 +0 +zimmerman +youandme +yorkshire +wallpaper +vinicius +version +veronique +vauxhall +utility +understand +tyler123 +tiptop +the +terminus +sweeney +susie +surround +suckmydick +stronghold +storage +spurs +spice +sonora +soccer13 +snicker +sneaky +smokin +slipknot1 +slim +shauna +shaun +shades +sexylady +sessions +scirocco +schiller +schedule +sasha1 +sapper +sanjay +ruthie +rosebud1 +repair +regional +rainman +radiance +quarter +quaker +punk +portia +popo +poiuy +pioneers +phantasy +peaches1 +p@ssw0rd +orpheus +one +obsession +nigel +neutrino +mountains +moore +model +mike123 +marta +marmalade +maribel +mariano +malaga +lourdes +llamas +linda1 +lavinia +larkin +kilroy +kendrick +jamesbond007 +irvine +image +hogwarts +helloo +heinlein +hatred +harlem +hard +haley +guitarra +guitar1 +grande +gillette +germania +fun +fruitcake +flowers1 +fighters +field +feeling +fastback +farrell +fabrizio +export +exercise +essence +envelope +element1 +eeeeeeee +e +dynamo +doraemon +divorce +dickie +diabetes +destination +death1 +davenport +danish +damascus +cutlass +cubbies +corpse +coronado +cook +cloud9 +christo +chevalier +cheese1 +cashflow +carola +cardigan +canary caca -c2h5oh -bubbles1 -brook +buddah +british +boyfriend +books +bogdan +blueprint +blackboy +bitchy +bitchass +beacon +bbbbb +bball +backpack +babycakes +austin1 +arschloch +arielle +aquila +aquamarine +anakonda +aimee +adrien +abcxyz +Victoria +911turbo +8888888 +4runner +258963 +1993 +19851985 +19721972 +1234567899 +yogurt +worldwide +woody1 +witches +wiseman +water123 +vivien +viscount +violette +venezuela +vegas +undertow +traveller +transformer +topaz +toni +tombstone +tits +think +tessie +tennis1 +teacher1 +tank +tacoma +sword +surgery +surfboard +success1 +stuff +stratocaster +stephani +stainless +spikes +siobhan +silva +shania +sergey +seaman +scorpions +rudolph +rosanna +romain +rolando +ritchie +redstone +ready +premiere +planning +piranha +piper +peacemaker +paramore +panter +packers1 +outcast +numberone +nitrogen +natascha +mutter +munich +moonwalk +midori +meme +maurizio +matty +marzipan +mandolin +mamamama +maintain +macgyver +ludacris +loredana +london1 +logout +lillie +lexington +landscape +lahore +ladder +kristie +kodak +kim +killkill +khalil +justice1 +judy +joachim +jazmin +jailbird +ilovemyself +iiiiii +harrier +google123 +goodnews +golden1 +glass +gene +gatekeeper +gandhi +freshman +frankfurt +frankenstein +flower1 +flavia +firestar +etienne +erik +eleonora +dumdum +dreamland +dragon11 +domenico +dog123 +django +discreet +detective +darian +dalila +crossbow +crispy +creative1 +cordoba +cola +cock +clown +cleaner +citroen +christi +choppers +cheesy +canela +buddie +bryce +breathe +brando +bowman +bollox +bloom +betrayed +bernice +bernhard +benton +basilisk +bahamut +augusto +asdqwe123 +asdqwe +asdasd123 +armadillo +aries +antigone +annabel +altima +alterego +allie +alhambra +aladin +aerobics +advantage +adelina +Superman +Dragon +888999 +224466 +20012001 +1million +1983 +143143 +123qaz +yyyyyyyy +yellowstone +www +workout +woodruff +woodrow +woodman +verena +vampire1 +trout +treetop +tickle +texas1 +terra +tequiero +sylvie +surf +sunnyboy +star69 +spot +spence +specialk +sorrento +socks +snyder +smokie +simsim +simba1 +short +shiva +sevilla +school1 +salazar +sabres +rolex +rhino +reliance +ratchet +rajesh +qqqq +q2w3e4r5 +proverbs +prime +policeman +point +playgirl +pitcher +petra +persian +pentium4 +pedigree +partners +overdrive +oswald +origami +orange12 +observer +nomad +nolimit +noah +nnnnnnnn +nicholas1 +newworld +needle +navarro +morrow +morley +moriarty +more +mommy1 +mmmmm +misty1 +missing +minotaur +mikaela +metro +mazda +maya +margo +manunited +malaka +lydia +lori +location +leo +leavemealone +larry1 +komodo +knockout +knickers +kerrie +keepout +katie1 +kassandra +kamila +july +joelle +jemima +jelly +jeffrey1 +jajaja +ismael +ignacio +iforget +hi +hellyeah +harald +griffey +greentea +goodgood +giselle +gisela +germany1 +gasoline +garret +fugazi +fuck123 +fox +flashman +five +firestarter +fatty +fatality +fallon +evan +emiliano +ellipsis +doom +dogwood +disorder +dianna +device +deadlock +davidoff +dasher +couscous +county +construction +congress +comics +cloudy +cleaning +clarkson +christoph +cheerleader +charlie2 +ceramics +catnip +casandra +carman +carlson +caramba +cancun +campus +cambodia +budman +bridges +brain +blackstar +bigmoney +bigbang +ballerina +backbone +aurelie +astra +aragon +anfield +ananas +amnesia +alexandru +alexa +alessio +airhead +90210 +7895123 +74108520 +24681012 +24242424 +1password +1995 +19881988 +123zxc +123456123456 +12 +yesyes +yamato +x +wraith +whatwhat +westcoast +watching +underwear +truth +treble +tortuga +tomatoes +tiramisu +tiberian +thurston +tanaka +tammie +taffy +sutton +sun +stream +steffen +spinning +slippers +slave +slapper +simon123 +shayne +shasha +serene +sequoia +scuba +sadie1 +romana +review +response +reindeer +ransom +rambler +raccoon +qwertyuio +quinton +prosperity +porsche1 +pinguin +phones +payday +patch +password1234 +panchito +onions +nuggets +nottingham +noreen +niagara +nessie +mythology +mummy +muller +montana1 +medium +mayfield +marquise +manifest +mammoth +magnetic +lumina +lovelace +loser1 +letmein123 +lesbians +leinad +kosmos +kids +kane +joystick +jonny +johndoe +iris +inspector +industry +ilovejesus +husker +hunters +hola +herring +henry1 +hardy +hannes +hambone +gulliver +ground +griffon +goldman +gogo +gianna +getlost +gaylord +ganymede +ganja +galactic +furniture +forums +flashback +flanker +firenze +felix1 +fedora +fast +eyeball +esoteric +emmitt +elvis1 +elias +dropdead +drinking +diving +dingle +digimon +devildog +cullen +courier +copeland +cobain +christop +christian1 +chess +cherish +cheerios +cheerio +chatting +chantelle +changeit +chang +chad +cerulean +carrots +carmelo +carmela +cabernet +buckley +brendon +boiler +blackheart +bizkit +bizarre +bionicle +bertram +barron +bandit1 +baltazar +babes +auto +as +archery +amoremio +alpha123 +alleycat +allah +accident +abraxas +Joshua +353535 +1994 +19771977 +1970 +1964 +147369 +123mudar +wrigley +warfare +viola +veteran +tulips +trickster +trailer +todd +toast +tingting +thething +testing1 +tallulah +talking +taiwan +symmetry +sweeper +summer69 +sugars +stubby +stroke +stonehenge +ssss +spoiled +spark +smartass +sliver +sissy +shortcake +shakur +shadow11 +sex123 +series +seaweed +sarina +salesman +rushmore +royalty +roxana +rodolfo +resource +replay +rebirth +rayman +racoon +privet +pride +pregnant +praxis +pleasant +playground +platoon +plankton +peoples +pendulum +peabody +paterson +password8 +partizan +outlook +ottawa +olympics +nursing +northwest +networks +nederland +nate +napalm +mystique +mouser +mosaic +monte +models +mischa +mini +mickey1 +metallica1 +mendoza +mckinley +mcgregor +maxpower +matias +mathematics +marita +love12 +longhorns +longer +lombard +livelife +leoleo +lamer +lafayette +kokakola +kleopatra +kimkim +khan +keywest +katherin +kaboom +justina +julianna +jezebel +jessika +jeannine +j +horseman +homeland +holiday1 +hidalgo +hennessy +healthy +hazel +gunman +guesswho +greywolf +grand +gilligan +gifted +gentle +gasman +gallardo +freewill +franks +francisca +francis1 +fordf150 +fleetwood +flamer +fantomas +exotic +evil +eightball +eddy +echo +ebony +dutchman +drummer1 +diamant +dementia +deaths +data +cygnus +cousin +copycat +coolest +concert +compaq1 +coming +clay +citron +chaotic +cellphone +cattle +carissa +cadence +budgie +breanna +breakdown +bread +boring +blitz +blessings +binder +bethel +berliner +bengal +barnaby +atlas +ashraf +arnaud +antonella +anthem +andrew123 +aleksandra +adrenaline +acmilan +achtung +abrakadabra +Shadow +QWERTY +George +20002000 +1996 +19951995 +1967 +007 +zoltan +yoshi +yoda +woodwork +women +winifred +welkom +welcome2 +waterboy +wakeup +vargas +troopers +trees +torture +theodora +taylor1 +styles +stick +starlet +sphere +sound +sonoma +sometime +smackdown +skillet +shayla +sharp +sandeep +sagittarius +sadness +russell1 +rocketman +roadking +rifleman +riders +refresh +raymond1 +ramon +racer +qwerty13 +priyanka +private1 +pop +pizzaman +phantasm +pathetic +parliament +park +p +oldschool +norwood +norwich +norfolk +nicotine +nefertiti +nadia +motherlode +mormon +moose1 +mollydog +modena +mocha +minstrel +minicooper +milwaukee +millionaire +milk +midnight1 +matthieu +maroon +markie +marisol +maria123 +logical +logic +live +lipton +lemming +lebron23 +lander +lakshmi +lakers24 +kitty123 +kindness +kent +karla +javelin +java +invest +insurance +independence +homer1 +hippo +hero +heller +hatter +hatfield +hangman +gymnastics +gonzalo +goat +glover +gigi +getmoney +general1 +fuckin +fubar +freelance +forsythe +fontaine +final +fiddle +feelgood +fart +experience +evidence +erickson +enter123 +energizer +enable +dupont +downfall +develop +delores +delgado +deadwood +dani +dandelion +damaris +cumshot +crusty +crazyman +corporate +corinna +commandos +clarice +citation +chinchilla +changed +champions +ceasar +calliope +byron +broccoli +brenna +boozer +bone +bleeding +bigben +berserker +bergkamp +belfast +backstreet +asmodeus +asia +asdfgh1 +artistic +antilles +anteater +anhyeuem +amy +alameda +aaaa1111 +a1a2a3 +Sunshine +Jonathan +789654 +585858 +414141 +321321321 +1qa2ws +19731973 +19691969 +112112 +000000000 +wrinkles +wowwow +wishes +winter1 +website +vanity +trumpet1 +trotter +triplets +towers +totoro +toolbox +tomboy +terran +telecaster +tandem +talbot +sunnyday +summer12 +students +stockholm +steward +start123 +starshine +spam +spain +sopranos +slipper +sleep +slappy +sigma +siberian +shetland +sheppard +shamus +senate +scrapper +schooner +salina +rush +rosa +rogue +robby +ritter +rhodes +restart +regent +rebellion +qqq111 +qazwsx1 +psyche +poochie +pigpen +pershing +pecker +password7 +parasite +pantera1 +palmetto +overture +odysseus +notredame +noisette +nibbles +narayana +nakamura +mushrooms +mongol +moderator +metalgear +mediator +mcintosh +mazda626 +mayflower +massey +marykate +manpower +malamute +macaco +lukas +louisiana +look +loki +little1 +libra +lena +kryptonite +keaton +kathmandu +justin12 +junkie +jumping +jumbo +joker1 +jewel +jeronimo +jeremy1 +jeremias +jamaican +imperium +hurricanes +humberto +hotmail1 +horton +hoosiers +holly1 +henning +helmut +harpoon +goldmine +futurama +fulcrum +erotic +elisabet +effect +eden +earthquake +dumpling +dragster +dragon13 +doubled +dominica +dominate +didier +dictator +desperate +denton +darnell +corwin +corbin +cookbook +confusion +concerto +cole +christel +charge +chaplin +caster +cashmoney +cartier +breast +branden +book +boating +blank +blacksmith +bilbo +biker +bigone +bigcock +beholder +beebee +baddog +babushka +autobahn +audia4 +attention +atmosphere +anywhere +anjali +ancient +analsex +amateur +alright +allyson +aftermath +afrika +acidburn +abhishek +aaron1 +789987 +789654123 +44444 +321456 +123123a +100100 +zippy +zapata +z1x2c3v4 +winslow +whiteout +wertyu +welder +vickie +vicki +typewriter +trauma +topolino +thousand +thorsten +thematrix +tetris +symbol +symantec +sugar1 +stanley1 +stacie +splatter +spiderman1 +sorry +sonysony +smegma +slaughter +skull +shady +setter +seth +sensitive +schaefer +saphire +samsara +robbins +reddwarf +puddin +providence +position +popopopo +policy +pikapika +piercing +performance +pebble +pearls +peanut1 +pasquale +paramedic +pakistani +paddy +neil +neighbor +motorcycle +mireille +mierda +marcie +mantle +manga +manatee +makoto +makeup +lyndon +lucia +lovesick +loverman +london12 +lockwood +lockout +loading +lllllll +lifeguard +kowalski +kerberos +kellyann +karaoke +julie1 +jughead +johnny1 +jimmy123 +jayhawks +jarred +jarhead +ipswich +invalid +innuendo +incorrect +ilovemom +iiiiiiii +hummingbird +houston1 +horrible +hooter +himalaya +hill +highlife +hetfield +heartbeat +guitarist +graphite +gorgon +goodies +godisgood +ghostrider +gerhard +gamble +furball +funnyman +frenzy +frenchy +foreman +flip +flasher +f00tball +estate +erotica +epiphany +elvis123 +dogshit +discount +dipshit +danny123 +danielle1 +cristi +creepy +copyright +consumer +conquer +concordia +conan +complicated +clyde +clothes +clementine +city +chouette +chosen +chip +chinchin +chinatown +chinaman +chicco +chesterfield +cervantes +celestial +caracas +calderon +caitlyn +c +bullhead +buffer +brussels +broadband +brian1 +brasilia +boy +boricua +bookie +bobby123 +bluedog +bellevue +bank +bang +bagpipes +baby123 +aurelius +aristotle +altitude +althea +aloysius +alabama1 +airwolf +affinity +abcdefg1 +Password123 +Hunter +969696 +292929 +20102010 +09876543 +030303 +zxasqw12 +winters +winnipeg +whistle +wannabe +ultraman +treefrog +totally +tongue +tigercat +terrier +taratara +tactical +system32 +swastika +suzette +starr +spades +sneaker +smokes +skipper1 +simple1 +simeon +shaker +session +searcher +salem +rules +rodger +riviera +reserved +release +reject +redbeard +rebeca +realtime +rasmus +qwqwqw +qwert1234 +qwer123 +qwe123qwe +pyramids +provider +projects +production +poptart +poontang +planeta +pippo +pippen +pinecone +photon +pericles +pereira +pennywise +peavey +passing +paradiso +parachute +parabola +pants +palestine +overflow +nico +motorbike +mom +merrill +merlin1 +meeting +mechanical +mazdarx7 +mavericks +matrix1 +marybeth +marriott +marko +mario1 +manzana +madeira +madalena +mack +loophole +lonsdale +lolly +lingerie +libertad +leigh +ledzeppelin +lavalamp +kuwait +klaus +kkkkk +julieta +joselito +joker123 +johan +jerrylee +jan +jamison +jamboree +interest +inlove +imissyou +imation +human +hugoboss +hoosier +holahola +heythere +hellen +hehehehe +hate +hangover +guerrero +grinder +greatone +grammy +gianluca +giacomo +gardener +gangsta1 +galina +funeral +frieda +frantic +fields +farside +exorcist +espana +elizabeth1 +dressage +donner +dominic1 +dominator +domination +dodgeram +diver +display +devine +daisey +dada +dabomb +d +cyprus +cummings +crosby +corrie +corndog +commodore +colby +clemens +christen +chevy1 +callahan +calcutta +burberry +bumper +bulletproof +breezy brady +bombshell +blackburn +bimbo +betsy +betrayal +bearcat +avenue +atkinson +athletic +army +arachnid +arabian +angela1 +amaranth +alyson +altair +almost +allsop +alisa +algernon +alastair +alanna +absinthe +98765 +543210 +258258 +2020 +2005 +1965 +01020304 +zoomzoom +zimmer +wysiwyg +wonderboy +wiseguy +whatthefuck +watchman +warhead +vanilla1 +update +tugboat +trouble1 +troll +trivial +tripod +transform +trampoline +tortilla +torino +thunderbolt +termite +superduper +steaua +starry +squeaky +squadron +smile123 +skylight +skates +shower +shield +serial +score +schatz +sanfrancisco +salamandra +romario +rising +ricardo1 +reunion +resistance +reliable +recorder +radius +qwertyqwerty +quasar +puffin +provence +porsche9 +plato +pietro +piedmont +pentagram +patches1 +password9 +passed +parsons +paige +paco +overdose +omicron +oktober +oksana +nuts +nightman +nightingale +name +mymother +morgen +monument +missy1 +miamia +medicina +majesty +madonna1 +longtime +lolololo +lokiloki +littleman +lebanon +laughing +kilgore +kerrigan +karin +jordon +jeopardy +janjan +jamie1 +jackie1 +irina +iomega +inspiration +ibelieve +iamgod +houghton +horsemen +hootie +hondas +hologram +hideaway +hawaii50 +happydays +handicap +hamsters +hack +guillermo +gucci +gohome +gerber +georgia1 +geezer +gamma +fungus +freddie1 +forklift +food +flubber +finished +feeder +fairway +elefant +dorothea +dinero +devotion +deathstar +davies +darkknight +corsica +conchita +cocacola1 +classy +classics +chowder +chopper1 +choose +cecil +candies +burn +bumbum +buffalo1 +bubba123 +bridgette +brenden +bloods +blingbling +bigblue +bigballs +bebe +bean +barnyard +baphomet +badlands +badgirl +asterisk +arcangel +aol123 +antoinette +annemarie +anette +aditya +Richard +Master +Christian +4444444 +415263 +333666 +20022002 +200000 +19971997 +1966 +1963 +1960 +1234567891 +100200 +zzzzzzz +zxcv +zebras +wizzard +wild +whoknows +weirdo +weed420 +wazzup +victoria1 +useless +uniform +ulrich +tulip +trousers +treehouse +tranquil +tower +toriamos +tenten +temppass +temp123 +teardrop +superboy +stories +states +srinivas +solange +snowman1 +slammer +skills +shuffle +shortcut +shockwave +shocking +shelton +shelter +senha123 +scranton +sandoval +sandie +roseanne +riddler +rewind +red12345 +recycle +punjabi +prospero +pronto +products +process +pokey +playing +pepe +patrik +paperclip +papamama +paolo +padres +outdoors +otter +osborne +organic +nightwish +nemesis1 +nanook +nagasaki +mousepad +morrissey +morgan1 +monkeyman +modeling +minute +microlab +mick +mariel +margaux +maranatha +manish +mamma +makeitso +maine +maelstrom +luck +lineage +limpbizkit +lightbulb +lettuce +lalakers +kiwi +kirk +katharina +kakaroto +kaitlin +juggalo +jjjjjjj +jimjim +jewell +jesse1 +jeannette +jay +jaeger +jack123 +investor +insecure +ice +humanoid +hotline +hotel +hotboy +hondacivic +holler +holiness +hiroshi +high +hewitt +helpless +hello2 +healing +halo +hallelujah +haircut +guilty +greenhouse +great1 +graphic +grace1 +gigolo +ggggggg +germaine +georges +garland +gamer +gallagher +freefree +francais +forbes +follow +flora +flicker +firestone +firebolt +filipino +federica +fathead +fantom +falstaff +extra +evening +eleven11 +electronics +economist +durham +dunlop +dummy +dominant +dogcat +dogbert +diabolic +diablo2 +descent +degree +deadbeat +crockett +crazycat +comrade +composer +colombo +collier +coleslaw +citrus +cincinnati +chloe1 +cheval +cherub +chatter +cesare +cayenne +cascades +cantor +camilo +brook +bretagne +breasts +breaking +boxers +bourbon +bluenose +bluegrass +block +bisexual +binky +billions +billbill +bigbrother +belgium +beckham7 +avengers +athletics +assembly +asasasas +apple2 +anal +amore +allstars +ali +alakazam +agosto +adamant +activate +abcde12345 +abbey +Pa55word +Computer +794613 +777 +369258147 +1q2w3e4r5 +1997 +192837 +125125 +123456qwerty +zzzz +zoom +zombies +zerozero +zapper +windowsxp +whopper +whales +wachtwoord +voyage +vitamin +vigilant +verygood +vandal +under +trustnoone +truffles +trash +toothpaste +tigris +tigerman +thirty +thinker +thankgod +test12 +terrell +telefono +sweetwater +swatch +summoner +suicidal +strummer +striper +stiletto +start1 +stadium +squishy +squire +squeaker +springs +sixtynine +sithlord +siegfried +showcase +shibby +shandy +serenade +sepultura +secret123 +scrooge +rudy +rotation +romulus +rockhard +reserve +reeves +raisin +raining +quintana +pussies +purity +player1 +pepsicola +passenger +paris1 +papito +pacifica +orwell +ortega +optical +omsairam +obelix +nonstop +nightshade +newhouse +nazgul +napster +nairobi +nacional +muenchen +movie +mousey +motorhead +motley +morrigan +montecarlo +minette +michael2 +metroid +memememe +maybe +maximize +marino13 +marciano +manual +macdonald +lovegod +loveable +long +logan1 +loco +linux +lethal +lampard +lakeview +kurtis +konstantin +kenneth1 +junior1 +jukebox +jamal +ilikepie +hyderabad +hotspur +historia +highschool +hiawatha +hermitage +hendrik +haggard +grunge +gromit +gretel +goodtimes +getsome +gerry +gatsby +funk +freeport +flathead +fishy +filippo +faulkner +falcon1 +explode +evelina +endymion +emirates +edition +dresden +dreamers +dragon69 +douche +dooley +district +dingbat +dildo +dietrich +demonic +deicide +dannie +cyrano +crayola +cranberry +colibri +cockroach +cliff +clemence +claudia1 +classified +chriss +chocolate1 +chemist +chelle +chateau +cellular +catherin +carmella +canucks +calibra +butterfly1 +burgundy +bugaboo +brutal +brother1 +breath +branch +bonzai +bolivia +blooming +blitzkrieg +blender +bladerunner +bigboobs +bible +beijing +beavers +beachbum +barclay +barbara1 +balder +badgers +backyard +backward +babybear +argonaut +appleton +amour +alonzo +allied +aliyah +alina +aguilera +adonai +abundance +Nicholas +Michael1 +Anthony +9999999999 +676767 +373737 +321 +258369 +2009 +1qazzaq1 +172839 +13243546 +12qw34er +123456ab +000007 +zelda +zealot +zaragoza +worlds +woodcock +wolfen +wisteria +wilma +westlake +wert +vitoria +victoire +untouchable +tyrant +trapdoor +torment +tom123 +tigereye +thetruth +testicle +teste +team +talon +tabby +superbowl +student1 +stripe +store +sprinkle +snakebite +smart1 +silencer +sheeba +sharpie +shakti +shade +servant +sector +secreto +secretary +scottish +sanderson +sanandreas +sage +rockies +robertson +riddick +richelle +richardson +retire +rene +religion +redmond +rastafari +rashid +quiksilver +queenbee +pugsley +psychology +pool +playhouse +planes +physical +philipp +pensacola +pedersen +peace1 +pat +password0 +paperboy +pandemonium +outkast +origin +optima +nikolaus +nickie +newyear +newuser +murderer +morten +montero +montague +mockingbird +mindy +milagros +mercutio +mercurio +mcknight +maxpayne +mature +marmar +marie1 +mari +marcin +mandragora +manager1 +mamacita +malika +magics +madhatter +lucretia +loveya +love4ever +lorenz +lol12345 +logger +leilani +lauren1 +laura123 +kusanagi +knoxville +kira +kemper +katmandu +katina +kamala +kaka +julianne +juju +joseluis +jiujitsu +jingles +jeanpaul +ivanhoe +inspire +infrared +industrial +ichigo +hustle +humbug +humanity +house1 +hotwheels +hot +honeypot +honeybun +hester +heroin +herkules +heartbreaker +hawkeyes +hattie +hank +gregory1 +gilgamesh +ghost1 +geometry +garner +gaming +g +friedman +freiheit +freezer +foghorn +flashy +firework +finley +federation +fear +family1 +exeter +executive +exclusive +excellence +esprit +emotional +elohim +elbereth +edith +dylan1 +dragon99 +draco +dominus +dollface +devilish +derby +democrat +darkmoon +cretin +creeper +creamy +crackpot +cracked +costarica +costanza +cortina +corky +core +consuelo +clarisse +clarion +citibank +cingular +chrystal +channing +casio +carvalho +carolin +buffy1 +brownie1 +bluebear +birgit +billyjoe +beyonce +benedikt +beaufort +batman12 +barnabas +baracuda +banks +banana1 +baggio +augustine +assault +armitage +angell +alex12 +alcapone +afterlife +adrianne +acacia +a1a2a3a4 +Internet +Football +1998 +12345q +zappa +zack +yourself +yorktown +yeahyeah +xyzzy +winning +wildflower +weiner +web +waffles +victor1 +vantage +valdemar +unlocked +unleashed +twinkles +trujillo +torrents +tootie +tonyhawk +tobacco +tiny +tanzania +takedown +takamine +suresh +supra +supercool +subwoofer +storms +stitches +steiner +steeler +standing +stalingrad +srilanka +spliff +spirits +sparhawk +slowpoke +sizzle +shoelace +shiraz +service1 +senorita +seashore +sandstorm +sachin +sable +roulette +rocky123 +reboot +rambo1 +ralphie +radiator +quinn +q1q1q1 +problems +powerhouse +powered +postmaster +platform +plague +picnic +penner +paulo +parallax +outlaws +ostrich +obvious +oakwood +noel +niklas +nepenthe +naples +moonmoon +merrick +megan1 +mason1 +marconi +mansion +malik +mackie +lovehate +lovable +livingston +lifesucks +lickme +leo123 +leandro +labyrinth +kookie +komputer +kikiki +kerouac +joy +jeep +jazzy +jackhammer +intrigue +interface +interact +insider +imogen +hummel +honeymoon +hikaru +helium +hejsan +hayward +hansel +grapefruit +government +gossip +godfrey +ggggg +geology +geography +garnett +galloway +fullback +fuckhead +finder +fellow +faith1 +fairview +fabio +example +ella +eliana +edwina +eating +down +dondon +divorced +disabled +deputy +defiance +deeznutz +deep +ddddd +daniel01 +dan123 +cristy +cristo +council +cookies1 +communication +cocksucker +chocho +cheating +chakra +catalin +casper1 +casimir +carlin +carcass +candles +bush +buckwheat +break +bozo +boob +boner +boat +boarding +blackdragon +bergen +batata +basic +baseline +bandicoot +baldrick +back +arcane +apollo11 +annamaria +angola +ambulance +alvarez +aluminum +ahmed +acer +abercrombie +852963 +777888 +727272 +6543210 +357159 +2468 +19931993 +19791979 +zach +yugioh +youyou +woodwind +woodpecker +woodbury +whoami +watchdog +vikings1 +videos +vicente +vedder +vanille +unhappy +turbo1 +tribunal +total +toreador +tigerwoods +thinkpad +thebeach +test12345 +terrific +teaching +tantra +syzygy +supper +supermario +sunfire +sundown +successful +stud +stringer +stop +star123 +sovereign +souvenir +sombrero +skip +sk8board +sincere +simons +siberia +shuriken +shotokan +shock +shinichi +shawnee +sevens +scouts +scooters +schroeder +schnitzel +sargent +sanford +rugrats +rosalinda +rob +riches +rhinos +regiment +redbone +reaver +ramrod +rainfall +qwerty78 +qweasd123 +qpalzm +q123456 +puertorico +ppppppp +pop123 +plokij +planner +piston +pistache +pianoman +payment +paddle +paddington +overseas +orville +orthodox +nietzsche +nettie +needles +nachos +motor +mooses +moonman +monorail +momdad +missie +miss +minemine +milhouse +mickie +mermaids +memento +melon +maverick1 +margarida +mansfield +malena +madrigal +london22 +linus +lima +leander +lasalle +krakatoa +korea +karen1 +junction +joyful +joseph1 +jolene +johnboy +jenjen +jello +jamess +intranet +impreza +imperator +hunter123 +humility +hubbard +hotsex +horney +holy +hermit +hedwig +harmless +harlan +graves +grass +graeme +grace123 +googoo +giuliana +gauntlet +ganesha +fugitive +fuckyou123 +frazier +flatland +fenris +feelings +fabregas +esquire +escobar +entertainment +emanuele +elodie +election +dumpster +douglas1 +cruzeiro +crowley +crafty +cracking +cooper1 +control1 +compute +code +cobra1 +chillin +cheaters +centrino +carrier +captain1 +canberra +calling +caliban +bricks +botswana +bobber +blockbuster +blahblahblah +blackfire +blackbelt +bestfriend +base +banjo +bailey1 +autocad +atreides +athlete +asuncion +astronomy +astroboy +assist +aqualung +annie1 +andrej +amnesiac +amiga +allgood +adorable +Patrick +Matthew +David +3edc4rfv +2003 +1z2x3c4v +19921992 +14141414 +12211221 +120120 +zinger +yankees2 +yahoo1 +wrench +worldcup +witch +winger +wholesale +wendy1 +vulture +vittorio +vishal +vera +uuuuuu +underwood +underwater +ulises +tupac +trial +track +tracie +trace +toxic +touchdown +tonton +theworld +thebeast +thaddeus +telemark +tango1 +sylvania +surveyor +suitcase +sucks +stroller +stripped +stratford +stallone +spock +speedster +sniffer +smoke420 +shop +septembe +scales +saviour +sasa +sandbox +sandberg +samira +saber +rowland +rousseau +robin1 +revenant +redford +rattler +raffles +purdue +protector +protected +product +prasad +poppet +pianos +pepsi123 +pembroke +password4 +password13 +parkside +paint +outbreak +ohyeah +ocarina +obsolete +nyquist +nutshell +nounours +nonenone +nine +nigger1 +nielsen +nichols +nada +multisync +mueller +mousse +momentum +microwave +michele1 +mehmet +marguerite +maldives +magdalen +longbeach +lockhart +lawless +lantern +land +krokodil +kraken +khaled +kensington +kenken +just4me +junker +illegal +igor +icetea +i +humboldt +homebase +hippos +hhhh +headshot +headless +hazelnut +harmon +hades +guru +gremlins +golfer1 +geordie +frankie1 +frank123 +fireman1 +fireblade +faceoff +fabiola +external +entering +ellis +elegant +electrical +east +eagles1 +dulcinea +duffer +drums +dropkick +draconis +dont4get +domestic +dododo +doc +dirk +dimple +diddle +delmar +delano +daydreamer +darkwing +curly +cummins +corporal +colour +cocorico +closed +cleo +chino +chimaera +cheyanne +chavez +centre +centaur +celebrate +cashew +carsten +caballero +bully +breakers +braxton +brainstorm +boys +boogers +bluesman +blackpool +bethesda +beluga +beatle +bavaria +basketba +ballin +aviator +ashish +around +aprilia +antichrist +andyandy +allison1 +aisling +agnes +adolfo +accent +abcdefghi +William +Garfield +Abcd1234 +797979 +656565 +646464 +2010 +1236987 +050505 +yummy +yoyoyoyo +yeahbaby +yahweh +wwwww +wilfred +whites +wetter +wetpussy +wanda +villa +vergessen +vaughan +variable +urchin +unicorn1 +ttttttt +trustme +trillium +trey +tralala +torrance +tool +tikitiki +tiger2 +thumper1 +thesaint +theforce +thecat +tessa +teiubesc +tables +sweet123 +survey +sunbird +sunbeam +suck +succubus +stockman +steve123 +stefani +spleen +speeding +sonya +solitaire +sokrates +slut +slingshot +slayers +skateboarding +silverfox +showboat +shifty +sherwin +sexy123 +sequence +schultz +satanas +sandra1 +samuel1 +sambo +rottweiler +roma +rita +rincewind +rimmer +rico +ribbon +reveal +redhat +rainmaker +racers +qwerty99 +punker +postcard +polkadot +photoshop +persimmon +perfume +passes +parole +paradis +pandabear +panda1 +outland +orlando1 +open123 +nymets +nutrition +nowhere +nora +no +niggers +nicolas1 +nicknick +nectar +navajo +naughty1 +mysterious +murdock +mortis +morocco +montoya +momomo +miller1 +micky +mercy +meaghan +maxi +mauser +marine1 +marielle +maneater +lucian +loves +lizzy +lions +lionlion +liberal +leningrad +leapfrog +larsson +langley +kristopher +korn +koolaid +kool +kirkwood +kilkenny +kidney +kalle +jordan12 +joe123 +jerry1 +jediknight +jazmine +jacket +jabberwocky +intercom +intense +ingram +informix +include +illini +ib6ub9 +hunger +howdy +hounddog +hoops +homicide +hijack +herschel +hermosa +henrietta +hellcat +hatteras +harakiri +halfmoon +gunslinger +guide +gretzky +greeny +goodwin +gomez +glider +fremont +four +forgive +flint +flavor +fivestar +firewood +expedition +executor +euclid +elcamino +egyptian +edmond +eclipse1 +duckling +drumming +drifting +dorado +door +donny +dodo +denny +debra +davida +daisydog +dagmar +cute +crisis +court +cortney +coolgirl +contrast +collector +club +close +ciccio +choclate +chilling +channels +cerise +catapult +careless +capitan +californ +cadbury +bullets +brunswick +brick +brendan1 +braindead +bored +blunts +bluedragon +bloodline +blind +binary +bimmer +beverley +becca +bbbbbbb +barrel +baptist +audio +audi +atalanta +astonvilla +assword +ashley12 +asdfghjkl1 +art +arizona1 +antihero +andrew12 +andrea1 +anabel +allright +akasha +airman +ab123456 +aaasss +43214321 +369852 +2525 +2222222222 +2121 +1a2s3d +1974 +1961 +18436572 +162534 +123567 +123456654321 +1234561 +1231234 +1000 +youssef +yeah +woodlands +windows7 +wilkinson +wibble +white1 +wellcome +walters +waldemar +vanish +valerian +true +tristan1 +trilogy +tricks +trekker +tornado1 +thunders +thomas123 +testament +tennyson +taxman +tarragon +tapestry +tajmahal +sunny123 +struggle +storm1 +starling +starchild +spoons +spaniel +sodapop +sobriety +snowfall +snickers1 +skyline1 +skyhawk +shirley1 +shalimar +sexyman +settings +sebastian1 +schnecke +satriani +sasha123 +sameer +sailfish +roserose +robson +rickey +restore +rejoice +reference +raiden +rafaela +qwerty22 +qwedsa +qqqqqqqqqq +puffer +proteus +print +princes +prashant +prancer +ppppp +powerman +powerade +playstation2 +plastics +planets +plane +pinkpink +pieman +patron +patate +parents +parallel +papercut +p4ssword +olympic +offline +nutella +numlock +norma +nicolai +navyseal +mufasa +monopoli +moises +mnemonic +millenia +mercenary +membrane +mayfair +manitoba +magyar +magda +maddox +madcat +lineage2 +limelight +leopards +leeann +lasers +kurdistan +kittys +kindred +kimball +kidrock +kassie +karoline +kali +johnpaul +jenson +jeanine +jasmina +jamila +jaguars +jackman +ismail +interior +interesting +insomniac +idiots +homepage +hello1234 +hello12 +heavymetal +headhunter +harvester +hartman +halcyon +guitars +greeting +gray +goodie +goodday +golfgolf +godson +go +glassman +gladstone +galatasaray +galahad +fruit +friction +foot +florin +filthy +filomena +fffff +felice +fabien +eulalia +ethereal +emotions +dudedude +drizzle +drive +douglass +doofus +dominika +doll +divers +diva +dipper +desperados +demetrio +demented +deliver +deepak +decision +datsun +darrel +cuthbert +culture +crunchy +crappy +cornel +consult +compound +comatose +clocks +civilwar +circuit +chessie +charleston +chariot +chan +castello +caspar +carioca +candie +cachorro +bushman +bulletin +brown1 +britain +brandnew +braden +boswell +bogus +bluegill +blue32 +bloodhound +blondy +bliss +blanket +blader +billing +beautiful1 +baywatch +bastardo +baraka +bagheera +babette +avanti +aurore +aspirin +asimov +arrows +arcade +april1 +annalisa +anatomy +allstate +allegra +algeria +alfaromeo +aldebaran +alberto1 +agenda +actress +accept +Samantha +Jackson +Elizabeth +963963 +78945612 +654654 +2fast4u +2cool4u +2006 +1957 +1598753 +159632 +1234568 +01010101 +0007 +zxzxzx +yellow12 +woman +wolverines +wolfhound +wildbill +whittier +werty +watkins +warrant +vittoria +virgilio +vegetable +vangogh +uptown +upgrade +unbreakable +umberto +trusting +troubles +triplex +trading +tonytony +times +tiamat +thebest1 +terriers +template +temper +telefoon +talented +table +superpower +supermen +sugarplum +steroid +starting +sprout +spartan117 +sowhat +sophie1 +snowball1 +smurf +slimjim +sixpence +simplicity +sigmund +sidewalk +shoshana +shivers +shammy +seville +setup +serrano +section +schools +sasquatch +samtron +rugrat +roxane +rowing +rotary +rodent +rocky2 +resist +repeat +renate +relax +read +rattlesnake +rainbow7 +rafferty +qwerty77 +qwerty00 +pussys +promotion +pokemon123 +pinocchio +philosophy +philippines +pheasant +petter +pentium3 +pawpaw +patrizia +parking +parade +overlook +overhead +operations +okokokok +ohshit +oddball +nwo4life +novembre +nostradamus +niggas +nexus +newlife1 +newdelhi +nervous +myspace1 +myfriend +munchies +mouses +mountaindew +moneybag +molecule +mistake +miki +midnite +mercury1 +melville +mcintyre +mattress +marylou +martino +marshmallow +marmite +maritime +mariachi +maple +makemoney +magali +maddy +luckie +lucien +loveyou2 +lovesong +lolalola +lindsey1 +lifeboat +lana +kitties +kimono +katie123 +kasandra +kara +kaplan +kalamazoo +jupiter1 +jump +julia123 +judge +jordan123 +jockey +jenni +jackrabbit +isabela +intelligent +innocence +india123 +iamthebest +hundred +hollis +heyman +henry123 +henrique +hellion +hardball +handbook +hacienda +guilherme +grenoble +gotmilk +goodmorning +goddamn +giuliano +genie +geisha +fudge +frostbite +fresno +freehand +fragment +foreskin +folder +fido +f +explosion +experiment +erwin +erasure +ensemble +elisa +eclectic +duffy +ducky +dotcom +dong +dogger +dogfight +dodgers1 +disease +diogenes +dillweed +dickinson +derick +demon666 +demetrius +daybreak +darrin +dapper +dagobert +curtain +culinary +cuervo +crossing +cronos +croatia +coolboy +controls +consulting +cobblers +coaster +climax +click +clare +cindy1 +chrono +chill +chatterbox +charlie123 +charissa +changer +celebrity +campos +cable +buster12 +bungle +bungalow +bullit +brock +broadcast +brianna1 +boxcar +bootleg +bodyguard +bella123 +belkin +belize +beaker +barnett +ballroom +azrael +artur +aria +arbiter +andrzej +andre123 +analysis +ana +amber123 +all4one +alegria +albania +afghanistan +addiction +abc321 +aa123456 +Phoenix +686868 +434343 +2wsx3edc +2bornot2b +225588 +147741 +12131213 +zxcasdqwe +yes +yannick +wyvern +wwwwwww +writing +witchcraft +wertwert +weight +warcraft1 +wallet +vivienne +vivaldi +virago +versus +vermilion +vega +usarmy +unity +ultrasound +tweeter +tuppence +tropicana +trafford +tototo +teddy123 +t +survive +summer123 +strife +streamer +strato +stifler +starburst +star1234 +stapler +ssssssssss +spotlight +specialized +sparrows +songoku +solomon1 +soloman +solid +sloppy +simply +sideshow +shimmer +sherpa +sherbert +sentry +seminoles +sebastia +seagate +scribble +sarasota +sarasara +sarah123 +sanguine +sandy123 +sand +samwise +samsung123 +saibaba +robert12 +rhythm +request +reflection +redhorse +rational +raptors +ramiro +rakesh +radioman +qwerty01 +punjab +protein +progressive +poophead +plutonium +phantoms +pepino +peddler +password00 +passage +paperino +panic +panache +page +ozzy +osprey +organize +optiplex +october1 +null +nokia1 +niki +neverdie +nantucket +munch +mothers +moron +morbid +mooney +moondog +monsieur +monkfish +monica1 +modem +mmmm +minimal +mineral +midland +melodie +megane +mauritius +master01 +marymary +marvelous +marnie +mark123 +marduk +mann +manifesto +mahesh +macleod +machete +macedonia +lumber +lullaby +luckyme +lucas123 +loyalty +lovejoy +logistic +locker +llama +lili +libido +leprechaun +lemmings +langston +krusty +kipling +killer11 +killah +karl +kappa +joyride +joking +jimenez +jeffry +jayhawk +jack1234 +itsme +ireland1 +invent +innovation +import +iiyama +ihateu +hungary +house123 +honeys +holla +hihihihi +hhhhh +hemlock +hellhole +healer +hardwood +grandad +govinda +ginny +gentry +generator +gazelle +gaspar +funhouse +fullhouse +fulham +freebie +franny +foxfire +flowerpower +fiorella +farewell +fantasma +fall +faithless +fairy +failsafe +explicit +esposito +enters +enchanted +elissa +duckduck +drilling +drawing +dragon10 +doremi +doors +doodlebug +donjuan +dickweed +dewey +denial +demon1 +dallas1 +crunchie +crawfish +craft +conker +condition +chessman +charter +chanelle +chamonix +celebration +candys +candy123 +brotherhood +briggs +brewers +brainiac +borneo +bomb +bluewater +blocked +birdland +binladen +billings +before +barlow +bareback +bacteria +authority +astronaut +asdfqwer +asd12345 +arrakis +arpeggio +appleseed +anthony2 +animator +analyst +amazonas +alpacino +ajax +airline +adelaida +adamadam +aaron123 +Einstein +Buster +Bailey +Ashley +90909090 +741963 +5150 +444555 +369258 +1962 +12qwas +1234zxcv +101101 +zebulon +youtube +yasmeen +yamamoto +wormhole +witness +windows98 +wiggle +whiteman +westgate +watchmen +walden +visa +virgo +vergeten +veracruz +vanquish +uuuuuuuu +urban +undefined +tugger +trucking +trooper1 +tramp +tosser +tormentor +tomate +timelord +timberwolf +thrust +tangent +taichi +synapse +supers +stupid1 +strings +strangle +stoneman +stokes +starless +spiritual +spinach +spagetti +soviet +sorensen +somethin +snuggle +snowhite +snooze +smiler +slovakia +sledge +skydiver +skunk +sinful +silvester +silicone +silencio +siamese +shevchenko +shayna +shaved +shanty +selector +scumbag +scramble +scott123 +schalke +scarab +saracen +salinger +rosette +revival +renoir +rendezvous +reminder +redheads +rage +qwerty321 +qwe +propaganda +pringle +presidente +prakash +points +pocahontas +pierrot +photography +phaedrus +permanent +peeper +paulchen +password5 +passion1 +paraguay +panda123 +palacios +pain +pacino +osbourne +orange123 +opus +onepiece +nolan +nitrous +nippon +ninja1 +mutation +murcielago +murakami +mongo +mitchel +mina +mike1 +mercator +matematica +mario123 +marin +marcy +manticore +mahler +lynnette +luigi +lucero +loyola +lookatme +lock +lllll +linda123 +lightnin +lifeless +libby +leopoldo +lenore +lenin +lawman +latisha +latin +kristin1 +knitting +kinetic +killerbee +killa +kawaii +katrina1 +kabuki +julia1 +journal +jabber +iridium +interactive +hussein +hunt +hotdogs +holding +hickory +hershey1 +hellhound +haunted +happening +hansol +hanover +gutter +gussie +gridlock +greatness +grape +grandam +goethe +gigantic +getaway +gemma +garvey +gaby +fred1234 +florent +flavio +flatline +firehouse +firehawk +filbert +fight +fellatio +faraway +face +excite +eugenio +eruption +erasmus +encounter +dragons1 +dragon88 +doktor +dogfish +dionne +delorean +decipher +dddd +davidb +darken +darkblue +dario +danika +crush +creed +creatine +craven +couple +counting +cornbread +coolidge +cookie12 +converge +contest +clubbing +clear +cigars +charmaine +charade +chair +chains +cement +cbr600rr +casual +carnegie +caribbean +capcom +canton +calabria +buttfuck +butterflies +broncos1 +brindle +bowie +bonfire +blueball +blister +blair +bigcat +biatch +beware +beemer +beautifu +bbbb +batter +bateman +barnacle +barman +barbarossa +banking +bach +babygurl +azazel +azalea +avocado +automatic +asturias +assasin +ashwin +armchair +archives +aperture +andree +amos +amandine +ally +alexei +agnieszka +aggie +ace +Liverpool +Killer +717171 +535353 +515151 +474747 +22446688 +20032003 +1qaz +1a2b3c4d5e +19641964 +12141214 +101112 +01230123 +zarina +yourname +yahooo +wxcvbn +woods +wilkins +whores +whitewolf +warszawa +warsaw +viviana +vista +visionary +viagra +vette +versailles +valera +twist +trophy +tribble +trapped +toothpick +tillie +tigress +therock1 +there +theory +testuser +temp1234 +taipan +swordfis +swiss +superdog +sunflowers +sunflowe +stevenson +sportsman +somewhere +solar +soccer22 +snoopdogg +slovenia +slide +slayer666 +sinfonia +silverfish +shells +sexybitch +sexbomb +seadog +scrotum +scribe +scimitar +sceptre +sassy1 +sandal +sally1 +rossi +rosebush +rodeo +reznor +resonance +resolution +reno +registration +redriver +redeemed +ranger1 +ramstein +ram +rahman +radish +radiant +qweasdzx +quick +qazzaq +q12345 +q +purpose +puppy1 +proper +prince1 +primetime +precision +plumbing +pirata +pimping +pickwick +pavel +password22 +parsifal +paramount +pajero +overcome +otis +onetwo +olga +octagon +nutcracker +ninjutsu +newport1 +newcomer +net +neon +narayan +nanana +motmot +mostafa +monkey01 +minority +minion +midwest +marques +mariette +manu +manitou +manage +maldini +malawi +mahmoud +mafalda +lover1 +loveland +lottery +localhost +llcoolj +like +leona +league +leadership +lagrange +kenton +kelli +kanada +kaitlynn +justin123 +joshua123 +john1234 +joan +jjjj +jedi +janette +jamjam +isis +irish1 +invictus +inventor +inspired +inform +icecold +iamcool +hurrican +hotness +honey123 +holbrook +hiroshima +heracles +hehe +hawthorne +hathaway +grey +governor +goody +goodrich +gizmo123 +garion +front +friday13 +fortytwo +foreplay +foolproof +flash1 +flakes +fishhook +fishfish +fishers +financial +fillmore +figure +figment +fiddler +ferrets +fake +evangeline +espinoza +enough +emerald1 +electricity +ekaterina +edgewood +duisburg +drummers +dowjones +dopey +dodge1 +dizzy +delbert +dantes +danmark +crow +corrina +convict +continental +cococo +clinic +cipher +chewy +charmer +cards +cameroon +bunnie +buddyboy +bruno1 +britta +britt +bracelet +booter +bonner +bolivar +bogeyman +board +bluerose +birdcage +billy123 +billgates +bikers +bigfish +benny1 +bennet +benjie +beepbeep +batman123 +barret +barney1 +austen +ashtray +asdfgh12 +armenia +archive +architecture +anyone +antonina +andi +anaheim +anabolic +amor +alma +allister +aliali +albacore +airedale +aguilar +again +activity +Patricia +Nicole +Justin +99887766 +987321 +963258 +808080 +757575 +741741 +333 +20202020 +19701970 +153624 +1357924680 +1231 +115599 +080808 +yessir +yardbird +xcountry +wine +wildrose +waves +watanabe +wareagle +wanderlust +waldo +wakefield +volker +verity +verify +velcro +validate +unix +union +twin +tripping +tripleh +trip +treetree +timbuktu +tilly +tight +tesoro +teaser +taytay +tarantino +syndicate +sylvan +sylvain +swifty +swift +swansea +sunburn +summer00 +sultana +stuntman +strokes +stroker +strata +stlouis +stetson +steelman +steamer +spartan1 +spaceship +snowshoe +smuggler +slowhand +skynet +simcity +shorter +shift +sharpshooter +shanice +shadow01 +sensor +senna +seasons +schuster +schumi +schalke04 +satelite +sarge +samir +saddam +russ +romeo1 +rockin +rightnow +resume +reset +regret +reese +reactor +r4e3w2q1 +quagmire +punch +price +prefect +prague +portsmouth +porridge +pollock +plummer +platon +pinkerton +perseus +period +percy +peerless +paxton +paganini +orchestra +optional +opera +oioioi +nowayout +nounou +nintendo64 +nickle +nicaragua +newstart +neworder +neumann +monty1 +monkey13 +momoney +mom123 +moimoi +mission1 +michelangelo +menthol +mega +mcmillan +may +maxx +mara +manana +machado +m123456 +lurker +lucky777 +lotion +loren +lombardo +lisette +lindberg +leah +launch +larkspur +laredo +landing +lancia +lambchop +lalaland +lachlan +kosova +kirakira +kamehameha +just +jurgen +juneau +juggler +juanito +joshua12 +jonah +jetaime +jesper +jellybeans +january1 +itachi +innovision +infinito +index +indeed +identify +hostile +hgfdsa +here +hellomoto +hellgate +heatwave +heater +hartley +harlequin +hardon +hall +grounded +greenish +grandmother +gorillaz +goldsmith +gloves +glen +gerhardt +generous +gauthier +gator1 +gardens +frontera +fridge +freezing +franz +fracture +fourth +forces +fool +firewater +fellowship +fastlane +explosive +environment +embassy +elmo +elmer +eeeeeee +dummies +duane +drunk +drum +draven +drafting +donnelly +dolomite +direction +devlin +deviant +deception +daytime +darien +darby +damocles +cyanide +cunningham +crossroad +critters +crickets +crabtree +cowboy1 +cortland +cooley +convert +constantin +connected +confidential +comrades +codered +clothing +cleric +classical +chuchu +chiller +checking +chase1 +charmed1 +cathleen +carter15 +carleton +caribou +car123 +capella +candela +camelia +caboose +butterscotch +butterball +burgers +bulldozer +browny +brenner +borland +bomberman +blueline +blue11 +blondes +blaise +bittersweet +bigblack +berries +belial +beehive +bauer +bastard1 +baobab +bagger +backspin +babababa +audition +auction +ass123 +asians +argentum +antonius +antiques +ann +animate +angelfish +americana +ambush +aluminium +alfa +alain +abigail1 +abc123abc +abbie +aassdd +Brandon +666777 +636363 +575757 +369963 +2hot4u +147963 +14531453 +000 +wwww +worthy +woodlawn +woodchuck +winwin +windward +wind +warranty +wander +visitors +vertex +vanderbilt +valdez +turquoise +triathlon +trespass +trashcan +traitor +trade +tori +topnotch +tokyo +titania +tigger12 +thongs +theron +theo +thedog +tatjana +switzerland +suzie +surgeon +supply +summer05 +summer01 +sturgeon +studioworks +strikers +state +spook +sparky1 +sounds +solidus +soft +snowy +smoke1 +skipjack +simulator +silverman +shipyard +shimano +shekinah +sexy69 +severin +scouting +satanic +sanpedro +sandrock +rubicon +rootroot +ronaldo9 +romina +roger1 +rocco +riptide +riley1 +reynaldo +renaissance +rembrandt +relentless +relative +recover +ray +randy1 +rancho +rainier +radagast +qwertzui +qwe321 +quiet +quack +puddle +presidio +presence +prentice +porcupine +poppy1 +polar +playback +playa +place +ping +pilsner +philippa +peterman +persia +perrin +peregrin +peaceman +papabear +pagoda +organist +optimum +ok +octavio +octavian +northside +nnnnnnn +nikhil +nightwing +niceday +next +nathanael +nascar24 +muscles +multipass +mostwanted +monteiro +monkeys1 +monk +monet +monday1 +molina +mirella +minnow +millhouse +mikhail +micaela +metaphor +mervin +merida +matilde +masterp +manifold +mangos +mandala +mancity +maltese +makelove +makayla +mahoney +lysander +love69 +louisville +london123 +logistics +lobsters +line +lifesaver +liana +levi +layla +lagoon +kylie +kristofer +kinky +kimmy +kilimanjaro +kellogg +karmen +kalvin +julie123 +jolly +johngalt +jamaica1 +jalapeno +jakob +jacobsen +islanders +isengard +idefix +icecream1 +hutchins +hotlips +horizons +holger +hitchcock +hemingway +heavens +heartland +haynes +hawkwind +hasan +harding +happyboy +happy2 +halima +habitat +gwendolyn +gutentag +grunt +grenade +graveyard +gracious +godislove +glenwood +girlie +ghbdtn +gggg +frogs +frogfrog +freelancer +franck +fraction +foxy +forgetful +foreigner +folklore +flaming +firetruck +fever +fender1 +fantasy1 +fahrenheit +express1 +exposure +everton1 +ericson +eragon +enfield +endurance +employee +embrace +elysium +elektro +economic +dunhill +ducksoup +dragonslayer +doggystyle +diskette +devious +destin +despair +descartes +delacruz +davina +dashboard +damnation +daisies +custer +crissy +creepers +copperhead +colony +cognac +cobras +clements +cheerful +characters +chantel +certified +cecily +cathedral +catering +career +caracol +capucine +capacity +calvary +cabinet +bypass +bugs +buffet +budget +bridgett +breakaway +brat +boyscout +bourne +bogota +blue42 +bloomer +bloodlust +bling +blackstone +bird33 +bingo123 +bibi +belgrade +beginner +bavarian +band +baloo +bagels +backfire +astaroth +asswipe +asphalt +asdfg12345 +arsehole +argent +ararat +anselmo +annelise +andrew01 +anabelle +amherst +albright +airlines +adminadmin +adelante +adam12 +acrobat +account1 +abdulla +Maverick +Maggie +London +Dennis +998877 +85208520 +555 +357951 +2323 +2007 +1q2w3e4 +145236 +14121412 +134679852 +132435 +123456789abc +zzzzzzzzzz +zouzou +zazaza +yakuza +yahoo123 +wretched +winthrop +wildone +whirlwind +westwind +wendel +weinberg +weewee +wade +vacuum +upyours +tumbleweed +trashman +toronto1 +tissue +timtim +tigger2 +threesome +thomas01 +thibault +thesims +thekid +test11 +teller +tata +tartar +taco +system1 +syndrome +swinging +sweetiepie +sweetest +suspect +superwoman +sunita +sunburst +streak +strauss +sperma +sperling +spectral +soul +song +soldier1 +solace +smasher +sky +sixpack +simplex +silmaril +shoulder +shortie +shahrukh +settlers +semper +seduction +searching +scotsman +scofield +schumann +schule +scholar +satisfaction +santamaria +sandals +safeway +rudeboy +rossignol +ronny +rodrigues +rockrock +rockland +robyn +retriever +resurrection +restaurant +regine +redwine +redcar +rebelde +race +r +qwerty69 +qaywsx +prozac +promises +priscila +priority +principal +poop123 +pookie1 +polina +playoffs +persephone +peregrine +pebbles1 +pearl1 +patter +pasha +owner +owned +overseer +orleans +orion1 +order +orbit +opposite +oldsmobile +okay +octavius +oconnor +obscure +nikko +nikenike +nightcrawler +nehemiah +navy +nasser +nassau +mystery1 +myriam +mylene +moving +morticia +morrowind +moonraker +monkey11 +mogul +modest +mobster +mithrandir +misty123 +mingus +milenium +microphone +michael3 +miamor +mendez +matt123 +matrix123 +math +markos +marcio +maisie +mailer +lollollol +loader +lizbeth +lincoln1 +lilwayne +leanna +lawton +lausanne +lasher +lake +kokokoko +kobold +kisser +kilowatt +killall +kidding +kick +k +juggle +judson +joanie +jjjjj +jessy +jelena +jacob123 +issues +ishmael +isadora +interval +insect +ignorant +huntsman +hubble +hothot +host +hooligans +homo +homesick +holycow +hobgoblin +highlands +highbury +hhhhhhh +herrera +hellbent +hawks +hands +handle +hallie +halibut +hackman +guerilla +graywolf +grandson +goonies +gmoney +gizzmo +gertie +georgetown +gentleman +gecko +gargamel +gangsters +gameplay +galway +fractal +foryou +fortis +flowerpot +firefly1 +fighter1 +fielding +fermat +felony +favour +faramir +familiar +falconer +factor +ezequiel +ester +endgame +emotion +eeeee +edward1 +dynamics +dougal +dominican +dingo +dickson +demolition +demetria +demeter +dede +deathnote +david2 +daryl +darkroom +curtains +currency +crocodil +creativity +crawling +cranky +cory +commercial +cold +cigarette +ciao +christy1 +chivalry +charlie7 +chapter +chance1 +celestine +cecelia +ccccc +catriona +cassiopeia +carolann +carlie +card +cantona7 +cannonball +canfield +camber +buttocks +buller +brinkley +bribri +brianne +boromir +bordello +bonny +blissful +blast +blackwell +blackbox +billiard +bigbooty +bergman +belvedere +bauhaus +bastille +bashful +barbershop +background +avril +australian +atreyu +astalavista +assassins +ashes +asdfg1 +as123456 +artofwar +artichoke +aptiva +antique +annalena +animated +angle +alvarado +alternate +alive +alicante +alex2000 +aleksandr +alabaster +aerospace +accurate +aabbcc +852852 +2008 +2 +17171717 +159159159 +141516 +123456as +00112233 +00001111 diff --git a/misc/config_template.in.hjson b/misc/config_template.in.hjson new file mode 100644 index 00000000..abcdc09a --- /dev/null +++ b/misc/config_template.in.hjson @@ -0,0 +1,501 @@ +{ + /* + ./\/\.' ENiGMA½ System Configuration -/--/-------- - -- - + + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + + *-----------------------------------------------------------------------------* + Generated by ENiGMA½ v%ENIG_VERSION% / hjson v%HJSON_VERSION% + *-----------------------------------------------------------------------------* + + + ------------------------------- -- - - + General Information + ------------------------------- - - + This configuration is in HJSON (http://hjson.org/) format. Strict to-spec + JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON. + + See http://hjson.org/ for more information and syntax. + + Various editors and IDEs such as Sublime Text 3, Visual Studio Code, and so + on have syntax highlighting for the HJSON format which are highly recommended. + + + ------------------------------- -- - - + Configuration + ------------------------------- - - + ENiGMA½ is *highly* configurable, and thus can be overwhelming at first! + + By default, this file contains common configuration elements, examples, etc. + To see a more complete view of settings available to the system, don't be + afraid to open up core/config.js and look around. Do not make changes there + however! All system configuration can be extended and defaults overridden + via this file! + + Please see RTFM ...er, uh... see the documentation for more information, and + don't be shy to ask for help: + + BBS : Xibalba @ xibalba.l33t.codes + FTN : BBS Discussion on fsxNet + IRC : #enigma-bbs / FreeNode + Email : bryan@l33t.codes + */ + + 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: { + // + // Other paths can also be configured as well, + // but generally unnecessary + // + logs: XXXXX + } + + logging: { + // + // Each block here represents a Bunyan style config. + // See https://github.com/trentm/node-bunyan#streams + // + // Remember you can pipe logs through Bunyan to pretty-print: + // Linux : tail -F ./logs/enigma-bbs.log | bunyan + // PowerShell : Get-Content .\enigma-bbs.log -Tail 15 | bunyan.cmd + // + // (npm install -g bunyan to get the binary) + // + // We default to a rotating-file stream: + // https://github.com/trentm/node-bunyan#stream-type-rotating-file + // + rotatingFile: { + // If you're having trouble, try setting this to "trace" + level: XXXXX + } + } + + theme: { + // Default theme applied to new users. "*" indicates random. + default: XXXXX + // Theme applied before a user has logged in. "*" indicates random. + preLogin: XXXXX + + // + // dateFormat, timeFormat, and dateTimeFormat blocks configure + // moment.js (https://momentjs.com/docs/#/displaying/) style formats + // for dates and times. Short and long versions are available. + // Note that themes may override these settings. + // + } + + // + // Login servers represent available servers (or protocols) in which + // users are permitted to access your system. + // + loginServers: { + // Remember kids, Telnet is insecure! + telnet: { + // It's best to use non-privileged ports and NAT/foward to them + port: XXXXX + } + + // ...but SSH *is* secure! + ssh: { + port: XXXXX + + // + // To enable SSH, perform the following steps: + // + // 1 - Generate a Private Key (PK): + // Currently ENiGMA 1/2 requires a PKCS#1 PEM formatted PK. + // To generate a secure PK, issue the following command: + // + // > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + // -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \ + // -out ./config/security/ssh_private_key.pem -aes128 + // + // (The above is a more modern equivelant 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 + // + // 3 - Finally, set 'enabled' to 'true' + // + // Additional reading: + // - https://blog.sleeplessbeastie.eu/2017/12/28/how-to-generate-private-key/ + // - https://gist.github.com/briansmith/2ee42439923d8e65a266994d0f70180b + // + enabled: XXXXX + + // set this to your PK's password, generated in step #1 above + privateKeyPass: SuperSecretPasswordChangeMe! + + // + // It's possible to lock down various algorithms available to + // SSH, but be aware this may limit the clients that can connect! + // + algorithms: {} + } + + webSocket: { + // + // Setting "proxied" to true allows non-secure (ws://) WebSockets + // to be considered secure when the X-Fowarded-Proto HTTP header + // is set to "https". This is helpful when ENiGMA is running behind + // another web server doing SSL/TLS termination. + // + proxied: false + + // Non-secure WebSockets, or ws:// + ws: { + port: XXXXX + } + + // Secure WebSockets, or wss:// + wss: { + port: XXXXX + enabled: XXXXX + + // + // Certificate and Key in PEM format. + // Note that web browsers will not trust self-signed certs. Look + // into Let's Encrypt and perhaps running ENiGMA behind another + // web server such as Caddy. + // + certPem: XXXXX + keyPem: XXXXX + } + } + } + + // + // Content Servers expose content from the system + // + contentServers: { + // + // The Web Content Server can expose content over HTTP (http://) and + // HTTPS (https://) for (but not limited to) the following purposes: + // * Static content + // * Web downloads from the file base + // * Password reset forms (sent to users in PW reset emails; see + // "email" block below) + // + web: { + // Set to your public FQDN + domain: another-fine-enigma-bbs.org + + // Standard issue "www" folder. Place static content here + staticRoot: XXXXX + + // + // This block configures password reset emails. Template files + // support the following variables: + // * %BOARDNAME% : Name of BBS + // * %USERNAME% : Username of whom to reset password + // * %TOKEN% : Reset token + // * %RESET_URL% : In case of email, the link to follow + // for reset. In case of landing page, URL to POST submit reset form. + // + resetPassword: { + + } + + http: { + port: XXXXX + } + + https: { + port: XXXXX + enabled: XXXXX + + // + // Note that web browsers will not trust self-signed certs. Look + // into Let's Encrypt and perhaps running ENiGMA behind another + // web server such as Caddy. + // + } + } + + // Ladies and gentlemen, a Gopher server! + gopher: { + port: XXXXX + enabled: false + + // bannerFile path in misc/ by default. Full paths allowed. + bannerFile: XXXXX + + // + // The Gopher Content Server can export message base + // conferences and areas via the "messageConferences" key. + // + // Example: + // messageConferences: { + // some_conf: [ "area_tag1", "area_tag2" ] + // } + // + } + + // You may also wish to enable NNTP services + nntp: { + // + // Set publicMessageConferences{} to configure + // publicly exposed conferences & areas. + // + // Example: + // publicMessageConferences: { + // some_conf: [ "area_tag1", "area_tag2" ] + // } + // + publicMessageConferences: {} + + // non-secure + nntp: { + enabled: false + port: XXXXX + } + + // secure (TLS) + nntps: { + enabled: false + port: XXXXX + + // + // You will need a SSL/TLS certificate and key + // + certPem: XXXXX + keyPem: XXXXX + } + } + } + + 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 + // be added in the future. + // + email: { + // + // Set the following keys to configure: + // * "defaultFrom" to the reply address + // * "transport" to a configuration block that meets the + // requirements of Nodemailer (https://nodemailer.com/) + // + // Example: + // transport: { + // service: Zoho + // auth: { + // user: myuser@myhost.com + // pass: supersecretpassword + // } + // } + // + } + + // Message conferences and areas are within this block + messageConferences: { + // An entry here prepresents a conference taka aka confTag + another_sample_conf: { + name: "Another Sample Conference" + desc: "Another conf sample. Change me!" + areas: { + // Similar to confTags, this is a areaTag + another_sample_area: { + name: "Another Sample Area" + desc: "Another area example. Change me!" + // The 'sort' key can override natural sort order and can live at the conference and area levels + sort: 2 + } + } + } + } + + // Configuration block for scanner/tosser modules + scannerTossers: { + // The most popular being FTN/BSO style networks + ftn_bso: { + // + // When you're ready to hook up to FTN networks, please + // see the documentation on message networks. + // + } + } + + // + // ENiGMA½ comes with a very powerful File Base, but may be a bit strange + // until you get used to it. Please see the documentation! + // + fileBase: { + // + // Storage tags with relative paths (that is, paths that do not start + // with a "/") are relative to the following path: + // + areaStoragePrefix: XXXXX + + // + // Storage tags create a tag -> directory (relative or full path) + // that can be used in areas. + // + storageTags: { + // + // Example storage tag: "super_l33t_warez": + // super_l33t_warez: "/path/to/super/l33t/warez" + // + } + + areas: { + // + // Example area with the areaTag of "an_example_area": + // an_example_area: { + // name: "Example File Area" + // desc: "It's just an example, yo!" + // storageTags: [ + // "super_l33t_warez" + // ] + // } + // + // File Base Areas are read-only (ie: download only) by default. + // To make a uploadable area, set ACS as you like. For example, + // to allow all users to upload to an area: + // + // an_example_area: { + // // ... + // acs: { + // write: GM[users] + // } + // } + } + } + + // General user configuration + users: { + // + // ENiGMA½ utilizes user groups similar to Windows and *nix. Built in groups + // include "users" (for regular users) and "sysops" for +ops. You can add other + // groups to the system as well by adding a 'groups' key in this section: + // groups: [ + // "leet", "lamerz" + // ] + // + // + // Set default group(s) new users should automatically be assigned to: + // defaultGroups : [ + // "lamerz" + // ] + // + + // Should new users require +op activation? + requireActivation: false, + + // How long pre-authenticated users (have not logged in) can idle + preAuthIdleLogoutSeconds: XXXXX + + // How long authenticated users (logged in) can idle + idleLogoutSeconds: XXXXX + + // Usernames reserved for applying to your system + newUserNames: [] + + // Handling of failed logins + failedLogin : { + // disconnect after N failed attempts. 0=disabled. + disconnect : XXXXX + + // Lock the user out after N failed attempts. 0=disabled. + lockAccount : XXXXX + + // + // If locked out, how long until the user can login again? + // Set to 0 to disable auto-unlock + // + autoUnlockMinutes : XXXXX + }, + + // Allow email driven password resets to unlock accounts? + unlockAtEmailPwReset : XXXXX + } + + // Archive files and related + archives: { + archivers: { + // + // Each key in the "archivers" configuration block represents a specific + // external archive utility. ENiGMA½ has sane configuration by default + // for many archivers, but the tools themselves are likely not yet installed + // on your system! + // + // You'll want to have archivers configured for the many old-school archive + // formats that a BBS may encounter! Please consult the documentation on + // information as to where to find and install these utilities! + // + } + } + + fileTransferProtocols: { + // + // Each key in the "fileTransferProtocols" configuration block defines + // an external file transfer utility for legacy protocols such as + // X, Y, and Z-Modem. + // + // You will want to ensure your system has these external utilities + // installed and/or define new or additional protocols. Please + // see the documentation for more information! + // + } + + // + // Use the Event Scheduler to set up arbitrary scheduled events + // using Later style syntax and/or @watch files. + // See docs/event-scheduler.md for more information. + // + eventScheduler: { + events: { + // Example: + // + // sampleEvent: { + // schedule: every 2 hours + // action: @execute:/path/to/some/script.sh + // args: [ + // "--foo", "--bar" + // ] + // } + } + } + + statLog: { + systemEvents: { + // Max login history event records kept. -1 = unlimited + loginHistoryMax: -1 + } + } +} diff --git a/misc/descript_ion_export_entry_template.asc b/misc/descript_ion_export_entry_template.asc new file mode 100644 index 00000000..e7618280 --- /dev/null +++ b/misc/descript_ion_export_entry_template.asc @@ -0,0 +1 @@ +"{fileName}" {fileDesc} diff --git a/misc/file_list_entry.asc b/misc/file_list_entry.asc new file mode 100644 index 00000000..fe1785df --- /dev/null +++ b/misc/file_list_entry.asc @@ -0,0 +1,7 @@ +{fileName:<32.33} {fileSize!sizeWithAbbr:<8.7} {fileUploadTs} + + {fileDesc} + +tags: {fileHashTags} +sha1: {fileSha1} + diff --git a/misc/file_list_header.asc b/misc/file_list_header.asc new file mode 100644 index 00000000..4e307ca7 --- /dev/null +++ b/misc/file_list_header.asc @@ -0,0 +1,11 @@ +------------------------------------------------------------------------------- +{boardName} File Base List Export - Generated {nowTs} + +Search Criteria: + Area : {filterAreaName} + Terms: {filterTerms} + Tags : {filterHashTags} + +Total Files: {totalFileCount} / {totalFileSize!sizeWithAbbr} +------------------------------------------------------------------------------- + diff --git a/misc/gopher_banner.asc b/misc/gopher_banner.asc new file mode 100644 index 00000000..b758e066 --- /dev/null +++ b/misc/gopher_banner.asc @@ -0,0 +1,9 @@ +_____________________ _____ ____________________ __________\_ / +\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! +// __|___// | \// |// | \// | | \// \ /___ /_____ +/____ _____| __________ ___|__| ____| \ / _____ \ +---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + +------------------------------------------------------------------------------- diff --git a/misc/install.sh b/misc/install.sh index 44554da1..ed81d205 100755 --- a/misc/install.sh +++ b/misc/install.sh @@ -2,7 +2,8 @@ { # this ensures the entire script is downloaded before execution -ENIGMA_NODE_VERSION=${ENIGMA_NODE_VERSION:=6} +ENIGMA_NODE_VERSION=${ENIGMA_NODE_VERSION:=12} +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"` @@ -20,37 +21,36 @@ _____________________ _____ ____________________ __________\\_ / /__ _\\ <*> 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. -If this isn't what you were expecting, hit ctrl-c now. Installation will continue in ${WAIT_BEFORE_INSTALL} seconds... +If this isn't what you were expecting, hit CTRL-C now. Installation will continue in ${WAIT_BEFORE_INSTALL} seconds... EndOfMessage sleep ${WAIT_BEFORE_INSTALL} } +fatal_error() { + printf "${TIME_FORMAT} \e[41mERROR:\033[0m %b\n" "$*" >&2; + exit 1 +} + enigma_install_needs() { - command -v $1 >/dev/null 2>&1 || { log_error "ENiGMA½ requires $1 but it's not installed. Please install it and restart the installer."; exit 1; } + 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." } log() { printf "${TIME_FORMAT} %b\n" "$*"; } -log_error() { - printf "${TIME_FORMAT} \e[41mERROR:\033[0m %b\n" "$*" >&2; -} - 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() { @@ -66,34 +66,49 @@ configure_nvm() { } download_enigma_source() { - local INSTALL_DIR - INSTALL_DIR=${ENIGMA_INSTALL_DIR} + local INSTALL_DIR + INSTALL_DIR=${ENIGMA_INSTALL_DIR} - if [ -d "$INSTALL_DIR/.git" ]; then - log "ENiGMA½ is already installed in $INSTALL_DIR, trying to update using git" - command git --git-dir="$INSTALL_DIR"/.git --work-tree="$INSTALL_DIR" fetch 2> /dev/null || { - log_error "Failed to update ENiGMA½, run 'git fetch' in $INSTALL_DIR yourself." - exit 1 - } - else - log "Downloading ENiGMA½ from git to '$INSTALL_DIR'" - mkdir -p "$INSTALL_DIR" - command git clone ${ENIGMA_SOURCE} "$INSTALL_DIR" || { - log_error "Failed to clone ENiGMA½ repo. Please report this!" - exit 1 - } - fi + if [ -d "$INSTALL_DIR/.git" ]; then + log "ENiGMA½ is already installed in $INSTALL_DIR, trying to update using git" + command git --git-dir="$INSTALL_DIR"/.git --work-tree="$INSTALL_DIR" fetch 2> /dev/null || + fatal_error "Failed to update ENiGMA½, run 'git fetch' in $INSTALL_DIR yourself." + else + log "Downloading ENiGMA½ from git to '$INSTALL_DIR'" + mkdir -p "$INSTALL_DIR" + command git clone ${ENIGMA_SOURCE} "$INSTALL_DIR" || + fatal_error "Failed to clone ENiGMA½ repo. Please report this!" + fi +} + +is_arch_arm() { + local ARCH=`arch` + if [[ $ARCH == "arm"* ]]; then + true + else + false + fi +} + +extra_npm_install_args() { + if is_arch_arm ; then + echo "--build-from-source" + else + echo "" + fi } install_node_packages() { - log "Installing required Node packages" + log "Installing required Node packages..." + log "Note that on some systems such as RPi, this can take a VERY long time. Be patient!" + cd ${ENIGMA_INSTALL_DIR} - npm install + local EXTRA_NPM_ARGS=$(extra_npm_install_args) + git checkout ${ENIGMA_BRANCH} && npm install ${EXTRA_NPM_ARGS} if [ $? -eq 0 ]; then - log "npm package installation complete" + log "npm package installation complete" else - log_error "Failed to install ENiGMA½ npm packages. Please report this!" - exit 1 + fatal_error "Failed to install ENiGMA½ npm packages. Please report this!" fi } @@ -121,6 +136,8 @@ Additionally, the following support binaires are recommended: Debian/Ubuntu : apt-get install lrzsz CentOS : yum install lrzsz + See docs for more information! + EndOfMessage echo -e "\e[39m" } diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson new file mode 100644 index 00000000..57a3f761 --- /dev/null +++ b/misc/menu_template.in.hjson @@ -0,0 +1,4421 @@ +{ + /* + ./\/\.' ENiGMA½ Menu Configuration -/--/-------- - -- - + + _____________________ _____ ____________________ __________\_ / + \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! + // __|___// | \// |// | \// | | \// \ /___ /_____ + /____ _____| __________ ___|__| ____| \ / _____ \ + ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + + *-----------------------------------------------------------------------------* + + General Information + ------------------------------- - - + This configuration is in HJSON (http://hjson.org/) format. Strict to-spec + JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON. + + See http://hjson.org/ for more information and syntax. + + Various editors and IDEs such as Sublime Text 3, Visual Studio Code, and so + on have syntax highlighting for the HJSON format which are highly recommended. + + ------------------------------- -- - - + Menu Configuration + ------------------------------- - - + ENiGMA½ makes no assumptions about specific menu types (main, doors, etc.), + but instead allows full customization of all menus throughout the system. + Some menus such as a main menu are considered "standard" while others are + backed by a specific module. SysOps can tweak various settings about these + modules (look & feel, keyboard interation, and so on) or even fully replace + the module with something else. + + This file starts out as an example setup. Look at the examples, change + settings, menu ordering/flow, add/remove menus, implement ACS control, + etc.! + + Remember you can *live edit* this file. That is, make a change and save + while you're logged into the system and it will take effect on the next + menu change or screen refresh. + + Please see RTFM ...er, uh... see the documentation for more information, and + don't be shy to ask for help: + + BBS : Xibalba @ xibalba.l33t.codes + FTN : BBS Discussion on fsxNet + IRC : #enigma-bbs / FreeNode + Email : bryan@l33t.codes + */ + menus: { + // + // Send telnet connections to matrix where users can login, apply, etc. + // + telnetConnected: { + art: CONNECT + next: matrix + config: { nextTimeout: 1500 } + } + + // + // SSH connections are pre-authenticated via the SSH server itself. + // Jump directly to either the 2FA/OTP auth or the login sequence + // depending on user ACS. + // + sshConnected: { + art: CONNECT + next: [ + { + acs: AR2 + next: loginTwoFactorAuthOTPLoop + } + { + next: fullLoginSequenceLoginArt + } + ] + config: { nextTimeout: 1500 } + } + + // + // Another SSH specialization: If the user logs in with a new user + // name (e.g. "new", "apply", ...) they will be directed to the + // application process. + // + sshConnectedNewUser: { + art: CONNECT + next: newUserApplicationPreSsh + config: { nextTimeout: 1500 } + } + + // Ye ol' standard matrix + matrix: { + art: matrix + form: { + 0: { + VM: { + mci: { + VM1: { + submit: true + focus: true + argName: navSelect + items: [ + { + text: login + data: login + } + { + 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 + } + ] + } + } + submit: { + *: [ + { + value: { navSelect: "login" } + action: @menu:login + } + { + value: { navSelect: "apply" } + action: @menu:newUserApplicationPre + } + { + value: { navSelect: "forgot" } + action: @menu:forgotPassword + } + { + value: { navSelect: "logoff" } + action: @menu:logoff + } + ] + } + } + } + } + } + + login: { + art: USERLOG + 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 + disabled: loginAttemptAccountDisabled + locked: loginAttemptAccountLocked + } + form: { + 0: { + mci: { + ET1: { + maxLength: @config:users.usernameMax + argName: username + focus: true + } + ET2: { + password: true + maxLength: @config:users.passwordMax + argName: password + submit: true + } + } + submit: { + *: [ + { + value: { password: null } + action: @systemMethod:login + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + loginAttemptTooNode: { + art: TOONODE + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountLocked: { + art: ACCOUNTLOCKED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountDisabled: { + art: ACCOUNTDISABLED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountInactive: { + art: ACCOUNTINACTIVE + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + // + // 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 + submit: [ + { + value: { username: null } + action: @systemMethod:sendForgotPasswordEmail + extraArgs: { next: "forgotPasswordSubmitted" } + } + ] + } + + forgotPasswordSubmitted: { + desc: Forgot password + art: FORGOTPWSENT + config: { + cls: true + pause: true + } + next: @systemMethod:logoff + } + + // :TODO: Prompt Yes/No for logoff confirm + fullLogoffSequence: { + desc: Logging Off + prompt: logoffConfirmation + submit: [ + { + value: { promptValue: 0 } + action: @menu:fullLogoffSequencePreAd + } + { + value: { promptValue: 1 } + action: @systemMethod:prevMenu + } + ] + } + + fullLogoffSequencePreAd: { + art: PRELOGAD + desc: Logging Off + next: fullLogoffSequenceRandomBoardAd + config: { + cls: true + nextTimeout: 1500 + } + } + + fullLogoffSequenceRandomBoardAd: { + art: OTHRBBS + desc: Logging Off + next: logoff + config: { + baudRate: 57600 + pause: true + cls: true + } + } + + logoff: { + art: LOGOFF + desc: Logging Off + next: @systemMethod:logoff + } + + // A quick preamble - defaults to warning about broken terminals + newUserApplicationPre: { + art: NEWUSER1 + next: newUserApplication + desc: Applying + config: { + pause: true + cls: true + menuFlags: [ "noHistory" ] + } + } + + newUserApplication: { + module: nua + art: NUA + next: [ + { + // Initial SysOp does not send feedback to themselves + acs: ID1 + next: fullLoginSequenceLoginArt + } + { + // ...everyone else does + next: newUserFeedbackToSysOpPreamble + } + ] + form: { + 0: { + mci: { + ET1: { + focus: true + argName: username + maxLength: @config:users.usernameMax + validate: @systemMethod:validateUserNameAvail + } + ET2: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + } + MET3: { + argName: birthdate + maskPattern: "####/##/##" + validate: @systemMethod:validateBirthdate + } + ME4: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET6: { + argName: affils + maxLength: @config:users.affilsMax + } + ET7: { + argName: email + maxLength: @config:users.emailMax + validate: @systemMethod:validateEmailAvail + } + ET8: { + argName: web + maxLength: @config:users.webMax + } + ET9: { + argName: password + password: true + maxLength: @config:users.passwordMax + validate: @systemMethod:validatePasswordSpec + } + ET10: { + argName: passwordConfirm + password: true + maxLength: @config:users.passwordMax + validate: @method:validatePassConfirmMatch + } + TM12: { + argName: submission + items: [ "apply", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitApplication + extraArgs: { + inactive: userNeedsActivated + error: newUserCreateError + } + } + { + value: { "submission" : 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + // A quick preamble - defaults to warning about broken terminals (SSH version) + newUserApplicationPreSsh: { + art: NEWUSER1 + next: newUserApplicationSsh + desc: Applying + config: { + pause: true + cls: true + menuFlags: [ "noHistory" ] + } + } + + // + // SSH specialization of NUA + // Canceling this form logs off vs falling back to matrix + // + newUserApplicationSsh: { + module: nua + art: NUA + fallback: logoff + next: newUserFeedbackToSysOpPreamble + form: { + 0: { + mci: { + ET1: { + focus: true + argName: username + maxLength: @config:users.usernameMax + validate: @systemMethod:validateUserNameAvail + } + ET2: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + } + MET3: { + argName: birthdate + maskPattern: "####/##/##" + validate: @systemMethod:validateBirthdate + } + ME4: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET6: { + argName: affils + maxLength: @config:users.affilsMax + } + ET7: { + argName: email + maxLength: @config:users.emailMax + validate: @systemMethod:validateEmailAvail + } + ET8: { + argName: web + maxLength: @config:users.webMax + } + ET9: { + argName: password + password: true + maxLength: @config:users.passwordMax + validate: @systemMethod:validatePasswordSpec + } + ET10: { + argName: passwordConfirm + password: true + maxLength: @config:users.passwordMax + validate: @method:validatePassConfirmMatch + } + TM12: { + argName: submission + items: [ "apply", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitApplication + extraArgs: { + inactive: userNeedsActivated + error: newUserCreateError + } + } + { + value: { "submission" : 1 } + action: @systemMethod:logoff + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:logoff + } + ] + } + } + } + + newUserFeedbackToSysOpPreamble: { + art: LETTER + config: { pause: true } + next: newUserFeedbackToSysOp + } + + newUserFeedbackToSysOp: { + desc: Feedback to SysOp + module: msg_area_post_fse + next: [ + { + acs: AS2 + next: fullLoginSequenceLoginArt + } + { + next: newUserInactiveDone + } + ] + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + toUserId: 1 /* always to +op */ + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: @sysStat:sysop_username + maxLength: 36 + // :TODO: readOnly: true + } + ET3: { + argName: subject + maxLength: 72 + submit: true + text: New user feedback + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + newUserInactiveDone: { + desc: Finished with NUA + art: DONE + config: { pause: true } + next: @menu:logoff + } + + fullLoginSequenceLoginArt: { + desc: Logging In + art: WELCOME + config: { pause: true } + next: fullLoginSequenceLastCallers + } + + fullLoginSequenceLastCallers: { + desc: Last Callers + module: last_callers + art: LASTCALL + config: { + pause: true + font: cp437 + } + next: fullLoginSequenceWhosOnline + } + fullLoginSequenceWhosOnline: { + desc: Who's Online + module: whos_online + art: WHOSON + config: { pause: true } + next: fullLoginSequenceOnelinerz + } + + fullLoginSequenceOnelinerz: { + desc: Viewing Onelinerz + module: onelinerz + next: [ + { + // calls >= 2 + acs: NC2 + next: fullLoginSequenceNewScanConfirm + } + { + // new users - skip new scan + next: fullLoginSequenceUserStats + } + ] + config: { + cls: true + art: { + view: ONELINER + add: ONEADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: oneliner + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + fullLoginSequenceNewScanConfirm: { + desc: Logging In + prompt: loginGlobalNewScan + submit: [ + { + value: { promptValue: 0 } + action: @menu:fullLoginSequenceNewScan + } + { + value: { promptValue: 1 } + action: @menu:fullLoginSequenceUserStats + } + ] + } + + fullLoginSequenceNewScan: { + desc: Performing New Scan + module: new_scan + art: NEWSCAN + next: fullLoginSequenceSysStats + config: { + messageListMenu: newScanMessageList + } + } + + fullLoginSequenceSysStats: { + desc: System Stats + art: SYSSTAT + config: { pause: true } + next: fullLoginSequenceUserStats + } + fullLoginSequenceUserStats: { + desc: User Stats + art: STATUS + config: { pause: true } + next: mainMenu + } + + newScanMessageList: { + desc: New Messages + module: msg_list + art: NEWMSGS + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + TL6: { + // theme me! + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "x", "shift + x" ] + action: @method:fullExit + } + { + keys: [ "m", "shift + m" ] + action: @method:markAllRead + } + ] + } + } + } + + newScanFileBaseList: { + module: file_area_list + desc: New Files + config: { + art: { + browse: FNEWBRWSE + details: FDETAIL + detailsGeneral: FDETGEN + detailsNfo: FDETNFO + detailsFileList: FDETLST + help: FBHELP + } + } + form: { + 0: { + mci: { + MT1: { + mode: preview + ansiView: true + } + + HM2: { + focus: true + submit: true + argName: navSelect + items: [ + "prev", "next", "details", "toggle queue", "rate", "help", "quit" + ] + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFile + } + { + value: { navSelect: 1 } + action: @method:nextFile + } + { + value: { navSelect: 2 } + action: @method:viewDetails + } + { + value: { navSelect: 3 } + action: @method:toggleQueue + } + { + value: { navSelect: 4 } + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + value: { navSelect: 5 } + action: @method:displayHelp + } + { + value: { navSelect: 6 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "w", "shift + w" ] + action: @method:showWebDownloadLink + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "t", "shift + t" ] + action: @method:toggleQueue + } + { + keys: [ "v", "shift + v" ] + action: @method:viewDetails + } + { + keys: [ "r", "shift + r" ] + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + keys: [ "?" ] + action: @method:displayHelp + } + ] + } + + 1: { + mci: { + HM1: { + focus: true + submit: true + argName: navSelect + items: [ + "general", "nfo/readme", "file listing" + ] + } + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @method:detailsQuit + } + ] + } + + 2: { + // details - general + mci: {} + } + + 3: { + // details - nfo/readme + mci: { + MT1: { + mode: preview + } + } + } + + 4: { + // details - file listing + mci: { + VM1: { + + } + } + } + } + } + + /////////////////////////////////////////////////////////////////////// + // Main Menu + /////////////////////////////////////////////////////////////////////// + mainMenu: { + art: MMENU + desc: Main Menu + prompt: menuCommand + config: { + font: cp437 + interrupt: realtime + } + submit: [ + { + value: { command: "MSG" } + action: @menu:nodeMessage + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: { command: "D" } + action: @menu:doorMenu + } + { + value: { command: "F" } + action: @menu:fileBase + } + { + value: { command: "U" } + action: @menu:mainMenuUserList + } + { + value: { command: "L" } + action: @menu:mainMenuLastCallers + } + { + value: { command: "W" } + action: @menu:mainMenuWhosOnline + } + { + value: { command: "Y" } + action: @menu:mainMenuUserStats + } + { + value: { command: "M" } + action: @menu:messageArea + } + { + value: { command: "E" } + action: @menu:mailMenu + } + { + value: { command: "C" } + action: @menu:mainMenuUserConfig + } + { + value: { command: "S" } + action: @menu:mainMenuSystemStats + } + { + value: { command: "!" } + action: @menu:mainMenuGlobalNewScan + } + { + value: { command: "K" } + action: @menu:mainMenuFeedbackToSysOp + } + { + value: { command: "O" } + action: @menu:mainMenuOnelinerz + } + { + value: { command: "R" } + action: @menu:mainMenuRumorz + } + { + value: { command: "BBS"} + action: @menu:bbsList + } + { + 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 + } + ] + } + + mainMenuUserAchievementsEarned: { + desc: Achievements + module: user_achievements_earned + art: USERACHIEV + form: { + 0: { + mci: { + VM1: { + focus: true + } + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + 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 + art: NODEMSG + config: { + cls: true + art: { + header: NODEMSGHDR + footer: NODEMSGFTR + } + } + form: { + 0: { + mci: { + SM1: { + argName: node + } + ET2: { + argName: message + submit: true + } + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + submit: { + *: [ + { + value: { message: null } + action: @method:sendMessage + } + ] + } + } + } + } + + mainMenuLastCallers: { + desc: Last Callers + module: last_callers + art: LASTCALL + config: { pause: true } + } + + mainMenuWhosOnline: { + desc: Who's Online + module: whos_online + art: WHOSON + config: { pause: true } + } + + mainMenuUserStats: { + desc: User Stats + art: STATUS + config: { pause: true } + } + + mainMenuSystemStats: { + desc: System Stats + art: SYSSTAT + config: { pause: true } + } + + mainMenuUserList: { + desc: User Listing + module: user_list + art: USERLST + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + } + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + mainMenuUserConfig: { + module: user_config + art: CONFSCR + form: { + 0: { + mci: { + ET1: { + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + focus: true + } + ME2: { + argName: birthdate + maskPattern: "####/##/##" + } + ME3: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty + } + ET4: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET5: { + argName: affils + maxLength: @config:users.affilsMax + } + ET6: { + argName: email + maxLength: @config:users.emailMax + validate: @method:validateEmailAvail + } + ET7: { + argName: web + maxLength: @config:users.webMax + } + ME8: { + maskPattern: "##" + argName: termHeight + validate: @systemMethod:validateNonEmpty + } + SM9: { + argName: theme + } + ET10: { + argName: password + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassword + } + ET11: { + argName: passwordConfirm + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassConfirmMatch + } + TM25: { + argName: submission + items: [ "save", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { submission: 0 } + action: @method:saveChanges + } + { + value: { submission: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + mainMenuGlobalNewScan: { + desc: Performing New Scan + module: new_scan + art: NEWSCAN + config: { + messageListMenu: newScanMessageList + } + } + + mainMenuFeedbackToSysOp: { + desc: Feedback to SysOp + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + toUserId: 1 /* always to +op */ + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: @sysStat:sysop_username + maxLength: 36 + // :TODO: readOnly: true + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + mainMenuOnelinerz: { + desc: Viewing Onelinerz + module: onelinerz + config: { + cls: true + art: { + view: ONELINER + add: ONEADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: oneliner + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + mainMenuRumorz: { + desc: Rumorz + module: rumorz + config: { + cls: true + art: { + entries: RUMORS + add: RUMORADD + } + } + form: { + 0: { + mci: { + VM1: { + focus: false + height: 10 + } + TM2: { + argName: addOrExit + items: [ "yeah!", "nah" ] + "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } + submit: true + focus: true + } + } + submit: { + *: [ + { + value: { addOrExit: 0 } + action: @method:viewAddScreen + } + { + value: { addOrExit: null } + action: @systemMethod:nextMenu + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:nextMenu + } + ] + }, + 1: { + mci: { + ET1: { + focus: true + maxLength: 70 + argName: rumor + } + TL2: { + width: 60 + } + TM3: { + argName: addOrCancel + items: [ "add", "cancel" ] + "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } + submit: true + } + } + + submit: { + *: [ + { + value: { addOrCancel: 0 } + action: @method:addEntry + } + { + value: { addOrCancel: 1 } + action: @method:cancelAdd + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelAdd + } + ] + } + } + } + + bbsList: { + desc: Viewing BBS List + module: bbs_list + config: { + cls: true + art: { + entries: BBSLIST + add: BBSADD + } + } + + form: { + 0: { + mci: { + VM1: { maxLength: 32 } + TL2: { maxLength: 32 } + TL3: { maxLength: 32 } + TL4: { maxLength: 32 } + TL5: { maxLength: 32 } + TL6: { maxLength: 32 } + TL7: { maxLength: 32 } + TL8: { maxLength: 32 } + TL9: { maxLength: 32 } + } + actionKeys: [ + { + keys: [ "a" ] + action: @method:addBBS + } + { + // :TODO: add delete key + keys: [ "d" ] + action: @method:deleteBBS + } + { + keys: [ "q", "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + ET1: { + argName: name + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET2: { + argName: sysop + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET3: { + argName: telnet + maxLength: 32 + validate: @systemMethod:validateNonEmpty + } + ET4: { + argName: www + maxLength: 32 + } + ET5: { + argName: location + maxLength: 32 + } + ET6: { + argName: software + maxLength: 32 + } + ET7: { + argName: notes + maxLength: 32 + } + TM17: { + argName: submission + items: [ "save", "cancel" ] + submit: true + } + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:cancelSubmit + } + ] + + submit: { + *: [ + { + value: { "submission" : 0 } + action: @method:submitBBS + } + { + value: { "submission" : 1 } + action: @method:cancelSubmit + } + ] + } + } + } + } + + /////////////////////////////////////////////////////////////////////// + // Doors Menu + /////////////////////////////////////////////////////////////////////// + doorMenu: { + desc: Doors Menu + art: DOORMNU + prompt: menuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "G" } + action: @menu:logoff + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + // + // The system supports many ways of launching doors including + // modules for DoorParty!, BBSLink, etc. + // + // Below are some examples. See the documentation for more info. + // + { + value: { command: "ABRACADABRA" } + action: @menu:doorAbracadabraExample + } + { + value: { command: "TWBBSLINK" } + action: @menu:doorTradeWars2002BBSLinkExample + } + { + value: { command: "DP" } + action: @menu:doorPartyExample + } + { + value: { command: "CN" } + action: @menu:doorCombatNetExample + } + { + value: { command: "EXODUS" } + action: @menu:doorExodusCataclysm + } + ] + } + + // + // Local Door Example via abracadabra module + // + // This example assumes launch_door.sh (which is passed args) + // launches the door. + // + doorAbracadabraExample: { + desc: Abracadabra Example + module: abracadabra + config: { + name: Example Door + dropFileType: DORINFO + cmd: /home/enigma/DOS/scripts/launch_door.sh + args: [ + "{node}", + "{dropFile}", + "{srvPort}", + ], + nodeMax: 1 + tooManyArt: DOORMANY + io: socket + } + } + + // + // BBSLink Example (TradeWars 2000) + // + // Register @ https://bbslink.net/ + // + doorTradeWars2002BBSLinkExample: { + desc: Playing TW 2002 (BBSLink) + module: bbs_link + config: { + sysCode: XXXXXXXX + authCode: XXXXXXXX + schemeCode: XXXXXXXX + door: tw + } + } + + // + // DoorParty! Example + // + // Register @ http://throwbackbbs.com/ + // + doorPartyExample: { + desc: Using DoorParty! + module: door_party + config: { + username: XXXXXXXX + password: XXXXXXXX + bbsTag: XX + } + } + + // + // CombatNet Example + // + // Register @ http://combatnet.us/ + // + doorCombatNetExample: { + desc: Using CombatNet + module: combatnet + config: { + bbsTag: CBNxxx + password: XXXXXXXXX + } + } + + // + // Exodus Example (cataclysm) + // Register @ https://oddnetwork.org/exodus/ + // + doorExodusCataclysm: { + desc: Cataclysm + module: exodus + config: { + rejectUnauthorized: false + board: XXX + key: XXXXXXXX + door: cataclysm + } + } + + /////////////////////////////////////////////////////////////////////// + // Message Area Menu + /////////////////////////////////////////////////////////////////////// + messageArea: { + art: MSGMNU + desc: Message Area + prompt: messageMenuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "P" } + action: @menu:messageAreaNewPost + } + { + value: { command: "J" } + action: @menu:messageAreaChangeCurrentConference + } + { + value: { command: "C" } + action: @menu:messageAreaChangeCurrentArea + } + { + value: { command: "L" } + action: @menu:messageAreaMessageList + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: { command: "<" } + action: @systemMethod:prevConf + } + { + value: { command: ">" } + action: @systemMethod:nextConf + } + { + value: { command: "[" } + action: @systemMethod:prevArea + } + { + value: { command: "]" } + action: @systemMethod:nextArea + } + { + value: { command: "D" } + action: @menu:messageAreaSetNewScanDate + } + { + value: { command: "S" } + action: @menu:messageSearch + } + { + value: { command: "M" } + action: @menu:myMessages + } + { + value: { command: "A" } + action: @menu:editAutoSignature + } + { + value: 1 + action: @menu:messageArea + } + ] + } + + 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 + art: MSEARCH + config: { + messageListMenu: messageAreaSearchMessageList + } + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + SM3: { + argName: confTag + } + SM4: { + argName: areaTag + } + ET5: { + argName: toUserName + maxLength: @config:users.usernameMax + } + ET6: { + argName: fromUserName + maxLength: @config:users.usernameMax + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaSearchMessageList: { + desc: Message Search + module: msg_list + art: MSRCHLST + config: { + menuViewPost: messageAreaViewPost + } + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: message + } + TL6: { + // theme me! + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + 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 + config: { + pause: true + } + } + + messageAreaChangeCurrentConference: { + art: CCHANGE + module: msg_conf_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: conf + } + } + submit: { + *: [ + { + value: { conf: null } + action: @method:changeConference + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + messageAreaSetNewScanDate: { + module: set_newscan_date + desc: Message Base + art: SETMNSDATE + config: { + target: message + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + SM2: { + argName: targetSelection + submit: false + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + changeMessageConfPreArt: { + module: show_art + config: { + method: messageConf + key: confTag + pause: true + cls: true + menuFlags: [ "popParent", "noHistory" ] + } + } + + messageAreaChangeCurrentArea: { + // :TODO: rename this art to ACHANGE + art: CHANGE + module: msg_area_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: area + } + } + submit: { + *: [ + { + value: { area: null } + action: @method:changeArea + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + changeMessageAreaPreArt: { + module: show_art + config: { + method: messageArea + key: areaTag + pause: true + cls: true + menuFlags: [ "popParent", "noHistory" ] + } + } + + messageAreaMessageList: { + module: msg_list + art: MSGLIST + 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 + } + ] + } + } + } + + messageAreaViewPost: { + module: msg_area_view_fse + config: { + art: { + header: MSGVHDR + body: MSGBODY + footerView: MSGVFTR + help: MSGVHLP + }, + editorMode: view + editorType: area + } + form: { + 0: { + mci: { + // :TODO: ensure this block isn't even req. for theme to apply... + } + } + 1: { + mci: { + MT1: { + width: 79 + mode: preview + } + } + submit: { + *: [ + { + value: message + action: @method:editModeEscPressed + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + } + 2: { + TLTL: { + mci: { + TL1: { width: 5 } + TL2: { width: 4 } + } + } + } + 4: { + mci: { + HM1: { + // :TODO: (#)Jump/(L)Index (msg list)/Last + items: [ "prev", "next", "reply", "quit", "help" ] + focusItemIndex: 1 + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:prevMessage + } + { + value: { 1: 1 } + action: @method:nextMessage + } + { + value: { 1: 2 } + action: @method:replyMessage + extraArgs: { + menu: messageAreaReplyPost + } + } + { + value: { 1: 3 } + action: @systemMethod:prevMenu + } + { + value: { 1: 4 } + action: @method:viewModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "p", "shift + p" ] + action: @method:prevMessage + } + { + keys: [ "n", "shift + n" ] + action: @method:nextMessage + } + { + keys: [ "r", "shift + r" ] + action: @method:replyMessage + extraArgs: { + menu: messageAreaReplyPost + } + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "?" ] + action: @method:viewModeMenuHelp + } + { + keys: [ "down arrow", "up arrow", "page up", "page down" ] + action: @method:movementKeyPressed + } + ] + } + } + } + + messageAreaReplyPost: { + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + quote: MSGQUOT + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + } + editorMode: edit + editorType: area + } + form: { + 0: { + mci: { + // :TODO: use appropriate system properties for max lengths + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + validate: @systemMethod:validateNonEmpty + maxLength: 36 + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateNonEmpty + } + TL4: { + // :TODO: this is for RE: line (NYI) + //width: 27 + //textOverflow: ... + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + height: 14 + argName: message + mode: edit + } + } + submit: { + *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ], + viewId: 1 + } + ] + } + + 3: { + mci: { + HM1: { + items: [ "save", "discard", "quote", "help" ] + } + } + + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 }, + action: @method:editModeMenuQuote + } + { + value: { 1: 3 } + action: @method:editModeMenuHelp + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "s", "shift + s" ] + action: @method:editModeMenuSave + } + { + keys: [ "d", "shift + d" ] + action: @systemMethod:prevMenu + } + { + keys: [ "q", "shift + q" ] + action: @method:editModeMenuQuote + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + + // Quote builder + 5: { + mci: { + MT1: { + width: 79 + height: 7 + } + VM3: { + width: 79 + height: 4 + argName: quote + } + } + + submit: { + *: [ + { + value: { quote: null } + action: @method:appendQuoteEntry + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @method:quoteBuilderEscPressed + } + ] + } + } + } + // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu + messageAreaNewPost: { + desc: Posting message, + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + } + editorMode: edit + editorType: area + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + text: All + validate: @systemMethod:validateNonEmpty + maxLength: 36 + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateNonEmpty + // :TODO: Validate -> close/cancel if empty + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + + 1: { + "mci" : { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + } + 2: { + TLTL: { + mci: { + TL1: { width: 5 } + TL2: { width: 4 } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + "items" : [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + // :TODO: something like the following for overriding keymap + // this should only override specified entries. others will default + /* + "keyMap" : { + "accept" : [ "return" ] + } + */ + } + } + } + } + + + // + // User to User mail aka Email Menu + // + mailMenu: { + art: MAILMNU + desc: Mail Menu + prompt: menuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { command: "C" } + action: @menu:mailMenuCreateMessage + } + { + value: { command: "I" } + action: @menu:mailMenuInbox + } + { + value: { command: "Q" } + action: @systemMethod:prevMenu + } + { + value: { command: "G" } + action: @menu:fullLogoffSequence + } + { + value: 1 + action: @menu:mailMenu + } + ] + } + + mailMenuCreateMessage: { + desc: Mailing Someone + module: msg_area_post_fse + config: { + art: { + header: MSGEHDR + body: MSGBODY + footerEditor: MSGEFTR + footerEditorMenu: MSGEMFT + help: MSGEHLP + }, + editorMode: edit + editorType: email + messageAreaTag: private_mail + } + form: { + 0: { + mci: { + TL1: { + argName: from + } + ET2: { + argName: to + focus: true + validate: @systemMethod:validateGeneralMailAddressedTo + maxLength: 36 + } + ET3: { + argName: subject + maxLength: 72 + submit: true + validate: @systemMethod:validateMessageSubject + } + } + submit: { + 3: [ + { + value: { subject: null } + action: @method:headerSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + 1: { + mci: { + MT1: { + width: 79 + argName: message + mode: edit + } + } + + submit: { + *: [ { value: "message", action: "@method:editModeEscPressed" } ] + } + actionKeys: [ + { + keys: [ "escape" ] + viewId: 1 + } + ] + }, + 2: { + TLTL: { + mci: { + TL1: { + width: 5 + } + TL2: { + width: 4 + } + } + } + } + 3: { + HM: { + mci: { + HM1: { + // :TODO: clear + items: [ "save", "discard", "help" ] + } + } + submit: { + *: [ + { + value: { 1: 0 } + action: @method:editModeMenuSave + } + { + value: { 1: 1 } + action: @systemMethod:prevMenu + } + { + value: { 1: 2 } + action: @method:editModeMenuHelp + } + ] + } + actionKeys: [ + { + keys: [ "escape" ] + action: @method:editModeEscPressed + } + { + keys: [ "?" ] + action: @method:editModeMenuHelp + } + ] + } + } + } + } + + mailMenuInbox: { + module: msg_list + art: PRVMSGLIST + config: { + menuViewPost: messageAreaViewPost + messageAreaTag: private_mail + } + form: { + 0: { // main list + mci: { + VM1: { + focus: true + submit: true + argName: message + } + } + submit: { + *: [ + { + value: { message: null } + action: @method:selectMessage + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "delete", "d", "shift + d" ] + action: @method:deleteSelected + } + ] + } + 1: { // delete prompt form + submit: { + *: [ + { + value: { promptValue: 0 } + action: @method:deleteMessageYes + } + { + value: { promptValue: 1 } + action: @method:deleteMessageNo + } + ] + } + } + } + } + + //////////////////////////////////////////////////////////////////////// + // File Base + //////////////////////////////////////////////////////////////////////// + + fileBase: { + desc: File Base + art: FMENU + prompt: fileMenuCommand + config: { + interrupt: realtime + } + submit: [ + { + value: { menuOption: "L" } + action: @menu:fileBaseListEntries + } + { + value: { menuOption: "B" } + action: @menu:fileBaseBrowseByAreaSelect + } + { + value: { menuOption: "F" } + action: @menu:fileAreaFilterEditor + } + { + value: { menuOption: "Q" } + action: @systemMethod:prevMenu + } + { + value: { menuOption: "G" } + action: @menu:fullLogoffSequence + } + { + value: { menuOption: "D" } + action: @menu:fileBaseDownloadManager + } + { + value: { menuOption: "W" } + action: @menu:fileBaseWebDownloadManager + } + { + value: { menuOption: "U" } + action: @menu:fileBaseUploadFiles + } + { + value: { menuOption: "S" } + action: @menu:fileBaseSearch + } + { + value: { menuOption: "P" } + action: @menu:fileBaseSetNewScanDate + } + { + value: { menuOption: "E" } + action: @menu:fileBaseExportListFilter + } + ] + } + + fileBaseExportListFilter: { + module: file_base_search + art: FBLISTEXPSEARCH + config: { + fileBaseListEntriesMenu: fileBaseExportList + } + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + ET3: { + maxLength: 64 + argName: tags + } + SM4: { + maxLength: 64 + argName: areaIndex + } + SM5: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + "filename" + ] + argName: sortByIndex + } + SM6: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseExportList: { + module: file_base_user_list_export + art: FBLISTEXP + config: { + pause: true + templates: { + entry: file_list_entry.asc + } + } + form: { + 0: { + mci: { + TL1: { } + TL2: { } + } + } + } + } + + fileBaseExportListNoResults: { + desc: Browsing Files + art: FBNORES + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileBaseSetNewScanDate: { + module: set_newscan_date + desc: File Base + art: SETFNSDATE + config: { + target: file + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseListEntries: { + module: file_area_list + desc: Browsing Files + config: { + art: { + browse: FBRWSE + details: FDETAIL + detailsGeneral: FDETGEN + detailsNfo: FDETNFO + detailsFileList: FDETLST + help: FBHELP + } + } + form: { + 0: { + mci: { + MT1: { + mode: preview + } + + HM2: { + focus: true + submit: true + argName: navSelect + items: [ + "prev", "next", "details", "toggle queue", "rate", "change filter", "help", "quit" + ] + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFile + } + { + value: { navSelect: 1 } + action: @method:nextFile + } + { + value: { navSelect: 2 } + action: @method:viewDetails + } + { + value: { navSelect: 3 } + action: @method:toggleQueue + } + { + value: { navSelect: 4 } + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + value: { navSelect: 5 } + action: @menu:fileAreaFilterEditor + } + { + value: { navSelect: 6 } + action: @method:displayHelp + } + { + value: { navSelect: 7 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "w", "shift + w" ] + action: @method:showWebDownloadLink + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + { + keys: [ "t", "shift + t" ] + action: @method:toggleQueue + } + { + keys: [ "f", "shift + f" ] + action: @menu:fileAreaFilterEditor + } + { + keys: [ "v", "shift + v" ] + action: @method:viewDetails + } + { + keys: [ "r", "shift + r" ] + action: @menu:fileBaseGetRatingForSelectedEntry + } + { + keys: [ "?" ] + action: @method:displayHelp + } + ] + } + + 1: { + mci: { + HM1: { + focus: true + submit: true + argName: navSelect + items: [ + "general", "nfo/readme", "file listing" + ] + } + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @method:detailsQuit + } + ] + } + + 2: { + // details - general + mci: {} + } + + 3: { + // details - nfo/readme + mci: { + MT1: { + mode: preview + } + } + } + + 4: { + // details - file listing + mci: { + VM1: { + + } + } + } + } + } + + fileBaseBrowseByAreaSelect: { + desc: Browsing File Areas + module: file_base_area_select + art: FAREASEL + form: { + 0: { + mci: { + VM1: { + focus: true + argName: areaTag + } + } + + submit: { + *: [ + { + value: { areaTag: null } + action: @method:selectArea + } + ] + } + + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseGetRatingForSelectedEntry: { + desc: Rating a File + prompt: fileBaseRateEntryPrompt + config: { + cls: true + } + submit: [ + // :TODO: handle esc/q + { + // pass data back to caller + value: { rating: null } + action: @systemMethod:prevMenu + } + ] + } + + fileBaseListEntriesNoResults: { + desc: Browsing Files + art: FBNORES + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileBaseSearch: { + module: file_base_search + desc: Searching Files + art: FSEARCH + form: { + 0: { + mci: { + ET1: { + focus: true + argName: searchTerms + } + BT2: { + argName: search + text: search + submit: true + } + ET3: { + maxLength: 64 + argName: tags + } + SM4: { + maxLength: 64 + argName: areaIndex + } + SM5: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + "filename", + ] + argName: sortByIndex + } + SM6: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + BT7: { + argName: advancedSearch + text: advanced search + submit: true + } + } + + submit: { + *: [ + { + value: { search: null } + action: @method:search + } + { + value: { advancedSearch: null } + action: @method:search + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileAreaFilterEditor: { + desc: File Filter Editor + module: file_area_filter_edit + art: FFILEDT + form: { + 0: { + mci: { + ET1: { + argName: searchTerms + } + ET2: { + maxLength: 64 + argName: tags + } + SM3: { + maxLength: 64 + argName: areaIndex + } + SM4: { + items: [ + "upload date", + "uploaded by", + "downloads", + "rating", + "estimated year", + "size", + ] + argName: sortByIndex + } + SM5: { + items: [ + "decending", + "ascending" + ] + argName: orderByIndex + } + ET6: { + maxLength: 64 + argName: name + validate: @systemMethod:validateNonEmpty + } + HM7: { + focus: true + items: [ + "prev", "next", "make active", "save", "new", "delete" + ] + argName: navSelect + focusItemIndex: 1 + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:prevFilter + } + { + value: { navSelect: 1 } + action: @method:nextFilter + } + { + value: { navSelect: 2 } + action: @method:makeFilterActive + } + { + value: { navSelect: 3 } + action: @method:saveFilter + } + { + value: { navSelect: 4 } + action: @method:newFilter + } + { + value: { navSelect: 5 } + action: @method:deleteFilter + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseDownloadManager: { + desc: Download Manager + module: file_base_download_manager + config: { + art: { + queueManager: FDLMGR + /* + NYI + details: FDLDET + */ + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "download all", "quit" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:downloadAll + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "a", "shift + a" ] + action: @method:downloadAll + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseWebDownloadManager: { + desc: Web D/L Manager + module: file_base_web_download_manager + config: { + art: { + queueManager: FWDLMGR + batchList: BATDLINF + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "get batch link", "quit", "help" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:getBatchLink + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "b", "shift + b" ] + action: @method:getBatchLink + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseDownloadManagerEmptyQueue: { + desc: Empty Download Queue + art: FEMPTYQ + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + fileTransferProtocolSelection: { + desc: Protocol selection + module: file_transfer_protocol_select + art: FPROSEL + form: { + 0: { + mci: { + VM1: { + focus: true + argName: protocol + } + } + + submit: { + *: [ + { + value: { protocol: null } + action: @method:selectProtocol + } + ] + } + + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + + fileBaseUploadFiles: { + desc: Uploading + module: upload + config: { + interrupt: never + art: { + options: ULOPTS + fileDetails: ULDETAIL + processing: ULCHECK + dupes: ULDUPES + } + } + + form: { + // options + 0: { + mci: { + SM1: { + argName: areaSelect + focus: true + } + TM2: { + argName: uploadType + items: [ "blind", "supply filename" ] + } + ET3: { + argName: fileName + maxLength: 255 + validate: @method:validateNonBlindFileName + } + HM4: { + argName: navSelect + items: [ "continue", "cancel" ] + submit: true + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:optionsNavContinue + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + "actionKeys" : [ + { + "keys" : [ "escape" ], + action: @systemMethod:prevMenu + } + ] + } + + 1: { + mci: { } + } + + // file details entry + 2: { + mci: { + MT1: { + argName: shortDesc + tabSwitchesView: true + focus: true + } + + ET2: { + argName: tags + } + + ME3: { + argName: estYear + maskPattern: "####" + } + + BT4: { + argName: continue + text: continue + submit: true + } + } + + submit: { + *: [ + { + value: { continue: null } + action: @method:fileDetailsContinue + } + ] + } + } + + // dupes + 3: { + mci: { + VM1: { + /* + Use 'dupeInfoFormat' to custom format: + + areaDesc + areaName + areaTag + desc + descLong + fileId + fileName + fileSha256 + storageTag + uploadTimestamp + + */ + + mode: preview + } + } + } + } + } + + fileBaseNoUploadAreasAvail: { + desc: File Base + art: ULNOAREA + config: { + pause: true + menuFlags: [ "noHistory", "popParent" ] + } + } + + sendFilesToUser: { + desc: Downloading + module: file_transfer + config: { + // defaults - generally use extraArgs + protocol: zmodem8kSexyz + direction: send + } + } + + recvFilesFromUser: { + desc: Uploading + module: file_transfer + config: { + // defaults - generally use extraArgs + protocol: zmodem8kSexyz + direction: recv + } + } + + + //////////////////////////////////////////////////////////////////////// + // Required entries + //////////////////////////////////////////////////////////////////////// + idleLogoff: { + art: IDLELOG + next: @systemMethod:logoff + } + //////////////////////////////////////////////////////////////////////// + // Demo Section + // :TODO: This entire section needs updated!!! + //////////////////////////////////////////////////////////////////////// + "demoMain" : { + "art" : "demo_selection_vm.ans", + "form" : { + "0" : { + "VM" : { + "mci" : { + "VM1" : { + "items" : [ + "Single Line Text Editing Views", + "Spinner & Toggle Views", + "Mask Edit Views", + "Multi Line Text Editor", + "Vertical Menu Views", + "Horizontal Menu Views", + "Art Display", + "Full Screen Editor" + ], + "height" : 10, + "itemSpacing" : 1, + "justify" : "center", + "focusTextStyle" : "small i" + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@menu:demoEditTextView" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoSpinAndToggleView" + }, + { + "value" : { "1" : 2 }, + "action" : "@menu:demoMaskEditView" + }, + { + "value" : { "1" : 3 }, + "action" : "@menu:demoMultiLineEditTextView" + }, + { + "value" : { "1" : 4 }, + "action" : "@menu:demoVerticalMenuView" + }, + { + "value" : { "1" : 5 }, + "action" : "@menu:demoHorizontalMenuView" + }, + { + "value" : { "1" : 6 }, + "action" : "@menu:demoArtDisplay" + }, + { + "value" : { "1" : 7 }, + "action" : "@menu:demoFullScreenEditor" + } + ] + } + } + } + } + }, + "demoEditTextView" : { + "art" : "demo_edit_text_view1.ans", + "form" : { + "0" : { + "BTETETETET" : { + "mci" : { + "ET1" : { + "width" : 20, + "maxLength" : 20 + }, + "ET2" : { + "width" : 20, + "maxLength" : 40, + "textOverflow" : "..." + }, + "ET3" : { + "width" : 20, + "fillChar" : "-", + "styleSGR1" : "|00|36", + "maxLength" : 20 + }, + "ET4" : { + "width" : 20, + "maxLength" : 20, + "password" : true + }, + "BT5" : { + "width" : 8, + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoSpinAndToggleView" : { + "art" : "demo_spin_and_toggle.ans", + "form" : { + "0" : { + "BTSMSMTM" : { + "mci" : { + "SM1" : { + "items" : [ "Henry Morgan", "François l'Ollonais", "Roche Braziliano", "Black Bart", "Blackbeard" ] + }, + "SM2" : { + "items" : [ "Razor 1911", "DrinkOrDie", "TRSI" ] + }, + "TM3" : { + "items" : [ "Yarly", "Nowaii" ], + "styleSGR1" : "|00|30|01", + "hotKeys" : { "Y" : 0, "N" : 1 } + }, + "BT8" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 8, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 8 + } + ] + } + } + } + }, + "demoMaskEditView" : { + "art" : "demo_mask_edit_text_view1.ans", + "form" : { + "0" : { + "BTMEME" : { + "mci" : { + "ME1" : { + "maskPattern" : "##/##/##", + "styleSGR1" : "|00|30|01", + //"styleSGR2" : "|00|45|01", + "styleSGR3" : "|00|30|35", + "fillChar" : "#" + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoMultiLineEditTextView" : { + "art" : "demo_multi_line_edit_text_view1.ans", + "form" : { + "0" : { + "BTMT" : { + "mci" : { + "MT1" : { + "width" : 70, + "height" : 17, + //"text" : "@art:demo_multi_line_edit_text_view_text.txt", + // "text" : "@systemMethod:textFromFile" + text: "Hints:\n\t* Insert / CTRL-V toggles overtype mode\n\t* CTRL-Y deletes the current line\n\t* Try Page Up / Page Down\n\t* Home goes to the start of line text\n\t* End goes to the end of a line\n\n\nTab handling:\n-------------------------------------------------\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\nA0\tBB\t1\tCCC\t2\tDDD\t3EEEE\nW\t\tX\t\tY\t\tZ\n\nAn excerpt from A Clockwork Orange:\n\"What sloochatted then, of course, was that my cellmates woke up and started joining in, tolchocking a bit wild in the near-dark, and the shoom seemed to wake up the whole tier, so that you could slooshy a lot of creeching and banging about with tin mugs on the wall, as though all the plennies in all the cells thought a big break was about to commence, O my brothers.\n", + "focus" : true + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoHorizontalMenuView" : { + "art" : "demo_horizontal_menu_view1.ans", + "form" : { + "0" : { + "BTHMHM" : { + "mci" : { + "HM1" : { + "items" : [ "One", "Two", "Three" ], + "hotKeys" : { "1" : 0, "2" : 1, "3" : 2 } + }, + "HM2" : { + "items" : [ "Uno", "Dos", "Tres" ], + "hotKeys" : { "U" : 0, "D" : 1, "T" : 2 } + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + }, + "demoVerticalMenuView" : { + "art" : "demo_vertical_menu_view1.ans", + "form" : { + "0" : { + "BTVM" : { + "mci" : { + "VM1" : { + "items" : [ + "|33Oblivion/2", + "|33iNiQUiTY", + "|33ViSiON/X" + ], + "focusItems" : [ + "|33Oblivion|01/|00|332", + "|01|33i|00|33N|01i|00|33QU|01i|00|33TY", + "|33ViSiON/X" + ] + // + // :TODO: how to do the following: + // 1) Supply a view a string for a standard vs focused item + // "items" : [...], "focusItems" : [ ... ] ? + // "draw" : "@method:drawItemX", then items: [...] + }, + "BT5" : { + "text" : "< Back" + } + }, + "submit" : { + "*" : [ + { + "value" : 5, + "action" : "@menu:demoMain" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 5 + } + ] + } + } + } + + }, + "demoArtDisplay" : { + "art" : "demo_selection_vm.ans", + "form" : { + "0" : { + "VM" : { + "mci" : { + "VM1" : { + "items" : [ + "Defaults - DOS ANSI", + "bw_mindgames.ans - DOS", + "test.ans - DOS", + "Defaults - Amiga", + "Pause at Term Height" + ], + // :TODO: justify not working?? + "focusTextStyle" : "small i" + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@menu:demoDefaultsDosAnsi" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoDefaultsDosAnsi_bw_mindgames" + }, + { + "value" : { "1" : 2 }, + "action" : "@menu:demoDefaultsDosAnsi_test" + } + ] + } + } + } + } + }, + "demoDefaultsDosAnsi" : { + "art" : "DM-ENIG2.ANS" + }, + "demoDefaultsDosAnsi_bw_mindgames" : { + "art" : "bw_mindgames.ans" + }, + "demoDefaultsDosAnsi_test" : { + "art" : "test.ans" + }, + "demoFullScreenEditor" : { + "module" : "fse", + "config" : { + "editorType" : "netMail", + "art" : { + "header" : "demo_fse_netmail_header.ans", + "body" : "demo_fse_netmail_body.ans", + "footerEditor" : "demo_fse_netmail_footer_edit.ans", + "footerEditorMenu" : "demo_fse_netmail_footer_edit_menu.ans", + "footerView" : "demo_fse_netmail_footer_view.ans", + "help" : "demo_fse_netmail_help.ans" + } + }, + "form" : { + "0" : { + "ETETET" : { + "mci" : { + "ET1" : { + // :TODO: from/to may be set by args + // :TODO: focus may change dep on view vs edit + "width" : 36, + "focus" : true, + "argName" : "to" + }, + "ET2" : { + "width" : 36, + "argName" : "from" + }, + "ET3" : { + "width" : 65, + "maxLength" : 72, + "submit" : [ "enter" ], + "argName" : "subject" + } + }, + "submit" : { + "3" : [ + { + "value" : { "subject" : null }, + "action" : "@method:headerSubmit" + } + ] + } + } + }, + "1" : { + "MT" : { + "mci" : { + "MT1" : { + "width" : 79, + "height" : 17, + "text" : "", // :TODO: should not be req. + "argName" : "message" + } + }, + "submit" : { + "*" : [ + { + "value" : "message", + "action" : "@method:editModeEscPressed" + } + ] + }, + "actionKeys" : [ + { + "keys" : [ "escape" ], + "viewId" : 1 + } + ] + } + }, + "2" : { + "TLTL" : { + "mci" : { + "TL1" : { + "width" : 5 + }, + "TL2" : { + "width" : 4 + } + } + } + }, + "3" : { + "HM" : { + "mci" : { + "HM1" : { + // :TODO: Continue, Save, Discard, Clear, Quote, Help + "items" : [ "Save", "Discard", "Quote", "Help" ] + } + }, + "submit" : { + "*" : [ + { + "value" : { "1" : 0 }, + "action" : "@method:editModeMenuSave" + }, + { + "value" : { "1" : 1 }, + "action" : "@menu:demoMain" + }, + { + "value" : { "1" : 2 }, + "action" : "@method:editModeMenuQuote" + }, + { + "value" : { "1" : 3 }, + "action" : "@method:editModeMenuHelp" + }, + { + "value" : 1, + "action" : "@method:editModeEscPressed" + } + ] + }, + "actionKeys" : [ // :TODO: Need better name + { + "keys" : [ "escape" ], + "action" : "@method:editModeEscPressed" + } + ] + } + } + } + } + } +} diff --git a/misc/otp_register_email.template.html b/misc/otp_register_email.template.html new file mode 100644 index 00000000..f9fcd65b --- /dev/null +++ b/misc/otp_register_email.template.html @@ -0,0 +1,11 @@ +%USERNAME%:
+
+

+ You have requested to enable 2-Factor Authentication via One-Time-Password for your account on %BOARDNAME%.
+

+

+ If this was not you, please ignore this email and consider changing your password. Otherwise, please follow this link or copy and paste the link below:

+ %REGISTER_URL%
+

+ + diff --git a/misc/otp_register_email.template.txt b/misc/otp_register_email.template.txt new file mode 100644 index 00000000..c275515e --- /dev/null +++ b/misc/otp_register_email.template.txt @@ -0,0 +1,7 @@ +%USERNAME%: + +You have requested to enable 2-Factor Authentication via One-Time-Password for your account on %BOARDNAME%. + +If this was not you, please ignore this email and consider changing your password. Otherwise, please follow the link below: + +%REGISTER_URL% diff --git a/mods/prompt.hjson b/misc/prompt_template.in.hjson similarity index 91% rename from mods/prompt.hjson rename to misc/prompt_template.in.hjson index 83f01ec5..e5a50630 100644 --- a/mods/prompt.hjson +++ b/misc/prompt_template.in.hjson @@ -72,6 +72,20 @@ } } + loginSequenceFlavorSelect: { + art: LOGINSEL + mci: { + TM1: { + argName: promptValue + items: [ "yes", "no" ] + focus: true + focusItemIndex: 1 + hotKeys: { Y: 0, N: 1 } + hotKeySubmit: true + } + } + } + loginGlobalNewScan: { art: GNSPMPT mci: { @@ -119,6 +133,19 @@ } }, + deleteMessageFromListPrompt: { + art: MSGDELPMPT + mci: { + TM1: { + argName: promptValue + items: [ "yes", "no" ] + focus: true + hotKeys: { Y: 0, N: 1 } + hotKeySubmit: true + } + } + } + "newAreaPostPrompt" : { "art" : "message_area_new_post", "mci" : { @@ -206,7 +233,7 @@ // Any menu 'pause' will use this prompt // art: pause - options: { + config: { trailingLF: no } /* diff --git a/misc/vtx/vtx.html b/misc/vtx/vtx.html new file mode 100644 index 00000000..156246ed --- /dev/null +++ b/misc/vtx/vtx.html @@ -0,0 +1,27 @@ + + + + + + + + + + +
+
+ +
+
+ + \ No newline at end of file diff --git a/mods/.keep b/mods/.keep new file mode 100644 index 00000000..e69de29b diff --git a/mods/abracadabra.js b/mods/abracadabra.js deleted file mode 100644 index a84d2c63..00000000 --- a/mods/abracadabra.js +++ /dev/null @@ -1,197 +0,0 @@ -/* jslint node: true */ -'use strict'; - -const MenuModule = require('../core/menu_module.js').MenuModule; -const DropFile = require('../core/dropfile.js').DropFile; -const door = require('../core/door.js'); -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); - -const async = require('async'); -const assert = require('assert'); -const paths = require('path'); -const _ = require('lodash'); -const mkdirs = require('fs-extra').mkdirs; - -// :TODO: This should really be a system module... needs a little work to allow for such - -const activeDoorNodeInstances = {}; - -exports.moduleInfo = { - name : 'Abracadabra', - desc : 'External BBS Door Module', - author : 'NuSkooler', -}; - -/* - Example configuration for LORD under DOSEMU: - - { - config: { - name: PimpWars - dropFileType: DORINFO - cmd: qemu-system-i386 - args: [ - "-localtime", - "freedos.img", - "-chardev", - "socket,port={srvPort},nowait,host=localhost,id=s0", - "-device", - "isa-serial,chardev=s0" - ] - io: socket - } - } - - listen: socket | stdio - - { - "config" : { - "name" : "LORD", - "dropFileType" : "DOOR", - "cmd" : "/usr/bin/dosemu", - "args" : [ "-quiet", "-f", "/etc/dosemu/dosemu.conf", "X:\\PW\\START.BAT {dropfile} {node}" ] ], - "nodeMax" : 32, - "tooManyArt" : "toomany-lord.ans" - } - } - - :TODO: See Mystic & others for other arg options that we may need to support -*/ - -exports.getModule = class AbracadabraModule extends MenuModule { - constructor(options) { - super(options); - - this.config = options.menuConfig.config; - // :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... } - assert(_.isString(this.config.name, 'Config \'name\' is required')); - assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required')); - assert(_.isString(this.config.cmd, 'Config \'cmd\' is required')); - - this.config.nodeMax = this.config.nodeMax || 0; - this.config.args = this.config.args || []; - } - - /* - :TODO: - * disconnecting wile door is open leaves dosemu - * http://bbslink.net/sysop.php support - * Font support ala all other menus... or does this just work? - */ - - initSequence() { - const self = this; - - async.series( - [ - function validateNodeCount(callback) { - if(self.config.nodeMax > 0 && - _.isNumber(activeDoorNodeInstances[self.config.name]) && - activeDoorNodeInstances[self.config.name] + 1 > self.config.nodeMax) - { - self.client.log.info( - { - name : self.config.name, - activeCount : activeDoorNodeInstances[self.config.name] - }, - 'Too many active instances'); - - if(_.isString(self.config.tooManyArt)) { - theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() { - self.pausePrompt( () => { - callback(new Error('Too many active instances')); - }); - }); - } else { - self.client.term.write('\nToo many active instances. Try again later.\n'); - - // :TODO: Use MenuModule.pausePrompt() - self.pausePrompt( () => { - callback(new Error('Too many active instances')); - }); - } - } 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); - } - }, - function generateDropfile(callback) { - self.dropFile = new DropFile(self.client, self.config.dropFileType); - var fullPath = self.dropFile.fullPath; - - mkdirs(paths.dirname(fullPath), function dirCreated(err) { - if(err) { - callback(err); - } else { - self.dropFile.createFile(function created(err) { - callback(err); - }); - } - }); - } - ], - function complete(err) { - if(err) { - self.client.log.warn( { error : err.toString() }, 'Could not start door'); - self.lastError = err; - self.prevMenu(); - } else { - self.finishedLoading(); - } - } - ); - } - - runDoor() { - - const exeInfo = { - cmd : this.config.cmd, - args : this.config.args, - io : this.config.io || 'stdio', - encoding : this.config.encoding || this.client.term.outputEncoding, - dropFile : this.dropFile.fileName, - node : this.client.node, - //inhSocket : this.client.output._handle.fd, - }; - - const doorInstance = new door.Door(this.client, exeInfo); - - doorInstance.once('finished', () => { - // - // Try to clean up various settings such as scroll regions that may - // have been set within the door - // - this.client.term.rawWrite( - ansi.normal() + - ansi.goto(this.client.term.termHeight, this.client.term.termWidth) + - ansi.setScrollRegion() + - ansi.goto(this.client.term.termHeight, 0) + - '\r\n\r\n' - ); - - this.prevMenu(); - }); - - this.client.term.write(ansi.resetScreen()); - - doorInstance.run(); - } - - leave() { - super.leave(); - if(!this.lastError) { - activeDoorNodeInstances[this.config.name] -= 1; - } - } - - finishedLoading() { - this.runDoor(); - } -}; diff --git a/mods/art/CONNECT1.ANS b/mods/art/CONNECT1.ANS deleted file mode 100644 index d1a870bc..00000000 Binary files a/mods/art/CONNECT1.ANS and /dev/null differ diff --git a/mods/art/NEWSCAN.ANS b/mods/art/NEWSCAN.ANS deleted file mode 100644 index 96371880..00000000 Binary files a/mods/art/NEWSCAN.ANS and /dev/null differ diff --git a/mods/art/erc.ans b/mods/art/erc.ans deleted file mode 100644 index d2f336d2..00000000 Binary files a/mods/art/erc.ans and /dev/null differ diff --git a/mods/bbs_link.js b/mods/bbs_link.js deleted file mode 100644 index 0cf0a5db..00000000 --- a/mods/bbs_link.js +++ /dev/null @@ -1,207 +0,0 @@ -/* jslint node: true */ -'use strict'; - -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; - -const async = require('async'); -const _ = require('lodash'); -const http = require('http'); -const net = require('net'); -const crypto = require('crypto'); - -const packageJson = require('../package.json'); - -/* - Expected configuration block: - - { - module: bbs_link - ... - config: { - sysCode: XXXXX - authCode: XXXXX - schemeCode: XXXX - door: lord - - // default hoss: games.bbslink.net - host: games.bbslink.net - - // defualt port: 23 - port: 23 - } - } -*/ - -// :TODO: BUG: When a client disconnects, it's not handled very well -- the log is spammed with tons of errors -// :TODO: ENH: Support nodeMax and tooManyArt - -exports.moduleInfo = { - name : 'BBSLink', - desc : 'BBSLink Access Module', - author : 'NuSkooler', -}; - -exports.getModule = class BBSLinkModule extends MenuModule { - constructor(options) { - super(options); - - this.config = options.menuConfig.config; - this.config.host = this.config.host || 'games.bbslink.net'; - this.config.port = this.config.port || 23; - } - - initSequence() { - let token; - let randomKey; - let clientTerminated; - const self = this; - - async.series( - [ - function validateConfig(callback) { - if(_.isString(self.config.sysCode) && - _.isString(self.config.authCode) && - _.isString(self.config.schemeCode) && - _.isString(self.config.door)) - { - callback(null); - } else { - callback(new Error('Configuration is missing option(s)')); - } - }, - function acquireToken(callback) { - // - // Acquire an authentication token - // - crypto.randomBytes(16, function rand(ex, buf) { - if(ex) { - callback(ex); - } else { - randomKey = buf.toString('base64').substr(0, 6); - self.simpleHttpRequest('/token.php?key=' + randomKey, null, function resp(err, body) { - if(err) { - callback(err); - } else { - token = body.trim(); - self.client.log.trace( { token : token }, 'BBSLink token'); - callback(null); - } - }); - } - }); - }, - function authenticateToken(callback) { - // - // Authenticate the token we acquired previously - // - var headers = { - 'X-User' : self.client.user.userId.toString(), - 'X-System' : self.config.sysCode, - 'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'), - 'X-Code' : crypto.createHash('md5').update(self.config.schemeCode + token).digest('hex'), - 'X-Rows' : self.client.term.termHeight.toString(), - 'X-Key' : randomKey, - 'X-Door' : self.config.door, - 'X-Token' : token, - 'X-Type' : 'enigma-bbs', - 'X-Version' : packageJson.version, - }; - - self.simpleHttpRequest('/auth.php?key=' + randomKey, headers, function resp(err, body) { - var status = body.trim(); - - if('complete' === status) { - callback(null); - } else { - callback(new Error('Bad authentication status: ' + status)); - } - }); - }, - function createTelnetBridge(callback) { - // - // Authentication with BBSLink successful. Now, we need to create a telnet - // bridge from us to them - // - var connectOpts = { - port : self.config.port, - host : self.config.host, - }; - - var clientTerminated; - - self.client.term.write(resetScreen()); - self.client.term.write(' Connecting to BBSLink.net, please wait...\n'); - - var bridgeConnection = net.createConnection(connectOpts, function connected() { - self.client.log.info(connectOpts, 'BBSLink bridge connection established'); - - self.client.term.output.pipe(bridgeConnection); - - self.client.once('end', function clientEnd() { - self.client.log.info('Connection ended. Terminating BBSLink connection'); - clientTerminated = true; - bridgeConnection.end(); - }); - }); - - var restorePipe = function() { - self.client.term.output.unpipe(bridgeConnection); - self.client.term.output.resume(); - }; - - bridgeConnection.on('data', function incomingData(data) { - // pass along - // :TODO: just pipe this as well - self.client.term.rawWrite(data); - }); - - bridgeConnection.on('end', function connectionEnd() { - restorePipe(); - callback(clientTerminated ? new Error('Client connection terminated') : null); - }); - - bridgeConnection.on('error', function error(err) { - self.client.log.info('BBSLink bridge connection error: ' + err.message); - restorePipe(); - callback(err); - }); - } - ], - function complete(err) { - if(err) { - self.client.log.warn( { error : err.toString() }, 'BBSLink connection error'); - } - - if(!clientTerminated) { - self.prevMenu(); - } - } - ); - } - - simpleHttpRequest(path, headers, cb) { - const getOpts = { - host : this.config.host, - path : path, - headers : headers, - }; - - const req = http.get(getOpts, function response(resp) { - let data = ''; - - resp.on('data', function chunk(c) { - data += c; - }); - - resp.on('end', function respEnd() { - cb(null, data); - req.end(); - }); - }); - - req.on('error', function reqErr(err) { - cb(err); - }); - } -}; diff --git a/mods/bbs_list.js b/mods/bbs_list.js deleted file mode 100644 index e24beba6..00000000 --- a/mods/bbs_list.js +++ /dev/null @@ -1,430 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const getModDatabasePath = require('../core/database.js').getModDatabasePath; -const ViewController = require('../core/view_controller.js').ViewController; -const ansi = require('../core/ansi_term.js'); -const theme = require('../core/theme.js'); -const User = require('../core/user.js'); -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); -const sqlite3 = require('sqlite3'); -const _ = require('lodash'); - -// :TODO: add notes field - -const moduleInfo = exports.moduleInfo = { - name : 'BBS List', - desc : 'List of other BBSes', - author : 'Andrew Pamment', - packageName : 'com.magickabbs.enigma.bbslist' -}; - -const MciViewIds = { - view : { - BBSList : 1, - SelectedBBSName : 2, - SelectedBBSSysOp : 3, - SelectedBBSTelnet : 4, - SelectedBBSWww : 5, - SelectedBBSLoc : 6, - SelectedBBSSoftware : 7, - SelectedBBSNotes : 8, - SelectedBBSSubmitter : 9, - }, - add : { - BBSName : 1, - Sysop : 2, - Telnet : 3, - Www : 4, - Location : 5, - Software : 6, - Notes : 7, - Error : 8, - } -}; - -const FormIds = { - View : 0, - Add : 1, -}; - -const SELECTED_MCI_NAME_TO_ENTRY = { - SelectedBBSName : 'bbsName', - SelectedBBSSysOp : 'sysOp', - SelectedBBSTelnet : 'telnet', - SelectedBBSWww : 'www', - SelectedBBSLoc : 'location', - SelectedBBSSoftware : 'software', - SelectedBBSSubmitter : 'submitter', - SelectedBBSSubmitterId : 'submitterUserId', - SelectedBBSNotes : 'notes', -}; - -exports.getModule = class BBSListModule extends MenuModule { - constructor(options) { - super(options); - - const self = this; - this.menuMethods = { - // - // Validators - // - viewValidationListener : function(err, cb) { - const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error); - if(errMsgView) { - if(err) { - errMsgView.setText(err.message); - } else { - errMsgView.clearText(); - } - } - - return cb(null); - }, - - // - // Key & submit handlers - // - addBBS : function(formData, extraArgs, cb) { - self.displayAddScreen(cb); - }, - deleteBBS : function(formData, extraArgs, cb) { - const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList); - - if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) { - // must be owner or +op - return cb(null); - } - - const entry = self.entries[self.selectedBBS]; - if(!entry) { - return cb(null); - } - - self.database.run( - `DELETE FROM bbs_list - WHERE id=?;`, - [ entry.id ], - err => { - if (err) { - self.client.log.error( { err : err }, 'Error deleting from BBS list'); - } else { - self.entries.splice(self.selectedBBS, 1); - - self.setEntries(entriesView); - - if(self.entries.length > 0) { - entriesView.focusPrevious(); - } - - self.viewControllers.view.redrawAll(); - } - - return cb(null); - } - ); - }, - submitBBS : function(formData, extraArgs, cb) { - - let ok = true; - [ 'BBSName', 'Sysop', 'Telnet' ].forEach( mciName => { - if('' === self.viewControllers.add.getView(MciViewIds.add[mciName]).getData()) { - ok = false; - } - }); - if(!ok) { - // validators should prevent this! - return cb(null); - } - - self.database.run( - `INSERT INTO bbs_list (bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes) - VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, - [ formData.value.name, formData.value.sysop, formData.value.telnet, formData.value.www, formData.value.location, formData.value.software, self.client.user.userId, formData.value.notes ], - err => { - if(err) { - self.client.log.error( { err : err }, 'Error adding to BBS list'); - } - - self.clearAddForm(); - self.displayBBSList(true, cb); - } - ); - }, - cancelSubmit : function(formData, extraArgs, cb) { - self.clearAddForm(); - self.displayBBSList(true, cb); - } - }; - } - - initSequence() { - const self = this; - async.series( - [ - function beforeDisplayArt(callback) { - self.beforeArt(callback); - }, - function display(callback) { - self.displayBBSList(false, callback); - } - ], - err => { - if(err) { - // :TODO: Handle me -- initSequence() should really take a completion callback - } - self.finishedLoading(); - } - ); - } - - drawSelectedEntry(entry) { - if(!entry) { - Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { - this.setViewText('view', MciViewIds.view[mciName], ''); - }); - } else { - const youSubmittedFormat = this.menuConfig.youSubmittedFormat || '{submitter} (You!)'; - - Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { - const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]]; - if(MciViewIds.view[mciName]) { - - if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == this.client.user.userId) { - this.setViewText('view',MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry)); - } else { - this.setViewText('view',MciViewIds.view[mciName], t); - } - } - }); - } - } - - setEntries(entriesView) { - const config = this.menuConfig.config; - const listFormat = config.listFormat || '{bbsName}'; - const focusListFormat = config.focusListFormat || '{bbsName}'; - - entriesView.setItems(this.entries.map( e => stringFormat(listFormat, e) ) ); - entriesView.setFocusItems(this.entries.map( e => stringFormat(focusListFormat, e) ) ); - } - - displayBBSList(clearScreen, cb) { - const self = this; - - async.waterfall( - [ - function clearAndDisplayArt(callback) { - if(self.viewControllers.add) { - self.viewControllers.add.setFocus(false); - } - if (clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); - } - theme.displayThemedAsset( - self.menuConfig.config.art.entries, - self.client, - { font : self.menuConfig.font, trailingLF : false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function initOrRedrawViewController(artData, callback) { - if(_.isUndefined(self.viewControllers.add)) { - const vc = self.addViewController( - 'view', - new ViewController( { client : self.client, formId : FormIds.View } ) - ); - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds.View, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } else { - self.viewControllers.view.setFocus(true); - self.viewControllers.view.getView(MciViewIds.view.BBSList).redraw(); - return callback(null); - } - }, - function fetchEntries(callback) { - const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList); - self.entries = []; - - self.database.each( - `SELECT id, bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes - FROM bbs_list;`, - (err, row) => { - if (!err) { - self.entries.push({ - id : row.id, - bbsName : row.bbs_name, - sysOp : row.sysop, - telnet : row.telnet, - www : row.www, - location : row.location, - software : row.software, - submitterUserId : row.submitter_user_id, - notes : row.notes, - }); - } - }, - err => { - return callback(err, entriesView); - } - ); - }, - function getUserNames(entriesView, callback) { - async.each(self.entries, (entry, next) => { - User.getUserName(entry.submitterUserId, (err, username) => { - if(username) { - entry.submitter = username; - } else { - entry.submitter = 'N/A'; - } - return next(); - }); - }, () => { - return callback(null, entriesView); - }); - }, - function populateEntries(entriesView, callback) { - self.setEntries(entriesView); - - entriesView.on('index update', idx => { - const entry = self.entries[idx]; - - self.drawSelectedEntry(entry); - - if(!entry) { - self.selectedBBS = -1; - } else { - self.selectedBBS = idx; - } - }); - - if (self.selectedBBS >= 0) { - entriesView.setFocusItemIndex(self.selectedBBS); - self.drawSelectedEntry(self.entries[self.selectedBBS]); - } else if (self.entries.length > 0) { - entriesView.setFocusItemIndex(0); - self.drawSelectedEntry(self.entries[0]); - } - - entriesView.redraw(); - - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - displayAddScreen(cb) { - const self = this; - - async.waterfall( - [ - function clearAndDisplayArt(callback) { - self.viewControllers.view.setFocus(false); - self.client.term.rawWrite(ansi.resetScreen()); - - theme.displayThemedAsset( - self.menuConfig.config.art.add, - self.client, - { font : self.menuConfig.font }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function initOrRedrawViewController(artData, callback) { - if(_.isUndefined(self.viewControllers.add)) { - const vc = self.addViewController( - 'add', - new ViewController( { client : self.client, formId : FormIds.Add } ) - ); - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds.Add, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } else { - self.viewControllers.add.setFocus(true); - self.viewControllers.add.redrawAll(); - self.viewControllers.add.switchFocus(MciViewIds.add.BBSName); - return callback(null); - } - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - clearAddForm() { - [ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => { - this.setViewText('add', MciViewIds.add[mciName], ''); - }); - } - - initDatabase(cb) { - const self = this; - - async.series( - [ - function openDatabase(callback) { - self.database = new sqlite3.Database( - getModDatabasePath(moduleInfo), - callback - ); - }, - function createTables(callback) { - self.database.serialize( () => { - self.database.run( - `CREATE TABLE IF NOT EXISTS bbs_list ( - id INTEGER PRIMARY KEY, - bbs_name VARCHAR NOT NULL, - sysop VARCHAR NOT NULL, - telnet VARCHAR NOT NULL, - www VARCHAR, - location VARCHAR, - software VARCHAR, - submitter_user_id INTEGER NOT NULL, - notes VARCHAR - );` - ); - }); - callback(null); - } - ], - err => { - return cb(err); - } - ); - } - - beforeArt(cb) { - super.beforeArt(err => { - return err ? cb(err) : this.initDatabase(cb); - }); - } -}; diff --git a/mods/erc_client.js b/mods/erc_client.js deleted file mode 100644 index 02b42ad5..00000000 --- a/mods/erc_client.js +++ /dev/null @@ -1,179 +0,0 @@ -/* jslint node: true */ -'use strict'; - -const MenuModule = require('../core/menu_module.js').MenuModule; -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); -const _ = require('lodash'); -const net = require('net'); - -/* - Expected configuration block example: - - config: { - host: 192.168.1.171 - port: 5001 - bbsTag: SOME_TAG - } - -*/ - -exports.getModule = ErcClientModule; - -exports.moduleInfo = { - name : 'ENiGMA Relay Chat Client', - desc : 'Chat with other ENiGMA BBSes', - author : 'Andrew Pamment', -}; - -var MciViewIds = { - ChatDisplay : 1, - InputArea : 3, -}; - -// :TODO: needs converted to ES6 MenuModule subclass -function ErcClientModule(options) { - MenuModule.prototype.ctorShim.call(this, options); - - const self = this; - this.config = options.menuConfig.config; - - this.chatEntryFormat = this.config.chatEntryFormat || '[{bbsTag}] {userName}: {message}'; - this.systemEntryFormat = this.config.systemEntryFormat || '[*SYSTEM*] {message}'; - - this.finishedLoading = function() { - async.waterfall( - [ - function validateConfig(callback) { - if(_.isString(self.config.host) && - _.isNumber(self.config.port) && - _.isString(self.config.bbsTag)) - { - return callback(null); - } else { - return callback(new Error('Configuration is missing required option(s)')); - } - }, - function connectToServer(callback) { - const connectOpts = { - port : self.config.port, - host : self.config.host, - }; - - const chatMessageView = self.viewControllers.menu.getView(MciViewIds.ChatDisplay); - - chatMessageView.setText('Connecting to server...'); - chatMessageView.redraw(); - - self.viewControllers.menu.switchFocus(MciViewIds.InputArea); - - // :TODO: Track actual client->enig connection for optional prevMenu @ final CB - self.chatConnection = net.createConnection(connectOpts.port, connectOpts.host); - - self.chatConnection.on('data', data => { - data = data.toString(); - - if(data.startsWith('ERCHANDSHAKE')) { - self.chatConnection.write(`ERCMAGIC|${self.config.bbsTag}|${self.client.user.username}\r\n`); - } else if(data.startsWith('{')) { - try { - data = JSON.parse(data); - } catch(e) { - return self.client.log.warn( { error : e.message }, 'ERC: Error parsing ERC data from server'); - } - - let text; - try { - if(data.userName) { - // user message - text = stringFormat(self.chatEntryFormat, data); - } else { - // system message - text = stringFormat(self.systemEntryFormat, data); - } - } catch(e) { - return self.client.log.warn( { error : e.message }, 'ERC: chatEntryFormat error'); - } - - chatMessageView.addText(text); - - if(chatMessageView.getLineCount() > 30) { // :TODO: should probably be ChatDisplay.height? - chatMessageView.deleteLine(0); - chatMessageView.scrollDown(); - } - - chatMessageView.redraw(); - self.viewControllers.menu.switchFocus(MciViewIds.InputArea); - } - }); - - self.chatConnection.once('end', () => { - return callback(null); - }); - - self.chatConnection.once('error', err => { - self.client.log.info(`ERC connection error: ${err.message}`); - return callback(new Error('Failed connecting to ERC server!')); - }); - } - ], - err => { - if(err) { - self.client.log.warn( { error : err.message }, 'ERC error'); - } - - self.prevMenu(); - } - ); - }; - - this.scrollHandler = function(keyName) { - const inputAreaView = self.viewControllers.menu.getView(MciViewIds.InputArea); - const chatDisplayView = self.viewControllers.menu.getView(MciViewIds.ChatDisplay); - - if('up arrow' === keyName) { - chatDisplayView.scrollUp(); - } else { - chatDisplayView.scrollDown(); - } - - chatDisplayView.redraw(); - inputAreaView.setFocus(true); - }; - - - this.menuMethods = { - inputAreaSubmit : function(formData, extraArgs, cb) { - const inputAreaView = self.viewControllers.menu.getView(MciViewIds.InputArea); - const inputData = inputAreaView.getData(); - - if('/quit' === inputData.toLowerCase()) { - self.chatConnection.end(); - } else { - try { - self.chatConnection.write(`${inputData}\r\n`); - } catch(e) { - self.client.log.warn( { error : e.message }, 'ERC error'); - } - inputAreaView.clearText(); - } - return cb(null); - }, - scrollUp : function(formData, extraArgs, cb) { - self.scrollHandler(formData.key.name); - return cb(null); - }, - scrollDown : function(formData, extraArgs, cb) { - self.scrollHandler(formData.key.name); - return cb(null); - } - }; -} - -require('util').inherits(ErcClientModule, MenuModule); - -ErcClientModule.prototype.mciReady = function(mciData, cb) { - this.standardMCIReadyHandler(mciData, cb); -}; diff --git a/mods/file_area_filter_edit.js b/mods/file_area_filter_edit.js deleted file mode 100644 index cb3322f9..00000000 --- a/mods/file_area_filter_edit.js +++ /dev/null @@ -1,339 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../core/file_base_filter.js'); -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); - -exports.moduleInfo = { - name : 'File Area Filter Editor', - desc : 'Module for adding, deleting, and modifying file base filters', - author : 'NuSkooler', -}; - -const MciViewIds = { - editor : { - searchTerms : 1, - tags : 2, - area : 3, - sort : 4, - order : 5, - filterName : 6, - navMenu : 7, - - // :TODO: use the customs new standard thing - filter obj can have active/selected, etc. - selectedFilterInfo : 10, // { ...filter object ... } - activeFilterInfo : 11, // { ...filter object ... } - error : 12, // validation errors - } -}; - -exports.getModule = class FileAreaFilterEdit extends MenuModule { - constructor(options) { - super(options); - - this.filtersArray = new FileBaseFilters(this.client).toArray(); // ordered, such that we can index into them - this.currentFilterIndex = 0; // into |filtersArray| - - // - // Lexical sort + keep currently active filter (if any) as the first item in |filtersArray| - // - const activeFilter = FileBaseFilters.getActiveFilter(this.client); - this.filtersArray.sort( (filterA, filterB) => { - if(activeFilter) { - if(filterA.uuid === activeFilter.uuid) { - return -1; - } - if(filterB.uuid === activeFilter.uuid) { - return 1; - } - } - - return filterA.name.localeCompare(filterB.name, { sensitivity : false, numeric : true } ); - }); - - this.menuMethods = { - saveFilter : (formData, extraArgs, cb) => { - return this.saveCurrentFilter(formData, cb); - }, - prevFilter : (formData, extraArgs, cb) => { - this.currentFilterIndex -= 1; - if(this.currentFilterIndex < 0) { - this.currentFilterIndex = this.filtersArray.length - 1; - } - this.loadDataForFilter(this.currentFilterIndex); - return cb(null); - }, - nextFilter : (formData, extraArgs, cb) => { - this.currentFilterIndex += 1; - if(this.currentFilterIndex >= this.filtersArray.length) { - this.currentFilterIndex = 0; - } - this.loadDataForFilter(this.currentFilterIndex); - return cb(null); - }, - makeFilterActive : (formData, extraArgs, cb) => { - const filters = new FileBaseFilters(this.client); - filters.setActive(this.filtersArray[this.currentFilterIndex].uuid); - - this.updateActiveLabel(); - - return cb(null); - }, - newFilter : (formData, extraArgs, cb) => { - this.currentFilterIndex = this.filtersArray.length; // next avail slot - this.clearForm(MciViewIds.editor.searchTerms); - return cb(null); - }, - deleteFilter : (formData, extraArgs, cb) => { - const selectedFilter = this.filtersArray[this.currentFilterIndex]; - const filterUuid = selectedFilter.uuid; - - // cannot delete built-in/system filters - if(true === selectedFilter.system) { - this.showError('Cannot delete built in filters!'); - return cb(null); - } - - this.filtersArray.splice(this.currentFilterIndex, 1); // remove selected entry - - // remove from stored properties - const filters = new FileBaseFilters(this.client); - filters.remove(filterUuid); - filters.persist( () => { - - // - // If the item was also the active filter, we need to make a new one active - // - if(filterUuid === this.client.user.properties.file_base_filter_active_uuid) { - const newActive = this.filtersArray[this.currentFilterIndex]; - if(newActive) { - filters.setActive(newActive.uuid); - } else { - // nothing to set active to - this.client.user.removeProperty('file_base_filter_active_uuid'); - } - } - - // update UI - this.updateActiveLabel(); - - if(this.filtersArray.length > 0) { - this.loadDataForFilter(this.currentFilterIndex); - } else { - this.clearForm(); - } - return cb(null); - }); - }, - - viewValidationListener : (err, cb) => { - const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error); - let newFocusId; - - if(errorView) { - if(err) { - errorView.setText(err.message); - err.view.clearText(); // clear out the invalid data - } else { - errorView.clearText(); - } - } - - return cb(newFocusId); - }, - }; - } - - showError(errMsg) { - const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error); - if(errorView) { - if(errMsg) { - errorView.setText(errMsg); - } else { - errorView.clearText(); - } - } - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.addViewController( 'editor', new ViewController( { client : this.client } ) ); - - async.series( - [ - function loadFromConfig(callback) { - return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); - }, - function populateAreas(callback) { - self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); - - const areasView = vc.getView(MciViewIds.editor.area); - if(areasView) { - areasView.setItems( self.availAreas.map( a => a.name ) ); - } - - self.updateActiveLabel(); - self.loadDataForFilter(self.currentFilterIndex); - self.viewControllers.editor.resetInitialFocus(); - return callback(null); - } - ], - err => { - return cb(err); - } - ); - }); - } - - getCurrentFilter() { - return this.filtersArray[this.currentFilterIndex]; - } - - setText(mciId, text) { - const view = this.viewControllers.editor.getView(mciId); - if(view) { - view.setText(text); - } - } - - updateActiveLabel() { - const activeFilter = FileBaseFilters.getActiveFilter(this.client); - if(activeFilter) { - const activeFormat = this.menuConfig.config.activeFormat || '{name}'; - this.setText(MciViewIds.editor.activeFilterInfo, stringFormat(activeFormat, activeFilter)); - } - } - - setFocusItemIndex(mciId, index) { - const view = this.viewControllers.editor.getView(mciId); - if(view) { - view.setFocusItemIndex(index); - } - } - - clearForm(newFocusId) { - [ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => { - this.setText(mciId, ''); - }); - - [ MciViewIds.editor.area, MciViewIds.editor.order, MciViewIds.editor.sort ].forEach(mciId => { - this.setFocusItemIndex(mciId, 0); - }); - - if(newFocusId) { - this.viewControllers.editor.switchFocus(newFocusId); - } else { - this.viewControllers.editor.resetInitialFocus(); - } - } - - getSelectedAreaTag(index) { - if(0 === index) { - return ''; // -ALL- - } - const area = this.availAreas[index]; - if(!area) { - return ''; - } - return area.areaTag; - } - - getOrderBy(index) { - return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; - } - - setAreaIndexFromCurrentFilter() { - let index; - const filter = this.getCurrentFilter(); - if(filter) { - // special treatment: areaTag saved as blank ("") if -ALL- - index = (filter.areaTag && this.availAreas.findIndex(area => filter.areaTag === area.areaTag)) || 0; - } else { - index = 0; - } - this.setFocusItemIndex(MciViewIds.editor.area, index); - } - - setOrderByFromCurrentFilter() { - let index; - const filter = this.getCurrentFilter(); - if(filter) { - index = FileBaseFilters.OrderByValues.findIndex( ob => filter.order === ob ) || 0; - } else { - index = 0; - } - this.setFocusItemIndex(MciViewIds.editor.order, index); - } - - setSortByFromCurrentFilter() { - let index; - const filter = this.getCurrentFilter(); - if(filter) { - index = FileBaseFilters.SortByValues.findIndex( sb => filter.sort === sb ) || 0; - } else { - index = 0; - } - this.setFocusItemIndex(MciViewIds.editor.sort, index); - } - - getSortBy(index) { - return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; - } - - setFilterValuesFromFormData(filter, formData) { - filter.name = formData.value.name; - filter.areaTag = this.getSelectedAreaTag(formData.value.areaIndex); - filter.terms = formData.value.searchTerms; - filter.tags = formData.value.tags; - filter.order = this.getOrderBy(formData.value.orderByIndex); - filter.sort = this.getSortBy(formData.value.sortByIndex); - } - - saveCurrentFilter(formData, cb) { - const filters = new FileBaseFilters(this.client); - const selectedFilter = this.filtersArray[this.currentFilterIndex]; - - if(selectedFilter) { - // *update* currently selected filter - this.setFilterValuesFromFormData(selectedFilter, formData); - filters.replace(selectedFilter.uuid, selectedFilter); - } else { - // add a new entry; note that UUID will be generated - const newFilter = {}; - this.setFilterValuesFromFormData(newFilter, formData); - - // set current to what we just saved - newFilter.uuid = filters.add(newFilter); - - // add to our array (at current index position) - this.filtersArray[this.currentFilterIndex] = newFilter; - } - - return filters.persist(cb); - } - - loadDataForFilter(filterIndex) { - const filter = this.filtersArray[filterIndex]; - if(filter) { - this.setText(MciViewIds.editor.searchTerms, filter.terms); - this.setText(MciViewIds.editor.tags, filter.tags); - this.setText(MciViewIds.editor.filterName, filter.name); - - this.setAreaIndexFromCurrentFilter(); - this.setSortByFromCurrentFilter(); - this.setOrderByFromCurrentFilter(); - } - } -}; diff --git a/mods/file_area_list.js b/mods/file_area_list.js deleted file mode 100644 index 076d2a98..00000000 --- a/mods/file_area_list.js +++ /dev/null @@ -1,684 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const ansi = require('../core/ansi_term.js'); -const theme = require('../core/theme.js'); -const FileEntry = require('../core/file_entry.js'); -const stringFormat = require('../core/string_format.js'); -const FileArea = require('../core/file_base_area.js'); -const Errors = require('../core/enig_error.js').Errors; -const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled; -const ArchiveUtil = require('../core/archive_util.js'); -const Config = require('../core/config.js').config; -const DownloadQueue = require('../core/download_queue.js'); -const FileAreaWeb = require('../core/file_area_web.js'); -const FileBaseFilters = require('../core/file_base_filter.js'); -const resolveMimeType = require('../core/mime_util.js').resolveMimeType; -const isAnsi = require('../core/string_util.js').isAnsi; - -// deps -const async = require('async'); -const _ = require('lodash'); -const moment = require('moment'); - -exports.moduleInfo = { - name : 'File Area List', - desc : 'Lists contents of file an file area', - author : 'NuSkooler', -}; - -const FormIds = { - browse : 0, - details : 1, - detailsGeneral : 2, - detailsNfo : 3, - detailsFileList : 4, -}; - -const MciViewIds = { - browse : { - desc : 1, - navMenu : 2, - - customRangeStart : 10, // 10+ = customs - }, - details : { - navMenu : 1, - infoXyTop : 2, // %XY starting position for info area - infoXyBottom : 3, - - customRangeStart : 10, // 10+ = customs - }, - detailsGeneral : { - customRangeStart : 10, // 10+ = customs - }, - detailsNfo : { - nfo : 1, - - customRangeStart : 10, // 10+ = customs - }, - detailsFileList : { - fileList : 1, - - customRangeStart : 10, // 10+ = customs - }, -}; - -exports.getModule = class FileAreaList extends MenuModule { - - constructor(options) { - super(options); - - this.filterCriteria = _.get(options, 'extraArgs.filterCriteria'); - this.fileList = _.get(options, 'extraArgs.fileList'); - - if(this.fileList) { - // we'll need to adjust position as well! - this.fileListPosition = 0; - } - - this.dlQueue = new DownloadQueue(this.client); - - if(!this.filterCriteria) { - this.filterCriteria = FileBaseFilters.getActiveFilter(this.client); - } - - if(_.isString(this.filterCriteria)) { - this.filterCriteria = JSON.parse(this.filterCriteria); - } - - if(_.has(options, 'lastMenuResult.value')) { - this.lastMenuResultValue = options.lastMenuResult.value; - } - - this.menuMethods = { - nextFile : (formData, extraArgs, cb) => { - if(this.fileListPosition + 1 < this.fileList.length) { - this.fileListPosition += 1; - - return this.displayBrowsePage(true, cb); // true=clerarScreen - } - - return cb(null); - }, - prevFile : (formData, extraArgs, cb) => { - if(this.fileListPosition > 0) { - --this.fileListPosition; - - return this.displayBrowsePage(true, cb); // true=clearScreen - } - - return cb(null); - }, - viewDetails : (formData, extraArgs, cb) => { - this.viewControllers.browse.setFocus(false); - return this.displayDetailsPage(cb); - }, - detailsQuit : (formData, extraArgs, cb) => { - [ 'detailsNfo', 'detailsFileList', 'details' ].forEach(n => { - const vc = this.viewControllers[n]; - if(vc) { - vc.detachClientEvents(); - } - }); - - return this.displayBrowsePage(true, cb); // true=clearScreen - }, - toggleQueue : (formData, extraArgs, cb) => { - this.dlQueue.toggle(this.currentFileEntry); - this.updateQueueIndicator(); - return cb(null); - }, - showWebDownloadLink : (formData, extraArgs, cb) => { - return this.fetchAndDisplayWebDownloadLink(cb); - }, - displayHelp : (formData, extraArgs, cb) => { - return this.displayHelpPage(cb); - } - }; - } - - enter() { - super.enter(); - } - - leave() { - super.leave(); - } - - getSaveState() { - return { - fileList : this.fileList, - fileListPosition : this.fileListPosition, - }; - } - - restoreSavedState(savedState) { - if(savedState) { - this.fileList = savedState.fileList; - this.fileListPosition = savedState.fileListPosition; - } - } - - updateFileEntryWithMenuResult(cb) { - if(!this.lastMenuResultValue) { - return cb(null); - } - - if(_.isNumber(this.lastMenuResultValue.rating)) { - const fileId = this.fileList[this.fileListPosition]; - FileEntry.persistUserRating(fileId, this.client.user.userId, this.lastMenuResultValue.rating, err => { - if(err) { - this.client.log.warn( { error : err.message, fileId : fileId }, 'Failed to persist file rating' ); - } - return cb(null); - }); - } else { - return cb(null); - } - } - - initSequence() { - const self = this; - - async.series( - [ - function preInit(callback) { - return self.updateFileEntryWithMenuResult(callback); - }, - function beforeArt(callback) { - return self.beforeArt(callback); - }, - function display(callback) { - return self.displayBrowsePage(false, err => { - if(err && 'NORESULTS' === err.reasonCode) { - self.gotoMenu(self.menuConfig.config.noResultsMenu || 'fileBaseListEntriesNoResults'); - } - return callback(err); - }); - } - ], - () => { - self.finishedLoading(); - } - ); - } - - populateCurrentEntryInfo(cb) { - const config = this.menuConfig.config; - const currEntry = this.currentFileEntry; - - const uploadTimestampFormat = config.browseUploadTimestampFormat || config.uploadTimestampFormat || 'YYYY-MMM-DD'; - const area = FileArea.getFileAreaByTag(currEntry.areaTag); - const hashTagsSep = config.hashTagsSep || ', '; - const isQueuedIndicator = config.isQueuedIndicator || 'Y'; - const isNotQueuedIndicator = config.isNotQueuedIndicator || 'N'; - - const entryInfo = currEntry.entryInfo = { - fileId : currEntry.fileId, - areaTag : currEntry.areaTag, - areaName : _.get(area, 'name') || 'N/A', - areaDesc : _.get(area, 'desc') || 'N/A', - fileSha256 : currEntry.fileSha256, - fileName : currEntry.fileName, - desc : currEntry.desc || '', - descLong : currEntry.descLong || '', - userRating : currEntry.userRating, - uploadTimestamp : moment(currEntry.uploadTimestamp).format(uploadTimestampFormat), - hashTags : Array.from(currEntry.hashTags).join(hashTagsSep), - isQueued : this.dlQueue.isQueued(currEntry) ? isQueuedIndicator : isNotQueuedIndicator, - webDlLink : '', // :TODO: fetch web any existing web d/l link - webDlExpire : '', // :TODO: fetch web d/l link expire time - }; - - // - // We need the entry object to contain meta keys even if they are empty as - // consumers may very likely attempt to use them - // - const metaValues = FileEntry.WellKnownMetaValues; - metaValues.forEach(name => { - const value = !_.isUndefined(currEntry.meta[name]) ? currEntry.meta[name] : 'N/A'; - entryInfo[_.camelCase(name)] = value; - }); - - if(entryInfo.archiveType) { - const mimeType = resolveMimeType(entryInfo.archiveType); - entryInfo.archiveTypeDesc = mimeType ? _.get(Config, [ 'fileTypes', mimeType, 'desc' ] ) || mimeType : entryInfo.archiveType; - } else { - entryInfo.archiveTypeDesc = 'N/A'; - } - - entryInfo.uploadByUsername = entryInfo.uploadByUsername || 'N/A'; // may be imported - entryInfo.hashTags = entryInfo.hashTags || '(none)'; - - // create a rating string, e.g. "**---" - const userRatingTicked = config.userRatingTicked || '*'; - const userRatingUnticked = config.userRatingUnticked || ''; - entryInfo.userRating = ~~Math.round(entryInfo.userRating) || 0; // be safe! - entryInfo.userRatingString = userRatingTicked.repeat(entryInfo.userRating); - if(entryInfo.userRating < 5) { - entryInfo.userRatingString += userRatingUnticked.repeat( (5 - entryInfo.userRating) ); - } - - FileAreaWeb.getExistingTempDownloadServeItem(this.client, this.currentFileEntry, (err, serveItem) => { - if(err) { - entryInfo.webDlExpire = ''; - if(ErrNotEnabled === err.reasonCode) { - entryInfo.webDlExpire = config.webDlLinkNoWebserver || 'Web server is not enabled'; - } else { - entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated'; - } - } else { - const webDlExpireTimeFormat = config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - - entryInfo.webDlLink = serveItem.url; - entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); - } - - return cb(null); - }); - } - - populateCustomLabels(category, startId) { - return this.updateCustomViewTextsWithFilter(category, startId, this.currentFileEntry.entryInfo); - } - - displayArtAndPrepViewController(name, options, cb) { - const self = this; - const config = this.menuConfig.config; - - async.waterfall( - [ - function readyAndDisplayArt(callback) { - if(options.clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); - } - - theme.displayThemedAsset( - config.art[name], - self.client, - { font : self.menuConfig.font, trailingLF : false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function prepeareViewController(artData, callback) { - if(_.isUndefined(self.viewControllers[name])) { - const vcOpts = { - client : self.client, - formId : FormIds[name], - }; - - if(!_.isUndefined(options.noInput)) { - vcOpts.noInput = options.noInput; - } - - const vc = self.addViewController(name, new ViewController(vcOpts)); - - if('details' === name) { - try { - self.detailsInfoArea = { - top : artData.mciMap.XY2.position, - bottom : artData.mciMap.XY3.position, - }; - } catch(e) { - return callback(Errors.DoesNotExist('Missing XY2 and XY3 position indicators!')); - } - } - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds[name], - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } - - self.viewControllers[name].setFocus(true); - return callback(null); - - }, - ], - err => { - return cb(err); - } - ); - } - - displayBrowsePage(clearScreen, cb) { - const self = this; - - async.series( - [ - function fetchEntryData(callback) { - if(self.fileList) { - return callback(null); - } - return self.loadFileIds(false, callback); // false=do not force - }, - function checkEmptyResults(callback) { - if(0 === self.fileList.length) { - return callback(Errors.General('No results for criteria', 'NORESULTS')); - } - return callback(null); - }, - function prepArtAndViewController(callback) { - return self.displayArtAndPrepViewController('browse', { clearScreen : clearScreen }, callback); - }, - function loadCurrentFileInfo(callback) { - self.currentFileEntry = new FileEntry(); - - self.currentFileEntry.load( self.fileList[ self.fileListPosition ], err => { - if(err) { - return callback(err); - } - - return self.populateCurrentEntryInfo(callback); - }); - }, - function populateDesc(callback) { - if(_.isString(self.currentFileEntry.desc)) { - const descView = self.viewControllers.browse.getView(MciViewIds.browse.desc); - if(descView) { - if(isAnsi(self.currentFileEntry.desc)) { - descView.setAnsi( - self.currentFileEntry.desc, - { - prepped : false, - forceLineTerm : true - }, - () => { - return callback(null); - } - ); - } else { - descView.setText(self.currentFileEntry.desc); - return callback(null); - } - } - } else { - return callback(null); - } - }, - function populateAdditionalViews(callback) { - self.updateQueueIndicator(); - self.populateCustomLabels('browse', MciViewIds.browse.customRangeStart); - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - displayDetailsPage(cb) { - const self = this; - - async.series( - [ - function prepArtAndViewController(callback) { - return self.displayArtAndPrepViewController('details', { clearScreen : true }, callback); - }, - function populateViews(callback) { - self.populateCustomLabels('details', MciViewIds.details.customRangeStart); - return callback(null); - }, - function prepSection(callback) { - return self.displayDetailsSection('general', false, callback); - }, - function listenNavChanges(callback) { - const navMenu = self.viewControllers.details.getView(MciViewIds.details.navMenu); - navMenu.setFocusItemIndex(0); - - navMenu.on('index update', index => { - const sectionName = { - 0 : 'general', - 1 : 'nfo', - 2 : 'fileList', - }[index]; - - if(sectionName) { - self.displayDetailsSection(sectionName, true); - } - }); - - return callback(null); - } - ], - err => { - return cb(err); - } - ); - } - - displayHelpPage(cb) { - this.displayAsset( - this.menuConfig.config.art.help, - { clearScreen : true }, - () => { - this.client.waitForKeyPress( () => { - return this.displayBrowsePage(true, cb); - }); - } - ); - } - - fetchAndDisplayWebDownloadLink(cb) { - const self = this; - - async.series( - [ - function generateLinkIfNeeded(callback) { - - if(self.currentFileEntry.webDlExpireTime < moment()) { - return callback(null); - } - - const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); - - FileAreaWeb.createAndServeTempDownload( - self.client, - self.currentFileEntry, - { expireTime : expireTime }, - (err, url) => { - if(err) { - return callback(err); - } - - self.currentFileEntry.webDlExpireTime = expireTime; - - const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - - self.currentFileEntry.entryInfo.webDlLink = url; - self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat); - - return callback(null); - } - ); - }, - function updateActiveViews(callback) { - self.updateCustomViewTextsWithFilter( - 'browse', - MciViewIds.browse.customRangeStart, self.currentFileEntry.entryInfo, - { filter : [ '{webDlLink}', '{webDlExpire}' ] } - ); - return callback(null); - } - ], - err => { - return cb(err); - } - ); - } - - updateQueueIndicator() { - const isQueuedIndicator = this.menuConfig.config.isQueuedIndicator || 'Y'; - const isNotQueuedIndicator = this.menuConfig.config.isNotQueuedIndicator || 'N'; - - this.currentFileEntry.entryInfo.isQueued = stringFormat( - this.dlQueue.isQueued(this.currentFileEntry) ? - isQueuedIndicator : - isNotQueuedIndicator - ); - - this.updateCustomViewTextsWithFilter( - 'browse', - MciViewIds.browse.customRangeStart, - this.currentFileEntry.entryInfo, - { filter : [ '{isQueued}' ] } - ); - } - - cacheArchiveEntries(cb) { - // check cache - if(this.currentFileEntry.archiveEntries) { - return cb(null, 'cache'); - } - - const areaInfo = FileArea.getFileAreaByTag(this.currentFileEntry.areaTag); - if(!areaInfo) { - return cb(Errors.Invalid('Invalid area tag')); - } - - const filePath = this.currentFileEntry.filePath; - const archiveUtil = ArchiveUtil.getInstance(); - - archiveUtil.listEntries(filePath, this.currentFileEntry.entryInfo.archiveType, (err, entries) => { - if(err) { - return cb(err); - } - - this.currentFileEntry.archiveEntries = entries; - return cb(null, 're-cached'); - }); - } - - populateFileListing() { - const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList); - - if(this.currentFileEntry.entryInfo.archiveType) { - this.cacheArchiveEntries( (err, cacheStatus) => { - if(err) { - // :TODO: Handle me!!! - fileListView.setItems( [ 'Failed getting file listing' ] ); // :TODO: make this not suck - return; - } - - if('re-cached' === cacheStatus) { - const fileListEntryFormat = this.menuConfig.config.fileListEntryFormat || '{fileName} {fileSize}'; // :TODO: use byteSize here? - const focusFileListEntryFormat = this.menuConfig.config.focusFileListEntryFormat || fileListEntryFormat; - - fileListView.setItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(fileListEntryFormat, entry) ) ); - fileListView.setFocusItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(focusFileListEntryFormat, entry) ) ); - - fileListView.redraw(); - } - }); - } else { - fileListView.setItems( [ stringFormat(this.menuConfig.config.notAnArchiveFormat || 'Not an archive', { fileName : this.currentFileEntry.fileName } ) ] ); - } - } - - displayDetailsSection(sectionName, clearArea, cb) { - const self = this; - const name = `details${_.upperFirst(sectionName)}`; - - async.series( - [ - function detachPrevious(callback) { - if(self.lastDetailsViewController) { - self.lastDetailsViewController.detachClientEvents(); - } - return callback(null); - }, - function prepArtAndViewController(callback) { - - function gotoTopPos() { - self.client.term.rawWrite(ansi.goto(self.detailsInfoArea.top[0], 1)); - } - - gotoTopPos(); - - if(clearArea) { - self.client.term.rawWrite(ansi.reset()); - - let pos = self.detailsInfoArea.top[0]; - const bottom = self.detailsInfoArea.bottom[0]; - - while(pos++ <= bottom) { - self.client.term.rawWrite(ansi.eraseLine() + ansi.down()); - } - - gotoTopPos(); - } - - return self.displayArtAndPrepViewController(name, { clearScreen : false, noInput : true }, callback); - }, - function populateViews(callback) { - self.lastDetailsViewController = self.viewControllers[name]; - - switch(sectionName) { - case 'nfo' : - { - const nfoView = self.viewControllers.detailsNfo.getView(MciViewIds.detailsNfo.nfo); - if(!nfoView) { - return callback(null); - } - - if(isAnsi(self.currentFileEntry.entryInfo.descLong)) { - nfoView.setAnsi( - self.currentFileEntry.entryInfo.descLong, - { - prepped : false, - forceLineTerm : true, - }, - () => { - return callback(null); - } - ); - } else { - nfoView.setText(self.currentFileEntry.entryInfo.descLong); - return callback(null); - } - } - break; - - case 'fileList' : - self.populateFileListing(); - return callback(null); - - default : - return callback(null); - } - }, - function setLabels(callback) { - self.populateCustomLabels(name, MciViewIds[name].customRangeStart); - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - loadFileIds(force, cb) { - if(force || (_.isUndefined(this.fileList) || _.isUndefined(this.fileListPosition))) { - this.fileListPosition = 0; - FileEntry.findFiles(this.filterCriteria, (err, fileIds) => { - this.fileList = fileIds; - return cb(err); - }); - } - } -}; diff --git a/mods/file_base_area_select.js b/mods/file_base_area_select.js deleted file mode 100644 index 5eef583b..00000000 --- a/mods/file_base_area_select.js +++ /dev/null @@ -1,84 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const Config = require('../core/config.js').config; -const stringFormat = require('../core/string_format.js'); -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'File Area Selector', - desc : 'Select from available file areas', - author : 'NuSkooler', -}; - -const MciViewIds = { - areaList : 1, -}; - -exports.getModule = class FileAreaSelectModule extends MenuModule { - constructor(options) { - super(options); - - this.config = this.menuConfig.config || {}; - - this.loadAvailAreas(); - - this.menuMethods = { - selectArea : (formData, extraArgs, cb) => { - const area = this.availAreas[formData.value.areaSelect] || 0; - - const filterCriteria = { - areaTag : area.areaTag, - }; - - const menuOpts = { - extraArgs : { - filterCriteria : filterCriteria, - }, - menuFlags : [ 'noHistory' ], - }; - - return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); - } - }; - } - - loadAvailAreas() { - this.availAreas = getSortedAvailableFileAreas(this.client); - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - this.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => { - if(err) { - return cb(err); - } - - const areaListView = vc.getView(MciViewIds.areaList); - - const areaListFormat = this.config.areaListFormat || '{name}'; - - areaListView.setItems(this.availAreas.map(a => stringFormat(areaListFormat, a) ) ); - - if(this.config.areaListFocusFormat) { - areaListView.setFocusItems(this.availAreas.map(a => stringFormat(this.config.areaListFocusFormat, a) ) ); - } - - areaListView.redraw(); - - return cb(null); - }); - }); - } -}; diff --git a/mods/file_base_download_manager.js b/mods/file_base_download_manager.js deleted file mode 100644 index 812a2422..00000000 --- a/mods/file_base_download_manager.js +++ /dev/null @@ -1,218 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const DownloadQueue = require('../core/download_queue.js'); -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); -const Errors = require('../core/enig_error.js').Errors; -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'File Base Download Queue Manager', - desc : 'Module for interacting with download queue/batch', - author : 'NuSkooler', -}; - -const FormIds = { - queueManager : 0, - details : 1, -}; - -const MciViewIds = { - queueManager : { - queue : 1, - navMenu : 2, - }, - details : { - - } -}; - -exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { - - constructor(options) { - super(options); - - this.dlQueue = new DownloadQueue(this.client); - - if(_.has(options, 'lastMenuResult.sentFileIds')) { - this.sentFileIds = options.lastMenuResult.sentFileIds; - } - - this.fallbackOnly = options.lastMenuResult ? true : false; - - this.menuMethods = { - downloadAll : (formData, extraArgs, cb) => { - const modOpts = { - extraArgs : { - sendQueue : this.dlQueue.items, - direction : 'send', - } - }; - - return this.gotoMenu(this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', modOpts, cb); - }, - viewItemInfo : (formData, extraArgs, cb) => { - }, - removeItem : (formData, extraArgs, cb) => { - const selectedItem = this.dlQueue.items[formData.value.queueItem]; - if(!selectedItem) { - return cb(null); - } - - this.dlQueue.removeItems(selectedItem.fileId); - - // :TODO: broken: does not redraw menu properly - needs fixed! - return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb); - }, - clearQueue : (formData, extraArgs, cb) => { - this.dlQueue.clear(); - - // :TODO: broken: does not redraw menu properly - needs fixed! - return this.removeItemsFromDownloadQueueView('all', cb); - } - }; - } - - initSequence() { - if(0 === this.dlQueue.items.length) { - if(this.sendFileIds) { - // we've finished everything up - just fall back - return this.prevMenu(); - } - - // Simply an empty D/L queue: Present a specialized "empty queue" page - return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); - } - - const self = this; - - async.series( - [ - function beforeArt(callback) { - return self.beforeArt(callback); - }, - function display(callback) { - return self.displayQueueManagerPage(false, callback); - } - ], - () => { - return self.finishedLoading(); - } - ); - } - - removeItemsFromDownloadQueueView(itemIndex, cb) { - const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); - if(!queueView) { - return cb(Errors.DoesNotExist('Queue view does not exist')); - } - - if('all' === itemIndex) { - queueView.setItems([]); - queueView.setFocusItems([]); - } else { - queueView.removeItem(itemIndex); - } - - queueView.redraw(); - return cb(null); - } - - updateDownloadQueueView(cb) { - const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); - if(!queueView) { - return cb(Errors.DoesNotExist('Queue view does not exist')); - } - - const queueListFormat = this.menuConfig.config.queueListFormat || '{fileName} {byteSize}'; - const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat; - - queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); - queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); - - queueView.redraw(); - - return cb(null); - } - - displayQueueManagerPage(clearScreen, cb) { - const self = this; - - async.series( - [ - function prepArtAndViewController(callback) { - return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback); - }, - function populateViews(callback) { - return self.updateDownloadQueueView(callback); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - displayArtAndPrepViewController(name, options, cb) { - const self = this; - const config = this.menuConfig.config; - - async.waterfall( - [ - function readyAndDisplayArt(callback) { - if(options.clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); - } - - theme.displayThemedAsset( - config.art[name], - self.client, - { font : self.menuConfig.font, trailingLF : false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function prepeareViewController(artData, callback) { - if(_.isUndefined(self.viewControllers[name])) { - const vcOpts = { - client : self.client, - formId : FormIds[name], - }; - - if(!_.isUndefined(options.noInput)) { - vcOpts.noInput = options.noInput; - } - - const vc = self.addViewController(name, new ViewController(vcOpts)); - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds[name], - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } - - self.viewControllers[name].setFocus(true); - return callback(null); - - }, - ], - err => { - return cb(err); - } - ); - } -}; diff --git a/mods/file_base_search.js b/mods/file_base_search.js deleted file mode 100644 index e984e1a4..00000000 --- a/mods/file_base_search.js +++ /dev/null @@ -1,120 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../core/file_base_filter.js'); - -// deps -const async = require('async'); - -exports.moduleInfo = { - name : 'File Base Search', - desc : 'Module for quickly searching the file base', - author : 'NuSkooler', -}; - -const MciViewIds = { - search : { - searchTerms : 1, - search : 2, - tags : 3, - area : 4, - orderBy : 5, - sort : 6, - advSearch : 7, - } -}; - -exports.getModule = class FileBaseSearch extends MenuModule { - constructor(options) { - super(options); - - this.menuMethods = { - search : (formData, extraArgs, cb) => { - const isAdvanced = formData.submitId === MciViewIds.search.advSearch; - return this.searchNow(formData, isAdvanced, cb); - }, - }; - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.addViewController( 'search', new ViewController( { client : this.client } ) ); - - async.series( - [ - function loadFromConfig(callback) { - return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); - }, - function populateAreas(callback) { - self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); - - const areasView = vc.getView(MciViewIds.search.area); - areasView.setItems( self.availAreas.map( a => a.name ) ); - areasView.redraw(); - vc.switchFocus(MciViewIds.search.searchTerms); - - return callback(null); - } - ], - err => { - return cb(err); - } - ); - }); - } - - getSelectedAreaTag(index) { - if(0 === index) { - return ''; // -ALL- - } - const area = this.availAreas[index]; - if(!area) { - return ''; - } - return area.areaTag; - } - - getOrderBy(index) { - return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; - } - - getSortBy(index) { - return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; - } - - getFilterValuesFromFormData(formData, isAdvanced) { - const areaIndex = isAdvanced ? formData.value.areaIndex : 0; - const orderByIndex = isAdvanced ? formData.value.orderByIndex : 0; - const sortByIndex = isAdvanced ? formData.value.sortByIndex : 0; - - return { - areaTag : this.getSelectedAreaTag(areaIndex), - terms : formData.value.searchTerms, - tags : isAdvanced ? formData.value.tags : '', - order : this.getOrderBy(orderByIndex), - sort : this.getSortBy(sortByIndex), - }; - } - - searchNow(formData, isAdvanced, cb) { - const filterCriteria = this.getFilterValuesFromFormData(formData, isAdvanced); - - const menuOpts = { - extraArgs : { - filterCriteria : filterCriteria, - }, - menuFlags : [ 'noHistory' ], - }; - - return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); - } -}; diff --git a/mods/file_transfer_protocol_select.js b/mods/file_transfer_protocol_select.js deleted file mode 100644 index 6efa5a93..00000000 --- a/mods/file_transfer_protocol_select.js +++ /dev/null @@ -1,158 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const Config = require('../core/config.js').config; -const stringFormat = require('../core/string_format.js'); -const ViewController = require('../core/view_controller.js').ViewController; - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'File transfer protocol selection', - desc : 'Select protocol / method for file transfer', - author : 'NuSkooler', -}; - -const MciViewIds = { - protList : 1, -}; - -exports.getModule = class FileTransferProtocolSelectModule extends MenuModule { - - constructor(options) { - super(options); - - this.config = this.menuConfig.config || {}; - - if(options.extraArgs) { - if(options.extraArgs.direction) { - this.config.direction = options.extraArgs.direction; - } - } - - this.config.direction = this.config.direction || 'send'; - - this.extraArgs = options.extraArgs; - - if(_.has(options, 'lastMenuResult.sentFileIds')) { - this.sentFileIds = options.lastMenuResult.sentFileIds; - } - - if(_.has(options, 'lastMenuResult.recvFilePaths')) { - this.recvFilePaths = options.lastMenuResult.recvFilePaths; - } - - this.fallbackOnly = options.lastMenuResult ? true : false; - - this.loadAvailProtocols(); - - this.menuMethods = { - selectProtocol : (formData, extraArgs, cb) => { - const protocol = this.protocols[formData.value.protocol]; - const finalExtraArgs = this.extraArgs || {}; - Object.assign(finalExtraArgs, { protocol : protocol.protocol, direction : this.config.direction }, extraArgs ); - - const modOpts = { - extraArgs : finalExtraArgs, - }; - - if('send' === this.config.direction) { - return this.gotoMenu(this.config.downloadFilesMenu || 'sendFilesToUser', modOpts, cb); - } else { - return this.gotoMenu(this.config.uploadFilesMenu || 'recvFilesFromUser', modOpts, cb); - } - }, - }; - } - - getMenuResult() { - if(this.sentFileIds) { - return { sentFileIds : this.sentFileIds }; - } - - if(this.recvFilePaths) { - return { recvFilePaths : this.recvFilePaths }; - } - } - - initSequence() { - if(this.sentFileIds || this.recvFilePaths) { - // nothing to do here; move along (we're just falling through) - this.prevMenu(); - } else { - super.initSequence(); - } - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); - - async.series( - [ - function loadFromConfig(callback) { - const loadOpts = { - callingMenu : self, - mciMap : mciData.menu - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - }, - function populateList(callback) { - const protListView = vc.getView(MciViewIds.protList); - - const protListFormat = self.config.protListFormat || '{name}'; - const protListFocusFormat = self.config.protListFocusFormat || protListFormat; - - protListView.setItems(self.protocols.map(p => stringFormat(protListFormat, p) ) ); - protListView.setFocusItems(self.protocols.map(p => stringFormat(protListFocusFormat, p) ) ); - - protListView.redraw(); - - return callback(null); - } - ], - err => { - return cb(err); - } - ); - }); - } - - loadAvailProtocols() { - this.protocols = _.map(Config.fileTransferProtocols, (protInfo, protocol) => { - return { - protocol : protocol, - name : protInfo.name, - hasBatch : _.has(protInfo, 'external.recvArgs'), - hasNonBatch : _.has(protInfo, 'external.recvArgsNonBatch'), - sort : protInfo.sort, - }; - }); - - // Filter out batch vs non-batch only protocols - if(this.extraArgs.recvFileName) { // non-batch aka non-blind - this.protocols = this.protocols.filter( prot => prot.hasNonBatch ); - } else { - this.protocols = this.protocols.filter( prot => prot.hasBatch ); - } - - // natural sort taking explicit orders into consideration - this.protocols.sort( (a, b) => { - if(_.isNumber(a.sort) && _.isNumber(b.sort)) { - return a.sort - b.sort; - } else { - return a.name.localeCompare(b.name, { sensitivity : false, numeric : true } ); - } - }); - } -}; diff --git a/mods/last_callers.js b/mods/last_callers.js deleted file mode 100644 index afb429d8..00000000 --- a/mods/last_callers.js +++ /dev/null @@ -1,151 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const StatLog = require('../core/stat_log.js'); -const User = require('../core/user.js'); -const stringFormat = require('../core/string_format.js'); - -// deps -const moment = require('moment'); -const async = require('async'); -const _ = require('lodash'); - -/* - Available listFormat object members: - userId - userName - location - affiliation - ts - -*/ - -exports.moduleInfo = { - name : 'Last Callers', - desc : 'Last callers to the system', - author : 'NuSkooler', - packageName : 'codes.l33t.enigma.lastcallers' -}; - -const MciCodeIds = { - CallerList : 1, -}; - -exports.getModule = class LastCallersModule extends MenuModule { - constructor(options) { - super(options); - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); - - let loginHistory; - let callersView; - - async.series( - [ - function loadFromConfig(callback) { - const loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - noInput : true, - }; - - vc.loadFromMenuConfig(loadOpts, callback); - }, - function fetchHistory(callback) { - callersView = vc.getView(MciCodeIds.CallerList); - - // fetch up - StatLog.getSystemLogEntries('user_login_history', StatLog.Order.TimestampDesc, 200, (err, lh) => { - loginHistory = lh; - - if(self.menuConfig.config.hideSysOpLogin) { - const noOpLoginHistory = loginHistory.filter(lh => { - return false === User.isRootUserId(parseInt(lh.log_value)); // log_value=userId - }); - - // - // If we have enough items to display, or hideSysOpLogin is set to 'always', - // then set loginHistory to our filtered list. Else, we'll leave it be. - // - if(noOpLoginHistory.length >= callersView.dimens.height || 'always' === self.menuConfig.config.hideSysOpLogin) { - loginHistory = noOpLoginHistory; - } - } - - // - // Finally, we need to trim up the list to the needed size - // - loginHistory = loginHistory.slice(0, callersView.dimens.height); - - return callback(err); - }); - }, - function getUserNamesAndProperties(callback) { - const getPropOpts = { - names : [ 'location', 'affiliation' ] - }; - - const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD'; - - async.each( - loginHistory, - (item, next) => { - item.userId = parseInt(item.log_value); - item.ts = moment(item.timestamp).format(dateTimeFormat); - - User.getUserName(item.userId, (err, userName) => { - if(err) { - item.deleted = true; - return next(null); - } else { - item.userName = userName || 'N/A'; - - User.loadProperties(item.userId, getPropOpts, (err, props) => { - if(!err && props) { - item.location = props.location || 'N/A'; - item.affiliation = item.affils = (props.affiliation || 'N/A'); - } else { - item.location = 'N/A'; - item.affiliation = item.affils = 'N/A'; - } - return next(null); - }); - } - }); - }, - err => { - loginHistory = loginHistory.filter(lh => true !== lh.deleted); - return callback(err); - } - ); - }, - function populateList(callback) { - const listFormat = self.menuConfig.config.listFormat || '{userName} - {location} - {affiliation} - {ts}'; - - callersView.setItems(_.map(loginHistory, ce => stringFormat(listFormat, ce) ) ); - - callersView.redraw(); - return callback(null); - } - ], - (err) => { - if(err) { - self.client.log.error( { error : err.toString() }, 'Error loading last callers'); - } - cb(err); - } - ); - }); - } -}; diff --git a/mods/menu.hjson b/mods/menu.hjson deleted file mode 100644 index d1822cfc..00000000 --- a/mods/menu.hjson +++ /dev/null @@ -1,3644 +0,0 @@ -{ - /* - ./\/\.' ENiGMA½ Menu Configuration -/--/-------- - -- - - - _____________________ _____ ____________________ __________\_ / - \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! - // __|___// | \// |// | \// | | \// \ /___ /_____ - /____ _____| __________ ___|__| ____| \ / _____ \ - ---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ - /__ _\ - <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ - - ------------------------------------------------------------------------------- - - This configuration is in HJSON (http://hjson.org/) format. Strict to-spec - JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON. - - See http://hjson.org/ for more information and syntax. - - - If you haven't yet, copy the conents of this file to something like - sick_board.hjson. Point to it via config.hjson using the - 'general.menuFile' key: - - general: { menuFile: "sick_board.hjson" } - - */ - menus: { - // - // Send telnet connections to matrix where users can login, apply, etc. - // - telnetConnected: { - art: CONNECT - next: matrix - options: { nextTimeout: 1500 } - } - - // - // SSH connections are pre-authenticated via the SSH server itself. - // Jump directly to the login sequence - // - sshConnected: { - art: CONNECT - next: fullLoginSequenceLoginArt - options: { nextTimeout: 1500 } - } - - // - // Another SSH specialization: If the user logs in with a new user - // name (e.g. "new", "apply", ...) they will be directed to the - // application process. - // - sshConnectedNewUser: { - art: CONNECT - next: newUserApplicationPreSsh - options: { nextTimeout: 1500 } - } - - // Ye ol' standard matrix - matrix: { - art: matrix - form: { - 0: { - VM: { - mci: { - VM1: { - submit: true - focus: true - argName: navSelect - // :TODO: need a good way to localize these ... Standard Orig->Lookup seems good. - items: [ "login", "apply", "forgot pw", "log off" ] - } - } - submit: { - *: [ - { - value: { navSelect: 0 } - action: @menu:login - } - { - value: { navSelect: 1 }, - action: @menu:newUserApplicationPre - } - { - value: { navSelect: 2 } - action: @menu:forgotPassword - } - { - value: { navSelect: 3 }, - action: @menu:logoff - } - ] - } - } - } - } - } - - login: { - art: USERLOG - next: fullLoginSequenceLoginArt - config: { - tooNodeMenu: loginAttemptTooNode - } - form: { - 0: { - mci: { - ET1: { - maxLength: @config:users.usernameMax - argName: username - focus: true - } - ET2: { - password: true - maxLength: @config:users.passwordMax - argName: password - submit: true - } - } - submit: { - *: [ - { - value: { password: null } - action: @systemMethod:login - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - loginAttemptTooNode: { - art: TOONODE - options: { - cls: true - nextTimeout: 2000 - } - } - - forgotPassword: { - desc: Forgot password - prompt: forgotPasswordPrompt - submit: [ - { - value: { username: null } - action: @systemMethod:sendForgotPasswordEmail - extraArgs: { next: "forgotPasswordSubmitted" } - } - ] - } - - forgotPasswordSubmitted: { - desc: Forgot password - art: FORGOTPWSENT - options: { - cls: true - pause: true - } - next: @systemMethod:logoff - } - - // :TODO: Prompt Yes/No for logoff confirm - fullLogoffSequence: { - desc: Logging Off - prompt: logoffConfirmation - submit: [ - { - value: { promptValue: 0 } - action: @menu:fullLogoffSequencePreAd - } - { - value: { promptValue: 1 } - action: @systemMethod:prevMenu - } - ] - } - - fullLogoffSequencePreAd: { - art: PRELOGAD - desc: Logging Off - next: fullLogoffSequenceRandomBoardAd - options: { - cls: true - nextTimeout: 1500 - } - } - - fullLogoffSequenceRandomBoardAd: { - art: OTHRBBS - desc: Logging Off - next: logoff - options: { - baudRate: 57600 - pause: true - cls: true - } - } - - logoff: { - art: LOGOFF - desc: Logging Off - next: @systemMethod:logoff - } - - // A quick preamble - defaults to warning about broken terminals - newUserApplicationPre: { - art: NEWUSER1 - next: newUserApplication - desc: Applying - options: { - pause: true - cls: true - } - } - - newUserApplication: { - module: nua - art: NUA - options: { - menuFlags: [ "noHistory" ] - } - next: [ - { - // Initial SysOp does not send feedback to themselves - acs: ID1 - next: fullLoginSequenceLoginArt - } - { - // ...everyone else does - next: newUserFeedbackToSysOpPreamble - } - ] - form: { - 0: { - mci: { - ET1: { - focus: true - argName: username - maxLength: @config:users.usernameMax - validate: @systemMethod:validateUserNameAvail - } - ET2: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - } - MET3: { - argName: birthdate - maskPattern: "####/##/##" - validate: @systemMethod:validateBirthdate - } - ME4: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET6: { - argName: affils - maxLength: @config:users.affilsMax - } - ET7: { - argName: email - maxLength: @config:users.emailMax - validate: @systemMethod:validateEmailAvail - } - ET8: { - argName: web - maxLength: @config:users.webMax - } - ET9: { - argName: password - password: true - maxLength: @config:users.passwordMax - validate: @systemMethod:validatePasswordSpec - } - ET10: { - argName: passwordConfirm - password: true - maxLength: @config:users.passwordMax - validate: @method:validatePassConfirmMatch - } - TM12: { - argName: submission - items: [ "apply", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitApplication - extraArgs: { - inactive: userNeedsActivated - error: newUserCreateError - } - } - { - value: { "submission" : 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - // A quick preamble - defaults to warning about broken terminals (SSH version) - newUserApplicationPreSsh: { - art: NEWUSER1 - next: newUserApplicationSsh - desc: Applying - options: { - pause: true - cls: true - } - } - - // - // SSH specialization of NUA - // Canceling this form logs off vs falling back to matrix - // - newUserApplicationSsh: { - module: nua - art: NUA - fallback: logoff - options: { - menuFlags: [ "noHistory" ] - } - next: newUserFeedbackToSysOpPreamble - form: { - 0: { - mci: { - ET1: { - focus: true - argName: username - maxLength: @config:users.usernameMax - validate: @systemMethod:validateUserNameAvail - } - ET2: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - } - MET3: { - argName: birthdate - maskPattern: "####/##/##" - validate: @systemMethod:validateBirthdate - } - ME4: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET6: { - argName: affils - maxLength: @config:users.affilsMax - } - ET7: { - argName: email - maxLength: @config:users.emailMax - validate: @systemMethod:validateEmailAvail - } - ET8: { - argName: web - maxLength: @config:users.webMax - } - ET9: { - argName: password - password: true - maxLength: @config:users.passwordMax - validate: @systemMethod:validatePasswordSpec - } - ET10: { - argName: passwordConfirm - password: true - maxLength: @config:users.passwordMax - validate: @method:validatePassConfirmMatch - } - TM12: { - argName: submission - items: [ "apply", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitApplication - extraArgs: { - inactive: userNeedsActivated - error: newUserCreateError - } - } - { - value: { "submission" : 1 } - action: @systemMethod:logoff - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:logoff - } - ] - } - } - } - - newUserFeedbackToSysOpPreamble: { - art: LETTER - options: { pause: true } - next: newUserFeedbackToSysOp - } - - newUserFeedbackToSysOp: { - desc: Feedback to SysOp - module: msg_area_post_fse - next: [ - { - acs: AS2 - next: fullLoginSequenceLoginArt - } - { - next: newUserInactiveDone - } - ] - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - toUserId: 1 /* always to +op */ - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: @sysStat:sysop_username - // :TODO: readOnly: true - } - ET3: { - argName: subject - maxLength: 72 - submit: true - text: New user feedback - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - newUserInactiveDone: { - desc: Finished with NUA - art: DONE - options: { pause: true } - next: @menu:logoff - } - - fullLoginSequenceLoginArt: { - desc: Logging In - art: WELCOME - options: { pause: true } - next: fullLoginSequenceLastCallers - } - - fullLoginSequenceLastCallers: { - desc: Last Callers - module: last_callers - art: LASTCALL - options: { - pause: true - font: cp437 - } - next: fullLoginSequenceWhosOnline - } - fullLoginSequenceWhosOnline: { - desc: Who's Online - module: whos_online - art: WHOSON - options: { pause: true } - next: fullLoginSequenceOnelinerz - } - - fullLoginSequenceOnelinerz: { - desc: Viewing Onelinerz - module: onelinerz - next: fullLoginSequenceNewScanConfirm - options: { - cls: true - } - config: { - art: { - entries: ONELINER - add: ONEADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: oneliner - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - fullLoginSequenceNewScanConfirm: { - desc: Logging In - prompt: loginGlobalNewScan - submit: [ - { - value: { promptValue: 0 } - action: @menu:fullLoginSequenceNewScan - } - { - value: { promptValue: 1 } - action: @menu:fullLoginSequenceUserStats - } - ] - } - - fullLoginSequenceNewScan: { - desc: Performing New Scan - module: @systemModule:new_scan - art: NEWSCAN - next: fullLoginSequenceSysStats - config: { - messageListMenu: newScanMessageList - } - } - - fullLoginSequenceSysStats: { - desc: System Stats - art: SYSSTAT - options: { pause: true } - next: fullLoginSequenceUserStats - } - fullLoginSequenceUserStats: { - desc: User Stats - art: STATUS - options: { pause: true } - next: mainMenu - } - - newScanMessageList: { - desc: New Messages - module: msg_list - art: NEWMSGS - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - TL6: { - // theme me! - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "x", "shift + x" ] - action: @method:fullExit - } - ] - } - } - } - - newScanFileBaseList: { - module: file_area_list - desc: New Files - config: { - art: { - browse: FNEWBRWSE - details: FDETAIL - detailsGeneral: FDETGEN - detailsNfo: FDETNFO - detailsFileList: FDETLST - help: FBHELP - } - } - form: { - 0: { - mci: { - MT1: { - mode: preview - ansiView: true - } - - HM2: { - focus: true - submit: true - argName: navSelect - items: [ - "prev", "next", "details", "toggle queue", "rate", "help", "quit" - ] - focusItemIndex: 1 - } - - // :TODO: these can be removed once the hack is not required: - TL10: {} - TL11: {} - TL12: {} - TL13: {} - TL14: {} - TL15: {} - TL16: {} - TL17: {} - TL18: {} - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFile - } - { - value: { navSelect: 1 } - action: @method:nextFile - } - { - value: { navSelect: 2 } - action: @method:viewDetails - } - { - value: { navSelect: 3 } - action: @method:toggleQueue - } - { - value: { navSelect: 4 } - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - value: { navSelect: 5 } - action: @method:displayHelp - } - { - value: { navSelect: 6 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "w", "shift + w" ] - action: @method:showWebDownloadLink - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "t", "shift + t" ] - action: @method:toggleQueue - } - { - keys: [ "v", "shift + v" ] - action: @method:viewDetails - } - { - keys: [ "r", "shift + r" ] - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - keys: [ "?" ] - action: @method:displayHelp - } - ] - } - - 1: { - mci: { - HM1: { - focus: true - submit: true - argName: navSelect - items: [ - "general", "nfo/readme", "file listing" - ] - } - - // :TODO: these can be removed once the hack is not required: - TL10: {} - TL11: {} - TL12: {} - TL13: {} - TL14: {} - TL15: {} - TL16: {} - TL17: {} - TL18: {} - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @method:detailsQuit - } - ] - } - - 2: { - // details - general - mci: {} - } - - 3: { - // details - nfo/readme - mci: { - MT1: { - mode: preview - } - } - } - - 4: { - // details - file listing - mci: { - VM1: { - - } - } - } - } - } - - /////////////////////////////////////////////////////////////////////// - // Main Menu - /////////////////////////////////////////////////////////////////////// - mainMenu: { - art: MMENU - desc: Main Menu - prompt: menuCommand - options: { - font: cp437 - } - submit: [ - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: { command: "D" } - action: @menu:doorMenu - } - { - value: { command: "F" } - action: @menu:fileBase - } - { - value: { command: "U" } - action: @menu:mainMenuUserList - } - { - value: { command: "L" } - action: @menu:mainMenuLastCallers - } - { - value: { command: "W" } - action: @menu:mainMenuWhosOnline - } - { - value: { command: "Y" } - action: @menu:mainMenuUserStats - } - { - value: { command: "M" } - action: @menu:messageArea - } - { - value: { command: "E" } - action: @menu:mailMenu - } - { - value: { command: "C" } - action: @menu:mainMenuUserConfig - } - { - value: { command: "S" } - action: @menu:mainMenuSystemStats - } - { - value: { command: "!" } - action: @menu:mainMenuGlobalNewScan - } - { - value: { command: "K" } - action: @menu:mainMenuFeedbackToSysOp - } - { - value: { command: "O" } - action: @menu:mainMenuOnelinerz - } - { - value: { command: "R" } - action: @menu:mainMenuRumorz - } - { - value: { command: "CHAT"} - action: @menu:ercClient - } - { - value: { command: "BBS"} - action: @menu:bbsList - } - { - value: 1 - action: @menu:mainMenu - } - ] - } - mainMenuLastCallers: { - desc: Last Callers - module: last_callers - art: LASTCALL - options: { pause: true } - } - mainMenuWhosOnline: { - desc: Who's Online - module: whos_online - art: WHOSON - options: { pause: true } - } - mainMenuUserStats: { - desc: User Stats - art: STATUS - options: { pause: true } - } - mainMenuSystemStats: { - desc: System Stats - art: SYSSTAT - options: { pause: true } - } - mainMenuUserList: { - desc: User Listing - module: user_list - art: USERLST - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - } - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - mainMenuUserConfig: { - module: @systemModule:user_config - art: CONFSCR - form: { - 0: { - mci: { - ET1: { - argName: realName - maxLength: @config:users.realNameMax - validate: @systemMethod:validateNonEmpty - focus: true - } - ME2: { - argName: birthdate - maskPattern: "####/##/##" - } - ME3: { - argName: sex - maskPattern: A - textStyle: upper - validate: @systemMethod:validateNonEmpty - } - ET4: { - argName: location - maxLength: @config:users.locationMax - validate: @systemMethod:validateNonEmpty - } - ET5: { - argName: affils - maxLength: @config:users.affilsMax - } - ET6: { - argName: email - maxLength: @config:users.emailMax - validate: @method:validateEmailAvail - } - ET7: { - argName: web - maxLength: @config:users.webMax - } - ME8: { - maskPattern: "##" - argName: termHeight - validate: @systemMethod:validateNonEmpty - } - SM9: { - argName: theme - } - ET10: { - argName: password - maxLength: @config:users.passwordMax - password: true - validate: @method:validatePassword - } - ET11: { - argName: passwordConfirm - maxLength: @config:users.passwordMax - password: true - validate: @method:validatePassConfirmMatch - } - TM25: { - argName: submission - items: [ "save", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { submission: 0 } - action: @method:saveChanges - } - { - value: { submission: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - mainMenuGlobalNewScan: { - desc: Performing New Scan - module: @systemModule:new_scan - art: NEWSCAN - config: { - messageListMenu: newScanMessageList - } - } - - mainMenuFeedbackToSysOp: { - desc: Feedback to SysOp - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - toUserId: 1 /* always to +op */ - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: @sysStat:sysop_username - // :TODO: readOnly: true - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - mainMenuOnelinerz: { - desc: Viewing Onelinerz - module: onelinerz - options: { - cls: true - } - config: { - art: { - entries: ONELINER - add: ONEADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: oneliner - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - mainMenuRumorz: { - desc: Rumorz - module: rumorz - options: { - cls: true - } - config: { - art: { - entries: RUMORS - add: RUMORADD - } - } - form: { - 0: { - mci: { - VM1: { - focus: false - height: 10 - } - TM2: { - argName: addOrExit - items: [ "yeah!", "nah" ] - "hotKeys" : { "Y" : 0, "N" : 1, "Q" : 1 } - submit: true - focus: true - } - } - submit: { - *: [ - { - value: { addOrExit: 0 } - action: @method:viewAddScreen - } - { - value: { addOrExit: null } - action: @systemMethod:nextMenu - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:nextMenu - } - ] - }, - 1: { - mci: { - ET1: { - focus: true - maxLength: 70 - argName: rumor - } - TL2: { - width: 60 - } - TM3: { - argName: addOrCancel - items: [ "add", "cancel" ] - "hotKeys" : { "A" : 0, "C" : 1, "Q" : 1 } - submit: true - } - } - - submit: { - *: [ - { - value: { addOrCancel: 0 } - action: @method:addEntry - } - { - value: { addOrCancel: 1 } - action: @method:cancelAdd - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelAdd - } - ] - } - } - } - - ercClient: { - art: erc - module: erc_client - config: { - host: localhost - port: 5001 - bbsTag: CHANGEME - } - - form: { - 0: { - mci: { - MT1: { - width: 79 - height: 21 - mode: preview - autoScroll: true - } - ET3: { - autoScale: false - width: 77 - argName: inputArea - focus: true - submit: true - } - } - - submit: { - *: [ - { - value: { inputArea: null } - action: @method:inputAreaSubmit - } - ] - } - actionKeys: [ - { - keys: [ "tab" ] - } - { - keys: [ "up arrow" ] - action: @method:scrollDown - } - { - keys: [ "down arrow" ] - action: @method:scrollUp - } - ] - } - } - } - - bbsList: { - desc: Viewing BBS List - module: bbs_list - options: { - cls: true - } - config: { - art: { - entries: BBSLIST - add: BBSADD - } - } - - form: { - 0: { - mci: { - VM1: { maxLength: 32 } - TL2: { maxLength: 32 } - TL3: { maxLength: 32 } - TL4: { maxLength: 32 } - TL5: { maxLength: 32 } - TL6: { maxLength: 32 } - TL7: { maxLength: 32 } - TL8: { maxLength: 32 } - TL9: { maxLength: 32 } - } - actionKeys: [ - { - keys: [ "a" ] - action: @method:addBBS - } - { - // :TODO: add delete key - keys: [ "d" ] - action: @method:deleteBBS - } - { - keys: [ "q", "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - ET1: { - argName: name - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET2: { - argName: sysop - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: telnet - maxLength: 32 - validate: @systemMethod:validateNonEmpty - } - ET4: { - argName: www - maxLength: 32 - } - ET5: { - argName: location - maxLength: 32 - } - ET6: { - argName: software - maxLength: 32 - } - ET7: { - argName: notes - maxLength: 32 - } - TM17: { - argName: submission - items: [ "save", "cancel" ] - submit: true - } - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:cancelSubmit - } - ] - - submit: { - *: [ - { - value: { "submission" : 0 } - action: @method:submitBBS - } - { - value: { "submission" : 1 } - action: @method:cancelSubmit - } - ] - } - } - } - } - - /////////////////////////////////////////////////////////////////////// - // Doors Menu - /////////////////////////////////////////////////////////////////////// - doorMenu: { - desc: Doors Menu - art: DOORMNU - prompt: menuCommand - submit: [ - { - value: { command: "G" } - action: @menu:logoff - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - { - value: { command: "1" } - action: @menu:doorPimpWars - } - { - value: { command: "2" } - action: @menu:doorLORD - } - { - value: { command: "4" } - action: @menu:doorTradeWars2002BBSLink - } - { - value: { command: "DL" } - action: @menu:doorDarkLands - } - { - value: { command: "DP" } - action: @menu:doorParty - } - { - value: { command: "HL" } - action: @menu:telnetBridgeHappyLand - } - ] - } - - doorPimpWars: { - desc: Playing PimpWars - module: abracadabra - config: { - name: PimpWars - dropFileType: DORINFO - cmd: /home/nuskooler/DOS/scripts/pimpwars.sh - args: [ - "{node}", - "{dropFile}", - "{srvPort}", - ], - nodeMax: 1 - tooManyArt: DOORMANY - io: socket - } - } - - doorDarkLands: { - desc: Playing Dark Lands - module: abracadabra - config: { - name: DARKLANDS - dropFileType: DOOR - cmd: /home/nuskooler/dev/enigma-bbs/doors/darklands/start.sh - args: [ - "{node}", - "{dropFile}", - "{srvPort}", - ], - nodeMax: 1 - tooManyArt: DOORMANY - io: socket - } - } - - doorLORD: { - desc: Playing L.O.R.D. - module: abracadabra - config: { - name: LORD - dropFileType: DOOR - cmd: /usr/bin/dosemu - args: [ - "-quiet", "-f", "/home/nuskooler/DOS/X/LORD/dosemu.conf", "X:\\LORD\\START.BAT {node}" - ] - } - } - - // - // TradeWars 2000 example via BBSLink - // - // You will need to register with BBSLink to obtain sysCode, authCode and schemeCode - // - doorTradeWars2002BBSLink: { - desc: Playing TW 2002 (BBSLink) - module: bbs_link - config: { - sysCode: XXXXXXXX - authCode: XXXXXXXX - schemeCode: XXXXXXXX - door: tw - } - } - - doorParty: { - desc: Using DoorParty! - module: @systemModule:door_party - config: { - username: XXXXXXXX - password: XXXXXXXX - bbsTag: XX - } - } - - telnetBridgeHappyLand: { - desc: Connected to HappyLand BBS - module: telnet_bridge - config: { - host: andrew.homeunix.org - port: 2023 - //host: agency.bbs.geek.nz - //port: 23 - } - } - - /////////////////////////////////////////////////////////////////////// - // Message Area Menu - /////////////////////////////////////////////////////////////////////// - messageArea: { - art: MSGMNU - desc: Message Area - prompt: messageMenuCommand - submit: [ - { - value: { command: "P" } - action: @menu:messageAreaNewPost - } - { - value: { command: "J" } - action: @menu:messageAreaChangeCurrentConference - } - { - value: { command: "C" } - action: @menu:messageAreaChangeCurrentArea - } - { - value: { command: "L" } - action: @menu:messageAreaMessageList - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: { command: "<" } - action: @systemMethod:prevConf - } - { - value: { command: ">" } - action: @systemMethod:nextConf - } - { - value: { command: "[" } - action: @systemMethod:prevArea - } - { - value: { command: "]" } - action: @systemMethod:nextArea - } - { - value: 1 - action: @menu:messageArea - } - ] - } - - messageAreaChangeCurrentConference: { - art: CCHANGE - module: msg_conf_list - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: conf - } - } - submit: { - *: [ - { - value: { conf: null } - action: @method:changeConference - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaChangeCurrentArea: { - // :TODO: rename this art to ACHANGE - art: CHANGE - module: msg_area_list - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: area - } - } - submit: { - *: [ - { - value: { area: null } - action: @method:changeArea - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaMessageList: { - module: msg_list - art: MSGLIST - config: { - menuViewPost: messageAreaViewPost - } - form: { - 0: { - mci: { - VM1: { - focus: true - submit: true - argName: message - } - TL6: { - // theme me! - } - } - submit: { - *: [ - { - value: { message: null } - action: @method:selectMessage - } - ] - } - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - messageAreaViewPost: { - module: msg_area_view_fse - config: { - art: { - header: MSGVHDR - body: MSGBODY - footerView: MSGVFTR - help: MSGVHLP - }, - editorMode: view - editorType: area - } - form: { - 0: { - mci: { - // :TODO: ensure this block isn't even req. for theme to apply... - } - } - 1: { - mci: { - MT1: { - width: 79 - mode: preview - } - } - submit: { - *: [ - { - value: message - action: @method:editModeEscPressed - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - } - 2: { - TLTL: { - mci: { - TL1: { width: 5 } - TL2: { width: 4 } - } - } - } - 4: { - mci: { - HM1: { - // :TODO: (#)Jump/(L)Index (msg list)/Last - items: [ "prev", "next", "reply", "quit", "help" ] - focusItemIndex: 1 - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:prevMessage - } - { - value: { 1: 1 } - action: @method:nextMessage - } - { - value: { 1: 2 } - action: @method:replyMessage - extraArgs: { - menu: messageAreaReplyPost - } - } - { - value: { 1: 3 } - action: @systemMethod:prevMenu - } - { - value: { 1: 4 } - action: @method:viewModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "p", "shift + p" ] - action: @method:prevMessage - } - { - keys: [ "n", "shift + n" ] - action: @method:nextMessage - } - { - keys: [ "r", "shift + r" ] - action: @method:replyMessage - extraArgs: { - menu: messageAreaReplyPost - } - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "?" ] - action: @method:viewModeMenuHelp - } - { - keys: [ "down arrow", "up arrow", "page up", "page down" ] - action: @method:movementKeyPressed - } - ] - } - } - } - - messageAreaReplyPost: { - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - quote: MSGQUOT - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - } - editorMode: edit - editorType: area - } - form: { - 0: { - mci: { - // :TODO: use appropriate system properties for max lengths - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateNonEmpty - } - TL4: { - // :TODO: this is for RE: line (NYI) - //width: 27 - //textOverflow: ... - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - height: 14 - argName: message - mode: edit - } - } - submit: { - *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ], - viewId: 1 - } - ] - } - - 3: { - HM: { - mci: { - HM1: { - items: [ "save", "discard", "quote", "help" ] - } - } - - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 }, - action: @method:editModeMenuQuote - } - { - value: { 1: 3 } - action: @method:editModeMenuHelp - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "s", "shift + s" ] - action: @method:editModeMenuSave - } - { - keys: [ "d", "shift + d" ] - action: @systemMethod:prevMenu - } - { - keys: [ "q", "shift + q" ] - action: @method:editModeMenuQuote - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - - // Quote builder - 5: { - mci: { - MT1: { - width: 79 - height: 7 - } - VM3: { - width: 79 - height: 4 - argName: quote - } - } - - submit: { - *: [ - { - value: { quote: null } - action: @method:appendQuoteEntry - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @method:quoteBuilderEscPressed - } - ] - } - } - } - // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu - messageAreaNewPost: { - desc: Posting message, - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - } - editorMode: edit - editorType: area - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - text: All - validate: @systemMethod:validateNonEmpty - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateNonEmpty - // :TODO: Validate -> close/cancel if empty - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - - 1: { - "mci" : { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { "value": "message", "action": "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - } - 2: { - TLTL: { - mci: { - TL1: { width: 5 } - TL2: { width: 4 } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - "items" : [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - // :TODO: something like the following for overriding keymap - // this should only override specified entries. others will default - /* - "keyMap" : { - "accept" : [ "return" ] - } - */ - } - } - } - } - - - // - // User to User mail aka Email Menu - // - mailMenu: { - art: MAILMNU - desc: Mail Menu - prompt: menuCommand - submit: [ - { - value: { command: "C" } - action: @menu:mailMenuCreateMessage - } - { - value: { command: "I" } - action: @menu:mailMenuInbox - } - { - value: { command: "Q" } - action: @systemMethod:prevMenu - } - { - value: { command: "G" } - action: @menu:fullLogoffSequence - } - { - value: 1 - action: @menu:mailMenu - } - ] - } - - mailMenuCreateMessage: { - desc: Mailing Someone - module: msg_area_post_fse - config: { - art: { - header: MSGEHDR - body: MSGBODY - footerEditor: MSGEFTR - footerEditorMenu: MSGEMFT - help: MSGEHLP - }, - editorMode: edit - editorType: email - messageAreaTag: private_mail - } - form: { - 0: { - mci: { - TL1: { - argName: from - } - ET2: { - argName: to - focus: true - validate: @systemMethod:validateUserNameExists - } - ET3: { - argName: subject - maxLength: 72 - submit: true - validate: @systemMethod:validateMessageSubject - } - } - submit: { - 3: [ - { - value: { subject: null } - action: @method:headerSubmit - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - 1: { - mci: { - MT1: { - width: 79 - argName: message - mode: edit - } - } - - submit: { - *: [ { value: "message", action: "@method:editModeEscPressed" } ] - } - actionKeys: [ - { - keys: [ "escape" ] - viewId: 1 - } - ] - }, - 2: { - TLTL: { - mci: { - TL1: { - width: 5 - } - TL2: { - width: 4 - } - } - } - } - 3: { - HM: { - mci: { - HM1: { - // :TODO: clear - items: [ "save", "discard", "help" ] - } - } - submit: { - *: [ - { - value: { 1: 0 } - action: @method:editModeMenuSave - } - { - value: { 1: 1 } - action: @systemMethod:prevMenu - } - { - value: { 1: 2 } - action: @method:editModeMenuHelp - } - ] - } - actionKeys: [ - { - keys: [ "escape" ] - action: @method:editModeEscPressed - } - { - keys: [ "?" ] - action: @method:editModeMenuHelp - } - ] - } - } - } - } - - mailMenuInbox: { - module: msg_list - art: MSGLIST - config: { - menuViewPost: messageAreaViewPost - messageAreaTag: private_mail - } - 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 - } - ] - } - } - } - - //////////////////////////////////////////////////////////////////////// - // File Base - //////////////////////////////////////////////////////////////////////// - - fileBase: { - desc: File Base - art: FMENU - prompt: fileMenuCommand - submit: [ - { - value: { menuOption: "L" } - action: @menu:fileBaseListEntries - } - { - value: { menuOption: "B" } - action: @menu:fileBaseBrowseByAreaSelect - } - { - value: { menuOption: "F" } - action: @menu:fileAreaFilterEditor - } - { - value: { menuOption: "Q" } - action: @systemMethod:prevMenu - } - { - value: { menuOption: "G" } - action: @menu:fullLogoffSequence - } - { - value: { menuOption: "D" } - action: @menu:fileBaseDownloadManager - } - { - value: { menuOption: "U" } - action: @menu:fileBaseUploadFiles - } - { - value: { menuOption: "S" } - action: @menu:fileBaseSearch - } - ] - } - - fileBaseListEntries: { - module: file_area_list - desc: Browsing Files - config: { - art: { - browse: FBRWSE - details: FDETAIL - detailsGeneral: FDETGEN - detailsNfo: FDETNFO - detailsFileList: FDETLST - help: FBHELP - } - } - form: { - 0: { - mci: { - MT1: { - mode: preview - } - - HM2: { - focus: true - submit: true - argName: navSelect - items: [ - "prev", "next", "details", "toggle queue", "rate", "change filter", "help", "quit" - ] - focusItemIndex: 1 - } - - // :TODO: these can be removed once the hack is not required: - TL10: {} - TL11: {} - TL12: {} - TL13: {} - TL14: {} - TL15: {} - TL16: {} - TL17: {} - TL18: {} - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFile - } - { - value: { navSelect: 1 } - action: @method:nextFile - } - { - value: { navSelect: 2 } - action: @method:viewDetails - } - { - value: { navSelect: 3 } - action: @method:toggleQueue - } - { - value: { navSelect: 4 } - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - value: { navSelect: 5 } - action: @menu:fileAreaFilterEditor - } - { - value: { navSelect: 6 } - action: @method:displayHelp - } - { - value: { navSelect: 7 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "w", "shift + w" ] - action: @method:showWebDownloadLink - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - { - keys: [ "t", "shift + t" ] - action: @method:toggleQueue - } - { - keys: [ "f", "shift + f" ] - action: @menu:fileAreaFilterEditor - } - { - keys: [ "v", "shift + v" ] - action: @method:viewDetails - } - { - keys: [ "r", "shift + r" ] - action: @menu:fileBaseGetRatingForSelectedEntry - } - { - keys: [ "?" ] - action: @method:displayHelp - } - ] - } - - 1: { - mci: { - HM1: { - focus: true - submit: true - argName: navSelect - items: [ - "general", "nfo/readme", "file listing" - ] - } - - // :TODO: these can be removed once the hack is not required: - TL10: {} - TL11: {} - TL12: {} - TL13: {} - TL14: {} - TL15: {} - TL16: {} - TL17: {} - TL18: {} - } - - actionKeys: [ - { - keys: [ "escape", "q", "shift + q" ] - action: @method:detailsQuit - } - ] - } - - 2: { - // details - general - mci: {} - } - - 3: { - // details - nfo/readme - mci: { - MT1: { - mode: preview - } - } - } - - 4: { - // details - file listing - mci: { - VM1: { - - } - } - } - } - } - - fileBaseBrowseByAreaSelect: { - desc: Browsing File Areas - module: file_base_area_select - art: FAREASEL - form: { - 0: { - mci: { - VM1: { - focus: true - argName: areaSelect - } - } - - submit: { - *: [ - { - value: { areaSelect: null } - action: @method:selectArea - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseGetRatingForSelectedEntry: { - desc: Rating a File - prompt: fileBaseRateEntryPrompt - options: { - cls: true - } - submit: [ - // :TODO: handle esc/q - { - // pass data back to caller - value: { rating: null } - action: @systemMethod:prevMenu - } - ] - } - - fileBaseListEntriesNoResults: { - desc: Browsing Files - art: FBNORES - options: { - pause: true - menuFlags: [ "noHistory" ] - } - } - - fileBaseSearch: { - module: file_base_search - desc: Searching Files - art: FSEARCH - form: { - 0: { - mci: { - ET1: { - focus: true - argName: searchTerms - } - BT2: { - argName: search - text: search - submit: true - } - ET3: { - maxLength: 64 - argName: tags - } - SM4: { - maxLength: 64 - argName: areaIndex - } - SM5: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - ] - argName: sortByIndex - } - SM6: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - BT7: { - argName: advancedSearch - text: advanced search - submit: true - } - } - - submit: { - *: [ - { - value: { search: null } - action: @method:search - } - { - value: { advancedSearch: null } - action: @method:search - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileAreaFilterEditor: { - desc: File Filter Editor - module: file_area_filter_edit - art: FFILEDT - form: { - 0: { - mci: { - ET1: { - argName: searchTerms - } - ET2: { - maxLength: 64 - argName: tags - } - SM3: { - maxLength: 64 - argName: areaIndex - } - SM4: { - items: [ - "upload date", - "uploaded by", - "downloads", - "rating", - "estimated year", - "size", - ] - argName: sortByIndex - } - SM5: { - items: [ - "decending", - "ascending" - ] - argName: orderByIndex - } - ET6: { - maxLength: 64 - argName: name - validate: @systemMethod:validateNonEmpty - } - HM7: { - focus: true - items: [ - "prev", "next", "make active", "save", "new", "delete" - ] - argName: navSelect - focusItemIndex: 1 - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:prevFilter - } - { - value: { navSelect: 1 } - action: @method:nextFilter - } - { - value: { navSelect: 2 } - action: @method:makeFilterActive - } - { - value: { navSelect: 3 } - action: @method:saveFilter - } - { - value: { navSelect: 4 } - action: @method:newFilter - } - { - value: { navSelect: 5 } - action: @method:deleteFilter - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseDownloadManager: { - desc: Download Manager - module: file_base_download_manager - config: { - art: { - queueManager: FDLMGR - /* - NYI - details: FDLDET - */ - } - emptyQueueMenu: fileBaseDownloadManagerEmptyQueue - } - form: { - 0: { - mci: { - VM1: { - argName: queueItem - } - HM2: { - focus: true - items: [ "download all", "quit" ] - argName: navSelect - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:downloadAll - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - actionKeys: [ - { - keys: [ "a", "shift + a" ] - action: @method:downloadAll - } - { - keys: [ "delete", "r", "shift + r" ] - action: @method:removeItem - } - { - keys: [ "c", "shift + c" ] - action: @method:clearQueue - } - { - keys: [ "escape", "q", "shift + q" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseDownloadManagerEmptyQueue: { - desc: Empty Download Queue - art: FEMPTYQ - options: { - pause: true - menuFlags: [ "noHistory" ] - } - } - - fileTransferProtocolSelection: { - desc: Protocol selection - module: file_transfer_protocol_select - art: FPROSEL - form: { - 0: { - mci: { - VM1: { - focus: true - argName: protocol - } - } - - submit: { - *: [ - { - value: { protocol: null } - action: @method:selectProtocol - } - ] - } - - actionKeys: [ - { - keys: [ "escape" ] - action: @systemMethod:prevMenu - } - ] - } - } - } - - fileBaseUploadFiles: { - desc: Uploading - module: upload - config: { - art: { - options: ULOPTS - fileDetails: ULDETAIL - processing: ULCHECK - dupes: ULDUPES - } - } - - form: { - // options - 0: { - mci: { - SM1: { - argName: areaSelect - focus: true - } - TM2: { - argName: uploadType - items: [ "blind", "supply filename" ] - } - ET3: { - argName: fileName - maxLength: 255 - validate: @method:validateNonBlindFileName - } - HM4: { - argName: navSelect - items: [ "continue", "cancel" ] - submit: true - } - } - - submit: { - *: [ - { - value: { navSelect: 0 } - action: @method:optionsNavContinue - } - { - value: { navSelect: 1 } - action: @systemMethod:prevMenu - } - ] - } - - "actionKeys" : [ - { - "keys" : [ "escape" ], - action: @systemMethod:prevMenu - } - ] - } - - 1: { - mci: { - TL1: {} - TL2: {} - TL3: {} - MT4: {} - TL10: {} - } - } - - // file details entry - 2: { - mci: { - MT1: { - argName: shortDesc - tabSwitchesView: true - focus: true - } - - ET2: { - argName: tags - } - - ME3: { - argName: estYear - maskPattern: "####" - } - - BT4: { - argName: continue - text: continue - submit: true - } - } - - submit: { - *: [ - { - value: { continue: null } - action: @method:fileDetailsContinue - } - ] - } - } - - // dupes - 3: { - mci: { - VM1: { - /* - Use 'dupeInfoFormat' to custom format: - - areaDesc - areaName - areaTag - desc - descLong - fileId - fileName - fileSha256 - storageTag - uploadTimestamp - - */ - - mode: preview - } - } - } - } - } - - fileBaseNoUploadAreasAvail: { - desc: File Base - art: ULNOAREA - options: { - pause: true - menuFlags: [ "noHistory" ] - } - } - - sendFilesToUser: { - desc: Downloading - module: @systemModule:file_transfer - config: { - // defaults - generally use extraArgs - protocol: zmodem8kSexyz - direction: send - } - } - - recvFilesFromUser: { - desc: Uploading - module: @systemModule:file_transfer - config: { - // defaults - generally use extraArgs - protocol: zmodem8kSexyz - direction: recv - } - } - - - //////////////////////////////////////////////////////////////////////// - // Required entries - //////////////////////////////////////////////////////////////////////// - idleLogoff: { - art: IDLELOG - next: @systemMethod:logoff - } - //////////////////////////////////////////////////////////////////////// - // Demo Section - // :TODO: This entire section needs updated!!! - //////////////////////////////////////////////////////////////////////// - "demoMain" : { - "art" : "demo_selection_vm.ans", - "form" : { - "0" : { - "VM" : { - "mci" : { - "VM1" : { - "items" : [ - "Single Line Text Editing Views", - "Spinner & Toggle Views", - "Mask Edit Views", - "Multi Line Text Editor", - "Vertical Menu Views", - "Horizontal Menu Views", - "Art Display", - "Full Screen Editor" - ], - "height" : 10, - "itemSpacing" : 1, - "justify" : "center", - "focusTextStyle" : "small i" - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@menu:demoEditTextView" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoSpinAndToggleView" - }, - { - "value" : { "1" : 2 }, - "action" : "@menu:demoMaskEditView" - }, - { - "value" : { "1" : 3 }, - "action" : "@menu:demoMultiLineEditTextView" - }, - { - "value" : { "1" : 4 }, - "action" : "@menu:demoVerticalMenuView" - }, - { - "value" : { "1" : 5 }, - "action" : "@menu:demoHorizontalMenuView" - }, - { - "value" : { "1" : 6 }, - "action" : "@menu:demoArtDisplay" - }, - { - "value" : { "1" : 7 }, - "action" : "@menu:demoFullScreenEditor" - } - ] - } - } - } - } - }, - "demoEditTextView" : { - "art" : "demo_edit_text_view1.ans", - "form" : { - "0" : { - "BTETETETET" : { - "mci" : { - "ET1" : { - "width" : 20, - "maxLength" : 20 - }, - "ET2" : { - "width" : 20, - "maxLength" : 40, - "textOverflow" : "..." - }, - "ET3" : { - "width" : 20, - "fillChar" : "-", - "styleSGR1" : "|00|36", - "maxLength" : 20 - }, - "ET4" : { - "width" : 20, - "maxLength" : 20, - "password" : true - }, - "BT5" : { - "width" : 8, - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoSpinAndToggleView" : { - "art" : "demo_spin_and_toggle.ans", - "form" : { - "0" : { - "BTSMSMTM" : { - "mci" : { - "SM1" : { - "items" : [ "Henry Morgan", "François l'Ollonais", "Roche Braziliano", "Black Bart", "Blackbeard" ] - }, - "SM2" : { - "items" : [ "Razor 1911", "DrinkOrDie", "TRSI" ] - }, - "TM3" : { - "items" : [ "Yarly", "Nowaii" ], - "styleSGR1" : "|00|30|01", - "hotKeys" : { "Y" : 0, "N" : 1 } - }, - "BT8" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 8, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 8 - } - ] - } - } - } - }, - "demoMaskEditView" : { - "art" : "demo_mask_edit_text_view1.ans", - "form" : { - "0" : { - "BTMEME" : { - "mci" : { - "ME1" : { - "maskPattern" : "##/##/##", - "styleSGR1" : "|00|30|01", - //"styleSGR2" : "|00|45|01", - "styleSGR3" : "|00|30|35", - "fillChar" : "#" - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoMultiLineEditTextView" : { - "art" : "demo_multi_line_edit_text_view1.ans", - "form" : { - "0" : { - "BTMT" : { - "mci" : { - "MT1" : { - "width" : 70, - "height" : 17, - //"text" : "@art:demo_multi_line_edit_text_view_text.txt", - // "text" : "@systemMethod:textFromFile" - text: "Hints:\n\t* Insert / CTRL-V toggles overtype mode\n\t* CTRL-Y deletes the current line\n\t* Try Page Up / Page Down\n\t* Home goes to the start of line text\n\t* End goes to the end of a line\n\n\nTab handling:\n-------------------------------------------------\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\n\tA\tB\tC\tD\tE\tF\nA\tB\tC\tD\tE\tF\tG\tH\nA0\tBB\t1\tCCC\t2\tDDD\t3EEEE\nW\t\tX\t\tY\t\tZ\n\nAn excerpt from A Clockwork Orange:\n\"What sloochatted then, of course, was that my cellmates woke up and started joining in, tolchocking a bit wild in the near-dark, and the shoom seemed to wake up the whole tier, so that you could slooshy a lot of creeching and banging about with tin mugs on the wall, as though all the plennies in all the cells thought a big break was about to commence, O my brothers.\n", - "focus" : true - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoHorizontalMenuView" : { - "art" : "demo_horizontal_menu_view1.ans", - "form" : { - "0" : { - "BTHMHM" : { - "mci" : { - "HM1" : { - "items" : [ "One", "Two", "Three" ], - "hotKeys" : { "1" : 0, "2" : 1, "3" : 2 } - }, - "HM2" : { - "items" : [ "Uno", "Dos", "Tres" ], - "hotKeys" : { "U" : 0, "D" : 1, "T" : 2 } - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - }, - "demoVerticalMenuView" : { - "art" : "demo_vertical_menu_view1.ans", - "form" : { - "0" : { - "BTVM" : { - "mci" : { - "VM1" : { - "items" : [ - "|33Oblivion/2", - "|33iNiQUiTY", - "|33ViSiON/X" - ], - "focusItems" : [ - "|33Oblivion|01/|00|332", - "|01|33i|00|33N|01i|00|33QU|01i|00|33TY", - "|33ViSiON/X" - ] - // - // :TODO: how to do the following: - // 1) Supply a view a string for a standard vs focused item - // "items" : [...], "focusItems" : [ ... ] ? - // "draw" : "@method:drawItemX", then items: [...] - }, - "BT5" : { - "text" : "< Back" - } - }, - "submit" : { - "*" : [ - { - "value" : 5, - "action" : "@menu:demoMain" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 5 - } - ] - } - } - } - - }, - "demoArtDisplay" : { - "art" : "demo_selection_vm.ans", - "form" : { - "0" : { - "VM" : { - "mci" : { - "VM1" : { - "items" : [ - "Defaults - DOS ANSI", - "bw_mindgames.ans - DOS", - "test.ans - DOS", - "Defaults - Amiga", - "Pause at Term Height" - ], - // :TODO: justify not working?? - "focusTextStyle" : "small i" - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@menu:demoDefaultsDosAnsi" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoDefaultsDosAnsi_bw_mindgames" - }, - { - "value" : { "1" : 2 }, - "action" : "@menu:demoDefaultsDosAnsi_test" - } - ] - } - } - } - } - }, - "demoDefaultsDosAnsi" : { - "art" : "DM-ENIG2.ANS" - }, - "demoDefaultsDosAnsi_bw_mindgames" : { - "art" : "bw_mindgames.ans" - }, - "demoDefaultsDosAnsi_test" : { - "art" : "test.ans" - }, - "demoFullScreenEditor" : { - "module" : "@systemModule:fse", - "config" : { - "editorType" : "netMail", - "art" : { - "header" : "demo_fse_netmail_header.ans", - "body" : "demo_fse_netmail_body.ans", - "footerEditor" : "demo_fse_netmail_footer_edit.ans", - "footerEditorMenu" : "demo_fse_netmail_footer_edit_menu.ans", - "footerView" : "demo_fse_netmail_footer_view.ans", - "help" : "demo_fse_netmail_help.ans" - } - }, - "form" : { - "0" : { - "ETETET" : { - "mci" : { - "ET1" : { - // :TODO: from/to may be set by args - // :TODO: focus may change dep on view vs edit - "width" : 36, - "focus" : true, - "argName" : "to" - }, - "ET2" : { - "width" : 36, - "argName" : "from" - }, - "ET3" : { - "width" : 65, - "maxLength" : 72, - "submit" : [ "enter" ], - "argName" : "subject" - } - }, - "submit" : { - "3" : [ - { - "value" : { "subject" : null }, - "action" : "@method:headerSubmit" - } - ] - } - } - }, - "1" : { - "MT" : { - "mci" : { - "MT1" : { - "width" : 79, - "height" : 17, - "text" : "", // :TODO: should not be req. - "argName" : "message" - } - }, - "submit" : { - "*" : [ - { - "value" : "message", - "action" : "@method:editModeEscPressed" - } - ] - }, - "actionKeys" : [ - { - "keys" : [ "escape" ], - "viewId" : 1 - } - ] - } - }, - "2" : { - "TLTL" : { - "mci" : { - "TL1" : { - "width" : 5 - }, - "TL2" : { - "width" : 4 - } - } - } - }, - "3" : { - "HM" : { - "mci" : { - "HM1" : { - // :TODO: Continue, Save, Discard, Clear, Quote, Help - "items" : [ "Save", "Discard", "Quote", "Help" ] - } - }, - "submit" : { - "*" : [ - { - "value" : { "1" : 0 }, - "action" : "@method:editModeMenuSave" - }, - { - "value" : { "1" : 1 }, - "action" : "@menu:demoMain" - }, - { - "value" : { "1" : 2 }, - "action" : "@method:editModeMenuQuote" - }, - { - "value" : { "1" : 3 }, - "action" : "@method:editModeMenuHelp" - }, - { - "value" : 1, - "action" : "@method:editModeEscPressed" - } - ] - }, - "actionKeys" : [ // :TODO: Need better name - { - "keys" : [ "escape" ], - "action" : "@method:editModeEscPressed" - } - ] - } - } - } - } - } -} diff --git a/mods/msg_area_list.js b/mods/msg_area_list.js deleted file mode 100644 index a6a0df4c..00000000 --- a/mods/msg_area_list.js +++ /dev/null @@ -1,177 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const displayThemeArt = require('../core/theme.js').displayThemeArt; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'Message Area List', - desc : 'Module for listing / choosing message areas', - author : 'NuSkooler', -}; - -/* - :TODO: - - Obv/2 has the following: - CHANGE .ANS - Message base changing ansi - |SN Current base name - |SS Current base sponsor - |NM Number of messages in current base - |UP Number of posts current user made (total) - |LR Last read message by current user - |DT Current date - |TI Current time -*/ - -const MciViewIds = { - AreaList : 1, - SelAreaInfo1 : 2, - SelAreaInfo2 : 3, -}; - -exports.getModule = class MessageAreaListModule extends MenuModule { - constructor(options) { - super(options); - - this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag( - this.client.user.properties.message_conf_tag, - { client : this.client } - ); - - const self = this; - this.menuMethods = { - changeArea : function(formData, extraArgs, cb) { - if(1 === formData.submitId) { - let area = self.messageAreas[formData.value.area]; - const areaTag = area.areaTag; - area = area.area; // what we want is actually embedded - - messageArea.changeMessageArea(self.client, areaTag, err => { - if(err) { - self.client.term.pipeWrite(`\n|00Cannot change area: ${err.message}\n`); - - self.prevMenuOnTimeout(1000, cb); - } else { - if(_.isString(area.art)) { - const dispOptions = { - client : self.client, - name : area.art, - }; - - self.client.term.rawWrite(resetScreen()); - - displayThemeArt(dispOptions, () => { - // pause by default, unless explicitly told not to - if(_.has(area, 'options.pause') && false === area.options.pause) { - return self.prevMenuOnTimeout(1000, cb); - } else { - self.pausePrompt( () => { - return self.prevMenu(cb); - }); - } - }); - } else { - return self.prevMenu(cb); - } - } - }); - } else { - return cb(null); - } - } - }; - } - - prevMenuOnTimeout(timeout, cb) { - setTimeout( () => { - return this.prevMenu(cb); - }, timeout); - } - - updateGeneralAreaInfoViews(areaIndex) { - // :TODO: these concepts have been replaced with the {someKey} style formatting - update me! - /* experimental: not yet avail - const areaInfo = self.messageAreas[areaIndex]; - - [ MciViewIds.SelAreaInfo1, MciViewIds.SelAreaInfo2 ].forEach(mciId => { - const v = self.viewControllers.areaList.getView(mciId); - if(v) { - v.setFormatObject(areaInfo.area); - } - }); - */ - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); - - async.series( - [ - function loadFromConfig(callback) { - const loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - formId : 0, - }; - - vc.loadFromMenuConfig(loadOpts, function startingViewReady(err) { - callback(err); - }); - }, - function populateAreaListView(callback) { - const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}'; - const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; - - const areaListView = vc.getView(MciViewIds.AreaList); - let i = 1; - areaListView.setItems(_.map(self.messageAreas, v => { - return stringFormat(listFormat, { - index : i++, - areaTag : v.area.areaTag, - name : v.area.name, - desc : v.area.desc, - }); - })); - - i = 1; - areaListView.setFocusItems(_.map(self.messageAreas, v => { - return stringFormat(focusListFormat, { - index : i++, - areaTag : v.area.areaTag, - name : v.area.name, - desc : v.area.desc, - }); - })); - - areaListView.on('index update', areaIndex => { - self.updateGeneralAreaInfoViews(areaIndex); - }); - - areaListView.redraw(); - - callback(null); - } - ], - function complete(err) { - return cb(err); - } - ); - }); - } -}; diff --git a/mods/msg_area_post_fse.js b/mods/msg_area_post_fse.js deleted file mode 100644 index 21b5d068..00000000 --- a/mods/msg_area_post_fse.js +++ /dev/null @@ -1,67 +0,0 @@ -/* jslint node: true */ -'use strict'; - -const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; -const persistMessage = require('../core/message_area.js').persistMessage; - -const _ = require('lodash'); -const async = require('async'); - -exports.moduleInfo = { - name : 'Message Area Post', - desc : 'Module for posting a new message to an area', - author : 'NuSkooler', -}; - -exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule { - constructor(options) { - super(options); - - const self = this; - - // we're posting, so always start with 'edit' mode - this.editorMode = 'edit'; - - this.menuMethods.editModeMenuSave = function(formData, extraArgs, cb) { - - var msg; - async.series( - [ - function getMessageObject(callback) { - self.getMessage(function gotMsg(err, msgObj) { - msg = msgObj; - return callback(err); - }); - }, - function saveMessage(callback) { - return persistMessage(msg, callback); - }, - function updateStats(callback) { - self.updateUserStats(callback); - } - ], - function complete(err) { - if(err) { - // :TODO:... sooooo now what? - } else { - // note: not logging 'from' here as it's part of client.log.xxxx() - self.client.log.info( - { to : msg.toUserName, subject : msg.subject, uuid : msg.uuid }, - 'Message persisted' - ); - } - - return self.nextMenu(cb); - } - ); - }; - } - - enter() { - if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) { - this.messageAreaTag = this.client.user.properties.message_area_tag; - } - - super.enter(); - } -}; \ No newline at end of file diff --git a/mods/msg_area_reply_fse.js b/mods/msg_area_reply_fse.js deleted file mode 100644 index d1cb5faa..00000000 --- a/mods/msg_area_reply_fse.js +++ /dev/null @@ -1,18 +0,0 @@ -/* jslint node: true */ -'use strict'; - -var FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; - -exports.getModule = AreaReplyFSEModule; - -exports.moduleInfo = { - name : 'Message Area Reply', - desc : 'Module for replying to an area message', - author : 'NuSkooler', -}; - -function AreaReplyFSEModule(options) { - FullScreenEditorModule.call(this, options); -} - -require('util').inherits(AreaReplyFSEModule, FullScreenEditorModule); diff --git a/mods/msg_area_view_fse.js b/mods/msg_area_view_fse.js deleted file mode 100644 index de4657f1..00000000 --- a/mods/msg_area_view_fse.js +++ /dev/null @@ -1,135 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; -const Message = require('../core/message.js'); - -// deps -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'Message Area View', - desc : 'Module for viewing an area message', - author : 'NuSkooler', -}; - -exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule { - constructor(options) { - super(options); - - this.editorType = 'area'; - this.editorMode = 'view'; - - if(_.isObject(options.extraArgs)) { - this.messageList = options.extraArgs.messageList; - this.messageIndex = options.extraArgs.messageIndex; - this.lastMessageNextExit = options.extraArgs.lastMessageNextExit; - } - - this.messageList = this.messageList || []; - this.messageIndex = this.messageIndex || 0; - this.messageTotal = this.messageList.length; - - const self = this; - - // assign *additional* menuMethods - Object.assign(this.menuMethods, { - nextMessage : (formData, extraArgs, cb) => { - if(self.messageIndex + 1 < self.messageList.length) { - self.messageIndex++; - - return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb); - } - - // auto-exit if no more to go? - if(self.lastMessageNextExit) { - self.lastMessageReached = true; - return self.prevMenu(cb); - } - - return cb(null); - }, - - prevMessage : (formData, extraArgs, cb) => { - if(self.messageIndex > 0) { - self.messageIndex--; - - return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb); - } - - return cb(null); - }, - - movementKeyPressed : (formData, extraArgs, cb) => { - const bodyView = self.viewControllers.body.getView(1); // :TODO: use const here vs magic # - - // :TODO: Create methods for up/down vs using keyPressXXXXX - 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; - } - - // :TODO: need to stop down/page down if doing so would push the last - // visible page off the screen at all .... this should be handled by MLTEV though... - - return cb(null); - }, - - replyMessage : (formData, extraArgs, cb) => { - if(_.isString(extraArgs.menu)) { - const modOpts = { - extraArgs : { - messageAreaTag : self.messageAreaTag, - replyToMessage : self.message, - } - }; - - return self.gotoMenu(extraArgs.menu, modOpts, cb); - } - - self.client.log(extraArgs, 'Missing extraArgs.menu'); - return cb(null); - } - }); - } - - - loadMessageByUuid(uuid, cb) { - const msg = new Message(); - msg.load( { uuid : uuid, user : this.client.user }, () => { - this.setMessage(msg); - - if(cb) { - return cb(null); - } - }); - } - - finishedLoading() { - this.loadMessageByUuid(this.messageList[this.messageIndex].messageUuid); - } - - getSaveState() { - return { - messageList : this.messageList, - messageIndex : this.messageIndex, - messageTotal : this.messageList.length, - }; - } - - restoreSavedState(savedState) { - this.messageList = savedState.messageList; - this.messageIndex = savedState.messageIndex; - this.messageTotal = savedState.messageTotal; - } - - getMenuResult() { - return { - messageIndex : this.messageIndex, - lastMessageReached : this.lastMessageReached, - }; - } -}; diff --git a/mods/msg_conf_list.js b/mods/msg_conf_list.js deleted file mode 100644 index 91c24de4..00000000 --- a/mods/msg_conf_list.js +++ /dev/null @@ -1,148 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const displayThemeArt = require('../core/theme.js').displayThemeArt; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'Message Conference List', - desc : 'Module for listing / choosing message conferences', - author : 'NuSkooler', -}; - -const MciViewIds = { - ConfList : 1, - - // :TODO: - // # areas in conf .... see Obv/2, iNiQ, ... - // -}; - -exports.getModule = class MessageConfListModule extends MenuModule { - constructor(options) { - super(options); - - this.messageConfs = messageArea.getSortedAvailMessageConferences(this.client); - const self = this; - - this.menuMethods = { - changeConference : function(formData, extraArgs, cb) { - if(1 === formData.submitId) { - let conf = self.messageConfs[formData.value.conf]; - const confTag = conf.confTag; - conf = conf.conf; // what we want is embedded - - messageArea.changeMessageConference(self.client, confTag, err => { - if(err) { - self.client.term.pipeWrite(`\n|00Cannot change conference: ${err.message}\n`); - - setTimeout( () => { - return self.prevMenu(cb); - }, 1000); - } else { - if(_.isString(conf.art)) { - const dispOptions = { - client : self.client, - name : conf.art, - }; - - self.client.term.rawWrite(resetScreen()); - - displayThemeArt(dispOptions, () => { - // pause by default, unless explicitly told not to - if(_.has(conf, 'options.pause') && false === conf.options.pause) { - return self.prevMenuOnTimeout(1000, cb); - } else { - self.pausePrompt( () => { - return self.prevMenu(cb); - }); - } - }); - } else { - return self.prevMenu(cb); - } - } - }); - } else { - return cb(null); - } - } - }; - } - - prevMenuOnTimeout(timeout, cb) { - setTimeout( () => { - return this.prevMenu(cb); - }, timeout); - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); - - async.series( - [ - function loadFromConfig(callback) { - let loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - formId : 0, - }; - - vc.loadFromMenuConfig(loadOpts, callback); - }, - function populateConfListView(callback) { - const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}'; - const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; - - const confListView = vc.getView(MciViewIds.ConfList); - let i = 1; - confListView.setItems(_.map(self.messageConfs, v => { - return stringFormat(listFormat, { - index : i++, - confTag : v.conf.confTag, - name : v.conf.name, - desc : v.conf.desc, - }); - })); - - i = 1; - confListView.setFocusItems(_.map(self.messageConfs, v => { - return stringFormat(focusListFormat, { - index : i++, - confTag : v.conf.confTag, - name : v.conf.name, - desc : v.conf.desc, - }); - })); - - confListView.redraw(); - - callback(null); - }, - function populateTextViews(callback) { - // :TODO: populate other avail MCI, e.g. current conf name - callback(null); - } - ], - function complete(err) { - cb(err); - } - ); - }); - } -}; diff --git a/mods/msg_list.js b/mods/msg_list.js deleted file mode 100644 index bc80e27b..00000000 --- a/mods/msg_list.js +++ /dev/null @@ -1,259 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const stringFormat = require('../core/string_format.js'); -const MessageAreaConfTempSwitcher = require('../core/mod_mixins.js').MessageAreaConfTempSwitcher; - -// deps -const async = require('async'); -const _ = require('lodash'); -const moment = require('moment'); - -/* - Available listFormat/focusListFormat members (VM1): - - msgNum : Message number - to : To username/handle - from : From username/handle - subj : Subject - ts : Message mod timestamp (format with config.dateTimeFormat) - newIndicator : New mark/indicator (config.newIndicator) - - MCI codes: - - VM1 : Message list - TL2 : Message info 1: { msgNumSelected, msgNumTotal } -*/ - -exports.moduleInfo = { - name : 'Message List', - desc : 'Module for listing/browsing available messages', - author : 'NuSkooler', -}; - -const MCICodesIDs = { - MsgList : 1, // VM1 - MsgInfo1 : 2, // TL2 -}; - -exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(MenuModule) { - constructor(options) { - super(options); - - const self = this; - const config = this.menuConfig.config; - - this.messageAreaTag = config.messageAreaTag; - - this.lastMessageReachedExit = _.get(options, 'lastMenuResult.lastMessageReached', false); - - if(options.extraArgs) { - // - // |extraArgs| can override |messageAreaTag| provided by config - // as well as supply a pre-defined message list - // - if(options.extraArgs.messageAreaTag) { - this.messageAreaTag = options.extraArgs.messageAreaTag; - } - - if(options.extraArgs.messageList) { - this.messageList = options.extraArgs.messageList; - } - } - - this.menuMethods = { - selectMessage : function(formData, extraArgs, cb) { - if(1 === formData.submitId) { - self.initialFocusIndex = formData.value.message; - - const modOpts = { - extraArgs : { - messageAreaTag : self.messageAreaTag, - messageList : self.messageList, - messageIndex : formData.value.message, - lastMessageNextExit : true, - } - }; - - // - // Provide a serializer so we don't dump *huge* bits of information to the log - // due to the size of |messageList|. See https://github.com/trentm/node-bunyan/issues/189 - // - modOpts.extraArgs.toJSON = function() { - const logMsgList = (this.messageList.length <= 4) ? - this.messageList : - this.messageList.slice(0, 2).concat(this.messageList.slice(-2)); - - return { - messageAreaTag : this.messageAreaTag, - apprevMessageList : logMsgList, - messageCount : this.messageList.length, - messageIndex : formData.value.message, - }; - }; - - return self.gotoMenu(config.menuViewPost || 'messageAreaViewPost', modOpts, cb); - } else { - return cb(null); - } - }, - - fullExit : function(formData, extraArgs, cb) { - self.menuResult = { fullExit : true }; - return self.prevMenu(cb); - } - }; - } - - enter() { - if(this.lastMessageReachedExit) { - return this.prevMenu(); - } - - super.enter(); - - // - // Config can specify |messageAreaTag| else it comes from - // the user's current area - // - if(this.messageAreaTag) { - this.tempMessageConfAndAreaSwitch(this.messageAreaTag); - } else { - this.messageAreaTag = this.client.user.properties.message_area_tag; - } - } - - leave() { - this.tempMessageConfAndAreaRestore(); - super.leave(); - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); - - async.series( - [ - function loadFromConfig(callback) { - const loadOpts = { - callingMenu : self, - mciMap : mciData.menu - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - }, - function fetchMessagesInArea(callback) { - // - // Config can supply messages else we'll need to populate the list now - // - if(_.isArray(self.messageList)) { - return callback(0 === self.messageList.length ? new Error('No messages in area') : null); - } - - messageArea.getMessageListForArea( { client : self.client }, self.messageAreaTag, function msgs(err, msgList) { - if(!msgList || 0 === msgList.length) { - return callback(new Error('No messages in area')); - } - - self.messageList = msgList; - return callback(err); - }); - }, - function getLastReadMesageId(callback) { - messageArea.getMessageAreaLastReadId(self.client.user.userId, self.messageAreaTag, function lastRead(err, lastReadId) { - self.lastReadId = lastReadId || 0; - return callback(null); // ignore any errors, e.g. missing value - }); - }, - function updateMessageListObjects(callback) { - const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM Do'; - const newIndicator = self.menuConfig.config.newIndicator || '*'; - const regIndicator = new Array(newIndicator.length + 1).join(' '); // fill with space to avoid draw issues - - let msgNum = 1; - self.messageList.forEach( (listItem, index) => { - listItem.msgNum = msgNum++; - listItem.ts = moment(listItem.modTimestamp).format(dateTimeFormat); - listItem.newIndicator = listItem.messageId > self.lastReadId ? newIndicator : regIndicator; - - if(_.isUndefined(self.initialFocusIndex) && listItem.messageId > self.lastReadId) { - self.initialFocusIndex = index; - } - }); - return callback(null); - }, - function populateList(callback) { - const msgListView = vc.getView(MCICodesIDs.MsgList); - const listFormat = self.menuConfig.config.listFormat || '{msgNum} - {subject} - {toUserName}'; - const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; // :TODO: default change color here - const messageInfo1Format = self.menuConfig.config.messageInfo1Format || '{msgNumSelected} / {msgNumTotal}'; - - // :TODO: This can take a very long time to load large lists. What we need is to implement the "owner draw" concept in - // which items are requested (e.g. their format at least) *as-needed* vs trying to get the format for all of them at once - - msgListView.setItems(_.map(self.messageList, listEntry => { - return stringFormat(listFormat, listEntry); - })); - - msgListView.setFocusItems(_.map(self.messageList, listEntry => { - return stringFormat(focusListFormat, listEntry); - })); - - msgListView.on('index update', idx => { - self.setViewText( - 'allViews', - MCICodesIDs.MsgInfo1, - stringFormat(messageInfo1Format, { msgNumSelected : (idx + 1), msgNumTotal : self.messageList.length } )); - }); - - if(self.initialFocusIndex > 0) { - // note: causes redraw() - msgListView.setFocusItemIndex(self.initialFocusIndex); - } else { - msgListView.redraw(); - } - - return callback(null); - }, - function drawOtherViews(callback) { - const messageInfo1Format = self.menuConfig.config.messageInfo1Format || '{msgNumSelected} / {msgNumTotal}'; - self.setViewText( - 'allViews', - MCICodesIDs.MsgInfo1, - stringFormat(messageInfo1Format, { msgNumSelected : self.initialFocusIndex + 1, msgNumTotal : self.messageList.length } )); - return callback(null); - }, - ], - err => { - if(err) { - self.client.log.error( { error : err.message }, 'Error loading message list'); - } - return cb(err); - } - ); - }); - } - - getSaveState() { - return { initialFocusIndex : this.initialFocusIndex }; - } - - restoreSavedState(savedState) { - if(savedState) { - this.initialFocusIndex = savedState.initialFocusIndex; - } - } - - getMenuResult() { - return this.menuResult; - } -}; diff --git a/mods/nua.js b/mods/nua.js deleted file mode 100644 index 878e0581..00000000 --- a/mods/nua.js +++ /dev/null @@ -1,144 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const User = require('../core/user.js'); -const theme = require('../core/theme.js'); -const login = require('../core/system_menu_method.js').login; -const Config = require('../core/config.js').config; -const messageArea = require('../core/message_area.js'); - -exports.moduleInfo = { - name : 'NUA', - desc : 'New User Application', -}; - -const MciViewIds = { - userName : 1, - password : 9, - confirm : 10, - errMsg : 11, -}; - -exports.getModule = class NewUserAppModule extends MenuModule { - - constructor(options) { - super(options); - - const self = this; - - this.menuMethods = { - // - // Validation stuff - // - validatePassConfirmMatch : function(data, cb) { - const passwordView = self.viewControllers.menu.getView(MciViewIds.password); - return cb(passwordView.getData() === data ? null : new Error('Passwords do not match')); - }, - - viewValidationListener : function(err, cb) { - const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg); - let newFocusId; - - if(err) { - errMsgView.setText(err.message); - err.view.clearText(); - - if(err.view.getId() === MciViewIds.confirm) { - newFocusId = MciViewIds.password; - self.viewControllers.menu.getView(MciViewIds.password).clearText(); - } - } else { - errMsgView.clearText(); - } - - return cb(newFocusId); - }, - - - // - // Submit handlers - // - submitApplication : function(formData, extraArgs, cb) { - const newUser = new User(); - - newUser.username = formData.value.username; - - // - // We have to disable ACS checks for initial default areas as the user is not yet ready - // - let confTag = messageArea.getDefaultMessageConferenceTag(self.client, true); // true=disableAcsCheck - let areaTag = messageArea.getDefaultMessageAreaTagByConfTag(self.client, confTag, true); // true=disableAcsCheck - - // can't store undefined! - confTag = confTag || ''; - areaTag = areaTag || ''; - - newUser.properties = { - real_name : formData.value.realName, - birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), // :TODO: Use moment & explicit ISO string format - sex : formData.value.sex, - location : formData.value.location, - affiliation : formData.value.affils, - email_address : formData.value.email, - web_address : formData.value.web, - account_created : new Date().toISOString(), // :TODO: Use moment & explicit ISO string format - - message_conf_tag : confTag, - message_area_tag : areaTag, - - term_height : self.client.term.termHeight, - term_width : self.client.term.termWidth, - - // :TODO: Other defaults - // :TODO: should probably have a place to create defaults/etc. - }; - - if('*' === Config.defaults.theme) { - newUser.properties.theme_id = theme.getRandomTheme(); - } else { - newUser.properties.theme_id = Config.defaults.theme; - } - - // :TODO: User.create() should validate email uniqueness! - newUser.create(formData.value.password, err => { - if(err) { - self.client.log.info( { error : err, username : formData.value.username }, 'New user creation failed'); - - self.gotoMenu(extraArgs.error, err => { - if(err) { - return self.prevMenu(cb); - } - return cb(null); - }); - } else { - self.client.log.info( { username : formData.value.username, userId : newUser.userId }, 'New user created'); - - // Cache SysOp information now - // :TODO: Similar to bbs.js. DRY - if(newUser.isSysOp()) { - Config.general.sysOp = { - username : formData.value.username, - properties : newUser.properties, - }; - } - - if(User.AccountStatus.inactive === self.client.user.properties.account_status) { - return self.gotoMenu(extraArgs.inactive, cb); - } else { - // - // If active now, we need to call login() to authenticate - // - return login(self, formData, extraArgs, cb); - } - } - }); - }, - }; - } - - mciReady(mciData, cb) { - return this.standardMCIReadyHandler(mciData, cb); - } -}; diff --git a/mods/onelinerz.js b/mods/onelinerz.js deleted file mode 100644 index 335c25ce..00000000 --- a/mods/onelinerz.js +++ /dev/null @@ -1,333 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const getModDatabasePath = require('../core/database.js').getModDatabasePath; -const ViewController = require('../core/view_controller.js').ViewController; -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); -const stringFormat = require('../core/string_format.js'); - -// deps -const sqlite3 = require('sqlite3'); -const async = require('async'); -const _ = require('lodash'); -const moment = require('moment'); - -/* - Module :TODO: - * Add pipe code support - - override max length & monitor *display* len as user types in order to allow for actual display len with color - * Add preview control: Shows preview with pipe codes resolved - * Add ability to at least alternate formatStrings -- every other -*/ - - -exports.moduleInfo = { - name : 'Onelinerz', - desc : 'Standard local onelinerz', - author : 'NuSkooler', - packageName : 'codes.l33t.enigma.onelinerz', -}; - -const MciViewIds = { - ViewForm : { - Entries : 1, - AddPrompt : 2, - }, - AddForm : { - NewEntry : 1, - EntryPreview : 2, - AddPrompt : 3, - } -}; - -const FormIds = { - View : 0, - Add : 1, -}; - -exports.getModule = class OnelinerzModule extends MenuModule { - constructor(options) { - super(options); - - const self = this; - - this.menuMethods = { - viewAddScreen : function(formData, extraArgs, cb) { - return self.displayAddScreen(cb); - }, - - addEntry : function(formData, extraArgs, cb) { - if(_.isString(formData.value.oneliner) && formData.value.oneliner.length > 0) { - const oneliner = formData.value.oneliner.trim(); // remove any trailing ws - - self.storeNewOneliner(oneliner, err => { - if(err) { - self.client.log.warn( { error : err.message }, 'Failed saving oneliner'); - } - - self.clearAddForm(); - return self.displayViewScreen(true, cb); // true=cls - }); - - } else { - // empty message - treat as if cancel was hit - return self.displayViewScreen(true, cb); // true=cls - } - }, - - cancelAdd : function(formData, extraArgs, cb) { - self.clearAddForm(); - return self.displayViewScreen(true, cb); // true=cls - } - }; - } - - initSequence() { - const self = this; - async.series( - [ - function beforeDisplayArt(callback) { - return self.beforeArt(callback); - }, - function display(callback) { - return self.displayViewScreen(false, callback); - } - ], - err => { - if(err) { - // :TODO: Handle me -- initSequence() should really take a completion callback - } - self.finishedLoading(); - } - ); - } - - displayViewScreen(clearScreen, cb) { - const self = this; - - async.waterfall( - [ - function clearAndDisplayArt(callback) { - if(self.viewControllers.add) { - self.viewControllers.add.setFocus(false); - } - - if(clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); - } - - theme.displayThemedAsset( - self.menuConfig.config.art.entries, - self.client, - { font : self.menuConfig.font, trailingLF : false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function initOrRedrawViewController(artData, callback) { - if(_.isUndefined(self.viewControllers.add)) { - const vc = self.addViewController( - 'view', - new ViewController( { client : self.client, formId : FormIds.View } ) - ); - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds.View, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } else { - self.viewControllers.view.setFocus(true); - self.viewControllers.view.getView(MciViewIds.ViewForm.AddPrompt).redraw(); - return callback(null); - } - }, - function fetchEntries(callback) { - const entriesView = self.viewControllers.view.getView(MciViewIds.ViewForm.Entries); - const limit = entriesView.dimens.height; - let entries = []; - - self.db.each( - `SELECT * - FROM ( - SELECT * - FROM onelinerz - ORDER BY timestamp DESC - LIMIT ${limit} - ) - ORDER BY timestamp ASC;`, - (err, row) => { - if(!err) { - row.timestamp = moment(row.timestamp); // convert -> moment - entries.push(row); - } - }, - err => { - return callback(err, entriesView, entries); - } - ); - }, - function populateEntries(entriesView, entries, callback) { - const listFormat = self.menuConfig.config.listFormat || '{username}@{ts}: {oneliner}';// :TODO: should be userName to be consistent - const tsFormat = self.menuConfig.config.timestampFormat || 'ddd h:mma'; - - entriesView.setItems(entries.map( e => { - return stringFormat(listFormat, { - userId : e.user_id, - username : e.user_name, - oneliner : e.oneliner, - ts : e.timestamp.format(tsFormat), - } ); - })); - - entriesView.redraw(); - - return callback(null); - }, - function finalPrep(callback) { - const promptView = self.viewControllers.view.getView(MciViewIds.ViewForm.AddPrompt); - promptView.setFocusItemIndex(1); // default to NO - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - displayAddScreen(cb) { - const self = this; - - async.waterfall( - [ - function clearAndDisplayArt(callback) { - self.viewControllers.view.setFocus(false); - self.client.term.rawWrite(ansi.resetScreen()); - - theme.displayThemedAsset( - self.menuConfig.config.art.add, - self.client, - { font : self.menuConfig.font }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function initOrRedrawViewController(artData, callback) { - if(_.isUndefined(self.viewControllers.add)) { - const vc = self.addViewController( - 'add', - new ViewController( { client : self.client, formId : FormIds.Add } ) - ); - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds.Add, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } else { - self.viewControllers.add.setFocus(true); - self.viewControllers.add.redrawAll(); - self.viewControllers.add.switchFocus(MciViewIds.AddForm.NewEntry); - return callback(null); - } - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - clearAddForm() { - this.setViewText('add', MciViewIds.AddForm.NewEntry, ''); - this.setViewText('add', MciViewIds.AddForm.EntryPreview, ''); - } - - initDatabase(cb) { - const self = this; - - async.series( - [ - function openDatabase(callback) { - self.db = new sqlite3.Database( - getModDatabasePath(exports.moduleInfo), - err => { - return callback(err); - } - ); - }, - function createTables(callback) { - self.db.run( - `CREATE TABLE IF NOT EXISTS onelinerz ( - id INTEGER PRIMARY KEY, - user_id INTEGER_NOT NULL, - user_name VARCHAR NOT NULL, - oneliner VARCHAR NOT NULL, - timestamp DATETIME NOT NULL - );` - , - err => { - return callback(err); - }); - } - ], - err => { - return cb(err); - } - ); - } - - storeNewOneliner(oneliner, cb) { - const self = this; - const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ'); - - async.series( - [ - function addRec(callback) { - self.db.run( - `INSERT INTO onelinerz (user_id, user_name, oneliner, timestamp) - VALUES (?, ?, ?, ?);`, - [ self.client.user.userId, self.client.user.username, oneliner, ts ], - callback - ); - }, - function removeOld(callback) { - // keep 25 max most recent items - remove the older ones - self.db.run( - `DELETE FROM onelinerz - WHERE id IN ( - SELECT id - FROM onelinerz - ORDER BY id DESC - LIMIT -1 OFFSET 25 - );`, - callback - ); - } - ], - err => { - return cb(err); - } - ); - } - - beforeArt(cb) { - super.beforeArt(err => { - return err ? cb(err) : this.initDatabase(cb); - }); - } -}; diff --git a/mods/rumorz.js b/mods/rumorz.js deleted file mode 100644 index 20aace03..00000000 --- a/mods/rumorz.js +++ /dev/null @@ -1,247 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const theme = require('../core/theme.js'); -const resetScreen = require('../core/ansi_term.js').resetScreen; -const StatLog = require('../core/stat_log.js'); -const renderStringLength = require('../core/string_util.js').renderStringLength; -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'Rumorz', - desc : 'Standard local rumorz', - author : 'NuSkooler', - packageName : 'codes.l33t.enigma.rumorz', -}; - -const STATLOG_KEY_RUMORZ = 'system_rumorz'; - -const FormIds = { - View : 0, - Add : 1, -}; - -const MciCodeIds = { - ViewForm : { - Entries : 1, - AddPrompt : 2, - }, - AddForm : { - NewEntry : 1, - EntryPreview : 2, - AddPrompt : 3, - } -}; - -exports.getModule = class RumorzModule extends MenuModule { - constructor(options) { - super(options); - - this.menuMethods = { - viewAddScreen : (formData, extraArgs, cb) => { - return this.displayAddScreen(cb); - }, - - addEntry : (formData, extraArgs, cb) => { - if(_.isString(formData.value.rumor) && renderStringLength(formData.value.rumor) > 0) { - const rumor = formData.value.rumor.trim(); // remove any trailing ws - - StatLog.appendSystemLogEntry(STATLOG_KEY_RUMORZ, rumor, StatLog.KeepDays.Forever, StatLog.KeepType.Forever, () => { - this.clearAddForm(); - return this.displayViewScreen(true, cb); // true=cls - }); - } else { - // empty message - treat as if cancel was hit - return this.displayViewScreen(true, cb); // true=cls - } - }, - - cancelAdd : (formData, extraArgs, cb) => { - this.clearAddForm(); - return this.displayViewScreen(true, cb); // true=cls - } - }; - } - - get config() { return this.menuConfig.config; } - - clearAddForm() { - const newEntryView = this.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry); - const previewView = this.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview); - - newEntryView.setText(''); - - // preview is optional - if(previewView) { - previewView.setText(''); - } - } - - initSequence() { - const self = this; - - async.series( - [ - function beforeDisplayArt(callback) { - self.beforeArt(callback); - }, - function display(callback) { - self.displayViewScreen(false, callback); - } - ], - err => { - if(err) { - // :TODO: Handle me -- initSequence() should really take a completion callback - } - self.finishedLoading(); - } - ); - } - - displayViewScreen(clearScreen, cb) { - const self = this; - async.waterfall( - [ - function clearAndDisplayArt(callback) { - if(self.viewControllers.add) { - self.viewControllers.add.setFocus(false); - } - - if(clearScreen) { - self.client.term.rawWrite(resetScreen()); - } - - theme.displayThemedAsset( - self.config.art.entries, - self.client, - { font : self.menuConfig.font, trailingLF : false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function initOrRedrawViewController(artData, callback) { - if(_.isUndefined(self.viewControllers.add)) { - const vc = self.addViewController( - 'view', - new ViewController( { client : self.client, formId : FormIds.View } ) - ); - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds.View, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } else { - self.viewControllers.view.setFocus(true); - self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt).redraw(); - return callback(null); - } - }, - function fetchEntries(callback) { - const entriesView = self.viewControllers.view.getView(MciCodeIds.ViewForm.Entries); - - StatLog.getSystemLogEntries(STATLOG_KEY_RUMORZ, StatLog.Order.Timestamp, (err, entries) => { - return callback(err, entriesView, entries); - }); - }, - function populateEntries(entriesView, entries, callback) { - const config = self.config; - const listFormat = config.listFormat || '{rumor}'; - const focusListFormat = config.focusListFormat || listFormat; - - entriesView.setItems(entries.map( e => stringFormat(listFormat, { rumor : e.log_value } ) ) ); - entriesView.setFocusItems(entries.map(e => stringFormat(focusListFormat, { rumor : e.log_value } ) ) ); - entriesView.redraw(); - - return callback(null); - }, - function finalPrep(callback) { - const promptView = self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt); - promptView.setFocusItemIndex(1); // default to NO - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - displayAddScreen(cb) { - const self = this; - - async.waterfall( - [ - function clearAndDisplayArt(callback) { - self.viewControllers.view.setFocus(false); - self.client.term.rawWrite(resetScreen()); - - theme.displayThemedAsset( - self.config.art.add, - self.client, - { font : self.menuConfig.font }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function initOrRedrawViewController(artData, callback) { - if(_.isUndefined(self.viewControllers.add)) { - const vc = self.addViewController( - 'add', - new ViewController( { client : self.client, formId : FormIds.Add } ) - ); - - const loadOpts = { - callingMenu : self, - mciMap : artData.mciMap, - formId : FormIds.Add, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } else { - self.viewControllers.add.setFocus(true); - self.viewControllers.add.redrawAll(); - self.viewControllers.add.switchFocus(MciCodeIds.AddForm.NewEntry); - return callback(null); - } - }, - function initPreviewUpdates(callback) { - const previewView = self.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview); - const entryView = self.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry); - if(previewView) { - let timerId; - entryView.on('key press', () => { - clearTimeout(timerId); - timerId = setTimeout( () => { - const focused = self.viewControllers.add.getFocusedView(); - if(focused === entryView) { - previewView.setText(entryView.getData()); - focused.setFocus(true); - } - }, 500); - }); - } - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } -}; diff --git a/mods/telnet_bridge.js b/mods/telnet_bridge.js deleted file mode 100644 index 1dbb1ae9..00000000 --- a/mods/telnet_bridge.js +++ /dev/null @@ -1,198 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const setSyncTermFontWithAlias = require('../core/ansi_term.js').setSyncTermFontWithAlias; - -// deps -const async = require('async'); -const _ = require('lodash'); -const net = require('net'); -const EventEmitter = require('events'); -const buffers = require('buffers'); - -/* - Expected configuration block: - - { - module: telnet_bridge - ... - config: { - host: somehost.net - port: 23 - } - } -*/ - -// :TODO: ENH: Support nodeMax and tooManyArt -exports.moduleInfo = { - name : 'Telnet Bridge', - desc : 'Connect to other Telnet Systems', - author : 'Andrew Pamment', -}; - -const IAC_DO_TERM_TYPE = new Buffer( [ 255, 253, 24 ] ); - -class TelnetClientConnection extends EventEmitter { - constructor(client) { - super(); - - this.client = client; - } - - - restorePipe() { - if(!this.pipeRestored) { - this.pipeRestored = true; - - // client may have bailed - if(_.has(this, 'client.term.output')) { - if(this.bridgeConnection) { - this.client.term.output.unpipe(this.bridgeConnection); - } - this.client.term.output.resume(); - } - } - } - - connect(connectOpts) { - this.bridgeConnection = net.createConnection(connectOpts, () => { - this.emit('connected'); - - this.pipeRestored = false; - this.client.term.output.pipe(this.bridgeConnection); - }); - - this.bridgeConnection.on('data', data => { - this.client.term.rawWrite(data); - - // - // Wait for a terminal type request, and send it eactly once. - // This is enough (in additional to other negotiations handled in telnet.js) - // to get us in on most systems - // - if(!this.termSent && data.indexOf(IAC_DO_TERM_TYPE) > -1) { - this.termSent = true; - this.bridgeConnection.write(this.getTermTypeNegotiationBuffer()); - } - }); - - this.bridgeConnection.once('end', () => { - this.restorePipe(); - this.emit('end'); - }); - - this.bridgeConnection.once('error', err => { - this.restorePipe(); - this.emit('end', err); - }); - } - - disconnect() { - if(this.bridgeConnection) { - this.bridgeConnection.end(); - } - } - - getTermTypeNegotiationBuffer() { - // - // Create a TERMINAL-TYPE sub negotiation buffer using the - // actual/current terminal type. - // - let bufs = buffers(); - - bufs.push(new Buffer( - [ - 255, // IAC - 250, // SB - 24, // TERMINAL-TYPE - 0, // IS - ] - )); - - bufs.push( - new Buffer(this.client.term.termType), // e.g. "ansi" - new Buffer( [ 255, 240 ] ) // IAC, SE - ); - - return bufs.toBuffer(); - } - -} - -exports.getModule = class TelnetBridgeModule extends MenuModule { - constructor(options) { - super(options); - - this.config = options.menuConfig.config; - // defaults - this.config.port = this.config.port || 23; - } - - initSequence() { - let clientTerminated; - const self = this; - - async.series( - [ - function validateConfig(callback) { - if(_.isString(self.config.host) && - _.isNumber(self.config.port)) - { - callback(null); - } else { - callback(new Error('Configuration is missing required option(s)')); - } - }, - function createTelnetBridge(callback) { - const connectOpts = { - port : self.config.port, - host : self.config.host, - }; - - let clientTerminated; - - self.client.term.write(resetScreen()); - self.client.term.write(` Connecting to ${connectOpts.host}, please wait...\n`); - - const telnetConnection = new TelnetClientConnection(self.client); - - telnetConnection.on('connected', () => { - self.client.log.info(connectOpts, 'Telnet bridge connection established'); - - if(self.config.font) { - self.client.term.rawWrite(setSyncTermFontWithAlias(self.config.font)); - } - - self.client.once('end', () => { - self.client.log.info('Connection ended. Terminating connection'); - clientTerminated = true; - telnetConnection.disconnect(); - }); - }); - - telnetConnection.on('end', err => { - if(err) { - self.client.log.info(`Telnet bridge connection error: ${err.message}`); - } - - callback(clientTerminated ? new Error('Client connection terminated') : null); - }); - - telnetConnection.connect(connectOpts); - } - ], - err => { - if(err) { - self.client.log.warn( { error : err.message }, 'Telnet connection error'); - } - - if(!clientTerminated) { - self.prevMenu(); - } - } - ); - } -}; diff --git a/mods/themes/luciano_blocktronics/CHANGE.ANS b/mods/themes/luciano_blocktronics/CHANGE.ANS deleted file mode 100644 index 885b3cc9..00000000 Binary files a/mods/themes/luciano_blocktronics/CHANGE.ANS and /dev/null differ diff --git a/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS b/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS deleted file mode 100644 index 99219c10..00000000 Binary files a/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS and /dev/null differ diff --git a/mods/themes/luciano_blocktronics/MATRIX.ANS b/mods/themes/luciano_blocktronics/MATRIX.ANS deleted file mode 100644 index 4e183723..00000000 Binary files a/mods/themes/luciano_blocktronics/MATRIX.ANS and /dev/null differ diff --git a/mods/themes/luciano_blocktronics/MSGVHLP.ANS b/mods/themes/luciano_blocktronics/MSGVHLP.ANS deleted file mode 100644 index 0320614d..00000000 Binary files a/mods/themes/luciano_blocktronics/MSGVHLP.ANS and /dev/null differ diff --git a/mods/themes/luciano_blocktronics/USERLST.ANS b/mods/themes/luciano_blocktronics/USERLST.ANS deleted file mode 100644 index fa4e3499..00000000 Binary files a/mods/themes/luciano_blocktronics/USERLST.ANS and /dev/null differ diff --git a/mods/upload.js b/mods/upload.js deleted file mode 100644 index 5c5fd5b2..00000000 --- a/mods/upload.js +++ /dev/null @@ -1,711 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const stringFormat = require('../core/string_format.js'); -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const getAreaDefaultStorageDirectory = require('../core/file_base_area.js').getAreaDefaultStorageDirectory; -const scanFile = require('../core/file_base_area.js').scanFile; -const getFileAreaByTag = require('../core/file_base_area.js').getFileAreaByTag; -const getDescFromFileName = require('../core/file_base_area.js').getDescFromFileName; -const ansiGoto = require('../core/ansi_term.js').goto; -const moveFileWithCollisionHandling = require('../core/file_util.js').moveFileWithCollisionHandling; -const pathWithTerminatingSeparator = require('../core/file_util.js').pathWithTerminatingSeparator; -const Log = require('../core/logger.js').log; -const Errors = require('../core/enig_error.js').Errors; -const FileEntry = require('../core/file_entry.js'); - -// deps -const async = require('async'); -const _ = require('lodash'); -const temptmp = require('temptmp').createTrackedSession('upload'); -const paths = require('path'); -const sanatizeFilename = require('sanitize-filename'); - -exports.moduleInfo = { - name : 'Upload', - desc : 'Module for classic file uploads', - author : 'NuSkooler', -}; - -const FormIds = { - options : 0, - processing : 1, - fileDetails : 2, - dupes : 3, -}; - -const MciViewIds = { - options : { - area : 1, // area selection - uploadType : 2, // blind vs specify filename - fileName : 3, // for non-blind; not editable for blind - navMenu : 4, // next/cancel/etc. - errMsg : 5, // errors (e.g. filename cannot be blank) - }, - - processing : { - calcHashIndicator : 1, - archiveListIndicator : 2, - descFileIndicator : 3, - logStep : 4, - customRangeStart : 10, // 10+ = customs - }, - - fileDetails : { - desc : 1, // defaults to 'desc' (e.g. from FILE_ID.DIZ) - tags : 2, // tag(s) for item - estYear : 3, - accept : 4, // accept fields & continue - customRangeStart : 10, // 10+ = customs - }, - - dupes : { - dupeList : 1, - } -}; - -exports.getModule = class UploadModule extends MenuModule { - - constructor(options) { - super(options); - - if(_.has(options, 'lastMenuResult.recvFilePaths')) { - this.recvFilePaths = options.lastMenuResult.recvFilePaths; - } - - this.availAreas = getSortedAvailableFileAreas(this.client, { writeAcs : true } ); - - this.menuMethods = { - optionsNavContinue : (formData, extraArgs, cb) => { - return this.performUpload(cb); - }, - - fileDetailsContinue : (formData, extraArgs, cb) => { - // see displayFileDetailsPageForUploadEntry() for this hackery: - cb(null); - return this.fileDetailsCurrentEntrySubmitCallback(null, formData.value); // move on to the next entry, if any - }, - - // validation - validateNonBlindFileName : (fileName, cb) => { - fileName = sanatizeFilename(fileName); // remove unsafe chars, path info, etc. - if(0 === fileName.length) { - return cb(new Error('Invalid filename')); - } - - if(0 === fileName.length) { - return cb(new Error('Filename cannot be empty')); - } - - // At least SEXYZ doesn't like non-blind names that start with a number - it becomes confused - if(/^[0-9].*$/.test(fileName)) { - return cb(new Error('Invalid filename')); - } - - return cb(null); - }, - viewValidationListener : (err, cb) => { - const errView = this.viewControllers.options.getView(MciViewIds.options.errMsg); - if(errView) { - if(err) { - errView.setText(err.message); - } else { - errView.clearText(); - } - } - - return cb(null); - } - }; - } - - getSaveState() { - // if no areas, we're falling back due to lack of access/areas avail to upload to - if(this.availAreas.length > 0) { - return { - uploadType : this.uploadType, - tempRecvDirectory : this.tempRecvDirectory, - areaInfo : this.availAreas[ this.viewControllers.options.getView(MciViewIds.options.area).getData() ], - }; - } - } - - restoreSavedState(savedState) { - if(savedState.areaInfo) { - this.uploadType = savedState.uploadType; - this.areaInfo = savedState.areaInfo; - this.tempRecvDirectory = savedState.tempRecvDirectory; - } - } - - isBlindUpload() { return 'blind' === this.uploadType; } - isFileTransferComplete() { return !_.isUndefined(this.recvFilePaths); } - - initSequence() { - const self = this; - - if(0 === this.availAreas.length) { - // - return this.gotoMenu(this.menuConfig.config.noUploadAreasAvailMenu || 'fileBaseNoUploadAreasAvail'); - } - - async.series( - [ - function before(callback) { - return self.beforeArt(callback); - }, - function display(callback) { - if(self.isFileTransferComplete()) { - return self.displayProcessingPage(callback); - } else { - return self.displayOptionsPage(callback); - } - } - ], - () => { - return self.finishedLoading(); - } - ); - } - - finishedLoading() { - if(this.isFileTransferComplete()) { - return this.processUploadedFiles(); - } - } - - performUpload(cb) { - temptmp.mkdir( { prefix : 'enigul-' }, (err, tempRecvDirectory) => { - if(err) { - return cb(err); - } - - // need a terminator for various external protocols - this.tempRecvDirectory = pathWithTerminatingSeparator(tempRecvDirectory); - - const modOpts = { - extraArgs : { - recvDirectory : this.tempRecvDirectory, // we'll move files from here to their area container once processed/confirmed - direction : 'recv', - } - }; - - if(!this.isBlindUpload()) { - // data has been sanatized at this point - modOpts.extraArgs.recvFileName = this.viewControllers.options.getView(MciViewIds.options.fileName).getData(); - } - - // - // Move along to protocol selection -> file transfer - // Upon completion, we'll re-enter the module with some file paths handed to us - // - return this.gotoMenu( - this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', - modOpts, - cb - ); - }); - } - - continueNonBlindUpload(cb) { - return cb(null); - } - - updateScanStepInfoViews(stepInfo) { - // :TODO: add some blinking (e.g. toggle items) indicators - see OBV.DOC - - const fmtObj = Object.assign( {}, stepInfo); - let stepIndicatorFmt = ''; - let logStepFmt; - - const fmtConfig = this.menuConfig.config; - - const indicatorStates = fmtConfig.indicatorStates || [ '|', '/', '-', '\\' ]; - const indicatorFinished = fmtConfig.indicatorFinished || '√'; - - const indicator = { }; - const self = this; - - function updateIndicator(mci, isFinished) { - indicator.mci = mci; - - if(isFinished) { - indicator.text = indicatorFinished; - } else { - self.scanStatus.indicatorPos += 1; - if(self.scanStatus.indicatorPos >= indicatorStates.length) { - self.scanStatus.indicatorPos = 0; - } - indicator.text = indicatorStates[self.scanStatus.indicatorPos]; - } - } - - switch(stepInfo.step) { - case 'start' : - logStepFmt = stepIndicatorFmt = fmtConfig.scanningStartFormat || 'Scanning {fileName}'; - break; - - case 'hash_update' : - stepIndicatorFmt = fmtConfig.calcHashFormat || 'Calculating hash/checksums: {calcHashPercent}%'; - updateIndicator(MciViewIds.processing.calcHashIndicator); - break; - - case 'hash_finish' : - stepIndicatorFmt = fmtConfig.calcHashCompleteFormat || 'Finished calculating hash/checksums'; - updateIndicator(MciViewIds.processing.calcHashIndicator, true); - break; - - case 'archive_list_start' : - stepIndicatorFmt = fmtConfig.extractArchiveListFormat || 'Extracting archive list'; - updateIndicator(MciViewIds.processing.archiveListIndicator); - break; - - case 'archive_list_finish' : - fmtObj.archivedFileCount = stepInfo.archiveEntries.length; - stepIndicatorFmt = fmtConfig.extractArchiveListFinishFormat || 'Archive list extracted ({archivedFileCount} files)'; - updateIndicator(MciViewIds.processing.archiveListIndicator, true); - break; - - case 'archive_list_failed' : - stepIndicatorFmt = fmtConfig.extractArchiveListFailedFormat || 'Archive list extraction failed'; - break; - - case 'desc_files_start' : - stepIndicatorFmt = fmtConfig.processingDescFilesFormat || 'Processing description files'; - updateIndicator(MciViewIds.processing.descFileIndicator); - break; - - case 'desc_files_finish' : - stepIndicatorFmt = fmtConfig.processingDescFilesFinishFormat || 'Finished processing description files'; - updateIndicator(MciViewIds.processing.descFileIndicator, true); - break; - - case 'finished' : - logStepFmt = stepIndicatorFmt = fmtConfig.scanningStartFormat || 'Finished'; - break; - } - - fmtObj.stepIndicatorText = stringFormat(stepIndicatorFmt, fmtObj); - - if(this.hasProcessingArt) { - this.updateCustomViewTextsWithFilter('processing', MciViewIds.processing.customRangeStart, fmtObj, { appendMultiLine : true } ); - - if(indicator.mci && indicator.text) { - this.setViewText('processing', indicator.mci, indicator.text); - } - - if(logStepFmt) { - this.setViewText('processing', MciViewIds.processing.logStep, stringFormat(logStepFmt, fmtObj), { appendMultiLine : true } ); - } - } else { - this.client.term.pipeWrite(fmtObj.stepIndicatorText); - } - } - - scanFiles(cb) { - const self = this; - - const results = { - newEntries : [], - dupes : [], - }; - - self.client.log.debug('Scanning upload(s)', { paths : this.recvFilePaths } ); - - let currentFileNum = 0; - - async.eachSeries(this.recvFilePaths, (filePath, nextFilePath) => { - // :TODO: virus scanning/etc. should occur around here - - currentFileNum += 1; - - self.scanStatus = { - indicatorPos : 0, - }; - - const scanOpts = { - areaTag : self.areaInfo.areaTag, - storageTag : self.areaInfo.storageTags[0], - }; - - function handleScanStep(stepInfo, nextScanStep) { - stepInfo.totalFileNum = self.recvFilePaths.length; - stepInfo.currentFileNum = currentFileNum; - - self.updateScanStepInfoViews(stepInfo); - return nextScanStep(null); - } - - self.client.log.debug('Scanning file', { filePath : filePath } ); - - scanFile(filePath, scanOpts, handleScanStep, (err, fileEntry, dupeEntries) => { - if(err) { - return nextFilePath(err); - } - - // new or dupe? - if(dupeEntries.length > 0) { - // 1:n dupes found - self.client.log.debug('Duplicate file(s) found', { dupeEntries : dupeEntries } ); - - results.dupes = results.dupes.concat(dupeEntries); - } else { - // new one - results.newEntries.push(fileEntry); - } - - return nextFilePath(null); - }); - }, err => { - return cb(err, results); - }); - } - - cleanupTempFiles() { - temptmp.cleanup( paths => { - Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' ); - }); - } - - moveAndPersistUploadsToDatabase(newEntries) { - - const areaStorageDir = getAreaDefaultStorageDirectory(this.areaInfo); - const self = this; - - async.eachSeries(newEntries, (newEntry, nextEntry) => { - const src = paths.join(self.tempRecvDirectory, newEntry.fileName); - const dst = paths.join(areaStorageDir, newEntry.fileName); - - moveFileWithCollisionHandling(src, dst, (err, finalPath) => { - if(err) { - self.client.log.error( - 'Failed moving physical upload file', { error : err.message, fileName : newEntry.fileName, source : src, dest : dst } - ); - - if(dst !== finalPath) { - // name changed; ajust before persist - newEntry.fileName = paths.basename(finalPath); - } - - return nextEntry(null); // still try next file - } - - self.client.log.debug('Moved upload to area', { path : finalPath } ); - - // persist to DB - newEntry.persist(err => { - if(err) { - self.client.log.error('Failed persisting upload to database', { path : finalPath, error : err.message } ); - } - - return nextEntry(null); // still try next file - }); - }); - }, () => { - // - // Finally, we can remove any temp files that we may have created - // - self.cleanupTempFiles(); - }); - } - - prepDetailsForUpload(scanResults, cb) { - async.eachSeries(scanResults.newEntries, (newEntry, nextEntry) => { - newEntry.meta.upload_by_username = this.client.user.username; - newEntry.meta.upload_by_user_id = this.client.user.userId; - - this.displayFileDetailsPageForUploadEntry(newEntry, (err, newValues) => { - if(err) { - return nextEntry(err); - } - - // if the file entry did *not* have a desc, take the user desc - if(!this.fileEntryHasDetectedDesc(newEntry)) { - newEntry.desc = newValues.shortDesc.trim(); - } - - if(newValues.estYear.length > 0) { - newEntry.meta.est_release_year = newValues.estYear; - } - - if(newValues.tags.length > 0) { - newEntry.setHashTags(newValues.tags); - } - - return nextEntry(err); - }); - }, err => { - delete this.fileDetailsCurrentEntrySubmitCallback; - return cb(err, scanResults); - }); - } - - displayDupesPage(dupes, cb) { - // - // If we have custom art to show, use it - else just dump basic info. - // Pause at the end in either case. - // - const self = this; - - async.waterfall( - [ - function prepArtAndViewController(callback) { - self.prepViewControllerWithArt( - 'dupes', - FormIds.dupes, - { clearScreen : true, trailingLF : false }, - err => { - if(err) { - self.client.term.pipeWrite('|00|07Duplicate upload(s) found:\n'); - return callback(null, null); - } - - const dupeListView = self.viewControllers.dupes.getView(MciViewIds.dupes.dupeList); - return callback(null, dupeListView); - } - ); - }, - function prepDupeObjects(dupeListView, callback) { - // update dupe objects with additional info that can be used for formatString() and the like - async.each(dupes, (dupe, nextDupe) => { - FileEntry.loadBasicEntry(dupe.fileId, dupe, err => { - if(err) { - return nextDupe(err); - } - - const areaInfo = getFileAreaByTag(dupe.areaTag); - if(areaInfo) { - dupe.areaName = areaInfo.name; - dupe.areaDesc = areaInfo.desc; - } - return nextDupe(null); - }); - }, err => { - return callback(err, dupeListView); - }); - }, - function populateDupeInfo(dupeListView, callback) { - const dupeInfoFormat = self.menuConfig.config.dupeInfoFormat || '{fileName} @ {areaName}'; - - if(dupeListView) { - dupeListView.setItems(dupes.map(dupe => stringFormat(dupeInfoFormat, dupe) ) ); - dupeListView.redraw(); - } else { - dupes.forEach(dupe => { - self.client.term.pipeWrite(`${stringFormat(dupeInfoFormat, dupe)}\n`); - }); - } - - return callback(null); - }, - function pause(callback) { - return self.pausePrompt( { row : self.client.term.termHeight }, callback); - } - ], - err => { - return cb(err); - } - ); - } - - processUploadedFiles() { - // - // For each file uploaded, we need to process & gather information - // - const self = this; - - async.waterfall( - [ - function prepNonBlind(callback) { - if(self.isBlindUpload()) { - return callback(null); - } - - // - // For non-blind uploads, batch is not supported, we expect a single file - // in |recvFilePaths|. If not, it's an error (we don't want to process the wrong thing) - // - if(self.recvFilePaths.length > 1) { - self.client.log.warn( { recvFilePaths : self.recvFilePaths }, 'Non-blind upload received 2:n files' ); - return callback(Errors.UnexpectedState(`Non-blind upload expected single file but got received ${self.recvFilePaths.length}`)); - } - - return callback(null); - }, - function scan(callback) { - return self.scanFiles(callback); - }, - function pause(scanResults, callback) { - if(self.hasProcessingArt) { - self.client.term.rawWrite(ansiGoto(self.client.term.termHeight, 1)); - } else { - self.client.term.write('\n'); - } - - self.pausePrompt( () => { - return callback(null, scanResults); - }); - }, - function displayDupes(scanResults, callback) { - if(0 === scanResults.dupes.length) { - return callback(null, scanResults); - } - - return self.displayDupesPage(scanResults.dupes, () => { - return callback(null, scanResults); - }); - }, - function prepDetails(scanResults, callback) { - return self.prepDetailsForUpload(scanResults, callback); - }, - function startMovingAndPersistingToDatabase(scanResults, callback) { - // - // *Start* the process of moving files from their current |tempRecvDirectory| - // locations -> their final area destinations. Don't make the user wait - // here as I/O can take quite a bit of time. Log any failures. - // - self.moveAndPersistUploadsToDatabase(scanResults.newEntries); - return callback(null); - }, - ], - err => { - if(err) { - self.client.log.warn('File upload error encountered', { error : err.message } ); - self.cleanupTempFiles(); // normally called after moveAndPersistUploadsToDatabase() is completed. - } - - return self.prevMenu(); - } - ); - } - - displayOptionsPage(cb) { - const self = this; - - async.series( - [ - function prepArtAndViewController(callback) { - return self.prepViewControllerWithArt( - 'options', - FormIds.options, - { clearScreen : true, trailingLF : false }, - callback - ); - }, - function populateViews(callback) { - const areaSelectView = self.viewControllers.options.getView(MciViewIds.options.area); - areaSelectView.setItems( self.availAreas.map(areaInfo => areaInfo.name ) ); - - const uploadTypeView = self.viewControllers.options.getView(MciViewIds.options.uploadType); - const fileNameView = self.viewControllers.options.getView(MciViewIds.options.fileName); - - const blindFileNameText = self.menuConfig.config.blindFileNameText || '(blind - filename ignored)'; - - uploadTypeView.on('index update', idx => { - self.uploadType = (0 === idx) ? 'blind' : 'non-blind'; - - if(self.isBlindUpload()) { - fileNameView.setText(blindFileNameText); - fileNameView.acceptsFocus = false; - } else { - fileNameView.clearText(); - fileNameView.acceptsFocus = true; - } - }); - - // sanatize filename for display when leaving the view - self.viewControllers.options.on('leave', prevView => { - if(prevView.id === MciViewIds.options.fileName) { - fileNameView.setText(sanatizeFilename(fileNameView.getData())); - } - }); - - self.uploadType = 'blind'; - uploadTypeView.setFocusItemIndex(0); // default to blind - fileNameView.setText(blindFileNameText); - areaSelectView.redraw(); - - return callback(null); - } - ], - err => { - if(cb) { - return cb(err); - } - } - ); - } - - displayProcessingPage(cb) { - return this.prepViewControllerWithArt( - 'processing', - FormIds.processing, - { clearScreen : true, trailingLF : false }, - err => { - // note: this art is not required - this.hasProcessingArt = !err; - - return cb(null); - } - ); - } - - fileEntryHasDetectedDesc(fileEntry) { - return (fileEntry.desc && fileEntry.desc.length > 0); - } - - displayFileDetailsPageForUploadEntry(fileEntry, cb) { - const self = this; - - async.series( - [ - function prepArtAndViewController(callback) { - return self.prepViewControllerWithArt( - 'fileDetails', - FormIds.fileDetails, - { clearScreen : true, trailingLF : false }, - callback - ); - }, - function populateViews(callback) { - const descView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.desc); - const tagsView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.tags); - const yearView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.estYear); - - self.updateCustomViewTextsWithFilter('fileDetails', MciViewIds.fileDetails.customRangeStart, fileEntry ); - - tagsView.setText( Array.from(fileEntry.hashTags).join(',') ); // :TODO: optional 'hashTagsSep' like file list/browse - yearView.setText(fileEntry.meta.est_release_year || ''); - - if(self.fileEntryHasDetectedDesc(fileEntry)) { - descView.setPropertyValue('mode', 'preview'); - descView.setText(fileEntry.desc); - descView.acceptsFocus = false; - self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.tags); - } else { - descView.setPropertyValue('mode', 'edit'); - descView.setText(getDescFromFileName(fileEntry.fileName)); // try to come up with something good as a default - descView.acceptsFocus = true; - self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.desc); - } - - return callback(null); - } - ], - err => { - // - // we only call |cb| here if there is an error - // else, wait for the current from to be submit - then call - - // this way we'll move on to the next file entry when ready - // - if(err) { - return cb(err); - } - - self.fileDetailsCurrentEntrySubmitCallback = cb; // stash for moduleMethods.fileDetailsContinue - } - ); - } -}; diff --git a/mods/user_list.js b/mods/user_list.js deleted file mode 100644 index b2a88e79..00000000 --- a/mods/user_list.js +++ /dev/null @@ -1,112 +0,0 @@ -/* jslint node: true */ -'use strict'; - -const MenuModule = require('../core/menu_module.js').MenuModule; -const User = require('../core/user.js'); -const ViewController = require('../core/view_controller.js').ViewController; -const stringFormat = require('../core/string_format.js'); - -const moment = require('moment'); -const async = require('async'); -const _ = require('lodash'); - -/* - Available listFormat/focusListFormat object members: - - userId : User ID - userName : User name/handle - lastLoginTs : Last login timestamp - status : Status: active | inactive - location : Location - affiliation : Affils - note : User note -*/ - -exports.moduleInfo = { - name : 'User List', - desc : 'Lists all system users', - author : 'NuSkooler', -}; - -const MciViewIds = { - UserList : 1, -}; - -exports.getModule = class UserListModule extends MenuModule { - constructor(options) { - super(options); - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); - - let userList = []; - - const USER_LIST_OPTS = { - properties : [ 'location', 'affiliation', 'last_login_timestamp' ], - }; - - async.series( - [ - function loadFromConfig(callback) { - var loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - }; - - vc.loadFromMenuConfig(loadOpts, callback); - }, - function fetchUserList(callback) { - // :TODO: Currently fetching all users - probably always OK, but this could be paged - User.getUserList(USER_LIST_OPTS, function got(err, ul) { - userList = ul; - callback(err); - }); - }, - function populateList(callback) { - var userListView = vc.getView(MciViewIds.UserList); - - var listFormat = self.menuConfig.config.listFormat || '{userName} - {affils}'; - var focusListFormat = self.menuConfig.config.focusListFormat || listFormat; // :TODO: default changed color! - var dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD'; - - function getUserFmtObj(ue) { - return { - userId : ue.userId, - userName : ue.userName, - affils : ue.affiliation, - location : ue.location, - // :TODO: the rest! - note : ue.note || '', - lastLoginTs : moment(ue.last_login_timestamp).format(dateTimeFormat), - }; - } - - userListView.setItems(_.map(userList, function formatUserEntry(ue) { - return stringFormat(listFormat, getUserFmtObj(ue)); - })); - - userListView.setFocusItems(_.map(userList, function formatUserEntry(ue) { - return stringFormat(focusListFormat, getUserFmtObj(ue)); - })); - - userListView.redraw(); - callback(null); - } - ], - function complete(err) { - if(err) { - self.client.log.error( { error : err.toString() }, 'Error loading user list'); - } - cb(err); - } - ); - }); - } -}; diff --git a/mods/whos_online.js b/mods/whos_online.js deleted file mode 100644 index a0a87829..00000000 --- a/mods/whos_online.js +++ /dev/null @@ -1,84 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getActiveNodeList = require('../core/client_connections.js').getActiveNodeList; -const stringFormat = require('../core/string_format.js'); - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'Who\'s Online', - desc : 'Who is currently online', - author : 'NuSkooler', - packageName : 'codes.l33t.enigma.whosonline' -}; - -const MciViewIds = { - OnlineList : 1, -}; - -exports.getModule = class WhosOnlineModule extends MenuModule { - constructor(options) { - super(options); - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - const self = this; - const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); - - async.series( - [ - function loadFromConfig(callback) { - const loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - noInput : true, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - }, - function populateList(callback) { - const onlineListView = vc.getView(MciViewIds.OnlineList); - const listFormat = self.menuConfig.config.listFormat || '{node} - {userName} - {action} - {timeOn}'; - const nonAuthUser = self.menuConfig.config.nonAuthUser || 'Logging In'; - const otherUnknown = self.menuConfig.config.otherUnknown || 'N/A'; - const onlineList = getActiveNodeList(self.menuConfig.config.authUsersOnly).slice(0, onlineListView.height); - - onlineListView.setItems(_.map(onlineList, oe => { - if(oe.authenticated) { - oe.timeOn = _.upperFirst(oe.timeOn.humanize()); - } else { - [ 'realName', 'location', 'affils', 'timeOn' ].forEach(m => { - oe[m] = otherUnknown; - }); - oe.userName = nonAuthUser; - } - return stringFormat(listFormat, oe); - })); - - onlineListView.focusItems = onlineListView.items; - onlineListView.redraw(); - - return callback(null); - } - ], - function complete(err) { - if(err) { - self.client.log.error( { error : err.message }, 'Error loading who\'s online'); - } - return cb(err); - } - ); - }); - } -}; diff --git a/package.json b/package.json index ec508841..aee75136 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "enigma-bbs", - "version": "0.0.7-alpha", + "version": "0.0.11-beta", "description": "ENiGMA½ Bulletin Board System", "author": "Bryan Ashby ", "license": "BSD-2-Clause", + "scripts": { + "start": "node main.js" + }, "repository": { "type": "git", "url": "https://github.com/NuSkooler/enigma-bbs.git" @@ -19,37 +22,46 @@ "retro" ], "dependencies": { - "async": "^2.4.0", - "binary": "0.3.x", - "buffers": "NuSkooler/node-buffers", - "bunyan": "^1.8.10", - "farmhash": "^1.2.1", - "fs-extra": "^3.0.1", - "gaze": "^1.1.2", - "hashids": "^1.1.1", - "hjson": "^2.4.2", - "iconv-lite": "^0.4.17", - "inquirer": "^3.0.6", + "async": "3.2.0", + "binary-parser": "^1.6.2", + "buffers": "github:NuSkooler/node-buffers", + "bunyan": "^1.8.12", + "exiftool": "^0.0.3", + "fs-extra": "9.0.0", + "glob": "7.1.6", + "graceful-fs": "^4.2.4", + "hashids": "2.2.1", + "hjson": "^3.2.1", + "iconv-lite": "0.5.1", + "ini-config-parser": "^1.0.4", + "inquirer": "^7.1.0", "later": "1.2.0", - "lodash": "^4.17.4", - "mime-types": "^2.1.15", - "minimist": "1.2.x", - "moment": "^2.18.1", - "nodemailer": "^4.0.1", - "ptyw.js": "NuSkooler/ptyw.js", - "sanitize-filename": "^1.6.1", - "sqlite3": "^3.1.1", - "ssh2": "^0.5.5", - "temptmp": "^1.0.0", - "uuid": "^3.0.1", - "uuid-parse": "^1.0.0", - "ws" : "^3.0.0", - "graceful-fs" : "^4.1.11", - "exiftool" : "^0.0.3", - "node-glob" : "^1.2.0" + "lodash": "^4.17.15", + "lru-cache": "^5.1.1", + "mime-types": "2.1.27", + "minimist": "1.2.5", + "moment": "^2.25.3", + "nntp-server": "^1.0.3", + "node-pty": "^0.9.0", + "nodemailer": "^6.4.6", + "otplib": "11.0.1", + "qrcode-generator": "^1.4.4", + "rlogin": "^1.0.0", + "sane": "4.1.0", + "sanitize-filename": "^1.6.3", + "sqlite3": "^4.2.0", + "sqlite3-trans": "^1.2.2", + "ssh2": "0.8.9", + "temptmp": "^1.1.0", + "uuid": "^8.0.0", + "uuid-parse": "1.1.0", + "ws": "^7.3.0", + "xxhash": "^0.3.0", + "yazl": "^2.5.1", + "telnet-socket" : "^0.2.3" }, "devDependencies": {}, "engines": { - "node": ">=6.9.2" + "node": ">=12" } } diff --git a/util/dump_ftn_packet.js b/util/dump_ftn_packet.js new file mode 100755 index 00000000..88eeece8 --- /dev/null +++ b/util/dump_ftn_packet.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const { Packet } = require('../core/ftn_mail_packet.js'); + +const argv = require('minimist')(process.argv.slice(2)); + +function main() { + if(0 === argv._.length) { + console.error('usage: dump_ftn_packet.js PATH'); + process.exitCode = -1; + return; + } + + const packet = new Packet(); + const packetPath = argv._[0]; + + packet.read( + packetPath, + (dataType, data, next) => { + if('header' === dataType) { + console.info('--- header ---'); + console.info(`Created : ${data.created.format('dddd, MMMM Do YYYY, h:mm:ss a')}`); + console.info(`Dst. Addr : ${data.destAddress.toString()}`); + console.info(`Src. Addr : ${data.origAddress.toString()}`); + console.info('--- raw header ---'); + console.info(data); + console.info('--------------'); + console.info(''); + } else if('message' === dataType) { + console.info('--- message ---'); + console.info(`To : ${data.toUserName}`); + console.info(`From : ${data.fromUserName}`); + console.info(`Subject : ${data.subject}`); + console.info('--- raw message ---'); + console.info(data); + console.info('---------------'); + } + + return next(null); + }, + (err) => { + if(err) { + return console.error(`Error processing packet: ${err.message}`); + } + console.info(''); + console.info('--- EOF --- '); + console.info(''); + } + ); +} + +main(); diff --git a/util/exiftool2desc.js b/util/exiftool2desc.js index 210c800d..5299bcb5 100755 --- a/util/exiftool2desc.js +++ b/util/exiftool2desc.js @@ -20,96 +20,96 @@ const FILETYPE_HANDLERS = {}; [ 'MP4', 'MOV', 'AVI', 'MKV', 'MPG', 'MPEG', 'M4V', 'WMV' ].forEach(ext => FILETYPE_HANDLERS[ext] = videoFile); function audioFile(metadata) { - // nothing if we don't know at least the author or title - if(!metadata.author && !metadata.title) { - return; - } - - let desc = `${metadata.artist||'Unknown Artist'} - ${metadata.title||'Unknown'} (`; - if(metadata.year) { - desc += `${metadata.year}, `; - } - desc += `${metadata.audioBitrate})`; - return desc; + // nothing if we don't know at least the author or title + if(!metadata.author && !metadata.title) { + return; + } + + let desc = `${metadata.artist||'Unknown Artist'} - ${metadata.title||'Unknown'} (`; + if(metadata.year) { + desc += `${metadata.year}, `; + } + desc += `${metadata.audioBitrate})`; + return desc; } function videoFile(metadata) { - return `${metadata.fileType} video(${metadata.imageSize}px, ${metadata.duration}, ${metadata.audioBitsPerSample}/${metadata.audioSampleRate} audio)`; + return `${metadata.fileType} video(${metadata.imageSize}px, ${metadata.duration}, ${metadata.audioBitsPerSample}/${metadata.audioSampleRate} audio)`; } function documentFile(metadata) { - // nothing if we don't know at least the author or title - if(!metadata.author && !metadata.title) { - return; - } + // nothing if we don't know at least the author or title + if(!metadata.author && !metadata.title) { + return; + } - let desc = `${metadata.author||'Unknown Author'} - ${metadata.title||'Unknown'}`; - const created = moment(metadata.createdate); - if(created.isValid()) { - desc += ` (${created.format('YYYY')})`; - } - return desc; + let result = metadata.author || ''; + if(result) { + result += ' - '; + } + result += metadata.title || 'Unknown Title'; + return result; } function imageFile(metadata) { - let desc = `${metadata.fileType} image (`; - if(metadata.animationIterations) { - desc += 'Animated, '; - } - desc += `${metadata.imageSize}px`; - const created = moment(metadata.createdate); - if(created.isValid()) { - desc += `, ${created.format('YYYY')})`; - } else { - desc += ')'; - } - return desc; + let desc = `${metadata.fileType} image (`; + if(metadata.animationIterations) { + desc += 'Animated, '; + } + desc += `${metadata.imageSize}px`; + const created = moment(metadata.createdate); + if(created.isValid()) { + desc += `, ${created.format('YYYY')})`; + } else { + desc += ')'; + } + return desc; } function main() { - const argv = exports.argv = require('minimist')(process.argv.slice(2), { - alias : { - h : 'help', - v : 'version', - } - }); + const argv = exports.argv = require('minimist')(process.argv.slice(2), { + alias : { + h : 'help', + v : 'version', + } + }); - if(argv.version) { - console.info(TOOL_VERSION); - return 0; - } + if(argv.version) { + console.info(TOOL_VERSION); + return 0; + } - if(0 === argv._.length || argv.help) { - console.info('usage: exiftool2desc.js [--version] [--help] PATH'); - return 0; - } + if(0 === argv._.length || argv.help) { + console.info('usage: exiftool2desc.js [--version] [--help] PATH'); + return 0; + } - const path = argv._[0]; + const path = argv._[0]; - fs.readFile(path, (err, data) => { - if(err) { - return -1; - } + fs.readFile(path, (err, data) => { + if(err) { + return -1; + } - exiftool.metadata(data, (err, metadata) => { - if(err) { - return -1; - } + exiftool.metadata(data, (err, metadata) => { + if(err) { + return -1; + } - const handler = FILETYPE_HANDLERS[metadata.fileType]; - if(!handler) { - return -1; - } - - const info = handler(metadata); - if(!info) { - return -1; - } + const handler = FILETYPE_HANDLERS[metadata.fileType]; + if(!handler) { + return -1; + } - console.info(info); - return 0; - }); - }); + const info = handler(metadata); + if(!info) { + return -1; + } + + console.info(info); + return 0; + }); + }); } return main(); \ No newline at end of file diff --git a/util/to_ansi.js b/util/to_ansi.js new file mode 100755 index 00000000..72838493 --- /dev/null +++ b/util/to_ansi.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const { controlCodesToAnsi } = require('../core/color_codes.js'); + +const fs = require('graceful-fs'); +const iconv = require('iconv-lite'); + +const ToolVersion = '1.0.0'; + +function main() { + const argv = exports.argv = require('minimist')(process.argv.slice(2), { + alias : { + h : 'help', + v : 'version', + } + }); + + if(argv.version) { + console.info(ToolVersion); + return 0; + } + + if(0 === argv._.length || argv.help) { + console.info('usage: to_ansi.js [--version] [--help] PATH'); + return 0; + } + + const path = argv._[0]; + + fs.readFile(path, (err, data) => { + if(err) { + console.error(err.message); + return -1; + } + + data = iconv.decode(data, 'cp437'); + console.info(controlCodesToAnsi(data)); + return 0; + }); +} + +main(); diff --git a/www/otp_register.template.html b/www/otp_register.template.html new file mode 100644 index 00000000..20a63ed2 --- /dev/null +++ b/www/otp_register.template.html @@ -0,0 +1,29 @@ + + + + + Enable 2FA/OTP — ENiGMA½ BBS + + + + +

+ Your OTP secret:
+ %SECRET% +

+ +
+ Confirm One-Time-Password to continue: + + + + + +
+ + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..0954c769 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,2041 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# 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" + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +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" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +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-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +ansi-styles@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-union@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= + dependencies: + array-uniq "^1.0.1" + +array-uniq@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +asn1@~0.2.0: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +async@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +atob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +binary-parser@1.6.2, binary-parser@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/binary-parser/-/binary-parser-1.6.2.tgz#8410a82ffd9403271ec182bd91e63a09cee88cbe" + integrity sha512-cYAhKB51A9T/uylDvMK7uAYaPLWLwlferNOpnQ0E0fuO73yPi7kWaWiOm22BvuKxCbggmkiFN0VkuLg6gc+KQQ== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + integrity sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk= + dependencies: + node-int64 "^0.4.0" + +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= + +"buffers@github:NuSkooler/node-buffers": + version "0.1.1" + resolved "https://codeload.github.com/NuSkooler/node-buffers/tar.gz/cd0855598f7048b02f0a51c90e22573973e9e2c2" + +bunyan@^1.8.12: + version "1.8.12" + resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.12.tgz#f150f0f6748abdd72aeae84f04403be2ef113797" + integrity sha1-8VDw9nSKvdcq6uhPBEA74u8RN5c= + optionalDependencies: + dtrace-provider "~0.8" + moment "^2.10.6" + mv "~2" + safe-json-stringify "~1" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +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 "^4.8.4" + +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + +chownr@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" + integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g== + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +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 "^3.1.0" + +cli-width@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" + integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +coffee-script@^1.12.4: + version "1.12.7" + resolved "https://registry.yarnpkg.com/coffee-script/-/coffee-script-1.12.7.tgz#c05dae0cb79591d05b3070a8433a98c9a89ccc53" + integrity sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw== + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +component-emitter@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +debug@^2.1.2, debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" + integrity sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg== + dependencies: + ms "^2.1.1" + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +deep-extend@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f" + integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w== + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +del@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" + integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU= + dependencies: + globby "^6.1.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + p-map "^1.1.1" + pify "^3.0.0" + rimraf "^2.2.8" + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + +denque@^1.1.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.0.tgz#79e2f0490195502107f24d9553f374837dabc916" + integrity sha512-gh513ac7aiKrAgjiIBWZG0EASyDF9p4JMWwKA8YU5s9figrL5SRNEMT6FDynsegakuhWd1wVqTvqvqAoDxw7wQ== + +destroy@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + +detect-libc@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +dtrace-provider@~0.8: + version "0.8.7" + resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.7.tgz#dc939b4d3e0620cfe0c1cd803d0d2d7ed04ffd04" + integrity sha1-3JObTT4GIM/gwc2APQ0tftBP/QQ= + dependencies: + nan "^2.10.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" + integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== + dependencies: + once "^1.4.0" + +escape-string-regexp@^1.0.5: + version "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.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.2.tgz#6738de2eb7c8e671d0366aea0b0db8c6f7d7391b" + integrity sha512-9sLAvzhI5nc8TpuQUh4ahMdCrWT00wPWz7j47/emR5+2qEfoZP5zzUXvx+vdx+H6ohhnsYC31iX04QLYJK8zTg== + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +exiftool@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/exiftool/-/exiftool-0.0.3.tgz#f58a92bd77270adc54f3151ced61a4a3ab69d707" + integrity sha1-9YqSvXcnCtxU8xUc7WGko6tp1wc= + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +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== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + integrity sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg= + dependencies: + bser "^2.0.0" + +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" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +from2@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + +fs-extra@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.0.tgz#b6afc31036e247b2466dc99c29ae797d5d4580a3" + integrity sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^1.0.0" + +fs-minipass@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d" + integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ== + dependencies: + minipass "^2.2.1" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +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" + integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI= + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.3, glob@^7.0.5, glob@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== + 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" + +globby@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" + integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw= + dependencies: + array-union "^1.0.1" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +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.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +hashids@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/hashids/-/hashids-2.2.1.tgz#ad0c600f0083aa0df7451dfd184e53db34f71289" + integrity sha512-+hQeKWwpSDiWFeu/3jKUvwboE4Z035gR6FnpscbHPOEEjCbgv2px9/Mlb3O0nOTRyZOw4MMFRYfVL3zctOV6OQ== + +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== + +iconv-lite@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.1.tgz#b2425d3c7b18f7219f2ca663d103bddb91718d64" + integrity sha512-ONHr16SQvKZNSqjQT9gy5z24Jw+uqfO02/ngBSBoqChZ+W8qXX7GPRa1RoUnzGADw8K63R1BXUMzarCVQBpY8Q== + 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== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore-walk@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" + integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== + dependencies: + minimatch "^3.0.4" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +ini-config-parser@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/ini-config-parser/-/ini-config-parser-1.0.4.tgz#0abc75cb68c506204712d2b4861400b6adbfda78" + integrity sha512-5hLh5Cqai67pTrLQ9q/K/3EtSP2Tzu41AZzwPLSegkkMkc42dGweLgkbiocCBiBBEg2fPhs6pKmdFhwj5Ul3Bg== + dependencies: + coffee-script "^1.12.4" + deep-extend "^0.5.1" + rimraf "^2.6.1" + +ini@~1.3.0: + version "1.3.5" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" + integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + +inquirer@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" + integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== + dependencies: + ansi-escapes "^4.2.1" + chalk "^3.0.0" + cli-cursor "^3.1.0" + cli-width "^2.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.15" + mute-stream "0.0.8" + run-async "^2.4.0" + rxjs "^6.5.3" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "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" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-path-cwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" + integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0= + +is-path-in-cwd@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52" + integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ== + dependencies: + is-path-inside "^1.0.0" + +is-path-inside@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" + integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= + dependencies: + path-is-inside "^1.0.1" + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +isarray@1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +jsonfile@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179" + integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg== + dependencies: + universalify "^1.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== + +later@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/later/-/later-1.2.0.tgz#f2cf6c4dd7956dd2f520adf0329836e9876bad0f" + integrity sha1-8s9sTdeVbdL1IK3wMpg26YdrrQ8= + +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== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + dependencies: + tmpl "1.0.x" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +mime-db@1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== + +mime-types@2.1.27: + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + dependencies: + mime-db "1.44.0" + +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" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= + +minimist@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +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= + +minipass@^2.2.1, minipass@^2.3.3: + version "2.3.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.4.tgz#4768d7605ed6194d6d576169b9e12ef71e9d9957" + integrity sha512-mlouk1OHlaUE8Odt1drMtG1bAJA4ZA6B/ehysgV0LUIrDHdKgo1KorZq3pK0b/7Z7LJIQ12MNM6aC+Tn6lUZ5w== + dependencies: + safe-buffer "^5.1.2" + yallist "^3.0.0" + +minizlib@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb" + integrity sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA== + dependencies: + minipass "^2.2.1" + +mixin-deep@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= + dependencies: + minimist "0.0.8" + +moment@^2.10.6: + version "2.22.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" + integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y= + +moment@^2.25.3: + version "2.25.3" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.25.3.tgz#252ff41319cf41e47761a1a88cab30edfe9808c0" + integrity sha512-PuYv0PHxZvzc15Sp8ybUCoQ+xpyPWvjOuK72a5ovzp2LI32rJXOiIfyoFoYvG3s6EwwrdkMyWuRiEHSZRLJNdg== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@^2.1.1: + version "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.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" + resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2" + integrity sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI= + dependencies: + mkdirp "~0.5.1" + ncp "~2.0.0" + rimraf "~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.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" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +ncp@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= + +needle@^2.2.1: + version "2.2.4" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" + integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== + dependencies: + debug "^2.1.2" + iconv-lite "^0.4.4" + sax "^1.2.4" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +nntp-server@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/nntp-server/-/nntp-server-1.0.3.tgz#c556dc0d3481d52d49b1389c0e5d0889d3865088" + integrity sha512-I30wXcciO937DeADhuTkAtqL+FXHcmwKwUbqMhpr69RKlQl5PRfZMm/WNtOoLBta+b55ED/VqBvMmlasE3z4BA== + dependencies: + debug "^4.0.0" + denque "^1.1.1" + destroy "^1.0.4" + end-of-stream "^1.4.0" + from2 "^2.3.0" + glob "^7.1.2" + pump "^3.0.0" + serialize-error "^2.1.0" + split2 "^3.0.0" + through2 "^2.0.3" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + +node-pre-gyp@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" + integrity sha512-TwWAOZb0j7e9eGaf9esRx3ZcLaE5tQ2lvYy1pb5IAaG1a2e2Kv5Lms1Y4hpj+ciXJRofIxxlt5haeQ/2ANeE0Q== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.1" + needle "^2.2.1" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4" + +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.14.0" + +nodemailer@^6.4.6: + version "6.4.6" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.6.tgz#d37f504f6560b36616f646a606894fe18819107f" + integrity sha512-/kJ+FYVEm2HuUlw87hjSqTss+GU35D4giOpdSfGp7DO+5h6RlJj7R94YaYHOkoxu1CSaM0d3WRBtCzwXrY6MKA== + +nopt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" + integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= + dependencies: + abbrev "1" + osenv "^0.1.4" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +npm-bundled@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.5.tgz#3c1732b7ba936b3a10325aef616467c0ccbcc979" + integrity sha512-m/e6jgWu8/v5niCUKQi9qQl8QdeEduFA96xHDDzFGqly0OOjI7c+60KM/2sppfnUU9JJagf+zs+yGhqSOFj71g== + +npm-packlist@^1.1.6: + version "1.1.11" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.11.tgz#84e8c683cbe7867d34b1d357d893ce29e28a02de" + integrity sha512-CxKlZ24urLkJk+9kCm48RTQ7L4hsmgSVzEk0TLGPzzyuFxD7VNgy5Sl24tOLMzQv773a/NeJ1ce1DKeacqffEA== + dependencies: + ignore-walk "^3.0.1" + npm-bundled "^1.0.1" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +npmlog@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +object-assign@^4.0.1, object-assign@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +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 "^2.1.0" + +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + +os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +osenv@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" + integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== + dependencies: + 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" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-map@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" + integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-is-inside@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +pify@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +process-nextick-args@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" + integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +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== + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@~2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.0.0: + version "3.0.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.0.6.tgz#351302e4c68b5abd6a2ed55376a7f9a25be3057a" + integrity sha512-9E1oLoOWfhSXHGv6QlwXJim7uNzd9EVlWK+21tCU9Ju/kR0/p2AZYPz4qSchgO8PlLIH4FpZYfzwS+rEksZjIg== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== + +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +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 "^5.1.0" + signal-exit "^3.0.2" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +rimraf@^2.2.8, rimraf@^2.6.1: + version "2.6.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" + integrity sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w== + dependencies: + glob "^7.0.5" + +rimraf@~2.4.0: + version "2.4.5" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" + integrity sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto= + dependencies: + glob "^6.0.1" + +rlogin@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rlogin/-/rlogin-1.0.0.tgz#db07322b31219126625d9d0aa9872d7ebe8ac403" + integrity sha1-2wcyKzEhkSZiXZ0KqYctfr6KxAM= + +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.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + +rxjs@^6.5.3: + version "6.5.5" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec" + integrity sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ== + dependencies: + tslib "^1.9.0" + +safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-json-stringify@~1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd" + integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +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 "^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" + +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" + +sax@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +semver@^5.3.0: + version "5.5.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" + integrity sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw== + +semver@^5.5.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" + integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== + +serialize-error@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-2.1.0.tgz#50b679d5635cdf84667bdc8e59af4e5b81d5f60a" + integrity sha1-ULZ51WNc34Rme9yOWa9OW4HV9go= + +set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +set-value@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" + integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE= + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.1" + to-object-path "^0.3.0" + +set-value@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" + integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +source-map-resolve@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" + integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== + dependencies: + atob "^2.1.1" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= + +source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +split2@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-3.0.0.tgz#55057cd560687a7ef6464471597404577ff1735d" + integrity sha512-Cp7G+nUfKJyHCrAI8kze3Q00PFGEG1pMgrAlTFlDbn+GW24evSZHJuMl+iUJx1w/NTRDeBiTgvwnf6YOt94FMw== + dependencies: + readable-stream "^3.0.0" + +sqlite3-trans@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/sqlite3-trans/-/sqlite3-trans-1.2.2.tgz#faf268cc8d04dfd1a4854d64a70a229bdb50609f" + integrity sha512-+c2je0JMgPeNYHM7vMwEv/nHqOMYa5NNgQDcUyFkVMJ5QHATOQ+GywJptlVbkRCjgSTctmighfWLwUHPlkXbSQ== + dependencies: + lodash "^4.17.15" + +sqlite3@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-4.2.0.tgz#49026d665e9fc4f922e56fb9711ba5b4c85c4901" + integrity sha512-roEOz41hxui2Q7uYnWsjMOTry6TcNUNmp8audCx18gF10P2NknwdpF+E+HKvz/F2NvPKGGBF4NGc+ZPQ+AABwg== + dependencies: + nan "^2.12.1" + node-pre-gyp "^0.11.0" + +ssh2-streams@~0.4.10: + version "0.4.10" + resolved "https://registry.yarnpkg.com/ssh2-streams/-/ssh2-streams-0.4.10.tgz#48ef7e8a0e39d8f2921c30521d56dacb31d23a34" + integrity sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ== + dependencies: + asn1 "~0.2.0" + bcrypt-pbkdf "^1.0.2" + streamsearch "~0.1.2" + +ssh2@0.8.9: + version "0.8.9" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-0.8.9.tgz#54da3a6c4ba3daf0d8477a538a481326091815f3" + integrity sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw== + dependencies: + ssh2-streams "~0.4.10" + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +streamsearch@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.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== + dependencies: + 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" + integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w== + dependencies: + safe-buffer "~5.1.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.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.1.0" + +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + +tar@^4: + version "4.4.6" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.6.tgz#63110f09c00b4e60ac8bcfe1bf3c8660235fbc9b" + integrity sha512-tMkTnh9EdzxyfW+6GK6fCahagXsnYk6kE6S9Gr9pjVdys769+laCTbodXDhPAjzVtEBazRgP0gYqOjnk9dQzLg== + dependencies: + chownr "^1.0.1" + fs-minipass "^1.2.5" + minipass "^2.3.3" + minizlib "^1.1.0" + mkdirp "^0.5.0" + safe-buffer "^5.1.2" + yallist "^3.0.2" + +telnet-socket@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/telnet-socket/-/telnet-socket-0.2.3.tgz#0ffdc64ea957cb64f8ac5287d45a857f1c05a16e" + integrity sha512-PbZycTkGq6VcVUa35FYFySx4pCzmJo4xoMX6cimls1/kv/lrgMfddKfgjBKt6HQuokkkDfieDhGLq/L/P2Unaw== + dependencies: + binary-parser "1.6.2" + buffers "github:NuSkooler/node-buffers" + +temptmp@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/temptmp/-/temptmp-1.1.0.tgz#bfbbff858d7f7d59c563fbf069758a7775ecd431" + integrity sha512-gHelQlePUzxRmodWL1uJ9LiwI+a7a3rkFGS9azTf4noPZgGOlx0dOPV9tZs5+QwGc4Nm8BfFxL9cfvV42GNxPQ== + 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" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +through@^2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +truncate-utf8-bytes@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b" + integrity sha1-QFkjkJWS1W94pYGENLC3hInKXys= + dependencies: + utf8-byte-length "^1.0.1" + +tslib@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" + integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== + +tweetnacl@^0.14.3: + version "0.14.5" + 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" + integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ= + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^0.4.3" + +universalify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +utf8-byte-length@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61" + integrity sha1-9F8VDExm7uloGGUFq5P8u4rWv2E= + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +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@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.0.0.tgz#bc6ccf91b5ff0ac07bbcdbf1c7c4e150db4dbb6c" + integrity sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw== + +walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= + dependencies: + makeerror "1.0.x" + +which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +ws@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd" + integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w== + +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.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.13.2" + +yallist@^3.0.0, yallist@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9" + integrity sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k= + +yazl@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/yazl/-/yazl-2.5.1.tgz#a3d65d3dd659a5b0937850e8609f22fffa2b5c35" + integrity sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw== + dependencies: + buffer-crc32 "~0.2.3"