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 5e9b45b6..612da123 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -7,7 +7,7 @@
"rules": {
"indent": [
"error",
- "tab",
+ 4,
{
"SwitchCase" : 1
}
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 5a58c717..28a19883 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
*.pem
# Various directories
+config/config.hjson
logs/
db/
dropfiles/
diff --git a/LICENSE.TXT b/LICENSE.TXT
index 8db0cf42..74697ba9 100644
--- a/LICENSE.TXT
+++ b/LICENSE.TXT
@@ -1,4 +1,4 @@
-Copyright (c) 2015-2018, Bryan D. Ashby
+Copyright (c) 2015-2019, 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 8d448b25..1d178892 100644
--- a/README.md
+++ b/README.md
@@ -12,29 +12,27 @@ ENiGMA½ is a modern BBS software with a nostalgic flair!
* [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
+ * [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.
* [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
- * [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/servers/web-server.md). Legacy X/Y/Z modem also supported!
+ * [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/)
+[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 ACS support coverage
-* SysOp dashboard (ye ol' WFC)
-* Native DOS emulation
-* 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.
@@ -52,21 +50,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)
-* [fORCE9](https://bbs.force9.org/): (**telnet://bbs.force9.org**)
+* WQH: :skull: [Xibalba](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**)
## 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.9-alpha/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 [KICK ASS documentation](https://nuskooler.github.io/enigma-bbs/), code contributions, etc.
+* [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)!
@@ -77,11 +79,13 @@ Please see the [Quickstart](docs/index.md) for more information.
* Avon of [Agency BBS](http://bbs.geek.nz/) and [fsxNet](http://bbs.geek.nz/#fsxNet) for putting up with my experiments to his system
* Maskreet of [Throwback BBS](http://www.throwbackbbs.com/) hosting [DoorParty](http://forums.throwbackbbs.com/)!
* [Apam](https://github.com/apamment) of [Magicka](https://magickabbs.com/)
+* [nail/blocktronics](http://blocktronics.org/tag/nail/) for the [sickmade Xibalba logo](http://pc.textmod.es/pack/blocktronics-420/n-xbalba.ans)!
+* [Whazzit/blocktronics](http://blocktronics.org/tag/whazzit/) for the amazing Mayan ANSI pieces scattered about Xibalba BBS!
## License
Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license:
-Copyright (c) 2015-2018, Bryan D. Ashby
+Copyright (c) 2015-2019, 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 c7651fcd..038c5c0a 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -1,16 +1,17 @@
# Introduction
This document covers basic upgrade notes for major ENiGMA½ version updates.
-
# Before Upgrading
-* Always back up your system!
+* 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
-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:
+## Configuration File Updates
+In general, look at the `menu_template.in.hjson`, and `config_template.in.hjson` as well as the defualt `luciano_blocktronics/theme.hjson` files when you update. These files may come with new sections you wish to merge into your system!
+
+### 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: {
@@ -21,6 +22,9 @@ general: {
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:
@@ -32,11 +36,37 @@ 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.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**
diff --git a/WHATSNEW.md b/WHATSNEW.md
index 667c296f..39dfca49 100644
--- a/WHATSNEW.md
+++ b/WHATSNEW.md
@@ -1,6 +1,34 @@
# Whats New
This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub.
+## 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.db` `user_event_log` table for up to 90 days. An example usage can be found in the updated `last_callers` module where events are turned into Ami/X style actions. Please see `UPGRADE.md`!
+* 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 :)
+
+
## 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.
@@ -15,7 +43,8 @@ This document attempts to track **major** changes and additions in ENiGMA½. For
* Correly 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 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.
diff --git a/art/general/erc.ans b/art/general/erc.ans
deleted file mode 100644
index d2f336d2..00000000
Binary files a/art/general/erc.ans and /dev/null 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/art/themes/luciano_blocktronics/CHANGE.ANS b/art/themes/luciano_blocktronics/CHANGE.ANS
index 885b3cc9..056dd1cf 100644
Binary files a/art/themes/luciano_blocktronics/CHANGE.ANS and b/art/themes/luciano_blocktronics/CHANGE.ANS differ
diff --git a/art/themes/luciano_blocktronics/CONFSCR.ANS b/art/themes/luciano_blocktronics/CONFSCR.ANS
index 0bf72430..e3db0cbb 100644
Binary files a/art/themes/luciano_blocktronics/CONFSCR.ANS and b/art/themes/luciano_blocktronics/CONFSCR.ANS differ
diff --git a/art/themes/luciano_blocktronics/DOORMNU.ANS b/art/themes/luciano_blocktronics/DOORMNU.ANS
index bcd28f39..9621ec1d 100644
Binary files a/art/themes/luciano_blocktronics/DOORMNU.ANS and b/art/themes/luciano_blocktronics/DOORMNU.ANS differ
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/art/themes/luciano_blocktronics/FMENU.ANS b/art/themes/luciano_blocktronics/FMENU.ANS
index 55879dd7..1e340430 100644
Binary files a/art/themes/luciano_blocktronics/FMENU.ANS and b/art/themes/luciano_blocktronics/FMENU.ANS differ
diff --git a/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS b/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS
index 99219c10..c0ebbfd9 100644
Binary files a/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS and b/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS differ
diff --git a/art/themes/luciano_blocktronics/FSEARCH.ANS b/art/themes/luciano_blocktronics/FSEARCH.ANS
index efb19617..e97ec3d6 100644
Binary files a/art/themes/luciano_blocktronics/FSEARCH.ANS and b/art/themes/luciano_blocktronics/FSEARCH.ANS differ
diff --git a/art/themes/luciano_blocktronics/MATRIX.ANS b/art/themes/luciano_blocktronics/MATRIX.ANS
index 4e183723..14219543 100644
Binary files a/art/themes/luciano_blocktronics/MATRIX.ANS and b/art/themes/luciano_blocktronics/MATRIX.ANS differ
diff --git a/art/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS
index 995a5db5..bd8a483f 100644
Binary files a/art/themes/luciano_blocktronics/MMENU.ANS and b/art/themes/luciano_blocktronics/MMENU.ANS differ
diff --git a/art/themes/luciano_blocktronics/MSEARCH.ANS b/art/themes/luciano_blocktronics/MSEARCH.ANS
new file mode 100644
index 00000000..0e6a7afd
Binary files /dev/null and b/art/themes/luciano_blocktronics/MSEARCH.ANS differ
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/art/themes/luciano_blocktronics/MSGEHDR.ANS b/art/themes/luciano_blocktronics/MSGEHDR.ANS
index b2ed34e7..c455a9a3 100644
Binary files a/art/themes/luciano_blocktronics/MSGEHDR.ANS and b/art/themes/luciano_blocktronics/MSGEHDR.ANS differ
diff --git a/art/themes/luciano_blocktronics/MSGLIST.ANS b/art/themes/luciano_blocktronics/MSGLIST.ANS
index 911f1f20..b67b31a6 100644
Binary files a/art/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
index ce6f4815..3585799b 100644
Binary files a/art/themes/luciano_blocktronics/MSGMNU.ANS and b/art/themes/luciano_blocktronics/MSGMNU.ANS differ
diff --git a/art/themes/luciano_blocktronics/MSGVHLP.ANS b/art/themes/luciano_blocktronics/MSGVHLP.ANS
index 0320614d..6c79cbe2 100644
Binary files a/art/themes/luciano_blocktronics/MSGVHLP.ANS 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/NEWMSGS.ANS b/art/themes/luciano_blocktronics/NEWMSGS.ANS
index 5a58161e..90439992 100644
Binary files a/art/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/art/themes/luciano_blocktronics/PRVMSGLIST.ANS b/art/themes/luciano_blocktronics/PRVMSGLIST.ANS
new file mode 100644
index 00000000..180ace2b
Binary files /dev/null and b/art/themes/luciano_blocktronics/PRVMSGLIST.ANS differ
diff --git a/art/themes/luciano_blocktronics/SETMNSDATE.ANS b/art/themes/luciano_blocktronics/SETMNSDATE.ANS
index 4d3b43a3..61cbb3da 100644
Binary files a/art/themes/luciano_blocktronics/SETMNSDATE.ANS and b/art/themes/luciano_blocktronics/SETMNSDATE.ANS differ
diff --git a/art/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS
index b90ed2d9..dc2b0ca8 100644
Binary files a/art/themes/luciano_blocktronics/STATUS.ANS and b/art/themes/luciano_blocktronics/STATUS.ANS differ
diff --git a/art/themes/luciano_blocktronics/SYSSTAT.ANS b/art/themes/luciano_blocktronics/SYSSTAT.ANS
index 97beb53d..a76e3bd6 100644
Binary files a/art/themes/luciano_blocktronics/SYSSTAT.ANS and b/art/themes/luciano_blocktronics/SYSSTAT.ANS differ
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/art/themes/luciano_blocktronics/USERLST.ANS b/art/themes/luciano_blocktronics/USERLST.ANS
index fa4e3499..8c67ea58 100644
Binary files a/art/themes/luciano_blocktronics/USERLST.ANS and b/art/themes/luciano_blocktronics/USERLST.ANS differ
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/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson
index 19f63194..4a35eb15 100644
--- a/art/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,48 @@
}
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}"
}
}
}
@@ -261,25 +305,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 +348,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 +385,70 @@
}
}
+ 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
+ }
+ }
+ }
+ }
+
+ 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}"
+ }
+ }
+ }
messageAreaViewPost: {
0: {
@@ -410,20 +522,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 +549,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 +583,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 +611,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 +643,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 +701,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 +711,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 +743,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 +801,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 +835,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 +851,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 +906,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 +926,13 @@
}
fileBaseDownloadManager: {
- config: {
- queueListFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
- focusQueueListFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
- }
-
0: {
mci: {
VM1: {
height: 11
width: 69
+ itemFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
+ focusItemFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
HM2: {
width: 50
@@ -789,8 +944,6 @@
fileBaseWebDownloadManager: {
config: {
- queueListFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
- focusQueueListFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
queueManagerInfoFormat10: "|03batch|08: |03{webBatchDlLink}"
queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}"
}
@@ -799,6 +952,8 @@
mci: {
VM1: {
height: 8
+ itemFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
+ focusItemFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
HM2: {
width: 50
@@ -826,7 +981,7 @@
mci: {
SM1: {
width: 14
- justify: right
+ justify: left
focusTextStyle: first lower
}
@@ -895,17 +1050,14 @@
}
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}"
}
}
}
@@ -938,5 +1090,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/config/menu.hjson b/config/menu.hjson
deleted file mode 100644
index 386c6f89..00000000
--- a/config/menu.hjson
+++ /dev/null
@@ -1,3782 +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
- 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
- options: {
- 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
- 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: [
- {
- // calls >= 2
- acs: NC2
- next: fullLoginSequenceNewScanConfirm
- }
- {
- // new users - skip new scan
- next: fullLoginSequenceUserStats
- }
- ]
- 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: 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: 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
- // :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: "PW" }
- action: @menu:doorPimpWars
- }
- {
- value: { command: "TW" }
- action: @menu:doorTradeWars2002BBSLink
- }
- {
- value: { command: "DL" }
- action: @menu:doorDarkLands
- }
- {
- value: { command: "DP" }
- action: @menu:doorParty
- }
- {
- value: { command: "CN" }
- action: @menu:combatNet
- }
- {
- value: { command: "AGENT" }
- action: @menu:telnetBridgeAgency
- }
- ]
- }
-
- //
- // Example using the abracadabra module for a retro DOS door
- //
- doorPimpWars: {
- desc: Playing PimpWars
- module: abracadabra
- config: {
- name: PimpWars
- dropFileType: DORINFO
- cmd: /home/enigma/DOS/scripts/pimpwars.sh
- args: [
- "{node}",
- "{dropFile}",
- "{srvPort}",
- ],
- nodeMax: 1
- tooManyArt: DOORMANY
- io: socket
- }
- }
-
- //
- // 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! support. You'll need to register to obtain credentials
- doorParty: {
- desc: Using DoorParty!
- module: door_party
- config: {
- username: XXXXXXXX
- password: XXXXXXXX
- bbsTag: XX
- }
- }
-
- // CombatNet support. You'll need to register at http://combatnet.us/ to obtain credentials
- combatNet: {
- desc: Using CombatNet
- module: combatnet
- config: {
- bbsTag: CBNxxx
- password: XXXXXXXXX
- }
- }
-
- telnetBridgeAgency: {
- desc: Connected to HappyLand BBS
- module: telnet_bridge
- config: {
- host: agency.bbs.geek.nz
- }
- }
-
- ///////////////////////////////////////////////////////////////////////
- // 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: { command: "D" }
- action: @menu:messageAreaSetNewScanDate
- }
- {
- 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
- }
- ]
- }
- }
- }
-
- 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
- justify: right
- }
- }
- submit: {
- *: [
- {
- value: { scanDate: null }
- action: @method:scanDateSubmit
- }
- ]
- }
- 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: {
- 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:validateGeneralMailAddressedTo
- }
- 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: "W" }
- action: @menu:fileBaseWebDownloadManager
- }
- {
- value: { menuOption: "U" }
- action: @menu:fileBaseUploadFiles
- }
- {
- value: { menuOption: "S" }
- action: @menu:fileBaseSearch
- }
- {
- value: { menuOption: "P" }
- action: @menu:fileBaseSetNewScanDate
- }
- ]
- }
-
- 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
- }
-
- // :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", "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
- options: {
- 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: {
- 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", "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/core/abracadabra.js b/core/abracadabra.js
index 85d1e205..34374049 100644
--- a/core/abracadabra.js
+++ b/core/abracadabra.js
@@ -1,197 +1,199 @@
/* jslint node: true */
'use strict';
-const MenuModule = require('./menu_module.js').MenuModule;
-const DropFile = require('./dropfile.js').DropFile;
-const door = require('./door.js');
-const theme = require('./theme.js');
-const ansi = require('./ansi_term.js');
+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');
-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
+// 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',
+ name : 'Abracadabra',
+ desc : 'External BBS Door Module',
+ author : 'NuSkooler',
};
/*
- Example configuration for LORD under DOSEMU:
+ 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
- }
- }
+ {
+ 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
+ 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"
- }
- }
+ {
+ "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
+ :TODO: See Mystic & others for other arg options that we may need to support
*/
exports.getModule = class AbracadabraModule extends MenuModule {
- constructor(options) {
- super(options);
+ 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 = 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 || [];
- }
+ 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?
- */
+ /*
+ :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;
+ 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');
+ 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');
+ 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( () => {
- 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;
+ // :TODO: Use MenuModule.pausePrompt()
+ self.pausePrompt( () => {
+ return callback(Errors.AccessDenied('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;
+ }
- 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();
- }
- }
- );
- }
+ 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,
+ };
- runDoor() {
+ 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();
+ }
+ }
+ );
+ }
- 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,
- };
+ runDoor() {
+ this.client.term.write(ansi.resetScreen());
- const doorInstance = new door.Door(this.client, exeInfo);
+ 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,
+ };
- 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'
- );
+ const doorTracking = trackDoorRunBegin(this.client, this.config.name);
- this.prevMenu();
- });
+ this.doorInstance.run(exeInfo, () => {
+ trackDoorRunEnd(doorTracking);
- this.client.term.write(ansi.resetScreen());
+ //
+ // 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'
+ );
- doorInstance.run();
- }
+ this.prevMenu();
+ });
+ }
- leave() {
- super.leave();
- if(!this.lastError) {
- activeDoorNodeInstances[this.config.name] -= 1;
- }
- }
+ leave() {
+ super.leave();
+ if(!this.lastError) {
+ activeDoorNodeInstances[this.config.name] -= 1;
+ }
+ }
- finishedLoading() {
- this.runDoor();
- }
+ 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..1ae4fa93 100644
--- a/core/acs.js
+++ b/core/acs.js
@@ -1,86 +1,103 @@
/* 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);
- }
+ 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;
+ }
+ }
- hasMessageAreaRead(area) {
- return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead);
- }
+ //
+ // Message Conferences & Areas
+ //
+ hasMessageConfRead(conf) {
+ return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead);
+ }
- //
- // File Base / Areas
- //
- hasFileAreaRead(area) {
- return this.check(area.acs, 'read', ACS.Defaults.FileAreaRead);
- }
+ hasMessageAreaRead(area) {
+ return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead);
+ }
- hasFileAreaWrite(area) {
- return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite);
- }
+ //
+ // File Base / Areas
+ //
+ hasFileAreaRead(area) {
+ return this.check(area.acs, 'read', ACS.Defaults.FileAreaRead);
+ }
- hasFileAreaDownload(area) {
- return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload);
- }
+ hasFileAreaWrite(area) {
+ return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite);
+ }
- getConditionalValue(condArray, memberName) {
- assert(_.isArray(condArray));
- assert(_.isString(memberName));
+ hasFileAreaDownload(area) {
+ return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload);
+ }
- 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.
- }
- });
+ 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;
+ }
+ }
- if(matchCond) {
- return matchCond[memberName];
- }
- }
+ 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]',
+ MessageAreaRead : 'GM[users]',
+ MessageConfRead : 'GM[users]',
- FileAreaRead : 'GM[users]',
- FileAreaWrite : 'GM[sysops]',
- FileAreaDownload : '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..d4084b95 100644
--- a/core/acs_parser.js
+++ b/core/acs_parser.js
@@ -844,107 +844,206 @@ 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;
- 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);
},
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..9786ea4c 100644
--- a/core/ansi_escape_parser.js
+++ b/core/ansi_escape_parser.js
@@ -1,534 +1,524 @@
/* 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;
+
+ 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 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 45b93d32..09c9bbf6 100644
--- a/core/ansi_prep.js
+++ b/core/ansi_prep.js
@@ -1,220 +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.fillLines = _.get(options, 'fillLines', true);
- options.indent = options.indent || 0;
+ 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 = options.indent ?
- output.length > 0 ? ' '.repeat(options.indent) : '' :
- '';
-
- for(i = 0; i < lastCol; ++i) {
- const col = row[i];
+ canvas.slice(0, lastRow + 1).forEach(row => {
+ const lastCol = getLastPopulatedColumn(row) + 1;
- sgr = !options.asciiMode && 0 === i ?
- col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' :
- '';
-
- if(!options.asciiMode && 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 += `${options.asciiMode ? '' : ANSI.blackBG()}`;
- if(options.fillLines) {
- output += `${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 7eb10ec2..cac29681 100644
--- a/core/ansi_term.js
+++ b/core/ansi_term.js
@@ -2,497 +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');
+// 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;
+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 '';
- }
+ if(!client.terminalSupports('vtx_hyperlink')) {
+ return '';
+ }
- len = len || url.length;
+ len = len || url.length;
- url = url.split('').map(c => c.charCodeAt(0)).join(';');
- return `${ESC_CSI}1;${len};1;1;${url}\\`;
+ 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..8549cd12 100644
--- a/core/archive_util.js
+++ b/core/archive_util.js
@@ -1,288 +1,348 @@
/* 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, 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}`));
- }
+ const fmtObj = {
+ archivePath : archivePath,
+ fileList : files.join(' '), // :TODO: probably need same hack as extractTo here!
+ };
- const fmtObj = {
- archivePath : archivePath,
- };
+ const args = archiver.compress.args.map( arg => stringFormat(arg, fmtObj) );
- 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);
- }
+ let proc;
+ try {
+ proc = pty.spawn(archiver.compress.cmd, args, this.getPtyOpts());
+ } catch(e) {
+ return cb(Errors.ExternalProcess(
+ `Error spawning archiver process "${archiver.compress.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;
- });
+ return this.spawnHandler(proc, 'Compression', cb);
+ }
- proc.once('exit', exitCode => {
- if(exitCode) {
- return cb(Errors.ExternalProcess(`List failed with exit code: ${exitCode}`));
- }
+ extractTo(archivePath, extractPath, archType, fileList, cb) {
+ let haveFileList;
- const entryGroupOrder = archiver.list.entryGroupOrder || { byteSize : 1, fileName : 2 };
+ if(!cb && _.isFunction(fileList)) {
+ cb = fileList;
+ fileList = [];
+ haveFileList = false;
+ } else {
+ haveFileList = true;
+ }
- 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(),
- });
- }
+ const archiver = this.getArchiver(archType, paths.extname(archivePath));
- return cb(null, entries);
- });
- }
-
- getPtyOpts() {
- return {
- // :TODO: cwd
- name : 'enigma-archiver',
- cols : 80,
- rows : 24,
- env : process.env,
- };
- }
+ 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(extractPath) {
+ const opts = {
+ name : 'enigma-archiver',
+ cols : 80,
+ rows : 24,
+ env : process.env,
+ };
+ if(extractPath) {
+ opts.cwd = extractPath;
+ }
+ // :TODO: set cwd to supplied temp path if not sepcific extract
+ return opts;
+ }
};
diff --git a/core/art.js b/core/art.js
index 19e0bafe..d709d366 100644
--- a/core/art.js
+++ b/core/art.js
@@ -1,390 +1,391 @@
/* 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 xxhash = require('xxhash');
+// 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;
+ }
+ }
+ 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.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 = xxhash.hash(new Buffer(art), 0xCAFEBABE);
+ 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 9f2831b7..b3d62154 100644
--- a/core/asset.js
+++ b/core/asset.js
@@ -1,128 +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',
- 'userModule',
- '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;
- }
+ return asset;
+ }
- return {
- type : defaultType,
- asset : spec,
- };
+ 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, 'systemModule');
-
- if(!asset) {
- return null;
- }
+ const asset = getAssetWithShorthand(spec, 'systemModule');
- assert( ['userModule', 'systemModule' ].includes(asset.type) );
+ if(!asset) {
+ return null;
+ }
- return asset;
+ 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/bbs.js b/core/bbs.js
index 43bf7cf3..4d371fb3 100644
--- a/core/bbs.js
+++ b/core/bbs.js
@@ -5,29 +5,36 @@
//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/
@@ -38,244 +45,280 @@ valid args:
`;
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) {
- const configFile = configPath + 'config.hjson';
- conf.init(resolvePath(configFile), 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: ' + configFile);
- } 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 },
+ '**** 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 initFileAreaStats(callback) {
- const getAreaStats = require('./file_base_area.js').getAreaStats;
- getAreaStats( (err, stats) => {
- if(!err) {
- const StatLog = require('./stat_log.js');
- StatLog.setNonPeristentSystemStat('file_base_area_stats', stats);
- }
+ _.each(opProps, (v, k) => {
+ StatLog.setNonPersistentSystemStat(`sysop_${k}`, v);
+ });
+ }
- 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);
- }
- );
+ 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 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
index be341115..01eb3bfe 100644
--- a/core/bbs_link.js
+++ b/core/bbs_link.js
@@ -1,207 +1,217 @@
/* jslint node: true */
'use strict';
-const MenuModule = require('./menu_module.js').MenuModule;
-const resetScreen = require('./ansi_term.js').resetScreen;
+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');
-const async = require('async');
-const _ = require('lodash');
-const http = require('http');
-const net = require('net');
-const crypto = require('crypto');
+// deps
+const async = require('async');
+const http = require('http');
+const net = require('net');
+const crypto = require('crypto');
-const packageJson = require('../package.json');
+const packageJson = require('../package.json');
/*
- Expected configuration block:
+ 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
- }
- }
+ {
+ 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
+// :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',
+ name : 'BBSLink',
+ desc : 'BBSLink Access Module',
+ author : 'NuSkooler',
};
exports.getModule = class BBSLinkModule extends MenuModule {
- constructor(options) {
- super(options);
+ 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;
- }
+ 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;
+ 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,
- };
+ 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();
+ 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,
- };
+ 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,
+ };
- var clientTerminated;
+ let clientTerminated;
- self.client.term.write(resetScreen());
- self.client.term.write(' Connecting to BBSLink.net, please wait...\n');
+ 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');
+ const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`);
- self.client.term.output.pipe(bridgeConnection);
+ const bridgeConnection = net.createConnection(connectOpts, function connected() {
+ self.client.log.info(connectOpts, 'BBSLink bridge connection established');
- self.client.once('end', function clientEnd() {
- self.client.log.info('Connection ended. Terminating BBSLink connection');
- clientTerminated = true;
- bridgeConnection.end();
- });
- });
+ self.client.term.output.pipe(bridgeConnection);
- var restorePipe = function() {
- self.client.term.output.unpipe(bridgeConnection);
- self.client.term.output.resume();
- };
+ self.client.once('end', function clientEnd() {
+ self.client.log.info('Connection ended. Terminating BBSLink connection');
+ clientTerminated = true;
+ bridgeConnection.end();
+ });
+ });
- bridgeConnection.on('data', function incomingData(data) {
- // pass along
- // :TODO: just pipe this as well
- self.client.term.rawWrite(data);
- });
+ const restorePipe = function() {
+ self.client.term.output.unpipe(bridgeConnection);
+ self.client.term.output.resume();
- bridgeConnection.on('end', function connectionEnd() {
- restorePipe();
- callback(clientTerminated ? new Error('Client connection terminated') : null);
- });
+ trackDoorRunEnd(doorTracking);
+ };
- 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');
- }
+ bridgeConnection.on('data', function incomingData(data) {
+ // pass along
+ // :TODO: just pipe this as well
+ self.client.term.rawWrite(data);
+ });
- if(!clientTerminated) {
- self.prevMenu();
- }
- }
- );
- }
+ bridgeConnection.on('end', function connectionEnd() {
+ restorePipe();
+ return callback(clientTerminated ? Errors.General('Client connection terminated') : null);
+ });
- simpleHttpRequest(path, headers, cb) {
- const getOpts = {
- host : this.config.host,
- path : path,
- headers : headers,
- };
+ 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');
+ }
- const req = http.get(getOpts, function response(resp) {
- let data = '';
+ if(!clientTerminated) {
+ self.prevMenu();
+ }
+ }
+ );
+ }
- resp.on('data', function chunk(c) {
- data += c;
- });
+ simpleHttpRequest(path, headers, cb) {
+ const getOpts = {
+ host : this.config.host,
+ path : path,
+ headers : headers,
+ };
- resp.on('end', function respEnd() {
- cb(null, data);
- req.end();
- });
- });
+ const req = http.get(getOpts, function response(resp) {
+ let data = '';
- req.on('error', function reqErr(err) {
- cb(err);
- });
- }
+ 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
index ecf41c76..82943a80 100644
--- a/core/bbs_list.js
+++ b/core/bbs_list.js
@@ -1,438 +1,439 @@
/* jslint node: true */
'use strict';
-// ENiGMA½
-const MenuModule = require('./menu_module.js').MenuModule;
+// ENiGMA½
+const MenuModule = require('./menu_module.js').MenuModule;
-const {
- getModDatabasePath,
- getTransactionDatabase
-} = require('./database.js');
+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');
+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');
+// deps
+const async = require('async');
+const sqlite3 = require('sqlite3');
+const _ = require('lodash');
-// :TODO: add notes field
+// :TODO: add notes field
const moduleInfo = exports.moduleInfo = {
- name : 'BBS List',
- desc : 'List of other BBSes',
- author : 'Andrew Pamment',
- packageName : 'com.magickabbs.enigma.bbslist'
+ 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,
- }
+ 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,
+ 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',
+ 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);
+ 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();
- }
- }
+ 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);
- },
+ 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);
+ //
+ // 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);
+ }
- if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) {
- // must be owner or +op
- return cb(null);
- }
+ const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList);
- const entry = self.entries[self.selectedBBS];
- if(!entry) {
- return cb(null);
- }
+ if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) {
+ // must be owner or +op
+ 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);
+ const entry = self.entries[self.selectedBBS];
+ if(!entry) {
+ return cb(null);
+ }
- self.setEntries(entriesView);
+ 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);
- if(self.entries.length > 0) {
- entriesView.focusPrevious();
- }
+ self.setEntries(entriesView);
- self.viewControllers.view.redrawAll();
- }
+ if(self.entries.length > 0) {
+ entriesView.focusPrevious();
+ }
- return cb(null);
- }
- );
- },
- submitBBS : function(formData, extraArgs, cb) {
+ self.viewControllers.view.redrawAll();
+ }
- 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);
- }
+ return cb(null);
+ }
+ );
+ },
+ submitBBS : function(formData, extraArgs, cb) {
- 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');
- }
+ 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.clearAddForm();
- self.displayBBSList(true, cb);
- }
- );
- },
- cancelSubmit : function(formData, extraArgs, cb) {
- self.clearAddForm();
- self.displayBBSList(true, cb);
- }
- };
- }
+ 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');
+ }
- 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();
- }
- );
- }
+ self.clearAddForm();
+ self.displayBBSList(true, cb);
+ }
+ );
+ },
+ cancelSubmit : function(formData, extraArgs, cb) {
+ self.clearAddForm();
+ self.displayBBSList(true, cb);
+ }
+ };
+ }
- 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]) {
+ 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();
+ }
+ );
+ }
- 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);
- }
- }
- });
- }
- }
+ 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!)';
- setEntries(entriesView) {
- const config = this.menuConfig.config;
- const listFormat = config.listFormat || '{bbsName}';
- const focusListFormat = config.focusListFormat || '{bbsName}';
+ Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => {
+ const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]];
+ if(MciViewIds.view[mciName]) {
- entriesView.setItems(this.entries.map( e => stringFormat(listFormat, e) ) );
- entriesView.setFocusItems(this.entries.map( e => stringFormat(focusListFormat, e) ) );
- }
+ 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);
+ }
+ }
+ });
+ }
+ }
- displayBBSList(clearScreen, cb) {
- const self = this;
+ setEntries(entriesView) {
+ return entriesView.setItems(this.entries);
+ }
- 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 } )
- );
+ displayBBSList(clearScreen, cb) {
+ const self = this;
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds.View,
- };
+ 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 } )
+ );
- 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 = [];
+ const loadOpts = {
+ callingMenu : self,
+ mciMap : artData.mciMap,
+ formId : FormIds.View,
+ };
- 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);
+ 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 = [];
- entriesView.on('index update', idx => {
- const entry = self.entries[idx];
-
- self.drawSelectedEntry(entry);
-
- if(!entry) {
- self.selectedBBS = -1;
- } else {
- self.selectedBBS = idx;
- }
- });
+ 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);
- 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.on('index update', idx => {
+ const entry = self.entries[idx];
- entriesView.redraw();
+ self.drawSelectedEntry(entry);
- return callback(null);
- }
- ],
- err => {
- if(cb) {
- return cb(err);
- }
- }
- );
- }
+ if(!entry) {
+ self.selectedBBS = -1;
+ } else {
+ self.selectedBBS = idx;
+ }
+ });
- displayAddScreen(cb) {
- const self = this;
+ 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]);
+ }
- async.waterfall(
- [
- function clearAndDisplayArt(callback) {
- self.viewControllers.view.setFocus(false);
- self.client.term.rawWrite(ansi.resetScreen());
+ entriesView.redraw();
- 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 } )
- );
+ return callback(null);
+ }
+ ],
+ err => {
+ if(cb) {
+ return cb(err);
+ }
+ }
+ );
+ }
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds.Add,
- };
+ displayAddScreen(cb) {
+ const self = this;
- 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);
- }
- }
- );
- }
+ async.waterfall(
+ [
+ function clearAndDisplayArt(callback) {
+ self.viewControllers.view.setFocus(false);
+ self.client.term.rawWrite(ansi.resetScreen());
- clearAddForm() {
- [ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => {
- this.setViewText('add', MciViewIds.add[mciName], '');
- });
- }
+ 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 } )
+ );
- initDatabase(cb) {
- const self = this;
+ const loadOpts = {
+ callingMenu : self,
+ mciMap : artData.mciMap,
+ formId : FormIds.Add,
+ };
- 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);
- }
- );
- }
+ 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);
+ }
+ }
+ );
+ }
- beforeArt(cb) {
- super.beforeArt(err => {
- return err ? cb(err) : this.initDatabase(cb);
- });
- }
+ 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 424748a6..d340981b 100644
--- a/core/client.js
+++ b/core/client.js
@@ -2,522 +2,586 @@
'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.lastKeyPressMs = 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 + 1;
+ }
+ });
+ 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.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.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 = {
+ //
+ // See http://www.fbl.cz/arctel/download/techman.pdf
+ //
+ // Known clients:
+ // * Irssi ConnectBot (Android)
+ //
+ '63;1;2' : 'arctel',
+ '50;86;84;88' : 'vtx',
+ }[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('\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] || '');
-
- 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.lastKeyPressMs = 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;
+ this.lastKeyPressMs = Date.now();
- self.lastKeyPressMs = 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();
- //
- // Every 1m, check for idle.
- //
- self.idleCheck = setInterval(function checkForIdle() {
- const nowMs = Date.now();
+ let idleLogoutSeconds;
+ if(this.user.isAuthenticated()) {
+ idleLogoutSeconds = Config().users.idleLogoutSeconds;
- const idleLogoutSeconds = self.user.isAuthenticated() ?
- Config.misc.idleLogoutSeconds :
- Config.misc.preAuthIdleLogoutSeconds;
+ //
+ // 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;
+ }
- if(nowMs - self.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
- self.emit('idle timeout');
- }
- }, 1000 * 60);
+ if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
+ this.emit('idle timeout');
+ }
+ }, 1000 * 60);
+};
+
+Client.prototype.stopIdleMonitor = function() {
+ clearInterval(this.idleCheck);
};
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) {
- const termClient = this.term.termClient;
+ const termClient = this.term.termClient;
- switch(query) {
- case 'vtx_audio' :
- // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt
- return 'vtx' === 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;
- }
+ case 'vtx_hyperlink' :
+ return 'vtx' === termClient;
+
+ default :
+ return false;
+ }
};
diff --git a/core/client_connections.js b/core/client_connections.js
index 7e74e29d..21aa5c1c 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');
-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;
+ //
+ // Assign ID/client ID to next lowest & available #
+ //
+ let id = 0;
+ for(let i = 0; i < clientConnections.length; ++i) {
+ if(clientConnections[i].id > id) {
+ break;
+ }
+ id++;
+ }
- // 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 = id;
+ const remoteAddress = client.remoteAddress = clientSock.remoteAddress;
+ // create a unique identifier one-time ID for this session
+ client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, 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( { clientId : id, 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 id;
}
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,
+ clientId : client.session.id
+ },
+ '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 b313841e..f537c359 100644
--- a/core/client_term.js
+++ b/core/client_term.js
@@ -1,199 +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;
- }
+ //
+ // 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);
+ 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' ].includes(this.termType);
+ //
+ // 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 2e368aa3..ff08275e 100644
--- a/core/color_codes.js
+++ b/core/color_codes.js
@@ -1,292 +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.controlCodesToAnsi = controlCodesToAnsi;
+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 enigmaStrLen(s) {
- return stripEnigmaCodes(s).length;
+function pipeStringLength(s) {
+ return stripMciColorCodes(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' ],
+ 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' ],
+ 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' ],
+ 16 : [ 'blackBG' ],
+ 17 : [ 'blueBG' ],
+ 18 : [ 'greenBG' ],
+ 19 : [ 'cyanBG' ],
+ 20 : [ 'redBG' ],
+ 21 : [ 'magentaBG' ],
+ 22 : [ 'yellowBG' ],
+ 23 : [ 'whiteBG' ],
- 24 : [ 'bold', 'blackBG' ],
- 25 : [ 'bold', 'blueBG' ],
- 26 : [ 'bold', 'greenBG' ],
- 27 : [ 'bold', 'cyanBG' ],
- 28 : [ 'bold', 'redBG' ],
- 29 : [ 'bold', 'magentaBG' ],
- 30 : [ 'bold', 'yellowBG' ],
- 31 : [ 'bold', 'whiteBG' ],
- }[cc] || 'normal');
+ 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 {
- const attr = ansiSgrFromRenegadeColorCode(val);
- 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.
+// 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 FG color, and second is BG
-// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix
-// * WWIV : ^#
+// 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
+// TODO: Add Synchronet and Celerity format support
//
-// Resources:
-// * http://wiki.synchro.net/custom:colors
+// 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)/g; // eslint-disable-line no-control-regex
+ 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;
+ 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 or ENiGMA MCI
- v = parseInt(m[2], 10);
+ 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(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;
+ 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];
- }
+ case '@' :
+ // PCBoard @X## or Wildcat! @##@
+ if('@' === m[0].substr(-1)) {
+ // Wildcat!
+ v = m[6];
+ } else {
+ v = m[4];
+ }
- 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' ],
+ bg = {
+ 0 : [ 'blackBG' ],
+ 1 : [ 'blueBG' ],
+ 2 : [ 'greenBG' ],
+ 3 : [ 'cyanBG' ],
+ 4 : [ 'redBG' ],
+ 5 : [ 'magentaBG' ],
+ 6 : [ 'yellowBG' ],
+ 7 : [ 'whiteBG' ],
- 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(0)] || ['normal'];
+ 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' ];
- bg = {
- 0 : [ 'blackBG' ],
- 1 : [ 'blueBG' ],
- 2 : [ 'greenBG' ],
- 3 : [ 'cyanBG' ],
- 4 : [ 'redBG' ],
- 5 : [ 'magentaBG' ],
- 6 : [ 'yellowBG' ],
- 7 : [ 'whiteBG' ],
+ 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 : [ '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(1)] || [ 'normal' ];
+ 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;
+ v = ANSI.sgr(fg.concat(bg));
+ result += s.substr(lastIndex, m.index - lastIndex) + v;
+ break;
- case '\x03' :
- v = parseInt(m[8], 10);
+ 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');
- }
+ 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;
+ result += s.substr(lastIndex, m.index - lastIndex) + v;
+ break;
- 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;
- }
+ lastIndex = RE.lastIndex;
+ }
- return (0 === result.length ? s : result + s.substr(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
index 6cde9c7b..8f1a5623 100644
--- a/core/combatnet.js
+++ b/core/combatnet.js
@@ -1,83 +1,98 @@
/* 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('../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 _ = require('lodash');
+// deps
+const async = require('async');
const RLogin = require('rlogin');
exports.moduleInfo = {
- name : 'CombatNet',
- desc : 'CombatNet Access Module',
- author : 'Dave Stephens',
+ name : 'CombatNet',
+ desc : 'CombatNet Access Module',
+ author : 'Dave Stephens',
};
exports.getModule = class CombatNetModule extends MenuModule {
- constructor(options) {
- super(options);
+ 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) {
- 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 establishRloginConnection(callback) {
- self.client.term.write(resetScreen());
- self.client.term.write('Connecting to CombatNet, please wait...\n');
+ // establish defaults
+ this.config = options.menuConfig.config;
+ this.config.host = this.config.host || 'bbs.combatnet.us';
+ this.config.rloginPort = this.config.rloginPort || 4513;
+ }
- const restorePipeToNormal = function() {
- self.client.term.output.removeListener('data', sendToRloginBuffer);
- };
+ 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
+ {
+ 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();
- callback(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();
- callback(null);
+ 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,
+ 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) {
@@ -85,31 +100,32 @@ exports.getModule = class CombatNetModule extends MenuModule {
self.client.log.info('Connected to CombatNet');
self.client.term.output.on('data', sendToRloginBuffer);
+ doorTracking = trackDoorRunBegin(self.client);
} else {
- return callback(new Error('Failed to establish establish CombatNet connection'));
+ 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);
+ 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();
- }
- );
- }
+ // 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 ad6b6e5f..11f9bcb2 100644
--- a/core/config.js
+++ b/core/config.js
@@ -1,775 +1,1034 @@
/* jslint node: true */
'use strict';
-// ENiGMA½
+// 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, { } );
- }
+ 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
+ }
+ });
+ };
- fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => {
- if(err) {
- return callback(err);
- }
+ 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);
+ }
- let configJson;
- try {
- configJson = hjson.parse(configData, options);
- } catch(e) {
- return callback(e);
- }
-
- 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);
- }
- }
- );
-
- 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() {
- // e.g. /enigma-bbs-install-path/config/
- return './config/';
+ // 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',
- 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 ./config)
- promptFile : 'prompt.hjson', // Override to use soemthing else, e.g. myprompt.hjson. Can be a full path (defaults to ./config)
- },
+ 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
+ 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'
+ ],
- badUserNames : [
- 'sysop', 'admin', 'administrator', 'root', 'all',
- 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix'
- ],
- },
+ 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?
- },
+ theme : {
+ default : 'luciano_blocktronics',
+ preLogin : 'luciano_blocktronics',
- paths : {
- config : paths.join(__dirname, './../config/'),
- mods : paths.join(__dirname, './../mods/'),
- loginServers : paths.join(__dirname, './servers/login/'),
- contentServers : paths.join(__dirname, './servers/content/'),
+ 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',
+ }
+ },
- scannerTossers : paths.join(__dirname, './scanner_tossers/'),
- mailers : paths.join(__dirname, './mailers/') ,
+ menus : {
+ cls : true, // Clear screen before each menu by default?
+ },
- art : paths.join(__dirname, './../art/general/'),
- themes : paths.join(__dirname, './../art/themes/'),
- logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such
- db : paths.join(__dirname, './../db/'),
- modsDb : paths.join(__dirname, './../db/mods/'),
- dropFiles : paths.join(__dirname, './../dropfiles/'), // + "/node/
- misc : paths.join(__dirname, './../misc/'),
- },
+ paths : {
+ config : paths.join(__dirname, './../config/'),
+ mods : paths.join(__dirname, './../mods/'),
+ loginServers : paths.join(__dirname, './servers/login/'),
+ contentServers : paths.join(__dirname, './servers/content/'),
- loginServers : {
- telnet : {
- port : 8888,
- enabled : true,
- firstMenu : 'telnetConnected',
- },
- ssh : {
- port : 8889,
- enabled : false, // default to false as PK/pass in config.hjson are required
+ scannerTossers : paths.join(__dirname, './scanner_tossers/'),
+ mailers : paths.join(__dirname, './mailers/') ,
- //
- // Private key in PEM format
- //
- // Generating your PK:
- // Choose a cipher (3DES, AES128, or AES256) and a bit strength (2048 or 4096)
- // Ciphers:
- // 3des: older, most compatible, least secure
- // aes128: newer, widely compatible, fairly secure
- // aes256: newest, least compatible, best security
- // Bit strength:
- // 2048: most compatible, decent strength
- // 4096: stronger, but some software is completely incompatible
- // Sample command:
- // openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048
- //
- // Then, set servers.ssh.privateKeyPass to the password you use above
- // in your config.hjson
- //
- privateKeyPem : paths.join(__dirname, './../config/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, './../config/https_cert.pem'),
- keyPem : paths.join(__dirname, './../config/https_cert_key.pem'),
- },
- },
+ art : paths.join(__dirname, './../art/general/'),
+ themes : paths.join(__dirname, './../art/themes/'),
+ logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such
+ db : paths.join(__dirname, './../db/'),
+ modsDb : paths.join(__dirname, './../db/mods/'),
+ dropFiles : paths.join(__dirname, './../drop/'), // + "/node/
+ misc : paths.join(__dirname, './../misc/'),
+ },
- contentServers : {
- web : {
- domain : 'another-fine-enigma-bbs.org',
+ loginServers : {
+ telnet : {
+ port : 8888,
+ enabled : true,
+ firstMenu : 'telnetConnected',
+ },
+ ssh : {
+ port : 8889,
+ enabled : false, // default to false as PK/pass in config.hjson are required
+ //
+ // Private Key (PK) in PEM format
+ //
+ // Generating your PK:
+ // 1 - Choose a cipher (3DES, AES128, or AES256)
+ // 3des : older, most compatible, least secure
+ // aes128 : newer, widely compatible, fairly secure
+ // aes256 : newest, least compatible, best security
+ //
+ // 2 - Choose a bit strength (2048 or 4096)
+ // 2048 : most compatible, decent strength
+ // 4096 : stronger, but some software is completely incompatible
+ //
+ // Sample command:
+ // openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048
+ //
+ // Then, set servers.ssh.privateKeyPass to the password you use above
+ // in your config.hjson
+ //
+ //
+ privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'),
+ firstMenu : 'sshConnected',
+ firstMenuNewUser : 'sshConnectedNewUser',
- staticRoot : paths.join(__dirname, './../www'),
+ //
+ // 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'),
+ },
+ },
+ },
- 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.
+ contentServers : {
+ web : {
+ domain : 'another-fine-enigma-bbs.org',
- // 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
+ staticRoot : paths.join(__dirname, './../www'),
- // tempalte for pw reset *landing page*
- //
- resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'),
- },
+ 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.
- 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'),
- }
- }
- },
+ // 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
- 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'
- ]
- }
- },
+ // tempalte for pw reset *landing page*
+ //
+ resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'),
+ },
- 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
+ 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'),
+ }
+ },
- //
- // 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',
- }
+ gopher : {
+ enabled : false,
+ port : 8070,
+ publicHostname : 'another-fine-enigma-bbs.org',
+ publicPort : 8070, // adjust if behind NAT/etc.
+ bannerFile : 'gopher_banner.asc',
- // :TODO: update archives::formats to fall here
- // * archive handler -> archiveHandler (consider archive if archiveHandler present)
- // * sig, offset, ...
- // * mime-db -> exts lookup
- // *
- },
+ //
+ // Set messageConferences{} to maps of confTag -> [ areaTag1, areaTag2, ... ]
+ // to export message confs/areas
+ //
+ },
- 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}' ],
- },
- },
+ nntp : {
+ // internal caching of groups, message lists, etc.
+ cache : {
+ maxItems : 200,
+ maxAge : 1000 * 30, // 30s
+ },
- 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}' ]
- }
- },
+ //
+ // 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: {},
- 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}' ],
- }
- },
+ nntp : {
+ enabled : false,
+ port : 8119,
+ },
- 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}' ],
- }
- },
+ nntps : {
+ enabled : false,
+ port : 8563,
+ certPem : paths.join(__dirname, './../config/nntps_cert.pem'),
+ keyPem : paths.join(__dirname, './../config/nntps_key.pem'),
+ }
+ }
+ },
- 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}' ],
- }
- }
- },
- },
+ 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}' ]
+ },
+ },
- 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}' ],
- }
- },
+ 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
- xmodemSexyz : {
- name : 'XModem (SEXYZ)',
- type : 'external',
- sort : 3,
- external : {
- sendCmd : 'sexyz',
- sendArgs : [ '-telnet', 'sX', '@{fileListPath}' ],
- recvCmd : 'sexyz',
- recvArgsNonBatch : [ '-telnet', 'rC', '{fileName}' ]
- }
- },
+ //
+ // 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 : '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',
+ },
- ymodemSexyz : {
- name : 'YModem (SEXYZ)',
- type : 'external',
- sort : 4,
- external : {
- sendCmd : 'sexyz',
- sendArgs : [ '-telnet', 'sY', '@{fileListPath}' ],
- recvCmd : 'sexyz',
- recvArgs : [ '-telnet', 'ry', '{uploadDir}' ],
- }
- },
+ //
+ // 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',
+ }
+ ]
+ },
- 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
- }
- }
- },
+ 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}' ],
+ },
+ },
- messageAreaDefaults : {
- //
- // The following can be override per-area as well
- //
- maxMessages : 1024, // 0 = unlimited
- maxAgeDays : 0, // 0 = unlimited
- },
+ 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}' ]
+ }
+ },
- messageConferences : {
- system_internal : {
- name : 'System Internal',
- desc : 'Built in conference for private messages, bulletins, etc.',
+ 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+\\"([^"]+)\\"$',
+ }
+ },
- areas : {
- private_mail : {
- name : 'Private Mail',
- desc : 'Private user to user mail/email',
- maxExternalSentAgeDays : 30, // max external "outbox" item age
- },
+ 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}' ],
+ }
+ },
- local_bulletin : {
- name : 'System Bulletins',
- desc : 'Bulletin messages for all users',
- }
- }
- }
- },
+ 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}' ],
+ }
+ },
- 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
- },
+ 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}' ],
+ }
+ },
- //
- // 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
+ 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}' ]
+ }
+ }
+ },
+ },
- 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
- }
- }
- },
+ 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}' ],
+ }
+ },
- fileBase: {
- // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|:
- areaStoragePrefix : paths.join(__dirname, './../file_base/'),
+ xmodemSexyz : {
+ name : 'XModem (SEXYZ)',
+ type : 'external',
+ sort : 3,
+ external : {
+ sendCmd : 'sexyz',
+ sendArgs : [ '-telnet', 'sX', '@{fileListPath}' ],
+ recvCmd : 'sexyz',
+ recvArgsNonBatch : [ '-telnet', 'rC', '{fileName}' ]
+ }
+ },
- maxDescFileByteSize : 471859, // ~1/4 MB
- maxDescLongFileByteSize : 524288, // 1/2 MB
+ ymodemSexyz : {
+ name : 'YModem (SEXYZ)',
+ type : 'external',
+ sort : 4,
+ external : {
+ sendCmd : 'sexyz',
+ sendArgs : [ '-telnet', 'sY', '@{fileListPath}' ],
+ recvCmd : 'sexyz',
+ recvArgs : [ '-telnet', 'ry', '{uploadDir}' ],
+ }
+ },
- 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$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$'
- ],
+ 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
+ }
+ }
+ },
- // common README filename - https://en.wikipedia.org/wiki/README
- descLong : [
- '^[^/\]*\.NFO$', '^[^/\]*README\.1ST$', '^[^/\]*README\.NOW$', '^[^/\]*README\.TXT$', '^[^/\]*READ\.ME$', '^[^/\]*README$', '^[^/\]*README\.md$'
- ],
- },
+ messageAreaDefaults : {
+ //
+ // The following can be override per-area as well
+ //
+ maxMessages : 1024, // 0 = unlimited
+ maxAgeDays : 0, // 0 = unlimited
+ },
- 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.
- ],
+ messageConferences : {
+ system_internal : {
+ name : 'System Internal',
+ desc : 'Built in conference for private messages, bulletins, etc.',
- web : {
- path : '/f/',
- routePath : '/f/[a-zA-Z0-9]+$',
- expireMinutes : 1440, // 1 day
- },
+ areas : {
+ private_mail : {
+ name : 'Private Mail',
+ desc : 'Private user to user mail/email',
+ maxExternalSentAgeDays : 30, // max external "outbox" item age
+ },
- //
- // 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',
- },
+ local_bulletin : {
+ name : 'System Bulletins',
+ desc : 'Bulletin messages for all users',
+ }
+ }
+ }
+ },
- areas: {
- system_message_attachment : {
- name : 'System Message Attachments',
- desc : 'File attachments to messages',
- storageTags : [ 'sys_msg_attach' ],
- },
+ 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
+ },
- system_temporary_download : {
- name : 'System Temporary Downloads',
- desc : 'Temporary downloadables',
- storageTags : [ 'sys_temp_download' ],
- }
- }
- },
+ //
+ // 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
- eventScheduler : {
+ 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
+ }
+ }
+ },
- events : {
- trimMessageAreas : {
- // may optionally use [or ]@watch:/path/to/file
- schedule : 'every 24 hours',
+ fileBase: {
+ // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|:
+ areaStoragePrefix : paths.join(__dirname, './../file_base/'),
- // 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',
- },
+ maxDescFileByteSize : 471859, // ~1/4 MB
+ maxDescLongFileByteSize : 524288, // 1/2 MB
- updateFileAreaStats : {
- schedule : 'every 1 hours',
- action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent',
- },
+ 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
+ ],
- forgotPasswordMaintenance : {
- schedule : 'every 24 hours',
- action : '@method:core/web_password_reset.js:performMaintenanceTask',
- args : [ '24 hours' ] // items older than this will be removed
- }
- }
- },
+ // 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
+ ],
+ },
- misc : {
- preAuthIdleLogoutSeconds : 60 * 3, // 2m
- idleLogoutSeconds : 60 * 6, // 6m
- },
+ 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.
+ ],
- logging : {
- level : 'debug',
+ web : {
+ path : '/f/',
+ routePath : '/f/[a-zA-Z0-9]+$',
+ expireMinutes : 1440, // 1 day
+ },
- rotatingFile : { // set to 'disabled' or false to disable
- type : 'rotating-file',
- fileName : 'enigma-bbs.log',
- period : '1d',
- count : 3,
- level : 'debug',
- }
+ //
+ // 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',
+ },
- // :TODO: syslog - https://github.com/mcavage/node-bunyan-syslog
- },
+ areas: {
+ system_message_attachment : {
+ name : 'System Message Attachments',
+ desc : 'File attachments to messages',
+ storageTags : [ 'sys_msg_attach' ],
+ },
- debug : {
- assertsEnabled : false,
- }
- };
+ 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
+ },
+
+ //
+ // 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 8b57e125..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 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.gaze.on('error', function gazeErr(err) {
+ 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);
+ }
+ }
- });
+ getConfig(filePath, cb) {
+ return this.getConfigWithOptions( { filePath }, cb);
+ }
- this.gaze.on('changed', function fileChanged(filePath) {
- assert(filePath in self.cache);
+ recacheConfigFromFile(path, cb) {
+ fs.readFile(path, { encoding : 'utf-8' }, (err, data) => {
+ if(err) {
+ return cb(err);
+ }
- Log.info( { path : filePath }, 'Configuration file changed; re-caching');
+ 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);
+ }
- 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();
diff --git a/core/config_util.js b/core/config_util.js
index 40723d9a..d64c7a24 100644
--- a/core/config_util.js
+++ b/core/config_util.js
@@ -1,18 +1,67 @@
/* jslint node: true */
'use strict';
-const Config = require('./config.js').config;
-const configCache = require('./config_cache.js');
-const paths = require('path');
-exports.getFullConfig = getFullConfig;
+const Config = require('./config.js').get;
+const ConfigCache = require('./config_cache.js');
+const Events = require('./events.js');
+
+// deps
+const paths = require('path');
+const async = require('async');
+
+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 the config path if it's only a file name
- if('.' === paths.dirname(filePath)) {
- filePath = paths.join(Config.paths.config, 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 6a3c77f2..5d45eaa4 100644
--- a/core/connect.js
+++ b/core/connect.js
@@ -1,187 +1,266 @@
/* 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 = function(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-2018 Bryan Ashby |14- |12http://l33t.codes/
+|06Copyright (c) 2014-2019 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/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 14b3bf95..91f56a04 100644
--- a/core/database.js
+++ b/core/database.js
@@ -1,388 +1,433 @@
/* jslint node: true */
'use strict';
-// ENiGMA½
-const conf = require('./config.js');
+// ENiGMA½
+const conf = require('./config.js');
-// 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');
+// 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
+// database handles
const dbs = {};
-exports.getTransactionDatabase = getTransactionDatabase;
-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);
+ 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] = sqlite3Trans.wrap(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);
- },
+ return cb(null);
+ },
- message : (cb) => {
- enableForeignKeys(dbs.message);
+ message : (cb) => {
+ enableForeignKeys(dbs.message);
- 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 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 INDEX IF NOT EXISTS message_by_area_tag_index
- ON message (area_tag);`
- );
+ dbs.message.run(
+ `CREATE INDEX IF NOT EXISTS message_by_area_tag_index
+ ON message (area_tag);`
+ );
- dbs.message.run(
- `CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4 (
- content="message",
- subject,
- message
- );`
- );
+ 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_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 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_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_delete BEFORE DELETE 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_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 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_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)
+ );`
+ );
- dbs.file.run(
- `CREATE TABLE IF NOT EXISTS file_web_serve_batch (
- hash_id VARCHAR NOT NULL,
- file_id 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(hash_id, file_id)
- );`
- );
+ dbs.file.run(
+ `CREATE TABLE IF NOT EXISTS file_web_serve_batch (
+ hash_id VARCHAR NOT NULL,
+ file_id INTEGER NOT NULL,
- return cb(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 925e143e..b07c89bc 100644
--- a/core/door.js
+++ b/core/door.js
@@ -1,154 +1,137 @@
/* 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.debug(
+ { cmd : exeInfo.cmd, args, io : this.io },
+ 'Executing door'
+ );
- this.doorExited = function() {
- self.emit('finished');
- };
-}
+ let door;
+ try {
+ door = 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);
+ if('stdio' === this.io) {
+ this.client.log.debug('Using stdio for door I/O');
-Door.prototype.run = function() {
- const self = this;
+ this.client.term.output.pipe(door);
- this.prepareSocketIoServer( (err, sockServer) => {
- if(err) {
- this.client.log.warn( { error : err.toString() }, 'Failed executing door');
- return self.doorExited();
- }
+ door.on('data', this.doorDataHandler.bind(this));
- // 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
+ door.once('close', () => {
+ return this.restoreIo(door);
+ });
+ } 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'
+ );
+ }
- 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(),
- username : self.client.user.username,
- });
- }
+ door.once('exit', exitCode => {
+ this.client.log.info( { exitCode : exitCode }, 'Door exited');
- const door = pty.spawn(self.exeInfo.cmd, args, {
- cols : self.client.term.termWidth,
- rows : self.client.term.termHeight,
- // :TODO: cwd
- env : self.exeInfo.env,
- });
+ if(this.sockServer) {
+ this.sockServer.close();
+ }
- if('stdio' === self.exeInfo.io) {
- self.client.log.debug('Using stdio for door I/O');
+ // we may not get a close
+ if('stdio' === this.io) {
+ this.restoreIo(door);
+ }
- self.client.term.output.pipe(door);
+ door.removeAllListeners();
- door.on('data', self.doorDataHandler);
+ return cb(null);
+ });
+ }
- 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');
- }
+ doorDataHandler(data) {
+ this.client.term.write(decode(data, this.encoding));
+ }
- door.once('exit', exitCode => {
- self.client.log.info( { exitCode : exitCode }, 'Door exited');
-
- if(sockServer) {
- sockServer.close();
- }
-
- // we may not get a close
- if('stdio' === self.exeInfo.io) {
- self.restoreIo(door);
- }
-
- door.removeAllListeners();
-
- return self.doorExited();
- });
- });
+ restoreIo(piped) {
+ if(!this.restored && 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..6517f1be
--- /dev/null
+++ b/core/door_util.js
@@ -0,0 +1,38 @@
+/* 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) {
+ 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..28ca3aac 100644
--- a/core/download_queue.js
+++ b/core/download_queue.js
@@ -1,72 +1,79 @@
/* jslint node: true */
'use strict';
-const FileEntry = require('./file_entry.js');
+const FileEntry = require('./file_entry.js');
+const UserProps = require('./user_property.js');
+
+// deps
+const { partition } = 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;
- }
+ get items() {
+ return this.client.user.downloadQueue;
+ }
- clear() {
- this.client.user.downloadQueue = [];
- }
+ clear() {
+ 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);
- }
- }
+ 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);
+ }
+ }
- 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,
- });
- }
+ 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,
+ });
+ }
- removeItems(fileIds) {
- if(!Array.isArray(fileIds)) {
- fileIds = [ fileIds ];
- }
+ removeItems(fileIds) {
+ if(!Array.isArray(fileIds)) {
+ fileIds = [ fileIds ];
+ }
- this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => ( -1 === fileIds.indexOf(e.fileId) ) );
- }
+ const [ remain, removed ] = partition(this.client.user.downloadQueue, e => ( -1 === fileIds.indexOf(e.fileId) ));
+ this.client.user.downloadQueue = remain;
+ return removed;
+ }
- isQueued(entryOrId) {
- if(entryOrId instanceof FileEntry) {
- entryOrId = entryOrId.fileId;
- }
+ isQueued(entryOrId) {
+ if(entryOrId instanceof FileEntry) {
+ entryOrId = entryOrId.fileId;
+ }
- return this.client.user.downloadQueue.find(e => entryOrId === e.fileId) ? true : false;
- }
+ return this.client.user.downloadQueue.find(e => entryOrId === e.fileId) ? true : false;
+ }
- toProperty() { return JSON.stringify(this.client.user.downloadQueue); }
-
- loadFromProperty(prop) {
- try {
- this.client.user.downloadQueue = JSON.parse(prop);
- } catch(e) {
- this.client.user.downloadQueue = [];
+ toProperty() { return JSON.stringify(this.client.user.downloadQueue); }
- this.client.log.error( { error : e.message, property : prop }, 'Failed parsing download queue property');
- }
- }
+ 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');
+ }
+ }
};
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..1de3b034 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..33771564 100644
--- a/core/enig_error.js
+++ b/core/enig_error.js
@@ -2,41 +2,53 @@
'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),
};
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',
+};
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/erc_client.js b/core/erc_client.js
deleted file mode 100644
index 4fb549f6..00000000
--- a/core/erc_client.js
+++ /dev/null
@@ -1,179 +0,0 @@
-/* jslint node: true */
-'use strict';
-
-const MenuModule = require('./menu_module.js').MenuModule;
-const stringFormat = require('./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/core/event_scheduler.js b/core/event_scheduler.js
index 8b3d3239..4b7da062 100644
--- a/core/event_scheduler.js
+++ b/core/event_scheduler.js
@@ -1,268 +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 sane = require('sane');
-const moment = require('moment');
-const paths = require('path');
-const fse = require('fs-extra');
+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 : 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'
- );
+ this.eventTimers = [];
+ const self = this;
- if(schedEvent.schedule.sched) {
- this.eventTimers.push(later.setInterval( () => {
- self.performAction(schedEvent, 'Schedule');
- }, schedEvent.schedule.sched));
- }
+ if(this.moduleConfig && _.has(this.moduleConfig, 'events')) {
+ const events = Object.keys(this.moduleConfig.events).map( name => {
+ return new ScheduledEvent(this.moduleConfig.events, name);
+ });
- if(schedEvent.schedule.watchFile) {
- const watcher = sane(
- paths.dirname(schedEvent.schedule.watchFile),
- {
- glob : `**/${paths.basename(schedEvent.schedule.watchFile)}`
- }
- );
+ events.forEach( schedEvent => {
+ if(!schedEvent.isValid) {
+ Log.warn( { eventName : schedEvent.name }, 'Invalid scheduled event entry');
+ return;
+ }
- // :TODO: should track watched files & stop watching @ shutdown?
+ 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'
+ );
- [ '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}`);
- }
- });
- });
+ if(schedEvent.schedule.sched) {
+ this.eventTimers.push(later.setInterval( () => {
+ self.performAction(schedEvent, 'Schedule');
+ }, schedEvent.schedule.sched));
+ }
- fse.exists(schedEvent.schedule.watchFile, exists => {
- if(exists) {
- self.performAction(schedEvent, `Watch file: ${schedEvent.schedule.watchFile}`);
- }
- });
- }
- });
- }
-
- cb(null);
+ 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
index 4a53096c..e20d766b 100644
--- a/core/file_area_filter_edit.js
+++ b/core/file_area_filter_edit.js
@@ -1,339 +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');
+// 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');
+// deps
+const async = require('async');
exports.moduleInfo = {
- name : 'File Area Filter Editor',
- desc : 'Module for adding, deleting, and modifying file base filters',
- author : 'NuSkooler',
+ 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,
+ 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
- }
+ // :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);
+ constructor(options) {
+ super(options);
- this.filtersArray = new FileBaseFilters(this.client).toArray(); // ordered, such that we can index into them
- this.currentFilterIndex = 0; // into |filtersArray|
+ 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;
- }
- }
+ //
+ // 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 } );
- });
+ 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.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();
+ 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;
+ 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);
- }
+ // 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
+ this.filtersArray.splice(this.currentFilterIndex, 1); // remove selected entry
- // remove from stored properties
- const filters = new FileBaseFilters(this.client);
- filters.remove(filterUuid);
- filters.persist( () => {
+ // 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');
- }
- }
+ //
+ // 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);
- });
- },
+ // update UI
+ this.updateActiveLabel();
- viewValidationListener : (err, cb) => {
- const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error);
- let newFocusId;
+ if(this.filtersArray.length > 0) {
+ this.loadDataForFilter(this.currentFilterIndex);
+ } else {
+ this.clearForm();
+ }
+ return cb(null);
+ });
+ },
- if(errorView) {
- if(err) {
- errorView.setText(err.message);
- err.view.clearText(); // clear out the invalid data
- } else {
- errorView.clearText();
- }
- }
+ viewValidationListener : (err, cb) => {
+ const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error);
+ let newFocusId;
- return cb(newFocusId);
- },
- };
- }
+ if(errorView) {
+ if(err) {
+ errorView.setText(err.message);
+ err.view.clearText(); // clear out the invalid data
+ } else {
+ errorView.clearText();
+ }
+ }
- 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);
- }
+ return cb(newFocusId);
+ },
+ };
+ }
- const self = this;
- const vc = self.addViewController( 'editor', new ViewController( { client : this.client } ) );
+ showError(errMsg) {
+ const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error);
+ if(errorView) {
+ if(errMsg) {
+ errorView.setText(errMsg);
+ } else {
+ errorView.clearText();
+ }
+ }
+ }
- 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 ) );
- }
+ mciReady(mciData, cb) {
+ super.mciReady(mciData, err => {
+ if(err) {
+ return cb(err);
+ }
- self.updateActiveLabel();
- self.loadDataForFilter(self.currentFilterIndex);
- self.viewControllers.editor.resetInitialFocus();
- return callback(null);
- }
- ],
- err => {
- return cb(err);
- }
- );
- });
- }
+ const self = this;
+ const vc = self.addViewController( 'editor', new ViewController( { client : this.client } ) );
- getCurrentFilter() {
- return this.filtersArray[this.currentFilterIndex];
- }
+ 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) || []);
- setText(mciId, text) {
- const view = this.viewControllers.editor.getView(mciId);
- if(view) {
- view.setText(text);
- }
- }
+ const areasView = vc.getView(MciViewIds.editor.area);
+ if(areasView) {
+ areasView.setItems( self.availAreas.map( a => a.name ) );
+ }
- updateActiveLabel() {
- const activeFilter = FileBaseFilters.getActiveFilter(this.client);
- if(activeFilter) {
- const activeFormat = this.menuConfig.config.activeFormat || '{name}';
- this.setText(MciViewIds.editor.activeFilterInfo, stringFormat(activeFormat, activeFilter));
- }
- }
+ self.updateActiveLabel();
+ self.loadDataForFilter(self.currentFilterIndex);
+ self.viewControllers.editor.resetInitialFocus();
+ return callback(null);
+ }
+ ],
+ err => {
+ return cb(err);
+ }
+ );
+ });
+ }
- setFocusItemIndex(mciId, index) {
- const view = this.viewControllers.editor.getView(mciId);
- if(view) {
- view.setFocusItemIndex(index);
- }
- }
+ getCurrentFilter() {
+ return this.filtersArray[this.currentFilterIndex];
+ }
- clearForm(newFocusId) {
- [ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => {
- this.setText(mciId, '');
- });
+ setText(mciId, text) {
+ const view = this.viewControllers.editor.getView(mciId);
+ if(view) {
+ view.setText(text);
+ }
+ }
- [ MciViewIds.editor.area, MciViewIds.editor.order, MciViewIds.editor.sort ].forEach(mciId => {
- this.setFocusItemIndex(mciId, 0);
- });
+ updateActiveLabel() {
+ const activeFilter = FileBaseFilters.getActiveFilter(this.client);
+ if(activeFilter) {
+ const activeFormat = this.menuConfig.config.activeFormat || '{name}';
+ this.setText(MciViewIds.editor.activeFilterInfo, stringFormat(activeFormat, activeFilter));
+ }
+ }
- if(newFocusId) {
- this.viewControllers.editor.switchFocus(newFocusId);
- } else {
- this.viewControllers.editor.resetInitialFocus();
- }
- }
+ setFocusItemIndex(mciId, index) {
+ const view = this.viewControllers.editor.getView(mciId);
+ if(view) {
+ view.setFocusItemIndex(index);
+ }
+ }
- getSelectedAreaTag(index) {
- if(0 === index) {
- return ''; // -ALL-
- }
- const area = this.availAreas[index];
- if(!area) {
- return '';
- }
- return area.areaTag;
- }
+ clearForm(newFocusId) {
+ [ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => {
+ this.setText(mciId, '');
+ });
- getOrderBy(index) {
- return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0];
- }
+ [ MciViewIds.editor.area, MciViewIds.editor.order, MciViewIds.editor.sort ].forEach(mciId => {
+ this.setFocusItemIndex(mciId, 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);
- }
+ if(newFocusId) {
+ this.viewControllers.editor.switchFocus(newFocusId);
+ } else {
+ this.viewControllers.editor.resetInitialFocus();
+ }
+ }
- 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);
- }
+ getSelectedAreaTag(index) {
+ if(0 === index) {
+ return ''; // -ALL-
+ }
+ const area = this.availAreas[index];
+ if(!area) {
+ return '';
+ }
+ return area.areaTag;
+ }
- 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);
- }
+ getOrderBy(index) {
+ return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0];
+ }
- getSortBy(index) {
- return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[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);
+ }
- 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);
- }
+ 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);
+ }
- 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);
+ 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);
+ }
- // 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);
- }
+ getSortBy(index) {
+ return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0];
+ }
- 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);
+ 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);
+ }
- this.setAreaIndexFromCurrentFilter();
- this.setSortByFromCurrentFilter();
- this.setOrderByFromCurrentFilter();
- }
- }
+ 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
index 3bcfd7c2..a62271ca 100644
--- a/core/file_area_list.js
+++ b/core/file_area_list.js
@@ -1,701 +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').config;
-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;
+// 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');
+// 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',
+ 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,
+ browse : 0,
+ details : 1,
+ detailsGeneral : 2,
+ detailsNfo : 3,
+ detailsFileList : 4,
};
const MciViewIds = {
- browse : {
- desc : 1,
- navMenu : 2,
+ 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
+ },
+ 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
+ },
+ detailsGeneral : {
+ customRangeStart : 10, // 10+ = customs
+ },
+ detailsNfo : {
+ nfo : 1,
- customRangeStart : 10, // 10+ = customs
- },
- detailsFileList : {
- fileList : 1,
+ customRangeStart : 10, // 10+ = customs
+ },
+ detailsFileList : {
+ fileList : 1,
- customRangeStart : 10, // 10+ = customs
- },
+ 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 = 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)) {
- descView.setAnsi(
- 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 = 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);
- }
-
- 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;
-
- 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);
- });
- }
- }
+ 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 b12f4f7d..b36c8b72 100644
--- a/core/file_area_web.js
+++ b/core/file_area_web.js
@@ -1,492 +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 yazl = require('yazl');
+// 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 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
- }
-
- 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`));
- }
-
- 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);
- }
-
- isEnabled() {
- return this.webServer.instance.isEnabled();
- }
-
- static getHashIdTypes() {
- return {
- SingleFile : 0,
- BatchArchive : 1,
- };
- }
-
- 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);
- }
- );
- }
-
- removeEntry(hashId) {
- //
- // Delete record from DB, and our timer
- //
- FileDb.run(
- `DELETE FROM file_web_serve
- WHERE hash_id = ?;`,
- [ hashId ]
- );
-
- delete this.expireTimers[hashId];
- }
-
- scheduleExpire(hashId, expireTime) {
-
- // remove any previous entry for this hashId
- const previous = this.expireTimers[hashId];
- if(previous) {
- clearTimeout(previous);
- delete this.expireTimers[hashId];
- }
-
- const timeoutMs = expireTime.diff(moment());
-
- if(timeoutMs <= 0) {
- setImmediate( () => {
- this.removeEntry(hashId);
- });
- } else {
- this.expireTimers[hashId] = setTimeout( () => {
- this.removeEntry(hashId);
- }, timeoutMs);
- }
- }
-
- 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'));
- }
-
- const decoded = this.hashids.decode(hashId);
-
- // 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'));
- }
-
- const servedItem = {
- hashId : hashId,
- userId : decoded[0],
- hashIdType : decoded[1],
- expireTimestamp : moment(result.expire_timestamp),
- };
-
- if(FileAreaWebAccess.getHashIdTypes().SingleFile === servedItem.hashIdType) {
- servedItem.fileIds = decoded.slice(2);
- }
-
- return cb(null, servedItem);
- }
- );
- }
-
- getSingleFileHashId(client, fileEntry) {
- return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().SingleFile, [ fileEntry.fileId ] );
- }
-
- getBatchArchiveHashId(client, batchId) {
- return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().BatchArchive, batchId);
- }
-
- getHashId(client, hashIdType, identifier) {
- return this.hashids.encode(client.user.userId, hashIdType, identifier);
- }
-
- buildSingleFileTempDownloadLink(client, fileEntry, hashId) {
- hashId = hashId || this.getSingleFileHashId(client, fileEntry);
-
- return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`);
- }
-
- buildBatchArchiveTempDownloadLink(client, hashId) {
- return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`);
- }
-
- getExistingTempDownloadServeItem(client, fileEntry, cb) {
- if(!this.isEnabled()) {
- return cb(notEnabledError());
- }
-
- const hashId = this.getSingleFileHashId(client, fileEntry);
- this.loadServedHashId(hashId, (err, servedItem) => {
- if(err) {
- return cb(err);
- }
-
- servedItem.url = this.buildSingleFileTempDownloadLink(client, fileEntry);
-
- return cb(null, servedItem);
- });
- }
-
- _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);
- }
-
- this.scheduleExpire(hashId, expireTime);
-
- return cb(null);
- }
- );
- }
-
- createAndServeTempDownload(client, fileEntry, options, cb) {
- if(!this.isEnabled()) {
- return cb(notEnabledError());
- }
-
- const hashId = this.getSingleFileHashId(client, fileEntry);
- const url = this.buildSingleFileTempDownloadLink(client, fileEntry, hashId);
- options.expireTime = options.expireTime || moment().add(2, 'days');
-
- this._addOrUpdateHashIdRecord(FileDb, hashId, options.expireTime, err => {
- return cb(err, url);
- });
- }
-
- createAndServeTempBatchDownload(client, fileEntries, options, cb) {
- if(!this.isEnabled()) {
- return cb(notEnabledError());
- }
-
- 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');
-
- FileDb.beginTransaction( (err, trans) => {
- if(err) {
- 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);
- });
-
- 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) {
- const filePaths = [];
- async.eachSeries(fileIds, (fileId, nextFileId) => {
- const fileEntry = new FileEntry();
- fileEntry.load(fileId, err => {
- if(!err) {
- filePaths.push(fileEntry.filePath);
- }
- return nextFileId(err);
- });
- }, err => {
- if(err) {
- return callback(Errors.DoesNotExist('Coudl not load file IDs for batch'));
- }
-
- return callback(null, filePaths);
- });
- },
- function createAndServeStream(filePaths, callback) {
- 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);
- });
-
- 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, cb) {
- 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, '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);
- }
- }
- );
- }
+ constructor() {
+ this.hashids = new hashids(Config().general.boardName);
+ this.expireTimers = {}; // hashId->timer
+ }
+
+ 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`));
+ }
+
+ 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);
+ }
+
+ isEnabled() {
+ return this.webServer.instance.isEnabled();
+ }
+
+ static getHashIdTypes() {
+ return {
+ SingleFile : 0,
+ BatchArchive : 1,
+ };
+ }
+
+ 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);
+ }
+ );
+ }
+
+ removeEntry(hashId) {
+ //
+ // Delete record from DB, and our timer
+ //
+ FileDb.run(
+ `DELETE FROM file_web_serve
+ WHERE hash_id = ?;`,
+ [ hashId ]
+ );
+
+ delete this.expireTimers[hashId];
+ }
+
+ scheduleExpire(hashId, expireTime) {
+
+ // remove any previous entry for this hashId
+ const previous = this.expireTimers[hashId];
+ if(previous) {
+ clearTimeout(previous);
+ delete this.expireTimers[hashId];
+ }
+
+ const timeoutMs = expireTime.diff(moment());
+
+ if(timeoutMs <= 0) {
+ setImmediate( () => {
+ this.removeEntry(hashId);
+ });
+ } else {
+ this.expireTimers[hashId] = setTimeout( () => {
+ this.removeEntry(hashId);
+ }, timeoutMs);
+ }
+ }
+
+ 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'));
+ }
+
+ const decoded = this.hashids.decode(hashId);
+
+ // 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'));
+ }
+
+ const servedItem = {
+ hashId : hashId,
+ userId : decoded[0],
+ hashIdType : decoded[1],
+ expireTimestamp : moment(result.expire_timestamp),
+ };
+
+ if(FileAreaWebAccess.getHashIdTypes().SingleFile === servedItem.hashIdType) {
+ servedItem.fileIds = decoded.slice(2);
+ }
+
+ return cb(null, servedItem);
+ }
+ );
+ }
+
+ getSingleFileHashId(client, fileEntry) {
+ return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().SingleFile, [ fileEntry.fileId ] );
+ }
+
+ getBatchArchiveHashId(client, batchId) {
+ return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().BatchArchive, batchId);
+ }
+
+ getHashId(client, hashIdType, identifier) {
+ return this.hashids.encode(client.user.userId, hashIdType, identifier);
+ }
+
+ buildSingleFileTempDownloadLink(client, fileEntry, hashId) {
+ hashId = hashId || this.getSingleFileHashId(client, fileEntry);
+
+ return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
+ }
+
+ buildBatchArchiveTempDownloadLink(client, hashId) {
+ return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
+ }
+
+ getExistingTempDownloadServeItem(client, fileEntry, cb) {
+ if(!this.isEnabled()) {
+ return cb(notEnabledError());
+ }
+
+ const hashId = this.getSingleFileHashId(client, fileEntry);
+ this.loadServedHashId(hashId, (err, servedItem) => {
+ if(err) {
+ return cb(err);
+ }
+
+ servedItem.url = this.buildSingleFileTempDownloadLink(client, fileEntry);
+
+ return cb(null, servedItem);
+ });
+ }
+
+ _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);
+ }
+
+ this.scheduleExpire(hashId, expireTime);
+
+ return cb(null);
+ }
+ );
+ }
+
+ createAndServeTempDownload(client, fileEntry, options, cb) {
+ if(!this.isEnabled()) {
+ return cb(notEnabledError());
+ }
+
+ const hashId = this.getSingleFileHashId(client, fileEntry);
+ const url = this.buildSingleFileTempDownloadLink(client, fileEntry, hashId);
+ options.expireTime = options.expireTime || moment().add(2, 'days');
+
+ this._addOrUpdateHashIdRecord(FileDb, hashId, options.expireTime, err => {
+ return cb(err, url);
+ });
+ }
+
+ createAndServeTempBatchDownload(client, fileEntries, options, cb) {
+ if(!this.isEnabled()) {
+ return cb(notEnabledError());
+ }
+
+ 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');
+
+ FileDb.beginTransaction( (err, trans) => {
+ if(err) {
+ 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 f3845cc1..ec36c7ec 100644
--- a/core/file_base_area.js
+++ b/core/file_base_area.js
@@ -1,938 +1,1089 @@
/* 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;
-const StatLog = require('./stat_log.js');
+// 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');
-// 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.getAvailableFileAreaTags = getAvailableFileAreaTags;
-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.getAreaStats = getAreaStats;
+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.getFileEntryPath = getFileEntryPath;
+exports.changeFileAreaWithOptions = changeFileAreaWithOptions;
+exports.scanFile = scanFile;
+exports.scanFileAreaForChanges = scanFileAreaForChanges;
+exports.getDescFromFileName = getDescFromFileName;
+exports.getAreaStats = getAreaStats;
+exports.cleanUpTempSessionItems = cleanUpTempSessionItems;
-// for scheduler:
-exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent;
+// for scheduler:
+exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent;
-const WellKnownAreaTags = exports.WellKnownAreaTags = {
- Invalid : '',
- MessageAreaAttach : 'system_message_attachment',
- TempDownloads : 'system_temporary_download',
+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 [ WellKnownAreaTags.MessageAreaAttach, WellKnownAreaTags.TempDownloads ].includes(areaTag);
+ 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.skipAcsCheck) {
- return false; // no ACS checks (below)
- }
+ return _.omitBy(allAreas, areaInfo => {
+ if(!options.includeSystemInternal && isInternalArea(areaInfo.areaTag)) {
+ return true;
+ }
- if(options.writeAcs && !client.acs.hasFileAreaWrite(areaInfo)) {
- return true; // omit
- }
+ if(options.skipAcsCheck) {
+ return false; // no ACS checks (below)
+ }
- return !client.acs.hasFileAreaRead(areaInfo);
- });
+ 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);
+ 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 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');
- 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
- 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, 0x1a);
+ 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);
- fileEntry.descSrc = 'fileName';
- }
- 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}Src`] = 'infoTool';
- }
- }
+ 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);
- fileEntry.descSrc = 'fileName';
- }
- 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)
+// 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);
- }
+// 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'));
- }
+ 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;
+ 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 = stats.areas || {};
- stats.areas[v.area_tag] = {
- files : v.total_files,
- bytes : v.total_byte_size,
- };
- return stats;
- }, {})
- );
- }
- );
+ stats.areas[v.area_tag] = {
+ files : v.total_files,
+ bytes : v.total_byte_size,
+ };
+ return stats;
+ }, {})
+ );
+ }
+ );
}
-// method exposed for event scheduler
+// method exposed for event scheduler
function updateAreaStatsScheduledEvent(args, cb) {
- getAreaStats( (err, stats) => {
- if(!err) {
- StatLog.setNonPeristentSystemStat('file_base_area_stats', stats);
- }
+ getAreaStats( (err, stats) => {
+ if(!err) {
+ StatLog.setNonPersistentSystemStat(SysProps.FileBaseAreaStats, stats);
+ }
- return cb(err);
- });
+ 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
index 5ec266fd..a8f74322 100644
--- a/core/file_base_area_select.js
+++ b/core/file_base_area_select.js
@@ -1,104 +1,88 @@
/* 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 StatLog = require('./stat_log.js');
+// 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');
+// deps
+const async = require('async');
exports.moduleInfo = {
- name : 'File Area Selector',
- desc : 'Select from available file areas',
- author : 'NuSkooler',
+ name : 'File Area Selector',
+ desc : 'Select from available file areas',
+ author : 'NuSkooler',
};
const MciViewIds = {
- areaList : 1,
+ areaList : 1,
};
exports.getModule = class FileAreaSelectModule extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- this.config = this.menuConfig.config || {};
+ this.menuMethods = {
+ selectArea : (formData, extraArgs, cb) => {
+ const filterCriteria = {
+ areaTag : formData.value.areaTag,
+ };
- this.loadAvailAreas();
+ const menuOpts = {
+ extraArgs : {
+ filterCriteria : filterCriteria,
+ },
+ menuFlags : [ 'popParent', 'mergeFlags' ],
+ };
- this.menuMethods = {
- selectArea : (formData, extraArgs, cb) => {
- const area = this.availAreas[formData.value.areaSelect] || 0;
+ return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
+ }
+ };
+ }
- const filterCriteria = {
- areaTag : area.areaTag,
- };
+ mciReady(mciData, cb) {
+ super.mciReady(mciData, err => {
+ if(err) {
+ return cb(err);
+ }
- const menuOpts = {
- extraArgs : {
- filterCriteria : filterCriteria,
- },
- menuFlags : [ 'popParent' ],
- };
+ const self = this;
- return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
- }
- };
- }
+ async.waterfall(
+ [
+ function mergeAreaStats(callback) {
+ const areaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats) || { areas : {} };
- loadAvailAreas() {
- this.availAreas = getSortedAvailableFileAreas(this.client);
- }
+ // 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;
+ });
- mciReady(mciData, cb) {
- super.mciReady(mciData, err => {
- if(err) {
- return cb(err);
- }
+ return callback(null, availAreas);
+ },
+ function prepView(availAreas, callback) {
+ self.prepViewController('allViews', 0, mciData.menu, (err, vc) => {
+ if(err) {
+ return callback(err);
+ }
- const self = this;
+ const areaListView = vc.getView(MciViewIds.areaList);
+ areaListView.setItems(availAreas.map(area => Object.assign(area, { text : area.name, data : area.areaTag } )));
+ areaListView.redraw();
- async.series(
- [
- function mergeAreaStats(callback) {
- const areaStats = StatLog.getSystemStat('file_base_area_stats') || { areas : {} };
-
- self.availAreas.forEach(area => {
- const stats = areaStats.areas[area.areaTag];
- area.totalFiles = stats ? stats.files : 0;
- area.totalBytes = stats ? stats.bytes : 0;
- });
-
- return callback(null);
- },
- function prepView(callback) {
- self.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => {
- if(err) {
- return callback(err);
- }
-
- const areaListView = vc.getView(MciViewIds.areaList);
-
- const areaListFormat = self.config.areaListFormat || '{name}';
-
- areaListView.setItems(self.availAreas.map(a => stringFormat(areaListFormat, a) ) );
-
- if(self.config.areaListFocusFormat) {
- areaListView.setFocusItems(self.availAreas.map(a => stringFormat(self.config.areaListFocusFormat, a) ) );
- }
-
- areaListView.redraw();
-
- return callback(null);
- });
- }
- ],
- err => {
- return cb(err);
- }
- );
- });
- }
+ return callback(null);
+ });
+ }
+ ],
+ err => {
+ return cb(err);
+ }
+ );
+ });
+ }
};
diff --git a/core/file_base_download_manager.js b/core/file_base_download_manager.js
index 7444af56..8487697f 100644
--- a/core/file_base_download_manager.js
+++ b/core/file_base_download_manager.js
@@ -1,244 +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 stringFormat = require('./string_format.js');
-const FileAreaWeb = require('./file_area_web.js');
+// 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');
+// 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',
+ name : 'File Base Download Queue Manager',
+ desc : 'Module for interacting with download queue/batch',
+ author : 'NuSkooler',
};
const FormIds = {
- queueManager : 0,
+ queueManager : 0,
};
const MciViewIds = {
- queueManager : {
- queue : 1,
- navMenu : 2,
+ queueManager : {
+ queue : 1,
+ navMenu : 2,
- customRangeStart : 10,
- },
+ customRangeStart : 10,
+ },
};
exports.getModule = class FileBaseDownloadQueueManager extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- this.dlQueue = new DownloadQueue(this.client);
+ this.dlQueue = new DownloadQueue(this.client);
- if(_.has(options, 'lastMenuResult.sentFileIds')) {
- this.sentFileIds = options.lastMenuResult.sentFileIds;
- }
+ if(_.has(options, 'lastMenuResult.sentFileIds')) {
+ this.sentFileIds = options.lastMenuResult.sentFileIds;
+ }
- this.fallbackOnly = options.lastMenuResult ? true : false;
+ this.fallbackOnly = options.lastMenuResult ? true : false;
- this.menuMethods = {
- downloadAll : (formData, extraArgs, cb) => {
- const modOpts = {
- extraArgs : {
- sendQueue : this.dlQueue.items,
- direction : 'send',
- }
- };
+ 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);
- }
+ 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);
+ 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);
- }
- };
- }
+ // :TODO: broken: does not redraw menu properly - needs fixed!
+ return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb);
+ },
+ clearQueue : (formData, extraArgs, cb) => {
+ this.dlQueue.clear();
- initSequence() {
- if(0 === this.dlQueue.items.length) {
- if(this.sendFileIds) {
- // we've finished everything up - just fall back
- return this.prevMenu();
- }
+ // :TODO: broken: does not redraw menu properly - needs fixed!
+ return this.removeItemsFromDownloadQueueView('all', cb);
+ }
+ };
+ }
- // Simply an empty D/L queue: Present a specialized "empty queue" page
- return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue');
- }
+ initSequence() {
+ if(0 === this.dlQueue.items.length) {
+ if(this.sendFileIds) {
+ // we've finished everything up - just fall back
+ return this.prevMenu();
+ }
- const self = this;
+ // Simply an empty D/L queue: Present a specialized "empty queue" page
+ return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue');
+ }
- async.series(
- [
- function beforeArt(callback) {
- return self.beforeArt(callback);
- },
- function display(callback) {
- return self.displayQueueManagerPage(false, callback);
- }
- ],
- () => {
- return self.finishedLoading();
- }
- );
- }
+ const self = this;
- removeItemsFromDownloadQueueView(itemIndex, cb) {
- const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
- if(!queueView) {
- return cb(Errors.DoesNotExist('Queue view does not exist'));
- }
+ async.series(
+ [
+ function beforeArt(callback) {
+ return self.beforeArt(callback);
+ },
+ function display(callback) {
+ return self.displayQueueManagerPage(false, callback);
+ }
+ ],
+ () => {
+ return self.finishedLoading();
+ }
+ );
+ }
- if('all' === itemIndex) {
- queueView.setItems([]);
- queueView.setFocusItems([]);
- } else {
- queueView.removeItem(itemIndex);
- }
+ removeItemsFromDownloadQueueView(itemIndex, cb) {
+ const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
+ if(!queueView) {
+ return cb(Errors.DoesNotExist('Queue view does not exist'));
+ }
- queueView.redraw();
- return cb(null);
- }
+ if('all' === itemIndex) {
+ queueView.setItems([]);
+ queueView.setFocusItems([]);
+ } else {
+ queueView.removeItem(itemIndex);
+ }
- displayWebDownloadLinkForFileEntry(fileEntry) {
- FileAreaWeb.getExistingTempDownloadServeItem(this.client, fileEntry, (err, serveItem) => {
- if(serveItem && serveItem.url) {
- const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
+ queueView.redraw();
+ return cb(null);
+ }
- fileEntry.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url;
- fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat);
- } else {
- fileEntry.webDlLink = '';
- fileEntry.webDlExpire = '';
- }
+ displayWebDownloadLinkForFileEntry(fileEntry) {
+ FileAreaWeb.getExistingTempDownloadServeItem(this.client, fileEntry, (err, serveItem) => {
+ if(serveItem && serveItem.url) {
+ const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
- this.updateCustomViewTextsWithFilter(
- 'queueManager',
- MciViewIds.queueManager.customRangeStart, fileEntry,
- { filter : [ '{webDlLink}', '{webDlExpire}' ] }
- );
- });
- }
+ fileEntry.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url;
+ fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat);
+ } else {
+ fileEntry.webDlLink = '';
+ fileEntry.webDlExpire = '';
+ }
- updateDownloadQueueView(cb) {
- const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
- if(!queueView) {
- return cb(Errors.DoesNotExist('Queue view does not exist'));
- }
+ this.updateCustomViewTextsWithFilter(
+ 'queueManager',
+ MciViewIds.queueManager.customRangeStart, fileEntry,
+ { filter : [ '{webDlLink}', '{webDlExpire}' ] }
+ );
+ });
+ }
- const queueListFormat = this.menuConfig.config.queueListFormat || '{fileName} {byteSize}';
- const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
+ 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.map( queueItem => stringFormat(queueListFormat, queueItem) ) );
- queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) );
+ queueView.setItems(this.dlQueue.items);
- queueView.on('index update', idx => {
- const fileEntry = this.dlQueue.items[idx];
- this.displayWebDownloadLinkForFileEntry(fileEntry);
- });
+ queueView.on('index update', idx => {
+ const fileEntry = this.dlQueue.items[idx];
+ this.displayWebDownloadLinkForFileEntry(fileEntry);
+ });
- queueView.redraw();
- this.displayWebDownloadLinkForFileEntry(this.dlQueue.items[0]);
+ queueView.redraw();
+ this.displayWebDownloadLinkForFileEntry(this.dlQueue.items[0]);
- return cb(null);
- }
+ return cb(null);
+ }
- displayQueueManagerPage(clearScreen, cb) {
- const self = this;
+ 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);
- }
- }
- );
- }
+ 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;
+ 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());
- }
+ 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],
- };
+ 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;
- }
+ if(!_.isUndefined(options.noInput)) {
+ vcOpts.noInput = options.noInput;
+ }
- const vc = self.addViewController(name, new ViewController(vcOpts));
+ const vc = self.addViewController(name, new ViewController(vcOpts));
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds[name],
- };
+ 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);
- }
- );
- }
+ 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 320d36d3..d72b3eea 100644
--- a/core/file_base_filter.js
+++ b/core/file_base_filter.js
@@ -1,155 +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 uuidV4 = require('uuid/v4');
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',
- 'file_name',
- ];
- }
+ 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, allowOlder, cb) {
- if(!cb && _.isFunction(allowOlder)) {
- cb = allowOlder;
- allowOlder = false;
- }
+ return false;
+ }
- const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user);
- if(!allowOlder && fileId < current) {
- if(cb) {
- cb(null);
- }
- return;
- }
+ static getBuiltInSystemFilters() {
+ const U_LATEST = '7458b09d-40ab-4f9b-a0d7-0cf866646329';
- return user.persistProperty('user_file_base_last_viewed', fileId, cb);
- }
+ 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
index 27656123..168ed39a 100644
--- a/core/file_base_search.js
+++ b/core/file_base_search.js
@@ -1,120 +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');
+// 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');
+// deps
+const async = require('async');
exports.moduleInfo = {
- name : 'File Base Search',
- desc : 'Module for quickly searching the file base',
- author : 'NuSkooler',
+ 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,
- }
+ 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);
+ constructor(options) {
+ super(options);
- this.menuMethods = {
- search : (formData, extraArgs, cb) => {
- const isAdvanced = formData.submitId === MciViewIds.search.advSearch;
- return this.searchNow(formData, isAdvanced, cb);
- },
- };
- }
+ 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);
- }
+ 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 } ) );
+ 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) || []);
+ 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);
+ 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);
- }
- );
- });
- }
+ 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;
- }
+ 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];
- }
+ getOrderBy(index) {
+ return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0];
+ }
- getSortBy(index) {
- return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[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;
+ 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),
- };
- }
+ 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);
+ searchNow(formData, isAdvanced, cb) {
+ const filterCriteria = this.getFilterValuesFromFormData(formData, isAdvanced);
- const menuOpts = {
- extraArgs : {
- filterCriteria : filterCriteria,
- },
- menuFlags : [ 'popParent' ],
- };
+ const menuOpts = {
+ extraArgs : {
+ filterCriteria : filterCriteria,
+ },
+ menuFlags : [ 'popParent' ],
+ };
- return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
- }
+ 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..3c00d167
--- /dev/null
+++ b/core/file_base_user_list_export.js
@@ -0,0 +1,294 @@
+/* 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 Events = require('./events.js');
+const Log = require('./logger.js').log;
+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 uuidv4 = require('uuid/v4');
+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 comrpession 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!
+ const dlQueue = new DownloadQueue(self.client);
+ dlQueue.add(newEntry, true); // true=systemFile
+
+ // clean up after ourselves when the session ends
+ const thisClientId = self.client.session.id;
+ Events.once(Events.getSystemEvents().ClientDisconnected, evt => {
+ if(thisClientId === _.get(evt, 'client.session.id')) {
+ FileEntry.removeEntry(newEntry, { removePhysFile : true }, err => {
+ if(err) {
+ Log.warn( { fileId : newEntry.fileId, path : outputFileName }, 'Failed removing temporary session download' );
+ } else {
+ Log.debug( { fileId : newEntry.fileId, path : outputFileName }, 'Removed temporary session download item' );
+ }
+ });
+ }
+ });
+ }
+ return callback(err);
+ });
+ },
+ function done(callback) {
+ // re-enable idle monitor
+ 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
index dea7c5a8..cf509cd9 100644
--- a/core/file_base_web_download_manager.js
+++ b/core/file_base_web_download_manager.js
@@ -1,287 +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 stringFormat = require('./string_format.js');
-const FileAreaWeb = require('./file_area_web.js');
-const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled;
-const Config = require('./config.js').config;
+// 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');
+// 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',
+ name : 'File Base Download Web Queue Manager',
+ desc : 'Module for interacting with web backed download queue/batch',
+ author : 'NuSkooler',
};
const FormIds = {
- queueManager : 0
+ queueManager : 0
};
const MciViewIds = {
- queueManager : {
- queue : 1,
- navMenu : 2,
-
- customRangeStart : 10,
- }
+ queueManager : {
+ queue : 1,
+ navMenu : 2,
+
+ customRangeStart : 10,
+ }
};
exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
-
- constructor(options) {
- super(options);
- this.dlQueue = new DownloadQueue(this.client);
+ constructor(options) {
+ super(options);
- this.menuMethods = {
- removeItem : (formData, extraArgs, cb) => {
- const selectedItem = this.dlQueue.items[formData.value.queueItem];
- if(!selectedItem) {
- return cb(null);
- }
+ this.dlQueue = new DownloadQueue(this.client);
- this.dlQueue.removeItems(selectedItem.fileId);
+ this.menuMethods = {
+ removeItem : (formData, extraArgs, cb) => {
+ const selectedItem = this.dlQueue.items[formData.value.queueItem];
+ if(!selectedItem) {
+ return cb(null);
+ }
- // :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);
- }
- };
- }
+ this.dlQueue.removeItems(selectedItem.fileId);
- initSequence() {
- if(0 === this.dlQueue.items.length) {
- return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue');
- }
+ // :TODO: broken: does not redraw menu properly - needs fixed!
+ return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb);
+ },
+ clearQueue : (formData, extraArgs, cb) => {
+ this.dlQueue.clear();
- const self = this;
+ // :TODO: broken: does not redraw menu properly - needs fixed!
+ return this.removeItemsFromDownloadQueueView('all', cb);
+ },
+ getBatchLink : (formData, extraArgs, cb) => {
+ return this.generateAndDisplayBatchLink(cb);
+ }
+ };
+ }
- async.series(
- [
- function beforeArt(callback) {
- return self.beforeArt(callback);
- },
- function display(callback) {
- return self.displayQueueManagerPage(false, callback);
- }
- ],
- () => {
- return self.finishedLoading();
- }
- );
- }
+ initSequence() {
+ if(0 === this.dlQueue.items.length) {
+ return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue');
+ }
- removeItemsFromDownloadQueueView(itemIndex, cb) {
- const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
- if(!queueView) {
- return cb(Errors.DoesNotExist('Queue view does not exist'));
- }
+ const self = this;
- if('all' === itemIndex) {
- queueView.setItems([]);
- queueView.setFocusItems([]);
- } else {
- queueView.removeItem(itemIndex);
- }
+ async.series(
+ [
+ function beforeArt(callback) {
+ return self.beforeArt(callback);
+ },
+ function display(callback) {
+ return self.displayQueueManagerPage(false, callback);
+ }
+ ],
+ () => {
+ return self.finishedLoading();
+ }
+ );
+ }
- queueView.redraw();
- return cb(null);
- }
+ removeItemsFromDownloadQueueView(itemIndex, cb) {
+ const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
+ if(!queueView) {
+ return cb(Errors.DoesNotExist('Queue view does not exist'));
+ }
- displayFileInfoForFileEntry(fileEntry) {
- this.updateCustomViewTextsWithFilter(
- 'queueManager',
- MciViewIds.queueManager.customRangeStart, fileEntry,
- { filter : [ '{webDlLink}', '{webDlExpire}', '{fileName}' ] } // :TODO: Others....
- );
- }
+ if('all' === itemIndex) {
+ queueView.setItems([]);
+ queueView.setFocusItems([]);
+ } else {
+ queueView.removeItem(itemIndex);
+ }
- updateDownloadQueueView(cb) {
- const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
- if(!queueView) {
- return cb(Errors.DoesNotExist('Queue view does not exist'));
- }
+ queueView.redraw();
+ return cb(null);
+ }
- const queueListFormat = this.menuConfig.config.queueListFormat || '{webDlLink}';
- const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
+ displayFileInfoForFileEntry(fileEntry) {
+ this.updateCustomViewTextsWithFilter(
+ 'queueManager',
+ MciViewIds.queueManager.customRangeStart, fileEntry,
+ { filter : [ '{webDlLink}', '{webDlExpire}', '{fileName}' ] } // :TODO: Others....
+ );
+ }
- queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) );
- queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) );
+ updateDownloadQueueView(cb) {
+ const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
+ if(!queueView) {
+ return cb(Errors.DoesNotExist('Queue view does not exist'));
+ }
- queueView.on('index update', idx => {
- const fileEntry = this.dlQueue.items[idx];
- this.displayFileInfoForFileEntry(fileEntry);
- });
+ queueView.setItems(this.dlQueue.items);
- queueView.redraw();
- this.displayFileInfoForFileEntry(this.dlQueue.items[0]);
+ queueView.on('index update', idx => {
+ const fileEntry = this.dlQueue.items[idx];
+ this.displayFileInfoForFileEntry(fileEntry);
+ });
- return cb(null);
- }
+ queueView.redraw();
+ this.displayFileInfoForFileEntry(this.dlQueue.items[0]);
- generateAndDisplayBatchLink(cb) {
- const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes');
+ return cb(null);
+ }
- FileAreaWeb.createAndServeTempBatchDownload(
- this.client,
- this.dlQueue.items,
- {
- expireTime : expireTime
- },
- (err, webBatchDlLink) => {
- // :TODO: handle not enabled -> display such
- if(err) {
- return cb(err);
- }
+ generateAndDisplayBatchLink(cb) {
+ const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes');
- const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
+ FileAreaWeb.createAndServeTempBatchDownload(
+ this.client,
+ this.dlQueue.items,
+ {
+ expireTime : expireTime
+ },
+ (err, webBatchDlLink) => {
+ // :TODO: handle not enabled -> display such
+ if(err) {
+ return cb(err);
+ }
- const formatObj = {
- webBatchDlLink : ansi.vtxHyperlink(this.client, webBatchDlLink) + webBatchDlLink,
- webBatchDlExpire : expireTime.format(webDlExpireTimeFormat),
- };
+ const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
- this.updateCustomViewTextsWithFilter(
- 'queueManager',
- MciViewIds.queueManager.customRangeStart,
- formatObj,
- { filter : Object.keys(formatObj).map(k => '{' + k + '}' ) }
- );
+ const formatObj = {
+ webBatchDlLink : ansi.vtxHyperlink(this.client, webBatchDlLink) + webBatchDlLink,
+ webBatchDlExpire : expireTime.format(webDlExpireTimeFormat),
+ };
- return cb(null);
- }
- );
- }
+ this.updateCustomViewTextsWithFilter(
+ 'queueManager',
+ MciViewIds.queueManager.customRangeStart,
+ formatObj,
+ { filter : Object.keys(formatObj).map(k => '{' + k + '}' ) }
+ );
- displayQueueManagerPage(clearScreen, cb) {
- const self = this;
+ return cb(null);
+ }
+ );
+ }
- 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';
+ displayQueueManagerPage(clearScreen, cb) {
+ const self = this;
- 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
- }
+ 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 expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes');
-
- FileAreaWeb.createAndServeTempDownload(
- self.client,
- fileEntry,
- { expireTime : expireTime },
- (err, url) => {
- if(err) {
- return nextFileEntry(err);
- }
+ 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
+ }
- fileEntry.webDlLinkRaw = url;
- fileEntry.webDlLink = ansi.vtxHyperlink(self.client, url) + url;
- fileEntry.webDlExpire = expireTime.format(webDlExpireTimeFormat);
+ const expireTime = moment().add(config.fileBase.web.expireMinutes, 'minutes');
- 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);
- }
- }
- );
- }
+ FileAreaWeb.createAndServeTempDownload(
+ self.client,
+ fileEntry,
+ { expireTime : expireTime },
+ (err, url) => {
+ if(err) {
+ return nextFileEntry(err);
+ }
- displayArtAndPrepViewController(name, options, cb) {
- const self = this;
- const config = this.menuConfig.config;
+ fileEntry.webDlLinkRaw = url;
+ fileEntry.webDlLink = ansi.vtxHyperlink(self.client, url) + url;
+ fileEntry.webDlExpire = expireTime.format(webDlExpireTimeFormat);
- async.waterfall(
- [
- function readyAndDisplayArt(callback) {
- if(options.clearScreen) {
- self.client.term.rawWrite(ansi.resetScreen());
- }
+ 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);
+ }
+ }
+ );
+ }
- 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],
- };
+ displayArtAndPrepViewController(name, options, cb) {
+ const self = this;
+ const config = this.menuConfig.config;
- if(!_.isUndefined(options.noInput)) {
- vcOpts.noInput = options.noInput;
- }
+ async.waterfall(
+ [
+ function readyAndDisplayArt(callback) {
+ if(options.clearScreen) {
+ self.client.term.rawWrite(ansi.resetScreen());
+ }
- const vc = self.addViewController(name, new ViewController(vcOpts));
+ 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],
+ };
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds[name],
- };
+ if(!_.isUndefined(options.noInput)) {
+ vcOpts.noInput = options.noInput;
+ }
- return vc.loadFromMenuConfig(loadOpts, callback);
- }
-
- self.viewControllers[name].setFocus(true);
- return callback(null);
-
- },
- ],
- err => {
- return cb(err);
- }
- );
- }
+ 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);
+ }
+ );
+ }
};
-
\ No newline at end of file
diff --git a/core/file_entry.js b/core/file_entry.js
index 8bf7a69d..0539f137 100644
--- a/core/file_entry.js
+++ b/core/file_entry.js
@@ -1,655 +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');
-const { unlink, readFile } = require('graceful-fs');
-const crypto = require('crypto');
-const moment = require('moment');
+// 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 || {};
-
- 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;
- }
-
- 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);
- }
-
- 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];
- });
-
- return cb(null, dest);
- }
- );
- }
-
- 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);
- }
- );
- }
-
- persist(isUpdate, cb) {
- if(!cb && _.isFunction(isUpdate)) {
- cb = isUpdate;
- isUpdate = false;
- }
-
- const self = this;
-
- 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) {
- return callback(Errors.MissingParam('fileSha256 property must be set for updates!'));
- }
-
- readFile(self.filePath, (err, data) => {
- if(err) {
- return callback(err);
- }
-
- 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);
- }
- }
- );
- }
-
- static getAreaStorageDirectoryByTag(storageTag) {
- const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]);
-
- // absolute paths as-is
- if(storageLocation && '/' === storageLocation.charAt(0)) {
- return storageLocation;
- }
-
- // relative to |areaStoragePrefix|
- return paths.join(Config.fileBase.areaStoragePrefix, storageLocation || '');
- }
-
- get filePath() {
- const storageDir = FileEntry.getAreaStorageDirectoryByTag(this.storageTag);
- return paths.join(storageDir, this.fileName);
- }
-
- static persistUserRating(fileId, userId, rating, cb) {
- return fileDb.run(
- `REPLACE INTO file_user_rating (file_id, user_id, rating)
- VALUES (?, ?, ?);`,
- [ fileId, userId, rating ],
- cb
- );
- }
-
- static persistMetaValue(fileId, name, value, transOrDb, cb) {
- if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
- cb = transOrDb;
- transOrDb = fileDb;
- }
-
- return transOrDb.run(
- `REPLACE INTO file_meta (file_id, meta_name, meta_value)
- VALUES (?, ?, ?);`,
- [ fileId, name, value ],
- cb
- );
- }
-
- 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);
- }
- }
- );
- }
-
- 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 persistHashTag(fileId, hashTag, transOrDb, cb) {
- if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
- cb = transOrDb;
- transOrDb = fileDb;
- }
-
- transOrDb.serialize( () => {
- transOrDb.run(
- `INSERT OR IGNORE INTO hash_tag (hash_tag)
- VALUES (?);`,
- [ hashTag ]
- );
-
- 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);
- }
- );
- });
- }
-
- 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);
- }
- );
- }
-
- 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);
- }
- );
- }
-
- 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;
- }
- }
-
- static get WellKnownMetaValues() {
- return Object.keys(FILE_WELL_KNOWN_META);
- }
-
- 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);
- }
-
- if(!fileIdRows || 0 === fileIdRows.length) {
- return cb(Errors.DoesNotExist('No matches'));
- }
-
- if(fileIdRows.length > 1) {
- return cb(Errors.Invalid('SHA is ambiguous'));
- }
-
- const fileEntry = new FileEntry();
- return fileEntry.load(fileIdRows[0].file_id, err => {
- return cb(err, fileEntry);
- });
- }
- );
- }
-
- static findByFileNameWildcard(wc, cb) {
- // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
- wc = wc.replace(/\*/g, '%').replace(/\?/g, '_');
-
- fileDb.all(
- `SELECT file_id
- FROM file
- WHERE file_name LIKE "${wc}"
- `,
- (err, fileIdRows) => {
- if(err) {
- return cb(err);
- }
-
- if(!fileIdRows || 0 === fileIdRows.length) {
- return cb(Errors.DoesNotExist('No matches'));
- }
-
- 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);
- });
- }
- );
- }
-
- static findFiles(filter, cb) {
- filter = filter || {};
-
- let sql;
- let sqlWhere = '';
- let sqlOrderBy;
- const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC';
-
- if(moment.isMoment(filter.newerThanTimestamp)) {
- filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp);
- }
-
- function getOrderByWithCast(ob) {
- if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) {
- return `ORDER BY CAST(${ob} AS INTEGER)`;
- }
-
- return `ORDER BY ${ob}`;
- }
-
- function appendWhereClause(clause) {
- if(sqlWhere) {
- sqlWhere += ' AND ';
- } else {
- sqlWhere += ' WHERE ';
- }
- sqlWhere += clause;
- }
-
- 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`;
-
- appendWhereClause(`f.file_id = m.file_id AND m.meta_name = "${filter.sort}"`);
-
- 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`;
-
- sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir;
- }
- }
- } else {
- sql =
- `SELECT DISTINCT f.file_id
- FROM file f`;
-
- sqlOrderBy = `${getOrderByWithCast('f.file_id')} ${sqlOrderDir}`;
- }
-
- 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}"`);
- }
- }
-
- if(filter.metaPairs && filter.metaPairs.length > 0) {
-
- 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(filter.storageTag && filter.storageTag.length > 0) {
- appendWhereClause(`f.storage_tag="${filter.storageTag}"`);
- }
-
- 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 and/or comma separated values
- const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${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 += ';';
-
- const matchingFileIds = [];
- fileDb.each(sql, (err, fileId) => {
- if(fileId) {
- matchingFileIds.push(fileId.file_id);
- }
- }, err => {
- return cb(err, matchingFileIds);
- });
- }
-
- 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);
- }
- );
- }
+ constructor(options) {
+ options = options || {};
+
+ 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 || {};
+
+ 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'));
+ }
+
+ // assign props from |file|
+ FILE_TABLE_MEMBERS.forEach(prop => {
+ dest[_.camelCase(prop)] = file[prop];
+ });
+
+ return cb(null, dest);
+ }
+ );
+ }
+
+ 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);
+ }
+ );
+ }
+
+ persist(isUpdate, cb) {
+ if(!cb && _.isFunction(isUpdate)) {
+ cb = isUpdate;
+ isUpdate = false;
+ }
+
+ const self = this;
+
+ 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) {
+ return callback(Errors.MissingParam('fileSha256 property must be set for updates!'));
+ }
+
+ readFile(self.filePath, (err, data) => {
+ if(err) {
+ return callback(err);
+ }
+
+ 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);
+ }
+ }
+ );
+ }
+
+ static getAreaStorageDirectoryByTag(storageTag) {
+ const config = Config();
+ const storageLocation = (storageTag && config.fileBase.storageTags[storageTag]);
+
+ // absolute paths as-is
+ if(storageLocation && '/' === storageLocation.charAt(0)) {
+ return storageLocation;
+ }
+
+ // relative to |areaStoragePrefix|
+ return paths.join(config.fileBase.areaStoragePrefix, storageLocation || '');
+ }
+
+ get filePath() {
+ const storageDir = FileEntry.getAreaStorageDirectoryByTag(this.storageTag);
+ return paths.join(storageDir, this.fileName);
+ }
+
+ 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 persistUserRating(fileId, userId, rating, cb) {
+ return fileDb.run(
+ `REPLACE INTO file_user_rating (file_id, user_id, rating)
+ VALUES (?, ?, ?);`,
+ [ fileId, userId, rating ],
+ cb
+ );
+ }
+
+ static persistMetaValue(fileId, name, value, transOrDb, cb) {
+ if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
+ cb = transOrDb;
+ transOrDb = fileDb;
+ }
+
+ return transOrDb.run(
+ `REPLACE INTO file_meta (file_id, meta_name, meta_value)
+ VALUES (?, ?, ?);`,
+ [ fileId, name, value ],
+ cb
+ );
+ }
+
+ 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);
+ }
+ }
+ );
+ }
+
+ 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 persistHashTag(fileId, hashTag, transOrDb, cb) {
+ if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
+ cb = transOrDb;
+ transOrDb = fileDb;
+ }
+
+ transOrDb.serialize( () => {
+ transOrDb.run(
+ `INSERT OR IGNORE INTO hash_tag (hash_tag)
+ VALUES (?);`,
+ [ hashTag ]
+ );
+
+ 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);
+ }
+ );
+ });
+ }
+
+ 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);
+ }
+ );
+ }
+
+ 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);
+ }
+ );
+ }
+
+ 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;
+ }
+ }
+
+ static get WellKnownMetaValues() {
+ return Object.keys(FILE_WELL_KNOWN_META);
+ }
+
+ 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);
+ }
+
+ if(!fileIdRows || 0 === fileIdRows.length) {
+ return cb(Errors.DoesNotExist('No matches'));
+ }
+
+ if(fileIdRows.length > 1) {
+ return cb(Errors.Invalid('SHA is ambiguous'));
+ }
+
+ const fileEntry = new FileEntry();
+ return fileEntry.load(fileIdRows[0].file_id, err => {
+ return cb(err, fileEntry);
+ });
+ }
+ );
+ }
+
+ // 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'));
+ }
+
+ // 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);
+ }
+
+ return cb(Errors.DoesNotExist('No matches'));
+ });
+ }
+
+ static findByFileNameWildcard(wc, cb) {
+ // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
+ wc = wc.replace(/\*/g, '%').replace(/\?/g, '_');
+
+ fileDb.all(
+ `SELECT file_id
+ FROM file
+ WHERE file_name LIKE "${wc}"
+ `,
+ (err, fileIdRows) => {
+ if(err) {
+ return cb(err);
+ }
+
+ if(!fileIdRows || 0 === fileIdRows.length) {
+ return cb(Errors.DoesNotExist('No matches'));
+ }
+
+ 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);
+ });
+ }
+ );
+ }
+
+ static findFiles(filter, cb) {
+ filter = filter || {};
+
+ let sql;
+ let sqlWhere = '';
+ let sqlOrderBy;
+ const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC';
+
+ if(moment.isMoment(filter.newerThanTimestamp)) {
+ filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp);
+ }
+
+ function getOrderByWithCast(ob) {
+ if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) {
+ return `ORDER BY CAST(${ob} AS INTEGER)`;
+ }
+
+ return `ORDER BY ${ob}`;
+ }
+
+ function appendWhereClause(clause) {
+ if(sqlWhere) {
+ sqlWhere += ' AND ';
+ } else {
+ sqlWhere += ' WHERE ';
+ }
+ sqlWhere += clause;
+ }
+
+ 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`;
+
+ appendWhereClause(`f.file_id = m.file_id AND m.meta_name = "${filter.sort}"`);
+
+ 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
+ FROM file f`;
+
+ sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir;
+ }
+ }
+ } else {
+ sql =
+ `SELECT DISTINCT f.file_id
+ FROM file f`;
+
+ sqlOrderBy = `${getOrderByWithCast('f.file_id')} ${sqlOrderDir}`;
+ }
+
+ 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}"`);
+ }
+ }
+
+ if(filter.metaPairs && filter.metaPairs.length > 0) {
+
+ 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..83dedc13 100644
--- a/core/file_transfer.js
+++ b/core/file_transfer.js
@@ -1,582 +1,604 @@
/* 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(
+ () => 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`];
+
+ 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);
+
+ 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(Buffer.from(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(Buffer.from(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, 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
index f1b3dbed..13e30a74 100644
--- a/core/file_transfer_protocol_select.js
+++ b/core/file_transfer_protocol_select.js
@@ -1,158 +1,153 @@
/* 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 ViewController = require('./view_controller.js').ViewController;
+// 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');
+// deps
+const async = require('async');
+const _ = require('lodash');
exports.moduleInfo = {
- name : 'File transfer protocol selection',
- desc : 'Select protocol / method for file transfer',
- author : 'NuSkooler',
+ name : 'File transfer protocol selection',
+ desc : 'Select protocol / method for file transfer',
+ author : 'NuSkooler',
};
const MciViewIds = {
- protList : 1,
+ protList : 1,
};
exports.getModule = class FileTransferProtocolSelectModule extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- this.config = this.menuConfig.config || {};
+ this.config = this.menuConfig.config || {};
- if(options.extraArgs) {
- if(options.extraArgs.direction) {
- this.config.direction = options.extraArgs.direction;
- }
- }
+ if(options.extraArgs) {
+ if(options.extraArgs.direction) {
+ this.config.direction = options.extraArgs.direction;
+ }
+ }
- this.config.direction = this.config.direction || 'send';
+ this.config.direction = this.config.direction || 'send';
- this.extraArgs = options.extraArgs;
+ this.extraArgs = options.extraArgs;
- if(_.has(options, 'lastMenuResult.sentFileIds')) {
- this.sentFileIds = options.lastMenuResult.sentFileIds;
- }
+ if(_.has(options, 'lastMenuResult.sentFileIds')) {
+ this.sentFileIds = options.lastMenuResult.sentFileIds;
+ }
- if(_.has(options, 'lastMenuResult.recvFilePaths')) {
- this.recvFilePaths = options.lastMenuResult.recvFilePaths;
- }
+ if(_.has(options, 'lastMenuResult.recvFilePaths')) {
+ this.recvFilePaths = options.lastMenuResult.recvFilePaths;
+ }
- this.fallbackOnly = options.lastMenuResult ? true : false;
+ this.fallbackOnly = options.lastMenuResult ? true : false;
- this.loadAvailProtocols();
+ 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 );
+ 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,
- };
+ 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);
- }
- },
- };
- }
+ 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 };
- }
+ getMenuResult() {
+ if(this.sentFileIds) {
+ return { sentFileIds : this.sentFileIds };
+ }
- if(this.recvFilePaths) {
- return { recvFilePaths : this.recvFilePaths };
- }
- }
+ 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();
- }
- }
+ 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);
- }
+ 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 } );
+ 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
- };
+ 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);
+ 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);
+ protListView.redraw();
- protListView.setItems(self.protocols.map(p => stringFormat(protListFormat, p) ) );
- protListView.setFocusItems(self.protocols.map(p => stringFormat(protListFocusFormat, p) ) );
+ return callback(null);
+ }
+ ],
+ err => {
+ return cb(err);
+ }
+ );
+ });
+ }
- protListView.redraw();
+ 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,
+ };
+ });
- return callback(null);
- }
- ],
- err => {
- return cb(err);
- }
- );
- });
- }
+ // 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 );
+ }
- 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 } );
- }
- });
- }
+ // 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..fdea4e45 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(
+ () => 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 b266a9c9..1d9d6dd7 100644
--- a/core/fse.js
+++ b/core/fse.js
@@ -1,1072 +1,1073 @@
/* 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;
-const { getAddressedToInfo } = require('./mail_util.js');
+// 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;
- }
-
- 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];
- }
-
- // :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' } };
- 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.isPrivateMail()) {
- return callback(null);
- }
-
- // :TODO: shouldn't local from user ID be set for all mail?
- self.message.setLocalFromUserId(self.client.user.userId);
-
- 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);
- }
- );
- }
-
- 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().trim();
-
- if(quoteLines.length > 0) {
- if(this.replyIsAnsi) {
- const bodyMessageView = this.viewControllers.body.getView(1);
- 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);
- }
+ 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.messgae, '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 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(MciViewIds.body.message).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' : _.get(Config(), 'scannerTossers.ftn_bso.packetAnsiMsgEncoding', 'cp437') } };
+ 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);
+ }
+
+ 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 f0936e1d..6751adb8 100644
--- a/core/ftn_address.js
+++ b/core/ftn_address.js
@@ -1,207 +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);
+ }
+ }
+ }
+ }
- static isValidAddress(addr) {
- return addr && addr.isValid();
- }
+ static isValidAddress(addr) {
+ return addr && addr.isValid();
+ }
- isValid() {
- // FTN address is valid if we have at least a net/node
- return _.isNumber(this.net) && _.isNumber(this.node);
- }
+ isValid() {
+ // FTN address is valid if we have at least a net/node
+ return _.isNumber(this.net) && _.isNumber(this.node);
+ }
- isEqual(other) {
- if(_.isString(other)) {
- other = Address.fromString(other);
- }
+ isEqual(other) {
+ if(_.isString(other)) {
+ other = Address.fromString(other);
+ }
- return (
- this.net === other.net &&
- this.node === other.node &&
- this.zone === other.zone &&
- this.point === other.point &&
- this.domain === other.domain
- );
- }
+ return (
+ this.net === other.net &&
+ this.node === other.node &&
+ this.zone === other.zone &&
+ this.point === other.point &&
+ this.domain === other.domain
+ );
+ }
- getMatchAddr(pattern) {
- const m = FTN_PATTERN_REGEXP.exec(pattern);
- if(m) {
- let addr = { };
+ getMatchAddr(pattern) {
+ const m = FTN_PATTERN_REGEXP.exec(pattern);
+ if(m) {
+ let addr = { };
- if(m[1]) {
- addr.zone = m[1].slice(0, -1);
- if('*' !== addr.zone) {
- addr.zone = parseInt(addr.zone);
- }
- } else {
- addr.zone = '*';
- }
+ if(m[1]) {
+ addr.zone = m[1].slice(0, -1);
+ if('*' !== addr.zone) {
+ addr.zone = parseInt(addr.zone);
+ }
+ } else {
+ addr.zone = '*';
+ }
- if(m[2]) {
- addr.net = m[2];
- if('*' !== addr.net) {
- addr.net = parseInt(addr.net);
- }
- } else {
- addr.net = '*';
- }
+ if(m[2]) {
+ addr.net = m[2];
+ if('*' !== addr.net) {
+ addr.net = parseInt(addr.net);
+ }
+ } else {
+ addr.net = '*';
+ }
- if(m[3]) {
- addr.node = m[3].substr(1);
- if('*' !== addr.node) {
- addr.node = parseInt(addr.node);
- }
- } else {
- addr.node = '*';
- }
+ if(m[3]) {
+ addr.node = m[3].substr(1);
+ if('*' !== addr.node) {
+ addr.node = parseInt(addr.node);
+ }
+ } else {
+ addr.node = '*';
+ }
- if(m[4]) {
- addr.point = m[4].substr(1);
- if('*' !== addr.point) {
- addr.point = parseInt(addr.point);
- }
- } else {
- addr.point = '*';
- }
+ if(m[4]) {
+ addr.point = m[4].substr(1);
+ if('*' !== addr.point) {
+ addr.point = parseInt(addr.point);
+ }
+ } else {
+ addr.point = '*';
+ }
- if(m[5]) {
- addr.domain = m[5].substr(1);
- } else {
- addr.domain = '*';
- }
+ if(m[5]) {
+ addr.domain = m[5].substr(1);
+ } else {
+ addr.domain = '*';
+ }
- return addr;
- }
- }
+ return addr;
+ }
+ }
- /*
- 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;
- }
- }
- }
+ /*
+ 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 score;
- }
- */
+ return score;
+ }
+ */
- 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)
- );
- }
+ 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)
+ );
+ }
- return false;
- }
+ return false;
+ }
- 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)),
- };
+ static fromString(addrStr) {
+ const m = FTN_ADDRESS_REGEXP.exec(addrStr);
- // 3D: Addition of zone if present
- if(m[1]) {
- addr.zone = parseInt(m[1].slice(0, -1));
- }
+ if(m) {
+ // start with a 2D
+ let addr = {
+ net : parseInt(m[2]),
+ node : parseInt(m[3].substr(1)),
+ };
- // 4D if optional point is present
- if(m[4]) {
- addr.point = parseInt(m[4].substr(1));
- }
+ // 3D: Addition of zone if present
+ if(m[1]) {
+ addr.zone = parseInt(m[1].slice(0, -1));
+ }
- // 5D with @domain
- if(m[5]) {
- addr.domain = m[5].substr(1);
- }
+ // 4D if optional point is present
+ if(m[4]) {
+ addr.point = parseInt(m[4].substr(1));
+ }
- return new Address(addr);
- }
- }
+ // 5D with @domain
+ if(m[5]) {
+ addr.domain = m[5].substr(1);
+ }
- toString(dimensions) {
- dimensions = dimensions || '5D';
+ return new Address(addr);
+ }
+ }
- let addrStr = `${this.zone}:${this.net}`;
+ toString(dimensions) {
+ dimensions = dimensions || '5D';
- // allow for e.g. '4D' or 5
- const dim = parseInt(dimensions.toString()[0]);
+ let addrStr = `${this.zone}:${this.net}`;
- if(dim >= 3) {
- addrStr += `/${this.node}`;
- }
+ // allow for e.g. '4D' or 5
+ const dim = parseInt(dimensions.toString()[0]);
- // missing & .0 are equiv for point
- if(dim >= 4 && this.point) {
- addrStr += `.${this.point}`;
- }
+ if(dim >= 3) {
+ addrStr += `/${this.node}`;
+ }
- if(5 === dim && this.domain) {
- addrStr += `@${this.domain.toLowerCase()}`;
- }
+ // missing & .0 are equiv for point
+ if(dim >= 4 && this.point) {
+ addrStr += `.${this.point}`;
+ }
- return addrStr;
- }
+ if(5 === dim && this.domain) {
+ addrStr += `@${this.domain.toLowerCase()}`;
+ }
- static getComparator() {
- return function(left, right) {
- let c = (left.zone || 0) - (right.zone || 0);
- if(0 !== c) {
- return c;
- }
+ return addrStr;
+ }
- c = (left.net || 0) - (right.net || 0);
- if(0 !== c) {
- return c;
- }
+ static getComparator() {
+ return function(left, right) {
+ let c = (left.zone || 0) - (right.zone || 0);
+ if(0 !== c) {
+ return c;
+ }
- c = (left.node || 0) - (right.node || 0);
- if(0 !== c) {
- return c;
- }
+ c = (left.net || 0) - (right.net || 0);
+ if(0 !== c) {
+ return c;
+ }
- return (left.domain || '').localeCompare(right.domain || '');
- };
- }
+ 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 d84b4a69..cc0dde3e 100644
--- a/core/ftn_mail_packet.js
+++ b/core/ftn_mail_packet.js
@@ -1,1045 +1,1093 @@
/* 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;
-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 ] );
+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 = new Buffer('SAUCE00');
+// SAUCE magic header + version ("00")
+const FTN_MESSAGE_SAUCE_HEADER = Buffer.from('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.capWord = 0x0001;
+ this.capWordValidate = ((this.capWord & 0xff) << 8) | ((this.capWord >> 8) & 0xff); // swap
- this.prodCodeHi = 0xfe; // see above
- this.prodRevHi = 0;
- }
+ this.prodCodeHi = 0xfe; // see above
+ this.prodRevHi = 0;
+ }
- get origAddress() {
- let addr = new Address({
- node : this.origNode,
- zone : this.origZone,
- });
+ get origAddress() {
+ let addr = new Address({
+ node : this.origNode,
+ zone : this.origZone,
+ });
- if(this.origPoint) {
- addr.point = this.origPoint;
- addr.net = this.auxNet;
- } else {
- addr.net = this.origNet;
- }
+ if(this.origPoint) {
+ addr.point = this.origPoint;
+ addr.net = this.auxNet;
+ } else {
+ addr.net = this.origNet;
+ }
- return addr;
- }
+ return addr;
+ }
- set origAddress(address) {
- if(_.isString(address)) {
- address = Address.fromString(address);
- }
+ set origAddress(address) {
+ if(_.isString(address)) {
+ address = Address.fromString(address);
+ }
- this.origNode = address.node;
+ this.origNode = address.node;
- // 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;
+ // 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.origZone = address.zone;
- this.origZone2 = address.zone;
- this.origPoint = address.point || 0;
- }
+ this.origZone = address.zone;
+ this.origZone2 = address.zone;
+ this.origPoint = address.point || 0;
+ }
- get destAddress() {
- let addr = new Address({
- node : this.destNode,
- net : this.destNet,
- zone : this.destZone,
- });
+ get destAddress() {
+ let addr = new Address({
+ node : this.destNode,
+ net : this.destNet,
+ zone : this.destZone,
+ });
- if(this.destPoint) {
- addr.point = this.destPoint;
- }
+ if(this.destPoint) {
+ addr.point = this.destPoint;
+ }
- return addr;
- }
+ return addr;
+ }
- set destAddress(address) {
- if(_.isString(address)) {
- address = Address.fromString(address);
- }
+ set destAddress(address) {
+ if(_.isString(address)) {
+ address = Address.fromString(address);
+ }
- this.destNode = address.node;
- this.destNet = address.net;
- this.destZone = address.zone;
- this.destZone2 = address.zone;
- this.destPoint = address.point || 0;
- }
+ this.destNode = address.node;
+ this.destNet = address.net;
+ this.destZone = address.zone;
+ this.destZone2 = address.zone;
+ this.destPoint = address.point || 0;
+ }
- 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
- });
- }
+ 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
+ });
+ }
- set created(momentCreated) {
- if(!moment.isMoment(momentCreated)) {
- momentCreated = moment(momentCreated);
- }
+ 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();
- }
+ 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
//
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 = 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')
+ .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);
+ return cb(null, ph);
+ };
- ws.write(buffer);
+ this.getPacketHeaderBuffer = function(packetHeader) {
+ let buffer = Buffer.alloc(FTN_PACKET_HEADER_SIZE);
- return buffer.length;
- };
+ 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);
- 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 : [],
- };
+ 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);
- 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();
- }
+ const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8);
+ pass.copy(buffer, 26);
- //
- // 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.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);
- 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;
+ return buffer;
+ };
- 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);
- }
- });
+ this.writePacketHeader = function(packetHeader, ws) {
+ let buffer = Buffer.alloc(FTN_PACKET_HEADER_SIZE);
- 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');
- });
+ 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);
- //
- // 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;
+ 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);
- 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);
- }
- });
- });
- };
+ const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8);
+ pass.copy(buffer, 26);
- 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,
- ].forEach( propName => {
- if(message.meta.FtnProperty[propName]) {
- message.meta.FtnProperty[propName] = parseInt(message.meta.FtnProperty[propName]) || 0;
- }
- });
- };
+ 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);
- this.getMessageEntryBuffer = function(message, options, cb) {
+ ws.write(buffer);
- 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;
- }
+ return buffer.length;
+ };
- async.waterfall(
- [
- function prepareHeaderAndKludges(callback) {
- const basicHeader = new Buffer(34);
+ 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 : [],
+ };
- // ensure address FtnProperties are numbers
- self.sanatizeFtnProperties(message);
+ 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();
+ }
- 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);
+ //
+ // 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;
+ }
+ }
- const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0');
- dateTimeBuffer.copy(basicHeader, 14);
+ let encoding = 'cp437';
- //
- // 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 } );
+ 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 ] );
- //
- // message: unbound length, NULL term'd
- //
- // We need to build in various special lines - kludges, area,
- // seen-by, etc.
- //
- let msgBody = '';
+ let chrsPrefixIndex = messageBodyBuffer.indexOf(FTN_CHRS_PREFIX);
+ if(chrsPrefixIndex < 0) {
+ return callback(null);
+ }
- //
- // 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
+ chrsPrefixIndex += FTN_CHRS_PREFIX.length;
- case 'Via' :
- case 'FMPT' :
- case 'TOPT' :
- case 'INTL' :
- msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); // no sepChar
- break;
+ const chrsEndIndex = messageBodyBuffer.indexOf(FTN_CHRS_SUFFIX, chrsPrefixIndex);
+ if(chrsEndIndex < 0) {
+ return callback(null);
+ }
- default :
- msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]);
- break;
- }
- });
+ let chrsContent = messageBodyBuffer.slice(chrsPrefixIndex, chrsEndIndex);
+ if(0 === chrsContent.length) {
+ return callback(null);
+ }
- 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);
- }
+ 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');
+ }
- 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';
+ const messageLines = strUtil.splitTextAtTerms(decoded.replace(/\xec/g, ''));
+ let endOfMessage = false;
- //
- // 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`;
- }
+ messageLines.forEach(line => {
+ if(0 === line.length) {
+ messageBodyData.message.push('');
+ return;
+ }
- //
- // Origin line should be near the bottom of a message
- //
- if(message.meta.FtnProperty.ftn_origin) {
- msgBody += `${message.meta.FtnProperty.ftn_origin}\r`;
- }
+ 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);
+ }
+ });
- //
- // 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);
- }
- );
- };
+ return callback(null);
+ }
+ ],
+ () => {
+ messageBodyData.message = messageBodyData.message.join('\n');
+ return cb(messageBodyData);
+ }
+ );
+ };
- 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);
+ 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
+ }
- const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0');
- dateTimeBuffer.copy(basicHeader, 14);
+ let msgData;
+ try {
+ msgData = 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')
+ // :TODO: use string() for these if https://github.com/keichi/binary-parser/issues/33 is resolved
+ .array('modDateTime', {
+ type : 'uint8',
+ readUntil : b => 0x00 === b,
+ })
+ .array('toUserName', {
+ type : 'uint8',
+ 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,
+ })
+ .parse(packetBuffer);
+ } catch(e) {
+ return cb(Errors.Invalid(`Failed to parse FTN message header: ${e.message}`));
+ }
- ws.write(basicHeader);
+ if(FTN_PACKET_MESSAGE_TYPE != msgData.messageType) {
+ return cb(Errors.Invalid(`Unsupported FTN message type: ${msgData.messageType}`));
+ }
- // 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);
+ //
+ // Convert null terminated arrays to strings
+ //
+ [ 'modDateTime', 'toUserName', 'fromUserName', 'subject' ].forEach(k => {
+ msgData[k] = strUtil.stringFromNullTermBuffer(msgData[k], 'CP437');
+ });
- // 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);
+ // Technically the following fields have length limits as per fts-0001.016:
+ // * modDateTime : 20 bytes
+ // * toUserName : 36 bytes
+ // * fromUserName : 36 bytes
+ // * subject : 72 bytes
- //
- // 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 = '';
+ //
+ // 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),
+ });
- function appendMeta(k, m, sepChar=':') {
- if(m) {
- let a = m;
- if(!_.isArray(a)) {
- a = [ a ];
- }
- a.forEach(v => {
- msgBody += `${k}${sepChar} ${v}\r`;
- });
- }
- }
+ // :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-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
+ ftn_attr_flags : msgData.ftn_attr_flags,
+ ftn_cost : msgData.ftn_cost,
- case 'Via' :
- case 'FMPT' :
- case 'TOPT' :
- case 'INTL' : appendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); break; // no sepChar
+ 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,
+ };
- default : appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); break;
- }
- });
+ self.processMessageBody(msgData.message, messageBodyData => {
+ msg.message = messageBodyData.message;
+ msg.meta.FtnKludge = messageBodyData.kludgeLines;
- msgBody += message.message + '\r';
+ if(messageBodyData.tearLine) {
+ msg.meta.FtnProperty.ftn_tear_line = messageBodyData.tearLine;
- //
- // 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`;
- }
+ if(self.options.keepTearAndOrigin) {
+ msg.message += `\r\n${messageBodyData.tearLine}\r\n`;
+ }
+ }
- //
- // 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)
+ if(messageBodyData.seenBy.length > 0) {
+ msg.meta.FtnProperty.ftn_seen_by = messageBodyData.seenBy;
+ }
- appendMeta('\x01PATH', message.meta.FtnKludge['PATH']);
+ if(messageBodyData.area) {
+ msg.meta.FtnProperty.ftn_area = messageBodyData.area;
+ }
- //
- // :TODO: We should encode based on config and add the proper kludge here!
- ws.write(iconv.encode(msgBody + '\0', options.encoding));
- };
+ if(messageBodyData.originLine) {
+ msg.meta.FtnProperty.ftn_origin = messageBodyData.originLine;
- 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
- );
- };
+ 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 39093fab..e4637554 100644
--- a/core/ftn_util.js
+++ b/core/ftn_util.js
@@ -1,428 +1,424 @@
/* 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.getIntl = getIntl;
-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
//
-// 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.
+// 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)}`
- ;
+ 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
+// 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')}`;
+ //
+ // 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();
}
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' : '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];
}
\ 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
index 3a889468..9d875b2e 100644
--- a/core/last_callers.js
+++ b/core/last_callers.js
@@ -1,151 +1,223 @@
/* jslint node: true */
'use strict';
-// ENiGMA½
-const MenuModule = require('./menu_module.js').MenuModule;
-const ViewController = require('./view_controller.js').ViewController;
-const StatLog = require('./stat_log.js');
-const User = require('./user.js');
-const stringFormat = require('./string_format.js');
+// 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');
-
-/*
- Available listFormat object members:
- userId
- userName
- location
- affiliation
- ts
-
-*/
+// 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'
+ name : 'Last Callers',
+ desc : 'Last callers to the system',
+ author : 'NuSkooler',
+ packageName : 'codes.l33t.enigma.lastcallers'
};
-const MciCodeIds = {
- CallerList : 1,
+const MciViewIds = {
+ callerList : 1,
};
exports.getModule = class LastCallersModule extends MenuModule {
- constructor(options) {
- super(options);
- }
+ constructor(options) {
+ super(options);
- mciReady(mciData, cb) {
- super.mciReady(mciData, err => {
- if(err) {
- return cb(err);
- }
+ this.actionIndicators = _.get(options, 'menuConfig.config.actionIndicators', {});
+ this.actionIndicatorDefault = _.get(options, 'menuConfig.config.actionIndicatorDefault', '-');
+ }
- const self = this;
- const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
+ mciReady(mciData, cb) {
+ super.mciReady(mciData, err => {
+ if(err) {
+ return cb(err);
+ }
- let loginHistory;
- let callersView;
+ 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);
+ }
+ );
+ });
+ }
- async.series(
- [
- function loadFromConfig(callback) {
- const loadOpts = {
- callingMenu : self,
- mciMap : mciData.menu,
- noInput : true,
- };
+ 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]);
+ }
+ }
- vc.loadFromMenuConfig(loadOpts, callback);
- },
- function fetchHistory(callback) {
- callersView = vc.getView(MciCodeIds.CallerList);
+ fetchHistory(cb) {
+ const callersView = this.viewControllers.callers.getView(MciViewIds.callerList);
+ if(!callersView || 0 === callersView.dimens.height) {
+ return cb(null);
+ }
- // fetch up
- StatLog.getSystemLogEntries('user_login_history', StatLog.Order.TimestampDesc, 200, (err, lh) => {
- loginHistory = lh;
+ 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);
+ }
- if(self.menuConfig.config.hideSysOpLogin) {
- const noOpLoginHistory = loginHistory.filter(lh => {
- return false === User.isRootUserId(parseInt(lh.log_value)); // log_value=userId
- });
+ const dateTimeFormat = _.get(
+ this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateFormat('short'));
- //
- // 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' ]
- };
+ 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
+ }
- const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD';
+ item.timestamp = moment(item.timestamp);
- async.each(
- loginHistory,
- (item, next) => {
- item.userId = parseInt(item.log_value);
- item.ts = moment(item.timestamp).format(dateTimeFormat);
+ return Object.assign(
+ 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';
+ const hideSysOp = _.get(this, 'menuConfig.config.sysop.hide');
+ const sysOpCollapse = this.getCollapse('menuConfig.config.sysop.collapse');
- 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}';
+ 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);
- callersView.setItems(_.map(loginHistory, ce => stringFormat(listFormat, ce) ) );
+ lastUserId = item.userId;
+ lastTimestamp = item.timestamp;
- callersView.redraw();
- return callback(null);
- }
- ],
- (err) => {
- if(err) {
- self.client.log.error( { error : err.toString() }, 'Error loading last callers');
- }
- cb(err);
- }
- );
- });
- }
+ 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..aa573fa1 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' ], (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 f90aec41..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|passwordConfirm|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..041f317c 100644
--- a/core/login_server_module.js
+++ b/core/login_server_module.js
@@ -1,87 +1,93 @@
/* 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) {
+ //
+ // Start tracking the client. A session ID aka client ID
+ // will be established in addNewClient() below.
+ //
+ if(_.isUndefined(client.session)) {
+ client.session = {};
+ }
- client.on('ready', readyOptions => {
+ client.session.serverName = modInfo.name;
+ client.session.isSecure = _.isBoolean(client.isSecure) ? client.isSecure : (modInfo.isSecure || false);
- client.startIdleMonitor();
+ clientConns.addNewClient(client, clientSock);
- // Go to module -- use default error handler
- this.prepareClient(client, () => {
- require('./connect.js').connectEntry(client, readyOptions.firstMenu);
- });
- });
+ client.on('ready', readyOptions => {
- client.on('end', () => {
- clientConns.removeClient(client);
- });
+ client.startIdleMonitor();
- client.on('error', err => {
- logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message);
- });
+ // Go to module -- use default error handler
+ this.prepareClient(client, () => {
+ require('./connect.js').connectEntry(client, readyOptions.firstMenu);
+ });
+ });
- client.on('close', err => {
- const logFunc = err ? logger.log.info : logger.log.debug;
- logFunc( { clientId : client.session.id }, 'Connection closed');
-
- clientConns.removeClient(client);
- });
+ client.on('end', () => {
+ clientConns.removeClient(client);
+ });
- client.on('idle timeout', () => {
- client.log.info('User idle timeout expired');
+ client.on('error', err => {
+ logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message);
+ });
- client.menuStack.goto('idleLogoff', err => {
- if(err) {
- // likely just doesn't exist
- client.term.write('\nIdle timeout expired. Goodbye!\n');
- client.end();
- }
- });
- });
- }
+ client.on('close', err => {
+ const logFunc = err ? logger.log.info : logger.log.debug;
+ logFunc( { clientId : client.session.id }, '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
index 654b1617..6bd433d3 100644
--- a/core/mail_util.js
+++ b/core/mail_util.js
@@ -1,81 +1,81 @@
/* jslint node: true */
'use strict';
-const Address = require('./ftn_address.js');
-const Message = require('./message.js');
+const Address = require('./ftn_address.js');
+const Message = require('./message.js');
-exports.getAddressedToInfo = getAddressedToInfo;
+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,}))$/;
+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' }
+ 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();
+ input = input.trim();
- const firstAtPos = input.indexOf('@');
+ const firstAtPos = input.indexOf('@');
- if(firstAtPos < 0) {
- let addr = Address.fromString(input);
- if(Address.isValidAddress(addr)) {
- return { flavor : Message.AddressFlavor.FTN, remote : input };
- }
+ 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 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 };
- }
+ 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() };
- }
+ 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 };
- }
+ 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 };
- }
+ 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 };
- }
+ 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 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() } ;
- }
+ 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() };
- }
+ 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 };
+ 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/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..06c0f6d7 100644
--- a/core/menu_module.js
+++ b/core/menu_module.js
@@ -1,426 +1,633 @@
/* 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');
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(
+ () => 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);
+ }
+
+ 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(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, 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.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;
+ }
+ }
+
+ 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 b4bebea6..080d0efa 100644
--- a/core/menu_stack.js
+++ b/core/menu_stack.js
@@ -1,179 +1,211 @@
/* 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');
-// 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 = this.client.acs.getConditionalValue(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');
- const menuFlags = (options && Array.isArray(options.menuFlags)) ? options.menuFlags : modInst.menuConfig.options.menuFlags;
+ if(!this.client.acs.hasMenuModuleAccess(modInst)) {
+ if(cb) {
+ return cb(Errors.AccessDenied('No access to this menu'));
+ }
+ return;
+ }
- if(currentModuleInfo) {
- // save stack state
- currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState();
+ //
+ // 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;
+ }
- currentModuleInfo.instance.leave();
+ //
+ // 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(currentModuleInfo.menuFlags.includes('noHistory')) {
- this.pop();
- }
+ // in code we can ask to merge in
+ if(Array.isArray(options.menuFlags) && options.menuFlags.includes('mergeFlags')) {
+ menuFlags = _.uniq(menuFlags.concat(options.menuFlags));
+ }
+ }
- if(menuFlags.includes('popParent')) {
- this.pop().instance.leave(); // leave & remove current
- }
- }
+ if(currentModuleInfo) {
+ // save stack state
+ currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState();
- self.push({
- name : name,
- instance : modInst,
- extraArgs : loadOpts.extraArgs,
- menuFlags : menuFlags,
- });
+ currentModuleInfo.instance.leave();
- // restore previous state if requested
- if(options && options.savedState) {
- modInst.restoreSavedState(options.savedState);
- }
+ if(currentModuleInfo.menuFlags.includes('noHistory')) {
+ this.pop();
+ }
- 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(menuFlags.includes('popParent')) {
+ this.pop().instance.leave(); // leave & remove current
+ }
+ }
- self.client.log.trace( { stack : stackEntries }, 'Updated menu stack' );
+ self.push({
+ name : name,
+ instance : modInst,
+ extraArgs : loadOpts.extraArgs,
+ menuFlags : menuFlags,
+ });
- modInst.enter();
+ // restore previous state if requested
+ if(options.savedState) {
+ modInst.restoreSavedState(options.savedState);
+ }
- if(cb) {
- cb(null);
- }
- }
- });
- }
+ 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..c05f90d9 100644
--- a/core/menu_util.js
+++ b/core/menu_util.js
@@ -1,265 +1,256 @@
/* 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.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 actionAsset = asset.parseAsset(conf.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 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 = client.acs.getConditionalValue(nextSpec, 'next'); // handle any conditionals
- // :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 );
- }
+ const nextAsset = asset.getAssetWithShorthand(nextSpec, 'menu');
+ // :TODO: getAssetWithShorthand() can return undefined - handle it!
- const err = new Error('Method does not exist');
- client.log.warn( { method : nextAsset.asset }, err.message);
- return cb(err);
- }
+ conf = conf || {};
+ const extraArgs = conf.extraArgs || {};
- case 'menu' :
- return client.currentMenuModule.gotoMenu(nextAsset.asset, { extraArgs : extraArgs }, cb );
- }
+ // :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 );
+ }
- const err = new Error('Invalid asset type for "next"');
- client.log.error( { nextSpec : nextSpec }, err.message);
- return cb(err);
+ const err = Errors.DoesNotExist('Method does not exist');
+ client.log.warn( { method : nextAsset.asset }, 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 5d3c8db9..7a30fb02 100644
--- a/core/message.js
+++ b/core/message.js
@@ -1,652 +1,906 @@
/* 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 {
+ isAnsi, isFormattedLine,
+ splitTextAtTerms,
+ renderSubstr
+} = require('./string_util.js');
-const ansiPrep = require('./ansi_prep.js');
+const ansiPrep = require('./ansi_prep.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;
+// 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;
-module.exports = Message;
+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);
- };
-
- this.isFromRemoteUser = function() {
- return null !== _.get(this, 'meta.System.remote_from_user', null);
- };
-}
-
-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.
- 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
+// Types for Message.SystemMetaNames.ExternalFlavor meta
+const ADDRESS_FLAVOR = {
+ Local : 'local', // local / non-remote addressing
+ FTN : 'ftn', // FTN style
+ Email : 'email',
};
-// Types for Message.SystemMetaNames.ExternalFlavor meta
-Message.AddressFlavor = {
- Local : 'local', // local / non-remote addressing
- FTN : 'ftn', // FTN style
- Email : 'email',
+const STATE_FLAGS0 = {
+ None : 0x00000000,
+ Imported : 0x00000001, // imported from foreign system
+ Exported : 0x00000002, // exported to foreign system
};
-Message.StateFlags0 = {
- None : 0x00000000,
- Imported : 0x00000001, // imported from foreign system
- Exported : 0x00000002, // exported to foreign system
+// :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',
+
+ // 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
};
-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',
-
- 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
+// :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'
};
-// Note: kludges are stored with their names as-is
+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.uuid = uuid;
+ this.replyToMsgId = replyToMsgId;
+ this.toUserName = toUserName;
+ this.fromUserName = fromUserName;
+ this.subject = subject;
+ this.message = message;
-Message.prototype.setLocalToUserId = function(userId) {
- this.meta.System = this.meta.System || {};
- this.meta.System[Message.SystemMetaNames.LocalToUserID] = userId;
-};
-
-Message.prototype.setLocalFromUserId = function(userId) {
- this.meta.System = this.meta.System || {};
- this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId;
-};
-
-Message.prototype.setRemoteToUser = function(remoteTo) {
- this.meta.System = this.meta.System || {};
- this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo;
-};
-
-Message.prototype.setExternalFlavor = function(flavor) {
- this.meta.System = this.meta.System || {};
- this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor;
-};
-
-Message.createMessageUUID = function(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 ] )));
-};
-
-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, 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 ];
- }
-
- let self = this;
-
- async.each(value, (v, next) => {
- metaStmt.run(self.messageId, category, name, v, err => {
- next(err);
- });
- }, err => {
- cb(err);
- });
-};
-
-Message.prototype.persist = function(cb) {
-
- if(!this.isValid()) {
- return cb(new Error('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.uuid) {
- self.uuid = 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.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, 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 => {
- nextName(err);
- });
- }, err => {
- nextCat(err);
- });
-
- }, err => {
- 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);
- }
- }
- );
-};
-
-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;
+ }
+
+ 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);
+ }
+
+ /*
+ :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;
+ }
+
+ 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
+ filter.fromUserName
+ filter.replyToMessageId
+
+ 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
+ - any other areaTag or confTag filters will be ignored
+ - if NOT present, private areas are skipped
+
+ *=NYI
+ */
+ static findMessages(filter, cb) {
+ filter = filter || {};
+
+ filter.resultType = filter.resultType || 'id';
+ filter.extraFields = filter.extraFields || [];
+
+ 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) {
+ if(sqlWhere) {
+ sqlWhere += ' AND ';
+ } 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)) {
+ 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 if(_.isString(filter.areaTag) && Message.WellKnownAreaTags.Private !== filter.areaTag) {
+ appendWhereClause(`m.area_tag = "${filter.areaTag}"`);
+ }
+ }
+
+ // explicit exclude of Private
+ appendWhereClause(`m.area_tag != "${Message.WellKnownAreaTags.Private}"`);
+ }
+
+ if(_.isNumber(filter.replyToMessageId)) {
+ appendWhereClause(`m.reply_to_message_id=${filter.replyToMessageId}`);
+ }
+
+ [ 'toUserName', 'fromUserName' ].forEach(field => {
+ if(_.isString(filter[field]) && filter[field].length > 0) {
+ appendWhereClause(`m.${_.snakeCase(field)} LIKE "${sanitizeString(filter[field])}"`);
+ }
+ });
+
+ 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)}"
+ )`
+ );
+ }
+
+ 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.uuid) {
+ self.uuid = 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.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, 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(/\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);
+ }
+ }
};
diff --git a/core/message_area.js b/core/message_area.js
index 53dd3086..7f4993ec 100644
--- a/core/message_area.js
+++ b/core/message_area.js
@@ -1,744 +1,667 @@
/* 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;
-const { getISOTimestampString } = require('./database.js');
+// 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');
-const moment = require('moment');
+// 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.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.getMessageIdNewerThanTimestampByArea = getMessageIdNewerThanTimestampByArea;
-exports.getMessageAreaLastReadId = getMessageAreaLastReadId;
-exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId;
-exports.persistMessage = persistMessage;
-exports.trimMessageAreasScheduledEvent = trimMessageAreasScheduledEvent;
+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.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 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) => {
+ return (true === disableAcsCheck || client.acs.hasMessageAreaRead(area));
+ });
+
+ return defaultArea;
+ }
}
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 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 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;
+ }
}
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);
-}
-
-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 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';
-
- let sql =
- `SELECT ${selectWhat}
- FROM message
- WHERE area_tag = "${areaTag}" AND message_id > ${lastMessageId}`;
-
- 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}
- )`;
- }
-
- if('count' === what) {
- sql += ';';
- } else {
- sql += ' ORDER BY message_id;';
- }
-
- return sql;
+ changeMessageAreaWithOptions(client, areaTag, { persist : true }, cb);
}
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(Message.isPrivateAreaTag(areaTag)) {
+ filter.privateTagUserId = client.user.userId;
+ }
- assert(_.isObject(options.client));
-
- /*
- [
- {
- messageId, messageUuid, replyToId, toUserName, fromUserName, subject, modTimestamp,
- status(new|old),
- viewCount
- }
- ]
- */
-
- 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);
- }
- );
+ return Message.findMessages(filter, cb);
}
function getMessageIdNewerThanTimestampByArea(areaTag, newerThanTimestamp, cb) {
- if(moment.isMoment(newerThanTimestamp)) {
- newerThanTimestamp = getISOTimestampString(newerThanTimestamp);
- }
-
- msgDb.get(
- `SELECT message_id
- FROM message
- WHERE area_tag = ? AND DATETIME(modified_timestamp) > DATETIME("${newerThanTimestamp}", "+1 seconds")
- ORDER BY modified_timestamp ASC
- LIMIT 1;`,
- [ areaTag ],
- (err, row) => {
- if(err) {
- return cb(err);
- }
-
- return cb(null, row ? row.message_id : null);
- }
- );
+ 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, allowOlder, cb) {
- if(!cb && _.isFunction(allowOlder)) {
- cb = allowOlder;
- allowOlder = false;
- }
+ 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);
- }
- );
+ // :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() ],
- 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);
- }
- );
- }
+ 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 ],
- 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);
- }
- );
- }
+ function trimMessageAreaByMaxAgeDays(areaInfo, cb) {
+ if(0 === areaInfo.maxAgeDays) {
+ return cb(null);
+ }
- async.waterfall(
- [
- function getAreaTags(callback) {
- const areaTags = [];
+ 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);
+ }
+ );
+ }
- //
- // 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);
- }
+ async.waterfall(
+ [
+ function getAreaTags(callback) {
+ const areaTags = [];
- // 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 = [];
+ //
+ // 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);
+ }
- // determine maxMessages & maxAgeDays per area
- areaTags.forEach(areaTag => {
+ // 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 = [];
- let maxMessages = Config.messageAreaDefaults.maxMessages;
- let maxAgeDays = Config.messageAreaDefaults.maxAgeDays;
+ // determine maxMessages & maxAgeDays per area
+ const config = Config();
+ areaTags.forEach(areaTag => {
- const area = getMessageAreaByTag(areaTag); // note: we don't know the conf here
- if(area) {
- maxMessages = area.maxMessages || maxMessages;
- maxAgeDays = area.maxAgeDays || maxAgeDays;
- }
+ let maxMessages = config.messageAreaDefaults.maxMessages;
+ let maxAgeDays = config.messageAreaDefaults.maxAgeDays;
- areaInfos.push( {
- areaTag : areaTag,
- maxMessages : maxMessages,
- maxAgeDays : maxAgeDays,
- } );
- });
+ const area = getMessageAreaByTag(areaTag); // note: we don't know the conf here
+ if(area) {
+ maxMessages = area.maxMessages || maxMessages;
+ maxAgeDays = area.maxAgeDays || maxAgeDays;
+ }
- return callback(null, areaInfos);
- },
- function trimGeneralAreas(areaInfos, callback) {
- async.each(
- areaInfos,
- (areaInfo, next) => {
- trimMessageAreaByMaxMessages(areaInfo, err => {
- if(err) {
- return next(err);
- }
+ areaInfos.push( {
+ areaTag : areaTag,
+ maxMessages : maxMessages,
+ maxAgeDays : maxAgeDays,
+ } );
+ });
- 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
- );
+ return callback(null, areaInfos);
+ },
+ function trimGeneralAreas(areaInfos, callback) {
+ async.each(
+ areaInfos,
+ (areaInfo, next) => {
+ trimMessageAreaByMaxMessages(areaInfo, err => {
+ if(err) {
+ return next(err);
+ }
- 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');
- }
- }
- );
+ 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
+ );
- return callback(null);
- }
- ],
- err => {
- return cb(err);
- }
- );
+ 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_search.js b/core/message_base_search.js
new file mode 100644
index 00000000..9684b8f0
--- /dev/null
+++ b/core/message_base_search.js
@@ -0,0 +1,148 @@
+/* jslint node: true */
+'use strict';
+
+// ENiGMA½
+const MenuModule = require('./menu_module.js').MenuModule;
+const {
+ getSortedAvailMessageConferences,
+ getAvailableMessageAreasByConfTag,
+ getSortedAvailMessageAreasByConfTag,
+} = 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
+ };
+
+ 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) {
+ filter.areaTag = value.areaTag; // specific conf + area
+ }
+ }
+
+ Message.findMessages(filter, (err, messageList) => {
+ if(err) {
+ return cb(err);
+ }
+
+ if(0 === messageList.length) {
+ return this.gotoMenu(
+ this.menuConfig.config.noResultsMenu || 'messageSearchNoResults',
+ { menuFlags : [ 'popParent' ] },
+ cb
+ );
+ }
+
+ 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 1e2bd32c..857b967c 100644
--- a/core/mime_util.js
+++ b/core/mime_util.js
@@ -1,41 +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 = {
- 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; // alreaed 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..f61929d2 100644
--- a/core/module_util.js
+++ b/core/module_util.js
@@ -1,109 +1,173 @@
/* 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.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/msg_area_list.js b/core/msg_area_list.js
index eaedbef8..1d47f76c 100644
--- a/core/msg_area_list.js
+++ b/core/msg_area_list.js
@@ -1,177 +1,127 @@
/* 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 displayThemeArt = require('./theme.js').displayThemeArt;
-const resetScreen = require('./ansi_term.js').resetScreen;
-const stringFormat = require('./string_format.js');
+// 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');
+// deps
+const async = require('async');
+const _ = require('lodash');
exports.moduleInfo = {
- name : 'Message Area List',
- desc : 'Module for listing / choosing message areas',
- author : 'NuSkooler',
+ 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
-*/
+// :TODO: Obv/2 others can show # of messages in area
const MciViewIds = {
- AreaList : 1,
- SelAreaInfo1 : 2,
- SelAreaInfo2 : 3,
+ areaList : 1,
+ areaDesc : 2, // area desc updated @ index update
+ customRangeStart : 10, // updated @ index update
};
exports.getModule = class MessageAreaListModule extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag(
- this.client.user.properties.message_conf_tag,
- { client : this.client }
- );
+ this.initList();
- 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
+ this.menuMethods = {
+ changeArea : (formData, extraArgs, cb) => {
+ if(1 === formData.submitId) {
+ const area = this.messageAreas[formData.value.area];
- messageArea.changeMessageArea(self.client, areaTag, err => {
- if(err) {
- self.client.term.pipeWrite(`\n|00Cannot change area: ${err.message}\n`);
+ 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);
+ }
- self.prevMenuOnTimeout(1000, cb);
- } else {
- if(_.isString(area.art)) {
- const dispOptions = {
- client : self.client,
- name : area.art,
- };
+ if(area.hasArt) {
+ const menuOpts = {
+ extraArgs : {
+ areaTag : area.areaTag,
+ },
+ menuFlags : [ 'popParent', 'noHistory' ]
+ };
- self.client.term.rawWrite(resetScreen());
+ return this.gotoMenu(this.menuConfig.config.changeAreaPreArtMenu || 'changeMessageAreaPreArt', menuOpts, cb);
+ }
- 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);
- }
- }
- };
- }
+ return this.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);
+ }
- updateGeneralAreaInfoViews(areaIndex) {
- // :TODO: these concepts have been replaced with the {someKey} style formatting - update me!
- /* experimental: not yet avail
- const areaInfo = self.messageAreas[areaIndex];
+ 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}`));
+ }
- [ MciViewIds.SelAreaInfo1, MciViewIds.SelAreaInfo2 ].forEach(mciId => {
- const v = self.viewControllers.areaList.getView(mciId);
- if(v) {
- v.setFormatObject(areaInfo.area);
- }
- });
- */
- }
+ areaListView.on('index update', idx => {
+ this.selectionIndexUpdate(idx);
+ });
- mciReady(mciData, cb) {
- super.mciReady(mciData, err => {
- if(err) {
- return cb(err);
- }
+ 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);
+ }
+ );
+ });
+ }
- const self = this;
- const vc = self.viewControllers.areaList = new ViewController( { client : self.client } );
+ selectionIndexUpdate(idx) {
+ const area = this.messageAreas[idx];
+ if(!area) {
+ return;
+ }
+ this.setViewText('areaList', MciViewIds.areaDesc, area.desc);
+ this.updateCustomViewTextsWithFilter('areaList', MciViewIds.customRangeStart, area);
+ }
- 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);
- }
- );
- });
- }
+ 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
index c13f39a6..613cee04 100644
--- a/core/msg_area_post_fse.js
+++ b/core/msg_area_post_fse.js
@@ -1,67 +1,70 @@
/* jslint node: true */
'use strict';
-const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
-const persistMessage = require('./message_area.js').persistMessage;
+const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
+const persistMessage = require('./message_area.js').persistMessage;
+const UserProps = require('./user_property.js');
-const _ = require('lodash');
-const async = require('async');
+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',
+ 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);
+ constructor(options) {
+ super(options);
- const self = this;
+ const self = this;
- // we're posting, so always start with 'edit' mode
- this.editorMode = 'edit';
+ // we're posting, so always start with 'edit' mode
+ this.editorMode = 'edit';
- this.menuMethods.editModeMenuSave = function(formData, extraArgs, cb) {
+ 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);
- }
- );
- };
- }
+ 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.uuid },
+ 'Message persisted'
+ );
+ }
- enter() {
- if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) {
- this.messageAreaTag = this.client.user.properties.message_area_tag;
- }
+ return self.nextMenu(cb);
+ }
+ );
+ };
+ }
- super.enter();
- }
+ enter() {
+ if(_.isString(this.client.user.properties[UserProps.MessageAreaTag]) &&
+ !_.isString(this.messageAreaTag))
+ {
+ this.messageAreaTag = this.client.user.properties[UserProps.MessageAreaTag];
+ }
+
+ super.enter();
+ }
};
\ No newline at end of file
diff --git a/core/msg_area_reply_fse.js b/core/msg_area_reply_fse.js
index 24ee5377..11742865 100644
--- a/core/msg_area_reply_fse.js
+++ b/core/msg_area_reply_fse.js
@@ -1,18 +1,18 @@
/* jslint node: true */
'use strict';
-var FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
+var FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
-exports.getModule = AreaReplyFSEModule;
+exports.getModule = AreaReplyFSEModule;
exports.moduleInfo = {
- name : 'Message Area Reply',
- desc : 'Module for replying to an area message',
- author : 'NuSkooler',
+ name : 'Message Area Reply',
+ desc : 'Module for replying to an area message',
+ author : 'NuSkooler',
};
function AreaReplyFSEModule(options) {
- FullScreenEditorModule.call(this, 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
index 02915f79..1ca5617c 100644
--- a/core/msg_area_view_fse.js
+++ b/core/msg_area_view_fse.js
@@ -1,135 +1,145 @@
/* jslint node: true */
'use strict';
-// ENiGMA½
-const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
-const Message = require('./message.js');
+// ENiGMA½
+const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
+const Message = require('./message.js');
-// deps
-const _ = require('lodash');
+// deps
+const _ = require('lodash');
exports.moduleInfo = {
- name : 'Message Area View',
- desc : 'Module for viewing an area message',
- author : 'NuSkooler',
+ name : 'Message Area View',
+ desc : 'Module for viewing an area message',
+ author : 'NuSkooler',
};
exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- this.editorType = 'area';
- this.editorMode = 'view';
+ 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;
- }
+ 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;
+ this.messageList = this.messageList || [];
+ this.messageIndex = this.messageIndex || 0;
+ this.messageTotal = this.messageList.length;
- const self = this;
+ if(this.messageList.length > 0) {
+ this.messageAreaTag = this.messageList[this.messageIndex].areaTag;
+ }
- // assign *additional* menuMethods
- Object.assign(this.menuMethods, {
- nextMessage : (formData, extraArgs, cb) => {
- if(self.messageIndex + 1 < self.messageList.length) {
- self.messageIndex++;
+ const self = this;
- return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
- }
+ // assign *additional* menuMethods
+ Object.assign(this.menuMethods, {
+ nextMessage : (formData, extraArgs, cb) => {
+ if(self.messageIndex + 1 < self.messageList.length) {
+ self.messageIndex++;
- // auto-exit if no more to go?
- if(self.lastMessageNextExit) {
- self.lastMessageReached = true;
- return self.prevMenu(cb);
- }
+ 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 cb(null);
- },
+ return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
+ }
- prevMessage : (formData, extraArgs, cb) => {
- if(self.messageIndex > 0) {
- self.messageIndex--;
+ // auto-exit if no more to go?
+ if(self.lastMessageNextExit) {
+ self.lastMessageReached = true;
+ return self.prevMenu(cb);
+ }
- return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
- }
+ return cb(null);
+ },
- return cb(null);
- },
+ prevMessage : (formData, extraArgs, cb) => {
+ if(self.messageIndex > 0) {
+ self.messageIndex--;
- movementKeyPressed : (formData, extraArgs, cb) => {
- const bodyView = self.viewControllers.body.getView(1); // :TODO: use const here vs magic #
+ 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
- // :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;
- }
+ return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
+ }
- // :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);
+ },
- return cb(null);
- },
+ movementKeyPressed : (formData, extraArgs, cb) => {
+ const bodyView = self.viewControllers.body.getView(1); // :TODO: use const here vs magic #
- replyMessage : (formData, extraArgs, cb) => {
- if(_.isString(extraArgs.menu)) {
- const modOpts = {
- extraArgs : {
- messageAreaTag : self.messageAreaTag,
- replyToMessage : self.message,
- }
- };
+ // :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;
+ }
- return self.gotoMenu(extraArgs.menu, modOpts, cb);
- }
-
- self.client.log(extraArgs, 'Missing extraArgs.menu');
- return cb(null);
- }
- });
- }
+ // :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);
+ loadMessageByUuid(uuid, cb) {
+ const msg = new Message();
+ msg.load( { uuid : uuid, user : this.client.user }, () => {
+ this.setMessage(msg);
- if(cb) {
- return cb(null);
- }
- });
- }
+ if(cb) {
+ return cb(null);
+ }
+ });
+ }
- finishedLoading() {
- this.loadMessageByUuid(this.messageList[this.messageIndex].messageUuid);
- }
+ finishedLoading() {
+ this.loadMessageByUuid(this.messageList[this.messageIndex].messageUuid);
+ }
- getSaveState() {
- return {
- messageList : this.messageList,
- messageIndex : this.messageIndex,
- messageTotal : this.messageList.length,
- };
- }
+ 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;
- }
+ restoreSavedState(savedState) {
+ this.messageList = savedState.messageList;
+ this.messageIndex = savedState.messageIndex;
+ this.messageTotal = savedState.messageTotal;
+ }
- getMenuResult() {
- return {
- messageIndex : this.messageIndex,
- lastMessageReached : this.lastMessageReached,
- };
- }
+ getMenuResult() {
+ return {
+ messageIndex : this.messageIndex,
+ lastMessageReached : this.lastMessageReached,
+ };
+ }
};
diff --git a/core/msg_conf_list.js b/core/msg_conf_list.js
index 6f42cf36..7ba75376 100644
--- a/core/msg_conf_list.js
+++ b/core/msg_conf_list.js
@@ -1,148 +1,122 @@
/* 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 displayThemeArt = require('./theme.js').displayThemeArt;
-const resetScreen = require('./ansi_term.js').resetScreen;
-const stringFormat = require('./string_format.js');
+// 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');
+// deps
+const async = require('async');
+const _ = require('lodash');
exports.moduleInfo = {
- name : 'Message Conference List',
- desc : 'Module for listing / choosing message conferences',
- author : 'NuSkooler',
+ 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, ...
- //
+ confList : 1,
+ confDesc : 2, // description updated @ index update
+ customRangeStart : 10, // updated @ index update
};
exports.getModule = class MessageConfListModule extends MenuModule {
- constructor(options) {
- super(options);
+ 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
+ this.initList();
- messageArea.changeMessageConference(self.client, confTag, err => {
- if(err) {
- self.client.term.pipeWrite(`\n|00Cannot change conference: ${err.message}\n`);
+ this.menuMethods = {
+ changeConference : (formData, extraArgs, cb) => {
+ if(1 === formData.submitId) {
+ const conf = this.messageConfs[formData.value.conf];
- setTimeout( () => {
- return self.prevMenu(cb);
- }, 1000);
- } else {
- if(_.isString(conf.art)) {
- const dispOptions = {
- client : self.client,
- name : conf.art,
- };
+ 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);
+ }
- self.client.term.rawWrite(resetScreen());
+ if(conf.hasArt) {
+ const menuOpts = {
+ extraArgs : {
+ confTag : conf.confTag,
+ },
+ menuFlags : [ 'popParent', 'noHistory' ]
+ };
- 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);
- }
- }
- };
- }
+ return this.gotoMenu(this.menuConfig.config.changeConfPreArtMenu || 'changeMessageConfPreArt', menuOpts, cb);
+ }
- prevMenuOnTimeout(timeout, cb) {
- setTimeout( () => {
- return this.prevMenu(cb);
- }, timeout);
- }
+ return this.prevMenu(cb);
+ });
+ } else {
+ return cb(null);
+ }
+ }
+ };
+ }
- mciReady(mciData, cb) {
- super.mciReady(mciData, err => {
- if(err) {
- return cb(err);
- }
+ 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(
+ [
+ (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}`));
+ }
- async.series(
- [
- function loadFromConfig(callback) {
- let loadOpts = {
- callingMenu : self,
- mciMap : mciData.menu,
- formId : 0,
- };
+ confListView.on('index update', idx => {
+ this.selectionIndexUpdate(idx);
+ });
- 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,
- });
- }));
+ 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');
+ }
+ }
+ );
+ });
+ }
- 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,
- });
- }));
+ selectionIndexUpdate(idx) {
+ const conf = this.messageConfs[idx];
+ if(!conf) {
+ return;
+ }
+ this.setViewText('confList', MciViewIds.confDesc, conf.desc);
+ this.updateCustomViewTextsWithFilter('confList', MciViewIds.customRangeStart, conf);
+ }
- confListView.redraw();
-
- callback(null);
- },
- function populateTextViews(callback) {
- // :TODO: populate other avail MCI, e.g. current conf name
- callback(null);
- }
- ],
- function complete(err) {
- cb(err);
- }
- );
- });
- }
+ 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
index e5a69e80..f73dae8a 100644
--- a/core/msg_list.js
+++ b/core/msg_list.js
@@ -1,259 +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 stringFormat = require('./string_format.js');
-const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher;
+// 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');
+// deps
+const async = require('async');
+const _ = require('lodash');
+const moment = require('moment');
/*
- Available listFormat/focusListFormat members (VM1):
+ 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)
-
- MCI codes:
-
- VM1 : Message list
- TL2 : Message info 1: { msgNumSelected, msgNumTotal }
+ 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',
+ name : 'Message List',
+ desc : 'Module for listing/browsing available messages',
+ author : 'NuSkooler',
};
-const MCICodesIDs = {
- MsgList : 1, // VM1
- MsgInfo1 : 2, // TL2
+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);
+ constructor(options) {
+ super(options);
- const self = this;
- const config = this.menuConfig.config;
+ // :TODO: consider this pattern in base MenuModule - clean up code all over
+ this.config = Object.assign({}, _.get(options, 'menuConfig.config'), options.extraArgs);
- this.messageAreaTag = config.messageAreaTag;
+ this.lastMessageReachedExit = _.get(options, 'lastMenuResult.lastMessageReached', false);
- this.lastMessageReachedExit = _.get(options, 'lastMenuResult.lastMessageReached', false);
+ this.menuMethods = {
+ selectMessage : (formData, extraArgs, cb) => {
+ if(MciViewIds.allViews.msgList === formData.submitId) {
+ this.initialFocusIndex = formData.value.message;
- 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;
- }
+ const modOpts = {
+ extraArgs : {
+ messageAreaTag : this.getSelectedAreaTag(formData.value.message),
+ messageList : this.config.messageList,
+ messageIndex : formData.value.message,
+ lastMessageNextExit : true,
+ }
+ };
- if(options.extraArgs.messageList) {
- this.messageList = options.extraArgs.messageList;
- }
- }
+ if(_.isBoolean(this.config.noUpdateLastReadId)) {
+ modOpts.extraArgs.noUpdateLastReadId = this.config.noUpdateLastReadId;
+ }
- this.menuMethods = {
- selectMessage : function(formData, extraArgs, cb) {
- if(1 === formData.submitId) {
- self.initialFocusIndex = formData.value.message;
+ //
+ // 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));
- const modOpts = {
- extraArgs : {
- messageAreaTag : self.messageAreaTag,
- messageList : self.messageList,
- messageIndex : formData.value.message,
- lastMessageNextExit : true,
- }
- };
+ return {
+ // note |this| is scope of toJSON()!
+ messageAreaTag : this.messageAreaTag,
+ apprevMessageList : logMsgList,
+ messageCount : this.messageList.length,
+ messageIndex : this.messageIndex,
+ };
+ };
- //
- // 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 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 {
- messageAreaTag : this.messageAreaTag,
- apprevMessageList : logMsgList,
- messageCount : this.messageList.length,
- messageIndex : formData.value.message,
- };
- };
+ return this.markAllMessagesAsRead(cb);
+ }
+ };
+ }
- return self.gotoMenu(config.menuViewPost || 'messageAreaViewPost', modOpts, cb);
- } else {
- return cb(null);
- }
- },
+ getSelectedAreaTag(listIndex) {
+ return this.config.messageList[listIndex].areaTag || this.config.messageAreaTag;
+ }
- fullExit : function(formData, extraArgs, cb) {
- self.menuResult = { fullExit : true };
- return self.prevMenu(cb);
- }
- };
- }
+ enter() {
+ if(this.lastMessageReachedExit) {
+ return this.prevMenu();
+ }
- enter() {
- if(this.lastMessageReachedExit) {
- return this.prevMenu();
- }
+ super.enter();
- 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];
+ }
+ }
+ }
- //
- // 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();
+ }
- 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);
- }
+ 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 } );
+ 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
- };
+ 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
+ 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);
+ }
- 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;
+ messageArea.getMessageListForArea(self.client, self.config.messageAreaTag, function msgs(err, msgList) {
+ if(!msgList || 0 === msgList.length) {
+ return callback(new Error('No messages in area'));
+ }
- 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}';
+ self.config.messageList = msgList;
+ return callback(err);
+ });
+ },
+ function getLastReadMesageId(callback) {
+ // messageList entries can contain |isNew| if they want to be considered new
+ if(configProvidedMessageList) {
+ self.lastReadId = 0;
+ return callback(null);
+ }
- // :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
+ 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
- msgListView.setItems(_.map(self.messageList, listEntry => {
- return stringFormat(listFormat, listEntry);
- }));
+ 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;
- msgListView.setFocusItems(_.map(self.messageList, listEntry => {
- return stringFormat(focusListFormat, listEntry);
- }));
+ if(_.isUndefined(self.initialFocusIndex) && listItem.messageId > self.lastReadId) {
+ self.initialFocusIndex = index;
+ }
- 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();
- }
+ 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);
- 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);
- }
- );
- });
- }
+ if(self.initialFocusIndex > 0) {
+ // note: causes redraw()
+ msgListView.setFocusItemIndex(self.initialFocusIndex);
+ } else {
+ msgListView.redraw();
+ }
- getSaveState() {
- return { initialFocusIndex : this.initialFocusIndex };
- }
+ 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);
+ }
+ );
+ });
+ }
- restoreSavedState(savedState) {
- if(savedState) {
- this.initialFocusIndex = savedState.initialFocusIndex;
- }
- }
+ getSaveState() {
+ return { initialFocusIndex : this.initialFocusIndex };
+ }
- getMenuResult() {
- return this.menuResult;
- }
+ 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 68d8b3d6..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,1190 +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 : [ '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' ],
+ '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('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) {
- 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() {
- 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
+ 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, options = { scrollMode : 'default' } ) {
- this.textLines = [ ];
- this.addText(text, options);
- /*this.insertRawText(text);
+ 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, options = { scrollMode : 'default' }) {
- this.insertRawText(text);
+ this.insertRawText(text);
- switch(options.scrollMode) {
- case 'default' :
- if(this.isEditMode() || this.autoScroll) {
- this.cursorEndOfDocument();
- } else {
- this.cursorStartOfDocument();
- }
- break;
+ switch(options.scrollMode) {
+ case 'default' :
+ if(this.isEditMode() || this.autoScroll) {
+ this.cursorEndOfDocument();
+ } else {
+ this.cursorStartOfDocument();
+ }
+ break;
- case 'top' :
- case 'start' :
- this.cursorStartOfDocument();
- break;
+ case 'top' :
+ case 'start' :
+ this.cursorStartOfDocument();
+ break;
- case 'end' :
- case 'bottom' :
- this.cursorEndOfDocument();
- 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', 'delete',
- '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/new_scan.js b/core/new_scan.js
index f3e851fb..ac741ab3 100644
--- a/core/new_scan.js
+++ b/core/new_scan.js
@@ -1,268 +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;
-const { getAvailableFileAreaTags } = require('./file_base_area.js');
+// 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
- const filterCriteria = {
- newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user),
- areaTag : getAvailableFileAreaTags(this.client),
- order : 'ascending', // oldest first
- };
+ return self.gotoMenu(self.menuConfig.config.newScanMessageList || 'newScanMessageList', nextModuleOpts);
+ }
+ ],
+ err => {
+ return cb(err);
+ }
+ );
+ }
- FileEntry.findFiles(
- filterCriteria,
- (err, fileIds) => {
- if(err || 0 === fileIds.length) {
- return cb(err ? err : Errors.DoesNotExist('No more new files'));
- }
+ 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
+ };
- FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[fileIds.length - 1] );
+ FileEntry.findFiles(
+ filterCriteria,
+ (err, fileIds) => {
+ if(err || 0 === fileIds.length) {
+ return cb(err ? err : Errors.DoesNotExist('No more new files'));
+ }
- const menuOpts = {
- extraArgs : {
- fileList : fileIds,
- },
- };
+ FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[fileIds.length - 1] );
- return this.gotoMenu(this.menuConfig.config.newScanFileBaseList || 'newScanFileBaseList', menuOpts);
- }
- );
- }
+ const menuOpts = {
+ extraArgs : {
+ fileList : fileIds,
+ },
+ };
- getSaveState() {
- return {
- currentStep : this.currentStep,
- currentScanAux : this.currentScanAux,
- };
- }
+ return this.gotoMenu(this.menuConfig.config.newScanFileBaseList || 'newScanFileBaseList', menuOpts);
+ }
+ );
+ }
- restoreSavedState(savedState) {
- this.currentStep = savedState.currentStep;
- this.currentScanAux = savedState.currentScanAux;
- }
+ getSaveState() {
+ return {
+ currentStep : this.currentStep,
+ currentScanAux : this.currentScanAux,
+ };
+ }
- 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);
- }
- }
+ restoreSavedState(savedState) {
+ this.currentStep = savedState.currentStep;
+ this.currentScanAux = savedState.currentScanAux;
+ }
- mciReady(mciData, cb) {
- if(this.newScanFullExit) {
- // user has canceled the entire scan @ message list view
- return cb(null);
- }
+ performScanCurrentStep(cb) {
+ switch(this.currentStep) {
+ case Steps.MessageConfs :
+ this.newScanMessageConference( () => {
+ this.currentStep = Steps.FileBase;
+ return this.performScanCurrentStep(cb);
+ });
+ break;
- super.mciReady(mciData, err => {
- if(err) {
- return cb(err);
- }
+ case Steps.FileBase :
+ this.newScanFileBase( () => {
+ this.currentStep = Steps.Finished;
+ return this.performScanCurrentStep(cb);
+ });
+ break;
- const self = this;
- const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
+ default : return cb(null);
+ }
+ }
- // :TODO: display scan step/etc.
+ mciReady(mciData, cb) {
+ if(this.newScanFullExit) {
+ // user has canceled the entire scan @ message list view
+ return cb(null);
+ }
- async.series(
- [
- function loadFromConfig(callback) {
- const loadOpts = {
- callingMenu : self,
- mciMap : mciData.menu,
- noInput : true,
- };
+ super.mciReady(mciData, err => {
+ if(err) {
+ return cb(err);
+ }
- 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);
- }
- );
- });
- }
+ 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
index 7939e739..2cb4c26b 100644
--- a/core/nua.js
+++ b/core/nua.js
@@ -1,144 +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').config;
-const messageArea = require('./message_area.js');
+// 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',
+ name : 'NUA',
+ desc : 'New User Application',
};
const MciViewIds = {
- userName : 1,
- password : 9,
- confirm : 10,
- errMsg : 11,
+ 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'));
- },
+ constructor(options) {
+ super(options);
- viewValidationListener : function(err, cb) {
- const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg);
- let newFocusId;
-
- if(err) {
- errMsgView.setText(err.message);
- err.view.clearText();
+ const self = this;
- if(err.view.getId() === MciViewIds.confirm) {
- newFocusId = MciViewIds.password;
- self.viewControllers.menu.getView(MciViewIds.password).clearText();
- }
- } else {
- errMsgView.clearText();
- }
+ 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'));
+ },
- return cb(newFocusId);
- },
+ 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();
+ //
+ // Submit handlers
+ //
+ submitApplication : function(formData, extraArgs, cb) {
+ const newUser = new User();
+ const config = Config();
- newUser.username = formData.value.username;
+ 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
+ //
+ // 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,
+ // can't store undefined!
+ confTag = confTag || '';
+ areaTag = areaTag || '';
- term_height : self.client.term.termHeight,
- term_width : self.client.term.termWidth,
+ 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(),
- // :TODO: Other defaults
- // :TODO: should probably have a place to create defaults/etc.
- };
+ [ UserProps.MessageConfTag ] : confTag,
+ [ UserProps.MessageAreaTag ] : areaTag,
- 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');
+ [ UserProps.TermHeight ] : self.client.term.termHeight,
+ [ UserProps.TermWidth ] : self.client.term.termWidth,
- 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');
+ // :TODO: Other defaults
+ // :TODO: should probably have a place to create defaults/etc.
+ };
- // Cache SysOp information now
- // :TODO: Similar to bbs.js. DRY
- if(newUser.isSysOp()) {
- Config.general.sysOp = {
- username : formData.value.username,
- properties : newUser.properties,
- };
- }
+ const defaultTheme = _.get(config, 'theme.default');
+ if('*' === defaultTheme) {
+ newUser.properties[UserProps.ThemeId] = theme.getRandomTheme();
+ } else {
+ newUser.properties[UserProps.ThemeId] = defaultTheme;
+ }
- 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);
- }
- }
- });
- },
- };
- }
+ // :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');
- mciReady(mciData, cb) {
- return this.standardMCIReadyHandler(mciData, cb);
- }
+ 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
index 9e89addf..d840f731 100644
--- a/core/onelinerz.js
+++ b/core/onelinerz.js
@@ -1,338 +1,319 @@
/* jslint node: true */
'use strict';
-// ENiGMA½
-const MenuModule = require('./menu_module.js').MenuModule;
+// ENiGMA½
+const MenuModule = require('./menu_module.js').MenuModule;
const {
- getModDatabasePath,
- getTransactionDatabase
-} = require('./database.js');
+ getModDatabasePath,
+ getTransactionDatabase
+} = require('./database.js');
-const ViewController = require('./view_controller.js').ViewController;
-const theme = require('./theme.js');
-const ansi = require('./ansi_term.js');
-const stringFormat = require('./string_format.js');
+// deps
+const sqlite3 = require('sqlite3');
+const async = require('async');
+const _ = require('lodash');
+const moment = require('moment');
-// 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
+/*
+ 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',
+ 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,
- }
+ view : {
+ entries : 1,
+ addPrompt : 2,
+ },
+ add : {
+ newEntry : 1,
+ entryPreview : 2,
+ addPrompt : 3,
+ }
};
const FormIds = {
- View : 0,
- Add : 1,
+ view : 0,
+ add : 1,
};
exports.getModule = class OnelinerzModule extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- const self = this;
+ const self = this;
- this.menuMethods = {
- viewAddScreen : function(formData, extraArgs, cb) {
- return self.displayAddScreen(cb);
- },
+ 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
+ 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.storeNewOneliner(oneliner, err => {
+ if(err) {
+ self.client.log.warn( { error : err.message }, 'Failed saving oneliner');
+ }
- self.clearAddForm();
- return self.displayViewScreen(true, cb); // true=cls
- });
+ 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
- }
- },
+ } 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();
- }
- );
- }
+ cancelAdd : function(formData, extraArgs, cb) {
+ self.clearAddForm();
+ return self.displayViewScreen(true, cb); // true=cls
+ }
+ };
+ }
- displayViewScreen(clearScreen, cb) {
- const self = this;
+ 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();
+ }
+ );
+ }
- async.waterfall(
- [
- function clearAndDisplayArt(callback) {
- if(self.viewControllers.add) {
- self.viewControllers.add.setFocus(false);
- }
+ displayViewScreen(clearScreen, cb) {
+ const self = this;
- if(clearScreen) {
- self.client.term.rawWrite(ansi.resetScreen());
- }
+ async.waterfall(
+ [
+ function prepArtAndViewController(callback) {
+ if(self.viewControllers.add) {
+ self.viewControllers.add.setFocus(false);
+ }
- 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 } )
- );
+ 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 = [];
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds.View,
- };
+ 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');
- 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 = [];
+ 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),
+ };
+ }));
- 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.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);
+ }
+ }
+ );
+ }
- entriesView.setItems(entries.map( e => {
- return stringFormat(listFormat, {
- userId : e.user_id,
- username : e.user_name,
- oneliner : e.oneliner,
- ts : e.timestamp.format(tsFormat),
- } );
- }));
+ displayAddScreen(cb) {
+ const self = this;
- entriesView.redraw();
+ async.waterfall(
+ [
+ function clearAndDisplayArt(callback) {
+ self.viewControllers.view.setFocus(false);
- 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);
- }
- }
- );
- }
+ 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);
+ }
+ }
+ );
+ }
- displayAddScreen(cb) {
- const self = this;
+ clearAddForm() {
+ this.setViewText('add', MciViewIds.add.newEntry, '');
+ this.setViewText('add', MciViewIds.add.entryPreview, '');
+ }
- async.waterfall(
- [
- function clearAndDisplayArt(callback) {
- self.viewControllers.view.setFocus(false);
- self.client.term.rawWrite(ansi.resetScreen());
+ initDatabase(cb) {
+ const self = this;
- 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 } )
- );
+ 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);
+ }
+ );
+ }
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds.Add,
- };
+ storeNewOneliner(oneliner, cb) {
+ const self = this;
+ const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ');
- 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);
- }
- }
- );
- }
+ 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);
+ }
+ );
+ }
- 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 = 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 - 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);
- });
- }
+ 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 e175a166..e1d6c962 100644
--- a/core/oputil/oputil_common.js
+++ b/core/oputil/oputil_common.js
@@ -2,46 +2,61 @@
/* 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() {
@@ -49,42 +64,75 @@ function getDefaultConfigPath() {
}
function getConfigPath() {
- const baseConfigPath = argv.config ? argv.config : config.getDefaultPath();
- return baseConfigPath + 'config.hjson';
+ 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 d6a18026..4dc25fd2 100644
--- a/core/oputil/oputil_file_base.js
+++ b/core/oputil/oputil_file_base.js
@@ -7,7 +7,13 @@ 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 {
+ getAreaAndStorage,
+ looksLikePattern,
+ getConfigPath,
+ getAnswers,
+ writeConfig
+} = require('./oputil_common.js');
const Errors = require('../enig_error.js').Errors;
const async = require('async');
@@ -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
@@ -35,644 +45,979 @@ exports.handleFileBaseCommand = handleFileBaseCommand;
let fileArea; // required during init
function finalizeEntryAndPersist(isUpdate, 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
- }
+ 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(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 descFromFile = getDescFromFileName(fileEntry.fileName);
-
- if(false === argv.prompt) {
- fileEntry.desc = descFromFile;
- return callback(null);
- }
+ 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 : descFromFile,
- }
- ];
+ 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(isUpdate, 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;
- });
- });
+ const storageLocations = fileArea.getAreaStorageLocations(areaInfo).filter(sl => {
+ return options.areaAndStorageInfo.find(asi => {
+ return !asi.storageTag || sl.storageTag === asi.storageTag;
+ });
+ });
- function updateTags(fe) {
- if(Array.isArray(options.tags)) {
- fe.hashTags = new Set(options.tags);
- }
- }
-
- 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
- }
+ function updateTags(fe) {
+ if(Array.isArray(options.tags)) {
+ fe.hashTags = new Set(options.tags);
+ }
+ }
- loadDescHandler(paths.join(storageLoc.dir, 'DESCRIPT.ION'), (err, descHandler) => {
- return callback(null, descHandler);
- });
- },
- function scanPhysFiles(descHandler, callback) {
- const physDir = storageLoc.dir;
+ const FileEntry = require('../file_entry.js');
- fs.readdir(physDir, (err, files) => {
- if(err) {
- return callback(err);
- }
+ const readDir = options.glob ?
+ (dir, next) => {
+ return glob(options.glob, { cwd : dir, nodir : true }, next);
+ } :
+ (dir, next) => {
+ return fs.readdir(dir, next);
+ };
- async.eachSeries(files, (fileName, nextFile) => {
- const fullPath = paths.join(physDir, fileName);
+ 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
+ }
- if(SCAN_EXCLUDE_FILENAMES.includes(fileName.toUpperCase())) {
- console.info(`Excluding ${fullPath}`);
- return nextFile(null);
- }
+ findSuitableDescHandler(storageLoc.dir, (err, descHandler) => {
+ return callback(null, descHandler);
+ });
+ },
+ function scanPhysFiles(descHandler, callback) {
+ const physDir = storageLoc.dir;
- fs.stat(fullPath, (err, stats) => {
- if(err) {
- // :TODO: Log me!
- return nextFile(null); // always try next file
- }
+ readDir(physDir, (err, files) => {
+ if(err) {
+ return callback(err);
+ }
- if(!stats.isFile()) {
- return nextFile(null);
- }
+ async.eachSeries(files, (fileName, nextFile) => {
+ const fullPath = paths.join(physDir, fileName);
- process.stdout.write(`Scanning ${fullPath}... `);
+ if(SCAN_EXCLUDE_FILENAMES.includes(fileName.toUpperCase())) {
+ console.info(`Excluding ${fullPath}`);
+ return nextFile(null);
+ }
- fileArea.scanFile(
- fullPath,
- {
- areaTag : areaInfo.areaTag,
- storageTag : storageLoc.storageTag
- },
- (err, fileEntry, dupeEntries) => {
- if(err) {
- console.info(`Error: ${err.message}`);
- return nextFile(null); // try next anyway
- }
+ fs.stat(fullPath, (err, stats) => {
+ if(err) {
+ // :TODO: Log me!
+ return nextFile(null); // always try next file
+ }
- //
- // 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();
+ if(!stats.isFile()) {
+ return nextFile(null);
+ }
- return existingEntry.load(dupeEntries[0].fileId, err => {
- if(err) {
- console.info('Dupe (cannot update)');
- return nextFile(null);
- }
+ process.stdout.write(`Scanning ${fullPath}... `);
- //
- // 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);
+ async.series(
+ [
+ function quickCheck(next) {
+ if(!options.quick) {
+ return next(null);
+ }
- if( tagsEq &&
- fileEntry.desc === existingEntry.desc &&
- fileEntry.descLong == existingEntry.descLong &&
- fileEntry.meta.est_release_year == existingEntry.meta.est_release_year)
- {
- console.info('Dupe');
- return nextFile(null);
- }
+ FileEntry.quickCheckExistsByPath(fullPath, (err, exists) => {
+ if(exists) {
+ console.info('Dupe');
+ return nextFile(null);
+ }
- console.info('Dupe (updating)');
+ return next(null);
+ });
+ },
+ function fullScan() {
+ fileArea.scanFile(
+ fullPath,
+ {
+ areaTag : areaInfo.areaTag,
+ storageTag : storageLoc.storageTag
+ },
+ (err, fileEntry, dupeEntries) => {
+ if(err) {
+ console.info(`Error: ${err.message}`);
+ return nextFile(null); // try next anyway
+ }
- // don't allow overwrite of values if new version is blank
- existingEntry.desc = fileEntry.desc || existingEntry.desc;
- existingEntry.descLong = fileEntry.descLong || existingEntry.descLong;
+ //
+ // 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();
- if(fileEntry.meta.est_release_year) {
- existingEntry.meta.est_release_year = fileEntry.meta.est_release_year;
- }
+ return existingEntry.load(dupeEntries[0].fileId, err => {
+ if(err) {
+ console.info('Dupe (cannot update)');
+ return nextFile(null);
+ }
- updateTags(existingEntry);
+ //
+ // 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);
- 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);
- });
+ 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.quick = argv.quick;
- 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.js');
- scanFileAreaForChanges(areaInfo, options, err => {
- return callback(err);
- });
- }, err => {
- return callback(err);
- });
- }
- ],
- err => {
- if(err) {
- process.exitCode = ExitCodes.ERROR;
- console.error(err.message);
- }
- }
- );
+ 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 = [];
+ let entries = [];
- // Each entry may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG]
- const FileEntry = require('../../core/file_entry.js');
+ // 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);
+ 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(areaInfo) {
+ // AREA_TAG[@STORAGE_TAG] - all files in area@tag
+ const findFilter = {
+ areaTag : areaAndStorage.areaTag,
+ };
- if(areaAndStorage.storageTag) {
- findFilter.storageTag = areaAndStorage.storageTag;
- }
+ if(areaAndStorage.storageTag) {
+ findFilter.storageTag = areaAndStorage.storageTag;
+ }
- FileEntry.findFiles(findFilter, (err, fileIds) => {
- if(err) {
- return next(err);
- }
+ 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);
- });
- });
+ 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);
- }
+ } 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);
- });
+ 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);
- const src = getAreaAndStorage(moveArgs.slice(0, -1));
- const dst = getAreaAndStorage(moveArgs.slice(-1))[0];
+ const moveArgs = argv._.slice(2);
+ const src = getAreaAndStorage(moveArgs.slice(0, -1));
+ const dst = getAreaAndStorage(moveArgs.slice(-1))[0];
- let FileEntry;
+ let FileEntry;
- 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'));
- }
+ 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'));
+ }
- FileEntry = require('../../core/file_entry.js');
+ FileEntry = require('../../core/file_entry.js');
- expandFileTargets(src, (err, srcEntries) => {
- 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);
+ expandFileTargets(src, (err, srcEntries) => {
+ return callback(err, srcEntries);
+ });
+ },
+ function moveEntries(srcEntries, callback) {
- process.stdout.write(`Moving ${srcPath} => ${dstPath}... `);
+ if(!dst.storageTag) {
+ dst.storageTag = dst.areaInfo.storageTags[0];
+ }
- 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);
- }
- }
- );
+ 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);
+ });
+ }
+ ],
+ err => {
+ if(err) {
+ process.exitCode = ExitCodes.ERROR;
+ console.error(err.message);
+ }
+ }
+ );
}
function removeFiles() {
- //
- // 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);
- }
+ //
+ // 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 removePhysFile = argv['phys-file'];
- const src = getAreaAndStorage(argv._.slice(2));
+ 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');
+ 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)' : '';
+ const extraOutput = removePhysFile ? ' (including physical file)' : '';
- async.eachSeries(srcEntries, (entry, nextEntry) => {
+ async.eachSeries(srcEntries, (entry, nextEntry) => {
- process.stdout.write(`Removing ${entry.filePath}${extraOutput}... `);
+ process.stdout.write(`Removing ${entry.filePath}${extraOutput}... `);
- FileEntry.removeEntry(entry, { removePhysFile }, err => {
- if(err) {
- console.info(`Failed: ${err.message}`);
- } else {
- console.info('Done');
- }
+ 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);
- }
- }
- );
+ 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,
+ return ({
+ info : displayFileOrAreaInfo,
+ scan : scanFileAreas,
- mv : moveFiles,
- move : moveFiles,
+ mv : moveFiles,
+ move : moveFiles,
- rm : removeFiles,
- remove : removeFiles,
- del : removeFiles,
- delete : removeFiles,
- }[action] || errUsage)();
+ 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 5c7d202a..f8a0d42c 100644
--- a/core/oputil/oputil_help.js
+++ b/core/oputil/oputil_help.js
@@ -7,7 +7,7 @@ const getDefaultConfigPath = require('./oputil_common.js').getDefaultConfigPat
exports.getHelpFor = getHelpFor;
const usageHelp = exports.USAGE_HELP = {
- General :
+ General :
`usage: oputil.js [--version] [--help]
[]
@@ -19,39 +19,45 @@ commands:
user user utilities
config config file management
fb file base management
+ mb message base management
`,
- User :
-`usage: oputil.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
+ aliases: password, passwd
+ rm USERNAME permanently removes user from system
+ aliases: remove, delete, del
+ activate USERNAME set status to active
+ deactivate USERNAME set status to inactive
+ disable USERNAME set status to disabled
+ lock USERNAME set status to locked
+ group USERNAME [+|-]GROUP adds (+) or removes (-) user from a group
`,
- 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
+ cat cat current configuration to stdout
-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 args:
+ --no-color disable color
+ --no-comments strip any comments
`,
- FileBase :
+ FileBase :
`usage: oputil.js fb []
actions:
scan AREA_TAG[@STORAGE_TAG] scan specified area
+ may also contain optional GLOB as last parameter,
+ for example: scan some_area *.zip
- info AREA_TAG|SHA|FILE_ID display information about areas and/or files
- SHA may be a full or partial SHA-256
+ info CRITERIA display information about areas and/or files
+ matching CRITERIA.
mv SRC [SRC...] DST move entry(s) from SRC to DST
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
@@ -59,42 +65,60 @@ actions:
rm SRC [SRC...] remove entry(s) from the system matching SRC
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
+ desc CRITERIA sets a new file description for file base entry
+ matching CRITERIA. Launches an external editor using
+ $VISUAL, $EDITOR, or vim/notepad.
+ import-areas FILEGATE.ZXX import file base areas using FileGate RAID type format
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
- --update attempt to update information for existing entries
+ --desc-file [PATH] prefer file descriptions from supplied path over other
+ other sources such as FILE_ID.DIZ. Path must point to
+ a valid FILES.BBS or DESCRIPT.ION file.
+ --update attempt to update information for existing entries
+ --quick perform quick scan
info args:
--show-desc display short description, if any
remove args:
--phys-file also remove underlying physical file
+
+import-areas args:
+ --type TYPE sets import areas type. valid options are "zxx" or "na"
+ --create-dirs 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
+
+ CRITERIA file base entry criteria. in general, can be AREA_TAG, SHA,
+ FILE_ID, or FILENAME_WC.
FILENAME_WC filename with * and ? wildcard support. may match 0:n entries
SHA full or partial SHA-256
FILE_ID a file identifier. see file.sqlite3
`,
- MessageBase :
- `usage: oputil.js mb []
+ MessageBase :
+`usage: oputil.js mb []
- actions:
+actions:
areafix CMD1 CMD2 ... ADDR sends an AreaFix NetMail to ADDR with the supplied command(s)
one or more commands may be supplied. commands that are multi
part such as "%COMPRESS ZIP" should be quoted.
+ import-areas PATH import areas using fidonet *.NA or AREAS.BBS file from PATH
+
+import-areas args:
+ --conf CONF_TAG conference tag in which to import areas
+ --network NETWORK network name/key to associate FTN areas
+ --uplinks UL1,UL2,... one or more comma separated uplinks
+ --type TYPE area import type. valid options are "bbs" and "na"
`
};
function getHelpFor(command) {
- return usageHelp[command];
+ return usageHelp[command];
}
diff --git a/core/oputil/oputil_main.js b/core/oputil/oputil_main.js
index aa373ef2..aafc8ef1 100644
--- a/core/oputil/oputil_main.js
+++ b/core/oputil/oputil_main.js
@@ -14,23 +14,23 @@ 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' : return handleUserCommand();
- case 'config' : return handleConfigCommand();
- case 'fb' : return handleFileBaseCommand();
- case 'mb' : return handleMessageBaseCommand();
- 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
index 6e89cf73..0f1b5cfb 100644
--- a/core/oputil/oputil_message_base.js
+++ b/core/oputil/oputil_message_base.js
@@ -2,141 +2,455 @@
/* 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,
+ 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');
exports.handleMessageBaseCommand = handleMessageBaseCommand;
function areaFix() {
- //
- // oputil mb areafix CMD1 CMD2 ... ADDR [--password PASS]
- //
- if(argv._.length < 3) {
- return printUsageAndSetExitCode(
- getHelpFor('MessageBase'),
- ExitCodes.ERROR
- );
- }
+ //
+ // 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);
+ 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`));
- }
+ 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');
+ //
+ // 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);
- }
+ 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';
+ // 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 = 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
- }
- }
- });
+ 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);
- }
+ 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 : ''}`);
- }
- }
- );
+ 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 handleMessageBaseCommand() {
- function errUsage() {
- return printUsageAndSetExitCode(
- getHelpFor('MessageBase'),
- ExitCodes.ERROR
- );
- }
+ function errUsage() {
+ return printUsageAndSetExitCode(
+ getHelpFor('MessageBase'),
+ ExitCodes.ERROR
+ );
+ }
- if(true === argv.help) {
- return errUsage();
- }
+ if(true === argv.help) {
+ return errUsage();
+ }
- const action = argv._[1];
+ const action = argv._[1];
- return({
- areafix : areaFix,
- }[action] || errUsage)();
+ return({
+ areafix : areaFix,
+ 'import-areas' : importAreas,
+ }[action] || errUsage)();
}
\ No newline at end of file
diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js
index afe243bf..a52facfb 100644
--- a/core/oputil/oputil_user.js
+++ b/core/oputil/oputil_user.js
@@ -2,112 +2,353 @@
/* 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');
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 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';
+ };
+
+ console.info(`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)}
+`);
+}
+
+function handleUserCommand() {
+ function errUsage() {
+ return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
+ }
+
+ if(true === argv.help) {
+ return errUsage();
+ }
+
+ const action = argv._[1];
+ const usernameIdx = [ 'pw', 'pass', 'passwd', 'password', 'group' ].includes(action) ? argv._.length - 2 : argv._.length - 1;
+ const userName = argv._[usernameIdx];
+
+ if(!userName) {
+ return errUsage();
+ }
+
+ initAndGetUser(userName, (err, user) => {
+ if(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,
+
+ activate : setAccountStatus,
+ deactivate : setAccountStatus,
+ disable : setAccountStatus,
+ lock : setAccountStatus,
+
+ group : modUserGroups,
+
+ info : showUserInfo,
+ }[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 7fe921b3..a1182a79 100644
--- a/core/predefined_mci.js
+++ b/core/predefined_mci.js
@@ -1,250 +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).toLocaleString();
+ 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).toLocaleString();
+ 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').toLocaleString(); },
+ // :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
- },
- TF : function totalFilesOnSystem() {
- const areaStats = StatLog.getSystemStat('file_base_area_stats');
- return _.get(areaStats, 'totalFiles', 0).toLocaleString();
- },
- TB : function totalBytesOnSystem() {
- const areaStats = StatLog.getSystemStat('file_base_area_stats');
- const totalBytes = parseInt(_.get(areaStats, 'totalBytes', 0));
- return formatByteSize(totalBytes, 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: 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/rumorz.js b/core/rumorz.js
index b83853f0..51e58d53 100644
--- a/core/rumorz.js
+++ b/core/rumorz.js
@@ -1,247 +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 stringFormat = require('./string_format.js');
+// 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');
+// deps
+const async = require('async');
+const _ = require('lodash');
exports.moduleInfo = {
- name : 'Rumorz',
- desc : 'Standard local rumorz',
- author : 'NuSkooler',
- packageName : 'codes.l33t.enigma.rumorz',
+ 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,
+ View : 0,
+ Add : 1,
};
const MciCodeIds = {
- ViewForm : {
- Entries : 1,
- AddPrompt : 2,
- },
- AddForm : {
- NewEntry : 1,
- EntryPreview : 2,
- AddPrompt : 3,
- }
+ ViewForm : {
+ Entries : 1,
+ AddPrompt : 2,
+ },
+ AddForm : {
+ NewEntry : 1,
+ EntryPreview : 2,
+ AddPrompt : 3,
+ }
};
exports.getModule = class RumorzModule extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- this.menuMethods = {
- viewAddScreen : (formData, extraArgs, cb) => {
- return this.displayAddScreen(cb);
- },
+ 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
- }
- },
+ addEntry : (formData, extraArgs, cb) => {
+ if(_.isString(formData.value.rumor) && renderStringLength(formData.value.rumor) > 0) {
+ const rumor = formData.value.rumor.trim(); // remove any trailing ws
- cancelAdd : (formData, extraArgs, cb) => {
- this.clearAddForm();
- return this.displayViewScreen(true, cb); // true=cls
- }
- };
- }
+ 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
+ }
+ },
- get config() { return this.menuConfig.config; }
+ cancelAdd : (formData, extraArgs, cb) => {
+ this.clearAddForm();
+ return this.displayViewScreen(true, cb); // true=cls
+ }
+ };
+ }
- clearAddForm() {
- const newEntryView = this.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry);
- const previewView = this.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview);
+ get config() { return this.menuConfig.config; }
- newEntryView.setText('');
-
- // preview is optional
- if(previewView) {
- previewView.setText('');
- }
- }
+ clearAddForm() {
+ const newEntryView = this.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry);
+ const previewView = this.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview);
- initSequence() {
- const self = this;
+ newEntryView.setText('');
- 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();
- }
- );
- }
+ // preview is optional
+ if(previewView) {
+ previewView.setText('');
+ }
+ }
- displayViewScreen(clearScreen, cb) {
- const self = this;
- async.waterfall(
- [
- function clearAndDisplayArt(callback) {
- if(self.viewControllers.add) {
- self.viewControllers.add.setFocus(false);
- }
+ initSequence() {
+ const self = this;
- if(clearScreen) {
- self.client.term.rawWrite(resetScreen());
- }
+ 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();
+ }
+ );
+ }
- 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 } )
- );
+ displayViewScreen(clearScreen, cb) {
+ const self = this;
+ async.waterfall(
+ [
+ function clearAndDisplayArt(callback) {
+ if(self.viewControllers.add) {
+ self.viewControllers.add.setFocus(false);
+ }
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds.View,
- };
+ if(clearScreen) {
+ self.client.term.rawWrite(resetScreen());
+ }
- 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);
+ 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 } )
+ );
- 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;
+ const loadOpts = {
+ callingMenu : self,
+ mciMap : artData.mciMap,
+ formId : FormIds.View,
+ };
- 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 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);
- 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);
- }
- }
- );
- }
+ 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,
+ };
+ }));
- displayAddScreen(cb) {
- const self = this;
+ entriesView.redraw();
- async.waterfall(
- [
- function clearAndDisplayArt(callback) {
- self.viewControllers.view.setFocus(false);
- self.client.term.rawWrite(resetScreen());
+ 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);
+ }
+ }
+ );
+ }
- 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 } )
- );
+ displayAddScreen(cb) {
+ const self = this;
- const loadOpts = {
- callingMenu : self,
- mciMap : artData.mciMap,
- formId : FormIds.Add,
- };
+ async.waterfall(
+ [
+ function clearAndDisplayArt(callback) {
+ self.viewControllers.view.setFocus(false);
+ self.client.term.rawWrite(resetScreen());
- 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);
- }
- }
- );
- }
+ 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..7d5f52fd 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 ];
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 = 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
+ .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(vars.id)) {
- return cb(new Error('No SAUCE record present'));
- }
+ if(!SAUCE_ID.equals(sauceRec.id)) {
+ return cb(Errors.DoesNotExist('No SAUCE record present'));
+ }
- var ver = iconv.decode(vars.version, 'cp437');
+ const ver = iconv.decode(sauceRec.version, 'cp437');
- if('00' !== ver) {
- return cb(new Error('Unsupported SAUCE version: ' + ver));
- }
+ if('00' !== ver) {
+ return cb(Errors.Invalid(`Unsupported SAUCE version: ${ver}`));
+ }
- if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(vars.dataType)) {
- return cb(new Error('Unsupported SAUCE DataType: ' + vars.dataType));
- }
+ if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(sauceRec.dataType)) {
+ return cb(Errors.Invalid(`Unsupported SAUCE DataType: ${sauceRec.dataType}`));
+ }
- 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 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 dt = SAUCE_DATA_TYPES[sauce.dataType];
- if(dt && dt.parser) {
- sauce[dt.name] = dt.parser(sauce);
- }
+ const 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 8da71d02..3fec4fc3 100644
--- a/core/scanner_tossers/ftn_bso.js
+++ b/core/scanner_tossers/ftn_bso.js
@@ -1,2310 +1,2365 @@
/* 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;
-const User = require('../user.js');
+// 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 sane = require('sane');
-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 uuidV4 = require('uuid/v4');
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
- * 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);
-
- const self = this;
-
- this.archUtil = ArchiveUtil.getInstance();
-
- 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.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;
- };
- */
-
- this.getOutgoingEchoMailPacketDir = 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();
- }
-
- 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;
- }
-
- if('upper' === fileCase) {
- ext = ext.toUpperCase();
- }
-
- return ext;
- };
-
- this.getOutgoingFlowFileName = function(basePath, destAddress, flowType, exportType, fileCase) {
- let basename;
-
- const ext = self.getOutgoingFlowFileExtension(
- destAddress,
- flowType,
- exportType,
- fileCase
- );
-
- if(destAddress.point) {
-
- } 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);
- }
-
- 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.
- //
- const localAddress = new Address(options.network.localAddress); // ensure we have an Address obj not a string version
-
- message.meta.FtnProperty = message.meta.FtnProperty || {};
- message.meta.FtnKludge = message.meta.FtnKludge || {};
-
- message.meta.FtnProperty.ftn_orig_node = localAddress.node;
- message.meta.FtnProperty.ftn_orig_network = localAddress.net;
- message.meta.FtnProperty.ftn_cost = 0;
-
- // 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);
-
- let ftnAttribute = ftnMailPacket.Packet.Attribute.Local; // message from our system
-
- if(self.isNetMailMessage(message)) {
- // These should be set for Private/NetMail already
- assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_node)));
- assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_network)));
-
- ftnAttribute |= ftnMailPacket.Packet.Attribute.Private;
-
- //
- // 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));
- */
-
- //
- // 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);
-
- if(_.isNumber(localAddress.point) && localAddress.point > 0) {
- message.meta.FtnKludge.FMPT = localAddress.point;
- }
-
- if(_.isNumber(options.destAddress.point) && options.destAddress.point > 0) {
- message.meta.FtnKludge.TOPT = options.destAddress.point;
- }
- } else {
- // We need to set some destination info for EchoMail
- message.meta.FtnProperty.ftn_dest_node = options.destAddress.node;
- message.meta.FtnProperty.ftn_dest_network = options.destAddress.net;
-
- //
- // 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_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 =
- [ `${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);
-
- //
- // And create/update PATH for ourself
- //
- message.meta.FtnKludge.PATH = ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, 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,
- localAddress,
- message.isPrivate() // true = isNetMail
- );
- }
-
- 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);
- };
-
- 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;
- }
- }
-
- 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);
- });
- };
-
- // :TODO: deprecate this in favor of getNodeConfigByAddress()
- this.getNodeConfigKeyByAddress = function(uplink) {
- const nodeKey = _.filter(Object.keys(this.moduleConfig.nodes), addr => {
- return Address.fromString(addr).isPatternMatch(uplink);
- })[0];
-
- return nodeKey;
- };
-
- 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.destAddress,
- 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) {
- //
- // messageNetworks.ftn.netMail.routes{} full|wildcard -> full adddress lookup
- //
- const routes = _.get(Config, 'messageNetworks.ftn.netMail.routes');
- if(!routes) {
- return;
- }
-
- return _.find(routes, (route, addrWildcard) => {
- return dstAddr.isPatternMatch(addrWildcard);
- });
-
- /*
- const route = _.find(routes, (route, addrWildcard) => {
- return dstAddr.isPatternMatch(addrWildcard);
- });
-
- if(route && route.address) {
- return Address.fromString(route.address);
- }
- */
- };
-
- this.getAcceptableNetMailNetworkInfoFromAddress = function(dstAddr, cb) {
- //
- // Attempt to find an acceptable network configuration using the following
- // lookup order (most to least explicit config):
- //
- // 1) Routes: messageNetworks.ftn.netMail.routes{} -> scannerTossers.ftn_bso.nodes{} -> config
- // - Where we send may not be where dstAddress is (it's routed!)
- // 2) Direct to nodes: scannerTossers.ftn_bso.nodes{} -> config
- // - Where we send is direct to dstAddr
- //
- // 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(dstAddr);
-
- let routeAddress;
- let networkName;
- if(route) {
- routeAddress = Address.fromString(route.address);
- networkName = route.network;
- } else {
- routeAddress = dstAddr;
- }
-
- networkName = networkName ||
- this.getNetworkNameByAddressPattern(`${routeAddress.zone}:${routeAddress.net}/*`) ||
- Config.scannerTossers.ftn_bso.defaultNetwork
- ;
-
- const config = _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => {
- return routeAddress.isPatternMatch(nodeAddrWildcard);
- }) || {
- packetType : '2+',
- encoding : Config.scannerTossers.ftn_bso.packetMsgEncoding,
- };
-
- return cb(
- config ? null : Errors.DoesNotExist(`No configuration found for ${dstAddr.toString()}`),
- config, routeAddress, networkName
- );
- };
-
- 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]);
-
- return self.getAcceptableNetMailNetworkInfoFromAddress(dstAddr, (err, config, routeAddress, networkName) => {
- if(err) {
- return callback(err);
- }
-
- exportOpts.nodeConfig = config;
- exportOpts.destAddress = routeAddress;
- exportOpts.fileCase = config.fileCase || 'lower';
- exportOpts.network = Config.messageNetworks.ftn.networks[networkName];
- exportOpts.networkName = networkName;
- exportOpts.outgoingDir = self.getOutgoingEchoMailPacketDir(exportOpts.networkName, exportOpts.destAddress);
- exportOpts.exportType = self.getExportType(config);
-
- if(!exportOpts.network) {
- return callback(Errors.DoesNotExist(`No configuration found for network ${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.destAddress,
- '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 => {
- return cb(err);
- });
- };
-
- this.exportEchoMailMessagesToUplinks = 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.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.uuid = 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) {
- 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 preseved above
- if(lookupName !== message.toUserName) {
- message.toUserName = localUserName;
- }
-
- // set the meta information - used elsehwere 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 => {
- 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.uuid = 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.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);
- });
- }
- }, 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, '', 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;
- };
-
- // 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() {
- 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 config = {
- nodes : Config.scannerTossers.ftn_bso.nodes,
- defaultPassword : Config.scannerTossers.ftn_bso.tic.password,
- localAreaTags : self.getLocalAreaTagsForTic(),
- };
-
- return 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
- wcValue : 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.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 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.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.filePath }, 'Failed import/update TIC record' );
- } else {
- Log.debug(
- { 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 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)
- ;
- };
+ MessageScanTossModule.call(this);
+
+ const self = this;
+
+ this.archUtil = ArchiveUtil.getInstance();
+
+ const config = Config();
+ 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) {
+ const config = Config();
+ 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 _.isString(areaConf.tag) && 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.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;
+ };
+ */
+
+ this.getOutgoingEchoMailPacketDir = 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();
+ }
+
+ 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;
+ }
+
+ if('upper' === fileCase) {
+ ext = ext.toUpperCase();
+ }
+
+ return ext;
+ };
+
+ 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;
+
+ const ext = self.getOutgoingFlowFileExtension(
+ destAddress,
+ flowType,
+ exportType,
+ fileCase
+ );
+
+ const netComponent = `0000${destAddress.net.toString(16)}`.substr(-4);
+ const nodeComponent = `0000${destAddress.node.toString(16)}`.substr(-4);
+
+ 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 = '';
+
+ //
+ // Use |destAddress| nnnnNNNN.??? where nnnn is dest net and NNNN is dest
+ // node. This seems to match what Mystic does
+ //
+ controlFileBaseName = `${netComponent}${nodeComponent}`;
+ }
+
+ //
+ // 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();
+ }
+
+ return paths.join(basePath, pointDir, `${controlFileBaseName}.${ext}`);
+ };
+
+ 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`;
+ }, '');
+
+ fs.appendFile(filePath, appendLines, err => {
+ return 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.
+ //
+ const localAddress = new Address(options.network.localAddress); // ensure we have an Address obj not a string version
+
+ // :TODO: create Address.toMeta() / similar
+ message.meta.FtnProperty = message.meta.FtnProperty || {};
+ message.meta.FtnKludge = message.meta.FtnKludge || {};
+
+ 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;
+
+ const destAddress = options.routeAddress || options.destAddress;
+ message.meta.FtnProperty.ftn_dest_node = destAddress.node;
+ message.meta.FtnProperty.ftn_dest_network = destAddress.net;
+
+ if(destAddress.zone) {
+ message.meta.FtnProperty.ftn_dest_zone = destAddress.zone;
+ }
+ if(destAddress.point) {
+ message.meta.FtnProperty.ftn_dest_point = destAddress.point;
+ }
+
+ // 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);
+
+ let ftnAttribute = ftnMailPacket.Packet.Attribute.Local; // message from our system
+
+ 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;
+
+ ftnAttribute |= ftnMailPacket.Packet.Attribute.Private;
+
+ //
+ // 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));
+ */
+
+ //
+ // 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);
+
+ if(_.isNumber(localAddress.point) && localAddress.point > 0) {
+ message.meta.FtnKludge.FMPT = localAddress.point;
+ }
+
+ 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?
+ }
+
+ //
+ // EchoMail requires some additional properties & kludges
+ //
+ 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 =
+ [ `${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);
+
+ //
+ // And create/update PATH for ourself
+ //
+ message.meta.FtnKludge.PATH = ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, 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,
+ localAddress,
+ message.isPrivate() // true = isNetMail
+ );
+ }
+
+ 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);
+ };
+
+ 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;
+ }
+ }
+
+ 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.uuid = 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.uuid = 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.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);
+ });
+ }
+ }, 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`);
+ Log.info(`${exports.moduleInfo.name} Scanner/Tosser starting up`);
- let importing = false;
+ let importing = false;
- let self = this;
+ let self = this;
- function tryImportNow(reasonDesc, extraInfo) {
- if(!importing) {
- importing = true;
+ function tryImportNow(reasonDesc, extraInfo) {
+ if(!importing) {
+ importing = true;
- Log.info( Object.assign({ module : exports.moduleInfo.name }, extraInfo), reasonDesc);
+ Log.info( Object.assign({ module : exports.moduleInfo.name }, extraInfo), reasonDesc);
- self.performImport( () => {
- importing = false;
- });
- }
- }
+ self.performImport( () => {
+ importing = false;
+ });
+ }
+ }
- this.createTempDirectories(err => {
- if(err) {
- Log.warn( { error : err.toStrong() }, 'Failed creating temporary directories!');
- return cb(err);
- }
+ 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(_.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...');
+ 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);
- }
+ this.performExport( () => {
+ this.exportingEnd();
+ });
+ }
+ }, exportSchedule.sched);
+ }
- if(_.isBoolean(exportSchedule.immediate)) {
- this.exportImmediate = exportSchedule.immediate;
- }
- }
+ 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'
- );
+ 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(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)}`
- }
- );
+ 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 } );
- }
- });
- });
+ [ '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' } );
- }
- });
- }
- }
- }
+ //
+ // 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.super_.prototype.startup.call(this, cb);
+ });
};
FTNMessageScanTossModule.prototype.shutdown = function(cb) {
- Log.info('FidoNet Scanner/Tosser shutting down');
+ Log.info('FidoNet Scanner/Tosser shutting down');
- if(this.exportTimer) {
- this.exportTimer.clear();
- }
+ if(this.exportTimer) {
+ this.exportTimer.clear();
+ }
- if(this.importTimer) {
- this.importTimer.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,
- };
+ //
+ // 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');
+ Log.trace(fullStats, 'Temporary directories cleaned up');
- FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb);
- });
+ FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb);
+ });
- 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'));
- }
+ if(!this.hasValidConfiguration()) {
+ return cb(new Error('Missing or invalid configuration'));
+ }
- const self = this;
+ const self = this;
- async.each( [ 'inbound', 'secInbound' ], (inboundType, nextDir) => {
- self.importFromDirectory(inboundType, self.moduleConfig.paths[inboundType], () => {
- return nextDir(null);
- });
- }, cb);
+ async.each( [ 'inbound', 'secInbound' ], (inboundType, nextDir) => {
+ self.importFromDirectory(inboundType, self.moduleConfig.paths[inboundType], () => {
+ 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'));
- }
+ //
+ // 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'));
+ }
- const self = this;
+ const self = this;
- async.eachSeries( [ 'EchoMail', 'NetMail' ], (type, nextType) => {
- self[`perform${type}Export`]( err => {
- if(err) {
- Log.warn( { error : err.message, type : type }, 'Error(s) during export' );
- }
- return nextType(null); // try next, always
- });
- }, () => {
- return cb(null);
- });
+ async.eachSeries( [ 'EchoMail', 'NetMail' ], (type, nextType) => {
+ self[`perform${type}Export`]( err => {
+ if(err) {
+ Log.warn( { error : err.message, type : type }, '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;
- }
+ //
+ // This module works off schedules, but we do support @immediate for export
+ //
+ if(true !== this.exportImmediate || !this.hasValidConfiguration()) {
+ return;
+ }
- const info = { uuid : message.uuid, subject : message.subject };
+ const info = { uuid : message.uuid, subject : message.subject };
- function exportLog(err) {
- if(err) {
- Log.warn(info, 'Failed exporting message');
- } else {
- Log.info(info, 'Message exported');
- }
- }
+ 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.isNetMailMessage(message)) {
+ Object.assign(info, { type : 'NetMail' } );
- if(this.exportingStart()) {
- this.exportNetMailMessagesToUplinks( [ message.uuid ], err => {
- this.exportingEnd( () => exportLog(err) );
- });
- }
- } else if(message.areaTag) {
- Object.assign(info, { type : 'EchoMail' } );
+ if(this.exportingStart()) {
+ this.exportNetMailMessagesToUplinks( [ message.uuid ], 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;
- }
+ const areaConfig = Config().messageNetworks.ftn.areas[message.areaTag];
+ if(!this.isAreaConfigValid(areaConfig)) {
+ return;
+ }
- if(this.exportingStart()) {
- this.exportEchoMailMessagesToUplinks( [ message.uuid ], areaConfig, err => {
- this.exportingEnd( () => exportLog(err) );
- });
- }
- }
+ if(this.exportingStart()) {
+ this.exportEchoMailMessagesToUplinks( [ message.uuid ], 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/content/gopher.js b/core/servers/content/gopher.js
new file mode 100644
index 00000000..ee889b8c
--- /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, 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..4959dc64
--- /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.authenticate(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 8a9903b4..1a09aace 100644
--- a/core/servers/content/web.js
+++ b/core/servers/content/web.js
@@ -1,259 +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, 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, routeKey : routeKey }, '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) );
+ this.routes[routeKey] = route;
+ return true;
+ }
- if(!route && '/' === req.url) {
- return this.routeIndex(req, resp);
- }
+ routeRequest(req, resp) {
+ const route = _.find(this.routes, r => r.matchesRequest(req) );
- return route ? route.handler(req, resp) : this.accessDenied(resp);
- }
+ if(!route && '/' === req.url) {
+ return this.routeIndex(req, resp);
+ }
- respondWithError(resp, code, bodyText, title) {
- const customErrorPage = paths.join(Config.contentServers.web.staticRoot, `${code}.html`);
+ return route ? route.handler(req, resp) : this.accessDenied(resp);
+ }
- fs.readFile(customErrorPage, 'utf8', (err, data) => {
- resp.writeHead(code, { 'Content-Type' : 'text/html' } );
+ respondWithError(resp, code, bodyText, title) {
+ const customErrorPage = paths.join(Config().contentServers.web.staticRoot, `${code}.html`);
- if(err) {
- return resp.end(`
-
-
-
- ${title}
-
-
-
-
- ${bodyText}
-
-
- `
- );
- }
+ fs.readFile(customErrorPage, 'utf8', (err, data) => {
+ resp.writeHead(code, { 'Content-Type' : 'text/html' } );
- return resp.end(data);
- });
- }
+ if(err) {
+ return resp.end(`
+
+
+
+ ${title}
+
+
+
+
+ ${bodyText}
+
+
+ `
+ );
+ }
- accessDenied(resp) {
- return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied');
- }
+ return resp.end(data);
+ });
+ }
- fileNotFound(resp) {
- return this.respondWithError(resp, 404, 'File not found.', 'File Not Found');
- }
+ accessDenied(resp) {
+ return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied');
+ }
- routeIndex(req, resp) {
- const filePath = paths.join(Config.contentServers.web.staticRoot, 'index.html');
+ fileNotFound(resp) {
+ return this.respondWithError(resp, 404, 'File not found.', 'File Not Found');
+ }
- return this.returnStaticPage(filePath, resp);
- }
+ routeIndex(req, resp) {
+ const filePath = paths.join(Config().contentServers.web.staticRoot, 'index.html');
- routeStaticFile(req, resp) {
- const fileName = req.url.substr(req.url.indexOf('/', 1));
- const filePath = paths.join(Config.contentServers.web.staticRoot, fileName);
+ return this.returnStaticPage(filePath, resp);
+ }
- return this.returnStaticPage(filePath, resp);
- }
+ routeStaticFile(req, resp) {
+ const fileName = req.url.substr(req.url.indexOf('/', 1));
+ const filePath = paths.join(Config().contentServers.web.staticRoot, fileName);
- returnStaticPage(filePath, resp) {
- const self = this;
+ return this.returnStaticPage(filePath, resp);
+ }
- fs.stat(filePath, (err, stats) => {
- if(err) {
- return self.fileNotFound(resp);
- }
+ returnStaticPage(filePath, resp) {
+ const self = this;
- const headers = {
- 'Content-Type' : mimeTypes.contentType(paths.basename(filePath)) || mimeTypes.contentType('.bin'),
- 'Content-Length' : stats.size,
- };
+ fs.stat(filePath, (err, stats) => {
+ if(err || !stats.isFile()) {
+ return self.fileNotFound(resp);
+ }
- const readStream = fs.createReadStream(filePath);
- resp.writeHead(200, headers);
- return readStream.pipe(resp);
- });
- }
+ const headers = {
+ 'Content-Type' : mimeTypes.contentType(paths.basename(filePath)) || mimeTypes.contentType('.bin'),
+ 'Content-Length' : stats.size,
+ };
- routeTemplateFilePage(templatePath, preprocessCallback, resp) {
- const self = this;
+ const readStream = fs.createReadStream(filePath);
+ resp.writeHead(200, headers);
+ return readStream.pipe(resp);
+ });
+ }
- fs.readFile(templatePath, 'utf8', (err, templateData) => {
- if(err) {
- return self.fileNotFound(resp);
- }
+ routeTemplateFilePage(templatePath, preprocessCallback, resp) {
+ const self = this;
- preprocessCallback(templateData, (err, finalPage, contentType) => {
- if(err || !finalPage) {
- return self.respondWithError(resp, 500, 'Internal Server Error.', 'Internal Server Error');
- }
+ fs.readFile(templatePath, 'utf8', (err, templateData) => {
+ if(err) {
+ return self.fileNotFound(resp);
+ }
- const headers = {
- 'Content-Type' : contentType || mimeTypes.contentType('.html'),
- 'Content-Length' : finalPage.length,
- };
+ preprocessCallback(templateData, (err, finalPage, contentType) => {
+ if(err || !finalPage) {
+ return self.respondWithError(resp, 500, 'Internal Server Error.', 'Internal Server Error');
+ }
- resp.writeHead(200, headers);
- return resp.end(finalPage);
- });
- });
- }
+ 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..ee63ac78 100644
--- a/core/servers/login/ssh.js
+++ b/core/servers/login/ssh.js
@@ -1,224 +1,294 @@
/* 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');
-// 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 password = ctx.password || '';
- clientConn.on('authentication', function authAttempt(ctx) {
- const username = ctx.username || '';
- const password = ctx.password || '';
-
- self.isNewUser = (Config.users.newUserNames || []).indexOf(username) > -1;
+ const config = Config();
+ 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');
- function terminateConnection() {
- ctx.reject();
- clientConn.end();
- }
+ const safeContextReject = (param) => {
+ try {
+ return ctx.reject(param);
+ } catch(e) {
+ return;
+ }
+ };
- //
- // 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();
- }
+ const terminateConnection = () => {
+ safeContextReject();
+ return clientConn.end();
+ };
- if(username.length > 0 && password.length > 0) {
- loginAttempts += 1;
+ // slow version to thwart brute force attacks
+ const slowTerminateConnection = () => {
+ setTimeout( () => {
+ return terminateConnection();
+ }, 2000);
+ };
- 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 promptAndTerm = (msg, method = 'standard') => {
+ if('keyboard-interactive' === ctx.method) {
+ ctx.prompt(msg);
+ }
+ return 'slow' === method ? slowTerminateConnection() : terminateConnection();
+ };
- if(0 === username.length) {
- // :TODO: can we display something here?
- return ctx.reject();
- }
+ const accountAlreadyLoggedIn = (username) => {
+ return promptAndTerm(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`);
+ };
- let interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false };
+ const accountDisabled = (username) => {
+ return promptAndTerm(`${username} is disabled.\n(Press any key to continue)`);
+ };
- ctx.prompt(interactivePrompt, function retryPrompt(answers) {
- loginAttempts += 1;
+ const accountInactive = (username) => {
+ return promptAndTerm(`${username} is waiting for +op activation.\n(Press any key to continue)`);
+ };
- 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 accountLocked = (username) => {
+ return promptAndTerm(`${username} is locked.\n(Press any key to continue)`, 'slow');
+ };
- 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 isSpecialHandleError = (err) => {
+ return [ ErrorReasons.AlreadyLoggedIn, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked ].includes(err.reasonCode);
+ };
- interactivePrompt.prompt = `Access denied\n${stringFormat(artInfo.data, { newUserNames : newUserNameList })}\n${ctx.username}'s password'`;
- }
- return ctx.prompt(interactivePrompt, retryPrompt);
- });
- }
- }
- } else {
- ctx.accept();
- }
- });
- });
- }
- });
+ 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();
+ }
+ };
- 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 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();
+ }
- 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;
- }
+ if(username.length > 0 && password.length > 0) {
+ userLogin(self, ctx.username, ctx.password, function authResult(err) {
+ if(err) {
+ if(isSpecialHandleError(err)) {
+ return handleSpecialError(err, username);
+ }
- assert(_.isObject(self.term));
+ if(Errors.BadLogin().code === err.code) {
+ return slowTerminateConnection();
+ }
- //
- // 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;
+ return safeContextReject(SSHClient.ValidAuthMethods);
+ }
- self.clearMciCache(); // term size changes = invalidate cache
- }
+ ctx.accept();
+ });
+ } else {
+ if(-1 === SSHClient.ValidAuthMethods.indexOf(ctx.method)) {
+ return safeContextReject(SSHClient.ValidAuthMethods);
+ }
- if(_.isString(info.term) && info.term.length > 0 && 'unknown' === self.term.termType) {
- self.setTermType(info.term);
- }
- };
+ if(0 === username.length) {
+ // :TODO: can we display something here?
+ return safeContextReject();
+ }
- clientConn.once('ready', function clientReady() {
- self.log.info('SSH authentication success');
+ const interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false };
- clientConn.on('session', accept => {
-
- const session = accept();
+ ctx.prompt(interactivePrompt, function retryPrompt(answers) {
+ userLogin(self, username, (answers[0] || ''), err => {
+ if(err) {
+ if(isSpecialHandleError(err)) {
+ return handleSpecialError(err, username);
+ }
- session.on('pty', function pty(accept, reject, info) {
- self.log.debug(info, 'SSH pty event');
+ if(Errors.BadLogin().code === err.code) {
+ return slowTerminateConnection();
+ }
- if(_.isFunction(accept)) {
- accept();
- }
+ const artOpts = {
+ client : self,
+ name : 'SSHPMPT.ASC',
+ readSauce : false,
+ };
- if(self.input) { // do we have I/O?
- self.updateTermInfo(info);
- } else {
- self.cachedPtyInfo = info;
- }
- });
+ 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!)';
- session.on('shell', accept => {
- self.log.debug('SSH shell event');
+ interactivePrompt.prompt = `Access denied\n${stringFormat(artInfo.data, { newUserNames : newUserNameList })}\n${ctx.username}'s password:`;
+ }
+ return ctx.prompt(interactivePrompt, retryPrompt);
+ });
+ } else {
+ ctx.accept();
+ }
+ });
+ });
+ }
+ });
- const channel = accept();
+ this.dataHandler = function(data) {
+ self.emit('data', data);
+ };
- self.setInputOutput(channel.stdin, channel.stdout);
+ 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;
- channel.stdin.on('data', data => {
- self.emit('data', data);
- });
+ 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;
+ }
- if(self.cachedPtyInfo) {
- self.updateTermInfo(self.cachedPtyInfo);
- delete self.cachedPtyInfo;
- }
+ assert(_.isObject(self.term));
- // we're ready!
- const firstMenu = self.isNewUser ? Config.loginServers.ssh.firstMenuNewUser : Config.loginServers.ssh.firstMenu;
- self.emit('ready', { firstMenu : firstMenu } );
- });
+ //
+ // 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;
- session.on('window-change', (accept, reject, info) => {
- self.log.debug(info, 'SSH window-change event');
-
- self.updateTermInfo(info);
- });
+ self.clearMciCache(); // term size changes = invalidate cache
+ }
- });
- });
+ if(_.isString(info.term) && info.term.length > 0 && 'unknown' === self.term.termType) {
+ self.setTermType(info.term);
+ }
+ };
- clientConn.on('end', () => {
- self.emit('end'); // remove client connection/tracking
- });
+ clientConn.once('ready', function clientReady() {
+ self.log.info('SSH authentication success');
- clientConn.on('error', err => {
- self.log.warn( { error : err.message, code : err.code }, 'SSH connection error');
- });
+ 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);
@@ -226,44 +296,68 @@ util.inherits(SSHClient, baseClient.Client);
SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ];
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, 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 a6fa0deb..fb6ca745 100644
--- a/core/servers/login/telnet.js
+++ b/core/servers/login/telnet.js
@@ -1,869 +1,885 @@
/* jslint node: true */
'use strict';
-// 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');
+// ENiGMA½
+const baseClient = require('../../client.js');
+const Log = require('../../logger.js').log;
+const LoginServerModule = require('../../login_server_module.js');
+const Config = require('../../config.js').get;
+const EnigAssert = require('../../enigma_assert.js');
+const { stringFromNullTermBuffer } = require('../../string_util.js');
+const { Errors } = require('../../enig_error.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 buffers = require('buffers');
+const { Parser } = require('binary-parser');
+const util = 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',
+ author : 'NuSkooler',
+ isSecure : false,
+ packageName : 'codes.l33t.enigma.telnet.server',
};
-exports.TelnetClient = TelnetClient;
+exports.TelnetClient = TelnetClient;
//
-// Telnet Protocol Resources
-// * http://pcmicro.com/netfoss/telnet.html
-// * http://mud-dev.wikidot.com/telnet:negotiation
+// Telnet Protocol Resources
+// * http://pcmicro.com/netfoss/telnet.html
+// * http://mud-dev.wikidot.com/telnet:negotiation
//
/*
- 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.
- *
-
- * Allow term (ttype) to be set by environ sub negotiation
-
- * Process terms in loop.... research needed
-
- * Handle will/won't
- * Handle do's, ..
- * Some won't should close connection
-
- * Options/Commands we don't understand shouldn't crash the server!!
-
-
+ TODO:
+ * Various (much lesser used) Telnet command coverage
*/
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)
+ 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)
};
//
-// Resources:
-// * http://www.faqs.org/rfcs/rfc1572.html
+// Resources:
+// * http://www.faqs.org/rfcs/rfc1572.html
//
const SB_COMMANDS = {
- IS : 0,
- SEND : 1,
- INFO : 2,
+ IS : 0,
+ SEND : 1,
+ INFO : 2,
};
//
-// Telnet Options
+// Telnet Options
//
-// Resources
-// * http://mars.netanya.ac.il/~unesco/cdrom/booklet/HTML/NETWORKING/node300.html
-// * http://www.networksorcery.com/enp/protocol/telnet.htm
+// Resources
+// * http://mars.netanya.ac.il/~unesco/cdrom/booklet/HTML/NETWORKING/node300.html
+// * http://www.networksorcery.com/enp/protocol/telnet.htm
//
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,
+ 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
+ //PRAGMA_LOGON : 138,
+ //SSPI_LOGON : 139,
+ //PRAGMA_HEARTBEAT : 140
- ARE_YOU_THERE : 246, // aka 'AYT' RFC 854 @ https://tools.ietf.org/html/rfc854
+ ARE_YOU_THERE : 246, // aka 'AYT' RFC 854 @ https://tools.ietf.org/html/rfc854
- EXTENDED_OPTIONS_LIST : 255, // RFC 861 (STD 32)
+ EXTENDED_OPTIONS_LIST : 255, // RFC 861 (STD 32)
};
-// Commands used within NEW_ENVIRONMENT[_DEP]
+// Commands used within NEW_ENVIRONMENT[_DEP]
const NEW_ENVIRONMENT_COMMANDS = {
- VAR : 0,
- VALUE : 1,
- ESC : 2,
- USERVAR : 3,
+ 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 IAC_BUF = Buffer.from([ COMMANDS.IAC ]);
+const IAC_SE_BUF = Buffer.from([ COMMANDS.IAC, COMMANDS.SE ]);
const COMMAND_NAMES = Object.keys(COMMANDS).reduce(function(names, name) {
- names[COMMANDS[name]] = name.toLowerCase();
- return names;
+ 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);
- };
+ 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
+// :TODO: See TooTallNate's telnet.js: Handle COMMAND_IMPL for IAC in binary mode
-// Create option names such as 'transmit binary' -> OPTIONS.TRANSMIT_BINARY
+// 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;
+ 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');
- event.buf = bufs.splice(0, i).toBuffer();
- return event;
+ Log.warn( { bufs : bufs, i : i, event : event }, 'Unknown Telnet option');
+ event.buf = bufs.splice(0, i).toBuffer();
+ return event;
}
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;
+// :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;
- }
+ 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;
- }
+ const 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);
- });
+ let ttypeCmd;
+ try {
+ ttypeCmd = new Parser()
+ .uint8('iac1')
+ .uint8('sb')
+ .uint8('opt')
+ .uint8('is')
+ .array('ttype', {
+ type : 'uint8',
+ readUntil : b => 255 === b, // 255=COMMANDS.IAC
+ })
+ // note we read iac2 above
+ .uint8('se')
+ .parse(bufs.toBuffer());
+ } catch(e) {
+ Log.debug( { error : e }, 'Failed parsing TTYP telnet command');
+ return event;
+ }
- // eat up the rest
- end -= 4;
- buf = bufs.splice(0, end).toBuffer();
+ EnigAssert(COMMANDS.IAC === ttypeCmd.iac1);
+ EnigAssert(COMMANDS.SB === ttypeCmd.sb);
+ EnigAssert(OPTIONS.TERMINAL_TYPE === ttypeCmd.opt);
+ EnigAssert(SB_COMMANDS.IS === ttypeCmd.is);
+ EnigAssert(ttypeCmd.ttype.length > 0);
+ // note we found IAC_SE above
- //
- // 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;
- }
- }
+ // some terminals such as NetRunner provide a NULL-terminated buffer
+ // slice to remove IAC
+ event.ttype = stringFromNullTermBuffer(ttypeCmd.ttype.slice(0, -1), 'ascii');
- event.ttype = buf.toString('ascii', 0, trimAt);
+ bufs.splice(0, end);
+ }
- // pop off the terminating IAC SE
- bufs.splice(0, 2);
- }
-
- return event;
+ 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;
- }
+ 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);
+ let nawsCmd;
+ try {
+ nawsCmd = new Parser()
+ .uint8('iac1')
+ .uint8('sb')
+ .uint8('opt')
+ .uint16be('width')
+ .uint16be('height')
+ .uint8('iac2')
+ .uint8('se')
+ .parse(bufs.splice(0, 9).toBuffer());
+ } catch(e) {
+ Log.debug( { error : e }, 'Failed parsing NAWS telnet command');
+ return event;
+ }
- event.cols = event.columns = event.width = vars.width;
- event.rows = event.height = vars.height;
- });
- }
- return event;
+ EnigAssert(COMMANDS.IAC === nawsCmd.iac1);
+ EnigAssert(COMMANDS.SB === nawsCmd.sb);
+ EnigAssert(OPTIONS.WINDOW_SIZE === nawsCmd.opt);
+ EnigAssert(COMMANDS.IAC === nawsCmd.iac2);
+ EnigAssert(COMMANDS.SE === nawsCmd.se);
+
+ event.cols = event.columns = event.width = nawsCmd.width;
+ event.rows = event.height = nawsCmd.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]);
-});
+// Build an array of delimiters for parsing NEW_ENVIRONMENT[_DEP]
+//const NEW_ENVIRONMENT_DELIMITERS = _.values(NEW_ENVIRONMENT_COMMANDS);
-// 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;
- }
+// 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;
- }
+ 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);
+ // :TODO: It's likely that we could do all the env name/value parsing directly in Parser.
- event.type = vars.isOrInfo;
+ let envCmd;
+ try {
+ envCmd = new Parser()
+ .uint8('iac1')
+ .uint8('sb')
+ .uint8('opt')
+ .uint8('isOrInfo') // IS=initial, INFO=updates
+ .array('envBlock', {
+ type : 'uint8',
+ readUntil : b => 255 === b, // 255=COMMANDS.IAC
+ })
+ // note we consume IAC above
+ .uint8('se')
+ .parse(bufs.splice(0, bufs.length).toBuffer());
+ } catch(e) {
+ Log.debug( { error : e }, 'Failed parsing NEW-ENVIRON telnet command');
+ return event;
+ }
- if(vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP) {
- // :TODO: bring all this into Telnet class
- Log.log.warn('Handling deprecated RFC 1408 NEW-ENVIRON');
- }
- });
+ EnigAssert(COMMANDS.IAC === envCmd.iac1);
+ EnigAssert(COMMANDS.SB === envCmd.sb);
+ EnigAssert(OPTIONS.NEW_ENVIRONMENT === envCmd.opt || OPTIONS.NEW_ENVIRONMENT_DEP === envCmd.opt);
+ EnigAssert(SB_COMMANDS.IS === envCmd.isOrInfo || SB_COMMANDS.INFO === envCmd.isOrInfo);
- // eat up the rest
- end -= 4;
- buf = bufs.splice(0, end).toBuffer();
+ if(OPTIONS.NEW_ENVIRONMENT_DEP === envCmd.opt) {
+ // :TODO: we should probably support this for legacy clients?
+ Log.warn('Handling deprecated RFC 1408 NEW-ENVIRON');
+ }
- //
- // 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;
- }
+ const envBuf = envCmd.envBlock.slice(0, -1); // remove IAC
- params.push(buf.slice(p, j));
- p = j;
- }
+ if(envBuf.length < 4) { // TYPE + single char name + sep + single char value
+ // empty env block
+ return event;
+ }
- // remainder
- if(p < l) {
- params.push(buf.slice(p, l));
- }
+ const States = {
+ Name : 1,
+ Value : 2,
+ };
- 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 state = States.Name;
+ const setVars = {};
+ const delVars = [];
+ let varName;
+ // :TODO: handle ESC type!!!
+ while(envBuf.length) {
+ switch(state) {
+ case States.Name :
+ {
+ const type = parseInt(envBuf.splice(0, 1));
+ if(![ NEW_ENVIRONMENT_COMMANDS.VAR, NEW_ENVIRONMENT_COMMANDS.USERVAR, NEW_ENVIRONMENT_COMMANDS.ESC ].includes(type)) {
+ return event; // fail :(
+ }
- 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?
- }
- }
+ let nameEnd = envBuf.indexOf(NEW_ENVIRONMENT_COMMANDS.VALUE);
+ if(-1 === nameEnd) {
+ nameEnd = envBuf.length;
+ }
- // pop off remaining IAC SE
- bufs.splice(0, 2);
- }
+ varName = envBuf.splice(0, nameEnd);
+ if(!varName) {
+ return event; // something is wrong.
+ }
- return event;
+ varName = Buffer.from(varName).toString('ascii');
+
+ const next = parseInt(envBuf.splice(0, 1));
+ if(NEW_ENVIRONMENT_COMMANDS.VALUE === next) {
+ state = States.Value;
+ } else {
+ state = States.Name;
+ delVars.push(varName); // no value; del this var
+ }
+ }
+ break;
+
+ case States.Value :
+ {
+ let valueEnd = envBuf.indexOf(NEW_ENVIRONMENT_COMMANDS.VAR);
+ if(-1 === valueEnd) {
+ valueEnd = envBuf.indexOf(NEW_ENVIRONMENT_COMMANDS.USERVAR);
+ }
+ if(-1 === valueEnd) {
+ valueEnd = envBuf.length;
+ }
+
+ let value = envBuf.splice(0, valueEnd);
+ if(value) {
+ value = Buffer.from(value).toString('ascii');
+ setVars[varName] = value;
+ }
+ state = States.Name;
+ }
+ break;
+ }
+ }
+
+ // :TODO: Handle deleting previously set vars via delVars
+ event.type = envCmd.isOrInfo;
+ event.envVars = setVars;
+ }
+
+ return event;
};
-const MORE_DATA_REQUIRED = 0xfeedface;
+const MORE_DATA_REQUIRED = 0xfeedface;
function parseBufs(bufs) {
- EnigAssert(bufs.length >= 2);
- EnigAssert(bufs.get(0) === COMMANDS.IAC);
- return parseCommand(bufs, 1, {});
+ 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 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
- }
+ 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;
- }
+ 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 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);
+ 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);
+ baseClient.Client.apply(this, arguments);
- const self = this;
+ const self = this;
- let bufs = buffers();
- this.bufs = bufs;
+ let bufs = buffers();
+ this.bufs = bufs;
- this.sentDont = {}; // DON'T's we've already sent
+ this.sentDont = {}; // DON'T's we've already sent
- this.setInputOutput(input, output);
+ this.setInputOutput(input, output);
- this.negotiationsComplete = false; // are we in the 'negotiation' phase?
- this.didReady = false; // have we emit the 'ready' event?
+ this.negotiationsComplete = false; // are we in the 'negotiation' phase?
+ this.didReady = false; // have we emit the 'ready' event?
- this.subNegotiationState = {
- newEnvironRequested : false,
- };
+ this.subNegotiationState = {
+ newEnvironRequested : false,
+ };
- this.setTemporaryDirectDataHandler = function(handler) {
- this.input.removeAllListeners('data');
- this.input.on('data', handler);
- };
+ this.dataHandler = function(b) {
+ if(!Buffer.isBuffer(b)) {
+ EnigAssert(false, `Cannot push non-buffer ${typeof b}`);
+ return;
+ }
- this.restoreDataHandler = function() {
- this.input.removeAllListeners('data');
- this.input.on('data', this.dataHandler);
- };
+ bufs.push(b);
- this.dataHandler = function(b) {
- if(!Buffer.isBuffer(b)) {
- EnigAssert(false, `Cannot push non-buffer ${typeof b}`);
- return;
- }
+ let i;
+ while((i = bufs.indexOf(IAC_BUF)) >= 0) {
- bufs.push(b);
+ //
+ // Some clients will send even IAC separate from data
+ //
+ if(bufs.length <= (i + 1)) {
+ i = MORE_DATA_REQUIRED;
+ break;
+ }
- let i;
- while((i = bufs.indexOf(IAC_BUF)) >= 0) {
+ EnigAssert(bufs.length > (i + 1));
- //
- // Some clients will send even IAC separate from data
- //
- if(bufs.length <= (i + 1)) {
- i = MORE_DATA_REQUIRED;
- break;
- }
+ if(i > 0) {
+ self.emit('data', bufs.splice(0, i).toBuffer());
+ }
- EnigAssert(bufs.length > (i + 1));
-
- if(i > 0) {
- self.emit('data', bufs.splice(0, i).toBuffer());
- }
+ i = parseBufs(bufs);
- i = parseBufs(bufs);
-
- if(MORE_DATA_REQUIRED === i) {
- break;
- } else if(i) {
- if(i.option) {
- self.emit(i.option, i); // "transmit binary", "echo", ...
- }
+ if(MORE_DATA_REQUIRED === i) {
+ break;
+ } else if(i) {
+ if(i.option) {
+ self.emit(i.option, i); // "transmit binary", "echo", ...
+ }
- self.handleTelnetEvent(i);
+ self.handleTelnetEvent(i);
- if(i.data) {
- self.emit('data', i.data);
- }
- }
- }
+ 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());
- }
- };
+ 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('data', this.dataHandler);
- this.input.on('end', () => {
- self.emit('end');
- });
+ this.input.on('end', () => {
+ self.emit('end');
+ });
- this.input.on('error', err => {
- this.connectionDebug( { err : err }, 'Socket error' );
- return 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.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.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}`);
- };
+ this.connectionWarn = (info, msg) => {
+ const logger = self.log || Log;
+ return logger.warn(info, `Telnet: ${msg}`);
+ };
- this.readyNow = () => {
- if(!this.didReady) {
- this.didReady = true;
- this.emit('ready', { firstMenu : Config.loginServers.telnet.firstMenu } );
- }
- };
+ this.readyNow = () => {
+ if(!this.didReady) {
+ this.didReady = true;
+ this.emit('ready', { firstMenu : Config().loginServers.telnet.firstMenu } );
+ }
+ };
+
+ this.disconnect = function() {
+ try {
+ return this.output.end.apply(this.output, arguments);
+ }
+ catch(e) {
+ // nothing
+ }
+ };
}
util.inherits(TelnetClient, baseClient.Client);
///////////////////////////////////////////////////////////////////////////////
-// Telnet Command/Option handling
+// 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(!evt.command) {
+ return this.connectionWarn( { evt : evt }, 'No command for event');
+ }
- if(this[handlerName]) {
- // specialized
- this[handlerName](evt);
- } else {
- // generic-ish
- this.handleMiscCommand(evt);
- }
+ // 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');
- }
+ 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');
- }
+ if(this.sentDont[evt.option]) {
+ return this.connectionTrace(evt, 'WONT - DON\'T already sent');
+ }
- this.sentDont[evt.option] = true;
+ this.sentDont[evt.option] = true;
- if('new environment' === evt.option) {
- this.dont.new_environment();
- } else {
- this.connectionTrace(evt, 'WONT');
- }
+ 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
+ // :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');
- }
+ 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');
+ this.connectionTrace(evt, 'DONT');
};
TelnetClient.prototype.handleSbCommand = function(evt) {
- const self = this;
+ 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);
+ 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
+ self.negotiationsComplete = true; // :TODO: throw in a array of what we've taken care. Complete = array satisified or timeout
- self.readyNow();
- } 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) {
+ self.readyNow();
+ } 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
- );
+ 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' );
- }
- }
- });
+ 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;
- }
+ } 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.height > 0) {
- self.term.env.ROWS = evt.height;
- }
+ if(evt.width > 0) {
+ self.term.env.COLUMNS = evt.height;
+ }
- self.clearMciCache(); // term size changes = invalidate cache
+ if(evt.height > 0) {
+ self.term.env.ROWS = evt.height;
+ }
- self.connectionDebug({ termWidth : evt.width , termHeight : evt.height, source : 'NAWS' }, 'Window size updated');
- } else {
- self.connectionDebug(evt, 'SB');
- }
+ 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);
-});
+const IGNORED_COMMANDS = [
+ COMMANDS.EL, COMMANDS.GA, COMMANDS.NOP, COMMANDS.DM, COMMANDS.BRK
+];
TelnetClient.prototype.handleMiscCommand = function(evt) {
- EnigAssert(evt.command !== 'undefined' && evt.command.length > 0);
+ 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');
- }
+ //
+ // 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.trace({ command : evt.command, commandCode : evt.commandCode }, '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 buf = Buffer.from( [
+ 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' )
+ Buffer.from( 'LINES' ),
+ Buffer.from( 'COLUMNS' ),
+ Buffer.from( 'TERM' ),
+ Buffer.from( 'TERM_PROGRAM' )
];
TelnetClient.prototype.requestNewEnvironment = function() {
- if(this.subNegotiationState.newEnvironRequested) {
- this.log.debug('New environment already requested');
- return;
- }
+ if(this.subNegotiationState.newEnvironRequested) {
+ this.log.debug('New environment already requested');
+ return;
+ }
- const self = this;
+ const self = this;
- const bufs = buffers();
- bufs.push(new Buffer( [
- COMMANDS.IAC,
- COMMANDS.SB,
- OPTIONS.NEW_ENVIRONMENT,
- SB_COMMANDS.SEND ]
- ));
+ const bufs = buffers();
+ bufs.push(Buffer.from( [
+ 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] );
- }
+ for(let i = 0; i < WANTED_ENVIRONMENT_VAR_BUFS.length; ++i) {
+ bufs.push(Buffer.from( [ NEW_ENVIRONMENT_COMMANDS.VAR ] ), WANTED_ENVIRONMENT_VAR_BUFS[i] );
+ }
- bufs.push(new Buffer([ NEW_ENVIRONMENT_COMMANDS.USERVAR, COMMANDS.IAC, COMMANDS.SE ]));
+ bufs.push(Buffer.from([ NEW_ENVIRONMENT_COMMANDS.USERVAR, COMMANDS.IAC, COMMANDS.SE ]));
- self.output.write(bufs.toBuffer());
+ self.output.write(bufs.toBuffer());
- this.subNegotiationState.newEnvironRequested = true;
+ this.subNegotiationState.newEnvironRequested = true;
};
TelnetClient.prototype.banner = function() {
- this.will.echo();
+ this.will.echo();
- this.will.suppress_go_ahead();
- this.do.suppress_go_ahead();
+ this.will.suppress_go_ahead();
+ this.do.suppress_go_ahead();
- this.do.transmit_binary();
- this.will.transmit_binary();
+ this.do.transmit_binary();
+ this.will.transmit_binary();
- this.do.terminal_type();
+ this.do.terminal_type();
- this.do.window_size();
- this.do.new_environment();
+ this.do.window_size();
+ this.do.new_environment();
};
function Command(command, client) {
- this.command = COMMANDS[command.toUpperCase()];
- this.client = client;
+ this.command = COMMANDS[command.toUpperCase()];
+ this.client = client;
}
-// Create Command objects with echo, transmit_binary, ...
+// Create Command objects with echo, transmit_binary, ...
Object.keys(OPTIONS).forEach(function(name) {
- const code = OPTIONS[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);
- };
+ Command.prototype[name.toLowerCase()] = function() {
+ const buf = Buffer.alloc(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
+// Create do, dont, etc. methods on Client
['do', 'dont', 'will', 'wont'].forEach(function(command) {
- const get = function() {
- return new Command(command, this);
- };
+ const get = function() {
+ return new Command(command, this);
+ };
- Object.defineProperty(TelnetClient.prototype, command, {
- get : get,
- enumerable : true,
- configurable : true
- });
+ Object.defineProperty(TelnetClient.prototype, command, {
+ get : get,
+ enumerable : true,
+ configurable : true
+ });
});
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( sock => {
+ const client = new TelnetClient(sock, sock);
- client.banner();
+ client.banner();
- this.handleNewClient(client, sock, ModuleInfo);
+ this.handleNewClient(client, sock, ModuleInfo);
- //
- // Set a timeout and attempt to proceed even if we don't know
- // the term type yet, which is the preferred trigger
- // for moving along
- //
- setTimeout( () => {
- if(!client.didReady) {
- Log.info('Proceeding after 3s without knowing term type');
- client.readyNow();
- }
- }, 3000);
- });
+ //
+ // Set a timeout and attempt to proceed even if we don't know
+ // the term type yet, which is the preferred trigger
+ // for moving along
+ //
+ setTimeout( () => {
+ if(!client.didReady) {
+ Log.info('Proceeding after 3s without knowing term type');
+ client.readyNow();
+ }
+ }, 3000);
+ });
- this.server.on('error', err => {
- Log.info( { error : err.message }, 'Telnet server error');
- });
- }
+ this.server.on('error', err => {
+ Log.info( { error : err.message }, 'Telnet server error');
+ });
- 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;
- }
+ return cb(null);
+ }
- this.server.listen(port);
- Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' );
- return true;
- }
+ 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}`));
+ }
+
+ this.server.listen(port, err => {
+ if(!err) {
+ Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' );
+ }
+ return cb(err);
+ });
+ }
};
diff --git a/core/servers/login/websocket.js b/core/servers/login/websocket.js
index 378af7ef..35bc0757 100644
--- a/core/servers/login/websocket.js
+++ b/core/servers/login/websocket.js
@@ -1,106 +1,119 @@
/* 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 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) {
- Object.defineProperty(this, 'isSecure', {
- get : () => ('secure' === serverType || true === this.proxied) ? true : false,
- });
+ Object.defineProperty(this, 'isSecure', {
+ get : () => ('secure' === serverType || true === this.proxied) ? true : false,
+ });
- const self = this;
+ const self = this;
- //
- // 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;
- }
+ this.dataHandler = function(data) {
+ if(self.pipedDest) {
+ self.pipedDest.write(data);
+ } else {
+ self.socketBridge.emit('data', data);
+ }
+ };
- end() {
- return ws.close();
- }
+ //
+ // 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;
+ }
- write(data, cb) {
- cb = cb || ( () => { /* eat it up */} ); // handle data writes after close
+ end() {
+ return ws.close();
+ }
- return this.ws.send(data, { binary : true }, cb);
- }
+ write(data, cb) {
+ cb = cb || ( () => { /* eat it up */} ); // handle data writes after close
- // we need to fake some streaming work
- unpipe() {
- Log.trace('WebSocket SocketBridge unpipe()');
- }
+ return this.ws.send(data, { binary : true }, cb);
+ }
- resume() {
- Log.trace('WebSocket SocketBridge resume()');
- }
+ pipe(dest) {
+ Log.trace('WebSocket SocketBridge pipe()');
+ self.pipedDest = dest;
+ }
- 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);
+ unpipe() {
+ Log.trace('WebSocket SocketBridge unpipe()');
+ self.pipedDest = null;
+ }
- ws.on('message', data => {
- this.socketBridge.emit('data', data);
- });
+ resume() {
+ Log.trace('WebSocket SocketBridge resume()');
+ }
- ws.on('close', () => {
- // we'll remove client connection which will in turn end() via our SocketBridge above
- return this.emit('end');
- });
+ 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);
- //
- // Montior connection status with ping/pong
- //
- ws.on('pong', () => {
- Log.trace(`Pong from ${this.socketBridge.remoteAddress}`);
- ws.isConnectionAlive = true;
- });
+ ws.on('message', this.dataHandler);
- TelnetClient.call(this, this.socketBridge, this.socketBridge);
+ ws.on('close', () => {
+ // we'll remove client connection which will in turn end() via our SocketBridge above
+ return this.emit('end');
+ });
- Log.trace( { headers : req.headers }, 'WebSocket connection headers' );
+ //
+ // Montior connection status with ping/pong
+ //
+ ws.on('pong', () => {
+ Log.trace(`Pong from ${this.socketBridge.remoteAddress}`);
+ ws.isConnectionAlive = true;
+ });
- //
- // 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;
- }
+ TelnetClient.call(this, this.socketBridge, this.socketBridge);
- // start handshake process
- this.banner();
+ 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;
+ }
+
+ // start handshake process
+ this.banner();
}
require('util').inherits(WebSocketClient, TelnetClient);
@@ -108,98 +121,114 @@ 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 confPort = _.get(Config(), [ 'loginServers', 'webSocket', 'secure' === serverType ? 'wss' : 'ws', '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, 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.socketBridge, ModuleInfo);
+ });
+
+ Log.info( { server : serverName, port : port }, 'Listening for connections' );
+ return nextServerType(null);
+ });
+ },
+ err => {
+ cb(err);
+ });
+ }
+
+ webSocketConnection(conn) {
+ const webSocketClient = new WebSocketClient(conn);
+ this.handleNewClient(webSocketClient, webSocketClient.socketShim, ModuleInfo);
+ }
};
diff --git a/core/set_newscan_date.js b/core/set_newscan_date.js
index 0e29e999..7713f647 100644
--- a/core/set_newscan_date.js
+++ b/core/set_newscan_date.js
@@ -1,261 +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');
+// 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 stringFormat = require('./string_format.js');
+ 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');
+// 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',
+ name : 'Set New Scan Date',
+ desc : 'Sets new scan date for applicable scans',
+ author : 'NuSkooler',
};
const MciViewIds = {
- main : {
- scanDate : 1,
- targetSelection : 2,
- }
+ main : {
+ scanDate : 1,
+ targetSelection : 2,
+ }
};
-// :TODO: for messages, we could insert "conf - all areas" into targets, and allow such
+// :TODO: for messages, we could insert "conf - all areas" into targets, and allow such
exports.getModule = class SetNewScanDate extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- const config = this.menuConfig.config;
+ const config = this.menuConfig.config;
- this.target = config.target || 'message';
- this.scanDateFormat = config.scanDateFormat || 'YYYYMMDD';
+ 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'));
- }
+ 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`));
- }
+ 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
+ const targetSelection = _.get(formData, 'value.targetSelection'); // may be undefined if N/A
- this[`setNewScanDateFor${_.capitalize(this.target)}Base`](targetSelection, scanDate, () => {
- return this.prevMenu(cb);
- });
- },
- };
- }
+ 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'));
- }
+ 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 ];
- }
+ // 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);
- }
+ async.each(updateAreaTags, (areaTag, nextAreaTag) => {
+ getMessageIdNewerThanTimestampByArea(areaTag, scanDate, (err, messageId) => {
+ if(err) {
+ return nextAreaTag(err);
+ }
- if(!messageId) {
- return nextAreaTag(null); // nothing to do
- }
+ if(!messageId) {
+ return nextAreaTag(null); // nothing to do
+ }
- messageId = Math.max(messageId - 1, 0);
+ messageId = Math.max(messageId - 1, 0);
- return updateMessageAreaLastReadId(
- this.client.user.userId,
- areaTag,
- messageId,
- true, // allowOlder
- nextAreaTag
- );
- });
- }, err => {
- return cb(err);
- });
- }
+ 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',
- };
+ 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);
- }
+ FileEntry.findFiles(filterCriteria, (err, fileIds) => {
+ if(err) {
+ return cb(err);
+ }
- if(!fileIds || 0 === fileIds.length) {
- // nothing to do
- return cb(null);
- }
+ if(!fileIds || 0 === fileIds.length) {
+ // nothing to do
+ return cb(null);
+ }
- const pointerFileId = Math.max(fileIds[0] - 1, 0);
+ const pointerFileId = Math.max(fileIds[0] - 1, 0);
- return FileBaseFilters.setFileBaseLastViewedFileIdForUser(
- this.client.user,
- pointerFileId,
- true, // allowOlder
- cb
- );
- });
- }
+ 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,
- name : conf.conf.name,
- desc : conf.conf.desc,
- },
- area : {
- areaTag : area.areaTag,
- name : area.area.name,
- desc : area.area.desc,
- }
- });
- });
- });
+ 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 : '',
- name : 'All conferences',
- desc : 'All conferences',
- },
- area : {
- areaTag : '',
- name : 'All areas',
- desc : 'All areas',
- }
- });
+ selections.unshift({
+ conf : {
+ confTag : '',
+ text : 'All conferences',
+ name : 'All conferences',
+ desc : 'All conferences',
+ },
+ area : {
+ areaTag : '',
+ text : 'All areas',
+ name : 'All areas',
+ desc : 'All areas',
+ }
+ });
- // Find current conf/area & move it directly under "All"
- const currConfTag = this.client.user.properties.message_conf_tag;
- const currAreaTag = this.client.user.properties.message_area_tag;
- if(currConfTag && currAreaTag) {
- const confAreaIndex = selections.findIndex( confArea => {
- return confArea.conf.confTag === currConfTag && confArea.area.areaTag === currAreaTag;
- });
+ // 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]);
- }
- }
+ if(confAreaIndex > -1) {
+ selections.splice(1, 0, selections.splice(confAreaIndex, 1)[0]);
+ }
+ }
- this.targetSelections = selections;
+ this.targetSelections = selections;
- return cb(null);
- }
+ return cb(null);
+ }
- mciReady(mciData, cb) {
- super.mciReady(mciData, err => {
- if(err) {
- return cb(err);
- }
+ 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 } ) );
+ 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);
+ 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();
+ default :
+ return callback(null);
+ }
+ },
+ function populateForm(callback) {
+ const today = moment();
- const scanDateView = vc.getView(MciViewIds.main.scanDate);
+ 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));
+ // :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 messageSelectionsFormat = self.menuConfig.config.messageSelectionsFormat || '{conf.name} - {area.name}';
- const messageSelectionFocusFormat = self.menuConfig.config.messageSelectionFocusFormat || messageSelectionsFormat;
+ if('message' === self.target) {
+ const targetSelectionView = vc.getView(MciViewIds.main.targetSelection);
- const targetSelectionView = vc.getView(MciViewIds.main.targetSelection);
+ targetSelectionView.setItems(self.targetSelections);
+ targetSelectionView.setFocusItemIndex(0);
+ }
- targetSelectionView.setItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection)));
- targetSelectionView.setFocusItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection)));
-
- targetSelectionView.setFocusItemIndex(0);
- }
-
- self.viewControllers.main.resetInitialFocus();
- //vc.switchFocus(MciViewIds.main.scanDate);
- return callback(null);
- }
- ],
- err => {
- return cb(err);
- }
- );
- });
- }
+ 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..a480fa05
--- /dev/null
+++ b/core/show_art.js
@@ -0,0 +1,201 @@
+/* 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) {
+ 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 7fb7109a..4a5b110c 100644
--- a/core/string_format.js
+++ b/core/string_format.js
@@ -1,356 +1,369 @@
/* jslint node: true */
'use strict';
-const EnigError = require('./enig_error.js').EnigError;
+const EnigError = require('./enig_error.js').EnigError;
const {
- pad,
- stylizeString,
- renderStringLength,
- renderSubstr,
- formatByteSize, formatByteSizeAbbr,
- formatCount, formatCountAbbr,
-} = require('./string_util.js');
+ pad,
+ stylizeString,
+ renderStringLength,
+ renderSubstr,
+ formatByteSize, formatByteSizeAbbr,
+ formatCount, formatCountAbbr,
+} = require('./string_util.js');
-// deps
-const _ = require('lodash');
+// 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'),
- // :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),
+ // :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 238aeeee..4f7741ae 100644
--- a/core/string_util.js
+++ b/core/string_util.js
@@ -1,657 +1,459 @@
/* 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.formatCountAbbr = formatCountAbbr;
-exports.formatCount = formatCount;
-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.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;
-// :TODO: create Unicode verison of this
+// :TODO: create Unicode verison of this
const VOWELS = [ 'a', 'e', 'i', 'o', 'u' ];
VOWELS.concat(VOWELS.map(l => l.toUpperCase()));
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);
-}
-
-function stringLength(s) {
- // :TODO: See https://mathiasbynens.be/notes/javascript-unicode
- return s.length;
+ //
+ // 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 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 BYTE_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 BYTE_SIZE_ABBRS[0]; // B
- }
-
- return BYTE_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 = 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 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 '';
- }
+ if(count < 1000) {
+ return '';
+ }
- return COUNT_ABBRS[Math.floor(Math.log(count) / Math.log(1000))];
+ 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;
+ 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) {
- if(!input || 0 === input.length) {
- return false;
- }
-
- //
- // * 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);
}
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..ea2cbc09 100644
--- a/core/system_menu_method.js
+++ b/core/system_menu_method.js
@@ -1,173 +1,188 @@
/* 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');
-// 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.logoff = logoff;
+exports.prevMenu = prevMenu;
+exports.nextMenu = nextMenu;
+exports.prevConf = prevConf;
+exports.nextConf = nextConf;
+exports.prevArea = prevArea;
+exports.nextArea = nextArea;
+exports.sendForgotPasswordEmail = sendForgotPasswordEmail;
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) {
+ // 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);
+ }
+
+ // 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 e2a5b2e0..5eb0fc4a 100644
--- a/core/system_view_validate.js
+++ b/core/system_view_validate.js
@@ -1,154 +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;
-const { getAddressedToInfo } = require('./mail_util.js');
-const Message = require('./message.js');
+// 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.validateUserNameOrRealNameExists = validateUserNameOrRealNameExists;
-exports.validateGeneralMailAddressedTo = validateGeneralMailAddressedTo;
-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 {
- // 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'));
- }
+ 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);
- });
- }
- }
+ return cb(null);
+ });
+ }
+ }
}
const invalidUserNameError = () => new Error('Invalid username');
function validateUserNameExists(data, cb) {
- 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());
- }
+ if(0 === data.length) {
+ return cb(invalidUserNameError());
+ }
- User.getUserIdAndNameByLookup(data, err => {
- return cb(err ? invalidUserNameError() : null);
- });
+ 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);
+ //
+ // 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);
- }
+ if(Message.AddressFlavor.FTN === addressedToInfo.flavor) {
+ return cb(null);
+ }
- return validateUserNameOrRealNameExists(data, cb);
+ 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
index fa1754a5..a3a4d672 100644
--- a/core/telnet_bridge.js
+++ b/core/telnet_bridge.js
@@ -1,198 +1,218 @@
/* 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;
+// 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 buffers = require('buffers');
+// deps
+const async = require('async');
+const _ = require('lodash');
+const net = require('net');
+const EventEmitter = require('events');
+const buffers = require('buffers');
/*
- Expected configuration block:
+ Expected configuration block:
- {
- module: telnet_bridge
- ...
- config: {
- host: somehost.net
- port: 23
- }
- }
+ {
+ module: telnet_bridge
+ ...
+ config: {
+ host: somehost.net
+ port: 23
+ }
+ }
*/
-// :TODO: ENH: Support nodeMax and tooManyArt
+// :TODO: ENH: Support nodeMax and tooManyArt
exports.moduleInfo = {
- name : 'Telnet Bridge',
- desc : 'Connect to other Telnet Systems',
- author : 'Andrew Pamment',
+ name : 'Telnet Bridge',
+ desc : 'Connect to other Telnet Systems',
+ author : 'Andrew Pamment',
};
-const IAC_DO_TERM_TYPE = new Buffer( [ 255, 253, 24 ] );
+const IAC_DO_TERM_TYPE = Buffer.from( [ 255, 253, 24 ] );
class TelnetClientConnection extends EventEmitter {
- constructor(client) {
- super();
+ constructor(client) {
+ super();
- this.client = client;
- }
+ this.client = client;
+ }
-
- restorePipe() {
- if(!this.pipeRestored) {
- this.pipeRestored = true;
- // 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();
- }
- }
- }
+ restorePipe() {
+ if(!this.pipeRestored) {
+ this.pipeRestored = true;
- connect(connectOpts) {
- this.bridgeConnection = net.createConnection(connectOpts, () => {
- this.emit('connected');
+ // 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();
+ }
+ }
+ }
- this.pipeRestored = false;
- this.client.term.output.pipe(this.bridgeConnection);
- });
+ connect(connectOpts) {
+ this.bridgeConnection = net.createConnection(connectOpts, () => {
+ this.emit('connected');
- this.bridgeConnection.on('data', data => {
- this.client.term.rawWrite(data);
+ this.pipeRestored = false;
+ this.client.term.output.pipe(this.bridgeConnection);
+ });
- //
- // 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.on('data', data => {
+ this.client.term.rawWrite(data);
- this.bridgeConnection.once('end', () => {
- this.restorePipe();
- this.emit('end');
- });
+ //
+ // 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('error', err => {
- this.restorePipe();
- this.emit('end', err);
- });
- }
+ this.bridgeConnection.once('end', () => {
+ this.restorePipe();
+ this.emit('end');
+ });
- disconnect() {
- if(this.bridgeConnection) {
- this.bridgeConnection.end();
- }
- }
+ this.bridgeConnection.once('error', err => {
+ this.restorePipe();
+ this.emit('end', err);
+ });
+ }
- 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
- ]
- ));
+ disconnect() {
+ if(this.bridgeConnection) {
+ this.bridgeConnection.end();
+ }
+ }
- bufs.push(
- new Buffer(this.client.term.termType), // e.g. "ansi"
- new Buffer( [ 255, 240 ] ) // IAC, SE
- );
+ destroy() {
+ if(this.bridgeConnection) {
+ this.bridgeConnection.destroy();
+ this.bridgeConnection.removeAllListeners();
+ this.restorePipe();
+ this.emit('end');
+ }
+ }
- return bufs.toBuffer();
- }
+ getTermTypeNegotiationBuffer() {
+ //
+ // Create a TERMINAL-TYPE sub negotiation buffer using the
+ // actual/current terminal type.
+ //
+ let bufs = buffers();
+
+ bufs.push(Buffer.from(
+ [
+ 255, // IAC
+ 250, // SB
+ 24, // TERMINAL-TYPE
+ 0, // IS
+ ]
+ ));
+
+ bufs.push(
+ Buffer.from(this.client.term.termType), // e.g. "ansi"
+ Buffer.from( [ 255, 240 ] ) // IAC, SE
+ );
+
+ return bufs.toBuffer();
+ }
}
exports.getModule = class TelnetBridgeModule extends MenuModule {
- constructor(options) {
- super(options);
+ constructor(options) {
+ super(options);
- this.config = options.menuConfig.config;
- // defaults
- this.config.port = this.config.port || 23;
- }
-
- initSequence() {
- let clientTerminated;
- const self = this;
+ this.config = Object.assign({}, _.get(options, 'menuConfig.config'), options.extraArgs);
+ this.config.port = this.config.port || 23;
+ }
- 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,
- };
+ initSequence() {
+ let clientTerminated;
+ const self = this;
- let clientTerminated;
+ 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`);
+ 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);
-
- telnetConnection.on('connected', () => {
- self.client.log.info(connectOpts, 'Telnet bridge connection established');
+ const telnetConnection = new TelnetClientConnection(self.client);
- if(self.config.font) {
- self.client.term.rawWrite(setSyncTermFontWithAlias(self.config.font));
- }
+ const connectionKeyPressHandler = (ch, key) => {
+ if('escape' === key.name) {
+ self.client.removeListener('key press', connectionKeyPressHandler);
+ telnetConnection.destroy();
+ }
+ };
- self.client.once('end', () => {
- self.client.log.info('Connection ended. Terminating connection');
- clientTerminated = true;
- telnetConnection.disconnect();
- });
- });
+ self.client.on('key press', connectionKeyPressHandler);
- telnetConnection.on('end', err => {
- if(err) {
- self.client.log.info(`Telnet bridge connection error: ${err.message}`);
- }
+ telnetConnection.on('connected', () => {
+ self.client.removeListener('key press', connectionKeyPressHandler);
+ self.client.log.info(connectOpts, 'Telnet bridge connection established');
- callback(clientTerminated ? new Error('Client connection terminated') : null);
- });
+ if(self.config.font) {
+ self.client.term.rawWrite(setSyncTermFontWithAlias(self.config.font));
+ }
- telnetConnection.connect(connectOpts);
- }
- ],
- err => {
- if(err) {
- self.client.log.warn( { error : err.message }, 'Telnet connection error');
- }
+ self.client.once('end', () => {
+ self.client.log.info('Connection ended. Terminating connection');
+ clientTerminated = true;
+ telnetConnection.disconnect();
+ });
+ });
- if(!clientTerminated) {
- self.prevMenu();
- }
- }
- );
- }
+ 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 0796b8ab..9978fde3 100644
--- a/core/theme.js
+++ b/core/theme.js
@@ -1,168 +1,173 @@
/* 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.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,540 +181,539 @@ 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 = 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 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) {
+ const displayOpts = {
+ sauce : artInfo.sauce,
+ font : options.font,
+ trailingLF : options.trailingLF,
+ };
+ art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
+ return callback(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
+ });
+ }
+ ],
+ (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) => {
- if(err) {
- return callback(err);
- }
+ displayThemedAsset(
+ promptConfig.art,
+ client,
+ dispOptions,
+ (err, artInfo) => {
+ if(err) {
+ return callback(err);
+ }
- 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);
- }
-
- client.once('cursor position report', pos => {
- artInfo.startRow = pos[0] - artInfo.height;
- return callback(null, promptConfig, artInfo);
- });
+ 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);
+ }
- client.term.rawWrite(ansi.queryPos());
- },
- function createMCIViews(promptConfig, artInfo, callback) {
- const tempViewController = useTempViewController ? new ViewController( { client : client } ) : options.viewController;
+ client.once('cursor position report', pos => {
+ artInfo.startRow = pos[0] - artInfo.height;
+ return callback(null, promptConfig, artInfo);
+ });
- const loadOpts = {
- promptName : name,
- mciMap : artInfo.mciMap,
- config : promptConfig,
- };
+ client.term.rawWrite(ansi.queryPos());
+ },
+ function createMCIViews(promptConfig, artInfo, callback) {
+ const assocViewController = usingTempViewController ? new ViewController( { client : client } ) : options.viewController;
- tempViewController.loadFromPromptConfig(loadOpts, () => {
- return callback(null, artInfo, tempViewController);
- });
- },
- function pauseForUserInput(artInfo, tempViewController, callback) {
- if(!options.pause) {
- return callback(null, artInfo, tempViewController);
- }
+ const loadOpts = {
+ promptName : name,
+ mciMap : artInfo.mciMap,
+ config : promptConfig,
+ submitNotify : options.submitNotify,
+ };
- 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));
- }
- }
+ assocViewController.loadFromPromptConfig(loadOpts, () => {
+ return callback(null, artInfo, assocViewController);
+ });
+ },
+ function pauseForUserInput(artInfo, assocViewController, callback) {
+ if(!options.pause) {
+ return callback(null, artInfo, assocViewController);
+ }
- return callback(null, tempViewController);
- }
- ],
- (err, tempViewController) => {
- if(err) {
- client.log.warn( { error : err.message }, `Failed displaying "${name}" prompt` );
- }
+ 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));
- if(tempViewController && useTempViewController) {
- tempViewController.detachClientEvents();
- }
+ // Note: Does not work properly in NetRunner < 2.0b17:
+ client.term.rawWrite(ansi.deleteLine(artInfo.height));
+ } else {
+ client.term.rawWrite(ansi.eraseLine(1));
+ }
+ }
- return cb(null);
- }
- );
+ 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..aadfe9c3 100644
--- a/core/toggle_menu_view.js
+++ b/core/toggle_menu_view.js
@@ -1,123 +1,123 @@
/* 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();
+ //this.cachePositions();
- this.client.term.write(this.hasFocus ? this.getFocusSGR() : this.getSGR());
+ this.client.term.write(this.hasFocus ? this.getFocusSGR() : this.getSGR());
- 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 + ' / ');
- }
+ 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);
- this.client.term.write(i === this.focusedItemIndex ? this.getFocusSGR() : this.getSGR());
- this.client.term.write(text);
- }
+ 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.nam4e)) {
+ 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
index 5a49a0ca..6eaff2ab 100644
--- a/core/upload.js
+++ b/core/upload.js
@@ -1,727 +1,740 @@
/* 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;
+// 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');
+// 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',
+ name : 'Upload',
+ desc : 'Module for classic file uploads',
+ author : 'NuSkooler',
};
const FormIds = {
- options : 0,
- processing : 1,
- fileDetails : 2,
- dupes : 3,
+ 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)
- },
+ 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
- },
+ 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
- },
+ 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,
- }
+ 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(!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);
- },
- ],
- 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
- }
- );
- }
+ 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 }
+ );
+
+ if(!err && 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(!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 09d26163..3b261dc6 100644
--- a/core/user.js
+++ b/core/user.js
@@ -1,608 +1,805 @@
/* 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');
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)
- }
-
- // static property accessors
- static get RootUserID() {
- return 1;
- }
-
- static get PBKDF2() {
- return {
- iterations : 1000,
- keyLen : 128,
- saltLen : 32,
- };
- }
-
- static get StandardPropertyGroups() {
- return {
- password : [ 'pw_pbkdf2_salt', 'pw_pbkdf2_dk' ],
- };
- }
-
- static get AccountStatus() {
- return {
- disabled : 0,
- inactive : 1,
- active : 2,
- };
- }
-
- isAuthenticated() {
- return true === this.authenticated;
- }
-
- isValid() {
- if(this.userId <= 0 || this.username.length < Config.users.usernameMin) {
- return false;
- }
-
- return this.hasValidPassword();
- }
-
- hasValidPassword() {
- if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) {
- return false;
- }
-
- return this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2 && this.prop_name.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2;
- }
-
- isRoot() {
- return User.isRootUserId(this.userId);
- }
-
- isSysOp() { // alias to isRoot()
- return this.isRoot();
- }
-
- isGroupMember(groupNames) {
- if(_.isString(groupNames)) {
- groupNames = [ groupNames ];
- }
-
- const isMember = groupNames.some(gn => (-1 !== this.groups.indexOf(gn)));
- return isMember;
- }
-
- getLegacySecurityLevel() {
- if(this.isRoot() || this.isGroupMember('sysops')) {
- return 100;
- }
-
- if(this.isGroupMember('users')) {
- return 30;
- }
-
- return 10; // :TODO: Is this what we want?
- }
-
- authenticate(username, password, cb) {
- const self = this;
- const cachedInfo = {};
-
- async.waterfall(
- [
- function fetchUserId(callback) {
- // get user ID
- User.getUserIdAndName(username, (err, uid, un) => {
- cachedInfo.userId = uid;
- cachedInfo.username = un;
-
- 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');
-
- if(passDkBuf.length !== propsDkBuf.length) {
- return callback(Errors.AccessDenied('Invalid password'));
- }
-
- let c = 0;
- for(let i = 0; i < passDkBuf.length; i++) {
- c |= passDkBuf[i] ^ propsDkBuf[i];
- }
-
- return callback(0 === c ? null : Errors.AccessDenied('Invalid password'));
- },
- function initProps(callback) {
- User.loadProperties(cachedInfo.userId, (err, allProps) => {
- if(!err) {
- cachedInfo.properties = allProps;
- }
-
- return callback(err);
- });
- },
- function initGroups(callback) {
- userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => {
- if(!err) {
- cachedInfo.groups = groups;
- }
-
- 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(err);
- }
- );
- }
-
- create(password, cb) {
- assert(0 === this.userId);
-
- if(this.username.length < Config.users.usernameMin || this.username.length > Config.users.usernameMax) {
- return cb(Errors.Invalid('Invalid username length'));
- }
-
- const self = this;
-
- // :TODO: set various defaults, e.g. default activation status, etc.
- self.properties.account_status = Config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active;
-
- 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);
- }
-
- self.userId = this.lastID;
-
- // Do not require activation for userId 1 (root/admin)
- if(User.RootUserID === self.userId) {
- self.properties.account_status = User.AccountStatus.active;
- }
-
- return callback(null, trans);
- }
- );
- },
- function genAuthCredentials(trans, 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, trans);
- });
- },
- function setInitialGroupMembership(trans, callback) {
- self.groups = Config.users.defaultGroups;
-
- if(User.RootUserID === self.userId) { // root/SysOp?
- self.groups.push('sysops');
- }
-
- return callback(null, trans);
- },
- function saveAll(trans, callback) {
- self.persistWithTransaction(trans, err => {
- return callback(err, trans);
- });
- }
- ],
- (err, trans) => {
- if(trans) {
- trans[err ? 'rollback' : 'commit'](transErr => {
- return cb(err ? err : transErr);
- });
- } else {
- return cb(err);
- }
- }
- );
- }
-
- persistWithTransaction(trans, cb) {
- assert(this.userId > 0);
-
- const self = this;
-
- 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);
- }
- );
- }
-
- persistProperty(propName, propValue, cb) {
- // update live props
- this.properties[propName] = propValue;
-
- userDb.run(
- `REPLACE INTO user_property (user_id, prop_name, prop_value)
- VALUES (?, ?, ?);`,
- [ this.userId, propName, propValue ],
- err => {
- if(cb) {
- return cb(err);
- }
- }
- );
- }
-
- 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);
- }
- }
- );
- }
-
- 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 = {
- pw_pbkdf2_salt : info.salt,
- pw_pbkdf2_dk : info.dk,
- };
-
- this.persistProperties(newProperties, err => {
- return cb(err);
- });
- });
- }
-
- getAge() {
- if(_.has(this.properties, 'birthdate')) {
- return moment().diff(this.properties.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;
- user.authenticated = false; // this is NOT an authenticated user!
-
- 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='real_name' 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) {
- let userList = [];
- let 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) {
- 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 = new Buffer(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'));
- });
- }
+ constructor() {
+ this.userId = 0;
+ this.username = '';
+ this.properties = {}; // name:value
+ this.groups = []; // group membership(s)
+ }
+
+ // static property accessors
+ static get RootUserID() {
+ return 1;
+ }
+
+ static get PBKDF2() {
+ return {
+ iterations : 1000,
+ keyLen : 128,
+ saltLen : 32,
+ };
+ }
+
+ static get StandardPropertyGroups() {
+ return {
+ password : [ UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk ],
+ };
+ }
+
+ 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.)
+ };
+ }
+
+ static isSamePasswordSlowCompare(passBuf1, passBuf2) {
+ if(passBuf1.length !== passBuf2.length) {
+ return false;
+ }
+
+ let c = 0;
+ for(let i = 0; i < passBuf1.length; i++) {
+ c |= passBuf1[i] ^ passBuf2[i];
+ }
+ return 0 === c;
+ }
+
+ isAuthenticated() {
+ return true === this.authenticated;
+ }
+
+ isValid() {
+ if(this.userId <= 0 || this.username.length < Config().users.usernameMin) {
+ return false;
+ }
+
+ return this.hasValidPasswordProperties();
+ }
+
+ hasValidPasswordProperties() {
+ const salt = this.getProperty(UserProps.PassPbkdf2Salt);
+ const dk = this.getProperty(UserProps.PassPbkdf2Dk);
+
+ if(!salt || !dk ||
+ (salt.length !== User.PBKDF2.saltLen * 2) ||
+ (dk.length !== User.PBKDF2.keyLen * 2))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ isRoot() {
+ return User.isRootUserId(this.userId);
+ }
+
+ isSysOp() { // alias to isRoot()
+ return this.isRoot();
+ }
+
+ isGroupMember(groupNames) {
+ if(_.isString(groupNames)) {
+ groupNames = [ groupNames ];
+ }
+
+ const isMember = groupNames.some(gn => (-1 !== this.groups.indexOf(gn)));
+ return isMember;
+ }
+
+ getSanitizedName(type='username') {
+ const name = 'real' === type ? this.getProperty(UserProps.RealName) : this.username;
+ return sanatizeFilename(name) || `user${this.userId.toString()}`;
+ }
+
+ getLegacySecurityLevel() {
+ if(this.isRoot() || this.isGroupMember('sysops')) {
+ return 100;
+ }
+
+ if(this.isGroupMember('users')) {
+ return 30;
+ }
+
+ return 10; // :TODO: Is this what we want?
+ }
+
+ processFailedLogin(userId, cb) {
+ async.waterfall(
+ [
+ (callback) => {
+ return User.getUser(userId, callback);
+ },
+ (tempUser, callback) => {
+ return StatLog.incrementUserStat(
+ tempUser,
+ UserProps.FailedLoginAttempts,
+ 1,
+ (err, failedAttempts) => {
+ return callback(null, tempUser, failedAttempts);
+ }
+ );
+ },
+ (tempUser, failedAttempts, callback) => {
+ const lockAccount = _.get(Config(), 'users.failedLogin.lockAccount');
+ if(lockAccount > 0 && failedAttempts >= lockAccount) {
+ const props = {
+ [ UserProps.AccountStatus ] : User.AccountStatus.locked,
+ [ UserProps.AccountLockedTs ] : StatLog.now,
+ };
+ if(!_.has(tempUser.properties, UserProps.AccountLockedPrevStatus)) {
+ props[UserProps.AccountLockedPrevStatus] = tempUser.getProperty(UserProps.AccountStatus);
+ }
+ Log.info( { userId, failedAttempts }, '(Re)setting account to locked due to failed logins');
+ return tempUser.persistProperties(props, callback);
+ }
+
+ return cb(null);
+ }
+ ],
+ err => {
+ return cb(err);
+ }
+ );
+ }
+
+ unlockAccount(cb) {
+ const prevStatus = this.getProperty(UserProps.AccountLockedPrevStatus);
+ if(!prevStatus) {
+ return cb(null); // nothing to do
+ }
+
+ this.persistProperty(UserProps.AccountStatus, prevStatus, err => {
+ if(err) {
+ return cb(err);
+ }
+
+ return this.removeProperties( [ UserProps.AccountLockedPrevStatus, UserProps.AccountLockedTs ], cb);
+ });
+ }
+
+ authenticate(username, password, cb) {
+ const self = this;
+ const tempAuthInfo = {};
+
+ async.waterfall(
+ [
+ function fetchUserId(callback) {
+ // get user ID
+ User.getUserIdAndName(username, (err, uid, un) => {
+ tempAuthInfo.userId = uid;
+ tempAuthInfo.username = un;
+
+ return callback(err);
+ });
+ },
+ function getRequiredAuthProperties(callback) {
+ // fetch properties required for authentication
+ User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => {
+ return callback(err, props);
+ });
+ },
+ function getDkWithSalt(props, callback) {
+ // get DK from stored salt and password provided
+ User.generatePasswordDerivedKey(password, props[UserProps.PassPbkdf2Salt], (err, dk) => {
+ return callback(err, dk, props[UserProps.PassPbkdf2Dk]);
+ });
+ },
+ function validateAuth(passDk, propsDk, callback) {
+ //
+ // Use constant time comparison here for security feel-goods
+ //
+ const passDkBuf = Buffer.from(passDk, 'hex');
+ const propsDkBuf = Buffer.from(propsDk, 'hex');
+
+ return callback(User.isSamePasswordSlowCompare(passDkBuf, propsDkBuf) ?
+ null :
+ Errors.AccessDenied('Invalid password')
+ );
+ },
+ function initProps(callback) {
+ User.loadProperties(tempAuthInfo.userId, (err, allProps) => {
+ if(!err) {
+ tempAuthInfo.properties = allProps;
+ }
+
+ return callback(err);
+ });
+ },
+ function checkAccountStatus(callback) {
+ const accountStatus = parseInt(tempAuthInfo.properties[UserProps.AccountStatus], 10);
+ if(User.AccountStatus.disabled === accountStatus) {
+ return callback(Errors.AccessDenied('Account disabled', ErrorReasons.Disabled));
+ }
+ if(User.AccountStatus.inactive === accountStatus) {
+ return callback(Errors.AccessDenied('Account inactive', ErrorReasons.Inactive));
+ }
+
+ if(User.AccountStatus.locked === accountStatus) {
+ const autoUnlockMinutes = _.get(Config(), 'users.failedLogin.autoUnlockMinutes');
+ const lockedTs = moment(tempAuthInfo.properties[UserProps.AccountLockedTs]);
+ if(autoUnlockMinutes && lockedTs.isValid()) {
+ const minutesSinceLocked = moment().diff(lockedTs, 'minutes');
+ if(minutesSinceLocked >= autoUnlockMinutes) {
+ // allow the login - we will clear any lock there
+ Log.info(
+ { username, userId : tempAuthInfo.userId, lockedAt : lockedTs.format() },
+ 'Locked account will now be unlocked due to auto-unlock minutes policy'
+ );
+ return callback(null);
+ }
+ }
+ return callback(Errors.AccessDenied('Account is locked', ErrorReasons.Locked));
+ }
+
+ // anything else besides active is still not allowed
+ if(User.AccountStatus.active !== accountStatus) {
+ return callback(Errors.AccessDenied('Account is not active'));
+ }
+
+ return callback(null);
+ },
+ function initGroups(callback) {
+ userGroup.getGroupsForUser(tempAuthInfo.userId, (err, groups) => {
+ if(!err) {
+ tempAuthInfo.groups = groups;
+ }
+
+ 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.authenticated = true;
+
+ self.removeProperty(UserProps.FailedLoginAttempts);
+
+ //
+ // We need to *revert* any locked status back to
+ // the user's previous status & clean up props.
+ //
+ self.unlockAccount(unlockErr => {
+ if(unlockErr) {
+ Log.warn( { error : unlockErr.message }, 'Failed to unlock account');
+ }
+ return cb(null);
+ });
+ }
+ }
+ );
+ }
+
+ create(createUserInfo , cb) {
+ assert(0 === this.userId);
+ const config = Config();
+
+ if(this.username.length < config.users.usernameMin || this.username.length > config.users.usernameMax) {
+ return cb(Errors.Invalid('Invalid username length'));
+ }
+
+ const self = this;
+
+ // :TODO: set various defaults, e.g. default activation status, etc.
+ self.properties[UserProps.AccountStatus] = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active;
+
+ 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);
+ }
+
+ self.userId = this.lastID;
+
+ // Do not require activation for userId 1 (root/admin)
+ if(User.RootUserID === self.userId) {
+ self.properties[UserProps.AccountStatus] = User.AccountStatus.active;
+ }
+
+ return callback(null, trans);
+ }
+ );
+ },
+ function genAuthCredentials(trans, callback) {
+ User.generatePasswordDerivedKeyAndSalt(createUserInfo.password, (err, info) => {
+ if(err) {
+ return callback(err);
+ }
+
+ self.properties[UserProps.PassPbkdf2Salt] = info.salt;
+ self.properties[UserProps.PassPbkdf2Dk] = info.dk;
+ return callback(null, trans);
+ });
+ },
+ function setInitialGroupMembership(trans, callback) {
+ self.groups = config.users.defaultGroups;
+
+ if(User.RootUserID === self.userId) { // root/SysOp?
+ self.groups.push('sysops');
+ }
+
+ 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);
+ }
+ }
+ );
+ }
+
+ persistWithTransaction(trans, cb) {
+ assert(this.userId > 0);
+
+ const self = this;
+
+ 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);
+ }
+ );
+ }
+
+ 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);
+ }
+ }
+ );
+ }
+
+ setProperty(propName, propValue) {
+ this.properties[propName] = propValue;
+ }
+
+ 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;
+ }
+
+ getProperty(propName) {
+ return this.properties[propName];
+ }
+
+ getPropertyAsNumber(propName) {
+ return parseInt(this.getProperty(propName), 10);
+ }
+
+ persistProperty(propName, propValue, cb) {
+ // update live props
+ this.properties[propName] = propValue;
+
+ 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;
+ user.authenticated = false; // this is NOT an authenticated user!
+
+ 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_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..d2748c4b 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.getView(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.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 = {
+ [ 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);
+ }
+ });
+ },
+ };
+ }
+
+ getView(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.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);
+ }
+ }
+ );
+ });
+ }
};
diff --git a/core/user_group.js b/core/user_group.js
index 3903f2c3..4b1548b8 100644
--- a/core/user_group.js
+++ b/core/user_group.js
@@ -1,70 +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, transOrDb, cb) {
- if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
- cb = transOrDb;
- transOrDb = userDb;
- }
+ if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
+ cb = transOrDb;
+ transOrDb = userDb;
+ }
- transOrDb.run(
- 'REPLACE INTO user_group_member (group_name, user_id) ' +
- 'VALUES(?, ?);',
- [ groupName, userId ],
- function complete(err) {
- cb(err);
- }
- );
+ transOrDb.run(
+ `REPLACE INTO user_group_member (group_name, user_id)
+ VALUES(?, ?);`,
+ [ groupName, userId ],
+ err => {
+ return cb(err);
+ }
+ );
}
function addUserToGroups(userId, groups, transOrDb, cb) {
- async.each(groups, function item(groupName, next) {
- addUserToGroup(userId, groupName, transOrDb, 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
index be85c586..3b342fab 100644
--- a/core/user_list.js
+++ b/core/user_list.js
@@ -1,112 +1,85 @@
/* jslint node: true */
'use strict';
-const MenuModule = require('./menu_module.js').MenuModule;
-const User = require('./user.js');
-const ViewController = require('./view_controller.js').ViewController;
-const stringFormat = require('./string_format.js');
+// ENiGMA½
+const { MenuModule } = require('./menu_module.js');
+const { getUserList } = require('./user.js');
+const { Errors } = require('./enig_error.js');
+const UserProps = require('./user_property.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
-*/
+// deps
+const moment = require('moment');
+const async = require('async');
+const _ = require('lodash');
exports.moduleInfo = {
- name : 'User List',
- desc : 'Lists all system users',
- author : 'NuSkooler',
+ name : 'User List',
+ desc : 'Lists all system users',
+ author : 'NuSkooler',
};
const MciViewIds = {
- UserList : 1,
+ userList : 1,
};
exports.getModule = class UserListModule 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);
+ }
- const self = this;
- const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
+ 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}`));
+ }
- let 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 USER_LIST_OPTS = {
- properties : [ 'location', 'affiliation', 'last_login_timestamp' ],
- };
+ const dateTimeFormat = _.get(
+ this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateTimeFormat('short'));
- async.series(
- [
- function loadFromConfig(callback) {
- var loadOpts = {
- callingMenu : self,
- mciMap : mciData.menu,
- };
+ userList = userList.map(entry => {
+ return Object.assign(
+ entry,
+ {
+ text : entry.userName,
+ affils : entry.affiliation,
+ lastLoginTs : moment(entry.lastLoginTimestamp).format(dateTimeFormat),
+ }
+ );
+ });
- 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);
- }
- );
- });
- }
+ 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..3e3f5f04 100644
--- a/core/user_login.js
+++ b/core/user_login.js
@@ -1,87 +1,130 @@
/* 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');
-// deps
-const async = require('async');
+// deps
+const async = require('async');
+const _ = require('lodash');
-exports.userLogin = userLogin;
+exports.userLogin = userLogin;
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');
+ const config = Config();
- // :TODO: if username exists, record failed login attempt to properties
- // :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true
+ if(config.users.badUserNames.includes(username.toLowerCase())) {
+ client.log.info( { username, ip : client.remoteAddress }, 'Attempt to login with banned username');
- return cb(err);
- }
- const user = client.user;
+ // slow down a bit to thwart brute force attacks
+ return setTimeout( () => {
+ return cb(Errors.BadLogin('Disallowed username', ErrorReasons.NotAllowed));
+ }, 2000);
+ }
- //
- // 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;
- }
- });
+ client.user.authenticate(username, password, err => {
+ if(err) {
+ client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1;
+ const disconnect = config.users.failedLogin.disconnect;
+ if(disconnect > 0 && client.user.sessionFailedLoginAttempts >= disconnect) {
+ err = Errors.BadLogin('To many failed login attempts', ErrorReasons.TooMany);
+ }
- if(existingClientConnection) {
- client.log.info(
- {
- existingClientId : existingClientConnection.session.id,
- username : user.username,
- userId : user.userId
- },
- 'Already logged in'
- );
+ client.log.info( { username, ip : client.remoteAddress, reason : err.message }, 'Failed login attempt');
+ return cb(err);
+ }
- const existingConnError = new Error('Already logged in as supplied user');
- existingConnError.existingConn = true;
+ const user = client.user;
- // :TODO: We should use EnigError & pass existing connection as second param
+ // Good login; reset any failed attempts
+ delete user.sessionFailedLoginAttempts;
- return cb(existingConnError);
- }
+ //
+ // 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
+ });
+ if(existingClientConnection) {
+ client.log.info(
+ {
+ existingClientId : existingClientConnection.session.id,
+ username : user.username,
+ userId : user.userId
+ },
+ 'Already logged in'
+ );
- // 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');
+ return cb(Errors.BadLogin(
+ `User ${user.username} already logged in.`,
+ ErrorReasons.AlreadyLoggedIn
+ ));
+ }
- 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);
- }
- );
- });
+ // update client logger with addition of username
+ client.log = logger.log.child(
+ {
+ clientId : client.log.fields.clientId,
+ 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 } );
+
+ async.parallel(
+ [
+ function setTheme(callback) {
+ setClientTheme(client, user.properties[UserProps.ThemeId]);
+ return callback(null);
+ },
+ function updateSystemLoginCount(callback) {
+ StatLog.incrementNonPersistentSystemStat(SysProps.LoginsToday, 1);
+ return StatLog.incrementSystemStat(SysProps.LoginCount, 1, callback);
+ },
+ function recordLastLogin(callback) {
+ return StatLog.setUserStat(user, UserProps.LastLoginTs, StatLog.now, callback);
+ },
+ function updateUserLoginCount(callback) {
+ return StatLog.incrementUserStat(user, UserProps.LoginCount, 1, callback);
+ },
+ function recordLoginHistory(callback) {
+ const loginHistoryMax = Config().statLog.systemEvents.loginHistoryMax;
+ const historyItem = JSON.stringify({
+ userId : user.userId,
+ sessionId : user.sessionId,
+ });
+
+ return StatLog.appendSystemLogEntry(
+ SystemLogKeys.UserLoginHistory,
+ historyItem,
+ loginHistoryMax,
+ StatLog.KeepType.Max,
+ callback
+ );
+ }
+ ],
+ err => {
+ return cb(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..56e47e66
--- /dev/null
+++ b/core/user_property.js
@@ -0,0 +1,61 @@
+/* jslint node: true */
+'use strict';
+
+//
+// Common user properties used throughout the system.
+//
+// This IS NOT a full list. For example, custom modules
+// can utilize their own properties as well!
+//
+module.exports = {
+ PassPbkdf2Salt : 'pw_pbkdf2_salt',
+ PassPbkdf2Dk : 'pw_pbkdf2_dk',
+
+ AccountStatus : 'account_status', // See User.AccountStatus enum
+
+ RealName : 'real_name',
+ Sex : 'sex',
+ Birthdate : 'birthdate',
+ Location : 'location',
+ Affiliations : 'affiliation',
+ EmailAddress : 'email_address',
+ WebAddress : 'web_address',
+ TermHeight : 'term_height',
+ TermWidth : 'term_width',
+ ThemeId : 'theme_id',
+ AccountCreated : 'account_created',
+ LastLoginTs : 'last_login_timestamp',
+ LoginCount : 'login_count',
+ UserComment : 'user_comment', // NYI
+
+ DownloadQueue : 'dl_queue', // download_queue.js
+
+ FailedLoginAttempts : 'failed_login_attempts',
+ AccountLockedTs : 'account_locked_timestamp',
+ AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out
+
+ EmailPwResetToken : 'email_password_reset_token',
+ EmailPwResetTokenTs : 'email_password_reset_token_ts',
+
+ FileAreaTag : 'file_area_tag',
+ FileBaseFilters : 'file_base_filters',
+ FileBaseFilterActiveUuid : 'file_base_filter_active_uuid',
+ FileBaseLastViewedId : 'user_file_base_last_viewed',
+ FileDlTotalCount : 'dl_total_count',
+ FileUlTotalCount : 'ul_total_count',
+ FileDlTotalBytes : 'dl_total_bytes',
+ FileUlTotalBytes : 'ul_total_bytes',
+
+ MessageConfTag : 'message_conf_tag',
+ MessageAreaTag : 'message_area_tag',
+ MessagePostCount : 'post_count',
+
+ DoorRunTotalCount : 'door_run_total_count',
+ DoorRunTotalMinutes : 'door_run_total_minutes',
+
+ AchievementTotalCount : 'achievement_total_count',
+ AchievementTotalPoints : 'achievement_total_points',
+
+ MinutesOnlineTotalCount : 'minutes_online_total_count',
+};
+
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..7d3c5693 100644
--- a/core/view.js
+++ b/core/view.js
@@ -1,276 +1,275 @@
/* 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 'height' : this.setHeight(value); break;
+ case 'width' : this.setWidth(value); break;
+ case 'focus' : this.setFocus(value); break;
- case 'textStyle' : this.textStyle = value; break;
- case 'focusTextStyle' : this.focusTextStyle = value; break;
+ case 'text' :
+ if('setText' in this) {
+ this.setText(value);
+ }
+ break;
- case 'justify' : this.justify = value; break;
+ case 'textStyle' : this.textStyle = value; break;
+ case 'focusTextStyle' : this.focusTextStyle = 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 'justify' : this.justify = value; break;
- case 'submit' :
- if(_.isBoolean(value)) {
- this.submit = value;
- }/* else {
- this.submit = _.isArray(value) && value.length > 0;
- }
- */
- 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 'resizable' :
- if(_.isBoolean(value)) {
- this.resizable = value;
- }
- break;
+ case 'submit' :
+ if(_.isBoolean(value)) {
+ this.submit = value;
+ }/* else {
+ this.submit = _.isArray(value) && value.length > 0;
+ }
+ */
+ break;
- case 'argName' : this.submitArgName = value; break;
+ case 'resizable' :
+ if(_.isBoolean(value)) {
+ this.resizable = value;
+ }
+ break;
- case 'validate' :
- if(_.isFunction(value)) {
- this.validate = value;
- }
- break;
- }
+ case 'argName' : this.submitArgName = 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 '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..84f33756 100644
--- a/core/view_controller.js
+++ b/core/view_controller.js
@@ -1,864 +1,885 @@
/* 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.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.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 = false;
+ if(cb) {
+ return cb(null);
+ }
+ });
+ };
- this.viewActionListener = function(action, key) {
- switch(action) {
- case 'next' :
- self.emit('action', { view : this, action : action, key : key });
- 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);
+ }
+ }
+ };
- 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.viewActionListener = function(action, key) {
+ switch(action) {
+ case 'next' :
+ self.emit('action', { view : this, action : action, key : key });
+ self.nextFocus();
+ break;
- this.submitForm = function(key) {
- self.emit('submit', this.getFormData(key));
- };
+ 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;
+ }
+ };
- // :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;
- };
+ this.submitForm = function(key) {
+ self.emit('submit', this.getFormData(key));
+ };
- this.switchFocusEvent = function(event, view) {
- if(self.emitSwitchFocus) {
- return;
- }
+ // :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;
+ };
- self.emitSwitchFocus = true;
- self.emit(event, view);
- self.emitSwitchFocus = false;
- };
+ this.switchFocusEvent = function(event, view) {
+ if(self.emitSwitchFocus) {
+ return;
+ }
- this.createViewsFromMCI = function(mciMap, cb) {
- async.each(Object.keys(mciMap), function entry(name, nextItem) {
- var mci = mciMap[name];
- var view = self.mciViewFactory.createFromMCI(mci);
+ self.emitSwitchFocus = true;
+ self.emit(event, view);
+ self.emitSwitchFocus = false;
+ };
- if(view) {
- if(false === self.noInput) {
- view.on('action', self.viewActionListener);
- }
+ this.createViewsFromMCI = function(mciMap, cb) {
+ async.each(Object.keys(mciMap), (name, nextItem) => {
+ const mci = mciMap[name];
+ const view = self.mciViewFactory.createFromMCI(mci);
- self.addView(view);
- }
+ if(view) {
+ if(false === self.noInput) {
+ view.on('action', self.viewActionListener);
+ }
- nextItem(null);
- },
- function complete(err) {
- self.setViewOrder();
- cb(err);
- });
- };
+ self.addView(view);
+ }
- // :TODO: move this elsewhere
- this.setViewPropertiesFromMCIConf = function(view, conf) {
+ return nextItem(null);
+ },
+ err => {
+ self.setViewOrder();
+ return cb(err);
+ });
+ };
- var propAsset;
- var propValue;
+ // :TODO: move this elsewhere
+ this.setViewPropertiesFromMCIConf = function(view, conf) {
- function callModuleMethod(path) {
- if('' === paths.extname(path)) {
- path += '.js';
- }
+ var propAsset;
+ var propValue;
- 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');
- }
- }
+ for(var propName in conf) {
+ propAsset = asset.getViewPropertyAsset(conf[propName]);
+ if(propAsset) {
+ switch(propAsset.type) {
+ case 'config' :
+ propValue = asset.resolveConfigAsset(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;
-
- case 'sysStat' :
- propValue = asset.resolveSystemStatAsset(conf[propName]);
- break;
+ case 'sysStat' :
+ propValue = asset.resolveSystemStatAsset(conf[propName]);
+ break;
- // :TODO: handle @art (e.g. text : @art ...)
+ // :TODO: handle @art (e.g. text : @art ...)
- 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)) {
+ 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;
- } 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 = 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) {
+ let highestId = 1;
+ let submitId;
+ let initialFocusId = 1;
- this.applyViewConfig = function(config, cb) {
- var highestId = 1;
- var submitId;
- var initialFocusId = 1;
+ 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;
+ }
- 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;
- }
+ const viewId = parseInt(mciMatch[2]);
+ assert(!isNaN(viewId), 'Cannot parse view ID: ' + mciMatch[2]); // shouldn't be possible with RegExp used
- var 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;
- }
+ const view = self.getView(viewId);
- var view = self.getView(viewId);
-
- if(!view) {
- self.client.log.warn( { viewId : viewId }, 'Cannot find view');
- nextItem(null);
- return;
- }
+ if(!view) {
+ self.client.log.warn( { viewId : viewId }, 'Cannot find view');
+ nextItem(null);
+ return;
+ }
- var mciConf = config.mci[mci];
+ const mciConf = config.mci[mci];
- self.setViewPropertiesFromMCIConf(view, mciConf);
+ self.setViewPropertiesFromMCIConf(view, mciConf);
- if(mciConf.focus) {
- initialFocusId = viewId;
- }
+ if(mciConf.focus) {
+ initialFocusId = viewId;
+ }
- 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(true === view.submit) {
+ submitId = viewId;
+ }
- cb(err, { initialFocusId : initialFocusId } );
- });
- };
+ 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');
+ }
+ }
- // 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;
- }
+ return cb(err, { initialFocusId : initialFocusId } );
+ });
+ };
- if(null !== actionValue[viewId] && actionValue[viewId] !== formValue[viewId]) {
- return false;
- }
- }
- }
+ // 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;
+ }
- self.client.log.trace(
- {
- formValue : formValue,
- actionValue : actionValue
- },
- 'Action match'
- );
+ 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;
+ }
- return true;
- };
+ if(null !== actionValue[viewId] && actionValue[viewId] !== formValue[viewId]) {
+ return false;
+ }
+ }
+ }
- if(!options.detached) {
- this.attachClientEvents();
- }
+ self.client.log.trace(
+ {
+ formValue : formValue,
+ actionValue : actionValue
+ },
+ 'Action match'
+ );
- this.setViewFocusWithEvents = function(view, focused) {
- if(!view || !view.acceptsFocus) {
- return;
- }
+ return true;
+ };
- if(focused) {
- self.switchFocusEvent('return', view);
- self.focusedView = view;
- } else {
- self.switchFocusEvent('leave', view);
- }
+ if(!options.detached) {
+ this.attachClientEvents();
+ }
- view.setFocus(focused);
- };
+ this.setViewFocusWithEvents = function(view, focused) {
+ if(!view || !view.acceptsFocus) {
+ return;
+ }
- 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
- }
+ if(focused) {
+ self.switchFocusEvent('return', view);
+ self.focusedView = view;
+ } else {
+ self.switchFocusEvent('leave', view);
+ }
- viewValidationListener(err, function validationComplete(newViewFocusId) {
- cb(err, newViewFocusId);
- });
- } else {
- cb(err);
- }
- });
- } else {
- cb(null);
- }
- };
+ view.setFocus(focused);
+ };
+
+ 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 : self.getLogFriendlyFormData(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 : self.getLogFriendlyFormData(formData) }, 'Form submit');
+ self.client.log.trace( { formData : self.getLogFriendlyFormData(formData) }, 'Form 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;
- }
+ //
+ // Locate configuration for this form ID
+ //
+ const confForFormId =
+ _.get(formConfig, [ 'submit', formData.submitId ]) ||
+ _.get(formConfig, [ 'submit', '*' ]);
- //
- // Locate a matching action block based on the submitted data
- //
- for(var c = 0; c < confForFormId.length; ++c) {
- var actionBlock = confForFormId[c];
+ if(!Array.isArray(confForFormId)) {
+ return self.client.log.debug( { formId : formData.submitId }, 'No configuration for form ID');
+ }
- if(_.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)) {
- self.handleActionWrapper(formData, actionBlock);
- break; // there an only be one...
- }
- }
- });
+ // 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);
+ }
+ });
- 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;
- }
+ viewData = view.getData();
+ if(_.isUndefined(viewData)) {
+ return;
+ }
- formData.value[ view.submitArgName ? view.submitArgName : view.id ] = viewData;
- } catch(e) {
- this.client.log.error( { error : e.message }, 'Exception caught gathering form data' );
- }
- });
+ 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;
+ return formData;
};
diff --git a/core/web_password_reset.js b/core/web_password_reset.js
index c2d6852d..90c5f57c 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%:
+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.debug( { 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
index 6abd76ef..5910bd29 100644
--- a/core/whos_online.js
+++ b/core/whos_online.js
@@ -1,84 +1,64 @@
/* jslint node: true */
'use strict';
-// ENiGMA½
-const MenuModule = require('./menu_module.js').MenuModule;
-const ViewController = require('./view_controller.js').ViewController;
-const getActiveNodeList = require('./client_connections.js').getActiveNodeList;
-const stringFormat = require('./string_format.js');
+// 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');
+// 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'
+ name : 'Who\'s Online',
+ desc : 'Who is currently online',
+ author : 'NuSkooler',
+ packageName : 'codes.l33t.enigma.whosonline'
};
const MciViewIds = {
- OnlineList : 1,
+ onlineList : 1,
};
exports.getModule = class WhosOnlineModule 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);
+ }
- const self = this;
- const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
+ 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}`));
+ }
- async.series(
- [
- function loadFromConfig(callback) {
- const loadOpts = {
- callingMenu : self,
- mciMap : mciData.menu,
- noInput : true,
- };
+ const onlineList = getActiveConnectionList(true).slice(0, onlineListView.height).map(
+ oe => Object.assign(oe, { text : oe.userName, timeOn : _.upperFirst(oe.timeOn.humanize()) })
+ );
- 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);
- }
- );
- });
- }
+ 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/_includes/nav.md b/docs/_includes/nav.md
index dacb2f00..e67f1163 100644
--- a/docs/_includes/nav.md
+++ b/docs/_includes/nav.md
@@ -13,19 +13,20 @@
- Configuration
- [Creating Config Files]({{ site.baseurl }}{% link configuration/creating-config.md %})
- [SysOp Setup]({{ site.baseurl }}{% link configuration/sysop-setup.md %})
- - [Editing hjson]({{ site.baseurl }}{% link configuration/editing-hjson.md %})
- - [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %})
- - [menu.hjson]({{ site.baseurl }}{% link configuration/menu-hjson.md %})
- - [prompt.hjson]({{ site.baseurl }}{% link configuration/prompt-hjson.md %})
+ - [Editing HJSON]({{ site.baseurl }}{% link configuration/editing-hjson.md %})
+ - [System Configuration]({{ site.baseurl }}{% link configuration/config-hjson.md %})
+ - [HJSON General]({{ site.baseurl }}{% link configuration/hjson.md %})
+ - [Menus]({{ site.baseurl }}{% link configuration/menu-hjson.md %})
+ - [Prompts]({{ site.baseurl }}{% link configuration/prompt-hjson.md %})
- [Directory Structure]({{ site.baseurl }}{% link configuration/directory-structure.md %})
- [Archivers]({{ site.baseurl }}{% link configuration/archivers.md %})
- [File Transfer Protocols]({{ site.baseurl }}{% link configuration/file-transfer-protocols.md %})
- [Email]({{ site.baseurl }}{% link configuration/email.md %})
- [Colour Codes]({{ site.baseurl }}{% link configuration/colour-codes.md %})
- [Access Condition System (ACS)]({{ site.baseurl }}{% link configuration/acs.md %})
+ - [Event Scheduler]({{ site.baseurl }}{% link configuration/event-scheduler.md %})
- Scheduled jobs
-
- File Base
- [About]({{ site.baseurl }}{% link filebase/index.md %})
- [Configuring a File Area]({{ site.baseurl }}{% link filebase/first-file-area.md %})
@@ -55,6 +56,8 @@
- 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 %})
@@ -64,8 +67,26 @@
- Combatnet
- Exodus
- [Existing Mods]({{ site.baseurl }}{% link modding/existing-mods.md %})
-
- - [Oputil]({{ site.baseurl }}{% link oputil/index.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 %})
+
+ - Administration
+ - [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/admin/oputil.md b/docs/admin/oputil.md
new file mode 100644
index 00000000..bb6dcacf
--- /dev/null
+++ b/docs/admin/oputil.md
@@ -0,0 +1,240 @@
+---
+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 args:
+ -c, --config PATH specify config path (./config/)
+ -n, --no-prompt assume defaults/don't prompt for input where possible
+
+commands:
+ user user utilities
+ config config file 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
+ aliases: password, passwd
+ rm USERNAME permanently removes user from system
+ aliases: remove, delete, del
+ activate USERNAME set status to active
+ deactivate USERNAME set status to inactive
+ disable USERNAME set status to disabled
+ lock USERNAME set status to locked
+ group USERNAME [+|-]GROUP adds (+) or removes (-) user from a group
+```
+
+| 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` |
+| `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 |
+
+## Configuration
+The `config` command allows sysops to perform various system configuration and maintenance tasks.
+
+```
+usage: oputil.js config []
+
+actions:
+ new generate a new/initial configuration
+ cat cat current configuration to stdout
+
+cat args:
+ --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 also contain optional GLOB as last parameter,
+ for example: scan some_area *.zip
+
+ info CRITERIA display information about areas and/or files
+ matching CRITERIA.
+
+ mv SRC [SRC...] DST move entry(s) from SRC to DST
+ SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
+ DST: AREA_TAG[@STORAGE_TAG]
+
+ rm SRC [SRC...] remove entry(s) from the system matching SRC
+ SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
+ desc CRITERIA sets a new file description for file base entry
+ matching CRITERIA. Launches an external editor using
+ $VISUAL, $EDITOR, or vim/notepad.
+ import-areas FILEGATE.ZXX import file base areas using FileGate RAID type format
+
+scan args:
+ --tags TAG1,TAG2,... specify tag(s) to assign to discovered entries
+
+ --desc-file [PATH] prefer file descriptions from supplied path over other
+ other sources such as FILE_ID.DIZ. Path must point to
+ a valid FILES.BBS or DESCRIPT.ION file.
+ --update attempt to update information for existing entries
+ --quick perform quick scan
+
+info args:
+ --show-desc display short description, if any
+
+remove args:
+ --phys-file also remove underlying physical file
+
+import-areas args:
+ --type TYPE sets import areas type. valid options are "zxx" or "na"
+ --create-dirs create backing storage directories
+
+general information:
+ AREA_TAG[@STORAGE_TAG] can specify an area tag and optionally, a storage specific tag
+ example: retro@bbs
+
+ CRITERIA file base entry criteria. in general, can be AREA_TAG, SHA,
+ FILE_ID, or FILENAME_WC.
+
+ FILENAME_WC filename with * and ? wildcard support. may match 0:n entries
+ SHA full or partial SHA-256
+ FILE_ID a file identifier. see file.sqlite3
+```
+
+#### Scan 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
+$ ./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 to ADDR with the supplied command(s)
+ one or more commands may be supplied. commands that are multi
+ part such as "%COMPRESS ZIP" should be quoted.
+ import-areas PATH import areas using fidonet *.NA or AREAS.BBS file from PATH
+
+import-areas args:
+ --conf CONF_TAG conference tag in which to import areas
+ --network NETWORK network name/key to associate FTN areas
+ --uplinks UL1,UL2,... one or more comma separated uplinks
+ --type TYPE area import type. valid options are "bbs" and "na"
+```
+
+| 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` |
+
+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..310b6313
--- /dev/null
+++ b/docs/admin/updating.md
@@ -0,0 +1,20 @@
+---
+layout: page
+title: Updating
+---
+## Updating your Installation
+Updating ENiGMA½ can be a bit of a learning curve compared to other systems. Especially when running off of a development branch (such as `0.0.9-alpha` being the recommended branch as of this writing), you'll want frequent updates.
+
+## Steps
+In general the steps are as follows:
+1. `cd /path/to/enigma-bbs`
+2. `git pull`
+3. `npm update` or `yarn` to refresh any new or updated modules.
+4. Merge updates to `config/menu_template.hjson` to your `config/yourbbsname-menu.hjson` file.
+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.
+
+Visual diff tools such as [DiffMerge](https://www.sourcegear.com/diffmerge/downloads.php) (free, works on all major platforms) can be very helpful here.
+
+Remember to also keep an eye on [WHATSNEW](/WHATSNEW.md) and [UPGARDE](/UPGRADE.md)!
+
+
diff --git a/docs/art/general.md b/docs/art/general.md
index c43d915c..a0620de3 100644
--- a/docs/art/general.md
+++ b/docs/art/general.md
@@ -1,6 +1,111 @@
---
layout: page
-title: General
+title: General Art Information
---
-General art lives in the `art/general` directory. 'General' art is ANSI you want to stay consistent across themes,
-such as a welcome ANSI or a rotation of logoff ANSIs.
+## General Art Information
+One of the most basic elements of BBS customization is through it's artwork. ENiGMA½ supports a variety of ways to select, display, and manage art.
+
+As a general rule, art files live in one of two places:
+
+1. The `art/general` directory. This is where you place command non-themed art files.
+2. Within a theme such as `art/themes/super_fancy_theme`.
+
+### Menu Entries
+While art can be displayed programmatically such as from a custom module, the most basic and common form is via `menu.hjson` entries. This usually falls into one of two forms: a "standard" entry where a single `art` spec is utilized or a entry for a custom module where multiple pieces are declared and used. The second style usually takes the form of a `config.art` block with two or more entries.
+
+A menu entry has a few elements that control how art is choosen and displayed. First, the `art` *spec* tells teh system how to look for the art asset. Second, the `config` block can further control aspecs of lookup and display:
+
+| Item | Description|
+|------|------------|
+| `font` | Sets the [SyncTERM](http://syncterm.bbsdev.net/) style font to use when displaying this art. If unset, the system will use the art's embedded [SAUCE](http://www.acid.org/info/sauce/sauce.htm) record if present or simply use the current font. See Fonts below. |
+| `pause` | If set to `true`, pause after displaying. |
+| `baudRate` | Set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate when displaying this art. In other words, slow down the display. |
+| `cls` | Clear the screen before display if set to `true`. |
+| `random` | Set to `false` to explicitly disable random lookup. |
+| `types` | An optional array of types (aka file extensions) to consider for lookup. For example : `[ '.ans', '.asc' ]` |
+| `readSauce` | May be set to `false` if you need to explictly disable SAUCE support. |
+
+#### Art Spec
+It was mentioned that the `art` member is a *spec*. The value of a `art` member controls how the system looks for an asset. The following forms are supported:
+
+* `FOO`: The system will look for `FOO.ANS`, `FOO.ASC`, `FOO.TXT`, etc. using the default search path. Unless otherwise specified if `FOO1.ANS`, `FOO2.ANS`, and so on exist, a random selection will be made.
+* `FOO.ANS`: By specifying an extension, only that type will be searched for.
+* `rel/path/to/BAR.ANS`: Only match a path (relative to the system's `art` directory).
+* `/path/to/BAZ.ANS`: Exact path only.
+
+ENiGMA½ uses a fallback system for art selection. When a menu entry calls for a piece of art, the following search is made:
+
+1. If a direct or relative path is supplied, look there first.
+2. In the users current theme directory.
+3. In the system default theme directory.
+4. In the `art/general` directory.
+
+#### SyncTERM Style Fonts
+ENiGMA½ can set a [SyncTERM](http://syncterm.bbsdev.net/) style font for art display. This is supported by many popular BBS terminals besides just SyncTERM and is common for displaying Amiga style fonts for example. The system will use the `font` specifier or look for a font declared in an artworks SAUCE record (unless `readSauce` is `false`).
+
+The most common fonts are probably as follows:
+
+* `cp437`
+* `c64_upper`
+* `c64_lower`
+* `c128_upper`
+* `c128_lower`
+* `atari`
+* `pot_noodle`
+* `mo_soul`
+* `microknight_plus`
+* `topaz_plus`
+* `microknight`
+* `topaz`
+
+Other fonts fonts also available:
+* `cp1251`
+* `koi8_r`
+* `iso8859_2`
+* `iso8859_4`
+* `cp866`
+* `iso8859_9`
+* `haik8`
+* `iso8859_8`
+* `koi8_u`
+* `iso8859_15`
+* `iso8859_4`
+* `koi8_r_b`
+* `iso8859_4`
+* `iso8859_5`
+* `ARMSCII_8`
+* `iso8859_15`
+* `cp850`
+* `cp850`
+* `cp885`
+* `cp1251`
+* `iso8859_7`
+* `koi8-r_c`
+* `iso8859_4`
+* `iso8859_1`
+* `cp866`
+* `cp437`
+* `cp866`
+* `cp885`
+* `cp866_u`
+* `iso8859_1`
+* `cp1131`
+
+See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information.
+
+#### SyncTERM Style Baud Rates
+The `baudRate` member can set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate. May be `300`, `600`, `1200`, `2400`, `4800`, `9600`, `19200`, `38400`, `57600`, `76800`, or `115200`. A value of `ulimited`, `off`, or `0` resets (disables) the rate. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information.
+
+## Common Example
+```hjson
+fullLogoffSequenceRandomBoardAd: {
+ art: OTHRBBS
+ desc: Logging Off
+ next: logoff
+ config: {
+ baudRate: 57600
+ pause: true
+ cls: true
+ }
+}
+```
\ No newline at end of file
diff --git a/docs/art/mci.md b/docs/art/mci.md
index 4befc411..3e5e18c5 100644
--- a/docs/art/mci.md
+++ b/docs/art/mci.md
@@ -11,14 +11,13 @@ 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.
+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.3-alpha" |
-| `VN` | Version *number*, eg.. "0.0.3-alpha" |
+| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.9-alpha" |
+| `VN` | Version *number*, eg.. "0.0.9-alpha" |
| `SN` | SysOp username |
| `SR` | SysOp real name |
| `SL` | SysOp location |
@@ -31,12 +30,13 @@ iNiQUiTY, etc.
| `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) |
+| `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 ID* (e.g. "luciano_blocktronics") |
+| `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 |
@@ -58,6 +58,10 @@ iNiQUiTY, etc.
| `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.) |
@@ -65,14 +69,25 @@ iNiQUiTY, etc.
| `SC` | System CPU model |
| `NV` | System underlying Node.js version |
| `AN` | Current active node count |
-| `TC` | Total login/calls to system |
+| `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.) |
+| `TP` | Total messages posted/imported to the system *currently* |
+| `PT` | Total messages posted/imported to the system *today* |
-A special `XY` MCI code may also be utilized for placement identification when creating menus.
+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
@@ -92,13 +107,13 @@ a Vertical Menu (`%VM`): Old-school BBSers may recognize this as a lightbar menu
| `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`.
+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
@@ -112,10 +127,14 @@ Predefined MCI codes and other Views can have properties set via `menu.hjson` an
| `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
@@ -132,4 +151,43 @@ Standard style types available for `textStyle` and `focusTextStyle`:
| `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
+| `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
index 37631454..577a95fe 100644
--- a/docs/art/themes.md
+++ b/docs/art/themes.md
@@ -2,24 +2,131 @@
layout: page
title: Themes
---
-:warning: ***IMPORTANT!*** It is recommended you don't make any customisations to the included
-`luciano_blocktronics' theme. Create your own and make changes to that instead:
+## Themes
+ENiGMA½ comes with an advanced theming system allowing system operators to highly customize the look and feel of their boards. A given installation can have as many themes as you like for your users to choose from.
+
+## General Information
+Themes live in `art/themes/`. Each theme (and thus it's *theme ID*) is a directory within the `themes` directory. The theme itself is simply a collection of art files, and a `theme.hjson` file that further defines layout, colors & formatting, etc. ENiGMA½ comes with a default theme by [Luciano Ayres](http://blocktronics.org/tag/luciano-ayres/) of [Blocktronics](http://blocktronics.org/) called Mystery Skull. This theme is in `art/themes/luciano_blocktronics`, and thus it's *theme ID* is `luciano_blocktronics`.
+
+## Art
+For information on art files, see [General Art Information](general.md). TL;DR: In general, to theme a piece of art, create a version of it in your themes directory.
+
+:information_source: Remember that by default, the system will allow for randomly selecting art (in one of the directories mentioned above) by numbering it: `FOO1.ANS`, `FOO2.ANS`, etc.!
+
+## Theme Sections
+Themes are some important sections to be aware of:
+
+| Config Item | Description |
+|-------------|----------------------------------------------------------|
+| `info` | This section describes the theme. |
+| `customization` | The beef! |
+
+### Info Block
+The `info` configuration block describes the theme itself.
+
+| Item | Required | Description |
+|-------------|----------|----------------------------------------------------------|
+| `name` | :+1: | Name of the theme. Be creative! |
+| `author` | :+1: | Author of the theme/artwork. |
+| `group` | :-1: | Group/affils of author. |
+| `enabled` | :-1: | Boolean of enabled state. If set to `false`, this theme will not be available to your users. If a user currently has this theme selected, the system default will be selected for them at next login. |
+
+### Customization Block
+The `customization` block in is itself broken up into major parts:
+
+| Item | Description |
+|-------------|---------------------------------------------------|
+| `defaults` | Default values to use when this theme is active. These values override system defaults, but can still be overridden themselves in specific areas of your theme. |
+| `menus` | The bulk of what you theme in the system will be here. Any menu (that is, anything you find in `menu.hjson`) can be tweaked. |
+| `prompts` | Similar to `menus`, this file themes prompts found in `prompts.hjson`. |
+
+#### Defaults
+| Item | Description |
+|-------------|---------------------------------------------------|
+| `passwordChar` | Character to display in password fields. Defaults to `*` |
+| `dateFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for dates. |
+| `timeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for times. |
+| `dateTimeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for date/time combinations. |
+
+Example:
+```hjson
+defaults: {
+ dateTimeFormat: {
+ short: MMM Do h:mm a
+ }
+}
+```
+
+#### Menus Block
+Each *key* in the `menus` block matches up with a key found in your `menu.hjson`. For example, consider a `matrix` menu defined in `menu.hjson`. In addition to perhaps providing a `MATRIX.ANS` in your themes directory, you can also theme other parts of the menu via a `matrix` entry in `theme.hjson`.
+
+Major areas to override/theme:
+* `config`: Override and/or provide additional theme information over that found in the `menu.hjson`'s entry. Common entries here are for further overriding date/time formats, and custom range info formats (`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:
-
- info: {
- name: Awesome Theme
- author: Cool Artist
- group: Sick Group
- enabled: true
- }
+``` hjson
+ info: {
+ name: Awesome Theme
+ author: Cool Artist
+ group: Sick Group
+ enabled: true // default
+ }
+```
-3. Specify it in the `defaults` section of `config.hjson`. The name supplied should match the name of the
-directory you created in step 1:
-
- ```hjson
- defaults: {
- theme: your_board_theme
- }
- ```
+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/configuration/acs.md b/docs/configuration/acs.md
index 827050c4..d0a45d06 100644
--- a/docs/configuration/acs.md
+++ b/docs/configuration/acs.md
@@ -4,7 +4,7 @@ title: Access Condition System (ACS)
---
## Access Condition System (ACS)
-ENiGMA½ uses an Access Condition System (ACS) that is both familure 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).
+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:
@@ -16,7 +16,7 @@ The following are ACS codes available as of this writing:
| 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 | Current node is _node_ |
+| 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.) |
@@ -26,6 +26,17 @@ The following are ACS codes available as of this writing:
| 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 |
\* Many more ACS codes are planned for the near future.
@@ -39,19 +50,30 @@ The following logical operators are supported:
ENiGMA½ also supports groupings using `(` and `)`. Lastly, some ACS codes allow for lists of acceptable values using `[` and `]` — for example, `GM[users,sysops]`.
-### Examples
+### 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
-* File base areas
-* Menus within `menu.hjson`
+* [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/configuration/archivers.md b/docs/configuration/archivers.md
index 26755162..3d3f952a 100644
--- a/docs/configuration/archivers.md
+++ b/docs/configuration/archivers.md
@@ -20,6 +20,11 @@ The following archivers are pre-configured in ENiGMA½ as of this writing. Remem
* 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
+#### Lzx
+* Formats: Amiga LZX
+* Key: `Lzx`
+* Homepage/package: `unlzx` under most *nix 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`
diff --git a/docs/configuration/config-hjson.md b/docs/configuration/config-hjson.md
index 10ca119d..38392943 100644
--- a/docs/configuration/config-hjson.md
+++ b/docs/configuration/config-hjson.md
@@ -1,12 +1,14 @@
---
layout: page
-title: config.hjson
+title: System Configuration
---
## System Configuration
The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace.
+See also [HJSON General Information](hjson.md) for more information on the HJSON format.
+
### Creating a Configuration
-Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory:
+Your initial configuration skeleton should be created using the `oputil.js` command line utility. From your enigma-bbs root directory:
```
./oputil.js config new
```
@@ -28,95 +30,19 @@ general: {
}
```
-(Note the very slightly different syntax. **You can use standard JSON if you wish**)
+(Note the very slightly [HJSON](hjson.md) different syntax. **You can use standard JSON if you wish!**)
While not everything that is available in your `config.hjson` file can be found defaulted in `core/config.js`, a lot is. [Poke around and see what you can find](https://github.com/NuSkooler/enigma-bbs/blob/master/core/config.js)!
+### Configuration Sections
+Below is a list of various configuration sections. There are many more, but this should get you started:
-### A Sample Configuration
-Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked.
+* [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!
-**This is for illustration purposes! Do not cut & paste this configuration!**
-
-
-```hjson
-{
- general: {
- boardName: A Sample BBS
- menuFile: "your_bbs.hjson" // copy of menu.hjson file (and adapt to your needs)
- }
-
- defaults: {
- theme: "super-fancy-theme" // default-assigned theme (for new users)
- }
-
- preLoginTheme: "luciano_blocktronics" // theme used before a user logs in (matrix, NUA, etc.)
-
- 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
- }
- }
- }
- }
-}
-```
diff --git a/docs/configuration/creating-config.md b/docs/configuration/creating-config.md
index c8495f1c..5d845d5e 100644
--- a/docs/configuration/creating-config.md
+++ b/docs/configuration/creating-config.md
@@ -2,26 +2,13 @@
layout: page
title: Creating Initial Config Files
---
-Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just
-like JSON but simplified and much more resilient to human error.
+Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error.
-## config.hjson
-Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your
-enigma-bbs root directory:
-```
+## Initial Configuration
+Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory:
+```bash
./oputil.js config new
```
-You will be asked a series of questions to create an initial configuration.
+You will be asked a series of questions to create an initial configuration, which will be saved to `/enigma-bbs-install-path/config/config.hjson`. This will also produce `config/-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.
-## menu.hjson and prompt.hjson
-
-Create your own copy of `/config/menu.hjson` and `/config/prompt.hjson`, and specify it in the
-`general` section of `config.hjson`:
-
-````hjson
-general: {
- menuFile: my-menu.hjson
- promptFile: my-prompt.hjson
-}
-````
\ No newline at end of file
diff --git a/docs/configuration/email.md b/docs/configuration/email.md
index 5bc4d4c8..eb13ef71 100644
--- a/docs/configuration/email.md
+++ b/docs/configuration/email.md
@@ -2,16 +2,18 @@
layout: page
title: Email
---
-ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid SMTP
-config in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %})
+## Email Support
+ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid [Nodemailer](https://nodemailer.com/about/) compatible `email` block in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %}). Nodemailer supports SMTP in addition to many pre-defined services for ease of use. The `transport` block within `email` must be Nodemailer compatible.
-## SMTP Services
+Additional email support will come in the near future.
-If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) provide a reliable free
-service.
+## Services
-## Example SMTP Configuration
+If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) and [Zoho](https://www.zoho.com/mail/) both provide reliable and free services.
+## Example Configurations
+
+Example 1 - SMTP:
```hjson
email: {
defaultFrom: sysop@bbs.awesome.com
@@ -27,3 +29,21 @@ email: {
}
}
```
+
+Example 2 - Zoho
+```hjson
+email: {
+ defaultFrom: sysop@bbs.awesome.com
+
+ transport: {
+ service: Zoho
+ auth: {
+ user: noreply@bbs.awesome.com
+ pass: yuspymypass
+ }
+ }
+}
+```
+
+## Lockout Reset
+If email is available on your system and you allow email-driven password resets, you may elect to allow unlocking accounts at the time of a password reset. This is controlled by the `users.unlockAtEmailPwReset` configuration option. If an account is locked due to too many failed login attempts, a user may reset their password to remedy the situation themselves.
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
index da5697c7..2f7d48ac 100644
--- a/docs/configuration/file-transfer-protocols.md
+++ b/docs/configuration/file-transfer-protocols.md
@@ -2,16 +2,16 @@
layout: page
title: File Transfer Protocols
---
-ENiGMA½ currently relies on external executables for "legacy" file transfer protocols such as X, Y, and ZModem. The `fileTransferProtocols` section of `config.hjson` is used to override defaults, add new handlers, etc. Remember that ENiGMA½ also support modern web (HTTP/HTTPS) downloads!
+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 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.
+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](http://132.0.0.249/bbs-linux-binaries/).
+[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.
@@ -32,18 +32,21 @@ For protocols of type `external` the following members may be defined:
* `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}' ],
- }
+ 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}' ],
+ }
}
-```
\ No newline at end of file
+```
diff --git a/docs/configuration/hjson.md b/docs/configuration/hjson.md
new file mode 100644
index 00000000..cc8fa26d
--- /dev/null
+++ b/docs/configuration/hjson.md
@@ -0,0 +1,69 @@
+---
+layout: page
+title: HJSON General Information
+---
+## JSON for Humans!
+HJSON is the configuration file format used by ENiGMA½ for [System Configuration](config-hjson.md), [Menus](menu-hjson.md), [Prompts](prompt-hjson.md), etc. [HJSON](https://hjson.org/) is is [JSON](https://json.org/) for humans!
+
+For those completely unfamiliar, JSON stands for JavaScript Object Notation. But don't let that scare you! JSON is simply a text file format with a bit of structure ― kind of like a fancier INI file. HJSON on the other hand as mentioned previously, is JSON for humans. That is, it has the following features and more:
+
+* More resilient to syntax errors such as missing a comma
+* Strings generally do not need to be quoted. Multi-line strings are also supported!
+* Comments are supported (JSON doesn't allow this!): `#`, `//` and `/* ... */` style comments are allowed.
+* Keys never need to be quoted
+* ...much more! See [the official HJSON website](https://hjson.org/).
+
+## Terminology
+Through the documentation, some terms regarding HJSON and configuration files will be used:
+
+* `config.hjson`: Refers to `/path/to/enigma-bbs/config/config.hjson`. See [System Configuration](config-hjson.md).
+* `menu.hjson`: Refers to `/path/to/enigma-bbs/config/-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 3 via the `sublime-hjson` package.
+* Visual Studio code via the `vscode-hjson` plugin.
+* Notepad++ via the `npp-hjson` plugin.
+
+See https://hjson.org/users.html for more more editors & plugins.
+
+### Hot-Reload A.K.A. Live Editing
+ENiGMA½'s configuration, menu, and theme files can edited while your BBS is running. When a file is saved, it is hot-reloaded into the running system. If users are currently connected and you change a menu for example, the next reload of that menu will show the changes.
+
+### CaSe SeNsiTiVE
+Configuration keys are **case sensitive**. That means if a configuration key is `boardName` for example, `boardname`, or `BOARDNAME` **will not work**.
+
+### Escaping
+Some values need escaped. This is especially important to remember on Windows machines where file paths contain backslashes (`\`). To specify a path to `C:\foo\bar\baz.exe` for example, an entry may look like this in your configuration file:
+```hjson
+something: {
+ path: "C:\\foo\\bar\\baz.exe" // note the extra \'s!
+}
+```
+
+## Tips & Tricks
+### JSON Compatibility
+Remember that standard JSON is fully compatible with HJSON. If you are more comfortable with JSON (or have an editor that works with JSON that you prefer) simply convert your config file(s) to JSON and use that instead!
+
+HJSON can be converted to JSON with the `hjson` CLI:
+```bash
+cd /path/to/enigma-bbs
+cp ./config/config.hjson ./config/config.hjson.backup
+./node_modules/hjson/bin/hjson ./config/config.hjson.backup -j > ./config/config.hjson
+```
+
+You can always convert back to HJSON by omitting `-j` in the command above.
+
+### oputil
+You can easily dump out your current configuration in a pretty-printed style using oputil: ```./oputil.js config cat```
diff --git a/docs/configuration/menu-hjson.md b/docs/configuration/menu-hjson.md
index 3596e95a..a59e5b24 100644
--- a/docs/configuration/menu-hjson.md
+++ b/docs/configuration/menu-hjson.md
@@ -1,101 +1,217 @@
---
layout: page
-title: menu.hjson
+title: Menu HSJON
---
-:warning: ***IMPORTANT!*** Before making any customisations, create your own copy of `/config/menu.hjson`, and specify it in the
-`general` section of `config.hjson`:
+## 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.
-````hjson
-general: {
- menuFile: my-menu.hjson
-}
-````
-This document and others will refer to `menu.hjson`. This should be seen as an alias to `yourboardname.hjson`
-
-## The Basics
-Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format.
-
-Entries in `menu.hjson` are objects defining a menu. A menu in this sense is something the user can see
-or visit. Examples include but are not limited to:
+Entries in `menu.hjson` are often referred to as *blocks* or *sections*. Each entry defines a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to:
* Classical Main, Messages, and File menus
* Art file display
-* Module driven menus such as door launchers
+* 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. |
-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.
+## 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*. |
+
+#### 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. |
+| `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
- options: { nextTimeout: 1500 }
+ 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).
-
-An art pattern of `CONNECT` is set telling the system to look for `CONNECT.*` where `` represents
-a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. If
-desired, you can also be explicit by supplying a full filename with an extention such as `CONNECT.ANS`.
-
-The entry `next` sets up the next menu, by name, in the stack (`matrix`) that we'll go to after
-`telnetConnected`.
-
-Finally, an `options` object may contain various common options for menus. In this case, `nextTimeout`
-tells the system to proceed to the `next` entry automatically after 1500ms.
+The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config). The entry sets up a few things:
+* A `art` spec of `CONNECT`. (See [General Art Information](/docs/art/general.md)).
+* A `next` entry up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`.
+* An `config` block containing a single `nextTimeout` field telling the system to proceed to the `next` (`matrix`) entry automatically after 1500ms.
Now let's look at `matrix`, the `next` entry from `telnetConnected`:
```hjson
matrix: {
- art: matrix
- desc: Login Matrix
- form: {
- 0: {
- VM: {
- mci: {
- VM1: {
- submit: true
- focus: true
- items: [ "login", "apply", "log off" ]
- argName: matrixSubmit
- }
+ 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.
+ //
}
- submit: {
- *: [
- {
- value: { matrixSubmit: 0 }
- action: @menu:login
- }
- {
- value: { matrixSubmit: 1 },
- action: @menu:newUserApplication
- }
- {
- value: { matrixSubmit: 2 },
- action: @menu:logoff
- }
- ]
- }
- }
}
- }
}
```
-In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form
-by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM`
-(*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true`
-as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` for this
-action as `matrixSubmit`.
+In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. Some other bits about the form:
-The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`).
- Upon submit, the first match will be executed. For example, if the user selects "login", the first entry
- with a value of `{ matrixSubmit: 0 }` will match causing `action` of `@menu:login` to be executed (go
- to `login` menu).
+* `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` of `matrixSubmit` for this element view.
+* The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`).
+* Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ matrixSubmit: 0 }` will match (due to 0 being the first index in the list and `matrixSubmit` being the arg name in question) causing `action` of `@menu:login` to be executed (go to `login` menu).
+
+## ACS Checks
+Menu modules can check user ACS in order to restrict areas and perform flow control. See [ACS](acs.md) for available ACS syntax.
+
+### Menu Access
+To restrict menu access add an `acs` key to `config`. Example:
+```
+opOnlyMenu: {
+ desc: Ops Only!
+ config: {
+ acs: ID1
+ }
+}
+```
+
+### 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
index 7b7a3ab5..993e5b8e 100644
--- a/docs/configuration/prompt-hjson.md
+++ b/docs/configuration/prompt-hjson.md
@@ -3,4 +3,6 @@ layout: page
title: prompt.hjson
---
:zap: This page is to describe general information the `prompt.hjson` file. It
-needs fleshing out, please submit a PR if you'd like to help!
\ No newline at end of file
+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/sysop-setup.md b/docs/configuration/sysop-setup.md
index b8c4beb6..502f412e 100644
--- a/docs/configuration/sysop-setup.md
+++ b/docs/configuration/sysop-setup.md
@@ -2,5 +2,4 @@
layout: page
title: SysOp Setup
---
-SySop privileges will be granted to the first user to log into a fresh ENiGMA½ installation.
-
+SySop privileges will be granted to the first user to log into a fresh ENiGMA½ installation. +ops belong to the `sysop` user group by default.
\ No newline at end of file
diff --git a/docs/filebase/acs.md b/docs/filebase/acs.md
index 50527928..618c1843 100644
--- a/docs/filebase/acs.md
+++ b/docs/filebase/acs.md
@@ -2,10 +2,11 @@
layout: page
title: ACS
---
-
-If no `acs` block is supplied in a file area definition, the following defaults apply to an area:
-* `read` (list, download, etc.): `GM[users]`
-* `write` (upload): `GM[sysops]`
+## File Base ACS
+[ACS Codes](/docs/configuration/acs.md) may be used to control access to File Base areas by specifying an `acs` string in a file area's definition. If no `acs` is supplied in a file area definition, the following defaults apply to an area:
+* `read` : `GM[users]`: List/view the area and it's contents.
+* `write` : `GM[sysops]`: Upload.
+* `download` : `GM[users]`: Download.
To override read and/or write ACS, supply a valid `acs` member.
@@ -18,8 +19,13 @@ areas: {
desc: Oldschool PC/DOS
storageTags: [ "retro_pc", "retro_pc_bbs" ]
acs: {
- write: GM[users]
+ // only users of the "l33t" group or those who have
+ // uploaded 10+ files can download from here...
+ download: GM[l33t]|UP10
}
}
}
-```
\ No newline at end of file
+```
+
+## See Also
+[Access Condition System (ACS)](/docs/configuration/acs.md)
diff --git a/docs/filebase/first-file-area.md b/docs/filebase/first-file-area.md
index 5e83e8d7..ab76fe24 100644
--- a/docs/filebase/first-file-area.md
+++ b/docs/filebase/first-file-area.md
@@ -2,34 +2,40 @@
layout: page
title: Configuring a File Base
---
+## Configuring a File Base
+ENiGMA½ offers a powerful and flexible file base. Configuration of file the file base and areas is handled via the `fileBase` section of `config.hjson`.
+
## ENiGMA½ File Base Key Concepts
-Like many things in ENiGMA½, configuration of file base(s) is handled via `config.hjson` — specifically
-in the `fileBase` section. First, there are a couple of concepts you should understand:
+First, there are some core concepts you should understand:
+* Storage Tags
+* Areas (and Area Tags)
+### Storage Tags
+*Storage Tags* define paths to physical (file) storage locations that are referenced in a file *Area* entry. Each entry may be either a fully qualified path or a relative path. Relative paths are relative to the value set by the `areaStoragePrefix` key (defaults to `/path/to/enigma-bbs/file_base`).
-### Storage tags
-
-**Storage Tags** define paths to physical (file) storage locations that are referenced in a
-file *Area* entry. Each entry may be either a fully qualified path or a relative path. Relative paths
-are relative to the value set by the `areaStoragePrefix` key (defaults to `` syntax while `export` additionally supports `@immediate`. |
+| `packetMsgEncoding` | :-1: | Override default `utf8` encoding.
+| `defaultNetwork` | :-1: | Explicitly set default network (by tag in `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/*`). `archiveType` may be set to a FTN supported archive extention that the system supports (TODO); if unset, only .PKT files are produced. `encoding` may be set to override `packetMsgEncoding` on a per-node basis. If the node requires a packet password, set `packetPassword` |
+| `paths` | :-1: | An optional configuration block that can set a `retain` path and/or a `reject` path. These will be used for archiving processed packets. You may additionally override the default `outbound`, `inbound`, and `secInbound` (secure inbound) *base* paths for packet processing. |
+| `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) |
## 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.
+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 text can be things like `at 5:00 pm` or `every 2 hours`.
+ * Free form [Later style](https://bunkat.github.io/later/parsers.html#text) text — can be things like `at 5:00 pm` or `every 2 hours`.
See [Later text parsing documentation](http://bunkat.github.io/later/parsers.html#text) for more information.
-### Example Configuration
+### Example Schedule Configuration
```hjson
{
@@ -45,15 +45,14 @@ See [Later text parsing documentation](http://bunkat.github.io/later/parsers.htm
## Nodes
The `nodes` section defines how to export messages for one or more uplinks.
-A node entry starts with a FTN style address (up to 5D) **as a key** in `config.hjson`. This key may
-contain wildcard(s) for net/zone/node/point/domain.
+A node entry starts with a [FTN address](http://ftsc.org/docs/old/fsp-1028.001) (up to 5D) **as a key** in `config.hjson`. This key may contain wildcard(s) for net/zone/node/point/domain.
| Config Item | Required | Description |
|------------------|----------|---------------------------------------------------------------------------------|
-| `packetType` | :-1: | `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability |
-| `packetPassword` | :-1: | Password for the packet |
-| `encoding` | :-1: | Encoding to use for message bodies; Defaults to `utf-8` |
-| `archiveType` | :-1: | Specifies the archive type for ArcMail bundles. Must be a valid archiver name such as `zip` (See archiver configuration) |
+| `packetType` | :-1: | `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability. |
+| `packetPassword` | :-1: | Optional password for the packet |
+| `encoding` | :-1: | Encoding to use for message bodies; Defaults to `utf-8`. |
+| `archiveType` | :-1: | Specifies the archive type (by extension) for ArcMail bundles. This should be `zip` for most setups. Other valid examples include `arc`, `arj`, `lhz`, `pak`, `sqz`, or `zoo`. See docs on archiver configuration for more information. |
**Example**:
```hjson
@@ -61,9 +60,9 @@ contain wildcard(s) for net/zone/node/point/domain.
scannerTossers: {
ftn_bso: {
nodes: {
- "46:*": {
+ "21:*": { // wildcard address
packetType: 2+
- packetPassword: mypass
+ packetPassword: D@TP4SS
encoding: cp437
archiveType: zip
}
@@ -71,4 +70,84 @@ contain wildcard(s) for net/zone/node/point/domain.
}
}
}
-```
\ No newline at end of file
+```
+
+## 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 Scheuler entry in your `config.hjson`. As an example:
+```hjson
+eventScheduler: {
+ events: {
+ pollWithBink: {
+ // execute the script above very 1 hours
+ schedule: every 1 hours
+ action: @execute:/path/to/poll_bink.sh
+ }
+ }
+}
+```
+
+## Additional Resources
+* [Blog entry on setting up ENiGMA + Binkd on CentOS7](https://l33t.codes/enigma-12-binkd-on-centos-7/). Note that this references an **older version**, so be wary of the `config.hjson` refernces!
diff --git a/docs/messageareas/configuring-a-message-area.md b/docs/messageareas/configuring-a-message-area.md
index 5ba396ea..63af2826 100644
--- a/docs/messageareas/configuring-a-message-area.md
+++ b/docs/messageareas/configuring-a-message-area.md
@@ -2,49 +2,59 @@
layout: page
title: Configuring a Message Area
---
+## Message Conferences
**Message Conferences** and **Areas** allow for grouping of message base topics.
-## Message Conferences
-Message Conferences are the top level container for 1:n Message Areas via the `messageConferences` section
-in `config.hjson`. Common message conferences may include a local conference and one or more conferences
-each dedicated to a particular message network such as FsxNet, AgoraNet, etc.
+## 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`. **The areas key is the conferences tag**.
+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: | If supplied, provides a key used for sorting |
-| `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 |
+| 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 require to read (see) this conference. Defaults to `GM[users]`.
### Example
```hjson
{
messageConferences: {
- local: {
+ 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 area's key is its area tag**. For example, "General Discussion" may live under a Local conference
-while an AgoraNet conference may contain "BBS Discussion".
+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 discription |
-| `sort` | :-1: | If supplied, provides a key used for sorting |
-| `default` | :-1: | Specify `true` to make this the default area (e.g. assigned to new users) |
+| `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. |
+
+### ACS
+An optional standard [ACS](/docs/configuration/acs.md) block can be supplied with the following rules:
+* `read`: ACS require to read (see) this conference. Defaults to `GM[users]`.
### Example
@@ -57,9 +67,15 @@ messageConferences: {
name: ENiGMA 1/2 Development
desc: ENiGMA 1/2 discussion!
sort: 1
- default: true
+ default: true
+ acs: {
+ read: GM[users] // default
+ }
}
}
}
}
-```
\ No newline at end of file
+```
+
+## 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/message-networks.md b/docs/messageareas/message-networks.md
index a334ab94..5838da53 100644
--- a/docs/messageareas/message-networks.md
+++ b/docs/messageareas/message-networks.md
@@ -2,29 +2,36 @@
layout: page
title: Message Networks
---
-Configuring message networks in ENiGMA½ requires three specific pieces of config - the network and your
-assigned address on it, the message areas (echos) of the network you wish to map to ENiGMA½ message areas,
-then the schedule and routes to send mail packets on the network.
+## Message Networks
+ENiGMA½ considers all non-ENiGMA½, non-local messages (and their networks, such as FTN "external". That is, messages are only imported and exported from/to such a networks. Configuring such external message networks in ENiGMA½ requires three sections in your `config.hjson`.
-## FTN Networks
-
-FTN networks are configured under the `messageNetworks::ftn` section of `config.hjson`.
+1. `messageNetworks..networks`: declares available networks.
+2. `messageNetworks..areas`: establishes local area mappings and per-area specifics.
+3. `scannerTossers.`: general configuration for the scanner/tosser (import/export). This is also where we configure per-node settings.
-The `networks` section contains a sub section for each network you wish you join your board to.
-Each entry's key name is referenced elsewhere in `config.hjson` for FTN oriented configurations.
+### FTN Networks
+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`.
-### Example Configuration
+: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!
+#### Networks
+The `networks` block a per-network configuration where each entry's key may be referenced elsewhere in `config.hjson`.
+
+Example: the following example declares two networks: `araknet` and `fsxnet`:
```hjson
{
messageNetworks: {
ftn: {
networks: {
- agoranet: {
- localAddress: "46:3/102"
+ // it is recommended to use lowercase network tags
+ fsxnet: {
+ defaultZone: 21
+ localAddress: "21:1/121"
}
- fsxnet: {
- localAddress: "21:4/333"
+
+ araknet: {
+ defaultZone: 10
+ localAddress: "10:101/9"
}
}
}
@@ -32,36 +39,67 @@ Each entry's key name is referenced elsewhere in `config.hjson` for FTN oriented
}
```
-## Message Areas
+#### 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.
-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`.
+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 |
-| `uplinks` | :+1: | An array of FTN address uplink(s) for this network |
-
-### Example Configuration
+| `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: {
- agoranet_bbs: { // tag found within messageConferences
- network: agoranet
- tag: AGN_BBS
- uplinks: "46:1/100"
+ // 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.
diff --git a/docs/messageareas/netmail.md b/docs/messageareas/netmail.md
index 3e947e68..f43c2a4b 100644
--- a/docs/messageareas/netmail.md
+++ b/docs/messageareas/netmail.md
@@ -2,16 +2,13 @@
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.
+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.
+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:
+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
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/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/existing-mods.md b/docs/modding/existing-mods.md
index af1b2b74..c64469d4 100644
--- a/docs/modding/existing-mods.md
+++ b/docs/modding/existing-mods.md
@@ -2,6 +2,8 @@
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) |
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
index b9620748..4ab8037b 100644
--- a/docs/modding/local-doors.md
+++ b/docs/modding/local-doors.md
@@ -2,29 +2,58 @@
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, 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 process I/O through stdio or a temporary TCP server.
+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.
-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`
+### Configuration
+The `abracadabra` `config` block can contain the following members:
-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)
+| 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`. |
-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`
+#### 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.
@@ -45,7 +74,7 @@ $_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:
+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
@@ -62,30 +91,70 @@ Note that we also have the [BNU](http://www.pcmicro.com/bnu/) FOSSIL driver inst
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",
+ 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
+ ],
+ 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.
+### 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.
-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.
+#### 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 (`telnet_bridge` module) 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.
@@ -96,7 +165,7 @@ qemu-system-i386 -localtime /home/enigma/dos/images/freedos_c.img -hdb fat:/path
```
With the above you can now copy files from D: to C: within FreeDOS and add the following to it's `autoexec.bat`:
-```batch
+```bat
CALL E:\GO.BAT
```
@@ -128,36 +197,35 @@ unix2dos /home/enigma/dos/go/node$NODE/GO.BAT
qemu-system-i386 -localtime /home/enigma/dos/images/freedos_c.img -chardev socket,port=$SRVPORT,nowait,host=localhost,id=s0 -device isa-serial,chardev=s0 -hdb fat:/home/enigma/xibalba/dropfiles/node$NODE -hdc fat:/home/enigma/dos/go/node$NODE -nographic
```
-Note the `qemu-system-i386` line. We're telling QEMU to launch and use localtime for the clock, create a character device that connects to our temporary server port on localhost and map that to a serial device. The `-hdb` entry will represent the D: drive where our drop file is generated, while `-hdc` is the path that `GO.BAT` is generated in (`E:\GO.BAT`). Finally we specify `-nographic` to run headless.
+Note the `qemu-system-i386` line. We're telling QEMU to launch and use localtime for the clock, create a character device that connects to our temporary server port on localhost and map that to a serial device. The `-hdb` entry will represent the D: drive where our dropfile is generated, while `-hdc` is the path that `GO.BAT` is generated in (`E:\GO.BAT`). Finally we specify `-nographic` to run headless.
For doors that do not *require* a FOSSIL driver, it is recommended to not load or use one unless you are having issues.
-#### Step 3: Create a menu entry
+##### Step 3: Create a menu entry
Finally we can create a `menu.hjson` entry using the `abracadabra` module:
```hjson
doorLORD: {
- desc: Playing L.O.R.D.
- module: abracadabra
- config: {
- name: LORD
- dropFileType: DOOR
- cmd: /home/enigma/dos/scripts/lord.sh
- args: [
- "{node}",
- "{dropFile}",
- "{srvPort}",
- ],
- nodeMax: 1
- tooManyArt: DOORMANY
- io: socket
- }
+ desc: Playing L.O.R.D.
+ module: abracadabra
+ config: {
+ name: LORD
+ dropFileType: DOOR
+ cmd: /home/enigma/dos/scripts/lord.sh
+ args: [
+ "{node}",
+ "{dropFile}",
+ "{srvPort}",
+ ],
+ nodeMax: 1
+ tooManyArt: DOORMANY
+ io: socket
+ }
}
```
-## Resources
-
+## Additional Resources
### DOSBox
-* Custom DOSBox builds http://home.arcor.de/h-a-l-9000/
+* [DOSBox-X](https://github.com/joncampbell123/dosbox-x)
### Door Downloads & Support Sites
#### General
diff --git a/docs/modding/menu-modules.md b/docs/modding/menu-modules.md
new file mode 100644
index 00000000..ea3ea4a7
--- /dev/null
+++ b/docs/modding/menu-modules.md
@@ -0,0 +1,14 @@
+---
+layout: page
+title: Local Doors
+---
+## Menu Modules
+Menu entries found within `menu.hjson` are backed by *menu modules*.
+
+## Creating a New Module
+TODO
+
+### Lifecycle
+TODO
+
+
diff --git a/docs/modding/msg-area-list.md b/docs/modding/msg-area-list.md
new file mode 100644
index 00000000..499b7fa6
--- /dev/null
+++ b/docs/modding/msg-area-list.md
@@ -0,0 +1,17 @@
+---
+layout: page
+title: Message Area List
+---
+## The Message Area List Module
+The built in `msg_area_list` module provides a menu to display and change between message areas in the users current conference.
+
+### Theming
+The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`):
+* `index`: 1-based index into list.
+* `areaTag`: Area tag.
+* `name` or `text`: Display name.
+* `desc`: Description.
+
+The following additional MCIs are updated as the user changes selections in the main list:
+* MCI 2 (ie: `%TL2` or `%M%2`) is updated with the area description.
+* MCI 10+ (ie `%TL10`...) are custom ranges updated with the same information available above in `itemFormat`. Use `areaListItemFormat##`.
diff --git a/docs/modding/msg-conf-list.md b/docs/modding/msg-conf-list.md
new file mode 100644
index 00000000..c00c890d
--- /dev/null
+++ b/docs/modding/msg-conf-list.md
@@ -0,0 +1,18 @@
+---
+layout: page
+title: Message Conference List
+---
+## The Message Conference List Module
+The built in `msg_conf_list` module provides a menu to display and change between message conferences.
+
+### Theming
+The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`):
+* `index`: 1-based index into list.
+* `confTag`: Conference tag.
+* `name` or `text`: Display name.
+* `desc`: Description.
+* `areaCount`: Number of areas in this conference.
+
+The following additional MCIs are updated as the user changes selections in the main list:
+* MCI 2 (ie: `%TL2` or `%M%2`) is updated with the conference description.
+* MCI 10+ (ie `%TL10`...) are custom ranges updated with the same information available above in `itemFormat`.
diff --git a/docs/modding/node-msg.md b/docs/modding/node-msg.md
new file mode 100644
index 00000000..5377e68a
--- /dev/null
+++ b/docs/modding/node-msg.md
@@ -0,0 +1,41 @@
+---
+layout: page
+title: Node to Node Messaging
+---
+## The Node to Node Messaging Module
+The node to node messaging (`node_msg`) module allows users to send messages to one or more users on different nodes. Messages delivered to nodes follow standard [User Interruption](/docs/misc/user-interrupt.md) rules.
+
+## Configuration
+### Config Block
+Available `config` block entries:
+* `dateTimeFormat`: [moment.js](https://momentjs.com) style format. Defaults to current theme → system `short` format.
+* `messageFormat`: Format string for sent messages. Defaults to `Message from {fromUserName} on node {fromNodeId}:\r\n{message}`. The following format object members are available:
+ * `fromUserName`: Username who sent the message.
+ * `fromRealName`: Real name of user who sent the message.
+ * `fromNodeId`: Node ID where the message was sent from.
+ * `message`: User entered message. May contain pipe color codes.
+ * `timestamp`: A timestamp formatted using `dateTimeFormat` above.
+* `art`: Block containing:
+ * `header`: Art spec for header to display with message.
+ * `footer`: Art spec for footer to display with message.
+
+## Theming
+### MCI Codes
+1. Node selection. Must be a View that allows lists such as `SpinnerMenuView` (`%SM1`), `HorizontalMenuView` (`%HM1`), etc.
+2. Message entry (`%ET2`).
+3. Message preview (`%TL3`). A rendered (that is, pipe codes resolved) preview of the text in `%ET2`.
+
+10+: Custom using `itemFormat`. See below.
+
+### Item Format
+The following `itemFormat` object is provided for MCI 1 and 10+ for the currently selected item/node:
+* `text`: Node ID or "-ALL-" (All nodes).
+* `node`: Node ID or `-1` in the case of all nodes.
+* `userId`: User ID.
+* `action`: User's action.
+* `userName`: Username.
+* `realName`: Real name.
+* `location`: User's location.
+* `affils`: Affiliations.
+* `timeOn`: How long the user has been online (approx).
+
diff --git a/docs/modding/onelinerz.md b/docs/modding/onelinerz.md
new file mode 100644
index 00000000..92515617
--- /dev/null
+++ b/docs/modding/onelinerz.md
@@ -0,0 +1,18 @@
+---
+layout: page
+title: Onelinerz
+---
+## The Onelinerz Module
+The built in `onelinerz` module provides a retro onelinerz system.
+
+## Configuration
+### Config Block
+Available `config` block entries:
+* `dateTimeFormat`: [moment.js](https://momentjs.com) style format. Defaults to current theme → system `short` date format.
+
+### Theming
+The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`):
+* `userId`: User ID of the onliner entry.
+* `userName`: Login username of the onliner entry.
+* `oneliner`: The oneliner text. Note that the standard `{text}` field also contains this value.
+* `ts`: Timestamp of the entry formatted with `dateTimeFormat` format described above.
diff --git a/docs/modding/rumorz.md b/docs/modding/rumorz.md
new file mode 100644
index 00000000..f930edcc
--- /dev/null
+++ b/docs/modding/rumorz.md
@@ -0,0 +1,12 @@
+---
+layout: page
+title: Rumorz
+---
+## The Rumorz Module
+The built in `rumorz` module provides a classic interface for users to add and view rumorz!
+
+## Configuration
+
+### Theming
+The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) (the rumor list):
+* `rumor`: The rumor text. Also available in the standard `{text}` field.
diff --git a/docs/modding/set-newscan-date.md b/docs/modding/set-newscan-date.md
new file mode 100644
index 00000000..5649edac
--- /dev/null
+++ b/docs/modding/set-newscan-date.md
@@ -0,0 +1,29 @@
+---
+layout: page
+title: Set Newscan Date Module
+---
+## Set Newscan Date Module
+The `set_newscan_date` module allows setting newscan dates (aka pointers) for message conferences and areas as well as within the file base. Users can select specific conferences/areas or all (where applicable).
+
+## Configuration
+### Configuration Block
+Available `config` block entries are as follows:
+* `target`: Choose from `message` for message conferences & areas, or `file` for file base areas.
+* `scanDateFormat`: Format for scan date. This format must align with the **output** of the MaskEditView (`%ME1`) MCI utilized for input. Defaults to `YYYYMMDD` (which matches mask of `####/##/##`).
+
+### Theming
+#### Message Conference & Areas
+When `target` is `message`, the following `itemFormat` object is provided to MCI 2 (ie: `%SM2`):
+* `conf`: An object containing:
+ * `confTag`: Conference tag.
+ * `name`: Conference name. Also available in `{text}`.
+ * `desc`: Conference description.
+* `area`: An object containing:
+ * `areaTag`: Area tag.
+ * `name`: Area name. Also available in `{text}`.
+ * `desc`: Area description.
+
+When dealing with the file base, ENiGMA½ does not currently have the ability to set newscan dates for specific areas. No `%SM2` is used in this case.
+
+### Submit Actions
+Submit action should map to `@method:scanDateSubmit` and provide `scanDate` in form data. For message conf/areas (`target` of `message`), `targetSelection` should be also be provided in form data: An index to the selected conf/area.
diff --git a/docs/modding/show-art.md b/docs/modding/show-art.md
new file mode 100644
index 00000000..c00d7009
--- /dev/null
+++ b/docs/modding/show-art.md
@@ -0,0 +1,70 @@
+---
+layout: page
+title: User List
+---
+## The Show Art Module
+The built in `show_art` module add some advanced ways in which you can configure your system to display art assets beyond what a standard menu entry can provide. For example, based on user selection of a file or message base area.
+
+## Configuration
+### Config Block
+Available `config` block entries:
+* `method`: Set the method in which to show art. See **Methods** below.
+* `optional`: Is this art required or optional? If non-optional and we cannot show art based on `method`, it is an error.
+* `key`: Used for some `method`s. See **Methods**
+
+### Methods
+#### Extra Args
+When `method` is `extraArgs`, the module selects an *art spec* from a value found within `extraArgs` that were passed to `show_art` by `key`. Consider the following:
+
+Given an `menu.hjson` entry:
+```hjson
+showWithExtraArgs: {
+ module: show_art
+ config: {
+ method: extraArgs
+ key: fooBaz
+ }
+}
+```
+If the `showWithExtraArgs` menu was entered and passed `extraArgs` as the following:
+```json
+{
+ fizzBang : true,
+ fooBaz : "LOLART"
+}
+```
+
+...then the system would use the *art spec* of `LOLART`.
+
+#### Area & Conferences
+Handy for inserting into File Base, Message Conferences, or Mesage Area selections selections. When `method` is `fileBaseArea`, `messageConf`, or `messageArea` the selected conf/area's associated *art spec* is utilized. Example:
+
+Given a file base entry in `config.hjson`:
+```hjson
+areas: {
+ all_ur_base: {
+ name: All Your Base
+ desc: chown -r us ./base
+ art: ALLBASE
+ }
+}
+```
+
+A menu entry may look like this:
+```hjson
+showFileBaseAreaArt: {
+ module: show_art
+ config: {
+ method: fileBaseArea
+ cls: true
+ pause: true
+ menuFlags: [ "popParent", "noHistory" ]
+ }
+}
+```
+
+...if the user choose the "All Your Base" area, the *art spec* of `ALLBASE` would be selected and displayed.
+
+The only difference for `messageConf` or `messageArea` methods are where the art is defined (which is always next to the conf or area declaration in `config.hjson`).
+
+While `key` can be overridden, the system uses `areaTag` for message/file area selections, and `confTag` for conference selections by default.
diff --git a/docs/modding/top-x.md b/docs/modding/top-x.md
new file mode 100644
index 00000000..50d69bee
--- /dev/null
+++ b/docs/modding/top-x.md
@@ -0,0 +1,60 @@
+---
+layout: page
+title: TopX
+---
+## The TopX Module
+The built in `top_x` module allows for displaying oLDSKOOL (?!) top user stats for the week, month, etc. Ops can configure what stat(s) are displayed and how far back in days the stats are considered.
+
+## Configuration
+### Config Block
+Available `config` block entries:
+* `mciMap`: Supplies a mapping of MCI code to data source. See `mciMap` below.
+
+#### MCI Map (mciMap)
+The `mciMap` `config` block configures MCI code mapping to data sources. Currently the following data sources (determined by `type`) are available:
+
+| Type | Description |
+|-------------|-------------|
+| `userEventLog` | Top counts or sum of values found in the User Event Log. |
+| `userProp` | Top values (aka "scores") from user properties. |
+
+##### User Event Log (userEventLog)
+When `type` is set to `userEventLog`, entries from the User Event Log can be counted (ie: individual instances of a particular log item) or summed in the case of log items that have numeric values. The default is to sum.
+
+Some current User Event Log `value` examples include `ul_files`, `dl_file_bytes`, or `achievement_earned`. See [user_log_name.js](/core/user_log_name.js) for additional information.
+
+Example `userEventLog` entry:
+```hjson
+mciMap: {
+ 1: { // e.g.: %VM1
+ type: userEventLog
+ value: achievement_pts_earned // top achievement points earned
+ sum: true // this is the default
+ daysBack: 7 // omit daysBack for all-of-time
+ }
+}
+```
+
+#### User Properties (userProp)
+When `type` is set to `userProp`, data is collected from individual user's properties. For example a `value` of `minutes_online_total_count`. See [user_property.js](/core/user_property.js) for more information.
+
+Example `userProp` entry:
+```hjson
+mciMap: {
+ 2: { // e.g.: %VM2
+ type: userProp
+ value: minutes_online_total_count // top users by minutes spent on the board
+ }
+}
+```
+
+### Theming
+Generally `mciMap` entries will point to a Vertical List View Menu (`%VM1`, `%VM2`, etc.). The following `itemFormat` object is provided:
+* `value`: The value acquired from the supplied data source.
+* `userName`: User's username.
+* `realName`: User's real name.
+* `location`: User's location.
+* `affils` or `affiliation`: Users affiliations.
+* `position`: Rank position (numeric).
+
+Remember that string format rules apply, so for example, if displaying top uploaded bytes (`ul_file_bytes`), a `itemFormat` may be `{userName} - {value!sizeWithAbbr}` yielding something like "TopDude - 4 GB". See [MCI](/docs/art/mci.md) for additional information.
diff --git a/docs/modding/user-list.md b/docs/modding/user-list.md
new file mode 100644
index 00000000..37eb2e97
--- /dev/null
+++ b/docs/modding/user-list.md
@@ -0,0 +1,21 @@
+---
+layout: page
+title: User List
+---
+## The User List Module
+The built in `user_list` module provides basic user list functionality.
+
+## Configuration
+### Config Block
+Available `config` block entries:
+* `dateTimeFormat`: [moment.js](https://momentjs.com) style format. Defaults to current theme → system `short` format.
+
+### Theming
+The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`):
+* `userId`: User ID.
+* `userName`: Login username.
+* `realName`: User's real name.
+* `lastLoginTimestamp`: Full last login timestamp for formatting use.
+* `lastLoginTs`: Last login timestamp formatted with `dateTimeFormat` style.
+* `location`: User's location.
+* `affiliation` or `affils`: Users affiliations.
diff --git a/docs/modding/whos-online.md b/docs/modding/whos-online.md
new file mode 100644
index 00000000..22fde0ff
--- /dev/null
+++ b/docs/modding/whos-online.md
@@ -0,0 +1,18 @@
+---
+layout: page
+title: Who's Online
+---
+## The Who's Online Module
+The built in `whos_online` module provides a basic who's online mod.
+
+### Theming
+The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`):
+* `userId`: User ID.
+* `userName`: Login username.
+* `node`: Node ID the user is connected to.
+* `timeOn`: A human friendly amount of time the user has been online.
+* `realName`: User's real name.
+* `location`: User's location.
+* `affiliation` or `affils`: Users affiliations.
+* `action`: Current action/view in the system taken from the `desc` field of the current MenuModule they are interacting with. For example, "Playing L.O.R.D".
+
diff --git a/docs/oputil/index.md b/docs/oputil/index.md
deleted file mode 100644
index b23a7362..00000000
--- a/docs/oputil/index.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-layout: page
-title: oputil
----
-
-oputil is the ENiGMA½ command line utility for maintaining users, file areas and message areas, as well as
-generating your initial ENiGMA½ config.
-
-## File areas
-The `oputil.js` +op utilty `fb` command has tools for managing file bases. For example, to import existing
-files found within **all** storage locations tied to an area and set tags `tag1` and `tag2` to each import:
-
-```bash
-oputil.js fb scan some_area --tags tag1,tag2
-```
-
-See `oputil.js fb --help` for additional information.
\ No newline at end of file
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
index 676afb51..a71f8250 100644
--- a/docs/servers/ssh.md
+++ b/docs/servers/ssh.md
@@ -2,36 +2,41 @@
layout: page
title: SSH Server
---
-## Generate a SSH Private Key
+## SSH Login Server
+The ENiGMA½ SSH *login server* allows secure user logins over SSH (ssh://).
-To utilize the SSH server, an SSH Private Key will need generated. From the ENiGMA installation directory:
+## Configuration
+Entries available under `config.loginServers.ssh`:
-```bash
-openssl genrsa -des3 -out ./config/ssh_private_key.pem 2048
-```
+| 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`. |
+| `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.
-You then need to enable the SSH server in your `config.hjson`:
+### Example Configuration
```hjson
{
- loginServers: {
- ssh: {
- enabled: true
- port: 8889
- privateKeyPem: /path/to/ssh_private_key.pem
- privateKeyPass: YOUR_PK_PASS
+ loginServers: {
+ ssh: {
+ enabled: true
+ port: 8889
+ privateKeyPem: /path/to/ssh_private_key.pem
+ privateKeyPass: sup3rs3kr3tpa55
}
}
}
```
-### SSH Server Options
+## Generate a SSH Private Key
+To utilize the SSH server, an SSH Private Key will need generated. OpenSSL can be used for this task:
-| Option | Description
-|---------------------|--------------------------------------------------------------------------------------|
-| `privateKeyPem` | Path to private key file
-| `privateKeyPass` | Password to private key file
-| `firstMenu` | First menu an SSH connected user is presented with
-| `firstMenuNewUser` | Menu presented to user when logging in with `users::newUserNames` in your config.hjson (defaults to `new` and `apply`)
-| `enabled` | Enable/disable SSH server
-| `port` | Configure a custom port for the SSH server
+```bash
+openssl genrsa -des3 -out ./config/ssh_private_key.pem 2048
+```
diff --git a/docs/servers/telnet.md b/docs/servers/telnet.md
index 47aba591..ccefc966 100644
--- a/docs/servers/telnet.md
+++ b/docs/servers/telnet.md
@@ -2,24 +2,28 @@
layout: page
title: Telnet Server
---
+## Telnet Login Server
+The Telnet *login server* provides a standard **non-secure** Telnet login experience.
-Telnet is enabled by default on port `8888` in `config.hjson`:
+## Configuration
+The following configuration can be made in `config.hjson` under the `loginServers.telnet` block:
+| Item | Required | Description |
+|------|----------|-------------|
+| `enabled` | :-1: Defaults to `true`. Set to `false` to disable Telnet |
+| `port` | :-1: | Override the default port of `8888`. |
+| `firstMenu` | :-1: | First menu a telnet connected user is presented with. Defaults to `telnetConnected`. |
+
+### Example Configuration
```hjson
{
- loginServers: {
- telnet: {
- enabled: true
- port: 8888
- }
- }
+ loginServers: {
+ telnet: {
+ enabled: true
+ port: 8888
+ }
+ }
}
```
-### Telnet Server Options
-| Option | Description
-|---------------------|--------------------------------------------------------------------------------------|
-| `firstMenu` | First menu a telnet connected user is presented with
-| `enabled` | Enable/disable telnet server
-| `port` | Configure a custom port for the telnet server
diff --git a/docs/servers/websocket.md b/docs/servers/websocket.md
index 40d4b679..98f867e7 100644
--- a/docs/servers/websocket.md
+++ b/docs/servers/websocket.md
@@ -2,6 +2,9 @@
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).
@@ -27,11 +30,22 @@ don't already have it defined).
````hjson
loginServers: {
webSocket : {
- enabled: true
- port: 8810
- securePort: 8811
- certPem: /path/to/https_cert.pem
- keyPem: /path/to/https_cert_key.pem
+ 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
}
}
````
@@ -64,7 +78,7 @@ webserver, and unpack it to a temporary directory.
````javascript
var vtxdata = {
sysName: "Your Awesome BBS",
- wsConnect: "wss://your-hostname.here:8811"
+ wsConnect: "wss://your-hostname.here:8811",
term: "ansi-bbs",
codePage: "CP437",
fontName: "UVGA16",
diff --git a/docs/troubleshooting/monitoring-logs.md b/docs/troubleshooting/monitoring-logs.md
index 7c04cb40..28a3773a 100644
--- a/docs/troubleshooting/monitoring-logs.md
+++ b/docs/troubleshooting/monitoring-logs.md
@@ -2,14 +2,51 @@
layout: page
title: Monitoring Logs
---
-ENiGMA½ does not produce much to stdout. Logs are produced by Bunyan which outputs each entry as a
-JSON object.
+## Monitoring Logs
+ENiGMA½ does not produce much to stdout. Logs are produced by Bunyan which outputs each entry as a JSON object.
Start by installing bunyan and making it available on your path:
- npm install bunyan -g
+```bash
+npm install bunyan -g
+```
+
+or with Yarn:
+```bash
+yarn global add bunyan
+```
To tail logs in a colorized and pretty format, issue the following command:
-
- tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan
+```bash
+tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan
+```
+
+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/misc/acs_parser.pegjs b/misc/acs_parser.pegjs
index b381ccef..8a39deea 100644
--- a/misc/acs_parser.pegjs
+++ b/misc/acs_parser.pegjs
@@ -1,106 +1,205 @@
{
- var client = options.client;
- var user = options.client.user;
+ const UserProps = require('./user_property.js');
+ const Log = require('./logger.js').log;
- 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);
},
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..504cd02d
--- /dev/null
+++ b/misc/config_template.in.hjson
@@ -0,0 +1,460 @@
+{
+ /*
+ ./\/\.' 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
+ }
+
+ 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:
+ // 1) Generate a Private Key (PK):
+ // > openssl genrsa -des3 -out ./config/ssh_private_key.pem 2048
+ // 2) Set "privateKeyPass" below
+ //
+ 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
+ }
+ }
+ }
+
+ //
+ // 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..aa2b3d9b 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:=10}
+ENIGMA_BRANCH=0.0.9-alpha
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"`
@@ -24,24 +25,25 @@ ENiGMA½ will be installed to ${ENIGMA_INSTALL_DIR}, from source ${ENIGMA_SOURCE
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; }
+ 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
@@ -66,34 +68,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 +138,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..bdcf97cd
--- /dev/null
+++ b/misc/menu_template.in.hjson
@@ -0,0 +1,4105 @@
+{
+ /*
+ ./\/\.' 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 the login sequence
+ //
+ sshConnected: {
+ art: CONNECT
+ 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
+ //
+ // To enable forgot password, you will need to have the web server
+ // enabled and mail/SMTP configured. Once that is in place, swap out
+ // the commented lines below as well as in the submit block
+ //
+ items: [
+ {
+ text: login
+ data: login
+ }
+ {
+ text: apply
+ data: apply
+ }
+ {
+ 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: 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
+ }
+
+ 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
+ // :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: 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
+ }
+ ]
+ }
+ }
+ }
+
+ 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
+ // :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: 1
+ action: @menu:messageArea
+ }
+ ]
+ }
+
+ 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
+ }
+ ]
+ }
+ }
+ }
+
+ 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
+ }
+ 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
+ }
+ 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
+ }
+ 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/config/prompt.hjson b/misc/prompt_template.in.hjson
similarity index 96%
rename from config/prompt.hjson
rename to misc/prompt_template.in.hjson
index b6bf6691..e5a50630 100644
--- a/config/prompt.hjson
+++ b/misc/prompt_template.in.hjson
@@ -133,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" : {
@@ -220,7 +233,7 @@
// Any menu 'pause' will use this prompt
//
art: pause
- options: {
+ config: {
trailingLF: no
}
/*
diff --git a/package.json b/package.json
index 678628f1..15add31e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "enigma-bbs",
- "version": "0.0.8-alpha",
+ "version": "0.0.9-alpha",
"description": "ENiGMA½ Bulletin Board System",
"author": "Bryan Ashby ",
"license": "BSD-2-Clause",
@@ -22,40 +22,42 @@
"retro"
],
"dependencies": {
- "async": "^2.5.0",
- "binary": "0.3.x",
- "buffers": "NuSkooler/node-buffers",
+ "async": "^2.6.1",
+ "binary-parser": "^1.3.2",
+ "buffers": "github:NuSkooler/node-buffers",
"bunyan": "^1.8.12",
"exiftool": "^0.0.3",
- "fs-extra": "^5.0.0",
+ "fs-extra": "^7.0.1",
"glob": "^7.1.2",
- "graceful-fs": "^4.1.11",
+ "graceful-fs": "^4.1.15",
"hashids": "^1.1.1",
- "hjson": "^3.1.0",
- "iconv-lite": "^0.4.18",
- "inquirer": "^4.0.1",
+ "hjson": "^3.1.2",
+ "iconv-lite": "^0.4.23",
+ "inquirer": "^6.2.1",
"later": "1.2.0",
- "lodash": "^4.17.4",
- "mime-types": "^2.1.17",
+ "lodash": "^4.17.10",
+ "lru-cache": "^5.1.1",
+ "mime-types": "^2.1.21",
"minimist": "1.2.x",
- "moment": "^2.20.0",
- "nodemailer": "^4.4.1",
- "ptyw.js": "NuSkooler/ptyw.js",
+ "moment": "^2.24.0",
+ "nntp-server": "^1.0.3",
+ "node-pty": "^0.8.1",
+ "nodemailer": "^5.1.1",
"rlogin": "^1.0.0",
- "sane": "^2.2.0",
+ "sane": "^4.0.2",
"sanitize-filename": "^1.6.1",
- "sqlite3": "^3.1.9",
- "sqlite3-trans": "^1.2.0",
- "ssh2": "^0.5.5",
- "temptmp": "^1.0.0",
- "uuid": "^3.1.0",
+ "sqlite3": "^4.0.6",
+ "sqlite3-trans": "^1.2.1",
+ "ssh2": "^0.8.2",
+ "temptmp": "^1.1.0",
+ "uuid": "^3.2.1",
"uuid-parse": "^1.0.0",
- "ws": "^3.3.3",
+ "ws": "^6.1.3",
"xxhash": "^0.2.4",
- "yazl": "^2.4.2"
+ "yazl": "^2.5.1"
},
"devDependencies": {},
"engines": {
- "node": ">=6.9.2"
+ "node": ">=8"
}
}
diff --git a/util/exiftool2desc.js b/util/exiftool2desc.js
index 4b3b350f..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 result = metadata.author || '';
- if(result) {
- result += ' - ';
- }
- result += metadata.title || 'Unknown Title';
- return result;
+ 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/yarn.lock b/yarn.lock
new file mode 100644
index 00000000..8a18bc57
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,2236 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+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==
+
+ajv@^5.3.0:
+ version "5.5.2"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965"
+ integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=
+ dependencies:
+ co "^4.6.0"
+ fast-deep-equal "^1.0.0"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.3.0"
+
+ansi-escapes@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.1.0.tgz#f73207bb81207d75fd6c83f125af26eea378ca30"
+ integrity sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==
+
+ansi-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.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.0.0.tgz#70de791edf021404c3fd615aa89118ae0432e5a9"
+ integrity sha512-iB5Dda8t/UqpPI/IjsejXu5jOGDrzn41wJyljwPH65VCIbk6+1BzFIMJGFwTNrYXT1CrD+B4l19U7awiQ8rk7w==
+
+ansi-styles@^3.2.1:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+ integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+ dependencies:
+ color-convert "^1.9.0"
+
+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, asn1@~0.2.3:
+ 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"
+
+assert-plus@1.0.0, assert-plus@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
+ integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
+
+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-limiter@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
+ integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
+
+async@^2.6.1:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
+ integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==
+ dependencies:
+ lodash "^4.17.10"
+
+asynckit@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
+ integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+
+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==
+
+aws-sign2@~0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+ integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+
+aws4@^1.8.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
+ integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
+
+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.0, 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.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/binary-parser/-/binary-parser-1.3.2.tgz#5bd04f948ada1a6d78c528762308a9a335d63db9"
+ integrity sha512-VDhHcpeF1/ZZy1XvDmYD67bBjRNm1gacw+772xNd5BnTH6ax5TzlDV5dl7216/UlQXQoN9vug07ehk7e0PhNUw==
+
+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@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-1.2.0.tgz#1c5fcc489fd0ab00d4f1ac7ae1072e3173fbab6f"
+ integrity sha1-HF/MSJ/QqwDU8ax64QcuMXP7q28=
+ dependencies:
+ rsvp "^3.3.3"
+
+caseless@~0.12.0:
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
+ integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
+
+chalk@^2.0.0:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
+ integrity sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==
+ dependencies:
+ ansi-styles "^3.2.1"
+ escape-string-regexp "^1.0.5"
+ supports-color "^5.3.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@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
+ integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
+ dependencies:
+ restore-cursor "^2.0.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=
+
+co@^4.6.0:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+ integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
+
+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=
+
+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@^1.9.0:
+ version "1.9.3"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+ integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+ dependencies:
+ color-name "1.1.3"
+
+color-name@1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+ integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
+
+combined-stream@1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818"
+ integrity sha1-cj599ugBrFYTETp+RFqbactjKBg=
+ dependencies:
+ delayed-stream "~1.0.0"
+
+combined-stream@~1.0.6:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828"
+ integrity sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==
+ dependencies:
+ delayed-stream "~1.0.0"
+
+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.2, 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"
+
+dashdash@^1.12.0:
+ version "1.14.1"
+ resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
+ integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
+ dependencies:
+ assert-plus "^1.0.0"
+
+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.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"
+
+delayed-stream@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
+ integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+
+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"
+
+ecc-jsbn@~0.1.1:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
+ integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+ dependencies:
+ jsbn "~0.1.0"
+ safer-buffer "^2.1.0"
+
+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.2.0:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36"
+ integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==
+ dependencies:
+ merge "^1.2.0"
+
+exec-sh@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.2.tgz#6738de2eb7c8e671d0366aea0b0db8c6f7d7391b"
+ 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"
+
+extend@~3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+ integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
+external-editor@^3.0.0:
+ 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"
+
+extsprintf@1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
+ integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
+
+extsprintf@^1.2.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
+ integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
+
+fast-deep-equal@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614"
+ integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=
+
+fast-json-stable-stringify@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
+ integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
+
+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@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
+ integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=
+ 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=
+
+forever-agent@~0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
+ integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+
+form-data@~2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.2.tgz#4970498be604c20c005d4f5c23aecd21d6b49099"
+ integrity sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "1.0.6"
+ mime-types "^2.1.12"
+
+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@^7.0.1:
+ version "7.0.1"
+ resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
+ integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==
+ dependencies:
+ graceful-fs "^4.1.2"
+ jsonfile "^4.0.0"
+ universalify "^0.1.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=
+
+getpass@^0.1.1:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
+ integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
+ dependencies:
+ assert-plus "^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.15:
+ version "4.1.15"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
+ integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==
+
+graceful-fs@^4.1.2, graceful-fs@^4.1.6:
+ version "4.1.11"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
+ integrity sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=
+
+har-schema@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+ integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+
+har-validator@~5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.0.tgz#44657f5688a22cfd4b72486e81b3a3fb11742c29"
+ integrity sha512-+qnmNjI4OfH2ipQ9VQOw23bBd/ibtfbVdK2fYbY4acTDqKTW/YDp9McimZdDbG8iV9fZizUqQMD5xvriB146TA==
+ dependencies:
+ ajv "^5.3.0"
+ har-schema "^2.0.0"
+
+has-flag@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+ integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
+
+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@^1.1.1:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/hashids/-/hashids-1.2.2.tgz#28635c7f2f7360ba463686078eee837479e8eafb"
+ integrity sha512-dEHCG2LraR6PNvSGxosZHIRgxF5sNLOIBFEHbj8lfP9WWmu/PWPMzsip1drdVSOFi51N2pU7gZavrgn7sbGFuw==
+
+hjson@^3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/hjson/-/hjson-3.1.2.tgz#1ae8a3a897a1fab8d45180f98e9abf9b56f95b55"
+ integrity sha512-2ILrho8eRl2Bniy61mDFiXRAloYqH2T6OwWkoF/8y55DPFgG2RcqQGNXIfBLp432dnAbLOpBJ4pJs63W3X27EA==
+
+http-signature@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+ integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
+ dependencies:
+ assert-plus "^1.0.0"
+ jsprim "^1.2.2"
+ sshpk "^1.7.0"
+
+iconv-lite@^0.4.23, 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@~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@^6.2.1:
+ version "6.2.1"
+ resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.1.tgz#9943fc4882161bdb0b0c9276769c75b32dbfcd52"
+ integrity sha512-088kl3DRT2dLU5riVMKKr1DlImd6X7smDhpXUCkJDCKvTEJeRiXh0G132HG9u5a+6Ylw9plFRY7RuTnwohYSpg==
+ dependencies:
+ ansi-escapes "^3.0.0"
+ chalk "^2.0.0"
+ cli-cursor "^2.1.0"
+ cli-width "^2.0.0"
+ external-editor "^3.0.0"
+ figures "^2.0.0"
+ lodash "^4.17.10"
+ mute-stream "0.0.7"
+ run-async "^2.2.0"
+ rxjs "^6.1.0"
+ string-width "^2.1.0"
+ strip-ansi "^5.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-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-promise@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
+ integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=
+
+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-typedarray@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
+ integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
+
+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=
+
+isstream@~0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
+ integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
+
+jsbn@~0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
+ integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+
+json-schema-traverse@^0.3.0:
+ version "0.3.1"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
+ integrity sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=
+
+json-schema@0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
+ integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+
+json-stringify-safe@~5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+ integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+
+jsonfile@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+ integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
+ optionalDependencies:
+ graceful-fs "^4.1.6"
+
+jsprim@^1.2.2:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
+ integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+ dependencies:
+ assert-plus "1.0.0"
+ extsprintf "1.3.0"
+ json-schema "0.2.3"
+ verror "1.10.0"
+
+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.10, lodash@^4.17.4:
+ version "4.17.11"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
+ integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
+
+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"
+
+merge@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da"
+ integrity sha1-dTHjnUlJwoGma4xabgJl6LBYlNo=
+
+micromatch@^3.1.4:
+ version "3.1.10"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
+ 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.36.0:
+ version "1.36.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397"
+ integrity sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==
+
+mime-db@~1.37.0:
+ version "1.37.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.37.0.tgz#0b6a0ce6fdbe9576e25f1f2d2fde8830dc0ad0d8"
+ integrity sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==
+
+mime-types@^2.1.12, mime-types@~2.1.19:
+ version "2.1.20"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.20.tgz#930cb719d571e903738520f8470911548ca2cc19"
+ integrity sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A==
+ dependencies:
+ mime-db "~1.36.0"
+
+mime-types@^2.1.21:
+ version "2.1.21"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.21.tgz#28995aa1ecb770742fe6ae7e58f9181c744b3f96"
+ integrity sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg==
+ dependencies:
+ mime-db "~1.37.0"
+
+mimic-fn@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
+ integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==
+
+"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.x, 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.24.0:
+ version "2.24.0"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
+ integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
+
+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.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
+ integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
+
+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.12.1:
+ version "2.12.1"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552"
+ integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==
+
+nan@^2.10.0, nan@^2.4.0:
+ version "2.11.1"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.1.tgz#90e22bccb8ca57ea4cd37cc83d3819b52eea6766"
+ integrity sha512-iji6k87OSXa0CcrLl9z+ZiYSuR2o+c0bGuNmXdrhTQTakxytAFsC56SArGYoiHlJlFoHSnvmhpceZJaXkVuOtA==
+
+nan@~2.10.0:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"
+ integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==
+
+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.8.1:
+ version "0.8.1"
+ resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.8.1.tgz#94b457bec013e7a09b8d9141f63b0787fa25c23f"
+ integrity sha512-j+/g0Q5dR+vkELclpJpz32HcS3O/3EdPSGPvDXJZVJQLCvgG0toEbfmymxAEyQyZEpaoKHAcoL+PvKM+4N9nlw==
+ dependencies:
+ nan "2.12.1"
+
+nodemailer@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-5.1.1.tgz#0c48d1ecab02e86d9ff6c620ee75ed944b763505"
+ integrity sha512-hKGCoeNdFL2W7S76J/Oucbw0/qRlfG815tENdhzcqTpSjKgAN91mFOqU2lQUflRRxFM7iZvCyaFcAR9noc/CqQ==
+
+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=
+
+oauth-sign@~0.9.0:
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
+ integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
+
+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@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
+ integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=
+ dependencies:
+ mimic-fn "^1.0.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"
+
+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=
+
+performance-now@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+ integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
+
+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==
+
+psl@^1.1.24:
+ version "1.1.29"
+ resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67"
+ integrity sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ==
+
+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"
+
+punycode@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
+ integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
+
+qs@~6.5.2:
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+ integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+
+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=
+
+request@^2.87.0:
+ version "2.88.0"
+ resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
+ integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
+ dependencies:
+ aws-sign2 "~0.7.0"
+ aws4 "^1.8.0"
+ caseless "~0.12.0"
+ combined-stream "~1.0.6"
+ extend "~3.0.2"
+ forever-agent "~0.6.1"
+ form-data "~2.3.2"
+ har-validator "~5.1.0"
+ http-signature "~1.2.0"
+ is-typedarray "~1.0.0"
+ isstream "~0.1.2"
+ json-stringify-safe "~5.0.1"
+ mime-types "~2.1.19"
+ oauth-sign "~0.9.0"
+ performance-now "^2.1.0"
+ qs "~6.5.2"
+ safe-buffer "^5.1.2"
+ tough-cookie "~2.4.3"
+ tunnel-agent "^0.6.0"
+ uuid "^3.3.2"
+
+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@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
+ integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368=
+ dependencies:
+ onetime "^2.0.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@^3.3.3:
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a"
+ integrity sha512-OfWGQTb9vnwRjwtA2QwpG2ICclHC3pgXZO5xt8H2EfgDquO0qVdSb5T88L4qJVAEugbS56pAuV4XZM58UX8ulw==
+
+run-async@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
+ integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA=
+ dependencies:
+ is-promise "^2.1.0"
+
+rxjs@^6.1.0:
+ version "6.3.3"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55"
+ integrity sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw==
+ dependencies:
+ tslib "^1.9.0"
+
+safe-buffer@^5.0.1, 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.0.2, safer-buffer@^2.1.0, 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.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/sane/-/sane-4.0.2.tgz#5bd4a3f1268fd7a921a2dc657047de635c8f8f25"
+ integrity sha512-/3STCUfNSgMVpoREJc1i6ajKFlYZ5OflzZTOhlqPLa+01Ey+QR9iGZK7K5/qIRsQbEDCvqEJH/PL7yZywmnWsA==
+ dependencies:
+ anymatch "^2.0.0"
+ capture-exit "^1.2.0"
+ exec-sh "^0.3.2"
+ execa "^1.0.0"
+ fb-watchman "^2.0.0"
+ micromatch "^3.1.4"
+ minimist "^1.1.1"
+ walker "~1.0.5"
+ watch "~0.18.0"
+
+sanitize-filename@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.1.tgz#612da1c96473fa02dccda92dcd5b4ab164a6772a"
+ integrity sha1-YS2hyWRz+gLczaktzVtKsWSmdyo=
+ 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.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/sqlite3-trans/-/sqlite3-trans-1.2.1.tgz#642dff9f6da53d533ccd264b49e68c8818542255"
+ integrity sha512-KLtR+PBZN/moxDTKWTwWypkunDCJ0oi5vknjht8omjUXswwUEf+MX2DKtgQB1V5Tsjgc4mL4mHjv9zp7+FHs5g==
+ dependencies:
+ lodash "^4.17.4"
+
+sqlite3@^4.0.6:
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-4.0.6.tgz#e587b583b5acc6cb38d4437dedb2572359c080ad"
+ integrity sha512-EqBXxHdKiwvNMRCgml86VTL5TK1i0IKiumnfxykX0gh6H6jaKijAXvE9O1N7+omfNSawR2fOmIyJZcfe8HYWpw==
+ dependencies:
+ nan "~2.10.0"
+ node-pre-gyp "^0.11.0"
+ request "^2.87.0"
+
+ssh2-streams@~0.4.2:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/ssh2-streams/-/ssh2-streams-0.4.2.tgz#bac0d18727396d16049f5f0c8517a46516b45719"
+ integrity sha512-2rSj3oTIJnbAIzR3+XwIYef9wCOVrPQZNLL+fFPPjnPxf09tKkAbgrlYgh/1qynBTz65AUOS+s1zuko4M/GKCw==
+ dependencies:
+ asn1 "~0.2.0"
+ bcrypt-pbkdf "^1.0.2"
+ streamsearch "~0.1.2"
+
+ssh2@^0.8.2:
+ version "0.8.2"
+ resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-0.8.2.tgz#f7a172458d3a7a13d520438264f90de8a3ee72af"
+ integrity sha512-oaXu7faddvPFGavnLBkk0RFwLXvIzCPq6KqAC3ExlnFPAVIE1uo7pWHe9xmhNHXm+nIe7yg9qsssOm+ip2jijw==
+ dependencies:
+ ssh2-streams "~0.4.2"
+
+sshpk@^1.7.0:
+ version "1.14.2"
+ resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.14.2.tgz#c6fc61648a3d9c4e764fd3fcdf4ea105e492ba98"
+ integrity sha1-xvxhZIo9nE52T9P8306hBeSSupg=
+ dependencies:
+ asn1 "~0.2.3"
+ assert-plus "^1.0.0"
+ dashdash "^1.12.0"
+ getpass "^0.1.1"
+ safer-buffer "^2.0.2"
+ optionalDependencies:
+ bcrypt-pbkdf "^1.0.0"
+ ecc-jsbn "~0.1.1"
+ jsbn "~0.1.0"
+ tweetnacl "~0.14.0"
+
+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", string-width@^2.1.0:
+ 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_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.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.0.0.tgz#f78f68b5d0866c20b2c9b8c61b5298508dc8756f"
+ integrity sha512-Uu7gQyZI7J7gn5qLn1Np3G9vcYGTVqB+lFTytnDJv83dd8T22aGH451P3jueT2/QemInJDfxHB5Tde5OzgG1Ow==
+ dependencies:
+ ansi-regex "^4.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@^5.3.0:
+ version "5.5.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+ integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+ dependencies:
+ has-flag "^3.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"
+
+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"
+
+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"
+
+tough-cookie@~2.4.3:
+ version "2.4.3"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
+ integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
+ dependencies:
+ psl "^1.1.24"
+ punycode "^1.4.1"
+
+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==
+
+tunnel-agent@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+ integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+ dependencies:
+ safe-buffer "^5.0.1"
+
+tweetnacl@^0.14.3, tweetnacl@~0.14.0:
+ version "0.14.5"
+ resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
+ integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+
+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@^0.1.0:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+ integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
+
+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.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/uuid-parse/-/uuid-parse-1.0.0.tgz#f4657717624b0e4b88af36f98d89589a5bbee569"
+ integrity sha1-9GV3F2JLDkuIrzb5jYlYmlu+5Wk=
+
+uuid@^3.2.1, uuid@^3.3.2:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+ integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
+
+verror@1.10.0:
+ version "1.10.0"
+ resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
+ integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+ dependencies:
+ assert-plus "^1.0.0"
+ core-util-is "1.0.2"
+ extsprintf "^1.2.0"
+
+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"
+
+watch@~0.18.0:
+ version "0.18.0"
+ resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986"
+ integrity sha1-KAlUdsbffJDJYxOJkMClQj60uYY=
+ dependencies:
+ exec-sh "^0.2.0"
+ minimist "^1.2.0"
+
+which@^1.2.9:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+ 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@^6.1.3:
+ version "6.1.3"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.3.tgz#d2d2e5f0e3c700ef2de89080ebc0ac6e1bf3a72d"
+ integrity sha512-tbSxiT+qJI223AP4iLfQbkbxkwdFcneYinM2+x46Gx2wgvbaOMO36czfdfVUBRTHvzAMRhDd98sA5d/BuWbQdg==
+ dependencies:
+ async-limiter "~1.0.0"
+
+xtend@~4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
+ integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68=
+
+xxhash@^0.2.4:
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/xxhash/-/xxhash-0.2.4.tgz#8b8a48162cfccc21b920fa500261187d40216c39"
+ integrity sha1-i4pIFiz8zCG5IPpQAmEYfUAhbDk=
+ dependencies:
+ nan "^2.4.0"
+
+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"