Merge 0.0.9-alpha into develop

This commit is contained in:
Bryan Ashby 2019-02-15 14:43:50 -07:00
commit 562c73e096
No known key found for this signature in database
GPG Key ID: B49EB437951D2542
275 changed files with 60281 additions and 45024 deletions

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
# ACS parser is generated
core/acs_parser.js

View File

@ -7,7 +7,7 @@
"rules": { "rules": {
"indent": [ "indent": [
"error", "error",
"tab", 4,
{ {
"SwitchCase" : 1 "SwitchCase" : 1
} }

View File

@ -3,8 +3,8 @@ For :bug: bug reports, please fill out the information below plus any additional
**Short problem description** **Short problem description**
**Environment** **Environment**
- [ ] I am using Node.js v6.x or higher - [ ] I am using Node.js v10.x LTS or higher
- [ ] `npm install` reports success - [ ] `npm install` or `yarn` reports success
- Actual Node.js version (`node --version`): - Actual Node.js version (`node --version`):
- Operating system (`uname -a` on *nix systems): - Operating system (`uname -a` on *nix systems):
- Revision (`git rev-parse --short HEAD`): - Revision (`git rev-parse --short HEAD`):

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.pem *.pem
# Various directories # Various directories
config/config.hjson
logs/ logs/
db/ db/
dropfiles/ dropfiles/

View File

@ -1,4 +1,4 @@
Copyright (c) 2015-2018, Bryan D. Ashby Copyright (c) 2015-2019, Bryan D. Ashby
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

View File

@ -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 * [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 * 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 * [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 * [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 * Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support.
* Renegade style pipe color codes * Renegade style [pipe color codes](/docs/configuration/colour-codes.md).
* [SQLite](http://sqlite.org/) storage of users, message areas, and so on * [SQLite](http://sqlite.org/) storage of users, message areas, etc.
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption * 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! * [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 * [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 * [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) 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! * [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! * 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 ## 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 ## In the Works
* More ACS support coverage 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.
* 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)
## Known Issues ## 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. 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/) * [SyncTERM](http://syncterm.bbsdev.net/)
* [EtherTerm](https://github.com/M-griffin/EtherTerm) * [EtherTerm](https://github.com/M-griffin/EtherTerm)
* [NetRunner](http://mysticbbs.com/downloads.html) * [NetRunner](http://mysticbbs.com/downloads.html)
* [MagiTerm](https://magickabbs.com/index.php/magiterm/)
## Boards ## Boards
* WQH: :skull: [Xibalba](https://l33t.codes/xibalba-bbs) :skull: (**telnet://xibalba.l33t.codes:44510** or via SSH secure on port 44511) * WQH: :skull: [Xibalba](https://l33t.codes/xibalba-bbs) :skull: (**ssh://xibalba.l33t.codes:44511** or **telnet://xibalba.l33t.codes:44510**)
* [fORCE9](https://bbs.force9.org/): (**telnet://bbs.force9.org**) * [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 ## 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 ## 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 * [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. 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)! * [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 * 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/)! * Maskreet of [Throwback BBS](http://www.throwbackbbs.com/) hosting [DoorParty](http://forums.throwbackbbs.com/)!
* [Apam](https://github.com/apamment) of [Magicka](https://magickabbs.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 ## License
Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) 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. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

View File

@ -1,16 +1,17 @@
# Introduction # Introduction
This document covers basic upgrade notes for major ENiGMA½ version updates. This document covers basic upgrade notes for major ENiGMA½ version updates.
# Before Upgrading # 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) * At least back up the `db` directory and your `menu.hjson` (or renamed equivalent)
# General Notes # General Notes
Upgrades often come with changes to the default `menu.hjson`. It is wise to ## Configuration File Updates
use a *different* file name for your BBS's version of this file and point to 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!
it via `config.hjson`. For example:
### 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 ```hjson
general: { general: {
@ -21,6 +22,9 @@ general: {
After updating code, use a program such as DiffMerge to merge in updates to After updating code, use a program such as DiffMerge to merge in updates to
`my_bbs.hjson` from the shipping `menu.hjson`. `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 the Code
Upgrading from GitHub is easy: Upgrading from GitHub is easy:
@ -32,11 +36,37 @@ rm -rf npm_modules # do this any time you update Node.js itself
npm install npm install
``` ```
# Problems # Problems
Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or
[file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues). [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 # 0.0.7-alpha to 0.0.8-alpha
ENiGMA 0.0.8-alpha comes with some structure changes: 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** * Configuration files are defaulted to `./config`. Related, the `--config` option now points to a configuration **directory**

View File

@ -1,6 +1,34 @@
# Whats New # Whats New
This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub. 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 ## 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. * [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. * 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 * 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 <address>` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`) * NetMail support! You can now send and receive NetMail. To send a NetMail address a external user using `Name <address>` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`)
* `oputil.js`: Added `mb areafix` command to quickly send AreaFix messages from the command line. You can manually send them from personal mail as well. * `oputil.js`: 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 * Users can now (re)set File and Message base pointers
* Add `--update` option to `oputil.js fb scan` * 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. * Fix @watch path support for event scheduler including FTN, e.g. when looking for a `toss!.now` file produced by Binkd.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -9,9 +9,7 @@
customization: { customization: {
defaults: { defaults: {
general: { passwordChar: *
passwordChar: *
}
dateTimeFormat: { dateTimeFormat: {
short: MMM Do h:mm a short: MMM Do h:mm a
@ -22,7 +20,8 @@
matrix: { matrix: {
mci: { mci: {
VM1: { VM1: {
focusTextStyle: first lower itemFormat: "|03{text}"
focusItemFormat: "|11{text!styleFirstLower}"
} }
} }
} }
@ -87,11 +86,15 @@
fullLoginSequenceOnelinerz: { fullLoginSequenceOnelinerz: {
config: { config: {
listFormat: "|00|11{username:<12}|08: |03{oneliner:<59.58}" dateTimeFormat: ddd h:mma
} }
0: { 0: {
mci: { mci: {
VM1: { height: 10 } VM1: {
height: 10
width: 20
itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}"
}
TM2: { TM2: {
focusTextStyle: first lower 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: { mainMenuUserStats: {
mci: { mci: {
UN1: { width: 17 } UN1: { width: 15 }
UR2: { width: 17 } UR2: { width: 15 }
LO3: { width: 17 } LO3: { width: 15 }
UF4: { width: 17 } UF4: { width: 15 }
UG5: { width: 17 } UG5: { width: 15 }
UT6: { width: 17 } UT6: { width: 15 }
UC7: { width: 17 } UC7: { width: 15 }
ST8: { width: 17 } ST8: { width: 15 }
} }
} }
mainMenuSystemStats: { mainMenuSystemStats: {
mci: { mci: {
BN1: { width: 17 } BN1: { width: 17 }
VL2: { width: 17 } VN2: { width: 17 }
OS3: { width: 33 } OS3: { width: 33 }
SC4: { width: 33 } SC4: { width: 33 }
DT5: { width: 33 }
CT6: { width: 33 }
AN7: { width: 6 } AN7: { width: 6 }
ND8: { width: 6 } ND8: { width: 6 }
TC9: { width: 6 } TC9: { width: 6 }
TT11: { width: 6 }
PT12: { width: 6 }
TP13: { width: 6 }
NV14: { width: 17 }
} }
} }
mainMenuLastCallers: { mainMenuLastCallers: {
config: { config: {
listFormat: "|00|11{userName:<17.17}|03{location:<20.20}|11{affils:<17.17}|03{ts:<15}"
dateTimeFormat: MMM Do h:mma dateTimeFormat: MMM Do h:mma
} }
mci: { 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: { mainMenuUserList: {
config: { 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 dateTimeFormat: MMM Do h:mma
} }
mci: { 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: { mainMenuWhosOnline: {
config: {
listFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}"
}
mci: { 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: { mainMenuOnelinerz: {
// :TODO: Need way to just duplicate entry here & in menu.hjson, e.g. use: someName + must supply next/etc. in menu
config: { config: {
listFormat: "|00|11{username:<12}|08: |03{oneliner:<59.58}" dateTimeFormat: ddd h:mma
} }
0: { 0: {
mci: { mci: {
VM1: { height: 10 } VM1: {
height: 10
itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}"
}
TM2: { TM2: {
focusTextStyle: first lower focusTextStyle: first lower
} }
@ -207,39 +243,47 @@
messageAreaMessageList: { messageAreaMessageList: {
config: { 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 dateTimeFormat: ddd MMM Do
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
} }
mci: { mci: {
VM1: { VM1: {
height: 14 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: { messageAreaChangeCurrentConference: {
config: {
listFormat: "|00|15{index} |07- |03{name}"
focusListFormat: "|00|19|15{index} - {name}"
}
mci: { mci: {
VM1: { VM1: {
width: 26 width: 26
height: 19 height: 19
itemFormat: "|00|15{index} |07- |03{name}"
focusItemFormat: "|00|19|15{index} - {name}"
} }
} }
} }
messageAreaChangeCurrentArea: { messageAreaChangeCurrentArea: {
config: {
listFormat: "|00|15{index} |07- |03{name}"
focusListFormat: "|00|19|15{index} - {name}"
}
mci: { mci: {
VM1: { VM1: {
width: 26 width: 26
height: 19 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: { mailMenuInbox: {
config: { 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 dateTimeFormat: ddd MMM Do
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
} }
mci: { mci: {
VM1: { VM1: {
height: 14 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: { mainMenuRumorz: {
config: {
listFormat: "|00|11 {rumor}"
focusListFormat: "|00|15> |14{rumor}"
}
0: { 0: {
mci: { mci: {
VM1: { height: 14 } VM1: {
height: 14,
width: 70
itemFormat: "|00|11 {rumor}"
focusItemFormat: "|00|15> |14{rumor}"
}
TM2: { TM2: {
focusTextStyle: upper focusTextStyle: upper
items: [ "yes", "no" ] items: [ "yes", "no" ]
@ -298,16 +348,14 @@
} }
bbsList: { bbsList: {
config: {
listFormat: "|00|07{bbsName}"
focusListFormat: "|00|19|15{bbsName!styleFirstLower}"
}
0: { 0: {
mci: { mci: {
VM1: { VM1: {
height: 11 height: 11
width: 22 width: 22
focusTextStyle: first upper focusTextStyle: first upper
itemFormat: "|00|07{bbsName}"
focusItemFormat: "|00|19|15{bbsName!styleFirstLower}"
} }
TL2: { width: 28 } TL2: { width: 28 }
TL3: { 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: { messageAreaViewPost: {
0: { 0: {
@ -410,20 +522,24 @@
fullLoginSequenceLastCallers: { fullLoginSequenceLastCallers: {
config: { config: {
listFormat: "|00|11{userName:<17.17}|03{location:<20.20}|11{affils:<17.17}|03{ts:<15}"
dateTimeFormat: MMM Do h:mma dateTimeFormat: MMM Do h:mma
} }
mci: { 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: { fullLoginSequenceWhosOnline: {
config: {
listFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}"
}
mci: { 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: { fullLoginSequenceUserStats: {
mci: { mci: {
UN1: { width: 17 } UN1: { width: 15 }
UR2: { width: 17 } UR2: { width: 15 }
LO3: { width: 17 } LO3: { width: 15 }
UF4: { width: 17 } UF4: { width: 15 }
UG5: { width: 17 } UG5: { width: 15 }
UT6: { width: 17 } UT6: { width: 15 }
UC7: { width: 17 } UC7: { width: 15 }
ST8: { width: 17 } ST8: { width: 15 }
} }
} }
@ -468,13 +584,15 @@
newScanMessageList: { newScanMessageList: {
config: { 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}"
dateTimeFormat: ddd MMM Do dateTimeFormat: ddd MMM Do
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
} }
mci: { mci: {
VM1: { VM1: {
height: 14 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: { fileBaseListEntries: {
config: { config: {
hashTagsSep: "|08, |07" 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}" browseInfoFormat11: "|00|15{areaName}"
browseInfoFormat12: "|00|07{hashTags}" browseInfoFormat12: "|00|07{hashTags}"
browseInfoFormat13: "|00|07{estReleaseYear}" browseInfoFormat13: "|00|07{estReleaseYear}"
@ -525,9 +643,6 @@
detailsGeneralInfoFormat21: "{uploadTimestamp}" detailsGeneralInfoFormat21: "{uploadTimestamp}"
detailsGeneralInfoFormat22: "{archiveTypeDesc}" 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)" notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)"
} }
@ -586,6 +701,8 @@
VM1: { VM1: {
height: 17 height: 17
width: 79 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: { newScanFileBaseList: {
config: { config: {
hashTagsSep: "|08, |07" 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}" browseInfoFormat11: "|00|15{areaName}"
browseInfoFormat12: "|00|07{hashTags}" browseInfoFormat12: "|00|07{hashTags}"
browseInfoFormat13: "|00|07{estReleaseYear}" browseInfoFormat13: "|00|07{estReleaseYear}"
@ -626,9 +743,6 @@
detailsGeneralInfoFormat21: "{uploadTimestamp}" detailsGeneralInfoFormat21: "{uploadTimestamp}"
detailsGeneralInfoFormat22: "{archiveTypeDesc}" 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)" notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)"
} }
@ -687,23 +801,22 @@
VM1: { VM1: {
height: 17 height: 17
width: 79 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: { fileBaseBrowseByAreaSelect: {
config: {
protListFormat: "|00|03{name}"
protListFocusFormat: "|00|19|15{name}"
}
0: { 0: {
mci: { mci: {
VM1: { VM1: {
height: 15 height: 15
width: 30 width: 30
focusTextStyle: first lower focusTextStyle: first lower
itemFormat: "|00|03{name}"
focusItemFormat: "|00|19|15{name}"
} }
} }
} }
@ -722,15 +835,15 @@
} }
SM4: { SM4: {
width: 14 width: 14
justify: right justify: left
} }
SM5: { SM5: {
width: 14 width: 14
justify: right justify: left
} }
SM6: { SM6: {
width: 14 width: 14
justify: right justify: left
} }
BT7: { BT7: {
focusTextStyle: first lower 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: { fileAreaFilterEditor: {
mci: { mci: {
ET1: { ET1: {
@ -748,15 +906,15 @@
} }
SM3: { SM3: {
width: 14 width: 14
justify: right justify: left
} }
SM4: { SM4: {
width: 14 width: 14
justify: right justify: left
} }
SM5: { SM5: {
width: 14 width: 14
justify: right justify: left
} }
ET6: { ET6: {
width: 26 width: 26
@ -768,16 +926,13 @@
} }
fileBaseDownloadManager: { 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: { 0: {
mci: { mci: {
VM1: { VM1: {
height: 11 height: 11
width: 69 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: { HM2: {
width: 50 width: 50
@ -789,8 +944,6 @@
fileBaseWebDownloadManager: { fileBaseWebDownloadManager: {
config: { 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}" queueManagerInfoFormat10: "|03batch|08: |03{webBatchDlLink}"
queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}" queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}"
} }
@ -799,6 +952,8 @@
mci: { mci: {
VM1: { VM1: {
height: 8 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: { HM2: {
width: 50 width: 50
@ -826,7 +981,7 @@
mci: { mci: {
SM1: { SM1: {
width: 14 width: 14
justify: right justify: left
focusTextStyle: first lower focusTextStyle: first lower
} }
@ -895,17 +1050,14 @@
} }
fileTransferProtocolSelection: { fileTransferProtocolSelection: {
config: {
protListFormat: "|00|03{name}"
protListFocusFormat: "|00|19|15{name}"
}
0: { 0: {
mci: { mci: {
VM1: { VM1: {
height: 15 height: 15
width: 30 width: 30
focusTextStyle: first lower 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
//
}
}
}
}
}
} }
} }

469
config/achievements.hjson Normal file
View File

@ -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
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,197 +1,199 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const MenuModule = require('./menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const DropFile = require('./dropfile.js').DropFile; const DropFile = require('./dropfile.js');
const door = require('./door.js'); const Door = require('./door.js');
const theme = require('./theme.js'); const theme = require('./theme.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
const async = require('async'); // deps
const assert = require('assert'); const async = require('async');
const paths = require('path'); const assert = require('assert');
const _ = require('lodash'); const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs; const paths = require('path');
// :TODO: This should really be a system module... needs a little work to allow for such
const activeDoorNodeInstances = {}; const activeDoorNodeInstances = {};
exports.moduleInfo = { exports.moduleInfo = {
name : 'Abracadabra', name : 'Abracadabra',
desc : 'External BBS Door Module', desc : 'External BBS Door Module',
author : 'NuSkooler', author : 'NuSkooler',
}; };
/* /*
Example configuration for LORD under DOSEMU: Example configuration for LORD under DOSEMU:
{ {
config: { config: {
name: PimpWars name: PimpWars
dropFileType: DORINFO dropFileType: DORINFO
cmd: qemu-system-i386 cmd: qemu-system-i386
args: [ args: [
"-localtime", "-localtime",
"freedos.img", "freedos.img",
"-chardev", "-chardev",
"socket,port={srvPort},nowait,host=localhost,id=s0", "socket,port={srvPort},nowait,host=localhost,id=s0",
"-device", "-device",
"isa-serial,chardev=s0" "isa-serial,chardev=s0"
] ]
io: socket io: socket
} }
} }
listen: socket | stdio listen: socket | stdio
{ {
"config" : { "config" : {
"name" : "LORD", "name" : "LORD",
"dropFileType" : "DOOR", "dropFileType" : "DOOR",
"cmd" : "/usr/bin/dosemu", "cmd" : "/usr/bin/dosemu",
"args" : [ "-quiet", "-f", "/etc/dosemu/dosemu.conf", "X:\\PW\\START.BAT {dropfile} {node}" ] ], "args" : [ "-quiet", "-f", "/etc/dosemu/dosemu.conf", "X:\\PW\\START.BAT {dropfile} {node}" ] ],
"nodeMax" : 32, "nodeMax" : 32,
"tooManyArt" : "toomany-lord.ans" "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 { exports.getModule = class AbracadabraModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.config = options.menuConfig.config; this.config = options.menuConfig.config;
// :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... } // :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... }
assert(_.isString(this.config.name, 'Config \'name\' is required')); // .. and/or EnigAssert
assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required')); assert(_.isString(this.config.name, 'Config \'name\' is required'));
assert(_.isString(this.config.cmd, 'Config \'cmd\' 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.nodeMax = this.config.nodeMax || 0;
this.config.args = this.config.args || []; this.config.args = this.config.args || [];
} }
/* /*
:TODO: :TODO:
* disconnecting wile door is open leaves dosemu * disconnecting wile door is open leaves dosemu
* http://bbslink.net/sysop.php support * http://bbslink.net/sysop.php support
* Font support ala all other menus... or does this just work? * Font support ala all other menus... or does this just work?
*/ */
initSequence() { initSequence() {
const self = this; const self = this;
async.series( async.series(
[ [
function validateNodeCount(callback) { function validateNodeCount(callback) {
if(self.config.nodeMax > 0 && if(self.config.nodeMax > 0 &&
_.isNumber(activeDoorNodeInstances[self.config.name]) && _.isNumber(activeDoorNodeInstances[self.config.name]) &&
activeDoorNodeInstances[self.config.name] + 1 > self.config.nodeMax) activeDoorNodeInstances[self.config.name] + 1 > self.config.nodeMax)
{ {
self.client.log.info( self.client.log.info(
{ {
name : self.config.name, name : self.config.name,
activeCount : activeDoorNodeInstances[self.config.name] activeCount : activeDoorNodeInstances[self.config.name]
}, },
'Too many active instances'); 'Too many active instances');
if(_.isString(self.config.tooManyArt)) { if(_.isString(self.config.tooManyArt)) {
theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() { theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() {
self.pausePrompt( () => { self.pausePrompt( () => {
callback(new Error('Too many active instances')); return callback(Errors.AccessDenied('Too many active instances'));
}); });
}); });
} else { } else {
self.client.term.write('\nToo many active instances. Try again later.\n'); self.client.term.write('\nToo many active instances. Try again later.\n');
// :TODO: Use MenuModule.pausePrompt() // :TODO: Use MenuModule.pausePrompt()
self.pausePrompt( () => { self.pausePrompt( () => {
callback(new Error('Too many active instances')); return callback(Errors.AccessDenied('Too many active instances'));
}); });
} }
} else { } else {
// :TODO: JS elegant way to do this? // :TODO: JS elegant way to do this?
if(activeDoorNodeInstances[self.config.name]) { if(activeDoorNodeInstances[self.config.name]) {
activeDoorNodeInstances[self.config.name] += 1; activeDoorNodeInstances[self.config.name] += 1;
} else { } else {
activeDoorNodeInstances[self.config.name] = 1; activeDoorNodeInstances[self.config.name] = 1;
} }
callback(null); callback(null);
} }
}, },
function generateDropfile(callback) { function prepareDoor(callback) {
self.dropFile = new DropFile(self.client, self.config.dropFileType); self.doorInstance = new Door(self.client);
var fullPath = self.dropFile.fullPath; return self.doorInstance.prepare(self.config.io || 'stdio', callback);
},
function generateDropfile(callback) {
const dropFileOpts = {
fileType : self.config.dropFileType,
};
mkdirs(paths.dirname(fullPath), function dirCreated(err) { self.dropFile = new DropFile(self.client, dropFileOpts);
if(err) { return self.dropFile.createFile(callback);
callback(err); }
} else { ],
self.dropFile.createFile(function created(err) { function complete(err) {
callback(err); if(err) {
}); self.client.log.warn( { error : err.toString() }, 'Could not start door');
} self.lastError = err;
}); self.prevMenu();
} } else {
], self.finishedLoading();
function complete(err) { }
if(err) { }
self.client.log.warn( { error : err.toString() }, 'Could not start door'); );
self.lastError = err; }
self.prevMenu();
} else {
self.finishedLoading();
}
}
);
}
runDoor() { runDoor() {
this.client.term.write(ansi.resetScreen());
const exeInfo = { const exeInfo = {
cmd : this.config.cmd, cmd : this.config.cmd,
args : this.config.args, cwd : this.config.cwd || paths.dirname(this.config.cmd),
io : this.config.io || 'stdio', args : this.config.args,
encoding : this.config.encoding || this.client.term.outputEncoding, io : this.config.io || 'stdio',
dropFile : this.dropFile.fileName, encoding : this.config.encoding || 'cp437',
node : this.client.node, dropFile : this.dropFile.fileName,
//inhSocket : this.client.output._handle.fd, dropFilePath : this.dropFile.fullPath,
}; node : this.client.node,
};
const doorInstance = new door.Door(this.client, exeInfo); const doorTracking = trackDoorRunBegin(this.client, this.config.name);
doorInstance.once('finished', () => { this.doorInstance.run(exeInfo, () => {
// trackDoorRunEnd(doorTracking);
// Try to clean up various settings such as scroll regions that may
// have been set within the door
//
this.client.term.rawWrite(
ansi.normal() +
ansi.goto(this.client.term.termHeight, this.client.term.termWidth) +
ansi.setScrollRegion() +
ansi.goto(this.client.term.termHeight, 0) +
'\r\n\r\n'
);
this.prevMenu(); //
}); // Try to clean up various settings such as scroll regions that may
// have been set within the door
//
this.client.term.rawWrite(
ansi.normal() +
ansi.goto(this.client.term.termHeight, this.client.term.termWidth) +
ansi.setScrollRegion() +
ansi.goto(this.client.term.termHeight, 0) +
'\r\n\r\n'
);
this.client.term.write(ansi.resetScreen()); this.prevMenu();
});
}
doorInstance.run(); leave() {
} super.leave();
if(!this.lastError) {
activeDoorNodeInstances[this.config.name] -= 1;
}
}
leave() { finishedLoading() {
super.leave(); this.runDoor();
if(!this.lastError) { }
activeDoorNodeInstances[this.config.name] -= 1;
}
}
finishedLoading() {
this.runDoor();
}
}; };

634
core/achievement.js Normal file
View File

@ -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);
});
};

View File

@ -1,86 +1,103 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const checkAcs = require('./acs_parser.js').parse; const checkAcs = require('./acs_parser.js').parse;
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
// deps // deps
const assert = require('assert'); const assert = require('assert');
const _ = require('lodash'); const _ = require('lodash');
class ACS { class ACS {
constructor(client) { constructor(subject) {
this.client = client; this.subject = subject;
} }
check(acs, scope, defaultAcs) { check(acs, scope, defaultAcs) {
acs = acs ? acs[scope] : defaultAcs; acs = acs ? acs[scope] : defaultAcs;
acs = acs || defaultAcs; acs = acs || defaultAcs;
try { try {
return checkAcs(acs, { client : this.client } ); return checkAcs(acs, { subject : this.subject } );
} catch(e) { } catch(e) {
Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS'); Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS');
return false; return false;
} }
} }
// //
// Message Conferences & Areas // Message Conferences & Areas
// //
hasMessageConfRead(conf) { hasMessageConfRead(conf) {
return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead); return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead);
} }
hasMessageAreaRead(area) { hasMessageAreaRead(area) {
return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead); return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead);
} }
// //
// File Base / Areas // File Base / Areas
// //
hasFileAreaRead(area) { hasFileAreaRead(area) {
return this.check(area.acs, 'read', ACS.Defaults.FileAreaRead); return this.check(area.acs, 'read', ACS.Defaults.FileAreaRead);
} }
hasFileAreaWrite(area) { hasFileAreaWrite(area) {
return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite); return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite);
} }
hasFileAreaDownload(area) { hasFileAreaDownload(area) {
return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload); return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload);
} }
getConditionalValue(condArray, memberName) { hasMenuModuleAccess(modInst) {
assert(_.isArray(condArray)); const acs = _.get(modInst, 'menuConfig.config.acs');
assert(_.isString(memberName)); 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;
}
}
const matchCond = condArray.find( cond => { getConditionalValue(condArray, memberName) {
if(_.has(cond, 'acs')) { if(!Array.isArray(condArray)) {
try { // no cond array, just use the value
return checkAcs(cond.acs, { client : this.client } ); return condArray;
} catch(e) { }
Log.warn( { exception : e, acs : cond }, 'Exception caught checking ACS');
return false;
}
} else {
return true; // no acs check req.
}
});
if(matchCond) { assert(_.isString(memberName));
return matchCond[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 = { ACS.Defaults = {
MessageAreaRead : 'GM[users]', MessageAreaRead : 'GM[users]',
MessageConfRead : 'GM[users]', MessageConfRead : 'GM[users]',
FileAreaRead : 'GM[users]', FileAreaRead : 'GM[users]',
FileAreaWrite : 'GM[sysops]', FileAreaWrite : 'GM[sysops]',
FileAreaDownload : 'GM[users]', FileAreaDownload : 'GM[users]',
}; };
module.exports = ACS; module.exports = ACS;

View File

@ -844,107 +844,206 @@ function peg$parse(input, options) {
} }
var client = options.client; const UserProps = require('./user_property.js');
var user = options.client.user; const Log = require('./logger.js').log;
var _ = require('lodash'); const _ = require('lodash');
var assert = require('assert'); const moment = require('moment');
const client = _.get(options, 'subject.client');
const user = _.get(options, 'subject.user');
function checkAccess(acsCode, value) { function checkAccess(acsCode, value) {
try { try {
return { return {
LC : function isLocalConnection() { LC : function isLocalConnection() {
return client.isLocal(); return client && client.isLocal();
}, },
AG : function ageGreaterOrEqualThan() { AG : function ageGreaterOrEqualThan() {
return !isNaN(value) && user.getAge() >= value; return !isNaN(value) && user && user.getAge() >= value;
}, },
AS : function accountStatus() { AS : function accountStatus() {
if(!_.isArray(value)) { if(!user) {
return false;
}
if(!Array.isArray(value)) {
value = [ value ]; value = [ value ];
} }
const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
const userAccountStatus = parseInt(user.properties.account_status, 10); return value.map(n => parseInt(n, 10)).includes(userAccountStatus);
value = value.map(n => parseInt(n, 10)); // ensure we have integers
return value.indexOf(userAccountStatus) > -1;
}, },
EC : function isEncoding() { EC : function isEncoding() {
const encoding = _.get(client, 'term.outputEncoding', '').toLowerCase();
switch(value) { switch(value) {
case 0 : return 'cp437' === client.term.outputEncoding.toLowerCase(); case 0 : return 'cp437' === encoding;
case 1 : return 'utf-8' === client.term.outputEncoding.toLowerCase(); case 1 : return 'utf-8' === encoding;
default : return false; default : return false;
} }
}, },
GM : function isOneOfGroups() { GM : function isOneOfGroups() {
if(!_.isArray(value)) { if(!user) {
return false; return false;
} }
if(!Array.isArray(value)) {
return _.findIndex(value, function cmp(groupName) { return false;
return user.isGroupMember(groupName); }
}) > - 1; return value.some(groupName => user.isGroupMember(groupName));
}, },
NN : function isNode() { 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() { 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; return !isNaN(value) && postCount >= value;
}, },
NC : function numberOfCalls() { 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; 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() { SC : function isSecureConnection() {
return client.session.isSecure; return _.get(client, 'session.isSecure', false);
}, },
ML : function minutesLeft() { ML : function minutesLeft() {
// :TODO: implement me! // :TODO: implement me!
return false; return false;
}, },
TH : function termHeight() { TH : function termHeight() {
return !isNaN(value) && client.term.termHeight >= value; return !isNaN(value) && _.get(client, 'term.termHeight', 0) >= value;
}, },
TM : function isOneOfThemes() { TM : function isOneOfThemes() {
if(!_.isArray(value)) { if(!Array.isArray(value)) {
return false; return false;
} }
return value.includes(_.get(client, 'currentTheme.name'));
return value.indexOf(client.currentTheme.name) > -1;
}, },
TT : function isOneOfTermTypes() { TT : function isOneOfTermTypes() {
if(!_.isArray(value)) { if(!Array.isArray(value)) {
return false; return false;
} }
return value.includes(_.get(client, 'term.termType'));
return value.indexOf(client.term.termType) > -1;
}, },
TW : function termWidth() { TW : function termWidth() {
return !isNaN(value) && client.term.termWidth >= value; return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value;
}, },
ID : function isUserId(value) { ID : function isUserId() {
if(!_.isArray(value)) { if(!user) {
return false;
}
if(!Array.isArray(value)) {
value = [ value ]; value = [ value ];
} }
return value.map(n => parseInt(n, 10)).includes(user.userId);
value = value.map(n => parseInt(n, 10)); // ensure we have integers
return value.indexOf(user.userId) > -1;
}, },
WD : function isOneOfDayOfWeek() { WD : function isOneOfDayOfWeek() {
if(!_.isArray(value)) { if(!Array.isArray(value)) {
value = [ value ]; value = [ value ];
} }
return value.map(n => parseInt(n, 10)).includes(new Date().getDay());
value = value.map(n => parseInt(n, 10)); // ensure we have integers
return value.indexOf(new Date().getDay()) > -1;
}, },
MM : function isMinutesPastMidnight() { MM : function isMinutesPastMidnight() {
// :TODO: return true if value is >= minutes past midnight sys time const now = moment();
return false; 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); }[acsCode](value);
} catch (e) { } 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; return false;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,220 +1,220 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser; const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser;
const ANSI = require('./ansi_term.js'); const ANSI = require('./ansi_term.js');
const { const {
splitTextAtTerms, splitTextAtTerms,
renderStringLength renderStringLength
} = require('./string_util.js'); } = require('./string_util.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
module.exports = function ansiPrep(input, options, cb) { module.exports = function ansiPrep(input, options, cb) {
if(!input) { if(!input) {
return cb(null, ''); return cb(null, '');
} }
options.termWidth = options.termWidth || 80; options.termWidth = options.termWidth || 80;
options.termHeight = options.termHeight || 25; options.termHeight = options.termHeight || 25;
options.cols = options.cols || options.termWidth || 80; options.cols = options.cols || options.termWidth || 80;
options.rows = options.rows || options.termHeight || 'auto'; options.rows = options.rows || options.termHeight || 'auto';
options.startCol = options.startCol || 1; options.startCol = options.startCol || 1;
options.exportMode = options.exportMode || false; options.exportMode = options.exportMode || false;
options.fillLines = _.get(options, 'fillLines', true); options.fillLines = _.get(options, 'fillLines', true);
options.indent = options.indent || 0; options.indent = options.indent || 0;
// in auto we start out at 25 rows, but can always expand for more // 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 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 parser = new ANSIEscapeParser( { termHeight : options.termHeight, termWidth : options.termWidth } );
const state = { const state = {
row : 0, row : 0,
col : 0, col : 0,
}; };
let lastRow = 0; let lastRow = 0;
function ensureRow(row) { function ensureRow(row) {
if(canvas[row]) { if(canvas[row]) {
return; return;
} }
canvas[row] = Array.from( { length : options.cols}, () => new Object() ); canvas[row] = Array.from( { length : options.cols}, () => new Object() );
} }
parser.on('position update', (row, col) => { parser.on('position update', (row, col) => {
state.row = row - 1; state.row = row - 1;
state.col = col - 1; state.col = col - 1;
if(0 === state.col) { if(0 === state.col) {
state.initialSgr = state.lastSgr; state.initialSgr = state.lastSgr;
} }
lastRow = Math.max(state.row, lastRow); lastRow = Math.max(state.row, lastRow);
}); });
parser.on('literal', literal => { parser.on('literal', literal => {
// //
// CR/LF are handled for 'position update'; we don't need the chars themselves // CR/LF are handled for 'position update'; we don't need the chars themselves
// //
literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, ''); literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, '');
for(let c of literal) { for(let c of literal) {
if(state.col < options.cols && ('auto' === options.rows || state.row < options.rows)) { if(state.col < options.cols && ('auto' === options.rows || state.row < options.rows)) {
ensureRow(state.row); ensureRow(state.row);
if(0 === state.col) { if(0 === state.col) {
canvas[state.row][state.col].initialSgr = state.initialSgr; canvas[state.row][state.col].initialSgr = state.initialSgr;
} }
canvas[state.row][state.col].char = c; canvas[state.row][state.col].char = c;
if(state.sgr) { if(state.sgr) {
canvas[state.row][state.col].sgr = _.clone(state.sgr); canvas[state.row][state.col].sgr = _.clone(state.sgr);
state.lastSgr = canvas[state.row][state.col].sgr; state.lastSgr = canvas[state.row][state.col].sgr;
state.sgr = null; state.sgr = null;
} }
} }
state.col += 1; state.col += 1;
} }
}); });
parser.on('sgr update', sgr => { parser.on('sgr update', sgr => {
ensureRow(state.row); ensureRow(state.row);
if(state.col < options.cols) { if(state.col < options.cols) {
canvas[state.row][state.col].sgr = _.clone(sgr); canvas[state.row][state.col].sgr = _.clone(sgr);
state.lastSgr = canvas[state.row][state.col].sgr; state.lastSgr = canvas[state.row][state.col].sgr;
} else { } else {
state.sgr = sgr; state.sgr = sgr;
} }
}); });
function getLastPopulatedColumn(row) { function getLastPopulatedColumn(row) {
let col = row.length; let col = row.length;
while(--col > 0) { while(--col > 0) {
if(row[col].char || row[col].sgr) { if(row[col].char || row[col].sgr) {
break; break;
} }
} }
return col; return col;
} }
parser.on('complete', () => { parser.on('complete', () => {
let output = ''; let output = '';
let line; let line;
let sgr; let sgr;
canvas.slice(0, lastRow + 1).forEach(row => { canvas.slice(0, lastRow + 1).forEach(row => {
const lastCol = getLastPopulatedColumn(row) + 1; const lastCol = getLastPopulatedColumn(row) + 1;
let i; let i;
line = options.indent ? line = options.indent ?
output.length > 0 ? ' '.repeat(options.indent) : '' : output.length > 0 ? ' '.repeat(options.indent) : '' :
''; '';
for(i = 0; i < lastCol; ++i) { for(i = 0; i < lastCol; ++i) {
const col = row[i]; const col = row[i];
sgr = !options.asciiMode && 0 === i ? sgr = !options.asciiMode && 0 === i ?
col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' : col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' :
''; '';
if(!options.asciiMode && col.sgr) { if(!options.asciiMode && col.sgr) {
sgr += ANSI.getSGRFromGraphicRendition(col.sgr); sgr += ANSI.getSGRFromGraphicRendition(col.sgr);
} }
line += `${sgr}${col.char || ' '}`; line += `${sgr}${col.char || ' '}`;
} }
output += line; output += line;
if(i < row.length) { if(i < row.length) {
output += `${options.asciiMode ? '' : ANSI.blackBG()}`; output += `${options.asciiMode ? '' : ANSI.blackBG()}`;
if(options.fillLines) { if(options.fillLines) {
output += `${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`; output += `${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`;
} }
} }
if(options.startCol + i < options.termWidth || options.forceLineTerm) { if(options.startCol + i < options.termWidth || options.forceLineTerm) {
output += '\r\n'; output += '\r\n';
} }
}); });
if(options.exportMode) { if(options.exportMode) {
// //
// If we're in export mode, we do some additional hackery: // If we're in export mode, we do some additional hackery:
// //
// * Hard wrap ALL lines at <= 79 *characters* (not visible columns) // * Hard wrap ALL lines at <= 79 *characters* (not visible columns)
// if a line must wrap early, we'll place a ESC[A ESC[<N>C where <N> // if a line must wrap early, we'll place a ESC[A ESC[<N>C where <N>
// represents chars to get back to the position we were previously at // represents chars to get back to the position we were previously at
// //
// * Replace contig spaces with ESC[<N>C as well to save... space. // * Replace contig spaces with ESC[<N>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 // :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 const MAX_CHARS = 79 - 8; // 79 max, - 8 for max ESC seq's we may prefix a line with
let exportOutput = ''; let exportOutput = '';
let m; let m;
let afterSeq; let afterSeq;
let wantMore; let wantMore;
let renderStart; let renderStart;
splitTextAtTerms(output).forEach(fullLine => { splitTextAtTerms(output).forEach(fullLine => {
renderStart = 0; renderStart = 0;
while(fullLine.length > 0) { while(fullLine.length > 0) {
let splitAt; let splitAt;
const ANSI_REGEXP = ANSI.getFullMatchRegExp(); const ANSI_REGEXP = ANSI.getFullMatchRegExp();
wantMore = true; wantMore = true;
while((m = ANSI_REGEXP.exec(fullLine))) { while((m = ANSI_REGEXP.exec(fullLine))) {
afterSeq = m.index + m[0].length; afterSeq = m.index + m[0].length;
if(afterSeq < MAX_CHARS) { if(afterSeq < MAX_CHARS) {
// after current seq // after current seq
splitAt = afterSeq; splitAt = afterSeq;
} else { } else {
if(m.index < MAX_CHARS) { if(m.index < MAX_CHARS) {
// before last found seq // before last found seq
splitAt = m.index; splitAt = m.index;
wantMore = false; // can't eat up any more wantMore = false; // can't eat up any more
} }
break; // seq's beyond this point are >= MAX_CHARS break; // seq's beyond this point are >= MAX_CHARS
} }
} }
if(splitAt) { if(splitAt) {
if(wantMore) { if(wantMore) {
splitAt = Math.min(fullLine.length, MAX_CHARS - 1); splitAt = Math.min(fullLine.length, MAX_CHARS - 1);
} }
} else { } else {
splitAt = Math.min(fullLine.length, MAX_CHARS - 1); splitAt = Math.min(fullLine.length, MAX_CHARS - 1);
} }
const part = fullLine.slice(0, splitAt); const part = fullLine.slice(0, splitAt);
fullLine = fullLine.slice(splitAt); fullLine = fullLine.slice(splitAt);
renderStart += renderStringLength(part); renderStart += renderStringLength(part);
exportOutput += `${part}\r\n`; exportOutput += `${part}\r\n`;
if(fullLine.length > 0) { // more to go for this line? if(fullLine.length > 0) { // more to go for this line?
exportOutput += `${ANSI.up()}${ANSI.right(renderStart)}`; exportOutput += `${ANSI.up()}${ANSI.right(renderStart)}`;
} else { } else {
exportOutput += ANSI.up(); exportOutput += ANSI.up();
} }
} }
}); });
return cb(null, exportOutput); return cb(null, exportOutput);
} }
return cb(null, output); return cb(null, output);
}); });
parser.parse(input); parser.parse(input);
}; };

View File

@ -2,497 +2,505 @@
'use strict'; 'use strict';
// //
// ANSI Terminal Support Resources // ANSI Terminal Support Resources
// //
// ANSI-BBS // ANSI-BBS
// * http://ansi-bbs.org/ // * http://ansi-bbs.org/
// //
// CTerm / SyncTERM // CTerm / SyncTERM
// * https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt // * https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt
// //
// BananaCom // BananaCom
// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt // * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt
// //
// ANSI.SYS // ANSI.SYS
// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt // * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt
// * http://academic.evergreen.edu/projects/biophysics/technotes/program/ansi_esc.htm // * http://academic.evergreen.edu/projects/biophysics/technotes/program/ansi_esc.htm
// //
// VTX // Modern Windows (Win10+)
// * https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt // * https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
// //
// General // VT100
// * http://en.wikipedia.org/wiki/ANSI_escape_code // * http://www.noah.org/python/pexpect/ANSI-X3.64.htm
// * http://www.inwap.com/pdp10/ansicode.txt
// //
// Other Implementations // VTX
// * https://github.com/chjj/term.js/blob/master/src/term.js // * 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 // 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. // 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 // This gives us NetRunner, SyncTERM, EtherTerm, most *nix terminals, compatibilitiy
// with legit oldschool DOS terminals, and so on. // with legit oldschool DOS terminals, and so on.
// //
// ENiGMA½ // ENiGMA½
const miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
// deps // deps
const assert = require('assert'); const assert = require('assert');
const _ = require('lodash'); const _ = require('lodash');
exports.getFullMatchRegExp = getFullMatchRegExp; exports.getFullMatchRegExp = getFullMatchRegExp;
exports.getFGColorValue = getFGColorValue; exports.getFGColorValue = getFGColorValue;
exports.getBGColorValue = getBGColorValue; exports.getBGColorValue = getBGColorValue;
exports.sgr = sgr; exports.sgr = sgr;
exports.getSGRFromGraphicRendition = getSGRFromGraphicRendition; exports.getSGRFromGraphicRendition = getSGRFromGraphicRendition;
exports.clearScreen = clearScreen; exports.clearScreen = clearScreen;
exports.resetScreen = resetScreen; exports.resetScreen = resetScreen;
exports.normal = normal; exports.normal = normal;
exports.goHome = goHome; exports.goHome = goHome;
exports.disableVT100LineWrapping = disableVT100LineWrapping; exports.disableVT100LineWrapping = disableVT100LineWrapping;
exports.setSyncTERMFont = setSyncTERMFont; exports.setSyncTERMFont = setSyncTERMFont;
exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias; exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias;
exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias; exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias;
exports.setCursorStyle = setCursorStyle; exports.setCursorStyle = setCursorStyle;
exports.setEmulatedBaudRate = setEmulatedBaudRate; exports.setEmulatedBaudRate = setEmulatedBaudRate;
exports.vtxHyperlink = vtxHyperlink; exports.vtxHyperlink = vtxHyperlink;
// //
// See also // See also
// https://github.com/TooTallNate/ansi.js/blob/master/lib/ansi.js // https://github.com/TooTallNate/ansi.js/blob/master/lib/ansi.js
const ESC_CSI = '\u001b['; const ESC_CSI = '\u001b[';
const CONTROL = { const CONTROL = {
up : 'A', up : 'A',
down : 'B', down : 'B',
forward : 'C', forward : 'C',
right : 'C', right : 'C',
back : 'D', back : 'D',
left : 'D', left : 'D',
nextLine : 'E', nextLine : 'E',
prevLine : 'F', prevLine : 'F',
horizAbsolute : 'G', horizAbsolute : 'G',
// //
// CSI [ p1 ] J // CSI [ p1 ] J
// Erase in Page / Erase Data // Erase in Page / Erase Data
// Defaults: p1 = 0 // Defaults: p1 = 0
// Erases from the current screen according to the value of p1 // Erases from the current screen according to the value of p1
// 0 - Erase from the current position to the end of the screen. // 0 - Erase from the current position to the end of the screen.
// 1 - Erase from the current position to the start 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 // 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 // the cursor to position 1/1 as a number of BBS programs assume
// this behaviour. // this behaviour.
// Erased characters are set to the current attribute. // Erased characters are set to the current attribute.
// //
// Support: // Support:
// * SyncTERM: Works as expected // * SyncTERM: Works as expected
// * NetRunner: Always clears a screen *height* (e.g. 25) regardless of p1 // * NetRunner: Always clears a screen *height* (e.g. 25) regardless of p1
// and screen remainder // and screen remainder
// //
eraseData : 'J', eraseData : 'J',
eraseLine : 'K', eraseLine : 'K',
insertLine : 'L', insertLine : 'L',
// //
// CSI [ p1 ] M // CSI [ p1 ] M
// Delete Line(s) / "ANSI" Music // Delete Line(s) / "ANSI" Music
// Defaults: p1 = 1 // Defaults: p1 = 1
// Deletes the current line and the p1 - 1 lines after it scrolling the // 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 // 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. // empty lines at the end of the screen with the current attribute.
// If "ANSI" Music is fully enabled (CSI = 2 M), performs "ANSI" music // If "ANSI" Music is fully enabled (CSI = 2 M), performs "ANSI" music
// instead. // instead.
// See "ANSI" MUSIC section for more details. // See "ANSI" MUSIC section for more details.
// //
// Support: // Support:
// * SyncTERM: Works as expected // * SyncTERM: Works as expected
// * NetRunner: // * NetRunner:
// //
// General Notes: // General Notes:
// See also notes in bansi.txt and cterm.txt about the various // See also notes in bansi.txt and cterm.txt about the various
// incompatibilities & oddities around this sequence. ANSI-BBS // incompatibilities & oddities around this sequence. ANSI-BBS
// states that it *should* work with any value of p1. // states that it *should* work with any value of p1.
// //
deleteLine : 'M', deleteLine : 'M',
ansiMusic : 'M', ansiMusic : 'M',
scrollUp : 'S', scrollUp : 'S',
scrollDown : 'T', scrollDown : 'T',
setScrollRegion : 'r', setScrollRegion : 'r',
savePos : 's', savePos : 's',
restorePos : 'u', restorePos : 'u',
queryPos : '6n', queryPos : '6n',
queryScreenSize : '255n', // See bansi.txt queryScreenSize : '255n', // See bansi.txt
goto : 'H', // row Pr, column Pc -- same as f goto : 'H', // row Pr, column Pc -- same as f
gotoAlt : 'f', // same as H gotoAlt : 'f', // same as H
blinkToBrightIntensity : '?33h', blinkToBrightIntensity : '?33h',
blinkNormal : '?33l', 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 hideCursor : '?25l', // Nonstandard - cterm.txt
showCursor : '?25h', // 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 // :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 // apparently some terms can report screen size and text area via 18t and 19t
}; };
// //
// Select Graphics Rendition // Select Graphics Rendition
// See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt // See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt
// //
const SGRValues = { const SGRValues = {
reset : 0, reset : 0,
bold : 1, bold : 1,
dim : 2, dim : 2,
blink : 5, blink : 5,
fastBlink : 6, fastBlink : 6,
negative : 7, negative : 7,
hidden : 8, hidden : 8,
normal : 22, // normal : 22, //
steady : 25, steady : 25,
positive : 27, positive : 27,
black : 30, black : 30,
red : 31, red : 31,
green : 32, green : 32,
yellow : 33, yellow : 33,
blue : 34, blue : 34,
magenta : 35, magenta : 35,
cyan : 36, cyan : 36,
white : 37, white : 37,
blackBG : 40, blackBG : 40,
redBG : 41, redBG : 41,
greenBG : 42, greenBG : 42,
yellowBG : 43, yellowBG : 43,
blueBG : 44, blueBG : 44,
magentaBG : 45, magentaBG : 45,
cyanBG : 46, cyanBG : 46,
whiteBG : 47, whiteBG : 47,
}; };
function getFullMatchRegExp(flags = 'g') { function getFullMatchRegExp(flags = 'g') {
// :TODO: expand this a bit - see strip-ansi/etc. // :TODO: expand this a bit - see strip-ansi/etc.
// :TODO: \u009b ? // :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 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) { function getFGColorValue(name) {
return SGRValues[name]; return SGRValues[name];
} }
function getBGColorValue(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 // See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt
// :TODO: document // :TODO: document
// :TODO: Create mappings for aliases... maybe make this a map to values instead // :TODO: Create mappings for aliases... maybe make this a map to values instead
// :TODO: Break this up in to two parts: // :TODO: Break this up in to two parts:
// 1) FONT_AND_CODE_PAGES (e.g. SyncTERM/cterm) // 1) FONT_AND_CODE_PAGES (e.g. SyncTERM/cterm)
// 2) SAUCE_FONT_MAP: Sauce name(s) -> items in FONT_AND_CODE_PAGES. // 2) SAUCE_FONT_MAP: Sauce name(s) -> items in FONT_AND_CODE_PAGES.
// ...we can then have getFontFromSAUCEName(sauceFontName) // ...we can then have getFontFromSAUCEName(sauceFontName)
// Also, create a SAUCE_ENCODING_MAP: SAUCE font name -> encodings // Also, create a SAUCE_ENCODING_MAP: SAUCE font name -> encodings
// //
// An array of CTerm/SyncTERM font/encoding values. Each entry's index // An array of CTerm/SyncTERM font/encoding values. Each entry's index
// corresponds to it's escape sequence value (e.g. cp437 = 0) // 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 = [ const SYNCTERM_FONT_AND_ENCODING_TABLE = [
'cp437', 'cp437',
'cp1251', 'cp1251',
'koi8_r', 'koi8_r',
'iso8859_2', 'iso8859_2',
'iso8859_4', 'iso8859_4',
'cp866', 'cp866',
'iso8859_9', 'iso8859_9',
'haik8', 'haik8',
'iso8859_8', 'iso8859_8',
'koi8_u', 'koi8_u',
'iso8859_15', 'iso8859_15',
'iso8859_4', 'iso8859_4',
'koi8_r_b', 'koi8_r_b',
'iso8859_4', 'iso8859_4',
'iso8859_5', 'iso8859_5',
'ARMSCII_8', 'ARMSCII_8',
'iso8859_15', 'iso8859_15',
'cp850', 'cp850',
'cp850', 'cp850',
'cp885', 'cp885',
'cp1251', 'cp1251',
'iso8859_7', 'iso8859_7',
'koi8-r_c', 'koi8-r_c',
'iso8859_4', 'iso8859_4',
'iso8859_1', 'iso8859_1',
'cp866', 'cp866',
'cp437', 'cp437',
'cp866', 'cp866',
'cp885', 'cp885',
'cp866_u', 'cp866_u',
'iso8859_1', 'iso8859_1',
'cp1131', 'cp1131',
'c64_upper', 'c64_upper',
'c64_lower', 'c64_lower',
'c128_upper', 'c128_upper',
'c128_lower', 'c128_lower',
'atari', 'atari',
'pot_noodle', 'pot_noodle',
'mo_soul', 'mo_soul',
'microknight_plus', 'microknight_plus',
'topaz_plus', 'topaz_plus',
'microknight', 'microknight',
'topaz', 'topaz',
]; ];
// //
// A map of various font name/aliases such as those used // A map of various font name/aliases such as those used
// in SAUCE records to SyncTERM/CTerm names // in SAUCE records to SyncTERM/CTerm names
// //
// This table contains lowercased entries with any spaces // This table contains lowercased entries with any spaces
// replaced with '_' for lookup purposes. // replaced with '_' for lookup purposes.
// //
const FONT_ALIAS_TO_SYNCTERM_MAP = { const FONT_ALIAS_TO_SYNCTERM_MAP = {
'cp437' : 'cp437', 'cp437' : 'cp437',
'ibm_vga' : 'cp437', 'ibm_vga' : 'cp437',
'ibmpc' : 'cp437', 'ibmpc' : 'cp437',
'ibm_pc' : 'cp437', 'ibm_pc' : 'cp437',
'pc' : 'cp437', 'pc' : 'cp437',
'cp437_art' : 'cp437', 'cp437_art' : 'cp437',
'ibmpcart' : 'cp437', 'ibmpcart' : 'cp437',
'ibmpc_art' : 'cp437', 'ibmpc_art' : 'cp437',
'ibm_pc_art' : 'cp437', 'ibm_pc_art' : 'cp437',
'msdos_art' : 'cp437', 'msdos_art' : 'cp437',
'msdosart' : 'cp437', 'msdosart' : 'cp437',
'pc_art' : 'cp437', 'pc_art' : 'cp437',
'pcart' : 'cp437', 'pcart' : 'cp437',
'ibm_vga50' : 'cp437', 'ibm_vga50' : 'cp437',
'ibm_vga25g' : 'cp437', 'ibm_vga25g' : 'cp437',
'ibm_ega' : 'cp437', 'ibm_ega' : 'cp437',
'ibm_ega43' : 'cp437', 'ibm_ega43' : 'cp437',
'topaz' : 'topaz', 'topaz' : 'topaz',
'amiga_topaz_1' : 'topaz', 'amiga_topaz_1' : 'topaz',
'amiga_topaz_1+' : 'topaz_plus', 'amiga_topaz_1+' : 'topaz_plus',
'topazplus' : 'topaz_plus', 'topazplus' : 'topaz_plus',
'topaz_plus' : 'topaz_plus', 'topaz_plus' : 'topaz_plus',
'amiga_topaz_2' : 'topaz', 'amiga_topaz_2' : 'topaz',
'amiga_topaz_2+' : 'topaz_plus', 'amiga_topaz_2+' : 'topaz_plus',
'topaz2plus' : 'topaz_plus', 'topaz2plus' : 'topaz_plus',
'pot_noodle' : 'pot_noodle', 'pot_noodle' : 'pot_noodle',
'p0tnoodle' : 'pot_noodle', 'p0tnoodle' : 'pot_noodle',
'amiga_p0t-noodle' : 'pot_noodle', 'amiga_p0t-noodle' : 'pot_noodle',
'mo_soul' : 'mo_soul', 'mo_soul' : 'mo_soul',
'mosoul' : 'mo_soul', 'mosoul' : 'mo_soul',
'mO\'sOul' : 'mo_soul', 'mO\'sOul' : 'mo_soul',
'amiga_microknight' : 'microknight', 'amiga_microknight' : 'microknight',
'amiga_microknight+' : 'microknight_plus', 'amiga_microknight+' : 'microknight_plus',
'atari' : 'atari', 'atari' : 'atari',
'atarist' : 'atari', 'atarist' : 'atari',
}; };
function setSyncTERMFont(name, fontPage) { 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); const p2 = SYNCTERM_FONT_AND_ENCODING_TABLE.indexOf(name);
if(p2 > -1) { if(p2 > -1) {
return `${ESC_CSI}${p1};${p2} D`; return `${ESC_CSI}${p1};${p2} D`;
} }
return ''; return '';
} }
function getSyncTERMFontFromAlias(alias) { 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) { function setSyncTermFontWithAlias(nameOrAlias) {
nameOrAlias = getSyncTERMFontFromAlias(nameOrAlias) || nameOrAlias; nameOrAlias = getSyncTERMFontFromAlias(nameOrAlias) || nameOrAlias;
return setSyncTERMFont(nameOrAlias); return setSyncTERMFont(nameOrAlias);
} }
const DEC_CURSOR_STYLE = { const DEC_CURSOR_STYLE = {
'blinking block' : 0, 'blinking block' : 0,
'default' : 1, 'default' : 1,
'steady block' : 2, 'steady block' : 2,
'blinking underline' : 3, 'blinking underline' : 3,
'steady underline' : 4, 'steady underline' : 4,
'blinking bar' : 5, 'blinking bar' : 5,
'steady bar' : 6, 'steady bar' : 6,
}; };
function setCursorStyle(cursorStyle) { function setCursorStyle(cursorStyle) {
const ps = DEC_CURSOR_STYLE[cursorStyle]; const ps = DEC_CURSOR_STYLE[cursorStyle];
if(ps) { if(ps) {
return `${ESC_CSI}${ps} q`; return `${ESC_CSI}${ps} q`;
} }
return ''; return '';
} }
// Create methods such as up(), nextLine(),... // Create methods such as up(), nextLine(),...
Object.keys(CONTROL).forEach(function onControlName(name) { Object.keys(CONTROL).forEach(function onControlName(name) {
const code = CONTROL[name]; const code = CONTROL[name];
exports[name] = function() { exports[name] = function() {
let c = code; let c = code;
if(arguments.length > 0) { if(arguments.length > 0) {
// arguments are array like -- we want an array // arguments are array like -- we want an array
c = Array.prototype.slice.call(arguments).map(Math.round).join(';') + code; c = Array.prototype.slice.call(arguments).map(Math.round).join(';') + code;
} }
return `${ESC_CSI}${c}`; 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 => { Object.keys(SGRValues).forEach( name => {
const code = SGRValues[name]; const code = SGRValues[name];
exports[name] = function() { exports[name] = function() {
return `${ESC_CSI}${code}m`; return `${ESC_CSI}${code}m`;
}; };
}); });
function sgr() { function sgr() {
// //
// - Allow an single array or variable number of arguments // - Allow an single array or variable number of arguments
// - Each element can be either a integer or string found in SGRValues // - Each element can be either a integer or string found in SGRValues
// which in turn maps to a integer // which in turn maps to a integer
// //
if(arguments.length <= 0) { if(arguments.length <= 0) {
return ''; return '';
} }
let result = []; let result = [];
const args = Array.isArray(arguments[0]) ? arguments[0] : arguments; const args = Array.isArray(arguments[0]) ? arguments[0] : arguments;
for(let i = 0; i < args.length; ++i) { for(let i = 0; i < args.length; ++i) {
const arg = args[i]; const arg = args[i];
if(_.isString(arg) && arg in SGRValues) { if(_.isString(arg) && arg in SGRValues) {
result.push(SGRValues[arg]); result.push(SGRValues[arg]);
} else if(_.isNumber(arg)) { } else if(_.isNumber(arg)) {
result.push(arg); result.push(arg);
} }
} }
return `${ESC_CSI}${result.join(';')}m`; return `${ESC_CSI}${result.join(';')}m`;
} }
// //
// Converts a Graphic Rendition object used elsewhere // Converts a Graphic Rendition object used elsewhere
// to a ANSI SGR sequence. // to a ANSI SGR sequence.
// //
function getSGRFromGraphicRendition(graphicRendition, initialReset) { function getSGRFromGraphicRendition(graphicRendition, initialReset) {
let sgrSeq = []; let sgrSeq = [];
let styleCount = 0; let styleCount = 0;
[ 'intensity', 'underline', 'blink', 'negative', 'invisible' ].forEach( s => { [ 'intensity', 'underline', 'blink', 'negative', 'invisible' ].forEach( s => {
if(graphicRendition[s]) { if(graphicRendition[s]) {
sgrSeq.push(graphicRendition[s]); sgrSeq.push(graphicRendition[s]);
++styleCount; ++styleCount;
} }
}); });
if(graphicRendition.fg) { if(graphicRendition.fg) {
sgrSeq.push(graphicRendition.fg); sgrSeq.push(graphicRendition.fg);
} }
if(graphicRendition.bg) { if(graphicRendition.bg) {
sgrSeq.push(graphicRendition.bg); sgrSeq.push(graphicRendition.bg);
} }
if(0 === styleCount || initialReset) { if(0 === styleCount || initialReset) {
sgrSeq.unshift(0); sgrSeq.unshift(0);
} }
return sgr(sgrSeq); return sgr(sgrSeq);
} }
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// Shortcuts for common functions // Shortcuts for common functions
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
function clearScreen() { function clearScreen() {
return exports.eraseData(2); return exports.eraseData(2);
} }
function resetScreen() { function resetScreen() {
return `${exports.reset()}${exports.eraseData(2)}${exports.goHome()}`; return `${exports.reset()}${exports.eraseData(2)}${exports.goHome()}`;
} }
function normal() { function normal() {
return sgr( [ 'normal', 'reset' ] ); return sgr( [ 'normal', 'reset' ] );
} }
function goHome() { 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: // See:
// http://stjarnhimlen.se/snippets/vt100.txt // http://stjarnhimlen.se/snippets/vt100.txt
// https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt // https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt
// //
// WARNING: // WARNING:
// * Not honored by all clients // * Not honored by all clients
// * If it is honored, ANSI's that rely on this (e.g. do not have \r\n endings // * 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! // and use term width -- generally 80 columns -- will display garbled!
// //
function disableVT100LineWrapping() { function disableVT100LineWrapping() {
return `${ESC_CSI}?7l`; return `${ESC_CSI}?7l`;
} }
function setEmulatedBaudRate(rate) { function setEmulatedBaudRate(rate) {
const speed = { const speed = {
unlimited : 0, unlimited : 0,
off : 0, off : 0,
0 : 0, 0 : 0,
300 : 1, 300 : 1,
600 : 2, 600 : 2,
1200 : 3, 1200 : 3,
2400 : 4, 2400 : 4,
4800 : 5, 4800 : 5,
9600 : 6, 9600 : 6,
19200 : 7, 19200 : 7,
38400 : 8, 38400 : 8,
57600 : 9, 57600 : 9,
76800 : 10, 76800 : 10,
115200 : 11, 115200 : 11,
}[rate] || 0; }[rate] || 0;
return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed); return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed);
} }
function vtxHyperlink(client, url, len) { function vtxHyperlink(client, url, len) {
if(!client.terminalSupports('vtx_hyperlink')) { if(!client.terminalSupports('vtx_hyperlink')) {
return ''; return '';
} }
len = len || url.length; len = len || url.length;
url = url.split('').map(c => c.charCodeAt(0)).join(';'); url = url.split('').map(c => c.charCodeAt(0)).join(';');
return `${ESC_CSI}1;${len};1;1;${url}\\`; return `${ESC_CSI}1;${len};1;1;${url}\\`;
} }

135
core/archaicnet.js Normal file
View File

@ -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 - [<bbsTag>]<userName> 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();
}
}
);
}
};

View File

@ -1,288 +1,348 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const resolveMimeType = require('./mime_util.js').resolveMimeType; const resolveMimeType = require('./mime_util.js').resolveMimeType;
const Events = require('./events.js');
// base/modules // base/modules
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const _ = require('lodash'); const _ = require('lodash');
const pty = require('ptyw.js'); const pty = require('node-pty');
const paths = require('path');
let archiveUtil; let archiveUtil;
class Archiver { class Archiver {
constructor(config) { constructor(config) {
this.compress = config.compress; this.compress = config.compress;
this.decompress = config.decompress; this.decompress = config.decompress;
this.list = config.list; this.list = config.list;
this.extract = config.extract; this.extract = config.extract;
} }
ok() { ok() {
return this.canCompress() && this.canDecompress(); return this.canCompress() && this.canDecompress();
} }
can(what) { can(what) {
if(!_.has(this, [ what, 'cmd' ]) || !_.has(this, [ what, 'args' ])) { if(!_.has(this, [ what, 'cmd' ]) || !_.has(this, [ what, 'args' ])) {
return false; 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'); } canCompress() { return this.can('compress'); }
canDecompress() { return this.can('decompress'); } canDecompress() { return this.can('decompress'); }
canList() { return this.can('list'); } // :TODO: validate entryMatch canList() { return this.can('list'); } // :TODO: validate entryMatch
canExtract() { return this.can('extract'); } canExtract() { return this.can('extract'); }
} }
module.exports = class ArchiveUtil { module.exports = class ArchiveUtil {
constructor() { constructor() {
this.archivers = {}; this.archivers = {};
this.longestSignature = 0; this.longestSignature = 0;
} }
// singleton access // singleton access
static getInstance() { static getInstance(noWatch = false) {
if(!archiveUtil) { if(!archiveUtil) {
archiveUtil = new ArchiveUtil(); archiveUtil = new ArchiveUtil();
archiveUtil.init(); archiveUtil.init(noWatch);
} }
return archiveUtil; return archiveUtil;
} }
init() { init(noWatch = false) {
// this.reloadConfig();
// Load configuration if(!noWatch) {
// Events.on(Events.getSystemEvents().ConfigChanged, () => {
if(_.has(Config, 'archives.archivers')) { this.reloadConfig();
Object.keys(Config.archives.archivers).forEach(archKey => { });
}
}
const archConfig = Config.archives.archivers[archKey]; reloadConfig() {
const archiver = new Archiver(archConfig); const config = Config();
if(_.has(config, 'archives.archivers')) {
Object.keys(config.archives.archivers).forEach(archKey => {
if(!archiver.ok()) { const archConfig = config.archives.archivers[archKey];
// :TODO: Log warning - bad archiver/config const archiver = new Archiver(archConfig);
}
this.archivers[archKey] = archiver; if(!archiver.ok()) {
}); // :TODO: Log warning - bad archiver/config
} }
if(_.isObject(Config.fileTypes)) { this.archivers[archKey] = archiver;
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;
// :TODO: this is broken: sig is NOT this long, it's sig.length long; offset needs to allow for -negative values as well if(_.isObject(config.fileTypes)) {
const sigLen =fileType.offset + fileType.sig.length; const updateSig = (ft) => {
if(sigLen > this.longestSignature) { ft.sig = Buffer.from(ft.sig, 'hex');
this.longestSignature = sigLen; ft.offset = ft.offset || 0;
}
}
});
}
}
getArchiver(mimeTypeOrExtension) { // :TODO: this is broken: sig is NOT this long, it's sig.length long; offset needs to allow for -negative values as well
mimeTypeOrExtension = resolveMimeType(mimeTypeOrExtension); const sigLen = ft.offset + ft.sig.length;
if(sigLen > this.longestSignature) {
this.longestSignature = sigLen;
}
};
if(!mimeTypeOrExtension) { // lookup returns false on failure Object.keys(config.fileTypes).forEach(mimeType => {
return; const fileType = config.fileTypes[mimeType];
} if(Array.isArray(fileType)) {
fileType.forEach(ft => {
if(ft.sig) {
updateSig(ft);
}
});
} else if(fileType.sig) {
updateSig(fileType);
}
});
}
}
const archiveHandler = _.get( Config, [ 'fileTypes', mimeTypeOrExtension, 'archiveHandler'] ); getArchiver(mimeTypeOrExtension, justExtention) {
if(archiveHandler) { const mimeType = resolveMimeType(mimeTypeOrExtension);
return _.get( Config, [ 'archives', 'archivers', archiveHandler ] );
}
}
haveArchiver(archType) { if(!mimeType) { // lookup returns false on failure
return this.getArchiver(archType) ? true : false; return;
} }
detectTypeWithBuf(buf, cb) { const config = Config();
// :TODO: implement me! let fileType = _.get(config, [ 'fileTypes', mimeType ] );
}
detectType(path, cb) { if(Array.isArray(fileType)) {
fs.open(path, 'r', (err, fd) => { if(!justExtention) {
if(err) { // need extention for lookup; ambiguous as-is :(
return cb(err); return;
} }
// further refine by extention
fileType = fileType.find(ft => justExtention === ft.ext);
}
const buf = new Buffer(this.longestSignature); if(!_.isObject(fileType)) {
fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => { return;
if(err) { }
return cb(err);
}
const archFormat = _.findKey(Config.fileTypes, fileTypeInfo => { if(fileType.archiveHandler) {
if(!fileTypeInfo.sig) { return _.get( config, [ 'archives', 'archivers', fileType.archiveHandler ] );
return false; }
} }
const lenNeeded = fileTypeInfo.offset + fileTypeInfo.sig.length; haveArchiver(archType) {
return this.getArchiver(archType) ? true : false;
}
if(bytesRead < lenNeeded) { // :TODO: implement me:
return false; /*
} detectTypeWithBuf(buf, cb) {
}
*/
const comp = buf.slice(fileTypeInfo.offset, fileTypeInfo.offset + fileTypeInfo.sig.length); detectType(path, cb) {
return (fileTypeInfo.sig.equals(comp)); const closeFile = (fd) => {
}); fs.close(fd, () => { /* sadface */ });
};
return cb(archFormat ? null : Errors.General('Unknown type'), archFormat); fs.open(path, 'r', (err, fd) => {
}); if(err) {
}); return cb(err);
} }
spawnHandler(proc, action, cb) { const buf = Buffer.alloc(this.longestSignature);
// pty.js doesn't currently give us a error when things fail, fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => {
// so we have this horrible, horrible hack: if(err) {
let err; closeFile(fd);
proc.once('data', d => { return cb(err);
if(_.isString(d) && d.startsWith('execvp(3) failed.')) { }
err = Errors.ExternalProcess(`${action} failed: ${d.trim()}`);
}
});
proc.once('exit', exitCode => { const archFormat = _.findKey(Config().fileTypes, fileTypeInfo => {
return cb(exitCode ? Errors.ExternalProcess(`${action} failed with exit code: ${exitCode}`) : err); const fileTypeInfos = Array.isArray(fileTypeInfo) ? fileTypeInfo : [ fileTypeInfo ];
}); return fileTypeInfos.find(fti => {
} if(!fti.sig || !fti.archiveHandler) {
return false;
}
compressTo(archType, archivePath, files, cb) { const lenNeeded = fti.offset + fti.sig.length;
const archiver = this.getArchiver(archType);
if(!archiver) { if(bytesRead < lenNeeded) {
return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); return false;
} }
const fmtObj = { const comp = buf.slice(fti.offset, fti.offset + fti.sig.length);
archivePath : archivePath, return (fti.sig.equals(comp));
fileList : files.join(' '), // :TODO: probably need same hack as extractTo here! });
}; });
const args = archiver.compress.args.map( arg => stringFormat(arg, fmtObj) ); closeFile(fd);
return cb(archFormat ? null : Errors.General('Unknown type'), archFormat);
});
});
}
let proc; spawnHandler(proc, action, cb) {
try { // pty.js doesn't currently give us a error when things fail,
proc = pty.spawn(archiver.compress.cmd, args, this.getPtyOpts()); // so we have this horrible, horrible hack:
} catch(e) { let err;
return cb(e); proc.once('data', d => {
} if(_.isString(d) && d.startsWith('execvp(3) failed.')) {
err = Errors.ExternalProcess(`${action} failed: ${d.trim()}`);
}
});
return this.spawnHandler(proc, 'Compression', cb); proc.once('exit', exitCode => {
} return cb(exitCode ? Errors.ExternalProcess(`${action} failed with exit code: ${exitCode}`) : err);
});
}
extractTo(archivePath, extractPath, archType, fileList, cb) { compressTo(archType, archivePath, files, cb) {
let haveFileList; const archiver = this.getArchiver(archType, paths.extname(archivePath));
if(!cb && _.isFunction(fileList)) { if(!archiver) {
cb = fileList; return cb(Errors.Invalid(`Unknown archive type: ${archType}`));
fileList = []; }
haveFileList = false;
} else {
haveFileList = true;
}
const archiver = this.getArchiver(archType); const fmtObj = {
archivePath : archivePath,
fileList : files.join(' '), // :TODO: probably need same hack as extractTo here!
};
if(!archiver) { const args = archiver.compress.args.map( arg => stringFormat(arg, fmtObj) );
return cb(Errors.Invalid(`Unknown archive type: ${archType}`));
}
const fmtObj = { let proc;
archivePath : archivePath, try {
extractPath : extractPath, 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}`)
);
}
const action = haveFileList ? 'extract' : 'decompress'; return this.spawnHandler(proc, 'Compression', cb);
}
// we need to treat {fileList} special in that it should be broken up to 0:n args extractTo(archivePath, extractPath, archType, fileList, cb) {
const args = archiver[action].args.map( arg => { let haveFileList;
return '{fileList}' === arg ? arg : stringFormat(arg, fmtObj);
});
const fileListPos = args.indexOf('{fileList}'); if(!cb && _.isFunction(fileList)) {
if(fileListPos > -1) { cb = fileList;
// replace {fileList} with 0:n sep file list arguments fileList = [];
args.splice.apply(args, [fileListPos, 1].concat(fileList)); haveFileList = false;
} } else {
haveFileList = true;
}
let proc; const archiver = this.getArchiver(archType, paths.extname(archivePath));
try {
proc = pty.spawn(archiver[action].cmd, args, this.getPtyOpts());
} catch(e) {
return cb(e);
}
return this.spawnHandler(proc, (haveFileList ? 'Extraction' : 'Decompression'), cb); if(!archiver) {
} return cb(Errors.Invalid(`Unknown archive type: ${archType}`));
}
listEntries(archivePath, archType, cb) { const fmtObj = {
const archiver = this.getArchiver(archType); archivePath : archivePath,
extractPath : extractPath,
};
if(!archiver) { let action = haveFileList ? 'extract' : 'decompress';
return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); if('extract' === action && !_.isObject(archiver[action])) {
} // we're forced to do a full decompress
action = 'decompress';
haveFileList = false;
}
const fmtObj = { // we need to treat {fileList} special in that it should be broken up to 0:n args
archivePath : archivePath, const args = archiver[action].args.map( arg => {
}; return '{fileList}' === arg ? arg : stringFormat(arg, fmtObj);
});
const args = archiver.list.args.map( 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; let proc;
try { try {
proc = pty.spawn(archiver.list.cmd, args, this.getPtyOpts()); proc = pty.spawn(archiver[action].cmd, args, this.getPtyOpts(extractPath));
} catch(e) { } catch(e) {
return cb(e); return cb(Errors.ExternalProcess(
} `Error spawning archiver process "${archiver[action].cmd}" with args "${args.join(' ')}": ${e.message}`)
);
}
let output = ''; return this.spawnHandler(proc, (haveFileList ? 'Extraction' : 'Decompression'), cb);
proc.on('data', data => { }
// :TODO: hack for: execvp(3) failed.: No such file or directory
output += data; listEntries(archivePath, archType, cb) {
}); const archiver = this.getArchiver(archType, paths.extname(archivePath));
proc.once('exit', exitCode => { if(!archiver) {
if(exitCode) { return cb(Errors.Invalid(`Unknown archive type: ${archType}`));
return cb(Errors.ExternalProcess(`List failed with exit code: ${exitCode}`)); }
}
const entryGroupOrder = archiver.list.entryGroupOrder || { byteSize : 1, fileName : 2 }; const fmtObj = {
archivePath : archivePath,
};
const entries = []; const args = archiver.list.args.map( arg => stringFormat(arg, fmtObj) );
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); 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}`)
);
}
getPtyOpts() { let output = '';
return { proc.on('data', data => {
// :TODO: cwd // :TODO: hack for: execvp(3) failed.: No such file or directory
name : 'enigma-archiver',
cols : 80, output += data;
rows : 24, });
env : process.env,
}; 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;
}
}; };

View File

@ -1,390 +1,391 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const aep = require('./ansi_escape_parser.js'); const aep = require('./ansi_escape_parser.js');
const sauce = require('./sauce.js'); const sauce = require('./sauce.js');
const { Errors } = require('./enig_error.js');
// deps // deps
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const paths = require('path'); const paths = require('path');
const assert = require('assert'); const assert = require('assert');
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
const _ = require('lodash'); const _ = require('lodash');
const xxhash = require('xxhash'); const xxhash = require('xxhash');
exports.getArt = getArt; exports.getArt = getArt;
exports.getArtFromPath = getArtFromPath; exports.getArtFromPath = getArtFromPath;
exports.display = display; exports.display = display;
exports.defaultEncodingFromExtension = defaultEncodingFromExtension; exports.defaultEncodingFromExtension = defaultEncodingFromExtension;
// :TODO: Return MCI code information // :TODO: Return MCI code information
// :TODO: process SAUCE comments // :TODO: process SAUCE comments
// :TODO: return font + font mapped information from SAUCE // :TODO: return font + font mapped information from SAUCE
const SUPPORTED_ART_TYPES = { const SUPPORTED_ART_TYPES = {
// :TODO: the defualt encoding are really useless if they are all the same ... // :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 // perhaps .ansamiga and .ascamiga could be supported as well as overrides via conf
'.ans' : { name : 'ANSI', defaultEncoding : 'cp437', eof : 0x1a }, '.ans' : { name : 'ANSI', defaultEncoding : 'cp437', eof : 0x1a },
'.asc' : { name : 'ASCII', defaultEncoding : 'cp437', eof : 0x1a }, '.asc' : { name : 'ASCII', defaultEncoding : 'cp437', eof : 0x1a },
'.pcb' : { name : 'PCBoard', defaultEncoding : 'cp437', eof : 0x1a }, '.pcb' : { name : 'PCBoard', defaultEncoding : 'cp437', eof : 0x1a },
'.bbs' : { name : 'Wildcat', defaultEncoding : 'cp437', eof : 0x1a }, '.bbs' : { name : 'Wildcat', defaultEncoding : 'cp437', eof : 0x1a },
'.amiga' : { name : 'Amiga', defaultEncoding : 'amiga', eof : 0x1a }, '.amiga' : { name : 'Amiga', defaultEncoding : 'amiga', eof : 0x1a },
'.txt' : { name : 'Amiga Text', defaultEncoding : 'cp437', eof : 0x1a }, '.txt' : { name : 'Amiga Text', defaultEncoding : 'cp437', eof : 0x1a },
// :TODO: extentions for wwiv, renegade, celerity, syncronet, ... // :TODO: extentions for wwiv, renegade, celerity, syncronet, ...
// :TODO: extension for atari // :TODO: extension for atari
// :TODO: extension for topaz ansi/ascii. // :TODO: extension for topaz ansi/ascii.
}; };
function getFontNameFromSAUCE(sauce) { function getFontNameFromSAUCE(sauce) {
if(sauce.Character) { if(sauce.Character) {
return sauce.Character.fontName; return sauce.Character.fontName;
} }
} }
function sliceAtEOF(data, eofMarker) { function sliceAtEOF(data, eofMarker) {
let eof = data.length; let eof = data.length;
const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE) const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE)
for(let i = eof - 1; i > stopPos; i--) { for(let i = eof - 1; i > stopPos; i--) {
if(eofMarker === data[i]) { if(eofMarker === data[i]) {
eof = i; eof = i;
break; break;
} }
} }
return data.slice(0, eof); return data.slice(0, eof);
} }
function getArtFromPath(path, options, cb) { function getArtFromPath(path, options, cb) {
fs.readFile(path, (err, data) => { fs.readFile(path, (err, data) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
// //
// Convert from encodedAs -> j // Convert from encodedAs -> j
// //
const ext = paths.extname(path).toLowerCase(); const ext = paths.extname(path).toLowerCase();
const encoding = options.encodedAs || defaultEncodingFromExtension(ext); 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() { function sliceOfData() {
if(options.fullFile === true) { if(options.fullFile === true) {
return iconv.decode(data, encoding); return iconv.decode(data, encoding);
} else { } else {
const eofMarker = defaultEofFromExtension(ext); const eofMarker = defaultEofFromExtension(ext);
return iconv.decode(eofMarker ? sliceAtEOF(data, eofMarker) : data, encoding); return iconv.decode(eofMarker ? sliceAtEOF(data, eofMarker) : data, encoding);
} }
} }
function getResult(sauce) { function getResult(sauce) {
const result = { const result = {
data : sliceOfData(), data : sliceOfData(),
fromPath : path, fromPath : path,
}; };
if(sauce) { if(sauce) {
result.sauce = sauce; result.sauce = sauce;
} }
return result; return result;
} }
if(options.readSauce === true) { if(options.readSauce === true) {
sauce.readSAUCE(data, (err, sauce) => { sauce.readSAUCE(data, (err, sauce) => {
if(err) { if(err) {
return cb(null, getResult()); return cb(null, getResult());
} }
// //
// If a encoding was not provided & we have a mapping from // If a encoding was not provided & we have a mapping from
// the information provided by SAUCE, use that. // the information provided by SAUCE, use that.
// //
if(!options.encodedAs) { if(!options.encodedAs) {
/* /*
if(sauce.Character && sauce.Character.fontName) { if(sauce.Character && sauce.Character.fontName) {
var enc = SAUCE_FONT_TO_ENCODING_HINT[sauce.Character.fontName]; var enc = SAUCE_FONT_TO_ENCODING_HINT[sauce.Character.fontName];
if(enc) { if(enc) {
encoding = enc; encoding = enc;
} }
} }
*/ */
} }
return cb(null, getResult(sauce)); return cb(null, getResult(sauce));
}); });
} else { } else {
return cb(null, getResult()); return cb(null, getResult());
} }
}); });
} }
function getArt(name, options, cb) { 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.basePath = miscUtil.valueWithDefault(options.basePath, Config().paths.art);
options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true); 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) { if('' !== ext) {
options.types = [ ext.toLowerCase() ]; options.types = [ ext.toLowerCase() ];
} else { } else {
if(_.isUndefined(options.types)) { if(_.isUndefined(options.types)) {
options.types = Object.keys(SUPPORTED_ART_TYPES); options.types = Object.keys(SUPPORTED_ART_TYPES);
} else if(_.isString(options.types)) { } else if(_.isString(options.types)) {
options.types = [ options.types.toLowerCase() ]; options.types = [ options.types.toLowerCase() ];
} }
} }
// If an extension is provided, just read the file now // If an extension is provided, just read the file now
if('' !== ext) { if('' !== ext) {
const directPath = paths.join(options.basePath, name); const directPath = paths.join(options.basePath, name);
return getArtFromPath(directPath, options, cb); return getArtFromPath(directPath, options, cb);
} }
fs.readdir(options.basePath, (err, files) => { fs.readdir(options.basePath, (err, files) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
const filtered = files.filter( file => { const filtered = files.filter( file => {
// //
// Ignore anything not allowed in |options.types| // Ignore anything not allowed in |options.types|
// //
const fext = paths.extname(file); const fext = paths.extname(file);
if(!options.types.includes(fext.toLowerCase())) { if(!options.types.includes(fext.toLowerCase())) {
return false; return false;
} }
const bn = paths.basename(file, fext).toLowerCase(); const bn = paths.basename(file, fext).toLowerCase();
if(options.random) { if(options.random) {
const suppliedBn = paths.basename(name, fext).toLowerCase(); const suppliedBn = paths.basename(name, fext).toLowerCase();
// //
// Random selection enabled. We'll allow for // Random selection enabled. We'll allow for
// basename1.ext, basename2.ext, ... // basename1.ext, basename2.ext, ...
// //
if(!bn.startsWith(suppliedBn)) { if(!bn.startsWith(suppliedBn)) {
return false; return false;
} }
const num = bn.substr(suppliedBn.length); const num = bn.substr(suppliedBn.length);
if(num.length > 0) { if(num.length > 0) {
if(isNaN(parseInt(num, 10))) { if(isNaN(parseInt(num, 10))) {
return false; return false;
} }
} }
} else { } else {
// //
// We've already validated the extension (above). Must be an exact // We've already validated the extension (above). Must be an exact
// match to basename here // match to basename here
// //
if(bn != paths.basename(name, fext).toLowerCase()) { if(bn != paths.basename(name, fext).toLowerCase()) {
return false; return false;
} }
} }
return true; return true;
}); });
if(filtered.length > 0) { if(filtered.length > 0) {
// //
// We should now have: // We should now have:
// - Exactly (1) item in |filtered| if non-random // - Exactly (1) item in |filtered| if non-random
// - 1:n items in |filtered| to choose from if random // - 1:n items in |filtered| to choose from if random
// //
let readPath; let readPath;
if(options.random) { if(options.random) {
readPath = paths.join(options.basePath, filtered[Math.floor(Math.random() * filtered.length)]); readPath = paths.join(options.basePath, filtered[Math.floor(Math.random() * filtered.length)]);
} else { } else {
assert(1 === filtered.length); assert(1 === filtered.length);
readPath = paths.join(options.basePath, filtered[0]); readPath = paths.join(options.basePath, filtered[0]);
} }
return getArtFromPath(readPath, options, cb); return getArtFromPath(readPath, options, cb);
} }
return cb(new Error(`No matching art for supplied criteria: ${name}`)); return cb(Errors.DoesNotExist(`No matching art for supplied criteria: ${name}`));
}); });
} }
function defaultEncodingFromExtension(ext) { function defaultEncodingFromExtension(ext) {
const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()];
return artType ? artType.defaultEncoding : 'utf8'; return artType ? artType.defaultEncoding : 'utf8';
} }
function defaultEofFromExtension(ext) { function defaultEofFromExtension(ext) {
const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()]; const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()];
if(artType) { if(artType) {
return artType.eof; return artType.eof;
} }
} }
// :TODO: Implement the following // :TODO: Implement the following
// * Pause (disabled | termHeight | keyPress ) // * Pause (disabled | termHeight | keyPress )
// * Cancel (disabled | <keys> ) // * Cancel (disabled | <keys> )
// * Resume from pause -> continous (disabled | <keys>) // * Resume from pause -> continous (disabled | <keys>)
function display(client, art, options, cb) { function display(client, art, options, cb) {
if(_.isFunction(options) && !cb) { if(_.isFunction(options) && !cb) {
cb = options; cb = options;
options = {}; options = {};
} }
if(!art || !art.length) { if(!art || !art.length) {
return cb(new Error('Empty art')); return cb(Errors.Invalid('No art supplied!'));
} }
options.mciReplaceChar = options.mciReplaceChar || ' '; options.mciReplaceChar = options.mciReplaceChar || ' ';
options.disableMciCache = options.disableMciCache || false; options.disableMciCache = options.disableMciCache || false;
// :TODO: this is going to be broken into two approaches controlled via options: // :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. // 1) Standard - use internal tracking of locations for MCI -- no CPR's/etc.
// 2) CPR driven // 2) CPR driven
if(!_.isBoolean(options.iceColors)) { if(!_.isBoolean(options.iceColors)) {
// try to detect from SAUCE // try to detect from SAUCE
if(_.has(options, 'sauce.ansiFlags') && (options.sauce.ansiFlags & (1 << 0))) { if(_.has(options, 'sauce.ansiFlags') && (options.sauce.ansiFlags & (1 << 0))) {
options.iceColors = true; options.iceColors = true;
} }
} }
const ansiParser = new aep.ANSIEscapeParser({ const ansiParser = new aep.ANSIEscapeParser({
mciReplaceChar : options.mciReplaceChar, mciReplaceChar : options.mciReplaceChar,
termHeight : client.term.termHeight, termHeight : client.term.termHeight,
termWidth : client.term.termWidth, termWidth : client.term.termWidth,
trailingLF : options.trailingLF, trailingLF : options.trailingLF,
}); });
let parseComplete = false; let parseComplete = false;
let cprListener; let cprListener;
let mciMap; let mciMap;
const mciCprQueue = []; const mciCprQueue = [];
let artHash; let artHash;
let mciMapFromCache; let mciMapFromCache;
function completed() { function completed() {
if(cprListener) { if(cprListener) {
client.removeListener('cursor position report', cprListener); client.removeListener('cursor position report', cprListener);
} }
if(!options.disableMciCache && !mciMapFromCache) { if(!options.disableMciCache && !mciMapFromCache) {
// cache our MCI findings... // cache our MCI findings...
client.mciCache[artHash] = mciMap; client.mciCache[artHash] = mciMap;
client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Added MCI map to cache'); client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Added MCI map to cache');
} }
ansiParser.removeAllListeners(); // :TODO: Necessary??? ansiParser.removeAllListeners(); // :TODO: Necessary???
const extraInfo = { const extraInfo = {
height : ansiParser.row - 1, height : ansiParser.row - 1,
}; };
return cb(null, mciMap, extraInfo); return cb(null, mciMap, extraInfo);
} }
if(!options.disableMciCache) { if(!options.disableMciCache) {
artHash = xxhash.hash(new Buffer(art), 0xCAFEBABE); artHash = xxhash.hash(Buffer.from(art), 0xCAFEBABE);
// see if we have a mciMap cached for this art // see if we have a mciMap cached for this art
if(client.mciCache) { if(client.mciCache) {
mciMap = client.mciCache[artHash]; mciMap = client.mciCache[artHash];
} }
} }
if(mciMap) { if(mciMap) {
mciMapFromCache = true; mciMapFromCache = true;
client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Loaded MCI map from cache'); client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Loaded MCI map from cache');
} else { } else {
// no cached MCI info // no cached MCI info
mciMap = {}; mciMap = {};
cprListener = function(pos) { cprListener = function(pos) {
if(mciCprQueue.length > 0) { if(mciCprQueue.length > 0) {
mciMap[mciCprQueue.shift()].position = pos; mciMap[mciCprQueue.shift()].position = pos;
if(parseComplete && 0 === mciCprQueue.length) { if(parseComplete && 0 === mciCprQueue.length) {
return completed(); return completed();
} }
} }
}; };
client.on('cursor position report', cprListener); client.on('cursor position report', cprListener);
let generatedId = 100; let generatedId = 100;
ansiParser.on('mci', mciInfo => { ansiParser.on('mci', mciInfo => {
// :TODO: ensure generatedId's do not conflict with any existing |id| // :TODO: ensure generatedId's do not conflict with any existing |id|
const id = _.isNumber(mciInfo.id) ? mciInfo.id : generatedId; const id = _.isNumber(mciInfo.id) ? mciInfo.id : generatedId;
const mapKey = `${mciInfo.mci}${id}`; const mapKey = `${mciInfo.mci}${id}`;
const mapEntry = mciMap[mapKey]; const mapEntry = mciMap[mapKey];
if(mapEntry) { if(mapEntry) {
mapEntry.focusSGR = mciInfo.SGR; mapEntry.focusSGR = mciInfo.SGR;
mapEntry.focusArgs = mciInfo.args; mapEntry.focusArgs = mciInfo.args;
} else { } else {
mciMap[mapKey] = { mciMap[mapKey] = {
args : mciInfo.args, args : mciInfo.args,
SGR : mciInfo.SGR, SGR : mciInfo.SGR,
code : mciInfo.mci, code : mciInfo.mci,
id : id, id : id,
}; };
if(!mciInfo.id) { if(!mciInfo.id) {
++generatedId; ++generatedId;
} }
mciCprQueue.push(mapKey); mciCprQueue.push(mapKey);
client.term.rawWrite(ansi.queryPos()); client.term.rawWrite(ansi.queryPos());
} }
}); });
} }
ansiParser.on('literal', literal => client.term.write(literal, false) ); ansiParser.on('literal', literal => client.term.write(literal, false) );
ansiParser.on('control', control => client.term.rawWrite(control) ); ansiParser.on('control', control => client.term.rawWrite(control) );
ansiParser.on('complete', () => { ansiParser.on('complete', () => {
parseComplete = true; parseComplete = true;
if(0 === mciCprQueue.length) { if(0 === mciCprQueue.length) {
return completed(); return completed();
} }
}); });
let initSeq = ''; let initSeq = '';
if(options.font) { if(options.font) {
initSeq = ansi.setSyncTermFontWithAlias(options.font); initSeq = ansi.setSyncTermFontWithAlias(options.font);
} else if(options.sauce) { } else if(options.sauce) {
let fontName = getFontNameFromSAUCE(options.sauce); let fontName = getFontNameFromSAUCE(options.sauce);
if(fontName) { if(fontName) {
fontName = ansi.getSyncTERMFontFromAlias(fontName); fontName = ansi.getSyncTERMFontFromAlias(fontName);
} }
// //
// Set SyncTERM font if we're switching only. Most terminals // Set SyncTERM font if we're switching only. Most terminals
// that support this ESC sequence can only show *one* font // that support this ESC sequence can only show *one* font
// at a time. This applies to detection only (e.g. SAUCE). // at a time. This applies to detection only (e.g. SAUCE).
// If explicit, we'll set it no matter what (above) // If explicit, we'll set it no matter what (above)
// //
if(fontName && client.term.currentSyncFont != fontName) { if(fontName && client.term.currentSyncFont != fontName) {
client.term.currentSyncFont = fontName; client.term.currentSyncFont = fontName;
initSeq = ansi.setSyncTERMFont(fontName); initSeq = ansi.setSyncTERMFont(fontName);
} }
} }
if(options.iceColors) { if(options.iceColors) {
initSeq += ansi.blinkToBrightIntensity(); initSeq += ansi.blinkToBrightIntensity();
} }
if(initSeq) { if(initSeq) {
client.term.rawWrite(initSeq); client.term.rawWrite(initSeq);
} }
ansiParser.reset(art); ansiParser.reset(art);
return ansiParser.parse(); return ansiParser.parse();
} }

View File

@ -1,128 +1,132 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const assert = require('assert'); const assert = require('assert');
exports.parseAsset = parseAsset; exports.parseAsset = parseAsset;
exports.getAssetWithShorthand = getAssetWithShorthand; exports.getAssetWithShorthand = getAssetWithShorthand;
exports.getArtAsset = getArtAsset; exports.getArtAsset = getArtAsset;
exports.getModuleAsset = getModuleAsset; exports.getModuleAsset = getModuleAsset;
exports.resolveConfigAsset = resolveConfigAsset; exports.resolveConfigAsset = resolveConfigAsset;
exports.resolveSystemStatAsset = resolveSystemStatAsset; exports.resolveSystemStatAsset = resolveSystemStatAsset;
exports.getViewPropertyAsset = getViewPropertyAsset; exports.getViewPropertyAsset = getViewPropertyAsset;
const ALL_ASSETS = [ const ALL_ASSETS = [
'art', 'art',
'menu', 'menu',
'method', 'method',
'userModule', 'userModule',
'systemMethod', 'systemMethod',
'systemModule', 'systemModule',
'prompt', 'prompt',
'config', 'config',
'sysStat', '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) { function parseAsset(s) {
const m = ASSET_RE.exec(s); const m = ASSET_RE.exec(s);
if(m) {
const result = { type : m[1] };
if(m) { if(m[3]) {
let result = { type : m[1] }; result.asset = m[3];
if(m[2]) {
result.location = m[2];
}
} else {
result.asset = m[2];
}
if(m[3]) { return result;
result.location = m[2]; }
result.asset = m[3];
} else {
result.asset = m[2];
}
return result;
}
} }
function getAssetWithShorthand(spec, defaultType) { function getAssetWithShorthand(spec, defaultType) {
if(!_.isString(spec)) { if(!_.isString(spec)) {
return null; return null;
} }
if('@' === spec[0]) { if('@' === spec[0]) {
const asset = parseAsset(spec); const asset = parseAsset(spec);
assert(_.isString(asset.type)); assert(_.isString(asset.type));
return asset; return asset;
} }
return { return {
type : defaultType, type : defaultType,
asset : spec, asset : spec,
}; };
} }
function getArtAsset(spec) { function getArtAsset(spec) {
const asset = getAssetWithShorthand(spec, 'art'); const asset = getAssetWithShorthand(spec, 'art');
if(!asset) { if(!asset) {
return null; return null;
} }
assert( ['art', 'method' ].indexOf(asset.type) > -1); assert( ['art', 'method' ].indexOf(asset.type) > -1);
return asset; return asset;
} }
function getModuleAsset(spec) { function getModuleAsset(spec) {
const asset = getAssetWithShorthand(spec, 'systemModule'); const asset = getAssetWithShorthand(spec, 'systemModule');
if(!asset) { if(!asset) {
return null; return null;
} }
assert( ['userModule', 'systemModule' ].includes(asset.type) ); assert( ['userModule', 'systemModule' ].includes(asset.type) );
return asset; return asset;
} }
function resolveConfigAsset(spec) { function resolveConfigAsset(spec) {
const asset = parseAsset(spec); const asset = parseAsset(spec);
if(asset) { if(asset) {
assert('config' === asset.type); assert('config' === asset.type);
const path = asset.asset.split('.'); const path = asset.asset.split('.');
let conf = Config; let conf = Config();
for(let i = 0; i < path.length; ++i) { for(let i = 0; i < path.length; ++i) {
if(_.isUndefined(conf[path[i]])) { if(_.isUndefined(conf[path[i]])) {
return spec; return spec;
} }
conf = conf[path[i]]; conf = conf[path[i]];
} }
return conf; return conf;
} else { } else {
return spec; return spec;
} }
} }
function resolveSystemStatAsset(spec) { function resolveSystemStatAsset(spec) {
const asset = parseAsset(spec); const asset = parseAsset(spec);
if(!asset) { if(!asset) {
return spec; 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) { function getViewPropertyAsset(src) {
if(!_.isString(src) || '@' !== src.charAt(0)) { if(!_.isString(src) || '@' !== src.charAt(0)) {
return null; return null;
} }
return parseAsset(src); return parseAsset(src);
} }

View File

@ -5,29 +5,36 @@
//var SegfaultHandler = require('segfault-handler'); //var SegfaultHandler = require('segfault-handler');
//SegfaultHandler.registerHandler('enigma-bbs-segfault.log'); //SegfaultHandler.registerHandler('enigma-bbs-segfault.log');
// ENiGMA½ // ENiGMA½
const conf = require('./config.js'); const conf = require('./config.js');
const logger = require('./logger.js'); const logger = require('./logger.js');
const database = require('./database.js'); const database = require('./database.js');
const resolvePath = require('./misc_util.js').resolvePath; 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 // deps
const async = require('async'); const async = require('async');
const util = require('util'); const util = require('util');
const _ = require('lodash'); const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs; const mkdirs = require('fs-extra').mkdirs;
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const paths = require('path'); const paths = require('path');
const moment = require('moment');
// our main entry point // our main entry point
exports.main = main; 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 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 = const HELP =
`${ENIGMA_COPYRIGHT} `${FULL_COPYRIGHT}
usage: main.js <args> usage: main.js <args>
eg : main.js --config /enigma_install_path/config/ eg : main.js --config /enigma_install_path/config/
@ -38,244 +45,280 @@ valid args:
`; `;
function printHelpAndExit() { function printHelpAndExit() {
console.info(HELP); console.info(HELP);
process.exit(); process.exit();
}
function printVersionAndExit() {
console.info(require('../package.json').version);
} }
function main() { function main() {
async.waterfall( async.waterfall(
[ [
function processArgs(callback) { function processArgs(callback) {
const argv = require('minimist')(process.argv.slice(2)); const argv = require('minimist')(process.argv.slice(2));
if(argv.help) { if(argv.help) {
printHelpAndExit(); return printHelpAndExit();
} }
const configOverridePath = argv.config; if(argv.version) {
return printVersionAndExit();
}
return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath)); const configOverridePath = argv.config;
},
function initConfig(configPath, configPathSupplied, callback) {
const configFile = configPath + 'config.hjson';
conf.init(resolvePath(configFile), function configInit(err) {
// return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath));
// If the user supplied a path and we can't read/parse it },
// then it's a fatal error function initConfig(configPath, configPathSupplied, callback) {
// const configFile = configPath + 'config.hjson';
if(err) { conf.init(resolvePath(configFile), function configInit(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!');
});
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() { function shutdownSystem() {
const msg = 'Process interrupted. Shutting down...'; const msg = 'Process interrupted. Shutting down...';
console.info(msg); console.info(msg);
logger.log.info(msg); logger.log.info(msg);
async.series( async.series(
[ [
function closeConnections(callback) { function closeConnections(callback) {
const ClientConns = require('./client_connections.js'); const ClientConns = require('./client_connections.js');
const activeConnections = ClientConns.getActiveConnections(); const activeConnections = ClientConns.getActiveConnections();
let i = activeConnections.length; let i = activeConnections.length;
while(i--) { while(i--) {
activeConnections[i].term.write('\n\nServer is shutting down NOW! Disconnecting...\n\n'); const activeTerm = activeConnections[i].term;
ClientConns.removeClient(activeConnections[i]); if(activeTerm) {
} activeTerm.write('\n\nServer is shutting down NOW! Disconnecting...\n\n');
callback(null); }
}, ClientConns.removeClient(activeConnections[i]);
function stopListeningServers(callback) { }
return require('./listening_server.js').shutdown( () => { callback(null);
return callback(null); // ignore err },
}); function stopListeningServers(callback) {
}, return require('./listening_server.js').shutdown( () => {
function stopEventScheduler(callback) { return callback(null); // ignore err
if(initServices.eventScheduler) { });
return initServices.eventScheduler.shutdown( () => { },
return callback(null); // ignore err function stopEventScheduler(callback) {
}); if(initServices.eventScheduler) {
} else { return initServices.eventScheduler.shutdown( () => {
return callback(null); return callback(null); // ignore err
} });
}, } else {
function stopFileAreaWeb(callback) { return callback(null);
require('./file_area_web.js').startup( () => { }
return callback(null); // ignore err },
}); function stopFileAreaWeb(callback) {
}, require('./file_area_web.js').startup( () => {
function stopMsgNetwork(callback) { return callback(null); // ignore err
require('./msg_network.js').shutdown(callback); });
} },
], function stopMsgNetwork(callback) {
() => { require('./msg_network.js').shutdown(callback);
console.info('Goodbye!'); }
return process.exit(); ],
} () => {
); console.info('Goodbye!');
return process.exit();
}
);
} }
function initialize(cb) { function initialize(cb) {
async.series( async.series(
[ [
function createMissingDirectories(callback) { function createMissingDirectories(callback) {
async.each(Object.keys(conf.config.paths), function entry(pathKey, next) { async.each(Object.keys(conf.config.paths), function entry(pathKey, next) {
mkdirs(conf.config.paths[pathKey], function dirCreated(err) { mkdirs(conf.config.paths[pathKey], function dirCreated(err) {
if(err) { if(err) {
console.error('Could not create path: ' + conf.config.paths[pathKey] + ': ' + err.toString()); console.error('Could not create path: ' + conf.config.paths[pathKey] + ': ' + err.toString());
} }
return next(err); return next(err);
}); });
}, function dirCreationComplete(err) { }, function dirCreationComplete(err) {
return callback(err); return callback(err);
}); });
}, },
function basicInit(callback) { function basicInit(callback) {
logger.init(); logger.init();
logger.log.info( logger.log.info(
{ version : require('../package.json').version }, { version : require('../package.json').version },
'**** ENiGMA½ Bulletin Board System Starting Up! ****'); '**** 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); return callback(null);
}, },
function initDatabases(callback) { function initDatabases(callback) {
return database.initializeDatabases(callback); return database.initializeDatabases(callback);
}, },
function initMimeTypes(callback) { function initMimeTypes(callback) {
return require('./mime_util.js').startup(callback); return require('./mime_util.js').startup(callback);
}, },
function initStatLog(callback) { function initStatLog(callback) {
return require('./stat_log.js').init(callback); return require('./stat_log.js').init(callback);
}, },
function initThemes(callback) { function initConfigs(callback) {
// Have to pull in here so it's after Config init return require('./config_util.js').init(callback);
require('./theme.js').initAvailableThemes(function onThemesInit(err, themeCount) { },
logger.log.info({ themeCount : themeCount }, 'Themes initialized'); function initThemes(callback) {
return callback(err); // Have to pull in here so it's after Config init
}); require('./theme.js').initAvailableThemes( (err, themeCount) => {
}, logger.log.info({ themeCount }, 'Themes initialized');
function loadSysOpInformation(callback) { return callback(err);
// });
// Copy over some +op information from the user DB -> system propertys. },
// * Makes this accessible for MCI codes, easy non-blocking access, etc. function loadSysOpInformation(callback) {
// * We do this every time as the op is free to change this information just //
// like any other user // Copy over some +op information from the user DB -> system properties.
// // * Makes this accessible for MCI codes, easy non-blocking access, etc.
const User = require('./user.js'); // * 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( const propLoadOpts = {
[ names : [
function getOpUserName(next) { UserProps.RealName, UserProps.Sex, UserProps.EmailAddress,
return User.getUserName(1, next); UserProps.Location, UserProps.Affiliations,
}, ],
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');
if(err) { async.waterfall(
[ 'username', 'real_name', 'sex', 'email_address', 'location', 'affiliation' ].forEach(v => { [
StatLog.setNonPeristentSystemStat(`sysop_${v}`, 'N/A'); function getOpUserName(next) {
}); return User.getUserName(1, next);
} else { },
opProps.username = opUserName; 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) => { if(err) {
StatLog.setNonPeristentSystemStat(`sysop_${k}`, v); propLoadOpts.names.concat('username').forEach(v => {
}); StatLog.setNonPersistentSystemStat(`sysop_${v}`, 'N/A');
} });
} else {
opProps.username = opUserName;
return callback(null); _.each(opProps, (v, k) => {
} StatLog.setNonPersistentSystemStat(`sysop_${k}`, v);
); });
}, }
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);
}
return callback(null); return callback(null);
}); }
}, );
function initMCI(callback) { },
return require('./predefined_mci.js').init(callback); function initCallsToday(callback) {
}, const StatLog = require('./stat_log.js');
function readyMessageNetworkSupport(callback) { const filter = {
return require('./msg_network.js').startup(callback); logName : SysLogKeys.UserLoginHistory,
}, resultType : 'count',
function readyEvents(callback) { date : moment(),
return require('./events.js').startup(callback); };
},
function listenConnections(callback) { StatLog.findSystemLogEntries(filter, (err, callsToday) => {
return require('./listening_server.js').startup(callback); if(!err) {
}, StatLog.setNonPersistentSystemStat(SysProps.LoginsToday, callsToday);
function readyFileAreaWeb(callback) { }
return require('./file_area_web.js').startup(callback); return callback(null);
}, });
function readyPasswordReset(callback) { },
const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset; function initMessageStats(callback) {
return WebPasswordReset.startup(callback); return require('./message_area.js').startup(callback);
}, },
function readyEventScheduler(callback) { function initMCI(callback) {
const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule; return require('./predefined_mci.js').init(callback);
EventSchedulerModule.loadAndStart( (err, modInst) => { },
initServices.eventScheduler = modInst; function readyMessageNetworkSupport(callback) {
return callback(err); return require('./msg_network.js').startup(callback);
}); },
} function readyEvents(callback) {
], return require('./events.js').startup(callback);
function onComplete(err) { },
return cb(err); 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);
}
);
} }

View File

@ -1,207 +1,217 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const MenuModule = require('./menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const resetScreen = require('./ansi_term.js').resetScreen; const { resetScreen } = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
const async = require('async'); // deps
const _ = require('lodash'); const async = require('async');
const http = require('http'); const http = require('http');
const net = require('net'); const net = require('net');
const crypto = require('crypto'); const crypto = require('crypto');
const packageJson = require('../package.json'); const packageJson = require('../package.json');
/* /*
Expected configuration block: Expected configuration block:
{ {
module: bbs_link module: bbs_link
... ...
config: { config: {
sysCode: XXXXX sysCode: XXXXX
authCode: XXXXX authCode: XXXXX
schemeCode: XXXX schemeCode: XXXX
door: lord door: lord
// default hoss: games.bbslink.net // default hoss: games.bbslink.net
host: games.bbslink.net host: games.bbslink.net
// defualt port: 23 // defualt port: 23
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: 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: ENH: Support nodeMax and tooManyArt
exports.moduleInfo = { exports.moduleInfo = {
name : 'BBSLink', name : 'BBSLink',
desc : 'BBSLink Access Module', desc : 'BBSLink Access Module',
author : 'NuSkooler', author : 'NuSkooler',
}; };
exports.getModule = class BBSLinkModule extends MenuModule { exports.getModule = class BBSLinkModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.config = options.menuConfig.config; this.config = options.menuConfig.config;
this.config.host = this.config.host || 'games.bbslink.net'; this.config.host = this.config.host || 'games.bbslink.net';
this.config.port = this.config.port || 23; this.config.port = this.config.port || 23;
} }
initSequence() { initSequence() {
let token; let token;
let randomKey; let randomKey;
let clientTerminated; let clientTerminated;
const self = this; const self = this;
async.series( async.series(
[ [
function validateConfig(callback) { function validateConfig(callback) {
if(_.isString(self.config.sysCode) && return self.validateConfigFields(
_.isString(self.config.authCode) && {
_.isString(self.config.schemeCode) && host : 'string',
_.isString(self.config.door)) sysCode : 'string',
{ authCode : 'string',
callback(null); schemeCode : 'string',
} else { door : 'string',
callback(new Error('Configuration is missing option(s)')); port : 'number',
} },
}, callback
function acquireToken(callback) { );
// },
// Acquire an authentication token function acquireToken(callback) {
// //
crypto.randomBytes(16, function rand(ex, buf) { // Acquire an authentication token
if(ex) { //
callback(ex); crypto.randomBytes(16, function rand(ex, buf) {
} else { if(ex) {
randomKey = buf.toString('base64').substr(0, 6); callback(ex);
self.simpleHttpRequest('/token.php?key=' + randomKey, null, function resp(err, body) { } else {
if(err) { randomKey = buf.toString('base64').substr(0, 6);
callback(err); self.simpleHttpRequest('/token.php?key=' + randomKey, null, function resp(err, body) {
} else { if(err) {
token = body.trim(); callback(err);
self.client.log.trace( { token : token }, 'BBSLink token'); } else {
callback(null); token = body.trim();
} self.client.log.trace( { token : token }, 'BBSLink token');
}); callback(null);
} }
}); });
}, }
function authenticateToken(callback) { });
// },
// Authenticate the token we acquired previously function authenticateToken(callback) {
// //
var headers = { // Authenticate the token we acquired previously
'X-User' : self.client.user.userId.toString(), //
'X-System' : self.config.sysCode, const headers = {
'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'), 'X-User' : self.client.user.userId.toString(),
'X-Code' : crypto.createHash('md5').update(self.config.schemeCode + token).digest('hex'), 'X-System' : self.config.sysCode,
'X-Rows' : self.client.term.termHeight.toString(), 'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'),
'X-Key' : randomKey, 'X-Code' : crypto.createHash('md5').update(self.config.schemeCode + token).digest('hex'),
'X-Door' : self.config.door, 'X-Rows' : self.client.term.termHeight.toString(),
'X-Token' : token, 'X-Key' : randomKey,
'X-Type' : 'enigma-bbs', 'X-Door' : self.config.door,
'X-Version' : packageJson.version, 'X-Token' : token,
}; 'X-Type' : 'enigma-bbs',
'X-Version' : packageJson.version,
};
self.simpleHttpRequest('/auth.php?key=' + randomKey, headers, function resp(err, body) { self.simpleHttpRequest('/auth.php?key=' + randomKey, headers, function resp(err, body) {
var status = body.trim(); var status = body.trim();
if('complete' === status) { if('complete' === status) {
callback(null); return callback(null);
} else { }
callback(new Error('Bad authentication status: ' + status)); return callback(Errors.AccessDenied(`Bad authentication status: ${status}`));
} });
}); },
}, function createTelnetBridge(callback) {
function createTelnetBridge(callback) { //
// // Authentication with BBSLink successful. Now, we need to create a telnet
// Authentication with BBSLink successful. Now, we need to create a telnet // bridge from us to them
// bridge from us to them //
// const connectOpts = {
var connectOpts = { port : self.config.port,
port : self.config.port, host : self.config.host,
host : self.config.host, };
};
var clientTerminated; let clientTerminated;
self.client.term.write(resetScreen()); self.client.term.write(resetScreen());
self.client.term.write(' Connecting to BBSLink.net, please wait...\n'); self.client.term.write(' Connecting to BBSLink.net, please wait...\n');
var bridgeConnection = net.createConnection(connectOpts, function connected() { const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`);
self.client.log.info(connectOpts, 'BBSLink bridge connection established');
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.term.output.pipe(bridgeConnection);
self.client.log.info('Connection ended. Terminating BBSLink connection');
clientTerminated = true;
bridgeConnection.end();
});
});
var restorePipe = function() { self.client.once('end', function clientEnd() {
self.client.term.output.unpipe(bridgeConnection); self.client.log.info('Connection ended. Terminating BBSLink connection');
self.client.term.output.resume(); clientTerminated = true;
}; bridgeConnection.end();
});
});
bridgeConnection.on('data', function incomingData(data) { const restorePipe = function() {
// pass along self.client.term.output.unpipe(bridgeConnection);
// :TODO: just pipe this as well self.client.term.output.resume();
self.client.term.rawWrite(data);
});
bridgeConnection.on('end', function connectionEnd() { trackDoorRunEnd(doorTracking);
restorePipe(); };
callback(clientTerminated ? new Error('Client connection terminated') : null);
});
bridgeConnection.on('error', function error(err) { bridgeConnection.on('data', function incomingData(data) {
self.client.log.info('BBSLink bridge connection error: ' + err.message); // pass along
restorePipe(); // :TODO: just pipe this as well
callback(err); self.client.term.rawWrite(data);
}); });
}
],
function complete(err) {
if(err) {
self.client.log.warn( { error : err.toString() }, 'BBSLink connection error');
}
if(!clientTerminated) { bridgeConnection.on('end', function connectionEnd() {
self.prevMenu(); restorePipe();
} return callback(clientTerminated ? Errors.General('Client connection terminated') : null);
} });
);
}
simpleHttpRequest(path, headers, cb) { bridgeConnection.on('error', function error(err) {
const getOpts = { self.client.log.info('BBSLink bridge connection error: ' + err.message);
host : this.config.host, restorePipe();
path : path, callback(err);
headers : headers, });
}; }
],
function complete(err) {
if(err) {
self.client.log.warn( { error : err.toString() }, 'BBSLink connection error');
}
const req = http.get(getOpts, function response(resp) { if(!clientTerminated) {
let data = ''; self.prevMenu();
}
}
);
}
resp.on('data', function chunk(c) { simpleHttpRequest(path, headers, cb) {
data += c; const getOpts = {
}); host : this.config.host,
path : path,
headers : headers,
};
resp.on('end', function respEnd() { const req = http.get(getOpts, function response(resp) {
cb(null, data); let data = '';
req.end();
});
});
req.on('error', function reqErr(err) { resp.on('data', function chunk(c) {
cb(err); data += c;
}); });
}
resp.on('end', function respEnd() {
cb(null, data);
req.end();
});
});
req.on('error', function reqErr(err) {
cb(err);
});
}
}; };

View File

@ -1,438 +1,439 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const { const {
getModDatabasePath, getModDatabasePath,
getTransactionDatabase getTransactionDatabase
} = require('./database.js'); } = require('./database.js');
const ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const theme = require('./theme.js'); const theme = require('./theme.js');
const User = require('./user.js'); const User = require('./user.js');
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
// deps // deps
const async = require('async'); const async = require('async');
const sqlite3 = require('sqlite3'); const sqlite3 = require('sqlite3');
const _ = require('lodash'); const _ = require('lodash');
// :TODO: add notes field // :TODO: add notes field
const moduleInfo = exports.moduleInfo = { const moduleInfo = exports.moduleInfo = {
name : 'BBS List', name : 'BBS List',
desc : 'List of other BBSes', desc : 'List of other BBSes',
author : 'Andrew Pamment', author : 'Andrew Pamment',
packageName : 'com.magickabbs.enigma.bbslist' packageName : 'com.magickabbs.enigma.bbslist'
}; };
const MciViewIds = { const MciViewIds = {
view : { view : {
BBSList : 1, BBSList : 1,
SelectedBBSName : 2, SelectedBBSName : 2,
SelectedBBSSysOp : 3, SelectedBBSSysOp : 3,
SelectedBBSTelnet : 4, SelectedBBSTelnet : 4,
SelectedBBSWww : 5, SelectedBBSWww : 5,
SelectedBBSLoc : 6, SelectedBBSLoc : 6,
SelectedBBSSoftware : 7, SelectedBBSSoftware : 7,
SelectedBBSNotes : 8, SelectedBBSNotes : 8,
SelectedBBSSubmitter : 9, SelectedBBSSubmitter : 9,
}, },
add : { add : {
BBSName : 1, BBSName : 1,
Sysop : 2, Sysop : 2,
Telnet : 3, Telnet : 3,
Www : 4, Www : 4,
Location : 5, Location : 5,
Software : 6, Software : 6,
Notes : 7, Notes : 7,
Error : 8, Error : 8,
} }
}; };
const FormIds = { const FormIds = {
View : 0, View : 0,
Add : 1, Add : 1,
}; };
const SELECTED_MCI_NAME_TO_ENTRY = { const SELECTED_MCI_NAME_TO_ENTRY = {
SelectedBBSName : 'bbsName', SelectedBBSName : 'bbsName',
SelectedBBSSysOp : 'sysOp', SelectedBBSSysOp : 'sysOp',
SelectedBBSTelnet : 'telnet', SelectedBBSTelnet : 'telnet',
SelectedBBSWww : 'www', SelectedBBSWww : 'www',
SelectedBBSLoc : 'location', SelectedBBSLoc : 'location',
SelectedBBSSoftware : 'software', SelectedBBSSoftware : 'software',
SelectedBBSSubmitter : 'submitter', SelectedBBSSubmitter : 'submitter',
SelectedBBSSubmitterId : 'submitterUserId', SelectedBBSSubmitterId : 'submitterUserId',
SelectedBBSNotes : 'notes', SelectedBBSNotes : 'notes',
}; };
exports.getModule = class BBSListModule extends MenuModule { exports.getModule = class BBSListModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
const self = this; const self = this;
this.menuMethods = { this.menuMethods = {
// //
// Validators // Validators
// //
viewValidationListener : function(err, cb) { viewValidationListener : function(err, cb) {
const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error); const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error);
if(errMsgView) { if(errMsgView) {
if(err) { if(err) {
errMsgView.setText(err.message); errMsgView.setText(err.message);
} else { } else {
errMsgView.clearText(); errMsgView.clearText();
} }
} }
return cb(null); return cb(null);
}, },
// //
// Key & submit handlers // Key & submit handlers
// //
addBBS : function(formData, extraArgs, cb) { addBBS : function(formData, extraArgs, cb) {
self.displayAddScreen(cb); self.displayAddScreen(cb);
}, },
deleteBBS : function(formData, extraArgs, cb) { deleteBBS : function(formData, extraArgs, cb) {
const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList); 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()) { const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList);
// must be owner or +op
return cb(null);
}
const entry = self.entries[self.selectedBBS]; if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) {
if(!entry) { // must be owner or +op
return cb(null); return cb(null);
} }
self.database.run( const entry = self.entries[self.selectedBBS];
`DELETE FROM bbs_list if(!entry) {
WHERE id=?;`, return cb(null);
[ entry.id ], }
err => {
if (err) {
self.client.log.error( { err : err }, 'Error deleting from BBS list');
} else {
self.entries.splice(self.selectedBBS, 1);
self.setEntries(entriesView); 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) { self.setEntries(entriesView);
entriesView.focusPrevious();
}
self.viewControllers.view.redrawAll(); if(self.entries.length > 0) {
} entriesView.focusPrevious();
}
return cb(null); self.viewControllers.view.redrawAll();
} }
);
},
submitBBS : function(formData, extraArgs, cb) {
let ok = true; return cb(null);
[ 'BBSName', 'Sysop', 'Telnet' ].forEach( mciName => { }
if('' === self.viewControllers.add.getView(MciViewIds.add[mciName]).getData()) { );
ok = false; },
} submitBBS : function(formData, extraArgs, cb) {
});
if(!ok) {
// validators should prevent this!
return cb(null);
}
self.database.run( let ok = true;
`INSERT INTO bbs_list (bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes) [ 'BBSName', 'Sysop', 'Telnet' ].forEach( mciName => {
VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, if('' === self.viewControllers.add.getView(MciViewIds.add[mciName]).getData()) {
[ ok = false;
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 });
], if(!ok) {
err => { // validators should prevent this!
if(err) { return cb(null);
self.client.log.error( { err : err }, 'Error adding to BBS list'); }
}
self.clearAddForm(); self.database.run(
self.displayBBSList(true, cb); `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,
cancelSubmit : function(formData, extraArgs, cb) { formData.value.location, formData.value.software, self.client.user.userId, formData.value.notes
self.clearAddForm(); ],
self.displayBBSList(true, cb); err => {
} if(err) {
}; self.client.log.error( { err : err }, 'Error adding to BBS list');
} }
initSequence() { self.clearAddForm();
const self = this; self.displayBBSList(true, cb);
async.series( }
[ );
function beforeDisplayArt(callback) { },
self.beforeArt(callback); cancelSubmit : function(formData, extraArgs, cb) {
}, self.clearAddForm();
function display(callback) { self.displayBBSList(true, cb);
self.displayBBSList(false, callback); }
} };
], }
err => {
if(err) {
// :TODO: Handle me -- initSequence() should really take a completion callback
}
self.finishedLoading();
}
);
}
drawSelectedEntry(entry) { initSequence() {
if(!entry) { const self = this;
Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { async.series(
this.setViewText('view', MciViewIds.view[mciName], ''); [
}); function beforeDisplayArt(callback) {
} else { self.beforeArt(callback);
const youSubmittedFormat = this.menuConfig.youSubmittedFormat || '{submitter} (You!)'; },
function display(callback) {
self.displayBBSList(false, callback);
}
],
err => {
if(err) {
// :TODO: Handle me -- initSequence() should really take a completion callback
}
self.finishedLoading();
}
);
}
Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { drawSelectedEntry(entry) {
const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]]; if(!entry) {
if(MciViewIds.view[mciName]) { Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => {
this.setViewText('view', MciViewIds.view[mciName], '');
});
} else {
const youSubmittedFormat = this.menuConfig.youSubmittedFormat || '{submitter} (You!)';
if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == this.client.user.userId) { Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => {
this.setViewText('view',MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry)); const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]];
} else { if(MciViewIds.view[mciName]) {
this.setViewText('view',MciViewIds.view[mciName], t);
}
}
});
}
}
setEntries(entriesView) { if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == this.client.user.userId) {
const config = this.menuConfig.config; this.setViewText('view',MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry));
const listFormat = config.listFormat || '{bbsName}'; } else {
const focusListFormat = config.focusListFormat || '{bbsName}'; this.setViewText('view',MciViewIds.view[mciName], t);
}
}
});
}
}
entriesView.setItems(this.entries.map( e => stringFormat(listFormat, e) ) ); setEntries(entriesView) {
entriesView.setFocusItems(this.entries.map( e => stringFormat(focusListFormat, e) ) ); return entriesView.setItems(this.entries);
} }
displayBBSList(clearScreen, cb) { displayBBSList(clearScreen, cb) {
const self = this; const self = this;
async.waterfall( async.waterfall(
[ [
function clearAndDisplayArt(callback) { function clearAndDisplayArt(callback) {
if(self.viewControllers.add) { if(self.viewControllers.add) {
self.viewControllers.add.setFocus(false); self.viewControllers.add.setFocus(false);
} }
if (clearScreen) { if (clearScreen) {
self.client.term.rawWrite(ansi.resetScreen()); self.client.term.rawWrite(ansi.resetScreen());
} }
theme.displayThemedAsset( theme.displayThemedAsset(
self.menuConfig.config.art.entries, self.menuConfig.config.art.entries,
self.client, self.client,
{ font : self.menuConfig.font, trailingLF : false }, { font : self.menuConfig.font, trailingLF : false },
(err, artData) => { (err, artData) => {
return callback(err, artData); return callback(err, artData);
} }
); );
}, },
function initOrRedrawViewController(artData, callback) { function initOrRedrawViewController(artData, callback) {
if(_.isUndefined(self.viewControllers.add)) { if(_.isUndefined(self.viewControllers.add)) {
const vc = self.addViewController( const vc = self.addViewController(
'view', 'view',
new ViewController( { client : self.client, formId : FormIds.View } ) new ViewController( { client : self.client, formId : FormIds.View } )
); );
const loadOpts = { const loadOpts = {
callingMenu : self, callingMenu : self,
mciMap : artData.mciMap, mciMap : artData.mciMap,
formId : FormIds.View, formId : FormIds.View,
}; };
return vc.loadFromMenuConfig(loadOpts, callback); return vc.loadFromMenuConfig(loadOpts, callback);
} else { } else {
self.viewControllers.view.setFocus(true); self.viewControllers.view.setFocus(true);
self.viewControllers.view.getView(MciViewIds.view.BBSList).redraw(); self.viewControllers.view.getView(MciViewIds.view.BBSList).redraw();
return callback(null); return callback(null);
} }
}, },
function fetchEntries(callback) { function fetchEntries(callback) {
const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList); const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList);
self.entries = []; self.entries = [];
self.database.each( self.database.each(
`SELECT id, bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes `SELECT id, bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes
FROM bbs_list;`, FROM bbs_list;`,
(err, row) => { (err, row) => {
if (!err) { if (!err) {
self.entries.push({ self.entries.push({
id : row.id, text : row.bbs_name, // standard field
bbsName : row.bbs_name, id : row.id,
sysOp : row.sysop, bbsName : row.bbs_name,
telnet : row.telnet, sysOp : row.sysop,
www : row.www, telnet : row.telnet,
location : row.location, www : row.www,
software : row.software, location : row.location,
submitterUserId : row.submitter_user_id, software : row.software,
notes : row.notes, submitterUserId : row.submitter_user_id,
}); notes : row.notes,
} });
}, }
err => { },
return callback(err, entriesView); err => {
} return callback(err, entriesView);
); }
}, );
function getUserNames(entriesView, callback) { },
async.each(self.entries, (entry, next) => { function getUserNames(entriesView, callback) {
User.getUserName(entry.submitterUserId, (err, username) => { async.each(self.entries, (entry, next) => {
if(username) { User.getUserName(entry.submitterUserId, (err, username) => {
entry.submitter = username; if(username) {
} else { entry.submitter = username;
entry.submitter = 'N/A'; } else {
} entry.submitter = 'N/A';
return next(); }
}); return next();
}, () => { });
return callback(null, entriesView); }, () => {
}); return callback(null, entriesView);
}, });
function populateEntries(entriesView, callback) { },
self.setEntries(entriesView); function populateEntries(entriesView, callback) {
self.setEntries(entriesView);
entriesView.on('index update', idx => { entriesView.on('index update', idx => {
const entry = self.entries[idx]; const entry = self.entries[idx];
self.drawSelectedEntry(entry); self.drawSelectedEntry(entry);
if(!entry) { if(!entry) {
self.selectedBBS = -1; self.selectedBBS = -1;
} else { } else {
self.selectedBBS = idx; self.selectedBBS = idx;
} }
}); });
if (self.selectedBBS >= 0) { if (self.selectedBBS >= 0) {
entriesView.setFocusItemIndex(self.selectedBBS); entriesView.setFocusItemIndex(self.selectedBBS);
self.drawSelectedEntry(self.entries[self.selectedBBS]); self.drawSelectedEntry(self.entries[self.selectedBBS]);
} else if (self.entries.length > 0) { } else if (self.entries.length > 0) {
entriesView.setFocusItemIndex(0); self.selectedBBS = 0;
self.drawSelectedEntry(self.entries[0]); entriesView.setFocusItemIndex(0);
} self.drawSelectedEntry(self.entries[0]);
}
entriesView.redraw(); entriesView.redraw();
return callback(null); return callback(null);
} }
], ],
err => { err => {
if(cb) { if(cb) {
return cb(err); return cb(err);
} }
} }
); );
} }
displayAddScreen(cb) { displayAddScreen(cb) {
const self = this; const self = this;
async.waterfall( async.waterfall(
[ [
function clearAndDisplayArt(callback) { function clearAndDisplayArt(callback) {
self.viewControllers.view.setFocus(false); self.viewControllers.view.setFocus(false);
self.client.term.rawWrite(ansi.resetScreen()); self.client.term.rawWrite(ansi.resetScreen());
theme.displayThemedAsset( theme.displayThemedAsset(
self.menuConfig.config.art.add, self.menuConfig.config.art.add,
self.client, self.client,
{ font : self.menuConfig.font }, { font : self.menuConfig.font },
(err, artData) => { (err, artData) => {
return callback(err, artData); return callback(err, artData);
} }
); );
}, },
function initOrRedrawViewController(artData, callback) { function initOrRedrawViewController(artData, callback) {
if(_.isUndefined(self.viewControllers.add)) { if(_.isUndefined(self.viewControllers.add)) {
const vc = self.addViewController( const vc = self.addViewController(
'add', 'add',
new ViewController( { client : self.client, formId : FormIds.Add } ) new ViewController( { client : self.client, formId : FormIds.Add } )
); );
const loadOpts = { const loadOpts = {
callingMenu : self, callingMenu : self,
mciMap : artData.mciMap, mciMap : artData.mciMap,
formId : FormIds.Add, formId : FormIds.Add,
}; };
return vc.loadFromMenuConfig(loadOpts, callback); return vc.loadFromMenuConfig(loadOpts, callback);
} else { } else {
self.viewControllers.add.setFocus(true); self.viewControllers.add.setFocus(true);
self.viewControllers.add.redrawAll(); self.viewControllers.add.redrawAll();
self.viewControllers.add.switchFocus(MciViewIds.add.BBSName); self.viewControllers.add.switchFocus(MciViewIds.add.BBSName);
return callback(null); return callback(null);
} }
} }
], ],
err => { err => {
if(cb) { if(cb) {
return cb(err); return cb(err);
} }
} }
); );
} }
clearAddForm() { clearAddForm() {
[ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => { [ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => {
this.setViewText('add', MciViewIds.add[mciName], ''); this.setViewText('add', MciViewIds.add[mciName], '');
}); });
} }
initDatabase(cb) { initDatabase(cb) {
const self = this; const self = this;
async.series( async.series(
[ [
function openDatabase(callback) { function openDatabase(callback) {
self.database = getTransactionDatabase(new sqlite3.Database( self.database = getTransactionDatabase(new sqlite3.Database(
getModDatabasePath(moduleInfo), getModDatabasePath(moduleInfo),
callback callback
)); ));
}, },
function createTables(callback) { function createTables(callback) {
self.database.serialize( () => { self.database.serialize( () => {
self.database.run( self.database.run(
`CREATE TABLE IF NOT EXISTS bbs_list ( `CREATE TABLE IF NOT EXISTS bbs_list (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
bbs_name VARCHAR NOT NULL, bbs_name VARCHAR NOT NULL,
sysop VARCHAR NOT NULL, sysop VARCHAR NOT NULL,
telnet VARCHAR NOT NULL, telnet VARCHAR NOT NULL,
www VARCHAR, www VARCHAR,
location VARCHAR, location VARCHAR,
software VARCHAR, software VARCHAR,
submitter_user_id INTEGER NOT NULL, submitter_user_id INTEGER NOT NULL,
notes VARCHAR notes VARCHAR
);` );`
); );
}); });
callback(null); callback(null);
} }
], ],
err => { err => {
return cb(err); return cb(err);
} }
); );
} }
beforeArt(cb) { beforeArt(cb) {
super.beforeArt(err => { super.beforeArt(err => {
return err ? cb(err) : this.initDatabase(cb); return err ? cb(err) : this.initDatabase(cb);
}); });
} }
}; };

View File

@ -1,43 +1,45 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const TextView = require('./text_view.js').TextView; const TextView = require('./text_view.js').TextView;
const miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
const util = require('util'); const util = require('util');
exports.ButtonView = ButtonView; exports.ButtonView = ButtonView;
function ButtonView(options) { function ButtonView(options) {
options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true);
options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true);
options.justify = miscUtil.valueWithDefault(options.justify, 'center'); options.justify = miscUtil.valueWithDefault(options.justify, 'center');
options.cursor = miscUtil.valueWithDefault(options.cursor, 'hide'); options.cursor = miscUtil.valueWithDefault(options.cursor, 'hide');
TextView.call(this, options); TextView.call(this, options);
this.initDefaultWidth();
} }
util.inherits(ButtonView, TextView); util.inherits(ButtonView, TextView);
ButtonView.prototype.onKeyPress = function(ch, key) { ButtonView.prototype.onKeyPress = function(ch, key) {
if(this.isKeyMapped('accept', key.name) || ' ' === ch) { if(this.isKeyMapped('accept', key.name) || ' ' === ch) {
this.submitData = 'accept'; this.submitData = 'accept';
this.emit('action', 'accept'); this.emit('action', 'accept');
delete this.submitData; delete this.submitData;
} else { } else {
ButtonView.super_.prototype.onKeyPress.call(this, ch, key); ButtonView.super_.prototype.onKeyPress.call(this, ch, key);
} }
}; };
/* /*
ButtonView.prototype.onKeyPress = function(ch, key) { ButtonView.prototype.onKeyPress = function(ch, key) {
// allow space = submit // allow space = submit
if(' ' === ch) { if(' ' === ch) {
this.emit('action', 'accept'); this.emit('action', 'accept');
} }
ButtonView.super_.prototype.onKeyPress.call(this, ch, key); ButtonView.super_.prototype.onKeyPress.call(this, ch, key);
}; };
*/ */
ButtonView.prototype.getData = function() { ButtonView.prototype.getData = function() {
return this.submitData || null; return this.submitData || null;
}; };

View File

@ -2,522 +2,586 @@
'use strict'; 'use strict';
/* /*
Portions of this code for key handling heavily inspired from the following: Portions of this code for key handling heavily inspired from the following:
https://github.com/chjj/blessed/blob/master/lib/keys.js https://github.com/chjj/blessed/blob/master/lib/keys.js
chji's blessed is MIT licensed: chji's blessed is MIT licensed:
----/snip/---------------------- ----/snip/----------------------
The MIT License (MIT) The MIT License (MIT)
Copyright (c) <year> <copyright holders> Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software. all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
----/snip/---------------------- ----/snip/----------------------
*/ */
// ENiGMA½ // ENiGMA½
const term = require('./client_term.js'); const term = require('./client_term.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const User = require('./user.js'); const User = require('./user.js');
const Config = require('./config.js').config; const Config = require('./config.js').get;
const MenuStack = require('./menu_stack.js'); const MenuStack = require('./menu_stack.js');
const ACS = require('./acs.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 // deps
const stream = require('stream'); const stream = require('stream');
const assert = require('assert'); const assert = require('assert');
const _ = require('lodash'); 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: // Resources & Standards:
// * http://www.ansi-bbs.org/ansi-bbs-core-server.html // * http://www.ansi-bbs.org/ansi-bbs-core-server.html
// //
const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9\;]+)(R)/; /* eslint-disable no-control-regex */
const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[\=\?]([0-9a-zA-Z\;]+)(c)/; const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9;]+)(R)/;
const RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/; const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[=?]([0-9a-zA-Z;]+)(c)/;
const RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$'); const RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/;
const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [ const RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$');
'(\\d+)(?:;(\\d+))?([~^$])', const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [
'(?:M([@ #!a`])(.)(.))', // mouse stuff '(\\d+)(?:;(\\d+))?([~^$])',
'(?:1;)?(\\d+)?([a-zA-Z@])' '(?:M([@ #!a`])(.)(.))', // mouse stuff
'(?:1;)?(\\d+)?([a-zA-Z@])'
].join('|') + ')'); ].join('|') + ')');
/* eslint-enable no-control-regex */
const RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source); const RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source);
const RE_ESC_CODE_ANYWHERE = new RegExp( [ const RE_ESC_CODE_ANYWHERE = new RegExp( [
RE_FUNCTION_KEYCODE_ANYWHERE.source, RE_FUNCTION_KEYCODE_ANYWHERE.source,
RE_META_KEYCODE_ANYWHERE.source, RE_META_KEYCODE_ANYWHERE.source,
RE_DSR_RESPONSE_ANYWHERE.source, RE_DSR_RESPONSE_ANYWHERE.source,
RE_DEV_ATTR_RESPONSE_ANYWHERE.source, RE_DEV_ATTR_RESPONSE_ANYWHERE.source,
/\u001b./.source /\u001b./.source // eslint-disable-line no-control-regex
].join('|')); ].join('|'));
function Client(input, output) { function Client(/*input, output*/) {
stream.call(this); stream.call(this);
const self = this; const self = this;
this.user = new User(); this.user = new User();
this.currentTheme = { info : { name : 'N/A', description : 'None' } }; this.currentTheme = { info : { name : 'N/A', description : 'None' } };
this.lastKeyPressMs = Date.now(); this.lastKeyPressMs = Date.now();
this.menuStack = new MenuStack(this); this.menuStack = new MenuStack(this);
this.acs = new ACS(this); this.acs = new ACS( { client : this, user : this.user } );
this.mciCache = {}; this.mciCache = {};
this.interruptQueue = new UserInterruptQueue(this);
this.clearMciCache = function() { this.clearMciCache = function() {
this.mciCache = {}; this.mciCache = {};
}; };
Object.defineProperty(this, 'node', { Object.defineProperty(this, 'node', {
get : function() { get : function() {
return self.session.id + 1; return self.session.id + 1;
} }
}); });
Object.defineProperty(this, 'currentMenuModule', { Object.defineProperty(this, 'currentMenuModule', {
get : function() { get : function() {
return self.menuStack.currentModule; return self.menuStack.currentModule;
} }
}); });
this.setTemporaryDirectDataHandler = function(handler) {
this.input.removeAllListeners('data');
this.input.on('data', handler);
};
// this.restoreDataHandler = function() {
// Peek at incoming |data| and emit events for any special this.input.removeAllListeners('data');
// handling that may include: this.input.on('data', this.dataHandler);
// * 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];
if(!termClient) { this.themeChangedListener = function( { themeId } ) {
if(_.startsWith(deviceAttr, '67;84;101;114;109')) { if(_.get(self.currentTheme, 'info.themeId') === themeId) {
// self.currentTheme = require('./theme.js').getAvailableThemes().get(themeId);
// See https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt }
// };
// Known clients:
// * SyncTERM
//
termClient = 'cterm';
}
}
return termClient; Events.on(Events.getSystemEvents().ThemeChanged, this.themeChangedListener);
};
this.isMouseInput = function(data) { //
return /\x1b\[M/.test(data) || // Peek at incoming |data| and emit events for any special
/\u001b\[M([\x00\u0020-\uffff]{3})/.test(data) || // handling that may include:
/\u001b\[(\d+;\d+;\d+)M/.test(data) || // * Keyboard input
/\u001b\[<(\d+;\d+;\d+)([mM])/.test(data) || // * ANSI CSR's and the like
/\u001b\[<(\d+;\d+;\d+;\d+)&w/.test(data) || //
/\u001b\[24([0135])~\[(\d+),(\d+)\]\r/.test(data) || // References:
/\u001b\[(O|I)/.test(data); // * 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.getKeyComponentsFromCode = function(code) { if(!termClient) {
return { if(_.startsWith(deviceAttr, '67;84;101;114;109')) {
// xterm/gnome //
'OP' : { name : 'f1' }, // See https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt
'OQ' : { name : 'f2' }, //
'OR' : { name : 'f3' }, // Known clients:
'OS' : { name : 'f4' }, // * SyncTERM
//
termClient = 'cterm';
}
}
'OA' : { name : 'up arrow' }, return termClient;
'OB' : { name : 'down arrow' }, };
'OC' : { name : 'right arrow' },
'OD' : { name : 'left arrow' },
'OE' : { name : 'clear' },
'OF' : { name : 'end' },
'OH' : { name : 'home' },
// xterm/rxvt /* eslint-disable no-control-regex */
'[11~' : { name : 'f1' }, this.isMouseInput = function(data) {
'[12~' : { name : 'f2' }, return /\x1b\[M/.test(data) ||
'[13~' : { name : 'f3' }, /\u001b\[M([\x00\u0020-\uffff]{3})/.test(data) ||
'[14~' : { name : 'f4' }, /\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 */
'[1~' : { name : 'home' }, this.getKeyComponentsFromCode = function(code) {
'[2~' : { name : 'insert' }, return {
'[3~' : { name : 'delete' }, // xterm/gnome
'[4~' : { name : 'end' }, 'OP' : { name : 'f1' },
'[5~' : { name : 'page up' }, 'OQ' : { name : 'f2' },
'[6~' : { name : 'page down' }, 'OR' : { name : 'f3' },
'OS' : { name : 'f4' },
// Cygwin & libuv 'OA' : { name : 'up arrow' },
'[[A' : { name : 'f1' }, 'OB' : { name : 'down arrow' },
'[[B' : { name : 'f2' }, 'OC' : { name : 'right arrow' },
'[[C' : { name : 'f3' }, 'OD' : { name : 'left arrow' },
'[[D' : { name : 'f4' }, 'OE' : { name : 'clear' },
'[[E' : { name : 'f5' }, 'OF' : { name : 'end' },
'OH' : { name : 'home' },
// Common impls // xterm/rxvt
'[15~' : { name : 'f5' }, '[11~' : { name : 'f1' },
'[17~' : { name : 'f6' }, '[12~' : { name : 'f2' },
'[18~' : { name : 'f7' }, '[13~' : { name : 'f3' },
'[19~' : { name : 'f8' }, '[14~' : { name : 'f4' },
'[20~' : { name : 'f9' },
'[21~' : { name : 'f10' },
'[23~' : { name : 'f11' },
'[24~' : { name : 'f12' },
// xterm '[1~' : { name : 'home' },
'[A' : { name : 'up arrow' }, '[2~' : { name : 'insert' },
'[B' : { name : 'down arrow' }, '[3~' : { name : 'delete' },
'[C' : { name : 'right arrow' }, '[4~' : { name : 'end' },
'[D' : { name : 'left arrow' }, '[5~' : { name : 'page up' },
'[E' : { name : 'clear' }, '[6~' : { name : 'page down' },
'[F' : { name : 'end' },
'[H' : { name : 'home' },
// PuTTY // Cygwin & libuv
'[[5~' : { name : 'page up' }, '[[A' : { name : 'f1' },
'[[6~' : { name : 'page down' }, '[[B' : { name : 'f2' },
'[[C' : { name : 'f3' },
'[[D' : { name : 'f4' },
'[[E' : { name : 'f5' },
// rvxt // Common impls
'[7~' : { name : 'home' }, '[15~' : { name : 'f5' },
'[8~' : { name : 'end' }, '[17~' : { name : 'f6' },
'[18~' : { name : 'f7' },
'[19~' : { name : 'f8' },
'[20~' : { name : 'f9' },
'[21~' : { name : 'f10' },
'[23~' : { name : 'f11' },
'[24~' : { name : 'f12' },
// rxvt with modifiers // xterm
'[a' : { name : 'up arrow', shift : true }, '[A' : { name : 'up arrow' },
'[b' : { name : 'down arrow', shift : true }, '[B' : { name : 'down arrow' },
'[c' : { name : 'right arrow', shift : true }, '[C' : { name : 'right arrow' },
'[d' : { name : 'left arrow', shift : true }, '[D' : { name : 'left arrow' },
'[e' : { name : 'clear', shift : true }, '[E' : { name : 'clear' },
'[F' : { name : 'end' },
'[H' : { name : 'home' },
'[2$' : { name : 'insert', shift : true }, // PuTTY
'[3$' : { name : 'delete', shift : true }, '[[5~' : { name : 'page up' },
'[5$' : { name : 'page up', shift : true }, '[[6~' : { name : 'page down' },
'[6$' : { name : 'page down', shift : true },
'[7$' : { name : 'home', shift : true },
'[8$' : { name : 'end', shift : true },
'Oa' : { name : 'up arrow', ctrl : true }, // rvxt
'Ob' : { name : 'down arrow', ctrl : true }, '[7~' : { name : 'home' },
'Oc' : { name : 'right arrow', ctrl : true }, '[8~' : { name : 'end' },
'Od' : { name : 'left arrow', ctrl : true },
'Oe' : { name : 'clear', ctrl : true },
'[2^' : { name : 'insert', ctrl : true }, // rxvt with modifiers
'[3^' : { name : 'delete', ctrl : true }, '[a' : { name : 'up arrow', shift : true },
'[5^' : { name : 'page up', ctrl : true }, '[b' : { name : 'down arrow', shift : true },
'[6^' : { name : 'page down', ctrl : true }, '[c' : { name : 'right arrow', shift : true },
'[7^' : { name : 'home', ctrl : true }, '[d' : { name : 'left arrow', shift : true },
'[8^' : { name : 'end', ctrl : true }, '[e' : { name : 'clear', shift : true },
// SyncTERM / EtherTerm '[2$' : { name : 'insert', shift : true },
'[K' : { name : 'end' }, '[3$' : { name : 'delete', shift : true },
'[@' : { name : 'insert' }, '[5$' : { name : 'page up', shift : true },
'[V' : { name : 'page up' }, '[6$' : { name : 'page down', shift : true },
'[U' : { name : 'page down' }, '[7$' : { name : 'home', shift : true },
'[8$' : { name : 'end', shift : true },
// other 'Oa' : { name : 'up arrow', ctrl : true },
'[Z' : { name : 'tab', shift : true }, 'Ob' : { name : 'down arrow', ctrl : true },
}[code]; 'Oc' : { name : 'right arrow', ctrl : true },
}; 'Od' : { name : 'left arrow', ctrl : true },
'Oe' : { name : 'clear', ctrl : true },
this.on('data', function clientData(data) { '[2^' : { name : 'insert', ctrl : true },
// create a uniform format that can be parsed below '[3^' : { name : 'delete', ctrl : true },
if(data[0] > 127 && undefined === data[1]) { '[5^' : { name : 'page up', ctrl : true },
data[0] -= 128; '[6^' : { name : 'page down', ctrl : true },
data = '\u001b' + data.toString('utf-8'); '[7^' : { name : 'home', ctrl : true },
} else { '[8^' : { name : 'end', ctrl : true },
data = data.toString('utf-8');
}
if(self.isMouseInput(data)) { // SyncTERM / EtherTerm
return; '[K' : { name : 'end' },
} '[@' : { name : 'insert' },
'[V' : { name : 'page up' },
'[U' : { name : 'page down' },
var buf = []; // other
var m; '[Z' : { name : 'tab', shift : true },
while((m = RE_ESC_CODE_ANYWHERE.exec(data))) { }[code];
buf = buf.concat(data.slice(0, m.index).split('')); };
buf.push(m[0]);
data = data.slice(m.index + m[0].length);
}
buf = buf.concat(data.split('')); // remainder 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');
}
buf.forEach(function bufPart(s) { if(self.isMouseInput(data)) {
var key = { return;
seq : s, }
name : undefined,
ctrl : false,
meta : false,
shift : false,
};
var parts; 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);
}
if((parts = RE_DSR_RESPONSE_ANYWHERE.exec(s))) { buf = buf.concat(data.split('')); // remainder
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-<letter>
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; buf.forEach(function bufPart(s) {
var key = {
seq : s,
name : undefined,
ctrl : false,
meta : false,
shift : false,
};
key.ctrl = !!(modifier & 4); var parts;
key.meta = !!(modifier & 10);
key.shift = !!(modifier & 1);
key.code = code;
_.assign(key, self.getKeyComponentsFromCode(code)); 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-<letter>
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 ch; var modifier = (parts[3] || parts[8] || 1) - 1;
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.ctrl = !!(modifier & 4);
key = undefined; key.meta = !!(modifier & 10);
} else { key.shift = !!(modifier & 1);
// key.code = code;
// Adjust name for CTRL/Shift/Meta modifiers
//
key.name =
(key.ctrl ? 'ctrl + ' : '') +
(key.meta ? 'meta + ' : '') +
(key.shift ? 'shift + ' : '') +
key.name;
}
if(key || ch) { _.assign(key, self.getKeyComponentsFromCode(code));
if(Config.logging.traceUserKeyboardInput) { }
self.log.trace( { key : key, ch : escape(ch) }, 'User keyboard input'); // jshint ignore:line
}
self.lastKeyPressMs = Date.now(); 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(!self.ignoreInput) { if(_.isUndefined(key.name)) {
self.emit('key press', ch, key); 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); require('util').inherits(Client, stream);
Client.prototype.setInputOutput = function(input, output) { Client.prototype.setInputOutput = function(input, output) {
this.input = input; this.input = input;
this.output = output; this.output = output;
this.term = new term.ClientTerminal(this.output); this.term = new term.ClientTerminal(this.output);
}; };
Client.prototype.setTermType = function(termType) { Client.prototype.setTermType = function(termType) {
this.term.env.TERM = termType; this.term.env.TERM = termType;
this.term.termType = 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() { 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();
// let idleLogoutSeconds;
// Every 1m, check for idle. if(this.user.isAuthenticated()) {
// idleLogoutSeconds = Config().users.idleLogoutSeconds;
self.idleCheck = setInterval(function checkForIdle() {
const nowMs = Date.now();
const idleLogoutSeconds = self.user.isAuthenticated() ? //
Config.misc.idleLogoutSeconds : // We don't really want to be firing off an event every 1m for
Config.misc.preAuthIdleLogoutSeconds; // 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)) { if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
self.emit('idle timeout'); this.emit('idle timeout');
} }
}, 1000 * 60); }, 1000 * 60);
};
Client.prototype.stopIdleMonitor = function() {
clearInterval(this.idleCheck);
}; };
Client.prototype.end = function () { Client.prototype.end = function () {
if(this.term) { if(this.term) {
this.term.disconnect(); this.term.disconnect();
} }
var currentModule = this.menuStack.getCurrentModule; Events.removeListener(Events.getSystemEvents().ThemeChanged, this.themeChangedListener);
if(currentModule) { const currentModule = this.menuStack.getCurrentModule;
currentModule.leave();
}
clearInterval(this.idleCheck); if(currentModule) {
currentModule.leave();
}
try { // persist time online for authenticated users
// if(this.user.isAuthenticated()) {
// We can end up calling 'end' before TTY/etc. is established, e.g. with SSH this.user.persistProperty(
// UserProps.MinutesOnlineTotalCount,
// :TODO: is this OK? this.user.getProperty(UserProps.MinutesOnlineTotalCount)
return this.output.end.apply(this.output, arguments); );
} catch(e) { }
// TypeError
} 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 () { Client.prototype.destroy = function () {
return this.output.destroy.apply(this.output, arguments); return this.output.destroy.apply(this.output, arguments);
}; };
Client.prototype.destroySoon = function () { 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) { Client.prototype.waitForKeyPress = function(cb) {
this.once('key press', function kp(ch, key) { this.once('key press', function kp(ch, key) {
cb(ch, key); cb(ch, key);
}); });
}; };
Client.prototype.isLocal = function() { Client.prototype.isLocal = function() {
// :TODO: return rather client is a local connection or not // :TODO: Handle ipv6 better
return false; 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 // :TODO: getDefaultHandler(name) -- handlers in default_handlers.js or something
Client.prototype.defaultHandlerMissingMod = function(err) { Client.prototype.defaultHandlerMissingMod = function() {
var self = this; var self = this;
function handler(err) { function handler(err) {
self.log.error(err); self.log.error(err);
self.term.write(ansi.resetScreen()); self.term.write(ansi.resetScreen());
self.term.write('An unrecoverable error has been encountered!\n'); 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('This has been logged for your SysOp to review.\n');
self.term.write('\nGoodbye!\n'); self.term.write('\nGoodbye!\n');
//self.term.write(err); //self.term.write(err);
//if(miscUtil.isDevelopment() && err.stack) { //if(miscUtil.isDevelopment() && err.stack) {
// self.term.write('\n' + err.stack + '\n'); // self.term.write('\n' + err.stack + '\n');
//} //}
self.end(); self.end();
} }
return handler; return handler;
}; };
Client.prototype.terminalSupports = function(query) { Client.prototype.terminalSupports = function(query) {
const termClient = this.term.termClient; const termClient = this.term.termClient;
switch(query) { switch(query) {
case 'vtx_audio' : case 'vtx_audio' :
// https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt
return 'vtx' === termClient; return 'vtx' === termClient;
case 'vtx_hyperlink' : case 'vtx_hyperlink' :
return 'vtx' === termClient; return 'vtx' === termClient;
default : default :
return false; return false;
} }
}; };

View File

@ -1,106 +1,140 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const logger = require('./logger.js'); const logger = require('./logger.js');
const Events = require('./events.js'); const Events = require('./events.js');
const UserProps = require('./user_property.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const moment = require('moment'); const moment = require('moment');
const hashids = require('hashids');
exports.getActiveConnections = getActiveConnections; exports.getActiveConnections = getActiveConnections;
exports.getActiveNodeList = getActiveNodeList; exports.getActiveConnectionList = getActiveConnectionList;
exports.addNewClient = addNewClient; exports.addNewClient = addNewClient;
exports.removeClient = removeClient; exports.removeClient = removeClient;
exports.getConnectionByUserId = getConnectionByUserId; exports.getConnectionByUserId = getConnectionByUserId;
exports.getConnectionByNodeId = getConnectionByNodeId;
const clientConnections = []; 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)) { if(!_.isBoolean(authUsersOnly)) {
authUsersOnly = true; authUsersOnly = true;
} }
const now = moment(); const now = moment();
const activeConnections = getActiveConnections().filter(ac => { return _.map(getActiveConnections(authUsersOnly), ac => {
return ((authUsersOnly && ac.user.isAuthenticated()) || !authUsersOnly); 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 = { // There may be a connection, but not a logged in user as of yet
node : ac.node, //
authenticated : ac.user.isAuthenticated(), if(ac.user.isAuthenticated()) {
userId : ac.user.userId, entry.userName = ac.user.username;
action : _.has(ac, 'currentMenuModule.menuConfig.desc') ? ac.currentMenuModule.menuConfig.desc : 'Unknown', entry.realName = ac.user.properties[UserProps.RealName];
}; entry.location = ac.user.properties[UserProps.Location];
entry.affils = entry.affiliation = ac.user.properties[UserProps.Affiliations];
// const diff = now.diff(moment(ac.user.properties[UserProps.LastLoginTs]), 'minutes');
// There may be a connection, but not a logged in user as of yet entry.timeOn = moment.duration(diff, 'minutes');
// }
if(ac.user.isAuthenticated()) { return entry;
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;
});
} }
function addNewClient(client, clientSock) { 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 client.session.id = id;
// Note that this will be updated @ login with additional information const remoteAddress = client.remoteAddress = clientSock.remoteAddress;
client.log = logger.log.child( { clientId : id } ); // create a unique identifier one-time ID for this session
client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, moment().valueOf() ]);
const connInfo = { clientConnections.push(client);
remoteAddress : remoteAddress, clientConnections.sort( (c1, c2) => c1.session.id - c2.session.id);
serverName : client.session.serverName,
isSecure : client.session.isSecure,
};
if(client.log.debug()) { // Create a client specific logger
connInfo.port = clientSock.localPort; // Note that this will be updated @ login with additional information
connInfo.family = clientSock.localFamily; 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) { function removeClient(client) {
client.end(); client.end();
const i = clientConnections.indexOf(client); const i = clientConnections.indexOf(client);
if(i > -1) { if(i > -1) {
clientConnections.splice(i, 1); clientConnections.splice(i, 1);
logger.log.info( logger.log.info(
{ {
connectionCount : clientConnections.length, connectionCount : clientConnections.length,
clientId : client.session.id clientId : client.session.id
}, },
'Client disconnected' '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) { 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 );
} }

View File

@ -1,199 +1,189 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
var Log = require('./logger.js').log; var Log = require('./logger.js').log;
var enigmaToAnsi = require('./color_codes.js').enigmaToAnsi; var renegadeToAnsi = require('./color_codes.js').renegadeToAnsi;
var renegadeToAnsi = require('./color_codes.js').renegadeToAnsi;
var iconv = require('iconv-lite'); var iconv = require('iconv-lite');
var assert = require('assert'); var assert = require('assert');
var _ = require('lodash'); var _ = require('lodash');
exports.ClientTerminal = ClientTerminal; exports.ClientTerminal = ClientTerminal;
function ClientTerminal(output) { function ClientTerminal(output) {
this.output = output; this.output = output;
var self = this; var outputEncoding = 'cp437';
assert(iconv.encodingExists(outputEncoding));
var outputEncoding = 'cp437'; // convert line feeds such as \n -> \r\n
assert(iconv.encodingExists(outputEncoding)); 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';
// this.currentSyncFont = 'not_set';
// 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'; // Raw values set by e.g. telnet NAWS, ENVIRONMENT, etc.
this.env = {};
// Raw values set by e.g. telnet NAWS, ENVIRONMENT, etc. Object.defineProperty(this, 'outputEncoding', {
this.env = {}; get : function() {
return outputEncoding;
},
set : function(enc) {
if(iconv.encodingExists(enc)) {
outputEncoding = enc;
} else {
Log.warn({ encoding : enc }, 'Unknown encoding');
}
}
});
Object.defineProperty(this, 'outputEncoding', { Object.defineProperty(this, 'termType', {
get : function() { get : function() {
return outputEncoding; return termType;
}, },
set : function(enc) { set : function(ttype) {
if(iconv.encodingExists(enc)) { termType = ttype.toLowerCase();
outputEncoding = enc;
} else {
Log.warn({ encoding : enc }, 'Unknown encoding');
}
}
});
Object.defineProperty(this, 'termType', { if(this.isANSI()) {
get : function() { this.outputEncoding = 'cp437';
return termType; } else {
}, // :TODO: See how x84 does this -- only set if local/remote are binary
set : function(ttype) { this.outputEncoding = 'utf8';
termType = ttype.toLowerCase(); }
if(this.isANSI()) { // :TODO: according to this: http://mud-dev.wikidot.com/article:telnet-client-identification
this.outputEncoding = 'cp437'; // Windows telnet will send "VTNT". If so, set termClient='windows'
} else { // there are some others on the page as well
// :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 Log.debug( { encoding : this.outputEncoding }, 'Set output encoding due to terminal type change');
// 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'); Object.defineProperty(this, 'termWidth', {
} get : function() {
}); return termWidth;
},
set : function(width) {
if(width > 0) {
termWidth = width;
}
}
});
Object.defineProperty(this, 'termWidth', { Object.defineProperty(this, 'termHeight', {
get : function() { get : function() {
return termWidth; return termHeight;
}, },
set : function(width) { set : function(height) {
if(width > 0) { if(height > 0) {
termWidth = width; termHeight = height;
} }
} }
}); });
Object.defineProperty(this, 'termHeight', { Object.defineProperty(this, 'termClient', {
get : function() { get : function() {
return termHeight; return termClient;
}, },
set : function(height) { set : function(tc) {
if(height > 0) { termClient = tc;
termHeight = height;
}
}
});
Object.defineProperty(this, 'termClient', { Log.debug( { termClient : this.termClient }, 'Set known terminal client');
get : function() { }
return termClient; });
},
set : function(tc) {
termClient = tc;
Log.debug( { termClient : this.termClient }, 'Set known terminal client');
}
});
} }
ClientTerminal.prototype.disconnect = function() { ClientTerminal.prototype.disconnect = function() {
this.output = null; this.output = null;
}; };
ClientTerminal.prototype.isNixTerm = function() { ClientTerminal.prototype.isNixTerm = function() {
// //
// Standard *nix type terminals // Standard *nix type terminals
// //
if(this.termType.startsWith('xterm')) { if(this.termType.startsWith('xterm')) {
return true; 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() { ClientTerminal.prototype.isANSI = function() {
// //
// ANSI terminals should be encoded to CP437 // ANSI terminals should be encoded to CP437
// //
// Some terminal types provided by Mercyful Fate / Enthral: // Some terminal types provided by Mercyful Fate / Enthral:
// ANSI-BBS // ANSI-BBS
// PC-ANSI // PC-ANSI
// QANSI // QANSI
// SCOANSI // SCOANSI
// VT100 // VT100
// QNX // QNX
// //
// Reports from various terminals // Reports from various terminals
// //
// syncterm: // syncterm:
// * SyncTERM // * SyncTERM
// //
// xterm: // xterm:
// * PuTTY // * PuTTY
// //
// ansi-bbs: // ansi-bbs:
// * fTelnet // * fTelnet
// //
// pcansi: // pcansi:
// * ZOC // * ZOC
// //
// screen: // screen:
// * ConnectBot (Android) // * ConnectBot (Android)
// //
// linux: // linux:
// * JuiceSSH (note: TERM=linux also) // * JuiceSSH (note: TERM=linux also)
// //
return [ 'ansi', 'pcansi', 'pc-ansi', 'ansi-bbs', 'qansi', 'scoansi', 'syncterm' ].includes(this.termType); 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) { 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) { ClientTerminal.prototype.rawWrite = function(s, cb) {
if(this.output) { if(this.output) {
this.output.write(s, err => { this.output.write(s, err => {
if(cb) { if(cb) {
return cb(err); return cb(err);
} }
if(err) { if(err) {
Log.warn( { error : err.message }, 'Failed writing to socket'); Log.warn( { error : err.message }, 'Failed writing to socket');
} }
}); });
} }
}; };
ClientTerminal.prototype.pipeWrite = function(s, spec, cb) { ClientTerminal.prototype.pipeWrite = function(s, cb) {
spec = spec || 'renegade'; this.write(renegadeToAnsi(s, this), null, cb); // null = use default for |convertLineFeeds|
var conv = {
enigma : enigmaToAnsi,
renegade : renegadeToAnsi,
}[spec] || renegadeToAnsi;
this.write(conv(s, this), null, cb); // null = use default for |convertLineFeeds|
}; };
ClientTerminal.prototype.encode = function(s, convertLineFeeds) { ClientTerminal.prototype.encode = function(s, convertLineFeeds) {
convertLineFeeds = _.isBoolean(convertLineFeeds) ? convertLineFeeds : this.convertLF; convertLineFeeds = _.isBoolean(convertLineFeeds) ? convertLineFeeds : this.convertLF;
if(convertLineFeeds && _.isString(s)) { if(convertLineFeeds && _.isString(s)) {
s = s.replace(/\n/g, '\r\n'); s = s.replace(/\n/g, '\r\n');
} }
return iconv.encode(s, this.outputEncoding); return iconv.encode(s, this.outputEncoding);
}; };

View File

@ -1,292 +1,273 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var ansi = require('./ansi_term.js'); const ANSI = require('./ansi_term.js');
var getPredefinedMCIValue = require('./predefined_mci.js').getPredefinedMCIValue; const { getPredefinedMCIValue } = require('./predefined_mci.js');
var assert = require('assert'); // deps
var _ = require('lodash'); const _ = require('lodash');
exports.enigmaToAnsi = enigmaToAnsi; exports.stripMciColorCodes = stripMciColorCodes;
exports.stripPipeCodes = exports.stripEnigmaCodes = stripEnigmaCodes; exports.pipeStringLength = pipeStringLength;
exports.pipeStrLen = exports.enigmaStrLen = enigmaStrLen; exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi;
exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi; exports.controlCodesToAnsi = controlCodesToAnsi;
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?
function stripMciColorCodes(s) {
return s.replace(/\|[A-Z\d]{2}/g, '');
// Also add:
// * fromCelerity(): |<case sensitive letter>
// * fromPCBoard(): (@X<bg><fg>)
// * fromWildcat(): (@<bg><fg>@ (same as PCBoard without 'X' prefix and '@' suffix)
// * fromWWIV(): <ctrl-c><0-7>
// * fromSyncronet(): <ctrl-a><colorCode>
// 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 stripEnigmaCodes(s) { function pipeStringLength(s) {
return s.replace(/\|[A-Z\d]{2}/g, ''); return stripMciColorCodes(s).length;
}
function enigmaStrLen(s) {
return stripEnigmaCodes(s).length;
} }
function ansiSgrFromRenegadeColorCode(cc) { function ansiSgrFromRenegadeColorCode(cc) {
return ansi.sgr({ return ANSI.sgr({
0 : [ 'reset', 'black' ], 0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ], 1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ], 2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ], 3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ], 4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ], 5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ], 6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ], 7 : [ 'reset', 'white' ],
8 : [ 'bold', 'black' ], 8 : [ 'bold', 'black' ],
9 : [ 'bold', 'blue' ], 9 : [ 'bold', 'blue' ],
10 : [ 'bold', 'green' ], 10 : [ 'bold', 'green' ],
11 : [ 'bold', 'cyan' ], 11 : [ 'bold', 'cyan' ],
12 : [ 'bold', 'red' ], 12 : [ 'bold', 'red' ],
13 : [ 'bold', 'magenta' ], 13 : [ 'bold', 'magenta' ],
14 : [ 'bold', 'yellow' ], 14 : [ 'bold', 'yellow' ],
15 : [ 'bold', 'white' ], 15 : [ 'bold', 'white' ],
16 : [ 'blackBG' ], 16 : [ 'blackBG' ],
17 : [ 'blueBG' ], 17 : [ 'blueBG' ],
18 : [ 'greenBG' ], 18 : [ 'greenBG' ],
19 : [ 'cyanBG' ], 19 : [ 'cyanBG' ],
20 : [ 'redBG' ], 20 : [ 'redBG' ],
21 : [ 'magentaBG' ], 21 : [ 'magentaBG' ],
22 : [ 'yellowBG' ], 22 : [ 'yellowBG' ],
23 : [ 'whiteBG' ], 23 : [ 'whiteBG' ],
24 : [ 'bold', 'blackBG' ], 24 : [ 'blink', 'blackBG' ],
25 : [ 'bold', 'blueBG' ], 25 : [ 'blink', 'blueBG' ],
26 : [ 'bold', 'greenBG' ], 26 : [ 'blink', 'greenBG' ],
27 : [ 'bold', 'cyanBG' ], 27 : [ 'blink', 'cyanBG' ],
28 : [ 'bold', 'redBG' ], 28 : [ 'blink', 'redBG' ],
29 : [ 'bold', 'magentaBG' ], 29 : [ 'blink', 'magentaBG' ],
30 : [ 'bold', 'yellowBG' ], 30 : [ 'blink', 'yellowBG' ],
31 : [ 'bold', 'whiteBG' ], 31 : [ 'blink', 'whiteBG' ],
}[cc] || 'normal'); }[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) { function renegadeToAnsi(s, client) {
if(-1 == s.indexOf('|')) { if(-1 == s.indexOf('|')) {
return s; // no pipe codes present return s; // no pipe codes present
} }
var result = ''; let result = '';
var re = /\|([A-Z\d]{2}|\|)/g; const re = /\|(?:(C[FBUD])([0-9]{1,2})|([0-9]{2})|([A-Z]{2})|(\|))/g;
var m; let m;
var lastIndex = 0; let lastIndex = 0;
while((m = re.exec(s))) { while((m = re.exec(s))) {
var val = m[1]; 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) { lastIndex = re.lastIndex;
result += '|'; }
continue;
}
// convert to number return (0 === result.length ? s : result + s.substr(lastIndex));
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));
} }
// //
// Converts various control codes popular in BBS packages // Converts various control codes popular in BBS packages
// to ANSI escape sequences. Additionaly supports ENiGMA style // to ANSI escape sequences. Additionaly supports ENiGMA style
// MCI codes. // MCI codes.
// //
// Supported control code formats: // Supported control code formats:
// * Renegade : |## // * Renegade : |##
// * PCBoard : @X## where the first number/char is FG color, and second is BG // * 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 // * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix
// * WWIV : ^# // * 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: // Resources:
// * http://wiki.synchro.net/custom:colors // * http://wiki.synchro.net/custom:colors
// //
function controlCodesToAnsi(s, client) { 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 m;
let result = ''; let result = '';
let lastIndex = 0; let lastIndex = 0;
let v; let v;
let fg; let fg;
let bg; let bg;
while((m = RE.exec(s))) { while((m = RE.exec(s))) {
switch(m[0].charAt(0)) { switch(m[0].charAt(0)) {
case '|' : case '|' :
// Renegade or ENiGMA MCI // Renegade |##
v = parseInt(m[2], 10); v = parseInt(m[2], 10);
if(isNaN(v)) { if(isNaN(v)) {
v = getPredefinedMCIValue(client, m[2]) || m[0]; // value itself or literal v = getPredefinedMCIValue(client, m[2]) || m[0]; // value itself or literal
} }
if(_.isString(v)) { if(_.isString(v)) {
result += s.substr(lastIndex, m.index - lastIndex) + v; result += s.substr(lastIndex, m.index - lastIndex) + v;
} else { } else {
v = ansiSgrFromRenegadeColorCode(v); v = ansiSgrFromRenegadeColorCode(v);
result += s.substr(lastIndex, m.index - lastIndex) + v; result += s.substr(lastIndex, m.index - lastIndex) + v;
} }
break; break;
case '@' : case '@' :
// PCBoard @X## or Wildcat! @##@ // PCBoard @X## or Wildcat! @##@
if('@' === m[0].substr(-1)) { if('@' === m[0].substr(-1)) {
// Wildcat! // Wildcat!
v = m[6]; v = m[6];
} else { } else {
v = m[4]; v = m[4];
} }
fg = { bg = {
0 : [ 'reset', 'black' ], 0 : [ 'blackBG' ],
1 : [ 'reset', 'blue' ], 1 : [ 'blueBG' ],
2 : [ 'reset', 'green' ], 2 : [ 'greenBG' ],
3 : [ 'reset', 'cyan' ], 3 : [ 'cyanBG' ],
4 : [ 'reset', 'red' ], 4 : [ 'redBG' ],
5 : [ 'reset', 'magenta' ], 5 : [ 'magentaBG' ],
6 : [ 'reset', 'yellow' ], 6 : [ 'yellowBG' ],
7 : [ 'reset', 'white' ], 7 : [ 'whiteBG' ],
8 : [ 'blink', 'black' ], 8 : [ 'bold', 'blackBG' ],
9 : [ 'blink', 'blue' ], 9 : [ 'bold', 'blueBG' ],
A : [ 'blink', 'green' ], A : [ 'bold', 'greenBG' ],
B : [ 'blink', 'cyan' ], B : [ 'bold', 'cyanBG' ],
C : [ 'blink', 'red' ], C : [ 'bold', 'redBG' ],
D : [ 'blink', 'magenta' ], D : [ 'bold', 'magentaBG' ],
E : [ 'blink', 'yellow' ], E : [ 'bold', 'yellowBG' ],
F : [ 'blink', 'white' ], F : [ 'bold', 'whiteBG' ],
}[v.charAt(0)] || ['normal']; }[v.charAt(0)] || [ 'normal' ];
bg = { fg = {
0 : [ 'blackBG' ], 0 : [ 'reset', 'black' ],
1 : [ 'blueBG' ], 1 : [ 'reset', 'blue' ],
2 : [ 'greenBG' ], 2 : [ 'reset', 'green' ],
3 : [ 'cyanBG' ], 3 : [ 'reset', 'cyan' ],
4 : [ 'redBG' ], 4 : [ 'reset', 'red' ],
5 : [ 'magentaBG' ], 5 : [ 'reset', 'magenta' ],
6 : [ 'yellowBG' ], 6 : [ 'reset', 'yellow' ],
7 : [ 'whiteBG' ], 7 : [ 'reset', 'white' ],
8 : [ 'bold', 'blackBG' ], 8 : [ 'blink', 'black' ],
9 : [ 'bold', 'blueBG' ], 9 : [ 'blink', 'blue' ],
A : [ 'bold', 'greenBG' ], A : [ 'blink', 'green' ],
B : [ 'bold', 'cyanBG' ], B : [ 'blink', 'cyan' ],
C : [ 'bold', 'redBG' ], C : [ 'blink', 'red' ],
D : [ 'bold', 'magentaBG' ], D : [ 'blink', 'magenta' ],
E : [ 'bold', 'yellowBG' ], E : [ 'blink', 'yellow' ],
F : [ 'bold', 'whiteBG' ], F : [ 'blink', 'white' ],
}[v.charAt(1)] || [ 'normal' ]; }[v.charAt(1)] || ['normal'];
v = ansi.sgr(fg.concat(bg)); v = ANSI.sgr(fg.concat(bg));
result += s.substr(lastIndex, m.index - lastIndex) + v; result += s.substr(lastIndex, m.index - lastIndex) + v;
break; break;
case '\x03' : case '\x03' :
v = parseInt(m[8], 10); // WWIV
v = parseInt(m[8], 10);
if(isNaN(v)) { if(isNaN(v)) {
v += m[0]; v += m[0];
} else { } else {
v = ansi.sgr({ v = ANSI.sgr({
0 : [ 'reset', 'black' ], 0 : [ 'reset', 'black' ],
1 : [ 'bold', 'cyan' ], 1 : [ 'bold', 'cyan' ],
2 : [ 'bold', 'yellow' ], 2 : [ 'bold', 'yellow' ],
3 : [ 'reset', 'magenta' ], 3 : [ 'reset', 'magenta' ],
4 : [ 'bold', 'white', 'blueBG' ], 4 : [ 'bold', 'white', 'blueBG' ],
5 : [ 'reset', 'green' ], 5 : [ 'reset', 'green' ],
6 : [ 'bold', 'blink', 'red' ], 6 : [ 'bold', 'blink', 'red' ],
7 : [ 'bold', 'blue' ], 7 : [ 'bold', 'blue' ],
8 : [ 'reset', 'blue' ], 8 : [ 'reset', 'blue' ],
9 : [ 'reset', 'cyan' ], 9 : [ 'reset', 'cyan' ],
}[v] || 'normal'); }[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));
} }

View File

@ -1,83 +1,98 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// enigma-bbs // enigma-bbs
const MenuModule = require('../core/menu_module.js').MenuModule; const { MenuModule } = require('../core/menu_module.js');
const resetScreen = require('../core/ansi_term.js').resetScreen; const { resetScreen } = require('../core/ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash');
const RLogin = require('rlogin'); const RLogin = require('rlogin');
exports.moduleInfo = { exports.moduleInfo = {
name : 'CombatNet', name : 'CombatNet',
desc : 'CombatNet Access Module', desc : 'CombatNet Access Module',
author : 'Dave Stephens', author : 'Dave Stephens',
}; };
exports.getModule = class CombatNetModule extends MenuModule { exports.getModule = class CombatNetModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
// establish defaults // establish defaults
this.config = options.menuConfig.config; this.config = options.menuConfig.config;
this.config.host = this.config.host || 'bbs.combatnet.us'; this.config.host = this.config.host || 'bbs.combatnet.us';
this.config.rloginPort = this.config.rloginPort || 4513; this.config.rloginPort = this.config.rloginPort || 4513;
} }
initSequence() { initSequence() {
const self = this; const self = this;
async.series( async.series(
[ [
function validateConfig(callback) { function validateConfig(callback) {
if(!_.isString(self.config.password)) { return self.validateConfigFields(
return callback(new Error('Config requires "password"!')); {
} host : 'string',
if(!_.isString(self.config.bbsTag)) { password : 'string',
return callback(new Error('Config requires "bbsTag"!')); bbsTag : 'string',
} rloginPort : 'number',
return callback(null); },
}, callback
function establishRloginConnection(callback) { );
self.client.term.write(resetScreen()); },
self.client.term.write('Connecting to CombatNet, please wait...\n'); function establishRloginConnection(callback) {
self.client.term.write(resetScreen());
self.client.term.write('Connecting to CombatNet, please wait...\n');
const restorePipeToNormal = function() { let doorTracking;
self.client.term.output.removeListener('data', sendToRloginBuffer);
}; const restorePipeToNormal = function() {
if(self.client.term.output) {
self.client.term.output.removeListener('data', sendToRloginBuffer);
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
}
};
const rlogin = new RLogin( const rlogin = new RLogin(
{ 'clientUsername' : self.config.password, {
'serverUsername' : `${self.config.bbsTag}${self.client.user.username}`, clientUsername : self.config.password,
'host' : self.config.host, serverUsername : `${self.config.bbsTag}${self.client.user.username}`,
'port' : self.config.rloginPort, host : self.config.host,
'terminalType' : self.client.term.termClient, port : self.config.rloginPort,
'terminalSpeed' : 57600 terminalType : self.client.term.termClient,
terminalSpeed : 57600
} }
); );
// If there was an error ... // If there was an error ...
rlogin.on('error', err => { rlogin.on('error', err => {
self.client.log.info(`CombatNet rlogin client error: ${err.message}`); self.client.log.info(`CombatNet rlogin client error: ${err.message}`);
restorePipeToNormal(); restorePipeToNormal();
callback(err); return callback(err);
}); });
// If we've been disconnected ... // If we've been disconnected ...
rlogin.on('disconnect', () => { rlogin.on('disconnect', () => {
self.client.log.info(`Disconnected from CombatNet`); self.client.log.info('Disconnected from CombatNet');
restorePipeToNormal(); restorePipeToNormal();
callback(null); return callback(null);
}); });
function sendToRloginBuffer(buffer) { function sendToRloginBuffer(buffer) {
rlogin.send(buffer); rlogin.send(buffer);
}; }
rlogin.on("connect", rlogin.on('connect',
/* The 'connect' event handler will be supplied with one argument, /* The 'connect' event handler will be supplied with one argument,
a boolean indicating whether or not the connection was established. */ a boolean indicating whether or not the connection was established. */
function(state) { function(state) {
@ -85,31 +100,32 @@ exports.getModule = class CombatNetModule extends MenuModule {
self.client.log.info('Connected to CombatNet'); self.client.log.info('Connected to CombatNet');
self.client.term.output.on('data', sendToRloginBuffer); self.client.term.output.on('data', sendToRloginBuffer);
doorTracking = trackDoorRunBegin(self.client);
} else { } 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 ... // If data (a Buffer) has been received from the server ...
rlogin.on("data", (data) => { rlogin.on('data', (data) => {
self.client.term.rawWrite(data); self.client.term.rawWrite(data);
}); });
// connect... // connect...
rlogin.connect(); rlogin.connect();
// note: no explicit callback() until we're finished! // note: no explicit callback() until we're finished!
} }
], ],
err => { err => {
if(err) { if(err) {
self.client.log.warn( { error : err.message }, 'CombatNet error'); self.client.log.warn( { error : err.message }, 'CombatNet error');
} }
// if the client is still here, go to previous // if the client is still here, go to previous
self.prevMenu(); self.prevMenu();
} }
); );
} }
}; };

View File

@ -1,30 +1,30 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
exports.sortAreasOrConfs = sortAreasOrConfs; exports.sortAreasOrConfs = sortAreasOrConfs;
// //
// Method for sorting message, file, etc. areas and confs // Method for sorting message, file, etc. areas and confs
// If the sort key is present and is a number, sort in numerical order; // 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 // Otherwise, use a locale comparison on the sort key or name as a fallback
// //
function sortAreasOrConfs(areasOrConfs, type) { function sortAreasOrConfs(areasOrConfs, type) {
let entryA; let entryA;
let entryB; let entryB;
areasOrConfs.sort((a, b) => { areasOrConfs.sort((a, b) => {
entryA = type ? a[type] : a; entryA = type ? a[type] : a;
entryB = type ? b[type] : b; entryB = type ? b[type] : b;
if(_.isNumber(entryA.sort) && _.isNumber(entryB.sort)) { if(_.isNumber(entryA.sort) && _.isNumber(entryB.sort)) {
return entryA.sort - entryB.sort; return entryA.sort - entryB.sort;
} else { } else {
const keyA = entryA.sort ? entryA.sort.toString() : entryA.name; const keyA = entryA.sort ? entryA.sort.toString() : entryA.name;
const keyB = entryB.sort ? entryB.sort.toString() : entryB.name; const keyB = entryB.sort ? entryB.sort.toString() : entryB.name;
return keyA.localeCompare(keyB, { sensitivity : false, numeric : true } ); // "natural" compare return keyA.localeCompare(keyB, { sensitivity : false, numeric : true } ); // "natural" compare
} }
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,85 +1,76 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var Config = require('./config.js').config; // deps
var Log = require('./logger.js').log; const paths = require('path');
const fs = require('graceful-fs');
const hjson = require('hjson');
const sane = require('sane');
var paths = require('path'); module.exports = new class ConfigCache
var fs = require('graceful-fs'); {
var events = require('events'); constructor() {
var util = require('util'); this.cache = new Map(); // path->parsed config
var assert = require('assert'); }
var hjson = require('hjson');
var _ = require('lodash');
function ConfigCache() { getConfigWithOptions(options, cb) {
events.EventEmitter.call(this); const cached = this.cache.has(options.filePath);
var self = this; if(options.forceReCache || !cached) {
this.cache = {}; // filePath -> HJSON this.recacheConfigFromFile(options.filePath, (err, config) => {
//this.gaze = new Gaze(); if(!err && !cached) {
if(!options.noWatch) {
const watcher = sane(
paths.dirname(options.filePath),
{
glob : `**/${paths.basename(options.filePath)}`
}
);
this.reCacheConfigFromFile = function(filePath, cb) { watcher.on('change', (fileName, fileRoot) => {
fs.readFile(filePath, { encoding : 'utf-8' }, function fileRead(err, data) { require('./logger.js').log.info( { fileName, fileRoot }, 'Configuration file changed; re-caching');
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);
}
});
};
/* this.recacheConfigFromFile(paths.join(fileRoot, fileName), err => {
this.gaze.on('error', function gazeErr(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) { recacheConfigFromFile(path, cb) {
assert(filePath in self.cache); 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) { return cb(null, parsed);
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);
}
}; };
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();

View File

@ -1,18 +1,67 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; '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) { function getFullConfig(filePath, cb) {
// |filePath| is assumed to be in the config path if it's only a file name ConfigCache.getConfig(getConfigPath(filePath), (err, config) => {
if('.' === paths.dirname(filePath)) { return cb(err, config);
filePath = paths.join(Config.paths.config, filePath); });
}
configCache.getConfig(filePath, function loaded(err, configJson) {
cb(err, configJson);
});
} }

View File

@ -1,187 +1,266 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const Events = require('./events.js'); const Events = require('./events.js');
const { Errors } = require('./enig_error.js');
// deps // deps
const async = require('async'); const async = require('async');
exports.connectEntry = connectEntry; exports.connectEntry = connectEntry;
function ansiDiscoverHomePosition(client, cb) { function ansiDiscoverHomePosition(client, cb) {
// //
// We want to find the home position. ANSI-BBS and most terminals // We want to find the home position. ANSI-BBS and most terminals
// utilize 1,1 as home. However, some terminals such as ConnectBot // 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 // think of home as 0,0. If this is the case, we need to offset
// our positioning to accomodate for such. // our positioning to accommodate for such.
// //
const done = function(err) { const done = function(err) {
client.removeListener('cursor position report', cprListener); client.removeListener('cursor position report', cprListener);
clearTimeout(giveUpTimer); clearTimeout(giveUpTimer);
return cb(err); return cb(err);
}; };
const cprListener = function(pos) { const cprListener = function(pos) {
const h = pos[0]; const h = pos[0];
const w = pos[1]; const w = pos[1];
// //
// We expect either 0,0, or 1,1. Anything else will be filed as bad data // We expect either 0,0, or 1,1. Anything else will be filed as bad data
// //
if(h > 1 || w > 1) { if(h > 1 || w > 1) {
client.log.warn( { height : h, width : w }, 'Ignoring ANSI home position CPR due to unexpected values'); 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')); return done(Errors.UnexpectedState('Home position CPR expected to be 0,0, or 1,1'));
} }
if(0 === h & 0 === w) { if(0 === h & 0 === w) {
// //
// Store a CPR offset in the client. All CPR's from this point on will offset by this amount // 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.log.info('Setting CPR offset to 1');
client.cprOffset = 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( () => { const giveUpTimer = setTimeout( () => {
return done(new Error('Giving up on home position CPR')); return done(Errors.General('Giving up on home position CPR'));
}, 3000); // 3s }, 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) { function ansiQueryTermSizeIfNeeded(client, cb) {
if(client.term.termHeight > 0 || client.term.termWidth > 0) { if(client.term.termHeight > 0 || client.term.termWidth > 0) {
return cb(null); return cb(null);
} }
const done = function(err) { const done = function(err) {
client.removeListener('cursor position report', cprListener); client.removeListener('cursor position report', cprListener);
clearTimeout(giveUpTimer); clearTimeout(giveUpTimer);
return cb(err); return cb(err);
}; };
const cprListener = function(pos) { const cprListener = function(pos) {
// //
// If we've already found out, disregard // If we've already found out, disregard
// //
if(client.term.termHeight > 0 || client.term.termWidth > 0) { if(client.term.termHeight > 0 || client.term.termWidth > 0) {
return done(null); return done(null);
} }
const h = pos[0]; const h = pos[0];
const w = pos[1]; const w = pos[1];
// //
// Netrunner for example gives us 1x1 here. Not really useful. Ignore // NetRunner for example gives us 1x1 here. Not really useful. Ignore
// values that seem obviously bad. // values that seem obviously bad. Included in the set is the explicit
// // 999x999 values we asked to move to.
if(h < 10 || w < 10) { //
client.log.warn( if(h < 10 || h === 999 || w < 10 || w === 999) {
{ height : h, width : w }, client.log.warn(
'Ignoring ANSI CPR screen size query response due to very small values'); { height : h, width : w },
return done(new Error('Term size <= 10 considered invalid')); '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.termHeight = h;
client.term.termWidth = w; client.term.termWidth = w;
client.log.debug( client.log.debug(
{ {
termWidth : client.term.termWidth, termWidth : client.term.termWidth,
termHeight : client.term.termHeight, termHeight : client.term.termHeight,
source : 'ANSI CPR' source : 'ANSI CPR'
}, },
'Window size updated' '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 // give up after 2s
const giveUpTimer = setTimeout( () => { const giveUpTimer = setTimeout( () => {
return done(new Error('No term size established by CPR within timeout')); return done(Errors.General('No term size established by CPR within timeout'));
}, 2000); }, 2000);
// Start the process: Query for CPR // Start the process:
client.term.rawWrite(ansi.queryScreenSize()); // 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) { function prepareTerminal(term) {
term.rawWrite(ansi.normal()); term.rawWrite(`${ansi.normal()}${ansi.clearScreen()}`);
//term.rawWrite(ansi.disableVT100LineWrapping());
// :TODO: set xterm stuff -- see x84/others
} }
function displayBanner(term) { function displayBanner(term) {
// note: intentional formatting: // note: intentional formatting:
term.pipeWrite(` term.pipeWrite(`
|06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN |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/ |06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/
|00` |00`
); );
} }
function connectEntry(client, nextMenu) { function connectEntry(client, nextMenu) {
const term = client.term; const term = client.term;
async.series( async.series(
[ [
function basicPrepWork(callback) { function basicPrepWork(callback) {
term.rawWrite(ansi.queryDeviceAttributes(0)); term.rawWrite(ansi.queryDeviceAttributes(0));
return callback(null); return callback(null);
}, },
function discoverHomePosition(callback) { function discoverHomePosition(callback) {
ansiDiscoverHomePosition(client, () => { ansiDiscoverHomePosition(client, () => {
// :TODO: If CPR for home fully fails, we should bail out on the connection with an error, e.g. ANSI support required // :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 return callback(null); // we try to continue anyway
}); });
}, },
function queryTermSizeByNonStandardAnsi(callback) { function queryTermSizeByNonStandardAnsi(callback) {
ansiQueryTermSizeIfNeeded(client, err => { ansiQueryTermSizeIfNeeded(client, err => {
if(err) { if(err) {
// //
// Check again; We may have got via NAWS/similar before CPR completed. // Check again; We may have got via NAWS/similar before CPR completed.
// //
if(0 === term.termHeight || 0 === term.termWidth) { if(0 === term.termHeight || 0 === term.termWidth) {
// //
// We still don't have something good for term height/width. // We still don't have something good for term height/width.
// Default to DOS size 80x25. // Default to DOS size 80x25.
// //
// :TODO: Netrunner is currenting hitting this and it feels wrong. Why is NAWS/ENV/CPR all failing??? // :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!'); client.log.warn( { reason : err.message }, 'Failed to negotiate term size; Defaulting to 80x25!');
term.termHeight = 25; term.termHeight = 25;
term.termWidth = 80; term.termWidth = 80;
} }
} }
return callback(null); return callback(null);
}); });
}, },
], function checkUtf8IfNeeded(callback) {
() => { return ansiAttemptDetectUTF8(client, callback);
prepareTerminal(term); }
],
() => {
prepareTerminal(term);
// //
// Always show an ENiGMA½ banner // Always show an ENiGMA½ banner
// //
displayBanner(term); displayBanner(term);
// fire event // fire event
Events.emit('codes.l33t.enigma.system.term_detected', { client : client } ); Events.emit(Events.getSystemEvents().TermDetected, { client : client } );
setTimeout( () => { setTimeout( () => {
return client.menuStack.goto(nextMenu); return client.menuStack.goto(nextMenu);
}, 500); }, 500);
} }
); );
} }

View File

@ -1,54 +1,91 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const CRC32_TABLE = new Int32Array( 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))); 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 { exports.CRC32 = class CRC32 {
constructor() { constructor() {
this.crc = -1; this.crc = -1;
} }
update(input) { update(input) {
input = Buffer.isBuffer(input) ? input : Buffer.from(input, 'binary'); input = Buffer.isBuffer(input) ? input : Buffer.from(input, 'binary');
return input.length > 10240 ? this.update_8(input) : this.update_4(input); return input.length > 10240 ? this.update_8(input) : this.update_4(input);
} }
update_4(input) { update_4(input) {
const len = input.length - 3; const len = input.length - 3;
let i = 0; let i = 0;
for(i = 0; i < len;) { 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 + 3) { while(i < len + 3) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ]; this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
} }
} }
update_8(input) { update_8(input) {
const len = input.length - 7; const len = input.length - 7;
let i = 0; let i = 0;
for(i = 0; i < len;) { 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 ];
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) { while(i < len + 7) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ]; this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
} }
} }
finalize() { finalize() {
return (this.crc ^ (-1)) >>> 0; return (this.crc ^ (-1)) >>> 0;
} }
}; };

View File

@ -1,388 +1,433 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const conf = require('./config.js'); const conf = require('./config.js');
// deps // deps
const sqlite3 = require('sqlite3'); const sqlite3 = require('sqlite3');
const sqlite3Trans = require('sqlite3-trans'); const sqlite3Trans = require('sqlite3-trans');
const paths = require('path'); const paths = require('path');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const assert = require('assert'); const assert = require('assert');
const moment = require('moment'); const moment = require('moment');
// database handles // database handles
const dbs = {}; const dbs = {};
exports.getTransactionDatabase = getTransactionDatabase; exports.getTransactionDatabase = getTransactionDatabase;
exports.getModDatabasePath = getModDatabasePath; exports.getModDatabasePath = getModDatabasePath;
exports.getISOTimestampString = getISOTimestampString; exports.loadDatabaseForMod = loadDatabaseForMod;
exports.initializeDatabases = initializeDatabases; exports.getISOTimestampString = getISOTimestampString;
exports.sanitizeString = sanitizeString;
exports.initializeDatabases = initializeDatabases;
exports.dbs = dbs; exports.dbs = dbs;
function getTransactionDatabase(db) { function getTransactionDatabase(db) {
return sqlite3Trans.wrap(db); return sqlite3Trans.wrap(db);
} }
function getDatabasePath(name) { 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) { function getModDatabasePath(moduleInfo, suffix) {
// //
// Mods that use a database are stored in Config.paths.modsDb (e.g. enigma-bbs/db/mods) // 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 // We expect that moduleInfo defines packageName which will be the base of the modules
// filename. An optional suffix may be supplied as well. // 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])$/; 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(_.isObject(moduleInfo));
assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!'); assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!');
let full = moduleInfo.packageName; let full = moduleInfo.packageName;
if(suffix) { if(suffix) {
full += `.${suffix}`; full += `.${suffix}`;
} }
assert( assert(
(full.split('.').length > 1 && HOST_RE.test(full)), (full.split('.').length > 1 && HOST_RE.test(full)),
'packageName must follow Reverse Domain Name Notation - https://en.wikipedia.org/wiki/Reverse_domain_name_notation'); '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`); 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) { function getISOTimestampString(ts) {
ts = ts || moment(); ts = ts || moment();
return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); 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) { function initializeDatabases(cb) {
async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => {
dbs[dbName] = sqlite3Trans.wrap(new sqlite3.Database(getDatabasePath(dbName), err => { dbs[dbName] = sqlite3Trans.wrap(new sqlite3.Database(getDatabasePath(dbName), err => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
dbs[dbName].serialize( () => { dbs[dbName].serialize( () => {
DB_INIT_TABLE[dbName]( () => { DB_INIT_TABLE[dbName]( () => {
return next(null); return next(null);
}); });
}); });
})); }));
}, err => { }, err => {
return cb(err); return cb(err);
}); });
} }
function enableForeignKeys(db) { function enableForeignKeys(db) {
db.run('PRAGMA foreign_keys = ON;'); db.run('PRAGMA foreign_keys = ON;');
} }
const DB_INIT_TABLE = { const DB_INIT_TABLE = {
system : (cb) => { system : (cb) => {
enableForeignKeys(dbs.system); enableForeignKeys(dbs.system);
// Various stat/event logging - see stat_log.js // Various stat/event logging - see stat_log.js
dbs.system.run( dbs.system.run(
`CREATE TABLE IF NOT EXISTS system_stat ( `CREATE TABLE IF NOT EXISTS system_stat (
stat_name VARCHAR PRIMARY KEY NOT NULL, stat_name VARCHAR PRIMARY KEY NOT NULL,
stat_value VARCHAR NOT NULL stat_value VARCHAR NOT NULL
);` );`
); );
dbs.system.run( dbs.system.run(
`CREATE TABLE IF NOT EXISTS system_event_log ( `CREATE TABLE IF NOT EXISTS system_event_log (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
timestamp DATETIME NOT NULL, timestamp DATETIME NOT NULL,
log_name VARCHAR NOT NULL, log_name VARCHAR NOT NULL,
log_value VARCHAR NOT NULL, log_value VARCHAR NOT NULL,
UNIQUE(timestamp, log_name) UNIQUE(timestamp, log_name)
);` );`
); );
dbs.system.run( dbs.system.run(
`CREATE TABLE IF NOT EXISTS user_event_log ( `CREATE TABLE IF NOT EXISTS user_event_log (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
timestamp DATETIME NOT NULL, timestamp DATETIME NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
log_name VARCHAR NOT NULL, session_id VARCHAR NOT NULL,
log_value 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) => { user : (cb) => {
enableForeignKeys(dbs.user); enableForeignKeys(dbs.user);
dbs.user.run( dbs.user.run(
`CREATE TABLE IF NOT EXISTS user ( `CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
user_name VARCHAR NOT NULL, user_name VARCHAR NOT NULL,
UNIQUE(user_name) UNIQUE(user_name)
);` );`
); );
// :TODO: create FK on delete/etc. // :TODO: create FK on delete/etc.
dbs.user.run( dbs.user.run(
`CREATE TABLE IF NOT EXISTS user_property ( `CREATE TABLE IF NOT EXISTS user_property (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
prop_name VARCHAR NOT NULL, prop_name VARCHAR NOT NULL,
prop_value VARCHAR, prop_value VARCHAR,
UNIQUE(user_id, prop_name), UNIQUE(user_id, prop_name),
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);` );`
); );
dbs.user.run( dbs.user.run(
`CREATE TABLE IF NOT EXISTS user_group_member ( `CREATE TABLE IF NOT EXISTS user_group_member (
group_name VARCHAR NOT NULL, group_name VARCHAR NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
UNIQUE(group_name, user_id) UNIQUE(group_name, user_id)
);` );`
); );
dbs.user.run( dbs.user.run(
`CREATE TABLE IF NOT EXISTS user_login_history ( `CREATE TABLE IF NOT EXISTS user_achievement (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
user_name VARCHAR NOT NULL, achievement_tag VARCHAR NOT NULL,
timestamp DATETIME 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) => { message : (cb) => {
enableForeignKeys(dbs.message); enableForeignKeys(dbs.message);
dbs.message.run( dbs.message.run(
`CREATE TABLE IF NOT EXISTS message ( `CREATE TABLE IF NOT EXISTS message (
message_id INTEGER PRIMARY KEY, message_id INTEGER PRIMARY KEY,
area_tag VARCHAR NOT NULL, area_tag VARCHAR NOT NULL,
message_uuid VARCHAR(36) NOT NULL, message_uuid VARCHAR(36) NOT NULL,
reply_to_message_id INTEGER, reply_to_message_id INTEGER,
to_user_name VARCHAR NOT NULL, to_user_name VARCHAR NOT NULL,
from_user_name VARCHAR NOT NULL, from_user_name VARCHAR NOT NULL,
subject, /* FTS @ message_fts */ subject, /* FTS @ message_fts */
message, /* FTS @ message_fts */ message, /* FTS @ message_fts */
modified_timestamp DATETIME NOT NULL, modified_timestamp DATETIME NOT NULL,
view_count INTEGER NOT NULL DEFAULT 0, view_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(message_uuid) UNIQUE(message_uuid)
);` );`
); );
dbs.message.run( dbs.message.run(
`CREATE INDEX IF NOT EXISTS message_by_area_tag_index `CREATE INDEX IF NOT EXISTS message_by_area_tag_index
ON message (area_tag);` ON message (area_tag);`
); );
dbs.message.run( dbs.message.run(
`CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4 ( `CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4 (
content="message", content="message",
subject, subject,
message message
);` );`
); );
dbs.message.run( dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN `CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN
DELETE FROM message_fts WHERE docid=old.rowid; DELETE FROM message_fts WHERE docid=old.rowid;
END;` END;`
); );
dbs.message.run( dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN `CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN
DELETE FROM message_fts WHERE docid=old.rowid; DELETE FROM message_fts WHERE docid=old.rowid;
END;` END;`
); );
dbs.message.run( dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_after_update AFTER UPDATE ON message BEGIN `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); INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message);
END;` END;`
); );
dbs.message.run( dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_after_insert AFTER INSERT ON message BEGIN `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); INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message);
END;` END;`
); );
dbs.message.run( dbs.message.run(
`CREATE TABLE IF NOT EXISTS message_meta ( `CREATE TABLE IF NOT EXISTS message_meta (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
meta_category INTEGER NOT NULL, meta_category INTEGER NOT NULL,
meta_name VARCHAR NOT NULL, meta_name VARCHAR NOT NULL,
meta_value VARCHAR NOT NULL, meta_value VARCHAR NOT NULL,
UNIQUE(message_id, meta_category, meta_name, meta_value), UNIQUE(message_id, meta_category, meta_name, meta_value),
FOREIGN KEY(message_id) REFERENCES message(message_id) ON DELETE CASCADE FOREIGN KEY(message_id) REFERENCES message(message_id) ON DELETE CASCADE
);` );`
); );
// :TODO: need SQL to ensure cleaned up if delete from message? // :TODO: need SQL to ensure cleaned up if delete from message?
/* /*
dbs.message.run( dbs.message.run(
`CREATE TABLE IF NOT EXISTS hash_tag ( `CREATE TABLE IF NOT EXISTS hash_tag (
hash_tag_id INTEGER PRIMARY KEY, hash_tag_id INTEGER PRIMARY KEY,
hash_tag_name VARCHAR NOT NULL, hash_tag_name VARCHAR NOT NULL,
UNIQUE(hash_tag_name) UNIQUE(hash_tag_name)
);` );`
); );
// :TODO: need SQL to ensure cleaned up if delete from message? // :TODO: need SQL to ensure cleaned up if delete from message?
dbs.message.run( dbs.message.run(
`CREATE TABLE IF NOT EXISTS message_hash_tag ( `CREATE TABLE IF NOT EXISTS message_hash_tag (
hash_tag_id INTEGER NOT NULL, hash_tag_id INTEGER NOT NULL,
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
);` );`
); );
*/ */
dbs.message.run( dbs.message.run(
`CREATE TABLE IF NOT EXISTS user_message_area_last_read ( `CREATE TABLE IF NOT EXISTS user_message_area_last_read (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
area_tag VARCHAR NOT NULL, area_tag VARCHAR NOT NULL,
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
UNIQUE(user_id, area_tag) UNIQUE(user_id, area_tag)
);` );`
); );
dbs.message.run( dbs.message.run(
`CREATE TABLE IF NOT EXISTS message_area_last_scan ( `CREATE TABLE IF NOT EXISTS message_area_last_scan (
scan_toss VARCHAR NOT NULL, scan_toss VARCHAR NOT NULL,
area_tag VARCHAR NOT NULL, area_tag VARCHAR NOT NULL,
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
UNIQUE(scan_toss, area_tag) UNIQUE(scan_toss, area_tag)
);` );`
); );
return cb(null); return cb(null);
}, },
file : (cb) => { file : (cb) => {
enableForeignKeys(dbs.file); enableForeignKeys(dbs.file);
dbs.file.run( dbs.file.run(
// :TODO: should any of this be unique -- file_sha256 unless dupes are allowed on the system // :TODO: should any of this be unique -- file_sha256 unless dupes are allowed on the system
`CREATE TABLE IF NOT EXISTS file ( `CREATE TABLE IF NOT EXISTS file (
file_id INTEGER PRIMARY KEY, file_id INTEGER PRIMARY KEY,
area_tag VARCHAR NOT NULL, area_tag VARCHAR NOT NULL,
file_sha256 VARCHAR NOT NULL, file_sha256 VARCHAR NOT NULL,
file_name, /* FTS @ file_fts */ file_name, /* FTS @ file_fts */
storage_tag VARCHAR NOT NULL, storage_tag VARCHAR NOT NULL,
desc, /* FTS @ file_fts */ desc, /* FTS @ file_fts */
desc_long, /* FTS @ file_fts */ desc_long, /* FTS @ file_fts */
upload_timestamp DATETIME NOT NULL upload_timestamp DATETIME NOT NULL
);` );`
); );
dbs.file.run( dbs.file.run(
`CREATE INDEX IF NOT EXISTS file_by_area_tag_index `CREATE INDEX IF NOT EXISTS file_by_area_tag_index
ON file (area_tag);` ON file (area_tag);`
); );
dbs.file.run( dbs.file.run(
`CREATE INDEX IF NOT EXISTS file_by_sha256_index `CREATE INDEX IF NOT EXISTS file_by_sha256_index
ON file (file_sha256);` ON file (file_sha256);`
); );
dbs.file.run( dbs.file.run(
`CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts4 ( `CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts4 (
content="file", content="file",
file_name, file_name,
desc, desc,
desc_long desc_long
);` );`
); );
dbs.file.run( dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_before_update BEFORE UPDATE ON file BEGIN `CREATE TRIGGER IF NOT EXISTS file_before_update BEFORE UPDATE ON file BEGIN
DELETE FROM file_fts WHERE docid=old.rowid; DELETE FROM file_fts WHERE docid=old.rowid;
END;` END;`
); );
dbs.file.run( dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN `CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN
DELETE FROM file_fts WHERE docid=old.rowid; DELETE FROM file_fts WHERE docid=old.rowid;
END;` END;`
); );
dbs.file.run( dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_after_update AFTER UPDATE ON file BEGIN `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); INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long);
END;` END;`
); );
dbs.file.run( dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_after_insert AFTER INSERT ON file BEGIN `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); INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long);
END;` END;`
); );
dbs.file.run( dbs.file.run(
`CREATE TABLE IF NOT EXISTS file_meta ( `CREATE TABLE IF NOT EXISTS file_meta (
file_id INTEGER NOT NULL, file_id INTEGER NOT NULL,
meta_name VARCHAR NOT NULL, meta_name VARCHAR NOT NULL,
meta_value VARCHAR NOT NULL, meta_value VARCHAR NOT NULL,
UNIQUE(file_id, meta_name, meta_value), UNIQUE(file_id, meta_name, meta_value),
FOREIGN KEY(file_id) REFERENCES file(file_id) ON DELETE CASCADE FOREIGN KEY(file_id) REFERENCES file(file_id) ON DELETE CASCADE
);` );`
); );
dbs.file.run( dbs.file.run(
`CREATE TABLE IF NOT EXISTS hash_tag ( `CREATE TABLE IF NOT EXISTS hash_tag (
hash_tag_id INTEGER PRIMARY KEY, hash_tag_id INTEGER PRIMARY KEY,
hash_tag VARCHAR NOT NULL, hash_tag VARCHAR NOT NULL,
UNIQUE(hash_tag) UNIQUE(hash_tag)
);` );`
); );
dbs.file.run( dbs.file.run(
`CREATE TABLE IF NOT EXISTS file_hash_tag ( `CREATE TABLE IF NOT EXISTS file_hash_tag (
hash_tag_id INTEGER NOT NULL, hash_tag_id INTEGER NOT NULL,
file_id INTEGER NOT NULL, file_id INTEGER NOT NULL,
UNIQUE(hash_tag_id, file_id) UNIQUE(hash_tag_id, file_id)
);` );`
); );
dbs.file.run( dbs.file.run(
`CREATE TABLE IF NOT EXISTS file_user_rating ( `CREATE TABLE IF NOT EXISTS file_user_rating (
file_id INTEGER NOT NULL, file_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
rating INTEGER NOT NULL, rating INTEGER NOT NULL,
UNIQUE(file_id, user_id) UNIQUE(file_id, user_id)
);` );`
); );
dbs.file.run( dbs.file.run(
`CREATE TABLE IF NOT EXISTS file_web_serve ( `CREATE TABLE IF NOT EXISTS file_web_serve (
hash_id VARCHAR NOT NULL PRIMARY KEY, hash_id VARCHAR NOT NULL PRIMARY KEY,
expire_timestamp DATETIME NOT NULL expire_timestamp DATETIME NOT NULL
);` );`
); );
dbs.file.run( dbs.file.run(
`CREATE TABLE IF NOT EXISTS file_web_serve_batch ( `CREATE TABLE IF NOT EXISTS file_web_serve_batch (
hash_id VARCHAR NOT NULL, hash_id VARCHAR NOT NULL,
file_id INTEGER NOT NULL, file_id INTEGER NOT NULL,
UNIQUE(hash_id, file_id) UNIQUE(hash_id, file_id)
);` );`
); );
return cb(null); return cb(null);
} }
}; };

View File

@ -1,72 +1,77 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// deps const { Errors } = require('./enig_error.js');
const fs = require('graceful-fs');
const iconv = require('iconv-lite'); // deps
const async = require('async'); const fs = require('graceful-fs');
const iconv = require('iconv-lite');
const async = require('async');
module.exports = class DescriptIonFile { module.exports = class DescriptIonFile {
constructor() { constructor() {
this.entries = new Map(); this.entries = new Map();
} }
get(fileName) { get(fileName) {
return this.entries.get(fileName); return this.entries.get(fileName);
} }
getDescription(fileName) { getDescription(fileName) {
const entry = this.get(fileName); const entry = this.get(fileName);
if(entry) { if(entry) {
return entry.desc; return entry.desc;
} }
} }
static createFromFile(path, cb) { static createFromFile(path, cb) {
fs.readFile(path, (err, descData) => { fs.readFile(path, (err, descData) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
const descIonFile = new DescriptIonFile(); const descIonFile = new DescriptIonFile();
// DESCRIPT.ION entries are terminated with a CR and/or LF // DESCRIPT.ION entries are terminated with a CR and/or LF
const lines = iconv.decode(descData, 'cp437').split(/\r?\n/g); const lines = iconv.decode(descData, 'cp437').split(/\r?\n/g);
async.each(lines, (entryData, nextLine) => { async.each(lines, (entryData, nextLine) => {
// //
// We allow quoted (long) filenames or non-quoted filenames. // We allow quoted (long) filenames or non-quoted filenames.
// FILENAME<SPC>DESC<0x04><program data><CR/LF> // FILENAME<SPC>DESC<0x04><program data><CR/LF>
// //
const parts = entryData.match(/^(?:(?:"([^"]+)" )|(?:([^ ]+) ))([^\x04]+)\x04(.)[^\r\n]*$/); // eslint-disable-line no-control-regex const parts = entryData.match(/^(?:(?:"([^"]+)" )|(?:([^ ]+) ))([^\x04]+)\x04(.)[^\r\n]*$/); // eslint-disable-line no-control-regex
if(!parts) { if(!parts) {
return nextLine(null); return nextLine(null);
} }
const fileName = parts[1] || parts[2]; const fileName = parts[1] || parts[2];
// //
// Un-escape CR/LF's // Un-escape CR/LF's
// - escapped \r and/or \n // - escapped \r and/or \n
// - BBBS style @n - See https://www.bbbs.net/sysop.html // - BBBS style @n - See https://www.bbbs.net/sysop.html
// //
const desc = parts[3].replace(/\\r\\n|\\n|[^@]@n/g, '\r\n'); const desc = parts[3].replace(/\\r\\n|\\n|[^@]@n/g, '\r\n');
descIonFile.entries.set( descIonFile.entries.set(
fileName, fileName,
{ {
desc : desc, desc : desc,
programId : parts[4], programId : parts[4],
programData : parts[5], programData : parts[5],
} }
); );
return nextLine(null); return nextLine(null);
}, },
() => { () => {
return cb(null, descIonFile); return cb(
}); descIonFile.entries.size > 0 ? null : Errors.Invalid('Invalid or unrecognized DESCRIPT.ION format'),
}); descIonFile
} );
});
});
}
}; };

View File

@ -1,154 +1,137 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; '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'); module.exports = class Door {
const _ = require('lodash'); constructor(client) {
const pty = require('ptyw.js'); this.client = client;
const decode = require('iconv-lite').decode; this.restored = false;
const createServer = require('net').createServer; }
exports.Door = Door; prepare(ioType, cb) {
this.io = ioType;
function Door(client, exeInfo) { // we currently only have to do any real setup for 'socket'
events.EventEmitter.call(this); if('socket' !== ioType) {
return cb(null);
}
const self = this; this.sockServer = createServer(conn => {
this.client = client; conn.once('end', () => {
this.exeInfo = exeInfo; return this.restoreIo(conn);
this.exeInfo.encoding = this.exeInfo.encoding || 'cp437'; });
this.exeInfo.encoding = this.exeInfo.encoding.toLowerCase();
let restored = false;
// conn.once('error', err => {
// Members of exeInfo: this.client.log.info( { error : err.message }, 'Door socket server connection');
// cmd return this.restoreIo(conn);
// args[] });
// env{}
// cwd
// io
// encoding
// dropFile
// node
// inhSocket
//
this.doorDataHandler = function(data) { this.sockServer.getConnections( (err, count) => {
if(self.client.term.outputEncoding === self.exeInfo.encoding) { // We expect only one connection from our DOOR/emulator/etc.
self.client.term.rawWrite(data); if(!err && count <= 1) {
} else { this.client.term.output.pipe(conn);
self.client.term.write(decode(data, self.exeInfo.encoding)); conn.on('data', this.doorDataHandler.bind(this));
} }
}; });
});
this.restoreIo = function(piped) { this.sockServer.listen(0, () => {
if(!restored && self.client.term.output) { return cb(null);
self.client.term.output.unpipe(piped); });
self.client.term.output.resume(); }
restored = true;
}
};
this.prepareSocketIoServer = function(cb) { run(exeInfo, cb) {
if('socket' === self.exeInfo.io) { this.encoding = (exeInfo.encoding || 'cp437').toLowerCase();
const sockServer = createServer(conn => {
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. const cwd = exeInfo.cwd || paths.dirname(exeInfo.cmd);
if(!err && count <= 1) {
self.client.term.output.pipe(conn);
conn.on('data', self.doorDataHandler); 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('end', () => { const args = exeInfo.args.map( arg => stringFormat(arg, formatObj) );
return self.restoreIo(conn);
});
conn.once('error', err => { this.client.log.debug(
self.client.log.info( { error : err.toString() }, 'Door socket server connection'); { cmd : exeInfo.cmd, args, io : this.io },
return self.restoreIo(conn); 'Executing door'
}); );
}
});
});
sockServer.listen(0, () => { let door;
return cb(null, sockServer); try {
}); door = pty.spawn(exeInfo.cmd, args, {
} else { cols : this.client.term.termWidth,
return cb(null); rows : this.client.term.termHeight,
} cwd : cwd,
}; env : exeInfo.env,
encoding : null, // we want to handle all encoding ourself
});
} catch(e) {
return cb(e);
}
this.doorExited = function() { if('stdio' === this.io) {
self.emit('finished'); this.client.log.debug('Using stdio for door I/O');
};
}
require('util').inherits(Door, events.EventEmitter); this.client.term.output.pipe(door);
Door.prototype.run = function() { door.on('data', this.doorDataHandler.bind(this));
const self = this;
this.prepareSocketIoServer( (err, sockServer) => { door.once('close', () => {
if(err) { return this.restoreIo(door);
this.client.log.warn( { error : err.toString() }, 'Failed executing door'); });
return self.doorExited(); } 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'
);
}
// Expand arg strings, e.g. {dropFile} -> DOOR32.SYS door.once('exit', exitCode => {
// :TODO: Use .map() here this.client.log.info( { exitCode : exitCode }, 'Door exited');
let args = _.clone(self.exeInfo.args); // we need a copy so the original is not modified
for(let i = 0; i < args.length; ++i) { if(this.sockServer) {
args[i] = stringFormat(self.exeInfo.args[i], { this.sockServer.close();
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,
});
}
const door = pty.spawn(self.exeInfo.cmd, args, { // we may not get a close
cols : self.client.term.termWidth, if('stdio' === this.io) {
rows : self.client.term.termHeight, this.restoreIo(door);
// :TODO: cwd }
env : self.exeInfo.env,
});
if('stdio' === self.exeInfo.io) { door.removeAllListeners();
self.client.log.debug('Using stdio for door I/O');
self.client.term.output.pipe(door); return cb(null);
});
}
door.on('data', self.doorDataHandler); doorDataHandler(data) {
this.client.term.write(decode(data, this.encoding));
}
door.once('close', () => { restoreIo(piped) {
return self.restoreIo(door); if(!this.restored && this.client.term.output) {
}); this.client.term.output.unpipe(piped);
} else if('socket' === self.exeInfo.io) { this.client.term.output.resume();
self.client.log.debug( { port : sockServer.address().port }, 'Using temporary socket server for door I/O'); this.restored = true;
} }
}
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();
});
});
}; };

View File

@ -1,131 +1,145 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// enigma-bbs // enigma-bbs
const MenuModule = require('../core/menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const resetScreen = require('../core/ansi_term.js').resetScreen; const { resetScreen } = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash'); const SSHClient = require('ssh2').Client;
const SSHClient = require('ssh2').Client;
exports.moduleInfo = { exports.moduleInfo = {
name : 'DoorParty', name : 'DoorParty',
desc : 'DoorParty Access Module', desc : 'DoorParty Access Module',
author : 'NuSkooler', author : 'NuSkooler',
}; };
exports.getModule = class DoorPartyModule extends MenuModule { exports.getModule = class DoorPartyModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
// establish defaults // establish defaults
this.config = options.menuConfig.config; this.config = options.menuConfig.config;
this.config.host = this.config.host || 'dp.throwbackbbs.com'; this.config.host = this.config.host || 'dp.throwbackbbs.com';
this.config.sshPort = this.config.sshPort || 2022; this.config.sshPort = this.config.sshPort || 2022;
this.config.rloginPort = this.config.rloginPort || 513; this.config.rloginPort = this.config.rloginPort || 513;
} }
initSequence() { initSequence() {
let clientTerminated; let clientTerminated;
const self = this; const self = this;
async.series( async.series(
[ [
function validateConfig(callback) { function validateConfig(callback) {
if(!_.isString(self.config.username)) { return self.validateConfigFields(
return callback(new Error('Config requires "username"!')); {
} host : 'string',
if(!_.isString(self.config.password)) { username : 'string',
return callback(new Error('Config requires "password"!')); password : 'string',
} bbsTag : 'string',
if(!_.isString(self.config.bbsTag)) { sshPort : 'number',
return callback(new Error('Config requires "bbsTag"!')); rloginPort : 'number',
} },
return callback(null); callback
}, );
function establishSecureConnection(callback) { },
self.client.term.write(resetScreen()); function establishSecureConnection(callback) {
self.client.term.write('Connecting to DoorParty, please wait...\n'); self.client.term.write(resetScreen());
self.client.term.write('Connecting to DoorParty, please wait...\n');
const sshClient = new SSHClient(); const sshClient = new SSHClient();
let pipeRestored = false; let pipeRestored = false;
let pipedStream; let pipedStream;
const restorePipe = function() { let doorTracking;
if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream);
self.client.term.output.resume();
}
};
sshClient.on('ready', () => { const restorePipe = function() {
// track client termination so we can clean up early if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.once('end', () => { self.client.term.output.unpipe(pipedStream);
self.client.log.info('Connection ended. Terminating DoorParty connection'); self.client.term.output.resume();
clientTerminated = true;
sshClient.end();
});
// establish tunnel for rlogin if(doorTracking) {
sshClient.forwardOut('127.0.0.1', self.config.sshPort, self.config.host, self.config.rloginPort, (err, stream) => { trackDoorRunEnd(doorTracking);
if(err) { }
return callback(new Error('Failed to establish tunnel')); }
} };
// sshClient.on('ready', () => {
// Send rlogin // track client termination so we can clean up early
// DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g. self.client.once('end', () => {
// [XA]nuskooler self.client.log.info('Connection ended. Terminating DoorParty connection');
// clientTerminated = true;
const rlogin = `\x00${self.client.user.username}\x00[${self.config.bbsTag}]${self.client.user.username}\x00${self.client.term.termType}\x00`; sshClient.end();
stream.write(rlogin); });
pipedStream = stream; // :TODO: this is hacky... // establish tunnel for rlogin
self.client.term.output.pipe(stream); 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'));
}
stream.on('data', d => { doorTracking = trackDoorRunBegin(self.client);
// :TODO: we should just pipe this...
self.client.term.rawWrite(d);
});
stream.on('close', () => { //
restorePipe(); // Send rlogin
sshClient.end(); // 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);
sshClient.on('error', err => { pipedStream = stream; // :TODO: this is hacky...
self.client.log.info(`DoorParty SSH client error: ${err.message}`); self.client.term.output.pipe(stream);
});
sshClient.on('close', () => { stream.on('data', d => {
restorePipe(); // :TODO: we should just pipe this...
callback(null); self.client.term.rawWrite(d);
}); });
sshClient.connect( { stream.on('close', () => {
host : self.config.host, restorePipe();
port : self.config.sshPort, sshClient.end();
username : self.config.username, });
password : self.config.password, });
}); });
// note: no explicit callback() until we're finished! sshClient.on('error', err => {
} self.client.log.info(`DoorParty SSH client error: ${err.message}`);
], trackDoorRunEnd(doorTracking);
err => { });
if(err) {
self.client.log.warn( { error : err.message }, 'DoorParty error');
}
// if the client is stil here, go to previous sshClient.on('close', () => {
if(!clientTerminated) { restorePipe();
self.prevMenu(); 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();
}
}
);
}
}; };

38
core/door_util.js Normal file
View File

@ -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);
}
}

View File

@ -1,72 +1,79 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; '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 { module.exports = class DownloadQueue {
constructor(client) { constructor(client) {
this.client = client; this.client = client;
if(!Array.isArray(this.client.user.downloadQueue)) { if(!Array.isArray(this.client.user.downloadQueue)) {
if(this.client.user.properties.dl_queue) { if(this.client.user.properties[UserProps.DownloadQueue]) {
this.loadFromProperty(this.client.user.properties.dl_queue); this.loadFromProperty(this.client.user.properties[UserProps.DownloadQueue]);
} else { } else {
this.client.user.downloadQueue = []; this.client.user.downloadQueue = [];
} }
} }
} }
get items() { get items() {
return this.client.user.downloadQueue; return this.client.user.downloadQueue;
} }
clear() { clear() {
this.client.user.downloadQueue = []; this.client.user.downloadQueue = [];
} }
toggle(fileEntry) { toggle(fileEntry, systemFile=false) {
if(this.isQueued(fileEntry)) { if(this.isQueued(fileEntry)) {
this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId); this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId);
} else { } else {
this.add(fileEntry); this.add(fileEntry, systemFile);
} }
} }
add(fileEntry) { add(fileEntry, systemFile=false) {
this.client.user.downloadQueue.push({ this.client.user.downloadQueue.push({
fileId : fileEntry.fileId, fileId : fileEntry.fileId,
areaTag : fileEntry.areaTag, areaTag : fileEntry.areaTag,
fileName : fileEntry.fileName, fileName : fileEntry.fileName,
path : fileEntry.filePath, path : fileEntry.filePath,
byteSize : fileEntry.meta.byte_size || 0, byteSize : fileEntry.meta.byte_size || 0,
}); systemFile : systemFile,
} });
}
removeItems(fileIds) { removeItems(fileIds) {
if(!Array.isArray(fileIds)) { if(!Array.isArray(fileIds)) {
fileIds = [ 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) { isQueued(entryOrId) {
if(entryOrId instanceof FileEntry) { if(entryOrId instanceof FileEntry) {
entryOrId = entryOrId.fileId; 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); } toProperty() { return JSON.stringify(this.client.user.downloadQueue); }
loadFromProperty(prop) { loadFromProperty(prop) {
try { try {
this.client.user.downloadQueue = JSON.parse(prop); this.client.user.downloadQueue = JSON.parse(prop);
} catch(e) { } catch(e) {
this.client.user.downloadQueue = []; this.client.user.downloadQueue = [];
this.client.log.error( { error : e.message, property : prop }, 'Failed parsing download queue property'); this.client.log.error( { error : e.message, property : prop }, 'Failed parsing download queue property');
} }
} }
}; };

View File

@ -1,211 +1,227 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var Config = require('./config.js').config; // ENiGMA½
const StatLog = require('./stat_log.js'); 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'); // deps
var paths = require('path'); const fs = require('graceful-fs');
var _ = require('lodash'); const paths = require('path');
var moment = require('moment'); const _ = require('lodash');
var iconv = require('iconv-lite'); const moment = require('moment');
const iconv = require('iconv-lite');
exports.DropFile = DropFile; const { mkdirs } = require('fs-extra');
// //
// Resources // Resources
// * http://goldfndr.home.mindspring.com/dropfile/ // * https://github.com/NuSkooler/ansi-bbs/tree/master/docs/dropfile_formats
// * https://en.wikipedia.org/wiki/Talk%3ADropfile // * http://goldfndr.home.mindspring.com/dropfile/
// * http://thoughtproject.com/libraries/bbs/Sysop/Doors/DropFiles/index.htm // * https://en.wikipedia.org/wiki/Talk%3ADropfile
// * http://thebbs.org/bbsfaq/ch06.02.htm // * 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; isSupported() {
this.client = client; return this.getHandler() ? true : false;
this.fileType = (fileType || 'DORINFO').toUpperCase(); }
Object.defineProperty(this, 'fullPath', { getHandler() {
get : function() { return {
return paths.join(Config.paths.dropFiles, ('node' + self.client.node), self.fileName); DOOR : this.getDoorSysBuffer,
} DOOR32 : this.getDoor32Buffer,
}); DORINFO : this.getDoorInfoDefBuffer,
}[this.fileType];
}
Object.defineProperty(this, 'fileName', { getContents() {
get : function() { const handler = this.getHandler().bind(this);
return { return handler();
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];
}
});
Object.defineProperty(this, 'dropFileContents', { getDoorInfoFileName() {
get : function() { let x;
return { const node = this.client.node;
DOOR : self.getDoorSysBuffer(), if(10 === node) {
DOOR32 : self.getDoor32Buffer(), x = 0;
DORINFO : self.getDoorInfoDefBuffer(), } else if(node < 10) {
}[self.fileType]; x = node;
} } else {
}); x = String.fromCharCode('a'.charCodeAt(0) + (node - 11));
}
return 'DORINFO' + x + '.DEF';
}
this.getDoorInfoFileName = function() { getDoorSysBuffer() {
var x; const prop = this.client.user.properties;
var node = self.client.node; const now = moment();
if(10 === node) { const secLevel = this.client.user.getLegacySecurityLevel().toString();
x = 0; const fullName = this.client.user.getSanitizedName('real');
} else if(node < 10) { const bd = moment(prop[UserProps.Birthdate]).format('MM/DD/YY');
x = node;
} else {
x = String.fromCharCode('a'.charCodeAt(0) + (node - 11));
}
return 'DORINFO' + x + '.DEF';
};
this.getDoorSysBuffer = function() { const upK = Math.floor((parseInt(prop[UserProps.FileUlTotalBytes]) || 0) / 1024);
var up = self.client.user.properties; const downK = Math.floor((parseInt(prop[UserProps.FileDlTotalBytes]) || 0) / 1024);
var now = moment();
var secLevel = self.client.user.getLegacySecurityLevel().toString();
// :TODO: fix time remaining const timeOfCall = moment(prop[UserProps.LastLoginTs] || moment()).format('hh:mm');
// :TODO: fix default protocol -- user prop: transfer_protocol
return iconv.encode( [ // :TODO: fix time remaining
'COM1:', // "Comm Port - COM0: = LOCAL MODE" // :TODO: fix default protocol -- user prop: transfer_protocol
'57600', // "Baud Rate - 300 to 38400" (Note: set as 57600 instead!) return iconv.encode( [
'8', // "Parity - 7 or 8" 'COM1:', // "Comm Port - COM0: = LOCAL MODE"
self.client.node.toString(), // "Node Number - 1 to 99" '57600', // "Baud Rate - 300 to 38400" (Note: set as 57600 instead!)
'57600', // "DTE Rate. Actual BPS rate to use. (kg)" '8', // "Parity - 7 or 8"
'Y', // "Screen Display - Y=On N=Off (Default to Y)" this.client.node.toString(), // "Node Number - 1 to 99"
'Y', // "Printer Toggle - Y=On N=Off (Default to Y)" '57600', // "DTE Rate. Actual BPS rate to use. (kg)"
'Y', // "Page Bell - Y=On N=Off (Default to Y)" 'Y', // "Screen Display - Y=On N=Off (Default to Y)"
'Y', // "Caller Alarm - Y=On N=Off (Default to Y)" 'Y', // "Printer Toggle - Y=On N=Off (Default to Y)"
up.real_name || self.client.user.username, // "User Full Name" 'Y', // "Page Bell - Y=On N=Off (Default to Y)"
up.location || 'Anywhere', // "Calling From" 'Y', // "Caller Alarm - Y=On N=Off (Default to Y)"
'123-456-7890', // "Home Phone" fullName, // "User Full Name"
'123-456-7890', // "Work/Data Phone" prop[UserProps.Location]|| 'Anywhere', // "Calling From"
'NOPE', // "Password" (Note: this is never given out or even stored plaintext) '123-456-7890', // "Home Phone"
secLevel, // "Security Level" '123-456-7890', // "Work/Data Phone"
up.login_count.toString(), // "Total Times On" 'NOPE', // "Password" (Note: this is never given out or even stored plaintext)
now.format('MM/DD/YY'), // "Last Date Called" secLevel, // "Security Level"
'15360', // "Seconds Remaining THIS call (for those that particular)" prop[UserProps.LoginCount].toString(), // "Total Times On"
'256', // "Minutes Remaining THIS call" now.format('MM/DD/YY'), // "Last Date Called"
'GR', // "Graphics Mode - GR=Graph, NG=Non-Graph, 7E=7,E Caller" '15360', // "Seconds Remaining THIS call (for those that particular)"
self.client.term.termHeight.toString(), // "Page Length" '256', // "Minutes Remaining THIS call"
'N', // "User Mode - Y = Expert, N = Novice" 'GR', // "Graphics Mode - GR=Graph, NG=Non-Graph, 7E=7,E Caller"
'1,2,3,4,5,6,7', // "Conferences/Forums Registered In (ABCDEFG)" this.client.term.termHeight.toString(), // "Page Length"
'1', // "Conference Exited To DOOR From (G)" 'N', // "User Mode - Y = Expert, N = Novice"
'01/01/99', // "User Expiration Date (mm/dd/yy)" '1,2,3,4,5,6,7', // "Conferences/Forums Registered In (ABCDEFG)"
self.client.user.userId.toString(), // "User File's Record Number" '1', // "Conference Exited To DOOR From (G)"
'Z', // "Default Protocol - X, C, Y, G, I, N, Etc." '01/01/99', // "User Expiration Date (mm/dd/yy)"
// :TODO: fix up, down, etc. form user properties this.client.user.userId.toString(), // "User File's Record Number"
'0', // "Total Uploads" 'Z', // "Default Protocol - X, C, Y, G, I, N, Etc."
'0', // "Total Downloads" // :TODO: fix up, down, etc. form user properties
'0', // "Daily Download "K" Total" '0', // "Total Uploads"
'999999', // "Daily Download Max. "K" Limit" '0', // "Total Downloads"
moment(up.birthdate).format('MM/DD/YY'), // "Caller's Birthdate" '0', // "Daily Download "K" Total"
'X:\\MAIN\\', // "Path to the MAIN directory (where User File is)" '999999', // "Daily Download Max. "K" Limit"
'X:\\GEN\\', // "Path to the GEN directory" bd, // "Caller's Birthdate"
StatLog.getSystemStat('sysop_username'), // "Sysop's Name (name BBS refers to Sysop as)" 'X:\\MAIN\\', // "Path to the MAIN directory (where User File is)"
self.client.user.username, // "Alias name" 'X:\\GEN\\', // "Path to the GEN directory"
'00:05', // "Event time (hh:mm)" (note: wat?) StatLog.getSystemStat(SysProps.SysOpUsername), // "Sysop's Name (name BBS refers to Sysop as)"
'Y', // "If its an error correcting connection (Y/N)" this.client.user.getSanitizedName(), // "Alias name"
'Y', // "ANSI supported & caller using NG mode (Y/N)" '00:05', // "Event time (hh:mm)" (note: wat?)
'Y', // "Use Record Locking (Y/N)" 'Y', // "If its an error correcting connection (Y/N)"
'7', // "BBS Default Color (Standard IBM color code, ie, 1-15)" 'Y', // "ANSI supported & caller using NG mode (Y/N)"
// :TODO: fix minutes here also: 'Y', // "Use Record Locking (Y/N)"
'256', // "Time Credits In Minutes (positive/negative)" '7', // "BBS Default Color (Standard IBM color code, ie, 1-15)"
'07/07/90', // "Last New Files Scan Date (mm/dd/yy)" // :TODO: fix minutes here also:
// :TODO: fix last vs now times: '256', // "Time Credits In Minutes (positive/negative)"
now.format('hh:mm'), // "Time of This Call" '07/07/90', // "Last New Files Scan Date (mm/dd/yy)"
now.format('hh:mm'), // "Time of Last Call (hh:mm)" timeOfCall, // "Time of This Call"
'9999', // "Maximum daily files available" timeOfCall, // "Time of Last Call (hh:mm)"
// :TODO: fix these stats: '9999', // "Maximum daily files available"
'0', // "Files d/led so far today" '0', // "Files d/led so far today"
'0', // "Total "K" Bytes Uploaded" upK.toString(), // "Total "K" Bytes Uploaded"
'0', // "Total "K" Bytes Downloaded" downK.toString(), // "Total "K" Bytes Downloaded"
up.user_comment || 'None', // "User Comment" prop[UserProps.UserComment] || 'None', // "User Comment"
'0', // "Total Doors Opened" '0', // "Total Doors Opened"
'0', // "Total Messages Left" '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() { const commType = Door32CommTypes.Telnet;
//
// 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');
}; 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() { getDoorInfoDefBuffer() {
// :TODO: fix time remaining // :TODO: fix time remaining
// //
// Resources: // Resources:
// * http://goldfndr.home.mindspring.com/dropfile/dorinfo.htm // * http://goldfndr.home.mindspring.com/dropfile/dorinfo.htm
// //
// Note that usernames are just used for first/last names here // Note that usernames are just used for first/last names here
// //
var opUn = /[^\s]*/.exec(StatLog.getSystemStat('sysop_username'))[0]; const opUserName = /[^\s]*/.exec(StatLog.getSystemStat(SysProps.SysOpUsername))[0];
var un = /[^\s]*/.exec(self.client.user.username)[0]; const userName = /[^\s]*/.exec(this.client.user.getSanitizedName())[0];
var secLevel = self.client.user.getLegacySecurityLevel().toString(); const secLevel = this.client.user.getLegacySecurityLevel().toString();
const location = this.client.user.properties[UserProps.Location];
return iconv.encode( [ return iconv.encode( [
Config.general.boardName, // "The name of the system." Config().general.boardName, // "The name of the system."
opUn, // "The sysop's name up to the first space." opUserName, // "The sysop's name up to the first space."
opUn, // "The sysop's name following 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." 'COM1', // "The serial port the modem is connected to, or 0 if logged in on console."
'57600', // "The current port (DTE) rate." '57600', // "The current port (DTE) rate."
'0', // "The number "0"" '0', // "The number "0""
un, // "The current user's name, up to the first space." userName, // "The current user's name, up to the first space."
un, // "The current user's name, following the first space." userName, // "The current user's name, following the first space."
self.client.user.properties.location || '', // "Where the user lives, or a blank line if unknown." location || '', // "Where the user lives, or a blank line if unknown."
'1', // "The number "0" if TTY, or "1" if ANSI." '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." 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." '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." '-1' // "The number "-1" if using an external serial driver or "0" if using internal serial routines."
].join('\r\n') + '\r\n', 'cp437'); ].join('\r\n') + '\r\n', 'cp437');
}; }
} createFile(cb) {
mkdirs(paths.dirname(this.fullPath), err => {
DropFile.fileTypes = [ 'DORINFO' ]; if(err) {
return cb(err);
DropFile.prototype.createFile = function(cb) { }
fs.writeFile(this.fullPath, this.dropFileContents, function written(err) { return fs.writeFile(this.fullPath, this.getContents(), cb);
cb(err); });
}); }
}; };

View File

@ -1,90 +1,92 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const TextView = require('./text_view.js').TextView; const TextView = require('./text_view.js').TextView;
const miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
const strUtil = require('./string_util.js'); const strUtil = require('./string_util.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
exports.EditTextView = EditTextView; exports.EditTextView = EditTextView;
function EditTextView(options) { function EditTextView(options) {
options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true); options.acceptsFocus = miscUtil.valueWithDefault(options.acceptsFocus, true);
options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true); options.acceptsInput = miscUtil.valueWithDefault(options.acceptsInput, true);
options.cursorStyle = miscUtil.valueWithDefault(options.cursorStyle, 'steady block'); options.cursorStyle = miscUtil.valueWithDefault(options.cursorStyle, 'steady block');
options.resizable = false; options.resizable = false;
TextView.call(this, options); TextView.call(this, options);
this.cursorPos = { row : 0, col : 0 }; this.initDefaultWidth();
this.clientBackspace = function() { this.cursorPos = { row : 0, col : 0 };
const fillCharSGR = this.getStyleSGR(1) || this.getSGR();
this.client.term.write(`\b${fillCharSGR}${this.fillChar}\b${this.getFocusSGR()}`); 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); require('util').inherits(EditTextView, TextView);
EditTextView.prototype.onKeyPress = function(ch, key) { EditTextView.prototype.onKeyPress = function(ch, key) {
if(key) { if(key) {
if(this.isKeyMapped('backspace', key.name)) { if(this.isKeyMapped('backspace', key.name)) {
if(this.text.length > 0) { if(this.text.length > 0) {
this.text = this.text.substr(0, this.text.length - 1); this.text = this.text.substr(0, this.text.length - 1);
if(this.text.length >= this.dimens.width) { if(this.text.length >= this.dimens.width) {
this.redraw(); this.redraw();
} else { } else {
this.cursorPos.col -= 1; this.cursorPos.col -= 1;
if(this.cursorPos.col >= 0) { if(this.cursorPos.col >= 0) {
this.clientBackspace(); 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)) { } else if(this.isKeyMapped('clearLine', key.name)) {
this.text = ''; this.text = '';
this.cursorPos.col = 0; this.cursorPos.col = 0;
this.setFocus(true); // resetting focus will redraw & adjust cursor this.setFocus(true); // resetting focus will redraw & adjust cursor
return EditTextView.super_.prototype.onKeyPress.call(this, ch, key); return EditTextView.super_.prototype.onKeyPress.call(this, ch, key);
} }
} }
if(ch && strUtil.isPrintable(ch)) { if(ch && strUtil.isPrintable(ch)) {
if(this.text.length < this.maxLength) { if(this.text.length < this.maxLength) {
ch = strUtil.stylizeString(ch, this.textStyle); ch = strUtil.stylizeString(ch, this.textStyle);
this.text += ch; this.text += ch;
if(this.text.length > this.dimens.width) { if(this.text.length > this.dimens.width) {
// no shortcuts - redraw the view // no shortcuts - redraw the view
this.redraw(); this.redraw();
} else { } else {
this.cursorPos.col += 1; this.cursorPos.col += 1;
if(_.isString(this.textMaskChar)) { if(_.isString(this.textMaskChar)) {
if(this.textMaskChar.length > 0) { if(this.textMaskChar.length > 0) {
this.client.term.write(this.textMaskChar); this.client.term.write(this.textMaskChar);
} }
} else { } else {
this.client.term.write(ch); this.client.term.write(ch);
} }
} }
} }
} }
EditTextView.super_.prototype.onKeyPress.call(this, ch, key); EditTextView.super_.prototype.onKeyPress.call(this, ch, key);
}; };
EditTextView.prototype.setText = function(text) { EditTextView.prototype.setText = function(text) {
// draw & set |text| // draw & set |text|
EditTextView.super_.prototype.setText.call(this, text); EditTextView.super_.prototype.setText.call(this, text);
// adjust local cursor tracking // adjust local cursor tracking
this.cursorPos = { row : 0, col : text.length }; this.cursorPos = { row : 0, col : text.length };
}; };

View File

@ -1,31 +1,32 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const nodeMailer = require('nodemailer'); const nodeMailer = require('nodemailer');
exports.sendMail = sendMail; exports.sendMail = sendMail;
function sendMail(message, cb) { function sendMail(message, cb) {
if(!_.has(Config, 'email.transport')) { const config = Config();
return cb(Errors.MissingConfig('Email "email::transport" configuration missing')); 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, { const transportOptions = Object.assign( {}, config.email.transport, {
logger : Log, logger : Log,
}); });
const transport = nodeMailer.createTransport(transportOptions); const transport = nodeMailer.createTransport(transportOptions);
transport.sendMail(message, (err, info) => { transport.sendMail(message, (err, info) => {
return cb(err, info); return cb(err, info);
}); });
} }

View File

@ -2,41 +2,53 @@
'use strict'; 'use strict';
class EnigError extends Error { class EnigError extends Error {
constructor(message, code, reason, reasonCode) { constructor(message, code, reason, reasonCode) {
super(message); super(message);
this.name = this.constructor.name; this.name = this.constructor.name;
this.message = message; this.message = message;
this.code = code; this.code = code;
this.reason = reason; this.reason = reason;
this.reasonCode = reasonCode; this.reasonCode = reasonCode;
if(typeof Error.captureStackTrace === 'function') { if(this.reason) {
Error.captureStackTrace(this, this.constructor); this.message += `: ${this.reason}`;
} else { }
this.stack = (new Error(message)).stack;
} 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 = { exports.Errors = {
General : (reason, reasonCode) => new EnigError('An error occurred', -33000, reason, reasonCode), General : (reason, reasonCode) => new EnigError('An error occurred', -33000, reason, reasonCode),
MenuStack : (reason, reasonCode) => new EnigError('Menu stack error', -33001, 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), DoesNotExist : (reason, reasonCode) => new EnigError('Object does not exist', -33002, reason, reasonCode),
AccessDenied : (reason, reasonCode) => new EnigError('Access denied', -32003, reason, reasonCode), AccessDenied : (reason, reasonCode) => new EnigError('Access denied', -32003, reason, reasonCode),
Invalid : (reason, reasonCode) => new EnigError('Invalid', -32004, reason, reasonCode), Invalid : (reason, reasonCode) => new EnigError('Invalid', -32004, reason, reasonCode),
ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode), ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode),
MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode), MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode),
UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode), UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode),
MissingParam : (reason, reasonCode) => new EnigError('Missing paramater(s)', -32008, 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 = { exports.ErrorReasons = {
AlreadyThere : 'ALREADYTHERE', AlreadyThere : 'ALREADYTHERE',
InvalidNextMenu : 'BADNEXT', InvalidNextMenu : 'BADNEXT',
NoPreviousMenu : 'NOPREV', NoPreviousMenu : 'NOPREV',
NoConditionMatch : 'NOCONDMATCH', NoConditionMatch : 'NOCONDMATCH',
NotEnabled : 'NOTENABLED', NotEnabled : 'NOTENABLED',
AlreadyLoggedIn : 'ALREADYLOGGEDIN',
TooMany : 'TOOMANY',
Disabled : 'DISABLED',
Inactive : 'INACTIVE',
Locked : 'LOCKED',
NotAllowed : 'NOTALLOWED',
}; };

View File

@ -1,18 +1,18 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
// deps // deps
const assert = require('assert'); const assert = require('assert');
module.exports = function(condition, message) { module.exports = function(condition, message) {
if(Config.debug.assertsEnabled) { if(Config().debug.assertsEnabled) {
assert.apply(this, arguments); assert.apply(this, arguments);
} else if(!(condition)) { } else if(!(condition)) {
const stack = new Error().stack; const stack = new Error().stack;
Log.error( { condition : condition, stack : stack }, message || 'Assertion failed' ); Log.error( { condition : condition, stack : stack }, message || 'Assertion failed' );
} }
}; };

View File

@ -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);
};

View File

@ -1,268 +1,285 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const PluginModule = require('./plugin_module.js').PluginModule; const PluginModule = require('./plugin_module.js').PluginModule;
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const { Errors } = require('./enig_error.js');
const _ = require('lodash'); const _ = require('lodash');
const later = require('later'); const later = require('later');
const path = require('path'); const path = require('path');
const pty = require('ptyw.js'); const pty = require('node-pty');
const sane = require('sane'); const sane = require('sane');
const moment = require('moment'); const moment = require('moment');
const paths = require('path'); const paths = require('path');
const fse = require('fs-extra'); const fse = require('fs-extra');
exports.getModule = EventSchedulerModule; exports.getModule = EventSchedulerModule;
exports.EventSchedulerModule = EventSchedulerModule; // allow for loadAndStart exports.EventSchedulerModule = EventSchedulerModule; // allow for loadAndStart
exports.moduleInfo = { exports.moduleInfo = {
name : 'Event Scheduler', name : 'Event Scheduler',
desc : 'Support for scheduling arbritary events', desc : 'Support for scheduling arbritary events',
author : 'NuSkooler', author : 'NuSkooler',
}; };
const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:)([^\0]+)?$/; const SCHEDULE_REGEXP = /(?:^|or )?(@watch:)([^\0]+)?$/;
const ACTION_REGEXP = /\@(method|execute)\:([^\0]+)?$/; const ACTION_REGEXP = /@(method|execute):([^\0]+)?$/;
class ScheduledEvent { class ScheduledEvent {
constructor(events, name) { constructor(events, name) {
this.name = name; this.name = name;
this.schedule = this.parseScheduleString(events[name].schedule); this.schedule = this.parseScheduleString(events[name].schedule);
this.action = this.parseActionSpec(events[name].action); this.action = this.parseActionSpec(events[name].action);
if(this.action) { if(this.action) {
this.action.args = events[name].args || []; this.action.args = events[name].args || [];
} }
} }
get isValid() { get isValid() {
if((!this.schedule || (!this.schedule.sched && !this.schedule.watchFile)) || !this.action) { if((!this.schedule || (!this.schedule.sched && !this.schedule.watchFile)) || !this.action) {
return false; return false;
} }
if('method' === this.action.type && !this.action.location) { if('method' === this.action.type && !this.action.location) {
return false; return false;
} }
return true; return true;
} }
parseScheduleString(schedStr) { parseScheduleString(schedStr) {
if(!schedStr) { if(!schedStr) {
return false; return false;
} }
let schedule = {}; let schedule = {};
const m = SCHEDULE_REGEXP.exec(schedStr); const m = SCHEDULE_REGEXP.exec(schedStr);
if(m) { if(m) {
schedStr = schedStr.substr(0, m.index).trim(); schedStr = schedStr.substr(0, m.index).trim();
if('@watch:' === m[1]) { if('@watch:' === m[1]) {
schedule.watchFile = m[2]; schedule.watchFile = m[2];
} }
} }
if(schedStr.length > 0) { if(schedStr.length > 0) {
const sched = later.parse.text(schedStr); const sched = later.parse.text(schedStr);
if(-1 === sched.error) { if(-1 === sched.error) {
schedule.sched = sched; schedule.sched = sched;
} }
} }
// return undefined if we couldn't parse out anything useful // return undefined if we couldn't parse out anything useful
if(!_.isEmpty(schedule)) { if(!_.isEmpty(schedule)) {
return schedule; return schedule;
} }
} }
parseActionSpec(actionSpec) { parseActionSpec(actionSpec) {
if(actionSpec) { if(actionSpec) {
if('@' === actionSpec[0]) { if('@' === actionSpec[0]) {
const m = ACTION_REGEXP.exec(actionSpec); const m = ACTION_REGEXP.exec(actionSpec);
if(m) { if(m) {
if(m[2].indexOf(':') > -1) { if(m[2].indexOf(':') > -1) {
const parts = m[2].split(':'); const parts = m[2].split(':');
return { return {
type : m[1], type : m[1],
location : parts[0], location : parts[0],
what : parts[1], what : parts[1],
}; };
} else { } else {
return { return {
type : m[1], type : m[1],
what : m[2], what : m[2],
}; };
} }
} }
} else { } else {
return { return {
type : 'execute', type : 'execute',
what : actionSpec, what : actionSpec,
}; };
} }
} }
} }
executeAction(reason, cb) { executeAction(reason, cb) {
Log.info( { eventName : this.name, action : this.action, reason : reason }, 'Executing scheduled event action...'); Log.info( { eventName : this.name, action : this.action, reason : reason }, 'Executing scheduled event action...');
if('method' === this.action.type) { if('method' === this.action.type) {
const modulePath = path.join(__dirname, '../', this.action.location); // enigma-bbs base + supplied location (path/file.js') const modulePath = path.join(__dirname, '../', this.action.location); // enigma-bbs base + supplied location (path/file.js')
try { try {
const methodModule = require(modulePath); const methodModule = require(modulePath);
methodModule[this.action.what](this.action.args, err => { methodModule[this.action.what](this.action.args, err => {
if(err) { if(err) {
Log.debug( Log.debug(
{ error : err.toString(), eventName : this.name, action : this.action }, { error : err.message, eventName : this.name, action : this.action },
'Error performing scheduled event action'); 'Error performing scheduled event action');
} }
return cb(err); return cb(err);
}); });
} catch(e) { } catch(e) {
Log.warn( Log.warn(
{ error : e.toString(), eventName : this.name, action : this.action }, { error : e.message, eventName : this.name, action : this.action },
'Failed to perform scheduled event action'); 'Failed to perform scheduled event action');
return cb(e); return cb(e);
} }
} else if('execute' === this.action.type) { } else if('execute' === this.action.type) {
const opts = { const opts = {
// :TODO: cwd // :TODO: cwd
name : this.name, name : this.name,
cols : 80, cols : 80,
rows : 24, rows : 24,
env : process.env, env : process.env,
}; };
const proc = pty.spawn(this.action.what, this.action.args, opts); 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 => { proc.once('exit', exitCode => {
if(exitCode) { if(exitCode) {
Log.warn( Log.warn(
{ eventName : this.name, action : this.action, exitCode : exitCode }, { eventName : this.name, action : this.action, exitCode : exitCode },
'Bad exit code while performing scheduled event action'); 'Bad exit code while performing scheduled event action');
} }
return cb(exitCode ? new Error(`Bad exit code while performing scheduled event action: ${exitCode}`) : null); return cb(exitCode ? Errors.ExternalProcess(`Bad exit code while performing scheduled event action: ${exitCode}`) : null);
}); });
} }
} }
} }
function EventSchedulerModule(options) { function EventSchedulerModule(options) {
PluginModule.call(this, options); PluginModule.call(this, options);
if(_.has(Config, 'eventScheduler')) { const config = Config();
this.moduleConfig = Config.eventScheduler; if(_.has(config, 'eventScheduler')) {
} this.moduleConfig = config.eventScheduler;
}
const self = this; const self = this;
this.runningActions = new Set(); this.runningActions = new Set();
this.performAction = function(schedEvent, reason) { this.performAction = function(schedEvent, reason) {
if(self.runningActions.has(schedEvent.name)) { if(self.runningActions.has(schedEvent.name)) {
return; // already running return; // already running
} }
self.runningActions.add(schedEvent.name); self.runningActions.add(schedEvent.name);
schedEvent.executeAction(reason, () => { schedEvent.executeAction(reason, () => {
self.runningActions.delete(schedEvent.name); self.runningActions.delete(schedEvent.name);
}); });
}; };
} }
// convienence static method for direct load + start // convienence static method for direct load + start
EventSchedulerModule.loadAndStart = function(cb) { EventSchedulerModule.loadAndStart = function(cb) {
const loadModuleEx = require('./module_util.js').loadModuleEx; const loadModuleEx = require('./module_util.js').loadModuleEx;
const loadOpts = { const loadOpts = {
name : path.basename(__filename, '.js'), name : path.basename(__filename, '.js'),
path : __dirname, path : __dirname,
}; };
loadModuleEx(loadOpts, (err, mod) => { loadModuleEx(loadOpts, (err, mod) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
const modInst = new mod.getModule(); const modInst = new mod.getModule();
modInst.startup( err => { modInst.startup( err => {
return cb(err, modInst); return cb(err, modInst);
}); });
}); });
}; };
EventSchedulerModule.prototype.startup = function(cb) { EventSchedulerModule.prototype.startup = function(cb) {
this.eventTimers = []; this.eventTimers = [];
const self = this; const self = this;
if(this.moduleConfig && _.has(this.moduleConfig, 'events')) { if(this.moduleConfig && _.has(this.moduleConfig, 'events')) {
const events = Object.keys(this.moduleConfig.events).map( name => { const events = Object.keys(this.moduleConfig.events).map( name => {
return new ScheduledEvent(this.moduleConfig.events, name); return new ScheduledEvent(this.moduleConfig.events, name);
}); });
events.forEach( schedEvent => { events.forEach( schedEvent => {
if(!schedEvent.isValid) { if(!schedEvent.isValid) {
Log.warn( { eventName : schedEvent.name }, 'Invalid scheduled event entry'); Log.warn( { eventName : schedEvent.name }, 'Invalid scheduled event entry');
return; return;
} }
Log.debug( Log.debug(
{ {
eventName : schedEvent.name, eventName : schedEvent.name,
schedule : this.moduleConfig.events[schedEvent.name].schedule, schedule : this.moduleConfig.events[schedEvent.name].schedule,
action : schedEvent.action, 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', 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' 'Scheduled event loaded'
); );
if(schedEvent.schedule.sched) { if(schedEvent.schedule.sched) {
this.eventTimers.push(later.setInterval( () => { this.eventTimers.push(later.setInterval( () => {
self.performAction(schedEvent, 'Schedule'); self.performAction(schedEvent, 'Schedule');
}, schedEvent.schedule.sched)); }, schedEvent.schedule.sched));
} }
if(schedEvent.schedule.watchFile) { if(schedEvent.schedule.watchFile) {
const watcher = sane( const watcher = sane(
paths.dirname(schedEvent.schedule.watchFile), paths.dirname(schedEvent.schedule.watchFile),
{ {
glob : `**/${paths.basename(schedEvent.schedule.watchFile)}` glob : `**/${paths.basename(schedEvent.schedule.watchFile)}`
} }
); );
// :TODO: should track watched files & stop watching @ shutdown? // :TODO: should track watched files & stop watching @ shutdown?
[ 'change', 'add', 'delete' ].forEach(event => { [ 'change', 'add', 'delete' ].forEach(event => {
watcher.on(event, (fileName, fileRoot) => { watcher.on(event, (fileName, fileRoot) => {
const eventPath = paths.join(fileRoot, fileName); const eventPath = paths.join(fileRoot, fileName);
if(schedEvent.schedule.watchFile === eventPath) { if(schedEvent.schedule.watchFile === eventPath) {
self.performAction(schedEvent, `Watch file: ${eventPath}`); self.performAction(schedEvent, `Watch file: ${eventPath}`);
} }
}); });
}); });
fse.exists(schedEvent.schedule.watchFile, exists => { fse.exists(schedEvent.schedule.watchFile, exists => {
if(exists) { if(exists) {
self.performAction(schedEvent, `Watch file: ${schedEvent.schedule.watchFile}`); self.performAction(schedEvent, `Watch file: ${schedEvent.schedule.watchFile}`);
} }
}); });
} }
}); });
} }
cb(null); cb(null);
}; };
EventSchedulerModule.prototype.shutdown = function(cb) { EventSchedulerModule.prototype.shutdown = function(cb) {
if(this.eventTimers) { if(this.eventTimers) {
this.eventTimers.forEach( et => et.clear() ); this.eventTimers.forEach( et => et.clear() );
} }
cb(null); cb(null);
}; };

View File

@ -1,73 +1,76 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const paths = require('path'); const events = require('events');
const events = require('events'); const Log = require('./logger.js').log;
const Log = require('./logger.js').log; const SystemEvents = require('./system_events.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const async = require('async');
const glob = require('glob');
module.exports = new class Events extends events.EventEmitter { module.exports = new class Events extends events.EventEmitter {
constructor() { constructor() {
super(); super();
} this.setMaxListeners(64); // :TODO: play with this...
}
addListener(event, listener) { getSystemEvents() {
Log.trace( { event : event }, 'Registering event listener'); return SystemEvents;
return super.addListener(event, listener); }
}
emit(event, ...args) { addListener(event, listener) {
Log.trace( { event : event }, 'Emitting event'); Log.trace( { event : event }, 'Registering event listener');
return super.emit(event, args); return super.addListener(event, listener);
} }
on(event, listener) { emit(event, ...args) {
Log.trace( { event : event }, 'Registering event listener'); Log.trace( { event : event }, 'Emitting event');
return super.on(event, listener); return super.emit(event, ...args);
} }
once(event, listener) { on(event, listener) {
Log.trace( { event : event }, 'Registering single use event listener'); Log.trace( { event : event }, 'Registering event listener');
return super.once(event, listener); return super.on(event, listener);
} }
removeListener(event, listener) { once(event, listener) {
Log.trace( { event : event }, 'Removing listener'); Log.trace( { event : event }, 'Registering single use event listener');
return super.removeListener(event, listener); return super.once(event, listener);
} }
startup(cb) { //
async.each(require('./module_util.js').getModulePaths(), (modulePath, nextPath) => { // Listen to multiple events for a single listener.
glob('*{.js,/*.js}', { cwd : modulePath }, (err, files) => { // Called with: listener(event, eventName)
if(err) { //
return nextPath(err); // The returned object must be used with removeMultipleEventListener()
} //
addMultipleEventListener(events, listener) {
Log.trace( { events }, 'Registering event listeners');
async.each(files, (moduleName, nextModule) => { const listeners = [];
modulePath = paths.join(modulePath, moduleName);
try { events.forEach(eventName => {
const mod = require(modulePath); const listenWrapper = _.partial(listener, _, eventName);
this.on(eventName, listenWrapper);
listeners.push( { eventName, listenWrapper } );
});
if(_.isFunction(mod.registerEvents)) { return listeners;
// :TODO: ... or just systemInit() / systemShutdown() & mods could call Events.on() / Events.removeListener() ? }
mod.registerEvents(this);
}
} catch(e) {
} removeMultipleEventListener(listeners) {
Log.trace( { events }, 'Removing listeners');
listeners.forEach(listener => {
this.removeListener(listener.eventName, listener.listenWrapper);
});
}
return nextModule(null); removeListener(event, listener) {
}, err => { Log.trace( { event : event }, 'Removing listener');
return nextPath(err); return super.removeListener(event, listener);
}); }
});
}, err => { startup(cb) {
return cb(err); return cb(null);
}); }
}
}; };

View File

@ -1,231 +1,244 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('../core/menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const resetScreen = require('../core/ansi_term.js').resetScreen; const { resetScreen } = require('./ansi_term.js');
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors; const { Errors } = require('./enig_error.js');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent; const {
getEnigmaUserAgent
} = require('./misc_util.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const joinPath = require('path').join; const joinPath = require('path').join;
const crypto = require('crypto'); const crypto = require('crypto');
const moment = require('moment'); const moment = require('moment');
const https = require('https'); const https = require('https');
const querystring = require('querystring'); const querystring = require('querystring');
const fs = require('fs'); const fs = require('fs-extra');
const SSHClient = require('ssh2').Client; const SSHClient = require('ssh2').Client;
/* /*
Configuration block: Configuration block:
someDoor: { someDoor: {
module: exodus module: exodus
config: { config: {
// defaults // defaults
ticketHost: oddnetwork.org ticketHost: oddnetwork.org
ticketPort: 1984 ticketPort: 1984
ticketPath: /exodus ticketPath: /exodus
rejectUnauthorized: false // set to true to allow untrusted CA's (dangerous!) rejectUnauthorized: false // set to true to allow untrusted CA's (dangerous!)
sshHost: oddnetwork.org sshHost: oddnetwork.org
sshPort: 22 sshPort: 22
sshUser: exodus sshUser: exodus
sshKeyPem: /path/to/enigma-bbs/misc/exodus.id_rsa sshKeyPem: /path/to/enigma-bbs/misc/exodus.id_rsa
// optional // optional
caPem: /path/to/cacerts.pem // see https://curl.haxx.se/docs/caextract.html caPem: /path/to/cacerts.pem // see https://curl.haxx.se/docs/caextract.html
// required // required
board: XXXX board: XXXX
key: XXXX key: XXXX
door: some_door door: some_door
} }
} }
*/ */
exports.moduleInfo = { exports.moduleInfo = {
name : 'Exodus', name : 'Exodus',
desc : 'Exodus Door Server Access Module - https://oddnetwork.org/exodus/', desc : 'Exodus Door Server Access Module - https://oddnetwork.org/exodus/',
author : 'NuSkooler', author : 'NuSkooler',
}; };
exports.getModule = class ExodusModule extends MenuModule { exports.getModule = class ExodusModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.config = options.menuConfig.config || {}; this.config = options.menuConfig.config || {};
this.config.ticketHost = this.config.ticketHost || 'oddnetwork.org'; this.config.ticketHost = this.config.ticketHost || 'oddnetwork.org';
this.config.ticketPort = this.config.ticketPort || 1984, this.config.ticketPort = this.config.ticketPort || 1984,
this.config.ticketPath = this.config.ticketPath || '/exodus'; this.config.ticketPath = this.config.ticketPath || '/exodus';
this.config.rejectUnauthorized = _.get(this.config, 'rejectUnauthorized', true); this.config.rejectUnauthorized = _.get(this.config, 'rejectUnauthorized', true);
this.config.sshHost = this.config.sshHost || this.config.ticketHost; this.config.sshHost = this.config.sshHost || this.config.ticketHost;
this.config.sshPort = this.config.sshPort || 22; this.config.sshPort = this.config.sshPort || 22;
this.config.sshUser = this.config.sshUser || 'exodus_server'; this.config.sshUser = this.config.sshUser || 'exodus_server';
this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config.paths.misc, 'exodus.id_rsa'); this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config().paths.misc, 'exodus.id_rsa');
} }
initSequence() { initSequence() {
const self = this; const self = this;
let clientTerminated = false; let clientTerminated = false;
async.waterfall( async.waterfall(
[ [
function validateConfig(callback) { function validateConfig(callback) {
// very basic validation on optionals // very basic validation on optionals
async.each( [ 'board', 'key', 'door' ], (key, next) => { async.each( [ 'board', 'key', 'door' ], (key, next) => {
return _.isString(self.config[key]) ? next(null) : next(Errors.MissingConfig(`Config requires "${key}"!`)); return _.isString(self.config[key]) ? next(null) : next(Errors.MissingConfig(`Config requires "${key}"!`));
}, callback); }, callback);
}, },
function loadCertAuthorities(callback) { function loadCertAuthorities(callback) {
if(!_.isString(self.config.caPem)) { if(!_.isString(self.config.caPem)) {
return callback(null, null); return callback(null, null);
} }
fs.readFile(self.config.caPem, (err, certAuthorities) => { fs.readFile(self.config.caPem, (err, certAuthorities) => {
return callback(err, certAuthorities); return callback(err, certAuthorities);
}); });
}, },
function getTicket(certAuthorities, callback) { function getTicket(certAuthorities, callback) {
const now = moment.utc().unix(); const now = moment.utc().unix();
const sha256 = crypto.createHash('sha256').update(`${self.config.key}${now}`).digest('hex'); const sha256 = crypto.createHash('sha256').update(`${self.config.key}${now}`).digest('hex');
const token = `${sha256}|${now}`; const token = `${sha256}|${now}`;
const postData = querystring.stringify({ const postData = querystring.stringify({
token : token, token : token,
board : self.config.board, board : self.config.board,
user : self.client.user.username, user : self.client.user.username,
door : self.config.door, door : self.config.door,
}); });
const reqOptions = { const reqOptions = {
hostname : self.config.ticketHost, hostname : self.config.ticketHost,
port : self.config.ticketPort, port : self.config.ticketPort,
path : self.config.ticketPath, path : self.config.ticketPath,
rejectUnauthorized : self.config.rejectUnauthorized, rejectUnauthorized : self.config.rejectUnauthorized,
method : 'POST', method : 'POST',
headers : { headers : {
'Content-Type' : 'application/x-www-form-urlencoded', 'Content-Type' : 'application/x-www-form-urlencoded',
'Content-Length' : postData.length, 'Content-Length' : postData.length,
'User-Agent' : getEnigmaUserAgent(), 'User-Agent' : getEnigmaUserAgent(),
} }
}; };
if(certAuthorities) { if(certAuthorities) {
reqOptions.ca = certAuthorities; reqOptions.ca = certAuthorities;
} }
let ticket = ''; let ticket = '';
const req = https.request(reqOptions, res => { const req = https.request(reqOptions, res => {
res.on('data', data => { res.on('data', data => {
ticket += data; ticket += data;
}); });
res.on('end', () => { res.on('end', () => {
if(ticket.length !== 36) { if(ticket.length !== 36) {
return callback(Errors.Invalid(`Invalid Exodus ticket: ${ticket}`)); return callback(Errors.Invalid(`Invalid Exodus ticket: ${ticket}`));
} }
return callback(null, ticket); return callback(null, ticket);
}); });
}); });
req.on('error', err => { req.on('error', err => {
return callback(Errors.General(`Exodus error: ${err.message}`)); return callback(Errors.General(`Exodus error: ${err.message}`));
}); });
req.write(postData); req.write(postData);
req.end(); req.end();
}, },
function loadPrivateKey(ticket, callback) { function loadPrivateKey(ticket, callback) {
fs.readFile(self.config.sshKeyPem, (err, privateKey) => { fs.readFile(self.config.sshKeyPem, (err, privateKey) => {
return callback(err, ticket, privateKey); return callback(err, ticket, privateKey);
}); });
}, },
function establishSecureConnection(ticket, privateKey, callback) { function establishSecureConnection(ticket, privateKey, callback) {
let pipeRestored = false; let pipeRestored = false;
let pipedStream; let pipedStream;
let doorTracking;
function restorePipe() { function restorePipe() {
if(pipedStream && !pipeRestored && !clientTerminated) { if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream); self.client.term.output.unpipe(pipedStream);
self.client.term.output.resume(); self.client.term.output.resume();
}
}
self.client.term.write(resetScreen()); if(doorTracking) {
self.client.term.write('Connecting to Exodus server, please wait...\n'); trackDoorRunEnd(doorTracking);
}
}
}
const sshClient = new SSHClient(); self.client.term.write(resetScreen());
self.client.term.write('Connecting to Exodus server, please wait...\n');
const window = { const sshClient = new SSHClient();
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 options = { const window = {
env : { rows : self.client.term.termHeight,
exodus : ticket, 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', () => { const options = {
self.client.once('end', () => { env : {
self.client.log.info('Connection ended. Terminating Exodus connection'); exodus : ticket,
clientTerminated = true; },
return sshClient.end(); };
});
sshClient.shell(window, options, (err, stream) => { sshClient.on('ready', () => {
pipedStream = stream; // :TODO: ewwwwwwwww hack self.client.once('end', () => {
self.client.term.output.pipe(stream); self.client.log.info('Connection ended. Terminating Exodus connection');
clientTerminated = true;
return sshClient.end();
});
stream.on('data', d => { sshClient.shell(window, options, (err, stream) => {
return self.client.term.rawWrite(d); doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`);
});
stream.on('close', () => { pipedStream = stream; // :TODO: ewwwwwwwww hack
restorePipe(); self.client.term.output.pipe(stream);
return sshClient.end();
});
stream.on('error', err => { stream.on('data', d => {
Log.warn( { error : err.message }, 'Exodus SSH client stream error'); return self.client.term.rawWrite(d);
}); });
});
});
sshClient.on('close', () => { stream.on('close', () => {
restorePipe(); restorePipe();
return callback(null); return sshClient.end();
}); });
sshClient.connect({ stream.on('error', err => {
host : self.config.sshHost, Log.warn( { error : err.message }, 'Exodus SSH client stream error');
port : self.config.sshPort, });
username : self.config.sshUser, });
privateKey : privateKey, });
});
}
],
err => {
if(err) {
self.client.log.warn( { error : err.message }, 'Exodus error');
}
if(!clientTerminated) { sshClient.on('close', () => {
self.prevMenu(); 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();
}
}
);
}
}; };

View File

@ -1,339 +1,340 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas;
const FileBaseFilters = require('./file_base_filter.js'); const FileBaseFilters = require('./file_base_filter.js');
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const UserProps = require('./user_property.js');
// deps // deps
const async = require('async'); const async = require('async');
exports.moduleInfo = { exports.moduleInfo = {
name : 'File Area Filter Editor', name : 'File Area Filter Editor',
desc : 'Module for adding, deleting, and modifying file base filters', desc : 'Module for adding, deleting, and modifying file base filters',
author : 'NuSkooler', author : 'NuSkooler',
}; };
const MciViewIds = { const MciViewIds = {
editor : { editor : {
searchTerms : 1, searchTerms : 1,
tags : 2, tags : 2,
area : 3, area : 3,
sort : 4, sort : 4,
order : 5, order : 5,
filterName : 6, filterName : 6,
navMenu : 7, navMenu : 7,
// :TODO: use the customs new standard thing - filter obj can have active/selected, etc. // :TODO: use the customs new standard thing - filter obj can have active/selected, etc.
selectedFilterInfo : 10, // { ...filter object ... } selectedFilterInfo : 10, // { ...filter object ... }
activeFilterInfo : 11, // { ...filter object ... } activeFilterInfo : 11, // { ...filter object ... }
error : 12, // validation errors error : 12, // validation errors
} }
}; };
exports.getModule = class FileAreaFilterEdit extends MenuModule { exports.getModule = class FileAreaFilterEdit extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.filtersArray = new FileBaseFilters(this.client).toArray(); // ordered, such that we can index into them this.filtersArray = new FileBaseFilters(this.client).toArray(); // ordered, such that we can index into them
this.currentFilterIndex = 0; // into |filtersArray| this.currentFilterIndex = 0; // into |filtersArray|
// //
// Lexical sort + keep currently active filter (if any) as the first item in |filtersArray| // Lexical sort + keep currently active filter (if any) as the first item in |filtersArray|
// //
const activeFilter = FileBaseFilters.getActiveFilter(this.client); const activeFilter = FileBaseFilters.getActiveFilter(this.client);
this.filtersArray.sort( (filterA, filterB) => { this.filtersArray.sort( (filterA, filterB) => {
if(activeFilter) { if(activeFilter) {
if(filterA.uuid === activeFilter.uuid) { if(filterA.uuid === activeFilter.uuid) {
return -1; return -1;
} }
if(filterB.uuid === activeFilter.uuid) { if(filterB.uuid === activeFilter.uuid) {
return 1; return 1;
} }
} }
return filterA.name.localeCompare(filterB.name, { sensitivity : false, numeric : true } ); return filterA.name.localeCompare(filterB.name, { sensitivity : false, numeric : true } );
}); });
this.menuMethods = { this.menuMethods = {
saveFilter : (formData, extraArgs, cb) => { saveFilter : (formData, extraArgs, cb) => {
return this.saveCurrentFilter(formData, cb); return this.saveCurrentFilter(formData, cb);
}, },
prevFilter : (formData, extraArgs, cb) => { prevFilter : (formData, extraArgs, cb) => {
this.currentFilterIndex -= 1; this.currentFilterIndex -= 1;
if(this.currentFilterIndex < 0) { if(this.currentFilterIndex < 0) {
this.currentFilterIndex = this.filtersArray.length - 1; this.currentFilterIndex = this.filtersArray.length - 1;
} }
this.loadDataForFilter(this.currentFilterIndex); this.loadDataForFilter(this.currentFilterIndex);
return cb(null); return cb(null);
}, },
nextFilter : (formData, extraArgs, cb) => { nextFilter : (formData, extraArgs, cb) => {
this.currentFilterIndex += 1; this.currentFilterIndex += 1;
if(this.currentFilterIndex >= this.filtersArray.length) { if(this.currentFilterIndex >= this.filtersArray.length) {
this.currentFilterIndex = 0; this.currentFilterIndex = 0;
} }
this.loadDataForFilter(this.currentFilterIndex); this.loadDataForFilter(this.currentFilterIndex);
return cb(null); return cb(null);
}, },
makeFilterActive : (formData, extraArgs, cb) => { makeFilterActive : (formData, extraArgs, cb) => {
const filters = new FileBaseFilters(this.client); const filters = new FileBaseFilters(this.client);
filters.setActive(this.filtersArray[this.currentFilterIndex].uuid); filters.setActive(this.filtersArray[this.currentFilterIndex].uuid);
this.updateActiveLabel(); this.updateActiveLabel();
return cb(null); return cb(null);
}, },
newFilter : (formData, extraArgs, cb) => { newFilter : (formData, extraArgs, cb) => {
this.currentFilterIndex = this.filtersArray.length; // next avail slot this.currentFilterIndex = this.filtersArray.length; // next avail slot
this.clearForm(MciViewIds.editor.searchTerms); this.clearForm(MciViewIds.editor.searchTerms);
return cb(null); return cb(null);
}, },
deleteFilter : (formData, extraArgs, cb) => { deleteFilter : (formData, extraArgs, cb) => {
const selectedFilter = this.filtersArray[this.currentFilterIndex]; const selectedFilter = this.filtersArray[this.currentFilterIndex];
const filterUuid = selectedFilter.uuid; const filterUuid = selectedFilter.uuid;
// cannot delete built-in/system filters // cannot delete built-in/system filters
if(true === selectedFilter.system) { if(true === selectedFilter.system) {
this.showError('Cannot delete built in filters!'); this.showError('Cannot delete built in filters!');
return cb(null); 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 // remove from stored properties
const filters = new FileBaseFilters(this.client); const filters = new FileBaseFilters(this.client);
filters.remove(filterUuid); filters.remove(filterUuid);
filters.persist( () => { filters.persist( () => {
// //
// If the item was also the active filter, we need to make a new one active // 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) { if(filterUuid === this.client.user.properties[UserProps.FileBaseFilterActiveUuid]) {
const newActive = this.filtersArray[this.currentFilterIndex]; const newActive = this.filtersArray[this.currentFilterIndex];
if(newActive) { if(newActive) {
filters.setActive(newActive.uuid); filters.setActive(newActive.uuid);
} else { } else {
// nothing to set active to // nothing to set active to
this.client.user.removeProperty('file_base_filter_active_uuid'); this.client.user.removeProperty('file_base_filter_active_uuid');
} }
} }
// update UI // update UI
this.updateActiveLabel(); this.updateActiveLabel();
if(this.filtersArray.length > 0) { if(this.filtersArray.length > 0) {
this.loadDataForFilter(this.currentFilterIndex); this.loadDataForFilter(this.currentFilterIndex);
} else { } else {
this.clearForm(); this.clearForm();
} }
return cb(null); return cb(null);
}); });
}, },
viewValidationListener : (err, cb) => { viewValidationListener : (err, cb) => {
const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error); const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error);
let newFocusId; let newFocusId;
if(errorView) { if(errorView) {
if(err) { if(err) {
errorView.setText(err.message); errorView.setText(err.message);
err.view.clearText(); // clear out the invalid data err.view.clearText(); // clear out the invalid data
} else { } else {
errorView.clearText(); errorView.clearText();
} }
} }
return cb(newFocusId); return cb(newFocusId);
}, },
}; };
} }
showError(errMsg) { showError(errMsg) {
const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error); const errorView = this.viewControllers.editor.getView(MciViewIds.editor.error);
if(errorView) { if(errorView) {
if(errMsg) { if(errMsg) {
errorView.setText(errMsg); errorView.setText(errMsg);
} else { } else {
errorView.clearText(); errorView.clearText();
} }
} }
} }
mciReady(mciData, cb) { mciReady(mciData, cb) {
super.mciReady(mciData, err => { super.mciReady(mciData, err => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
const self = this; const self = this;
const vc = self.addViewController( 'editor', new ViewController( { client : this.client } ) ); const vc = self.addViewController( 'editor', new ViewController( { client : this.client } ) );
async.series( async.series(
[ [
function loadFromConfig(callback) { function loadFromConfig(callback) {
return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback);
}, },
function populateAreas(callback) { function populateAreas(callback) {
self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []);
const areasView = vc.getView(MciViewIds.editor.area); const areasView = vc.getView(MciViewIds.editor.area);
if(areasView) { if(areasView) {
areasView.setItems( self.availAreas.map( a => a.name ) ); areasView.setItems( self.availAreas.map( a => a.name ) );
} }
self.updateActiveLabel(); self.updateActiveLabel();
self.loadDataForFilter(self.currentFilterIndex); self.loadDataForFilter(self.currentFilterIndex);
self.viewControllers.editor.resetInitialFocus(); self.viewControllers.editor.resetInitialFocus();
return callback(null); return callback(null);
} }
], ],
err => { err => {
return cb(err); return cb(err);
} }
); );
}); });
} }
getCurrentFilter() { getCurrentFilter() {
return this.filtersArray[this.currentFilterIndex]; return this.filtersArray[this.currentFilterIndex];
} }
setText(mciId, text) { setText(mciId, text) {
const view = this.viewControllers.editor.getView(mciId); const view = this.viewControllers.editor.getView(mciId);
if(view) { if(view) {
view.setText(text); view.setText(text);
} }
} }
updateActiveLabel() { updateActiveLabel() {
const activeFilter = FileBaseFilters.getActiveFilter(this.client); const activeFilter = FileBaseFilters.getActiveFilter(this.client);
if(activeFilter) { if(activeFilter) {
const activeFormat = this.menuConfig.config.activeFormat || '{name}'; const activeFormat = this.menuConfig.config.activeFormat || '{name}';
this.setText(MciViewIds.editor.activeFilterInfo, stringFormat(activeFormat, activeFilter)); this.setText(MciViewIds.editor.activeFilterInfo, stringFormat(activeFormat, activeFilter));
} }
} }
setFocusItemIndex(mciId, index) { setFocusItemIndex(mciId, index) {
const view = this.viewControllers.editor.getView(mciId); const view = this.viewControllers.editor.getView(mciId);
if(view) { if(view) {
view.setFocusItemIndex(index); view.setFocusItemIndex(index);
} }
} }
clearForm(newFocusId) { clearForm(newFocusId) {
[ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => { [ MciViewIds.editor.searchTerms, MciViewIds.editor.tags, MciViewIds.editor.filterName ].forEach(mciId => {
this.setText(mciId, ''); this.setText(mciId, '');
}); });
[ MciViewIds.editor.area, MciViewIds.editor.order, MciViewIds.editor.sort ].forEach(mciId => { [ MciViewIds.editor.area, MciViewIds.editor.order, MciViewIds.editor.sort ].forEach(mciId => {
this.setFocusItemIndex(mciId, 0); this.setFocusItemIndex(mciId, 0);
}); });
if(newFocusId) { if(newFocusId) {
this.viewControllers.editor.switchFocus(newFocusId); this.viewControllers.editor.switchFocus(newFocusId);
} else { } else {
this.viewControllers.editor.resetInitialFocus(); this.viewControllers.editor.resetInitialFocus();
} }
} }
getSelectedAreaTag(index) { getSelectedAreaTag(index) {
if(0 === index) { if(0 === index) {
return ''; // -ALL- return ''; // -ALL-
} }
const area = this.availAreas[index]; const area = this.availAreas[index];
if(!area) { if(!area) {
return ''; return '';
} }
return area.areaTag; return area.areaTag;
} }
getOrderBy(index) { getOrderBy(index) {
return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0];
} }
setAreaIndexFromCurrentFilter() { setAreaIndexFromCurrentFilter() {
let index; let index;
const filter = this.getCurrentFilter(); const filter = this.getCurrentFilter();
if(filter) { if(filter) {
// special treatment: areaTag saved as blank ("") if -ALL- // special treatment: areaTag saved as blank ("") if -ALL-
index = (filter.areaTag && this.availAreas.findIndex(area => filter.areaTag === area.areaTag)) || 0; index = (filter.areaTag && this.availAreas.findIndex(area => filter.areaTag === area.areaTag)) || 0;
} else { } else {
index = 0; index = 0;
} }
this.setFocusItemIndex(MciViewIds.editor.area, index); this.setFocusItemIndex(MciViewIds.editor.area, index);
} }
setOrderByFromCurrentFilter() { setOrderByFromCurrentFilter() {
let index; let index;
const filter = this.getCurrentFilter(); const filter = this.getCurrentFilter();
if(filter) { if(filter) {
index = FileBaseFilters.OrderByValues.findIndex( ob => filter.order === ob ) || 0; index = FileBaseFilters.OrderByValues.findIndex( ob => filter.order === ob ) || 0;
} else { } else {
index = 0; index = 0;
} }
this.setFocusItemIndex(MciViewIds.editor.order, index); this.setFocusItemIndex(MciViewIds.editor.order, index);
} }
setSortByFromCurrentFilter() { setSortByFromCurrentFilter() {
let index; let index;
const filter = this.getCurrentFilter(); const filter = this.getCurrentFilter();
if(filter) { if(filter) {
index = FileBaseFilters.SortByValues.findIndex( sb => filter.sort === sb ) || 0; index = FileBaseFilters.SortByValues.findIndex( sb => filter.sort === sb ) || 0;
} else { } else {
index = 0; index = 0;
} }
this.setFocusItemIndex(MciViewIds.editor.sort, index); this.setFocusItemIndex(MciViewIds.editor.sort, index);
} }
getSortBy(index) { getSortBy(index) {
return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0];
} }
setFilterValuesFromFormData(filter, formData) { setFilterValuesFromFormData(filter, formData) {
filter.name = formData.value.name; filter.name = formData.value.name;
filter.areaTag = this.getSelectedAreaTag(formData.value.areaIndex); filter.areaTag = this.getSelectedAreaTag(formData.value.areaIndex);
filter.terms = formData.value.searchTerms; filter.terms = formData.value.searchTerms;
filter.tags = formData.value.tags; filter.tags = formData.value.tags;
filter.order = this.getOrderBy(formData.value.orderByIndex); filter.order = this.getOrderBy(formData.value.orderByIndex);
filter.sort = this.getSortBy(formData.value.sortByIndex); filter.sort = this.getSortBy(formData.value.sortByIndex);
} }
saveCurrentFilter(formData, cb) { saveCurrentFilter(formData, cb) {
const filters = new FileBaseFilters(this.client); const filters = new FileBaseFilters(this.client);
const selectedFilter = this.filtersArray[this.currentFilterIndex]; const selectedFilter = this.filtersArray[this.currentFilterIndex];
if(selectedFilter) { if(selectedFilter) {
// *update* currently selected filter // *update* currently selected filter
this.setFilterValuesFromFormData(selectedFilter, formData); this.setFilterValuesFromFormData(selectedFilter, formData);
filters.replace(selectedFilter.uuid, selectedFilter); filters.replace(selectedFilter.uuid, selectedFilter);
} else { } else {
// add a new entry; note that UUID will be generated // add a new entry; note that UUID will be generated
const newFilter = {}; const newFilter = {};
this.setFilterValuesFromFormData(newFilter, formData); this.setFilterValuesFromFormData(newFilter, formData);
// set current to what we just saved // set current to what we just saved
newFilter.uuid = filters.add(newFilter); newFilter.uuid = filters.add(newFilter);
// add to our array (at current index position) // add to our array (at current index position)
this.filtersArray[this.currentFilterIndex] = newFilter; this.filtersArray[this.currentFilterIndex] = newFilter;
} }
return filters.persist(cb); return filters.persist(cb);
} }
loadDataForFilter(filterIndex) { loadDataForFilter(filterIndex) {
const filter = this.filtersArray[filterIndex]; const filter = this.filtersArray[filterIndex];
if(filter) { if(filter) {
this.setText(MciViewIds.editor.searchTerms, filter.terms); this.setText(MciViewIds.editor.searchTerms, filter.terms);
this.setText(MciViewIds.editor.tags, filter.tags); this.setText(MciViewIds.editor.tags, filter.tags);
this.setText(MciViewIds.editor.filterName, filter.name); this.setText(MciViewIds.editor.filterName, filter.name);
this.setAreaIndexFromCurrentFilter(); this.setAreaIndexFromCurrentFilter();
this.setSortByFromCurrentFilter(); this.setSortByFromCurrentFilter();
this.setOrderByFromCurrentFilter(); this.setOrderByFromCurrentFilter();
} }
} }
}; };

File diff suppressed because it is too large Load Diff

View File

@ -1,492 +1,498 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const FileDb = require('./database.js').dbs.file; const FileDb = require('./database.js').dbs.file;
const getISOTimestampString = require('./database.js').getISOTimestampString; const getISOTimestampString = require('./database.js').getISOTimestampString;
const FileEntry = require('./file_entry.js'); const FileEntry = require('./file_entry.js');
const getServer = require('./listening_server.js').getServer; const getServer = require('./listening_server.js').getServer;
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled;
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
const User = require('./user.js'); const User = require('./user.js');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId; const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId;
const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; 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 // deps
const hashids = require('hashids'); const hashids = require('hashids');
const moment = require('moment'); const moment = require('moment');
const paths = require('path'); const paths = require('path');
const async = require('async'); const async = require('async');
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const mimeTypes = require('mime-types'); const mimeTypes = require('mime-types');
const yazl = require('yazl'); const yazl = require('yazl');
function notEnabledError() { function notEnabledError() {
return Errors.General('Web server is not enabled', ErrNotEnabled); return Errors.General('Web server is not enabled', ErrNotEnabled);
} }
class FileAreaWebAccess { class FileAreaWebAccess {
constructor() { constructor() {
this.hashids = new hashids(Config.general.boardName); this.hashids = new hashids(Config().general.boardName);
this.expireTimers = {}; // hashId->timer this.expireTimers = {}; // hashId->timer
} }
startup(cb) { startup(cb) {
const self = this; const self = this;
async.series( async.series(
[ [
function initFromDb(callback) { function initFromDb(callback) {
return self.load(callback); return self.load(callback);
}, },
function addWebRoute(callback) { function addWebRoute(callback) {
self.webServer = getServer(webServerPackageName); self.webServer = getServer(webServerPackageName);
if(!self.webServer) { if(!self.webServer) {
return callback(Errors.DoesNotExist(`Server with package name "${webServerPackageName}" does not exist`)); return callback(Errors.DoesNotExist(`Server with package name "${webServerPackageName}" does not exist`));
} }
if(self.isEnabled()) { if(self.isEnabled()) {
const routeAdded = self.webServer.instance.addRoute({ const routeAdded = self.webServer.instance.addRoute({
method : 'GET', method : 'GET',
path : Config.fileBase.web.routePath, path : Config().fileBase.web.routePath,
handler : self.routeWebRequest.bind(self), handler : self.routeWebRequest.bind(self),
}); });
return callback(routeAdded ? null : Errors.General('Failed adding route')); return callback(routeAdded ? null : Errors.General('Failed adding route'));
} else { } else {
return callback(null); // not enabled, but no error return callback(null); // not enabled, but no error
} }
} }
], ],
err => { err => {
return cb(err); return cb(err);
} }
); );
} }
shutdown(cb) { shutdown(cb) {
return cb(null); return cb(null);
} }
isEnabled() { isEnabled() {
return this.webServer.instance.isEnabled(); return this.webServer.instance.isEnabled();
} }
static getHashIdTypes() { static getHashIdTypes() {
return { return {
SingleFile : 0, SingleFile : 0,
BatchArchive : 1, BatchArchive : 1,
}; };
} }
load(cb) { load(cb) {
// //
// Load entries, register expiration timers // Load entries, register expiration timers
// //
FileDb.each( FileDb.each(
`SELECT hash_id, expire_timestamp `SELECT hash_id, expire_timestamp
FROM file_web_serve;`, FROM file_web_serve;`,
(err, row) => { (err, row) => {
if(row) { if(row) {
this.scheduleExpire(row.hash_id, moment(row.expire_timestamp)); this.scheduleExpire(row.hash_id, moment(row.expire_timestamp));
} }
}, },
err => { err => {
return cb(err); return cb(err);
} }
); );
} }
removeEntry(hashId) { removeEntry(hashId) {
// //
// Delete record from DB, and our timer // Delete record from DB, and our timer
// //
FileDb.run( FileDb.run(
`DELETE FROM file_web_serve `DELETE FROM file_web_serve
WHERE hash_id = ?;`, WHERE hash_id = ?;`,
[ hashId ] [ hashId ]
); );
delete this.expireTimers[hashId]; delete this.expireTimers[hashId];
} }
scheduleExpire(hashId, expireTime) { scheduleExpire(hashId, expireTime) {
// remove any previous entry for this hashId // remove any previous entry for this hashId
const previous = this.expireTimers[hashId]; const previous = this.expireTimers[hashId];
if(previous) { if(previous) {
clearTimeout(previous); clearTimeout(previous);
delete this.expireTimers[hashId]; delete this.expireTimers[hashId];
} }
const timeoutMs = expireTime.diff(moment()); const timeoutMs = expireTime.diff(moment());
if(timeoutMs <= 0) { if(timeoutMs <= 0) {
setImmediate( () => { setImmediate( () => {
this.removeEntry(hashId); this.removeEntry(hashId);
}); });
} else { } else {
this.expireTimers[hashId] = setTimeout( () => { this.expireTimers[hashId] = setTimeout( () => {
this.removeEntry(hashId); this.removeEntry(hashId);
}, timeoutMs); }, timeoutMs);
} }
} }
loadServedHashId(hashId, cb) { loadServedHashId(hashId, cb) {
FileDb.get( FileDb.get(
`SELECT expire_timestamp FROM `SELECT expire_timestamp FROM
file_web_serve file_web_serve
WHERE hash_id = ?`, WHERE hash_id = ?`,
[ hashId ], [ hashId ],
(err, result) => { (err, result) => {
if(err || !result) { if(err || !result) {
return cb(err ? err : Errors.DoesNotExist('Invalid or missing hash ID')); return cb(err ? err : Errors.DoesNotExist('Invalid or missing hash ID'));
} }
const decoded = this.hashids.decode(hashId); const decoded = this.hashids.decode(hashId);
// decode() should provide an array of [ userId, hashIdType, id, ... ] // decode() should provide an array of [ userId, hashIdType, id, ... ]
if(!Array.isArray(decoded) || decoded.length < 3) { if(!Array.isArray(decoded) || decoded.length < 3) {
return cb(Errors.Invalid('Invalid or unknown hash ID')); return cb(Errors.Invalid('Invalid or unknown hash ID'));
} }
const servedItem = { const servedItem = {
hashId : hashId, hashId : hashId,
userId : decoded[0], userId : decoded[0],
hashIdType : decoded[1], hashIdType : decoded[1],
expireTimestamp : moment(result.expire_timestamp), expireTimestamp : moment(result.expire_timestamp),
}; };
if(FileAreaWebAccess.getHashIdTypes().SingleFile === servedItem.hashIdType) { if(FileAreaWebAccess.getHashIdTypes().SingleFile === servedItem.hashIdType) {
servedItem.fileIds = decoded.slice(2); servedItem.fileIds = decoded.slice(2);
} }
return cb(null, servedItem); return cb(null, servedItem);
} }
); );
} }
getSingleFileHashId(client, fileEntry) { getSingleFileHashId(client, fileEntry) {
return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().SingleFile, [ fileEntry.fileId ] ); return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().SingleFile, [ fileEntry.fileId ] );
} }
getBatchArchiveHashId(client, batchId) { getBatchArchiveHashId(client, batchId) {
return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().BatchArchive, batchId); return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().BatchArchive, batchId);
} }
getHashId(client, hashIdType, identifier) { getHashId(client, hashIdType, identifier) {
return this.hashids.encode(client.user.userId, hashIdType, identifier); return this.hashids.encode(client.user.userId, hashIdType, identifier);
} }
buildSingleFileTempDownloadLink(client, fileEntry, hashId) { buildSingleFileTempDownloadLink(client, fileEntry, hashId) {
hashId = hashId || this.getSingleFileHashId(client, fileEntry); hashId = hashId || this.getSingleFileHashId(client, fileEntry);
return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
} }
buildBatchArchiveTempDownloadLink(client, hashId) { buildBatchArchiveTempDownloadLink(client, hashId) {
return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
} }
getExistingTempDownloadServeItem(client, fileEntry, cb) { getExistingTempDownloadServeItem(client, fileEntry, cb) {
if(!this.isEnabled()) { if(!this.isEnabled()) {
return cb(notEnabledError()); return cb(notEnabledError());
} }
const hashId = this.getSingleFileHashId(client, fileEntry); const hashId = this.getSingleFileHashId(client, fileEntry);
this.loadServedHashId(hashId, (err, servedItem) => { this.loadServedHashId(hashId, (err, servedItem) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
servedItem.url = this.buildSingleFileTempDownloadLink(client, fileEntry); servedItem.url = this.buildSingleFileTempDownloadLink(client, fileEntry);
return cb(null, servedItem); return cb(null, servedItem);
}); });
} }
_addOrUpdateHashIdRecord(dbOrTrans, hashId, expireTime, cb) { _addOrUpdateHashIdRecord(dbOrTrans, hashId, expireTime, cb) {
// add/update rec with hash id and (latest) timestamp // add/update rec with hash id and (latest) timestamp
dbOrTrans.run( dbOrTrans.run(
`REPLACE INTO file_web_serve (hash_id, expire_timestamp) `REPLACE INTO file_web_serve (hash_id, expire_timestamp)
VALUES (?, ?);`, VALUES (?, ?);`,
[ hashId, getISOTimestampString(expireTime) ], [ hashId, getISOTimestampString(expireTime) ],
err => { err => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
this.scheduleExpire(hashId, expireTime); this.scheduleExpire(hashId, expireTime);
return cb(null); return cb(null);
} }
); );
} }
createAndServeTempDownload(client, fileEntry, options, cb) { createAndServeTempDownload(client, fileEntry, options, cb) {
if(!this.isEnabled()) { if(!this.isEnabled()) {
return cb(notEnabledError()); return cb(notEnabledError());
} }
const hashId = this.getSingleFileHashId(client, fileEntry); const hashId = this.getSingleFileHashId(client, fileEntry);
const url = this.buildSingleFileTempDownloadLink(client, fileEntry, hashId); const url = this.buildSingleFileTempDownloadLink(client, fileEntry, hashId);
options.expireTime = options.expireTime || moment().add(2, 'days'); options.expireTime = options.expireTime || moment().add(2, 'days');
this._addOrUpdateHashIdRecord(FileDb, hashId, options.expireTime, err => { this._addOrUpdateHashIdRecord(FileDb, hashId, options.expireTime, err => {
return cb(err, url); return cb(err, url);
}); });
} }
createAndServeTempBatchDownload(client, fileEntries, options, cb) { createAndServeTempBatchDownload(client, fileEntries, options, cb) {
if(!this.isEnabled()) { if(!this.isEnabled()) {
return cb(notEnabledError()); return cb(notEnabledError());
} }
const batchId = moment().utc().unix(); const batchId = moment().utc().unix();
const hashId = this.getBatchArchiveHashId(client, batchId); const hashId = this.getBatchArchiveHashId(client, batchId);
const url = this.buildBatchArchiveTempDownloadLink(client, hashId); const url = this.buildBatchArchiveTempDownloadLink(client, hashId);
options.expireTime = options.expireTime || moment().add(2, 'days'); options.expireTime = options.expireTime || moment().add(2, 'days');
FileDb.beginTransaction( (err, trans) => { FileDb.beginTransaction( (err, trans) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
this._addOrUpdateHashIdRecord(trans, hashId, options.expireTime, err => { this._addOrUpdateHashIdRecord(trans, hashId, options.expireTime, err => {
if(err) { if(err) {
return trans.rollback( () => { return trans.rollback( () => {
return cb(err); return cb(err);
}); });
} }
async.eachSeries(fileEntries, (entry, nextEntry) => { async.eachSeries(fileEntries, (entry, nextEntry) => {
trans.run( trans.run(
`INSERT INTO file_web_serve_batch (hash_id, file_id) `INSERT INTO file_web_serve_batch (hash_id, file_id)
VALUES (?, ?);`, VALUES (?, ?);`,
[ hashId, entry.fileId ], [ hashId, entry.fileId ],
err => { err => {
return nextEntry(err); return nextEntry(err);
} }
); );
}, err => { }, err => {
trans[err ? 'rollback' : 'commit']( () => { trans[err ? 'rollback' : 'commit']( () => {
return cb(err, url); return cb(err, url);
}); });
}); });
}); });
}); });
} }
fileNotFound(resp) { fileNotFound(resp) {
return this.webServer.instance.fileNotFound(resp); return this.webServer.instance.fileNotFound(resp);
} }
routeWebRequest(req, resp) { routeWebRequest(req, resp) {
const hashId = paths.basename(req.url); const hashId = paths.basename(req.url);
Log.debug( { hashId : hashId, url : req.url }, 'File area web request'); Log.debug( { hashId : hashId, url : req.url }, 'File area web request');
this.loadServedHashId(hashId, (err, servedItem) => { this.loadServedHashId(hashId, (err, servedItem) => {
if(err) { if(err) {
return this.fileNotFound(resp); return this.fileNotFound(resp);
} }
const hashIdTypes = FileAreaWebAccess.getHashIdTypes(); const hashIdTypes = FileAreaWebAccess.getHashIdTypes();
switch(servedItem.hashIdType) { switch(servedItem.hashIdType) {
case hashIdTypes.SingleFile : case hashIdTypes.SingleFile :
return this.routeWebRequestForSingleFile(servedItem, req, resp); return this.routeWebRequestForSingleFile(servedItem, req, resp);
case hashIdTypes.BatchArchive : case hashIdTypes.BatchArchive :
return this.routeWebRequestForBatchArchive(servedItem, req, resp); return this.routeWebRequestForBatchArchive(servedItem, req, resp);
default : default :
return this.fileNotFound(resp); return this.fileNotFound(resp);
} }
}); });
} }
routeWebRequestForSingleFile(servedItem, req, resp) { routeWebRequestForSingleFile(servedItem, req, resp) {
Log.debug( { servedItem : servedItem }, 'Single file web request'); Log.debug( { servedItem : servedItem }, 'Single file web request');
const fileEntry = new FileEntry(); const fileEntry = new FileEntry();
servedItem.fileId = servedItem.fileIds[0]; servedItem.fileId = servedItem.fileIds[0];
fileEntry.load(servedItem.fileId, err => { fileEntry.load(servedItem.fileId, err => {
if(err) { if(err) {
return this.fileNotFound(resp); return this.fileNotFound(resp);
} }
const filePath = fileEntry.filePath; const filePath = fileEntry.filePath;
if(!filePath) { if(!filePath) {
return this.fileNotFound(resp); return this.fileNotFound(resp);
} }
fs.stat(filePath, (err, stats) => { fs.stat(filePath, (err, stats) => {
if(err) { if(err) {
return this.fileNotFound(resp); return this.fileNotFound(resp);
} }
resp.on('close', () => { resp.on('close', () => {
// connection closed *before* the response was fully sent // connection closed *before* the response was fully sent
// :TODO: Log and such // :TODO: Log and such
}); });
resp.on('finish', () => { resp.on('finish', () => {
// transfer completed fully // transfer completed fully
this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size); this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size, [ fileEntry ]);
}); });
const headers = { const headers = {
'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'),
'Content-Length' : stats.size, 'Content-Length' : stats.size,
'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`,
}; };
const readStream = fs.createReadStream(filePath); const readStream = fs.createReadStream(filePath);
resp.writeHead(200, headers); resp.writeHead(200, headers);
return readStream.pipe(resp); return readStream.pipe(resp);
}); });
}); });
} }
routeWebRequestForBatchArchive(servedItem, req, resp) { routeWebRequestForBatchArchive(servedItem, req, resp) {
Log.debug( { servedItem : servedItem }, 'Batch file web request'); Log.debug( { servedItem : servedItem }, 'Batch file web request');
// //
// We are going to build an on-the-fly zip file stream of 1:n // We are going to build an on-the-fly zip file stream of 1:n
// files in the batch. // files in the batch.
// //
// First, collect all file IDs // First, collect all file IDs
// //
const self = this; const self = this;
async.waterfall( async.waterfall(
[ [
function fetchFileIds(callback) { function fetchFileIds(callback) {
FileDb.all( FileDb.all(
`SELECT file_id `SELECT file_id
FROM file_web_serve_batch FROM file_web_serve_batch
WHERE hash_id = ?;`, WHERE hash_id = ?;`,
[ servedItem.hashId ], [ servedItem.hashId ],
(err, fileIdRows) => { (err, fileIdRows) => {
if(err || !Array.isArray(fileIdRows) || 0 === fileIdRows.length) { if(err || !Array.isArray(fileIdRows) || 0 === fileIdRows.length) {
return callback(Errors.DoesNotExist('Could not get file IDs for batch')); return callback(Errors.DoesNotExist('Could not get file IDs for batch'));
} }
return callback(null, fileIdRows.map(r => r.file_id)); return callback(null, fileIdRows.map(r => r.file_id));
} }
); );
}, },
function loadFileEntries(fileIds, callback) { function loadFileEntries(fileIds, callback) {
const filePaths = []; async.map(fileIds, (fileId, nextFileId) => {
async.eachSeries(fileIds, (fileId, nextFileId) => { const fileEntry = new FileEntry();
const fileEntry = new FileEntry(); fileEntry.load(fileId, err => {
fileEntry.load(fileId, err => { return nextFileId(err, fileEntry);
if(!err) { });
filePaths.push(fileEntry.filePath); }, (err, fileEntries) => {
} if(err) {
return nextFileId(err); return callback(Errors.DoesNotExist('Could not load file IDs for batch'));
}); }
}, err => {
if(err) { return callback(null, fileEntries);
return callback(Errors.DoesNotExist('Coudl not load file IDs for batch')); });
} },
function createAndServeStream(fileEntries, callback) {
return callback(null, filePaths); const filePaths = fileEntries.map(fe => fe.filePath);
}); Log.trace( { filePaths : filePaths }, 'Creating zip archive for batch web request');
},
function createAndServeStream(filePaths, callback) { const zipFile = new yazl.ZipFile();
Log.trace( { filePaths : filePaths }, 'Creating zip archive for batch web request');
zipFile.on('error', err => {
const zipFile = new yazl.ZipFile(); Log.warn( { error : err.message }, 'Error adding file to batch web request archive');
});
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
filePaths.forEach(fp => { paths.basename(fp), // filename/path *stored in archive*
zipFile.addFile( {
fp, // path to physical file compress : false, // :TODO: do this smartly - if ext is in set = false, else true via isArchive() or such... mimeDB has this for us.
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'));
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('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);
resp.on('finish', () => { });
// transfer completed fully
self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize); const batchFileName = `batch_${servedItem.hashId}.zip`;
});
const headers = {
const batchFileName = `batch_${servedItem.hashId}.zip`; 'Content-Type' : mimeTypes.contentType(batchFileName) || mimeTypes.contentType('.bin'),
'Content-Length' : finalZipSize,
const headers = { 'Content-Disposition' : `attachment; filename="${batchFileName}"`,
'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);
});
resp.writeHead(200, headers); }
return zipFile.outputStream.pipe(resp); ],
}); err => {
} if(err) {
], // :TODO: Log me!
err => { return this.fileNotFound(resp);
if(err) { }
// :TODO: Log me!
return this.fileNotFound(resp); // ...otherwise, we would have called resp() already.
} }
);
// ...otherwise, we would have called resp() already. }
}
); updateDownloadStatsForUserIdAndSystem(userId, dlBytes, fileEntries) {
} async.waterfall(
[
updateDownloadStatsForUserIdAndSystem(userId, dlBytes, cb) { function fetchActiveUser(callback) {
async.waterfall( const clientForUserId = getConnectionByUserId(userId);
[ if(clientForUserId) {
function fetchActiveUser(callback) { return callback(null, clientForUserId.user);
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);
// 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);
function updateStats(user, callback) {
StatLog.incrementUserStat(user, 'dl_total_count', 1); StatLog.incrementSystemStat(SysProps.FileDlTotalCount, 1);
StatLog.incrementUserStat(user, 'dl_total_bytes', dlBytes); StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, dlBytes);
StatLog.incrementSystemStat('dl_total_count', 1);
StatLog.incrementSystemStat('dl_total_bytes', dlBytes); return callback(null, user);
},
return callback(null); function sendEvent(user, callback) {
} Events.emit(
], Events.getSystemEvents().UserDownload,
err => { {
if(cb) { user : user,
return cb(err); files : fileEntries,
} }
} );
); return callback(null);
} }
]
);
}
} }
module.exports = new FileAreaWebAccess(); module.exports = new FileAreaWebAccess();

File diff suppressed because it is too large Load Diff

View File

@ -1,104 +1,88 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// enigma-bbs // enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const stringFormat = require('./string_format.js'); const { getSortedAvailableFileAreas } = require('./file_base_area.js');
const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; const StatLog = require('./stat_log.js');
const StatLog = require('./stat_log.js'); const SysProps = require('./system_property.js');
// deps // deps
const async = require('async'); const async = require('async');
exports.moduleInfo = { exports.moduleInfo = {
name : 'File Area Selector', name : 'File Area Selector',
desc : 'Select from available file areas', desc : 'Select from available file areas',
author : 'NuSkooler', author : 'NuSkooler',
}; };
const MciViewIds = { const MciViewIds = {
areaList : 1, areaList : 1,
}; };
exports.getModule = class FileAreaSelectModule extends MenuModule { exports.getModule = class FileAreaSelectModule extends MenuModule {
constructor(options) { constructor(options) {
super(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 = { return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
selectArea : (formData, extraArgs, cb) => { }
const area = this.availAreas[formData.value.areaSelect] || 0; };
}
const filterCriteria = { mciReady(mciData, cb) {
areaTag : area.areaTag, super.mciReady(mciData, err => {
}; if(err) {
return cb(err);
}
const menuOpts = { const self = this;
extraArgs : {
filterCriteria : filterCriteria,
},
menuFlags : [ 'popParent' ],
};
return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); async.waterfall(
} [
}; function mergeAreaStats(callback) {
} const areaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats) || { areas : {} };
loadAvailAreas() { // we could use 'sort' alone, but area/conf sorting has some special properties; user can still override
this.availAreas = getSortedAvailableFileAreas(this.client); 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) { return callback(null, availAreas);
super.mciReady(mciData, err => { },
if(err) { function prepView(availAreas, callback) {
return cb(err); 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( return callback(null);
[ });
function mergeAreaStats(callback) { }
const areaStats = StatLog.getSystemStat('file_base_area_stats') || { areas : {} }; ],
err => {
self.availAreas.forEach(area => { return cb(err);
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);
}
);
});
}
}; };

View File

@ -1,244 +1,237 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
const DownloadQueue = require('./download_queue.js'); const DownloadQueue = require('./download_queue.js');
const theme = require('./theme.js'); const theme = require('./theme.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const stringFormat = require('./string_format.js'); const FileAreaWeb = require('./file_area_web.js');
const FileAreaWeb = require('./file_area_web.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const moment = require('moment'); const moment = require('moment');
exports.moduleInfo = { exports.moduleInfo = {
name : 'File Base Download Queue Manager', name : 'File Base Download Queue Manager',
desc : 'Module for interacting with download queue/batch', desc : 'Module for interacting with download queue/batch',
author : 'NuSkooler', author : 'NuSkooler',
}; };
const FormIds = { const FormIds = {
queueManager : 0, queueManager : 0,
}; };
const MciViewIds = { const MciViewIds = {
queueManager : { queueManager : {
queue : 1, queue : 1,
navMenu : 2, navMenu : 2,
customRangeStart : 10, customRangeStart : 10,
}, },
}; };
exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { exports.getModule = class FileBaseDownloadQueueManager extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.dlQueue = new DownloadQueue(this.client); this.dlQueue = new DownloadQueue(this.client);
if(_.has(options, 'lastMenuResult.sentFileIds')) { if(_.has(options, 'lastMenuResult.sentFileIds')) {
this.sentFileIds = options.lastMenuResult.sentFileIds; this.sentFileIds = options.lastMenuResult.sentFileIds;
} }
this.fallbackOnly = options.lastMenuResult ? true : false; this.fallbackOnly = options.lastMenuResult ? true : false;
this.menuMethods = { this.menuMethods = {
downloadAll : (formData, extraArgs, cb) => { downloadAll : (formData, extraArgs, cb) => {
const modOpts = { const modOpts = {
extraArgs : { extraArgs : {
sendQueue : this.dlQueue.items, sendQueue : this.dlQueue.items,
direction : 'send', direction : 'send',
} }
}; };
return this.gotoMenu(this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', modOpts, cb); 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];
removeItem : (formData, extraArgs, cb) => { if(!selectedItem) {
const selectedItem = this.dlQueue.items[formData.value.queueItem]; return cb(null);
if(!selectedItem) { }
return cb(null);
}
this.dlQueue.removeItems(selectedItem.fileId); this.dlQueue.removeItems(selectedItem.fileId);
// :TODO: broken: does not redraw menu properly - needs fixed! // :TODO: broken: does not redraw menu properly - needs fixed!
return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb); return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb);
}, },
clearQueue : (formData, extraArgs, cb) => { clearQueue : (formData, extraArgs, cb) => {
this.dlQueue.clear(); this.dlQueue.clear();
// :TODO: broken: does not redraw menu properly - needs fixed! // :TODO: broken: does not redraw menu properly - needs fixed!
return this.removeItemsFromDownloadQueueView('all', cb); return this.removeItemsFromDownloadQueueView('all', cb);
} }
}; };
} }
initSequence() { initSequence() {
if(0 === this.dlQueue.items.length) { if(0 === this.dlQueue.items.length) {
if(this.sendFileIds) { if(this.sendFileIds) {
// we've finished everything up - just fall back // we've finished everything up - just fall back
return this.prevMenu(); return this.prevMenu();
} }
// Simply an empty D/L queue: Present a specialized "empty queue" page // Simply an empty D/L queue: Present a specialized "empty queue" page
return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue');
} }
const self = this; const self = this;
async.series( async.series(
[ [
function beforeArt(callback) { function beforeArt(callback) {
return self.beforeArt(callback); return self.beforeArt(callback);
}, },
function display(callback) { function display(callback) {
return self.displayQueueManagerPage(false, callback); return self.displayQueueManagerPage(false, callback);
} }
], ],
() => { () => {
return self.finishedLoading(); return self.finishedLoading();
} }
); );
} }
removeItemsFromDownloadQueueView(itemIndex, cb) { removeItemsFromDownloadQueueView(itemIndex, cb) {
const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
if(!queueView) { if(!queueView) {
return cb(Errors.DoesNotExist('Queue view does not exist')); return cb(Errors.DoesNotExist('Queue view does not exist'));
} }
if('all' === itemIndex) { if('all' === itemIndex) {
queueView.setItems([]); queueView.setItems([]);
queueView.setFocusItems([]); queueView.setFocusItems([]);
} else { } else {
queueView.removeItem(itemIndex); queueView.removeItem(itemIndex);
} }
queueView.redraw(); queueView.redraw();
return cb(null); return cb(null);
} }
displayWebDownloadLinkForFileEntry(fileEntry) { displayWebDownloadLinkForFileEntry(fileEntry) {
FileAreaWeb.getExistingTempDownloadServeItem(this.client, fileEntry, (err, serveItem) => { FileAreaWeb.getExistingTempDownloadServeItem(this.client, fileEntry, (err, serveItem) => {
if(serveItem && serveItem.url) { if(serveItem && serveItem.url) {
const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
fileEntry.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; fileEntry.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url;
fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat);
} else { } else {
fileEntry.webDlLink = ''; fileEntry.webDlLink = '';
fileEntry.webDlExpire = ''; fileEntry.webDlExpire = '';
} }
this.updateCustomViewTextsWithFilter( this.updateCustomViewTextsWithFilter(
'queueManager', 'queueManager',
MciViewIds.queueManager.customRangeStart, fileEntry, MciViewIds.queueManager.customRangeStart, fileEntry,
{ filter : [ '{webDlLink}', '{webDlExpire}' ] } { filter : [ '{webDlLink}', '{webDlExpire}' ] }
); );
}); });
} }
updateDownloadQueueView(cb) { updateDownloadQueueView(cb) {
const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
if(!queueView) { if(!queueView) {
return cb(Errors.DoesNotExist('Queue view does not exist')); return cb(Errors.DoesNotExist('Queue view does not exist'));
} }
const queueListFormat = this.menuConfig.config.queueListFormat || '{fileName} {byteSize}'; queueView.setItems(this.dlQueue.items);
const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); queueView.on('index update', idx => {
queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); const fileEntry = this.dlQueue.items[idx];
this.displayWebDownloadLinkForFileEntry(fileEntry);
});
queueView.on('index update', idx => { queueView.redraw();
const fileEntry = this.dlQueue.items[idx]; this.displayWebDownloadLinkForFileEntry(this.dlQueue.items[0]);
this.displayWebDownloadLinkForFileEntry(fileEntry);
});
queueView.redraw(); return cb(null);
this.displayWebDownloadLinkForFileEntry(this.dlQueue.items[0]); }
return cb(null); displayQueueManagerPage(clearScreen, cb) {
} const self = this;
displayQueueManagerPage(clearScreen, cb) { async.series(
const self = this; [
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( displayArtAndPrepViewController(name, options, cb) {
[ const self = this;
function prepArtAndViewController(callback) { const config = this.menuConfig.config;
return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback);
},
function populateViews(callback) {
return self.updateDownloadQueueView(callback);
}
],
err => {
if(cb) {
return cb(err);
}
}
);
}
displayArtAndPrepViewController(name, options, cb) { async.waterfall(
const self = this; [
const config = this.menuConfig.config; function readyAndDisplayArt(callback) {
if(options.clearScreen) {
self.client.term.rawWrite(ansi.resetScreen());
}
async.waterfall( theme.displayThemedAsset(
[ config.art[name],
function readyAndDisplayArt(callback) { self.client,
if(options.clearScreen) { { font : self.menuConfig.font, trailingLF : false },
self.client.term.rawWrite(ansi.resetScreen()); (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( if(!_.isUndefined(options.noInput)) {
config.art[name], vcOpts.noInput = options.noInput;
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)) { const vc = self.addViewController(name, new ViewController(vcOpts));
vcOpts.noInput = options.noInput;
}
const vc = self.addViewController(name, new ViewController(vcOpts)); const loadOpts = {
callingMenu : self,
mciMap : artData.mciMap,
formId : FormIds[name],
};
const loadOpts = { return vc.loadFromMenuConfig(loadOpts, callback);
callingMenu : self, }
mciMap : artData.mciMap,
formId : FormIds[name],
};
return vc.loadFromMenuConfig(loadOpts, callback); self.viewControllers[name].setFocus(true);
} return callback(null);
self.viewControllers[name].setFocus(true); },
return callback(null); ],
err => {
}, return cb(err);
], }
err => { );
return cb(err); }
}
);
}
}; };

View File

@ -1,155 +1,157 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// deps const UserProps = require('./user_property.js');
const _ = require('lodash');
const uuidV4 = require('uuid/v4'); // deps
const _ = require('lodash');
const uuidV4 = require('uuid/v4');
module.exports = class FileBaseFilters { module.exports = class FileBaseFilters {
constructor(client) { constructor(client) {
this.client = client; this.client = client;
this.load(); this.load();
} }
static get OrderByValues() { static get OrderByValues() {
return [ 'descending', 'ascending' ]; return [ 'descending', 'ascending' ];
} }
static get SortByValues() { static get SortByValues() {
return [ return [
'upload_timestamp', 'upload_timestamp',
'upload_by_username', 'upload_by_username',
'dl_count', 'dl_count',
'user_rating', 'user_rating',
'est_release_year', 'est_release_year',
'byte_size', 'byte_size',
'file_name', 'file_name',
]; ];
} }
toArray() { toArray() {
return _.map(this.filters, (filter, uuid) => { return _.map(this.filters, (filter, uuid) => {
return Object.assign( { uuid : uuid }, filter ); return Object.assign( { uuid : uuid }, filter );
}); });
} }
get(filterUuid) { get(filterUuid) {
return this.filters[filterUuid]; return this.filters[filterUuid];
} }
add(filterInfo) { add(filterInfo) {
const filterUuid = uuidV4(); const filterUuid = uuidV4();
filterInfo.tags = this.cleanTags(filterInfo.tags); filterInfo.tags = this.cleanTags(filterInfo.tags);
this.filters[filterUuid] = filterInfo; this.filters[filterUuid] = filterInfo;
return filterUuid; return filterUuid;
} }
replace(filterUuid, filterInfo) { replace(filterUuid, filterInfo) {
const filter = this.get(filterUuid); const filter = this.get(filterUuid);
if(!filter) { if(!filter) {
return false; return false;
} }
filterInfo.tags = this.cleanTags(filterInfo.tags); filterInfo.tags = this.cleanTags(filterInfo.tags);
this.filters[filterUuid] = filterInfo; this.filters[filterUuid] = filterInfo;
return true; return true;
} }
remove(filterUuid) { remove(filterUuid) {
delete this.filters[filterUuid]; delete this.filters[filterUuid];
} }
load() { load() {
let filtersProperty = this.client.user.properties.file_base_filters; let filtersProperty = this.client.user.properties[UserProps.FileBaseFilters];
let defaulted; let defaulted;
if(!filtersProperty) { if(!filtersProperty) {
filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters()); filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters());
defaulted = true; defaulted = true;
} }
try { try {
this.filters = JSON.parse(filtersProperty); this.filters = JSON.parse(filtersProperty);
} catch(e) { } catch(e) {
this.filters = FileBaseFilters.getBuiltInSystemFilters(); // something bad happened; reset everything back to defaults :( this.filters = FileBaseFilters.getBuiltInSystemFilters(); // something bad happened; reset everything back to defaults :(
defaulted = true; defaulted = true;
this.client.log.error( { error : e.message, property : filtersProperty }, 'Failed parsing file base filters property' ); this.client.log.error( { error : e.message, property : filtersProperty }, 'Failed parsing file base filters property' );
} }
if(defaulted) { if(defaulted) {
this.persist( err => { this.persist( err => {
if(!err) { if(!err) {
const defaultActiveUuid = this.toArray()[0].uuid; const defaultActiveUuid = this.toArray()[0].uuid;
this.setActive(defaultActiveUuid); this.setActive(defaultActiveUuid);
} }
}); });
} }
} }
persist(cb) { persist(cb) {
return this.client.user.persistProperty('file_base_filters', JSON.stringify(this.filters), cb); return this.client.user.persistProperty(UserProps.FileBaseFilters, JSON.stringify(this.filters), cb);
} }
cleanTags(tags) { cleanTags(tags) {
return tags.toLowerCase().replace(/,?\s+|\,/g, ' ').trim(); return tags.toLowerCase().replace(/,?\s+|,/g, ' ').trim();
} }
setActive(filterUuid) { setActive(filterUuid) {
const activeFilter = this.get(filterUuid); const activeFilter = this.get(filterUuid);
if(activeFilter) { if(activeFilter) {
this.activeFilter = activeFilter; this.activeFilter = activeFilter;
this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid); this.client.user.persistProperty(UserProps.FileBaseFilterActiveUuid, filterUuid);
return true; return true;
} }
return false; return false;
} }
static getBuiltInSystemFilters() { static getBuiltInSystemFilters() {
const U_LATEST = '7458b09d-40ab-4f9b-a0d7-0cf866646329'; const U_LATEST = '7458b09d-40ab-4f9b-a0d7-0cf866646329';
const filters = { const filters = {
[ U_LATEST ] : { [ U_LATEST ] : {
name : 'By Date Added', name : 'By Date Added',
areaTag : '', // all areaTag : '', // all
terms : '', // * terms : '', // *
tags : '', // * tags : '', // *
order : 'descending', order : 'descending',
sort : 'upload_timestamp', sort : 'upload_timestamp',
uuid : U_LATEST, uuid : U_LATEST,
system : true, system : true,
} }
}; };
return filters; return filters;
} }
static getActiveFilter(client) { static getActiveFilter(client) {
return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid); return new FileBaseFilters(client).get(client.user.properties[UserProps.FileBaseFilterActiveUuid]);
} }
static getFileBaseLastViewedFileIdByUser(user) { static getFileBaseLastViewedFileIdByUser(user) {
return parseInt((user.properties.user_file_base_last_viewed || 0)); return parseInt((user.properties[UserProps.FileBaseLastViewedId] || 0));
} }
static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) { static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) {
if(!cb && _.isFunction(allowOlder)) { if(!cb && _.isFunction(allowOlder)) {
cb = allowOlder; cb = allowOlder;
allowOlder = false; allowOlder = false;
} }
const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user); const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user);
if(!allowOlder && fileId < current) { if(!allowOlder && fileId < current) {
if(cb) { if(cb) {
cb(null); cb(null);
} }
return; return;
} }
return user.persistProperty('user_file_base_last_viewed', fileId, cb); return user.persistProperty(UserProps.FileBaseLastViewedId, fileId, cb);
} }
}; };

View File

@ -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: <QUOTED_LFN> <DESC>[0x04<AppData>]\r\n
// * Multi line descriptions are stored with *escaped* \r\n pairs
// * Default template uses 0x2c for <AppData> 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);
});
}

View File

@ -1,120 +1,120 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas;
const FileBaseFilters = require('./file_base_filter.js'); const FileBaseFilters = require('./file_base_filter.js');
// deps // deps
const async = require('async'); const async = require('async');
exports.moduleInfo = { exports.moduleInfo = {
name : 'File Base Search', name : 'File Base Search',
desc : 'Module for quickly searching the file base', desc : 'Module for quickly searching the file base',
author : 'NuSkooler', author : 'NuSkooler',
}; };
const MciViewIds = { const MciViewIds = {
search : { search : {
searchTerms : 1, searchTerms : 1,
search : 2, search : 2,
tags : 3, tags : 3,
area : 4, area : 4,
orderBy : 5, orderBy : 5,
sort : 6, sort : 6,
advSearch : 7, advSearch : 7,
} }
}; };
exports.getModule = class FileBaseSearch extends MenuModule { exports.getModule = class FileBaseSearch extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.menuMethods = { this.menuMethods = {
search : (formData, extraArgs, cb) => { search : (formData, extraArgs, cb) => {
const isAdvanced = formData.submitId === MciViewIds.search.advSearch; const isAdvanced = formData.submitId === MciViewIds.search.advSearch;
return this.searchNow(formData, isAdvanced, cb); return this.searchNow(formData, isAdvanced, cb);
}, },
}; };
} }
mciReady(mciData, cb) { mciReady(mciData, cb) {
super.mciReady(mciData, err => { super.mciReady(mciData, err => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
const self = this; const self = this;
const vc = self.addViewController( 'search', new ViewController( { client : this.client } ) ); const vc = self.addViewController( 'search', new ViewController( { client : this.client } ) );
async.series( async.series(
[ [
function loadFromConfig(callback) { function loadFromConfig(callback) {
return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback);
}, },
function populateAreas(callback) { function populateAreas(callback) {
self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []);
const areasView = vc.getView(MciViewIds.search.area); const areasView = vc.getView(MciViewIds.search.area);
areasView.setItems( self.availAreas.map( a => a.name ) ); areasView.setItems( self.availAreas.map( a => a.name ) );
areasView.redraw(); areasView.redraw();
vc.switchFocus(MciViewIds.search.searchTerms); vc.switchFocus(MciViewIds.search.searchTerms);
return callback(null); return callback(null);
} }
], ],
err => { err => {
return cb(err); return cb(err);
} }
); );
}); });
} }
getSelectedAreaTag(index) { getSelectedAreaTag(index) {
if(0 === index) { if(0 === index) {
return ''; // -ALL- return ''; // -ALL-
} }
const area = this.availAreas[index]; const area = this.availAreas[index];
if(!area) { if(!area) {
return ''; return '';
} }
return area.areaTag; return area.areaTag;
} }
getOrderBy(index) { getOrderBy(index) {
return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0]; return FileBaseFilters.OrderByValues[index] || FileBaseFilters.OrderByValues[0];
} }
getSortBy(index) { getSortBy(index) {
return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0]; return FileBaseFilters.SortByValues[index] || FileBaseFilters.SortByValues[0];
} }
getFilterValuesFromFormData(formData, isAdvanced) { getFilterValuesFromFormData(formData, isAdvanced) {
const areaIndex = isAdvanced ? formData.value.areaIndex : 0; const areaIndex = isAdvanced ? formData.value.areaIndex : 0;
const orderByIndex = isAdvanced ? formData.value.orderByIndex : 0; const orderByIndex = isAdvanced ? formData.value.orderByIndex : 0;
const sortByIndex = isAdvanced ? formData.value.sortByIndex : 0; const sortByIndex = isAdvanced ? formData.value.sortByIndex : 0;
return { return {
areaTag : this.getSelectedAreaTag(areaIndex), areaTag : this.getSelectedAreaTag(areaIndex),
terms : formData.value.searchTerms, terms : formData.value.searchTerms,
tags : isAdvanced ? formData.value.tags : '', tags : isAdvanced ? formData.value.tags : '',
order : this.getOrderBy(orderByIndex), order : this.getOrderBy(orderByIndex),
sort : this.getSortBy(sortByIndex), sort : this.getSortBy(sortByIndex),
}; };
} }
searchNow(formData, isAdvanced, cb) { searchNow(formData, isAdvanced, cb) {
const filterCriteria = this.getFilterValuesFromFormData(formData, isAdvanced); const filterCriteria = this.getFilterValuesFromFormData(formData, isAdvanced);
const menuOpts = { const menuOpts = {
extraArgs : { extraArgs : {
filterCriteria : filterCriteria, filterCriteria : filterCriteria,
}, },
menuFlags : [ 'popParent' ], menuFlags : [ 'popParent' ],
}; };
return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
} }
}; };

View File

@ -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);
});
});
});
});
});
}
};

View File

@ -1,287 +1,282 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
const DownloadQueue = require('./download_queue.js'); const DownloadQueue = require('./download_queue.js');
const theme = require('./theme.js'); const theme = require('./theme.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const stringFormat = require('./string_format.js'); const FileAreaWeb = require('./file_area_web.js');
const FileAreaWeb = require('./file_area_web.js'); const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled;
const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; const Config = require('./config.js').get;
const Config = require('./config.js').config;
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const moment = require('moment'); const moment = require('moment');
exports.moduleInfo = { exports.moduleInfo = {
name : 'File Base Download Web Queue Manager', name : 'File Base Download Web Queue Manager',
desc : 'Module for interacting with web backed download queue/batch', desc : 'Module for interacting with web backed download queue/batch',
author : 'NuSkooler', author : 'NuSkooler',
}; };
const FormIds = { const FormIds = {
queueManager : 0 queueManager : 0
}; };
const MciViewIds = { const MciViewIds = {
queueManager : { queueManager : {
queue : 1, queue : 1,
navMenu : 2, navMenu : 2,
customRangeStart : 10, customRangeStart : 10,
} }
}; };
exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule { exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.dlQueue = new DownloadQueue(this.client); this.dlQueue = new DownloadQueue(this.client);
this.menuMethods = { this.menuMethods = {
removeItem : (formData, extraArgs, cb) => { removeItem : (formData, extraArgs, cb) => {
const selectedItem = this.dlQueue.items[formData.value.queueItem]; const selectedItem = this.dlQueue.items[formData.value.queueItem];
if(!selectedItem) { if(!selectedItem) {
return cb(null); return cb(null);
} }
this.dlQueue.removeItems(selectedItem.fileId); this.dlQueue.removeItems(selectedItem.fileId);
// :TODO: broken: does not redraw menu properly - needs fixed! // :TODO: broken: does not redraw menu properly - needs fixed!
return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb); return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb);
}, },
clearQueue : (formData, extraArgs, cb) => { clearQueue : (formData, extraArgs, cb) => {
this.dlQueue.clear(); this.dlQueue.clear();
// :TODO: broken: does not redraw menu properly - needs fixed! // :TODO: broken: does not redraw menu properly - needs fixed!
return this.removeItemsFromDownloadQueueView('all', cb); return this.removeItemsFromDownloadQueueView('all', cb);
}, },
getBatchLink : (formData, extraArgs, cb) => { getBatchLink : (formData, extraArgs, cb) => {
return this.generateAndDisplayBatchLink(cb); return this.generateAndDisplayBatchLink(cb);
} }
}; };
} }
initSequence() { initSequence() {
if(0 === this.dlQueue.items.length) { if(0 === this.dlQueue.items.length) {
return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue');
} }
const self = this; const self = this;
async.series( async.series(
[ [
function beforeArt(callback) { function beforeArt(callback) {
return self.beforeArt(callback); return self.beforeArt(callback);
}, },
function display(callback) { function display(callback) {
return self.displayQueueManagerPage(false, callback); return self.displayQueueManagerPage(false, callback);
} }
], ],
() => { () => {
return self.finishedLoading(); return self.finishedLoading();
} }
); );
} }
removeItemsFromDownloadQueueView(itemIndex, cb) { removeItemsFromDownloadQueueView(itemIndex, cb) {
const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
if(!queueView) { if(!queueView) {
return cb(Errors.DoesNotExist('Queue view does not exist')); return cb(Errors.DoesNotExist('Queue view does not exist'));
} }
if('all' === itemIndex) { if('all' === itemIndex) {
queueView.setItems([]); queueView.setItems([]);
queueView.setFocusItems([]); queueView.setFocusItems([]);
} else { } else {
queueView.removeItem(itemIndex); queueView.removeItem(itemIndex);
} }
queueView.redraw(); queueView.redraw();
return cb(null); return cb(null);
} }
displayFileInfoForFileEntry(fileEntry) { displayFileInfoForFileEntry(fileEntry) {
this.updateCustomViewTextsWithFilter( this.updateCustomViewTextsWithFilter(
'queueManager', 'queueManager',
MciViewIds.queueManager.customRangeStart, fileEntry, MciViewIds.queueManager.customRangeStart, fileEntry,
{ filter : [ '{webDlLink}', '{webDlExpire}', '{fileName}' ] } // :TODO: Others.... { filter : [ '{webDlLink}', '{webDlExpire}', '{fileName}' ] } // :TODO: Others....
); );
} }
updateDownloadQueueView(cb) { updateDownloadQueueView(cb) {
const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue);
if(!queueView) { if(!queueView) {
return cb(Errors.DoesNotExist('Queue view does not exist')); return cb(Errors.DoesNotExist('Queue view does not exist'));
} }
const queueListFormat = this.menuConfig.config.queueListFormat || '{webDlLink}'; queueView.setItems(this.dlQueue.items);
const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); queueView.on('index update', idx => {
queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); const fileEntry = this.dlQueue.items[idx];
this.displayFileInfoForFileEntry(fileEntry);
});
queueView.on('index update', idx => { queueView.redraw();
const fileEntry = this.dlQueue.items[idx]; this.displayFileInfoForFileEntry(this.dlQueue.items[0]);
this.displayFileInfoForFileEntry(fileEntry);
});
queueView.redraw(); return cb(null);
this.displayFileInfoForFileEntry(this.dlQueue.items[0]); }
return cb(null); generateAndDisplayBatchLink(cb) {
} const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes');
generateAndDisplayBatchLink(cb) { FileAreaWeb.createAndServeTempBatchDownload(
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); this.client,
this.dlQueue.items,
{
expireTime : expireTime
},
(err, webBatchDlLink) => {
// :TODO: handle not enabled -> display such
if(err) {
return cb(err);
}
FileAreaWeb.createAndServeTempBatchDownload( const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
this.client,
this.dlQueue.items,
{
expireTime : expireTime
},
(err, webBatchDlLink) => {
// :TODO: handle not enabled -> display such
if(err) {
return cb(err);
}
const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; const formatObj = {
webBatchDlLink : ansi.vtxHyperlink(this.client, webBatchDlLink) + webBatchDlLink,
webBatchDlExpire : expireTime.format(webDlExpireTimeFormat),
};
const formatObj = { this.updateCustomViewTextsWithFilter(
webBatchDlLink : ansi.vtxHyperlink(this.client, webBatchDlLink) + webBatchDlLink, 'queueManager',
webBatchDlExpire : expireTime.format(webDlExpireTimeFormat), MciViewIds.queueManager.customRangeStart,
}; formatObj,
{ filter : Object.keys(formatObj).map(k => '{' + k + '}' ) }
);
this.updateCustomViewTextsWithFilter( return cb(null);
'queueManager', }
MciViewIds.queueManager.customRangeStart, );
formatObj, }
{ filter : Object.keys(formatObj).map(k => '{' + k + '}' ) }
);
return cb(null); displayQueueManagerPage(clearScreen, cb) {
} const self = this;
);
}
displayQueueManagerPage(clearScreen, cb) { async.series(
const self = this; [
function prepArtAndViewController(callback) {
return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback);
},
function prepareQueueDownloadLinks(callback) {
const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
async.series( const config = Config();
[ async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => {
function prepArtAndViewController(callback) { FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => {
return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback); if(err) {
}, if(ErrNotEnabled === err.reasonCode) {
function prepareQueueDownloadLinks(callback) { return nextFileEntry(err); // we should have caught this prior
const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; }
async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => { const expireTime = moment().add(config.fileBase.web.expireMinutes, 'minutes');
FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => {
if(err) {
if(ErrNotEnabled === err.reasonCode) {
return nextFileEntry(err); // we should have caught this prior
}
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); FileAreaWeb.createAndServeTempDownload(
self.client,
fileEntry,
{ expireTime : expireTime },
(err, url) => {
if(err) {
return nextFileEntry(err);
}
FileAreaWeb.createAndServeTempDownload( fileEntry.webDlLinkRaw = url;
self.client, fileEntry.webDlLink = ansi.vtxHyperlink(self.client, url) + url;
fileEntry, fileEntry.webDlExpire = expireTime.format(webDlExpireTimeFormat);
{ expireTime : expireTime },
(err, url) => {
if(err) {
return nextFileEntry(err);
}
fileEntry.webDlLinkRaw = url; return nextFileEntry(null);
fileEntry.webDlLink = ansi.vtxHyperlink(self.client, url) + url; }
fileEntry.webDlExpire = expireTime.format(webDlExpireTimeFormat); );
} 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);
}
}
);
}
return nextFileEntry(null); displayArtAndPrepViewController(name, options, cb) {
} const self = this;
); const config = this.menuConfig.config;
} else {
fileEntry.webDlLinkRaw = serveItem.url;
fileEntry.webDlLink = ansi.vtxHyperlink(self.client, serveItem.url) + serveItem.url;
fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat);
return nextFileEntry(null);
}
});
}, err => {
return callback(err);
});
},
function populateViews(callback) {
return self.updateDownloadQueueView(callback);
}
],
err => {
if(cb) {
return cb(err);
}
}
);
}
displayArtAndPrepViewController(name, options, cb) { async.waterfall(
const self = this; [
const config = this.menuConfig.config; function readyAndDisplayArt(callback) {
if(options.clearScreen) {
self.client.term.rawWrite(ansi.resetScreen());
}
async.waterfall( theme.displayThemedAsset(
[ config.art[name],
function readyAndDisplayArt(callback) { self.client,
if(options.clearScreen) { { font : self.menuConfig.font, trailingLF : false },
self.client.term.rawWrite(ansi.resetScreen()); (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( if(!_.isUndefined(options.noInput)) {
config.art[name], vcOpts.noInput = options.noInput;
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)) { const vc = self.addViewController(name, new ViewController(vcOpts));
vcOpts.noInput = options.noInput;
}
const vc = self.addViewController(name, new ViewController(vcOpts)); const loadOpts = {
callingMenu : self,
mciMap : artData.mciMap,
formId : FormIds[name],
};
const loadOpts = { return vc.loadFromMenuConfig(loadOpts, callback);
callingMenu : self, }
mciMap : artData.mciMap,
formId : FormIds[name],
};
return vc.loadFromMenuConfig(loadOpts, callback); self.viewControllers[name].setFocus(true);
} return callback(null);
self.viewControllers[name].setFocus(true); },
return callback(null); ],
err => {
}, return cb(err);
], }
err => { );
return cb(err); }
}
);
}
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More