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": {
"indent": [
"error",
"tab",
4,
{
"SwitchCase" : 1
}

View File

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

1
.gitignore vendored
View File

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

View File

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

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
* Telnet, **SSH**, and both secure and non-secure [WebSocket](https://en.wikipedia.org/wiki/WebSocket) access built in! Additional servers are easy to implement
* [CP437](http://www.ascii-codes.com/) and UTF-8 output
* [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior
* Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support
* Renegade style pipe color codes
* [SQLite](http://sqlite.org/) storage of users, message areas, and so on
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption
* [SyncTERM](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior.
* Full [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support.
* Renegade style [pipe color codes](/docs/configuration/colour-codes.md).
* [SQLite](http://sqlite.org/) storage of users, message areas, etc.
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption.
* [Door support](docs/modding/door-servers.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), [Exodus](https://oddnetwork.org/exodus/) and [CombatNet](http://combatnet.us/) support!
* [Bunyan](https://github.com/trentm/node-bunyan) logging
* [Message networks](docs/messageareas/message-networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export
* [Gazelle](https://github.com/WhatCD/Gazelle) inspirted File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/servers/web-server.md). Legacy X/Y/Z modem also supported!
* [Bunyan](https://github.com/trentm/node-bunyan) logging!
* [Message networks](docs/messageareas/message-networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export. Messages Bases can also be exposed via [Gopher](docs/servers/gopher.md), or [NNTP](docs/servers/nntp.md)!
* [Gazelle](https://github.com/WhatCD/Gazelle) inspired File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/servers/web-server.md). Legacy X/Y/Z modem also supported!
* Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more!
* ANSI support in the Full Screen Editor (FSE), file descriptions, and so on
* ANSI support in the Full Screen Editor (FSE), file descriptions, etc.
* A built in achievement system. BBSing gamified!
## Documentation
[Browse the docs online](https://nuskooler.github.io/enigma-bbs/)
[Browse the docs online](https://nuskooler.github.io/enigma-bbs/). Be sure to checkout the [/docs/](/docs/) folder as well for the latest and greatest documentation.
## In the Works
* More ACS support coverage
* SysOp dashboard (ye ol' WFC)
* Native DOS emulation
* A lot more! Feel free to request features via [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues)
Many more features are in the pipeline. Checkout the [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) and feel free to request features (or contribute!) features.
## Known Issues
As of now this is considered **alpha** code! Please **expect bugs** :bug: -- and when you find them, log issues and/or submit pull requests. Feature requests, suggestions, and so on are always welcome! I am also **looking for semi dedicated testers, artists, etc**!
As of now this is considered **alpha** code! Please **expect bugs** :bug: -- and when you find them, log issues and/or submit pull requests. With that said, the code is actually quite stable and is used by a number of boards.
See [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) for more information.
@ -52,21 +50,25 @@ ENiGMA has been tested with many terminals. However, the following are suggested
* [SyncTERM](http://syncterm.bbsdev.net/)
* [EtherTerm](https://github.com/M-griffin/EtherTerm)
* [NetRunner](http://mysticbbs.com/downloads.html)
* [MagiTerm](https://magickabbs.com/index.php/magiterm/)
## Boards
* WQH: :skull: [Xibalba](https://l33t.codes/xibalba-bbs) :skull: (**telnet://xibalba.l33t.codes:44510** or via SSH secure on port 44511)
* [fORCE9](https://bbs.force9.org/): (**telnet://bbs.force9.org**)
* WQH: :skull: [Xibalba](https://l33t.codes/xibalba-bbs) :skull: (**ssh://xibalba.l33t.codes:44511** or **telnet://xibalba.l33t.codes:44510**)
* [fORCE9](http://bbs.force9.org/): (**telnet://bbs.force9.org**)
* [Undercurrents](https://undercurrents.io): (**ssh://undercurrents.io**)
* [PlaneT Afr0](https://planetafr0.org/): (**ssh://planetafr0.org:8889**)
## Installation
On *nix type systems:
```
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/install.sh | bash
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/0.0.9-alpha/misc/install.sh | bash
```
Please see the [Quickstart](docs/index.md) for more information.
Please see [Installation Methods](https://nuskooler.github.io/enigma-bbs/installation/installation-methods.html) for Windows, Docker, and so on...
## Special Thanks
* [Dave Stephens aka RiPuk](https://github.com/davestephens) for the [KICK ASS documentation](https://nuskooler.github.io/enigma-bbs/), code contributions, etc.
* [Dave Stephens aka RiPuk](https://github.com/davestephens) for the awesome [ENiGMA website](https://enigma-bbs.github.io/) and [KICK ASS documentation](https://nuskooler.github.io/enigma-bbs/), code contributions, etc.
* [Daniel Mecklenburg Jr.](https://github.com/codewar65) for the awesome VTX terminal and general coding talk
* [M. Brutman](http://www.brutman.com/), author of [mTCP](http://www.brutman.com/mTCP/mTCP.html) (Interwebs for DOS!)
* [M. Griffin](https://github.com/M-griffin), author of [Enthral BBS](https://github.com/M-griffin/Enthral), [Oblivion/2 XRM](https://github.com/M-griffin/Oblivion2-XRM) and [EtherTerm](https://github.com/M-griffin/EtherTerm)!
@ -77,11 +79,13 @@ Please see the [Quickstart](docs/index.md) for more information.
* Avon of [Agency BBS](http://bbs.geek.nz/) and [fsxNet](http://bbs.geek.nz/#fsxNet) for putting up with my experiments to his system
* Maskreet of [Throwback BBS](http://www.throwbackbbs.com/) hosting [DoorParty](http://forums.throwbackbbs.com/)!
* [Apam](https://github.com/apamment) of [Magicka](https://magickabbs.com/)
* [nail/blocktronics](http://blocktronics.org/tag/nail/) for the [sickmade Xibalba logo](http://pc.textmod.es/pack/blocktronics-420/n-xbalba.ans)!
* [Whazzit/blocktronics](http://blocktronics.org/tag/whazzit/) for the amazing Mayan ANSI pieces scattered about Xibalba BBS!
## License
Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license:
Copyright (c) 2015-2018, Bryan D. Ashby
Copyright (c) 2015-2019, Bryan D. Ashby
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@ -1,16 +1,17 @@
# Introduction
This document covers basic upgrade notes for major ENiGMA½ version updates.
# Before Upgrading
* Always back up your system!
* Always back up your system!
* Seriously, always back up your system!
* At least back up the `db` directory and your `menu.hjson` (or renamed equivalent)
# General Notes
Upgrades often come with changes to the default `menu.hjson`. It is wise to
use a *different* file name for your BBS's version of this file and point to
it via `config.hjson`. For example:
## Configuration File Updates
In general, look at the `menu_template.in.hjson`, and `config_template.in.hjson` as well as the defualt `luciano_blocktronics/theme.hjson` files when you update. These files may come with new sections you wish to merge into your system!
### menu.hjson
Upgrades often come with changes to the default `menu_template.in.hjson`. It is wise to use a *different* file name for your BBS's version of this file and point to it via `config.hjson`. For example:
```hjson
general: {
@ -21,6 +22,9 @@ general: {
After updating code, use a program such as DiffMerge to merge in updates to
`my_bbs.hjson` from the shipping `menu.hjson`.
### theme.hjson
Any custom themes you have created may now be missing features as well. Take a look at the default `luciano_blocktronics/theme.hjson` file. You can use missing sections in your `theme.hjson` (which will generally correspond to sections you've also merged in to your `menu.hjson`).
# Upgrading the Code
Upgrading from GitHub is easy:
@ -32,11 +36,37 @@ rm -rf npm_modules # do this any time you update Node.js itself
npm install
```
# Problems
Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or
[file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues).
# 0.0.8-alpha to 0.0.9-alpha
* Development is now against Node.js 10.x LTS. Follow your standard upgrade path to update to Node 10.x before using 0.0.9-alpha!
* The property `justify` found on various views previously had `left` and `right` values swapped (oops!); you will need to adjust any custom `theme.hjson` that use one or the other and swap them as well.
* Possible breaking changes in FSE: The MCI code `%TL13` for error indicator is now `%TL4`. This is part of a cleanup and standardization on "custom ranges". You may need to update your `theme.hjson` and related artwork.
* Removed view width auto-size: Some views still can auto-size their height, but in general you should be explicit in your themes
* More standardization using "custom ranges" and `itemFormat` / `focusItemFormat` semantics. Update your themes!
* In addition to using `itemFormat`, the `onelinerz` module uses `userName` vs `username` (note the case) to match other modules
* `loginServers.webSocket` configuration block has changed to be more consistent with other servers. Example:
```
webSocket: {
ws: {
enabled: true
}
wss: {
enabled: true
port: 1234
}
proxied: true // X-Forwarded-Proto: https support
}
```
* The module export `registerEvents` has been deprecated. If you have a module that depends on this, use the new more generic `moduleInitialize` export instead.
* The `system.db` `user_event_log` table has been updated to include a unique session ID. Previously this table was not used, but you will need to perform a slight maintenance task before it can be properly used. After updating to `0.0.9-alpha`, please run the following: `sqlite3 db/system.db DROP TABLE user_event_log;`. The new table format will be created and used at startup.
* If you have art configured for message conference or area selection via the `art` configuration value, you will need to include a `show_art` menu reference. Defaulted to `changeMessageConfPreArt` for conferences and `changeMessageAreaPreArt` for areas & included in the example `menu.hjson`.
* Config `defaults` section was theme related and as such, has been renamed to `theme`. `defaults.theme` is now `theme.default`, and `preLoginTheme` is now `theme.preLogin`. See `config.js` if this isn't clear as mud.
* Similar to the last item, `defaults.general.passwordChar` in `theme.hjson` is now just `defaults.passwordChar`.
# 0.0.7-alpha to 0.0.8-alpha
ENiGMA 0.0.8-alpha comes with some structure changes:
* Configuration files are defaulted to `./config`. Related, the `--config` option now points to a configuration **directory**

View File

@ -1,6 +1,34 @@
# Whats New
This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub.
## 0.0.9-alpha
* Development is now against Node.js 10.x LTS. While other Node.js series may continue to work, you're own your own and YMMV!
* Fixed `justify` properties: `left` and `right` values were formerly swapped (oops!)
* Menu items can now be arrays of *objects* not just arrays of strings.
* The properties `itemFormat` and `focusItemFormat` allow you to supply the string format for items. For example if a menu object is `{ "userName" : "Bob", "age" : 35 }`, a `itemFormat` might be `|04{userName} |08- |14{age}`.
* If no `itemFormat` is supplied, the default formatter is `{text}`.
* Setting the `data` member of an object will cause form submissions to use this value instead of the selected items index.
* See the default `luciano_blocktronics` `matrix` menu for example usage.
* You can now set the `sort` property on a menu to sort items. If `true` items are sorted by `text`. If the value is a string, it represents the key in menu objects to sort by.
* Hot-reload of configuration files such as menu.hjson, config.hjson, your themes.hjson, etc.: When a file is saved, it will be hot-reloaded into the running system
* Note that any custom modules should make use of the new Config.get() method.
* The old concept of `autoScale` has been removed. See https://github.com/NuSkooler/enigma-bbs/issues/166
* Ability to delete from personal mailbox (finally!)
* Add ability to skip file and/or message areas during newscan. Set config.omitFileAreaTags and config.omitMessageAreaTags in new_scan configuration of your menu.hjson
* `{userName}` (sanitized) and `{userNameRaw}` as well as `{cwd}` have been added to param options when launching a door.
* Any module may now register for a system startup initialization via the `initializeModules(initInfo, cb)` export.
* User event log is now functional. Various events a user performs will be persisted to the `system.db` `user_event_log` table for up to 90 days. An example usage can be found in the updated `last_callers` module where events are turned into Ami/X style actions. Please see `UPGRADE.md`!
* New MCI codes including general purpose movement codes. See [MCI codes](docs/art/mci.md)
* `install.sh` will now attempt to use NPM's `--build-from-source` option when ARM is detected.
* `oputil.js config new` will now generate a much more complete configuration file with comments, examples, etc. `oputil.js config cat` dumps your current config to stdout.
* Handling of failed login attempts is now fully in. Disconnect clients, lock out accounts, ability to auto or unlock at (email-driven) password reset, etc. See `users.failedLogin` in `config.hjson`.
* NNTP support! See [NNTP docs](/docs/servers/nntp.md) for more information.
* `oputil.js user rm` and `oputil.js user info` are in! See [oputil CLI](/docs/admin/oputil.md).
* Performing a file scan/import using `oputil.js fb scan` now recognizes various `FILES.BBS` formats.
* Usernames found in the `config.users.badUserNames` are now not only disallowed from applying, but disconnected at any login attempt.
* Total minutes online is now tracked for users. Of course, it only starts after you get the update :)
## 0.0.8-alpha
* [Mystic BBS style](http://wiki.mysticbbs.com/doku.php?id=displaycodes) extended pipe color codes. These allow for example, to set "iCE" background colors.
* File descriptions (FILE_ID.DIZ, etc.) now support Renegade |## pipe, PCBoard, and other less common color codes found commonly in BBS era scene releases.
@ -15,7 +43,8 @@ This document attempts to track **major** changes and additions in ENiGMA½. For
* Correly parse oddball `INTL`, `TOPT`, `FMPT`, `Via`, etc. FTN kludge lines
* NetMail support! You can now send and receive NetMail. To send a NetMail address a external user using `Name <address>` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`)
* `oputil.js`: Added `mb areafix` command to quickly send AreaFix messages from the command line. You can manually send them from personal mail as well.
* `oputil.js fb rm|remove|del|delete` functionality to remove file base entries
* `oputil.js fb rm|remove|del|delete` functionality to remove file base entries.
* `oputil.js fb desc` for setting/updating a file entry description.
* Users can now (re)set File and Message base pointers
* Add `--update` option to `oputil.js fb scan`
* Fix @watch path support for event scheduler including FTN, e.g. when looking for a `toss!.now` file produced by Binkd.

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: {
defaults: {
general: {
passwordChar: *
}
passwordChar: *
dateTimeFormat: {
short: MMM Do h:mm a
@ -22,7 +20,8 @@
matrix: {
mci: {
VM1: {
focusTextStyle: first lower
itemFormat: "|03{text}"
focusItemFormat: "|11{text!styleFirstLower}"
}
}
}
@ -87,11 +86,15 @@
fullLoginSequenceOnelinerz: {
config: {
listFormat: "|00|11{username:<12}|08: |03{oneliner:<59.58}"
dateTimeFormat: ddd h:mma
}
0: {
mci: {
VM1: { height: 10 }
VM1: {
height: 10
width: 20
itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}"
}
TM2: {
focusTextStyle: first lower
}
@ -108,61 +111,92 @@
}
}
mainMenuUserAchievementsEarned: {
config: {
dateTimeFormat: MMM Do h:mma
achievementsInfoFormat10: "|00|07\"|11{title}|07\""
achievementsInfoFormat11: "|00|03{text}"
}
mci: {
VM1: {
height: 11
width: 76
itemFormat: "|00|15{ts} |07- |03{title:<47.46} |15{points:,}|07 pts"
focusItemFormat: "|00|19|15{ts} - {title:<47.46} {points:,} pts"
}
TL10: {
width: 76
}
TL11: {
width: 76
}
}
}
mainMenuUserStats: {
mci: {
UN1: { width: 17 }
UR2: { width: 17 }
LO3: { width: 17 }
UF4: { width: 17 }
UG5: { width: 17 }
UT6: { width: 17 }
UC7: { width: 17 }
ST8: { width: 17 }
UN1: { width: 15 }
UR2: { width: 15 }
LO3: { width: 15 }
UF4: { width: 15 }
UG5: { width: 15 }
UT6: { width: 15 }
UC7: { width: 15 }
ST8: { width: 15 }
}
}
mainMenuSystemStats: {
mci: {
BN1: { width: 17 }
VL2: { width: 17 }
VN2: { width: 17 }
OS3: { width: 33 }
SC4: { width: 33 }
DT5: { width: 33 }
CT6: { width: 33 }
AN7: { width: 6 }
ND8: { width: 6 }
TC9: { width: 6 }
TT11: { width: 6 }
PT12: { width: 6 }
TP13: { width: 6 }
NV14: { width: 17 }
}
}
mainMenuLastCallers: {
config: {
listFormat: "|00|11{userName:<17.17}|03{location:<20.20}|11{affils:<17.17}|03{ts:<15}"
dateTimeFormat: MMM Do h:mma
}
mci: {
VM1: { height: 10 }
VM1: {
height: 10,
width: 20
itemFormat: "|00|11{userName:<17.16} |03{location:<20.19} |11{affils:<18.17} |03{ts:<15}"
}
}
}
mainMenuUserList: {
config: {
listFormat: "|00|11{userName:<17.17}|03{affils:<21.21}|11{note:<19.19}|03{lastLoginTs}"
focusListFormat: "|00|19|15{userName:<17.17}{affils:<21.21}{note:<19.19}{lastLoginTs}"
dateTimeFormat: MMM Do h:mma
}
mci: {
VM1: { height: 15 }
VM1: {
height: 15,
width: 50
itemFormat: "|00|11{userName:<17.17}|03{affils:<21.21}|11{location:<19.19}|03{lastLoginTs}"
focusItemFormat: "|00|19|15{userName:<17.17}{affils:<21.21}{location:<19.19}{lastLoginTs}"
}
}
}
mainMenuWhosOnline: {
config: {
listFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}"
}
mci: {
VM1: { height: 10 }
VM1: {
height: 10,
width: 20
itemFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}"
}
}
}
@ -182,13 +216,15 @@
}
mainMenuOnelinerz: {
// :TODO: Need way to just duplicate entry here & in menu.hjson, e.g. use: someName + must supply next/etc. in menu
config: {
listFormat: "|00|11{username:<12}|08: |03{oneliner:<59.58}"
dateTimeFormat: ddd h:mma
}
0: {
mci: {
VM1: { height: 10 }
VM1: {
height: 10
itemFormat: "|00|11{userName:<12}|08: |03{oneliner:<59.59}"
}
TM2: {
focusTextStyle: first lower
}
@ -206,40 +242,48 @@
}
messageAreaMessageList: {
config: {
listFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |01|31{newIndicator}"
focusListFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}"
config: {
dateTimeFormat: ddd MMM Do
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
}
mci: {
VM1: {
height: 14
width: 70
itemFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |15{newIndicator}"
focusItemFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}"
}
}
}
messageAreaChangeCurrentConference: {
config: {
listFormat: "|00|15{index} |07- |03{name}"
focusListFormat: "|00|19|15{index} - {name}"
}
mci: {
VM1: {
width: 26
height: 19
itemFormat: "|00|15{index} |07- |03{name}"
focusItemFormat: "|00|19|15{index} - {name}"
}
}
}
messageAreaChangeCurrentArea: {
config: {
listFormat: "|00|15{index} |07- |03{name}"
focusListFormat: "|00|19|15{index} - {name}"
}
mci: {
VM1: {
width: 26
height: 19
itemFormat: "|00|15{index:.2} |07- |03{name}"
focusItemFormat: "|00|09|15{index:.2} - {name}"
}
}
}
messageAreaSetNewScanDate: {
mci: {
SM2: {
width: 54
itemFormat: "|00|07{conf.name} |08- |07{area.name}"
focusItemFormat: "|00|15{conf.name} |07- |15{area.name}"
}
}
}
@ -261,25 +305,31 @@
mailMenuInbox: {
config: {
listFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |01|31{newIndicator}"
focusListFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}"
dateTimeFormat: ddd MMM Do
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
}
mci: {
VM1: {
height: 14
width: 70
itemFormat: "|00|15{msgNum:>4} |03{subject:<28.27} |11{fromUserName:<20.20} |03{ts} |15{newIndicator}"
focusItemFormat: "|00|19|15{msgNum:>4} {subject:<28.27} {fromUserName:<20.20} {ts} {newIndicator}"
}
XY2: {
width: 30
}
}
}
mainMenuRumorz: {
config: {
listFormat: "|00|11 {rumor}"
focusListFormat: "|00|15> |14{rumor}"
}
0: {
mci: {
VM1: { height: 14 }
VM1: {
height: 14,
width: 70
itemFormat: "|00|11 {rumor}"
focusItemFormat: "|00|15> |14{rumor}"
}
TM2: {
focusTextStyle: upper
items: [ "yes", "no" ]
@ -298,16 +348,14 @@
}
bbsList: {
config: {
listFormat: "|00|07{bbsName}"
focusListFormat: "|00|19|15{bbsName!styleFirstLower}"
}
0: {
mci: {
VM1: {
height: 11
width: 22
focusTextStyle: first upper
itemFormat: "|00|07{bbsName}"
focusItemFormat: "|00|19|15{bbsName!styleFirstLower}"
}
TL2: { width: 28 }
TL3: { width: 28 }
@ -337,6 +385,70 @@
}
}
nodeMessage: {
config: {
messageFormat: "|00|08 :: |03message from |11{fromUserName} |08/ |03node |11{fromNodeId}|08 @ |11{timestamp} |08::\r\n|07 {message}"
}
0: {
mci: {
SM1: {
width: 25
itemFormat: "|00|03node |07{text} |08(|07{userName}|08)"
focusItemFormat: "|00|11node |15{text} |07(|15{userName}|07)"
}
ET2: {
width: 65
}
TL3: {
width: 65
}
}
}
}
messageSearch: {
0: {
mci: {
ET1: {
width: 42
}
BT2: {
focusTextStyle: upper
}
SM3: {
width: 42
}
SM4: {
width: 42
}
ET5: {
width: 42
}
ET6: {
width: 42
}
BT7: {
focusTextStyle: upper
}
}
}
}
messageAreaSearchMessageList: {
config: {
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
// Fri Sep 25th
dateTimeFormat: ddd MMM Do
}
mci: {
VM1: {
height: 14
width: 71
itemFormat: "|00|15 {msgNum:<4.4} |03{subject:<27.26} |07{toUserName:<13.12} {fromUserName:<13.12} |03{ts:<12.12}"
focusItemFormat: "|00|19> |15{msgNum:<4.4} {subject:<27.26} {toUserName:<13.12} {fromUserName:<13.12} {ts:<12.12}"
}
}
}
messageAreaViewPost: {
0: {
@ -410,20 +522,24 @@
fullLoginSequenceLastCallers: {
config: {
listFormat: "|00|11{userName:<17.17}|03{location:<20.20}|11{affils:<17.17}|03{ts:<15}"
dateTimeFormat: MMM Do h:mma
}
mci: {
VM1: { height: 10 }
VM1: {
height: 10,
width: 20
itemFormat: "|00|11{userName:<17.16} |03{location:<20.19} |11{affils:<18.17} |03{ts:<15}"
}
}
}
fullLoginSequenceWhosOnline: {
config: {
listFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}"
}
mci: {
VM1: { height: 10 }
VM1: {
height: 10,
width: 20
itemFormat: "|00|03{node:<6.6}|11{userName:<17.17}|03{affils:<19.19}|11{action:<20.20}|03{timeOn:<8}"
}
}
}
@ -433,14 +549,14 @@
fullLoginSequenceUserStats: {
mci: {
UN1: { width: 17 }
UR2: { width: 17 }
LO3: { width: 17 }
UF4: { width: 17 }
UG5: { width: 17 }
UT6: { width: 17 }
UC7: { width: 17 }
ST8: { width: 17 }
UN1: { width: 15 }
UR2: { width: 15 }
LO3: { width: 15 }
UF4: { width: 15 }
UG5: { width: 15 }
UT6: { width: 15 }
UC7: { width: 15 }
ST8: { width: 15 }
}
}
@ -467,14 +583,16 @@
}
newScanMessageList: {
config: {
listFormat: "|00|15 {msgNum:<5.5}|03{subject:<28.27} |15{fromUserName:<20.20} {ts}"
focusListFormat: "|00|19> |15{msgNum:<5.5}{subject:<28.27} {fromUserName:<20.20} {ts}"
config: {
dateTimeFormat: ddd MMM Do
allViewsInfoFormat10: "|00|15{msgNumSelected:>4.4} |08/ |15{msgNumTotal:<4.4}"
}
mci: {
VM1: {
height: 14
width: 70
itemFormat: "|00|15 {msgNum:<5.5}|03{subject:<28.27} |15{fromUserName:<20.20} {ts}"
focusItemFormat: "|00|19> |15{msgNum:<5.5}{subject:<28.27} {fromUserName:<20.20} {ts}"
}
}
}
@ -493,7 +611,7 @@
fileBaseListEntries: {
config: {
hashTagsSep: "|08, |07"
browseInfoFormat10: "|00|10{fileName} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}"
browseInfoFormat10: "|00|10{fileName:<.44} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}"
browseInfoFormat11: "|00|15{areaName}"
browseInfoFormat12: "|00|07{hashTags}"
browseInfoFormat13: "|00|07{estReleaseYear}"
@ -525,9 +643,6 @@
detailsGeneralInfoFormat21: "{uploadTimestamp}"
detailsGeneralInfoFormat22: "{archiveTypeDesc}"
fileListEntryFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusFileListEntryFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)"
}
@ -586,6 +701,8 @@
VM1: {
height: 17
width: 79
itemFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusItemFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
}
}
@ -594,7 +711,7 @@
newScanFileBaseList: {
config: {
hashTagsSep: "|08, |07"
browseInfoFormat10: "|00|10{fileName} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}"
browseInfoFormat10: "|00|10{fileName:<44} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}"
browseInfoFormat11: "|00|15{areaName}"
browseInfoFormat12: "|00|07{hashTags}"
browseInfoFormat13: "|00|07{estReleaseYear}"
@ -626,9 +743,6 @@
detailsGeneralInfoFormat21: "{uploadTimestamp}"
detailsGeneralInfoFormat22: "{archiveTypeDesc}"
fileListEntryFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusFileListEntryFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)"
}
@ -687,23 +801,22 @@
VM1: {
height: 17
width: 79
itemFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusItemFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
}
}
}
fileBaseBrowseByAreaSelect: {
config: {
protListFormat: "|00|03{name}"
protListFocusFormat: "|00|19|15{name}"
}
0: {
mci: {
VM1: {
height: 15
width: 30
focusTextStyle: first lower
itemFormat: "|00|03{name}"
focusItemFormat: "|00|19|15{name}"
}
}
}
@ -722,15 +835,15 @@
}
SM4: {
width: 14
justify: right
justify: left
}
SM5: {
width: 14
justify: right
justify: left
}
SM6: {
width: 14
justify: right
justify: left
}
BT7: {
focusTextStyle: first lower
@ -738,6 +851,51 @@
}
}
fileBaseExportListFilter: {
mci: {
ET1: {
width: 42
}
BT2: {
focusTextStyle: first lower
}
ET3: {
width: 42
}
SM4: {
width: 14
justify: left
}
SM5: {
width: 14
justify: left
}
SM6: {
width: 14
justify: left
}
BT7: {
focusTextStyle: first lower
}
}
}
fileBaseExportList: {
config: {
progBarChar: "|15▒"
mainInfoFormat10: "|07{currentFile} |08/ |07{totalFileCount} |08(|07{progress} %|08)"
}
mci: {
TL1: {
width: 60
}
TL2: {
width: 56
fillChar: "|06░"
}
}
}
fileAreaFilterEditor: {
mci: {
ET1: {
@ -748,15 +906,15 @@
}
SM3: {
width: 14
justify: right
justify: left
}
SM4: {
width: 14
justify: right
justify: left
}
SM5: {
width: 14
justify: right
justify: left
}
ET6: {
width: 26
@ -768,16 +926,13 @@
}
fileBaseDownloadManager: {
config: {
queueListFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusQueueListFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
0: {
mci: {
VM1: {
height: 11
width: 69
itemFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusItemFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
HM2: {
width: 50
@ -789,8 +944,6 @@
fileBaseWebDownloadManager: {
config: {
queueListFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusQueueListFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
queueManagerInfoFormat10: "|03batch|08: |03{webBatchDlLink}"
queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}"
}
@ -799,6 +952,8 @@
mci: {
VM1: {
height: 8
itemFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusItemFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
HM2: {
width: 50
@ -826,7 +981,7 @@
mci: {
SM1: {
width: 14
justify: right
justify: left
focusTextStyle: first lower
}
@ -895,17 +1050,14 @@
}
fileTransferProtocolSelection: {
config: {
protListFormat: "|00|03{name}"
protListFocusFormat: "|00|19|15{name}"
}
0: {
mci: {
VM1: {
height: 15
width: 30
focusTextStyle: first lower
itemFormat: "|00|03{name}"
focusItemFormat: "|00|19|15{name}"
}
}
}
@ -938,5 +1090,31 @@
}
}
}
achievements: {
defaults: {
format: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}"
globalFormat: "|08 > |10{title} |08(|11{points} |03points|08)\r\n\r\n {message}"
titleSGR: "|10"
pointsSGR: "|12"
textSGR: "|00|03"
globalTextSGR: "|03"
boardNameSGR: "|10"
userNameSGR: "|11"
achievedValueSGR: "|15"
}
overrides: {
user_login_count: {
match: {
2: {
//
// You may override title, text, and globalText here
//
}
}
}
}
}
}
}

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

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

View File

@ -844,107 +844,206 @@ function peg$parse(input, options) {
}
var client = options.client;
var user = options.client.user;
const UserProps = require('./user_property.js');
const Log = require('./logger.js').log;
var _ = require('lodash');
var assert = require('assert');
const _ = require('lodash');
const moment = require('moment');
const client = _.get(options, 'subject.client');
const user = _.get(options, 'subject.user');
function checkAccess(acsCode, value) {
try {
return {
LC : function isLocalConnection() {
return client.isLocal();
return client && client.isLocal();
},
AG : function ageGreaterOrEqualThan() {
return !isNaN(value) && user.getAge() >= value;
return !isNaN(value) && user && user.getAge() >= value;
},
AS : function accountStatus() {
if(!_.isArray(value)) {
if(!user) {
return false;
}
if(!Array.isArray(value)) {
value = [ value ];
}
const userAccountStatus = parseInt(user.properties.account_status, 10);
value = value.map(n => parseInt(n, 10)); // ensure we have integers
return value.indexOf(userAccountStatus) > -1;
const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
return value.map(n => parseInt(n, 10)).includes(userAccountStatus);
},
EC : function isEncoding() {
const encoding = _.get(client, 'term.outputEncoding', '').toLowerCase();
switch(value) {
case 0 : return 'cp437' === client.term.outputEncoding.toLowerCase();
case 1 : return 'utf-8' === client.term.outputEncoding.toLowerCase();
case 0 : return 'cp437' === encoding;
case 1 : return 'utf-8' === encoding;
default : return false;
}
},
GM : function isOneOfGroups() {
if(!_.isArray(value)) {
if(!user) {
return false;
}
return _.findIndex(value, function cmp(groupName) {
return user.isGroupMember(groupName);
}) > - 1;
if(!Array.isArray(value)) {
return false;
}
return value.some(groupName => user.isGroupMember(groupName));
},
NN : function isNode() {
return client.node === value;
if(!client) {
return false;
}
if(!Array.isArray(value)) {
value = [ value ];
}
return value.map(n => parseInt(n, 10)).includes(client.node);
},
NP : function numberOfPosts() {
const postCount = parseInt(user.properties.post_count, 10);
if(!user) {
return false;
}
const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0;
return !isNaN(value) && postCount >= value;
},
NC : function numberOfCalls() {
const loginCount = parseInt(user.properties.login_count, 10);
if(!user) {
return false;
}
const loginCount = user.getPropertyAsNumber(UserProps.LoginCount);
return !isNaN(value) && loginCount >= value;
},
AA : function accountAge() {
if(!user) {
return false;
}
const accountCreated = moment(user.getProperty(UserProps.AccountCreated));
const now = moment();
const daysOld = accountCreated.diff(moment(), 'days');
return !isNaN(value) &&
accountCreated.isValid() &&
now.isAfter(accountCreated) &&
daysOld >= value;
},
BU : function bytesUploaded() {
if(!user) {
return false;
}
const bytesUp = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0;
return !isNaN(value) && bytesUp >= value;
},
UP : function uploads() {
if(!user) {
return false;
}
const uls = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0;
return !isNaN(value) && uls >= value;
},
BD : function bytesDownloaded() {
if(!user) {
return false;
}
const bytesDown = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0;
return !isNaN(value) && bytesDown >= value;
},
DL : function downloads() {
if(!user) {
return false;
}
const dls = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0;
return !isNaN(value) && dls >= value;
},
NR : function uploadDownloadRatioGreaterThan() {
if(!user) {
return false;
}
const ulCount = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0;
const dlCount = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0;
const ratio = ~~((ulCount / dlCount) * 100);
return !isNaN(value) && ratio >= value;
},
KR : function uploadDownloadByteRatioGreaterThan() {
if(!user) {
return false;
}
const ulBytes = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0;
const dlBytes = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0;
const ratio = ~~((ulBytes / dlBytes) * 100);
return !isNaN(value) && ratio >= value;
},
PC : function postCallRatio() {
if(!user) {
return false;
}
const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0;
const loginCount = user.getPropertyAsNumber(UserProps.LoginCount) || 0;
const ratio = ~~((postCount / loginCount) * 100);
return !isNaN(value) && ratio >= value;
},
SC : function isSecureConnection() {
return client.session.isSecure;
return _.get(client, 'session.isSecure', false);
},
ML : function minutesLeft() {
// :TODO: implement me!
return false;
},
TH : function termHeight() {
return !isNaN(value) && client.term.termHeight >= value;
return !isNaN(value) && _.get(client, 'term.termHeight', 0) >= value;
},
TM : function isOneOfThemes() {
if(!_.isArray(value)) {
if(!Array.isArray(value)) {
return false;
}
return value.indexOf(client.currentTheme.name) > -1;
return value.includes(_.get(client, 'currentTheme.name'));
},
TT : function isOneOfTermTypes() {
if(!_.isArray(value)) {
if(!Array.isArray(value)) {
return false;
}
return value.indexOf(client.term.termType) > -1;
return value.includes(_.get(client, 'term.termType'));
},
TW : function termWidth() {
return !isNaN(value) && client.term.termWidth >= value;
return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value;
},
ID : function isUserId(value) {
if(!_.isArray(value)) {
ID : function isUserId() {
if(!user) {
return false;
}
if(!Array.isArray(value)) {
value = [ value ];
}
value = value.map(n => parseInt(n, 10)); // ensure we have integers
return value.indexOf(user.userId) > -1;
return value.map(n => parseInt(n, 10)).includes(user.userId);
},
WD : function isOneOfDayOfWeek() {
if(!_.isArray(value)) {
if(!Array.isArray(value)) {
value = [ value ];
}
value = value.map(n => parseInt(n, 10)); // ensure we have integers
return value.indexOf(new Date().getDay()) > -1;
return value.map(n => parseInt(n, 10)).includes(new Date().getDay());
},
MM : function isMinutesPastMidnight() {
// :TODO: return true if value is >= minutes past midnight sys time
return false;
const now = moment();
const midnight = now.clone().startOf('day')
const minutesPastMidnight = now.diff(midnight, 'minutes');
return !isNaN(value) && minutesPastMidnight >= value;
},
AC : function achievementCount() {
if(!user) {
return false;
}
const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0;
return !isNan(value) && points >= value;
},
AP : function achievementPoints() {
if(!user) {
return false;
}
const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0;
return !isNan(value) && points >= value;
}
}[acsCode](value);
} catch (e) {
client.log.warn( { acsCode : acsCode, value : value }, 'Invalid ACS string!');
const logger = _.get(client, 'log', Log);
logger.warn( { acsCode : acsCode, value : value }, 'Invalid ACS string!');
return false;
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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

View File

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

View File

@ -5,29 +5,36 @@
//var SegfaultHandler = require('segfault-handler');
//SegfaultHandler.registerHandler('enigma-bbs-segfault.log');
// ENiGMA½
const conf = require('./config.js');
const logger = require('./logger.js');
const database = require('./database.js');
const resolvePath = require('./misc_util.js').resolvePath;
// ENiGMA½
const conf = require('./config.js');
const logger = require('./logger.js');
const database = require('./database.js');
const resolvePath = require('./misc_util.js').resolvePath;
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
const SysLogKeys = require('./system_log.js');
// deps
const async = require('async');
const util = require('util');
const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs;
const fs = require('graceful-fs');
const paths = require('path');
// deps
const async = require('async');
const util = require('util');
const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs;
const fs = require('graceful-fs');
const paths = require('path');
const moment = require('moment');
// our main entry point
exports.main = main;
// our main entry point
exports.main = main;
// object with various services we want to de-init/shutdown cleanly if possible
// object with various services we want to de-init/shutdown cleanly if possible
const initServices = {};
const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby';
// only include bbs.js once @ startup; this should be fine
const COPYRIGHT = fs.readFileSync(paths.join(__dirname, '../LICENSE.TXT'), 'utf8').split(/\r?\n/g)[0];
const FULL_COPYRIGHT = `ENiGMA½ ${COPYRIGHT}`;
const HELP =
`${ENIGMA_COPYRIGHT}
`${FULL_COPYRIGHT}
usage: main.js <args>
eg : main.js --config /enigma_install_path/config/
@ -38,244 +45,280 @@ valid args:
`;
function printHelpAndExit() {
console.info(HELP);
process.exit();
console.info(HELP);
process.exit();
}
function printVersionAndExit() {
console.info(require('../package.json').version);
}
function main() {
async.waterfall(
[
function processArgs(callback) {
const argv = require('minimist')(process.argv.slice(2));
async.waterfall(
[
function processArgs(callback) {
const argv = require('minimist')(process.argv.slice(2));
if(argv.help) {
printHelpAndExit();
}
if(argv.help) {
return printHelpAndExit();
}
const configOverridePath = argv.config;
if(argv.version) {
return printVersionAndExit();
}
return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath));
},
function initConfig(configPath, configPathSupplied, callback) {
const configFile = configPath + 'config.hjson';
conf.init(resolvePath(configFile), function configInit(err) {
const configOverridePath = argv.config;
//
// If the user supplied a path and we can't read/parse it
// then it's a fatal error
//
if(err) {
if('ENOENT' === err.code) {
if(configPathSupplied) {
console.error('Configuration file does not exist: ' + configFile);
} else {
configPathSupplied = null; // make non-fatal; we'll go with defaults
}
} else {
console.error(err.toString());
}
}
callback(err);
});
},
function initSystem(callback) {
initialize(function init(err) {
if(err) {
console.error('Error initializing: ' + util.inspect(err));
}
return callback(err);
});
}
],
function complete(err) {
// note this is escaped:
fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => {
console.info(ENIGMA_COPYRIGHT);
if(!err) {
console.info(banner);
}
console.info('System started!');
});
return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath));
},
function initConfig(configPath, configPathSupplied, callback) {
const configFile = configPath + 'config.hjson';
conf.init(resolvePath(configFile), function configInit(err) {
if(err) {
console.error('Error initializing: ' + util.inspect(err));
}
}
);
//
// If the user supplied a path and we can't read/parse it
// then it's a fatal error
//
if(err) {
if('ENOENT' === err.code) {
if(configPathSupplied) {
console.error('Configuration file does not exist: ' + configFile);
} else {
configPathSupplied = null; // make non-fatal; we'll go with defaults
}
} else {
console.error(err.message);
}
}
return callback(err);
});
},
function initSystem(callback) {
initialize(function init(err) {
if(err) {
console.error('Error initializing: ' + util.inspect(err));
}
return callback(err);
});
}
],
function complete(err) {
if(!err) {
// note this is escaped:
fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => {
console.info(FULL_COPYRIGHT);
if(!err) {
console.info(banner);
}
console.info('System started!');
});
}
if(err) {
console.error('Error initializing: ' + util.inspect(err));
}
}
);
}
function shutdownSystem() {
const msg = 'Process interrupted. Shutting down...';
console.info(msg);
logger.log.info(msg);
const msg = 'Process interrupted. Shutting down...';
console.info(msg);
logger.log.info(msg);
async.series(
[
function closeConnections(callback) {
const ClientConns = require('./client_connections.js');
const activeConnections = ClientConns.getActiveConnections();
let i = activeConnections.length;
while(i--) {
activeConnections[i].term.write('\n\nServer is shutting down NOW! Disconnecting...\n\n');
ClientConns.removeClient(activeConnections[i]);
}
callback(null);
},
function stopListeningServers(callback) {
return require('./listening_server.js').shutdown( () => {
return callback(null); // ignore err
});
},
function stopEventScheduler(callback) {
if(initServices.eventScheduler) {
return initServices.eventScheduler.shutdown( () => {
return callback(null); // ignore err
});
} else {
return callback(null);
}
},
function stopFileAreaWeb(callback) {
require('./file_area_web.js').startup( () => {
return callback(null); // ignore err
});
},
function stopMsgNetwork(callback) {
require('./msg_network.js').shutdown(callback);
}
],
() => {
console.info('Goodbye!');
return process.exit();
}
);
async.series(
[
function closeConnections(callback) {
const ClientConns = require('./client_connections.js');
const activeConnections = ClientConns.getActiveConnections();
let i = activeConnections.length;
while(i--) {
const activeTerm = activeConnections[i].term;
if(activeTerm) {
activeTerm.write('\n\nServer is shutting down NOW! Disconnecting...\n\n');
}
ClientConns.removeClient(activeConnections[i]);
}
callback(null);
},
function stopListeningServers(callback) {
return require('./listening_server.js').shutdown( () => {
return callback(null); // ignore err
});
},
function stopEventScheduler(callback) {
if(initServices.eventScheduler) {
return initServices.eventScheduler.shutdown( () => {
return callback(null); // ignore err
});
} else {
return callback(null);
}
},
function stopFileAreaWeb(callback) {
require('./file_area_web.js').startup( () => {
return callback(null); // ignore err
});
},
function stopMsgNetwork(callback) {
require('./msg_network.js').shutdown(callback);
}
],
() => {
console.info('Goodbye!');
return process.exit();
}
);
}
function initialize(cb) {
async.series(
[
function createMissingDirectories(callback) {
async.each(Object.keys(conf.config.paths), function entry(pathKey, next) {
mkdirs(conf.config.paths[pathKey], function dirCreated(err) {
if(err) {
console.error('Could not create path: ' + conf.config.paths[pathKey] + ': ' + err.toString());
}
return next(err);
});
}, function dirCreationComplete(err) {
return callback(err);
});
},
function basicInit(callback) {
logger.init();
logger.log.info(
{ version : require('../package.json').version },
'**** ENiGMA½ Bulletin Board System Starting Up! ****');
async.series(
[
function createMissingDirectories(callback) {
async.each(Object.keys(conf.config.paths), function entry(pathKey, next) {
mkdirs(conf.config.paths[pathKey], function dirCreated(err) {
if(err) {
console.error('Could not create path: ' + conf.config.paths[pathKey] + ': ' + err.toString());
}
return next(err);
});
}, function dirCreationComplete(err) {
return callback(err);
});
},
function basicInit(callback) {
logger.init();
logger.log.info(
{ version : require('../package.json').version },
'**** ENiGMA½ Bulletin Board System Starting Up! ****');
process.on('SIGINT', shutdownSystem);
process.on('SIGINT', shutdownSystem);
require('later').date.localTime(); // use local times for later.js/scheduling
require('later').date.localTime(); // use local times for later.js/scheduling
return callback(null);
},
function initDatabases(callback) {
return database.initializeDatabases(callback);
},
function initMimeTypes(callback) {
return require('./mime_util.js').startup(callback);
},
function initStatLog(callback) {
return require('./stat_log.js').init(callback);
},
function initThemes(callback) {
// Have to pull in here so it's after Config init
require('./theme.js').initAvailableThemes(function onThemesInit(err, themeCount) {
logger.log.info({ themeCount : themeCount }, 'Themes initialized');
return callback(err);
});
},
function loadSysOpInformation(callback) {
//
// Copy over some +op information from the user DB -> system propertys.
// * Makes this accessible for MCI codes, easy non-blocking access, etc.
// * We do this every time as the op is free to change this information just
// like any other user
//
const User = require('./user.js');
return callback(null);
},
function initDatabases(callback) {
return database.initializeDatabases(callback);
},
function initMimeTypes(callback) {
return require('./mime_util.js').startup(callback);
},
function initStatLog(callback) {
return require('./stat_log.js').init(callback);
},
function initConfigs(callback) {
return require('./config_util.js').init(callback);
},
function initThemes(callback) {
// Have to pull in here so it's after Config init
require('./theme.js').initAvailableThemes( (err, themeCount) => {
logger.log.info({ themeCount }, 'Themes initialized');
return callback(err);
});
},
function loadSysOpInformation(callback) {
//
// Copy over some +op information from the user DB -> system properties.
// * Makes this accessible for MCI codes, easy non-blocking access, etc.
// * We do this every time as the op is free to change this information just
// like any other user
//
const User = require('./user.js');
async.waterfall(
[
function getOpUserName(next) {
return User.getUserName(1, next);
},
function getOpProps(opUserName, next) {
const propLoadOpts = {
names : [ 'real_name', 'sex', 'email_address', 'location', 'affiliation' ],
};
User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => {
return next(err, opUserName, opProps);
});
}
],
(err, opUserName, opProps) => {
const StatLog = require('./stat_log.js');
const propLoadOpts = {
names : [
UserProps.RealName, UserProps.Sex, UserProps.EmailAddress,
UserProps.Location, UserProps.Affiliations,
],
};
if(err) {
[ 'username', 'real_name', 'sex', 'email_address', 'location', 'affiliation' ].forEach(v => {
StatLog.setNonPeristentSystemStat(`sysop_${v}`, 'N/A');
});
} else {
opProps.username = opUserName;
async.waterfall(
[
function getOpUserName(next) {
return User.getUserName(1, next);
},
function getOpProps(opUserName, next) {
User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => {
return next(err, opUserName, opProps);
});
},
],
(err, opUserName, opProps) => {
const StatLog = require('./stat_log.js');
_.each(opProps, (v, k) => {
StatLog.setNonPeristentSystemStat(`sysop_${k}`, v);
});
}
if(err) {
propLoadOpts.names.concat('username').forEach(v => {
StatLog.setNonPersistentSystemStat(`sysop_${v}`, 'N/A');
});
} else {
opProps.username = opUserName;
return callback(null);
}
);
},
function initFileAreaStats(callback) {
const getAreaStats = require('./file_base_area.js').getAreaStats;
getAreaStats( (err, stats) => {
if(!err) {
const StatLog = require('./stat_log.js');
StatLog.setNonPeristentSystemStat('file_base_area_stats', stats);
}
_.each(opProps, (v, k) => {
StatLog.setNonPersistentSystemStat(`sysop_${k}`, v);
});
}
return callback(null);
});
},
function initMCI(callback) {
return require('./predefined_mci.js').init(callback);
},
function readyMessageNetworkSupport(callback) {
return require('./msg_network.js').startup(callback);
},
function readyEvents(callback) {
return require('./events.js').startup(callback);
},
function listenConnections(callback) {
return require('./listening_server.js').startup(callback);
},
function readyFileAreaWeb(callback) {
return require('./file_area_web.js').startup(callback);
},
function readyPasswordReset(callback) {
const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset;
return WebPasswordReset.startup(callback);
},
function readyEventScheduler(callback) {
const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule;
EventSchedulerModule.loadAndStart( (err, modInst) => {
initServices.eventScheduler = modInst;
return callback(err);
});
}
],
function onComplete(err) {
return cb(err);
}
);
return callback(null);
}
);
},
function initCallsToday(callback) {
const StatLog = require('./stat_log.js');
const filter = {
logName : SysLogKeys.UserLoginHistory,
resultType : 'count',
date : moment(),
};
StatLog.findSystemLogEntries(filter, (err, callsToday) => {
if(!err) {
StatLog.setNonPersistentSystemStat(SysProps.LoginsToday, callsToday);
}
return callback(null);
});
},
function initMessageStats(callback) {
return require('./message_area.js').startup(callback);
},
function initMCI(callback) {
return require('./predefined_mci.js').init(callback);
},
function readyMessageNetworkSupport(callback) {
return require('./msg_network.js').startup(callback);
},
function readyEvents(callback) {
return require('./events.js').startup(callback);
},
function genericModulesInit(callback) {
return require('./module_util.js').initializeModules(callback);
},
function listenConnections(callback) {
return require('./listening_server.js').startup(callback);
},
function readyFileBaseArea(callback) {
return require('./file_base_area.js').startup(callback);
},
function readyFileAreaWeb(callback) {
return require('./file_area_web.js').startup(callback);
},
function readyPasswordReset(callback) {
const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset;
return WebPasswordReset.startup(callback);
},
function readyEventScheduler(callback) {
const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule;
EventSchedulerModule.loadAndStart( (err, modInst) => {
initServices.eventScheduler = modInst;
return callback(err);
});
},
function listenUserEventsForStatLog(callback) {
return require('./stat_log.js').initUserEvents(callback);
}
],
function onComplete(err) {
return cb(err);
}
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,292 +1,273 @@
/* jslint node: true */
'use strict';
var ansi = require('./ansi_term.js');
var getPredefinedMCIValue = require('./predefined_mci.js').getPredefinedMCIValue;
const ANSI = require('./ansi_term.js');
const { getPredefinedMCIValue } = require('./predefined_mci.js');
var assert = require('assert');
var _ = require('lodash');
// deps
const _ = require('lodash');
exports.enigmaToAnsi = enigmaToAnsi;
exports.stripPipeCodes = exports.stripEnigmaCodes = stripEnigmaCodes;
exports.pipeStrLen = exports.enigmaStrLen = enigmaStrLen;
exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi;
exports.controlCodesToAnsi = controlCodesToAnsi;
exports.stripMciColorCodes = stripMciColorCodes;
exports.pipeStringLength = pipeStringLength;
exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi;
exports.controlCodesToAnsi = controlCodesToAnsi;
// :TODO: Not really happy with the module name of "color_codes". Would like something better
// :TODO: Not really happy with the module name of "color_codes". Would like something better ... control_code_string?
// Also add:
// * fromCelerity(): |<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 stripMciColorCodes(s) {
return s.replace(/\|[A-Z\d]{2}/g, '');
}
function stripEnigmaCodes(s) {
return s.replace(/\|[A-Z\d]{2}/g, '');
}
function enigmaStrLen(s) {
return stripEnigmaCodes(s).length;
function pipeStringLength(s) {
return stripMciColorCodes(s).length;
}
function ansiSgrFromRenegadeColorCode(cc) {
return ansi.sgr({
0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ],
return ANSI.sgr({
0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ],
8 : [ 'bold', 'black' ],
9 : [ 'bold', 'blue' ],
10 : [ 'bold', 'green' ],
11 : [ 'bold', 'cyan' ],
12 : [ 'bold', 'red' ],
13 : [ 'bold', 'magenta' ],
14 : [ 'bold', 'yellow' ],
15 : [ 'bold', 'white' ],
8 : [ 'bold', 'black' ],
9 : [ 'bold', 'blue' ],
10 : [ 'bold', 'green' ],
11 : [ 'bold', 'cyan' ],
12 : [ 'bold', 'red' ],
13 : [ 'bold', 'magenta' ],
14 : [ 'bold', 'yellow' ],
15 : [ 'bold', 'white' ],
16 : [ 'blackBG' ],
17 : [ 'blueBG' ],
18 : [ 'greenBG' ],
19 : [ 'cyanBG' ],
20 : [ 'redBG' ],
21 : [ 'magentaBG' ],
22 : [ 'yellowBG' ],
23 : [ 'whiteBG' ],
16 : [ 'blackBG' ],
17 : [ 'blueBG' ],
18 : [ 'greenBG' ],
19 : [ 'cyanBG' ],
20 : [ 'redBG' ],
21 : [ 'magentaBG' ],
22 : [ 'yellowBG' ],
23 : [ 'whiteBG' ],
24 : [ 'bold', 'blackBG' ],
25 : [ 'bold', 'blueBG' ],
26 : [ 'bold', 'greenBG' ],
27 : [ 'bold', 'cyanBG' ],
28 : [ 'bold', 'redBG' ],
29 : [ 'bold', 'magentaBG' ],
30 : [ 'bold', 'yellowBG' ],
31 : [ 'bold', 'whiteBG' ],
}[cc] || 'normal');
24 : [ 'blink', 'blackBG' ],
25 : [ 'blink', 'blueBG' ],
26 : [ 'blink', 'greenBG' ],
27 : [ 'blink', 'cyanBG' ],
28 : [ 'blink', 'redBG' ],
29 : [ 'blink', 'magentaBG' ],
30 : [ 'blink', 'yellowBG' ],
31 : [ 'blink', 'whiteBG' ],
}[cc] || 'normal');
}
function ansiSgrFromCnetStyleColorCode(cc) {
return ANSI.sgr({
c0 : [ 'reset', 'black' ],
c1 : [ 'reset', 'red' ],
c2 : [ 'reset', 'green' ],
c3 : [ 'reset', 'yellow' ],
c4 : [ 'reset', 'blue' ],
c5 : [ 'reset', 'magenta' ],
c6 : [ 'reset', 'cyan' ],
c7 : [ 'reset', 'white' ],
c8 : [ 'bold', 'black' ],
c9 : [ 'bold', 'red' ],
ca : [ 'bold', 'green' ],
cb : [ 'bold', 'yellow' ],
cc : [ 'bold', 'blue' ],
cd : [ 'bold', 'magenta' ],
ce : [ 'bold', 'cyan' ],
cf : [ 'bold', 'white' ],
z0 : [ 'blackBG' ],
z1 : [ 'redBG' ],
z2 : [ 'greenBG' ],
z3 : [ 'yellowBG' ],
z4 : [ 'blueBG' ],
z5 : [ 'magentaBG' ],
z6 : [ 'cyanBG' ],
z7 : [ 'whiteBG' ],
}[cc] || 'normal');
}
function renegadeToAnsi(s, client) {
if(-1 == s.indexOf('|')) {
return s; // no pipe codes present
}
if(-1 == s.indexOf('|')) {
return s; // no pipe codes present
}
var result = '';
var re = /\|([A-Z\d]{2}|\|)/g;
var m;
var lastIndex = 0;
while((m = re.exec(s))) {
var val = m[1];
let result = '';
const re = /\|(?:(C[FBUD])([0-9]{1,2})|([0-9]{2})|([A-Z]{2})|(\|))/g;
let m;
let lastIndex = 0;
while((m = re.exec(s))) {
if(m[3]) {
// |## color
const val = parseInt(m[3], 10);
const attr = ansiSgrFromRenegadeColorCode(val);
result += s.substr(lastIndex, m.index - lastIndex) + attr;
} else if(m[4] || m[1]) {
// |AA MCI code or |Cx## movement where ## is in m[1]
let val = getPredefinedMCIValue(client, m[4] || m[1], m[2]);
val = _.isString(val) ? val : m[0]; // value itself or literal
result += s.substr(lastIndex, m.index - lastIndex) + val;
} else if(m[5]) {
// || -- literal '|', that is.
result += '|';
}
if('|' == val) {
result += '|';
continue;
}
lastIndex = re.lastIndex;
}
// convert to number
val = parseInt(val, 10);
if(isNaN(val)) {
val = getPredefinedMCIValue(client, m[1]) || ('|' + m[1]); // value itself or literal
}
if(_.isString(val)) {
result += s.substr(lastIndex, m.index - lastIndex) + val;
} else {
const attr = ansiSgrFromRenegadeColorCode(val);
result += s.substr(lastIndex, m.index - lastIndex) + attr;
}
lastIndex = re.lastIndex;
}
return (0 === result.length ? s : result + s.substr(lastIndex));
return (0 === result.length ? s : result + s.substr(lastIndex));
}
//
// Converts various control codes popular in BBS packages
// to ANSI escape sequences. Additionaly supports ENiGMA style
// MCI codes.
// Converts various control codes popular in BBS packages
// to ANSI escape sequences. Additionaly supports ENiGMA style
// MCI codes.
//
// Supported control code formats:
// * Renegade : |##
// * PCBoard : @X## where the first number/char is FG color, and second is BG
// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix
// * WWIV : ^#
// Supported control code formats:
// * Renegade : |##
// * PCBoard : @X## where the first number/char is BG color, and second is FG
// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix
// * WWIV : ^#
// * CNET Y-Style : 0x19## where ## is a specific set of codes -- this is the older format
// * CNET Q-style : 0x11##} where ## is a specific set of codes -- this is the newer format
//
// TODO: Add Synchronet and Celerity format support
// TODO: Add Synchronet and Celerity format support
//
// Resources:
// * http://wiki.synchro.net/custom:colors
// Resources:
// * http://wiki.synchro.net/custom:colors
//
function controlCodesToAnsi(s, client) {
const RE = /(\|([A-Z0-9]{2})|\|)|(\@X([0-9A-F]{2}))|(\@([0-9A-F]{2})\@)|(\x03[0-9]|\x03)/g; // eslint-disable-line no-control-regex
const RE = /(\|([A-Z0-9]{2})|\|)|(@X([0-9A-F]{2}))|(@([0-9A-F]{2})@)|(\x03[0-9]|\x03)|(\x19(c[0-9a-f]|z[0-7]|n1|f1)|\x19)|(\x11(c[0-9a-f]|z[0-7]|n1|f1)}|\x11)/g; // eslint-disable-line no-control-regex
let m;
let result = '';
let lastIndex = 0;
let v;
let fg;
let bg;
let m;
let result = '';
let lastIndex = 0;
let v;
let fg;
let bg;
while((m = RE.exec(s))) {
switch(m[0].charAt(0)) {
case '|' :
// Renegade or ENiGMA MCI
v = parseInt(m[2], 10);
while((m = RE.exec(s))) {
switch(m[0].charAt(0)) {
case '|' :
// Renegade |##
v = parseInt(m[2], 10);
if(isNaN(v)) {
v = getPredefinedMCIValue(client, m[2]) || m[0]; // value itself or literal
}
if(isNaN(v)) {
v = getPredefinedMCIValue(client, m[2]) || m[0]; // value itself or literal
}
if(_.isString(v)) {
result += s.substr(lastIndex, m.index - lastIndex) + v;
} else {
v = ansiSgrFromRenegadeColorCode(v);
result += s.substr(lastIndex, m.index - lastIndex) + v;
}
break;
if(_.isString(v)) {
result += s.substr(lastIndex, m.index - lastIndex) + v;
} else {
v = ansiSgrFromRenegadeColorCode(v);
result += s.substr(lastIndex, m.index - lastIndex) + v;
}
break;
case '@' :
// PCBoard @X## or Wildcat! @##@
if('@' === m[0].substr(-1)) {
// Wildcat!
v = m[6];
} else {
v = m[4];
}
case '@' :
// PCBoard @X## or Wildcat! @##@
if('@' === m[0].substr(-1)) {
// Wildcat!
v = m[6];
} else {
v = m[4];
}
fg = {
0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ],
bg = {
0 : [ 'blackBG' ],
1 : [ 'blueBG' ],
2 : [ 'greenBG' ],
3 : [ 'cyanBG' ],
4 : [ 'redBG' ],
5 : [ 'magentaBG' ],
6 : [ 'yellowBG' ],
7 : [ 'whiteBG' ],
8 : [ 'blink', 'black' ],
9 : [ 'blink', 'blue' ],
A : [ 'blink', 'green' ],
B : [ 'blink', 'cyan' ],
C : [ 'blink', 'red' ],
D : [ 'blink', 'magenta' ],
E : [ 'blink', 'yellow' ],
F : [ 'blink', 'white' ],
}[v.charAt(0)] || ['normal'];
8 : [ 'bold', 'blackBG' ],
9 : [ 'bold', 'blueBG' ],
A : [ 'bold', 'greenBG' ],
B : [ 'bold', 'cyanBG' ],
C : [ 'bold', 'redBG' ],
D : [ 'bold', 'magentaBG' ],
E : [ 'bold', 'yellowBG' ],
F : [ 'bold', 'whiteBG' ],
}[v.charAt(0)] || [ 'normal' ];
bg = {
0 : [ 'blackBG' ],
1 : [ 'blueBG' ],
2 : [ 'greenBG' ],
3 : [ 'cyanBG' ],
4 : [ 'redBG' ],
5 : [ 'magentaBG' ],
6 : [ 'yellowBG' ],
7 : [ 'whiteBG' ],
fg = {
0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ],
8 : [ 'bold', 'blackBG' ],
9 : [ 'bold', 'blueBG' ],
A : [ 'bold', 'greenBG' ],
B : [ 'bold', 'cyanBG' ],
C : [ 'bold', 'redBG' ],
D : [ 'bold', 'magentaBG' ],
E : [ 'bold', 'yellowBG' ],
F : [ 'bold', 'whiteBG' ],
}[v.charAt(1)] || [ 'normal' ];
8 : [ 'blink', 'black' ],
9 : [ 'blink', 'blue' ],
A : [ 'blink', 'green' ],
B : [ 'blink', 'cyan' ],
C : [ 'blink', 'red' ],
D : [ 'blink', 'magenta' ],
E : [ 'blink', 'yellow' ],
F : [ 'blink', 'white' ],
}[v.charAt(1)] || ['normal'];
v = ansi.sgr(fg.concat(bg));
result += s.substr(lastIndex, m.index - lastIndex) + v;
break;
v = ANSI.sgr(fg.concat(bg));
result += s.substr(lastIndex, m.index - lastIndex) + v;
break;
case '\x03' :
v = parseInt(m[8], 10);
case '\x03' :
// WWIV
v = parseInt(m[8], 10);
if(isNaN(v)) {
v += m[0];
} else {
v = ansi.sgr({
0 : [ 'reset', 'black' ],
1 : [ 'bold', 'cyan' ],
2 : [ 'bold', 'yellow' ],
3 : [ 'reset', 'magenta' ],
4 : [ 'bold', 'white', 'blueBG' ],
5 : [ 'reset', 'green' ],
6 : [ 'bold', 'blink', 'red' ],
7 : [ 'bold', 'blue' ],
8 : [ 'reset', 'blue' ],
9 : [ 'reset', 'cyan' ],
}[v] || 'normal');
}
if(isNaN(v)) {
v += m[0];
} else {
v = ANSI.sgr({
0 : [ 'reset', 'black' ],
1 : [ 'bold', 'cyan' ],
2 : [ 'bold', 'yellow' ],
3 : [ 'reset', 'magenta' ],
4 : [ 'bold', 'white', 'blueBG' ],
5 : [ 'reset', 'green' ],
6 : [ 'bold', 'blink', 'red' ],
7 : [ 'bold', 'blue' ],
8 : [ 'reset', 'blue' ],
9 : [ 'reset', 'cyan' ],
}[v] || 'normal');
}
result += s.substr(lastIndex, m.index - lastIndex) + v;
result += s.substr(lastIndex, m.index - lastIndex) + v;
break;
break;
}
case '\x19' :
case '\0x11' :
// CNET "Y-Style" & "Q-Style"
v = m[9] || m[11];
if(v) {
if('n1' === v) {
v = '\n';
} else if('f1' === v) {
v = ANSI.clearScreen();
} else {
v = ansiSgrFromCnetStyleColorCode(v);
}
} else {
v = m[0];
}
result += s.substr(lastIndex, m.index - lastIndex) + v;
break;
}
lastIndex = RE.lastIndex;
}
lastIndex = RE.lastIndex;
}
return (0 === result.length ? s : result + s.substr(lastIndex));
return (0 === result.length ? s : result + s.substr(lastIndex));
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,85 +1,76 @@
/* jslint node: true */
'use strict';
var Config = require('./config.js').config;
var Log = require('./logger.js').log;
// deps
const paths = require('path');
const fs = require('graceful-fs');
const hjson = require('hjson');
const sane = require('sane');
var paths = require('path');
var fs = require('graceful-fs');
var events = require('events');
var util = require('util');
var assert = require('assert');
var hjson = require('hjson');
var _ = require('lodash');
module.exports = new class ConfigCache
{
constructor() {
this.cache = new Map(); // path->parsed config
}
function ConfigCache() {
events.EventEmitter.call(this);
getConfigWithOptions(options, cb) {
const cached = this.cache.has(options.filePath);
var self = this;
this.cache = {}; // filePath -> HJSON
//this.gaze = new Gaze();
if(options.forceReCache || !cached) {
this.recacheConfigFromFile(options.filePath, (err, config) => {
if(!err && !cached) {
if(!options.noWatch) {
const watcher = sane(
paths.dirname(options.filePath),
{
glob : `**/${paths.basename(options.filePath)}`
}
);
this.reCacheConfigFromFile = function(filePath, cb) {
fs.readFile(filePath, { encoding : 'utf-8' }, function fileRead(err, data) {
try {
self.cache[filePath] = hjson.parse(data);
cb(null, self.cache[filePath]);
} catch(e) {
Log.error( { filePath : filePath, error : e.toString() }, 'Failed recaching');
cb(e);
}
});
};
watcher.on('change', (fileName, fileRoot) => {
require('./logger.js').log.info( { fileName, fileRoot }, 'Configuration file changed; re-caching');
/*
this.gaze.on('error', function gazeErr(err) {
this.recacheConfigFromFile(paths.join(fileRoot, fileName), err => {
if(!err) {
if(options.callback) {
options.callback( { fileName, fileRoot } );
}
}
});
});
}
}
return cb(err, config, true);
});
} else {
return cb(null, this.cache.get(options.filePath), false);
}
}
});
getConfig(filePath, cb) {
return this.getConfigWithOptions( { filePath }, cb);
}
this.gaze.on('changed', function fileChanged(filePath) {
assert(filePath in self.cache);
recacheConfigFromFile(path, cb) {
fs.readFile(path, { encoding : 'utf-8' }, (err, data) => {
if(err) {
return cb(err);
}
Log.info( { path : filePath }, 'Configuration file changed; re-caching');
let parsed;
try {
parsed = hjson.parse(data);
this.cache.set(path, parsed);
} catch(e) {
try {
require('./logger.js').log.error( { filePath : path, error : e.message }, 'Failed to re-cache' );
} catch(ignored) {
// nothing - we may be failing to parse the config in which we can't log here!
}
return cb(e);
}
self.reCacheConfigFromFile(filePath, function reCached(err) {
if(err) {
Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration');
} else {
self.emit('recached', filePath);
}
});
});
*/
}
util.inherits(ConfigCache, events.EventEmitter);
ConfigCache.prototype.getConfigWithOptions = function(options, cb) {
assert(_.isString(options.filePath));
// var self = this;
var isCached = (options.filePath in this.cache);
if(options.forceReCache || !isCached) {
this.reCacheConfigFromFile(options.filePath, function fileCached(err, config) {
if(!err && !isCached) {
//self.gaze.add(options.filePath);
}
cb(err, config, true);
});
} else {
cb(null, this.cache[options.filePath], false);
}
return cb(null, parsed);
});
}
};
ConfigCache.prototype.getConfig = function(filePath, cb) {
this.getConfigWithOptions( { filePath : filePath }, cb);
};
ConfigCache.prototype.getModConfig = function(fileName, cb) {
this.getConfig(paths.join(Config.paths.mods, fileName), cb);
};
module.exports = exports = new ConfigCache();

View File

@ -1,18 +1,67 @@
/* jslint node: true */
'use strict';
const Config = require('./config.js').config;
const configCache = require('./config_cache.js');
const paths = require('path');
exports.getFullConfig = getFullConfig;
const Config = require('./config.js').get;
const ConfigCache = require('./config_cache.js');
const Events = require('./events.js');
// deps
const paths = require('path');
const async = require('async');
exports.init = init;
exports.getConfigPath = getConfigPath;
exports.getFullConfig = getFullConfig;
function getConfigPath(filePath) {
// |filePath| is assumed to be in the config path if it's only a file name
if('.' === paths.dirname(filePath)) {
filePath = paths.join(Config().paths.config, filePath);
}
return filePath;
}
function init(cb) {
// pre-cache menu.hjson and prompt.hjson + establish events
const changed = ( { fileName, fileRoot } ) => {
const reCachedPath = paths.join(fileRoot, fileName);
if(reCachedPath === getConfigPath(Config().general.menuFile)) {
Events.emit(Events.getSystemEvents().MenusChanged);
} else if(reCachedPath === getConfigPath(Config().general.promptFile)) {
Events.emit(Events.getSystemEvents().PromptsChanged);
}
};
const config = Config();
async.series(
[
function menu(callback) {
return ConfigCache.getConfigWithOptions(
{
filePath : getConfigPath(config.general.menuFile),
callback : changed,
},
callback
);
},
function prompt(callback) {
return ConfigCache.getConfigWithOptions(
{
filePath : getConfigPath(config.general.promptFile),
callback : changed,
},
callback
);
}
],
err => {
return cb(err);
}
);
}
function getFullConfig(filePath, cb) {
// |filePath| is assumed to be in the config path if it's only a file name
if('.' === paths.dirname(filePath)) {
filePath = paths.join(Config.paths.config, filePath);
}
configCache.getConfig(filePath, function loaded(err, configJson) {
cb(err, configJson);
});
}
ConfigCache.getConfig(getConfigPath(filePath), (err, config) => {
return cb(err, config);
});
}

View File

@ -1,187 +1,266 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const ansi = require('./ansi_term.js');
const Events = require('./events.js');
// ENiGMA½
const ansi = require('./ansi_term.js');
const Events = require('./events.js');
const { Errors } = require('./enig_error.js');
// deps
const async = require('async');
// deps
const async = require('async');
exports.connectEntry = connectEntry;
exports.connectEntry = connectEntry;
function ansiDiscoverHomePosition(client, cb) {
//
// We want to find the home position. ANSI-BBS and most terminals
// utilize 1,1 as home. However, some terminals such as ConnectBot
// think of home as 0,0. If this is the case, we need to offset
// our positioning to accomodate for such.
//
const done = function(err) {
client.removeListener('cursor position report', cprListener);
clearTimeout(giveUpTimer);
return cb(err);
};
//
// We want to find the home position. ANSI-BBS and most terminals
// utilize 1,1 as home. However, some terminals such as ConnectBot
// think of home as 0,0. If this is the case, we need to offset
// our positioning to accommodate for such.
//
const done = function(err) {
client.removeListener('cursor position report', cprListener);
clearTimeout(giveUpTimer);
return cb(err);
};
const cprListener = function(pos) {
const h = pos[0];
const w = pos[1];
const cprListener = function(pos) {
const h = pos[0];
const w = pos[1];
//
// We expect either 0,0, or 1,1. Anything else will be filed as bad data
//
if(h > 1 || w > 1) {
client.log.warn( { height : h, width : w }, 'Ignoring ANSI home position CPR due to unexpected values');
return done(new Error('Home position CPR expected to be 0,0, or 1,1'));
}
//
// We expect either 0,0, or 1,1. Anything else will be filed as bad data
//
if(h > 1 || w > 1) {
client.log.warn( { height : h, width : w }, 'Ignoring ANSI home position CPR due to unexpected values');
return done(Errors.UnexpectedState('Home position CPR expected to be 0,0, or 1,1'));
}
if(0 === h & 0 === w) {
//
// Store a CPR offset in the client. All CPR's from this point on will offset by this amount
//
client.log.info('Setting CPR offset to 1');
client.cprOffset = 1;
}
if(0 === h & 0 === w) {
//
// Store a CPR offset in the client. All CPR's from this point on will offset by this amount
//
client.log.info('Setting CPR offset to 1');
client.cprOffset = 1;
}
return done(null);
};
return done(null);
};
client.once('cursor position report', cprListener);
client.once('cursor position report', cprListener);
const giveUpTimer = setTimeout( () => {
return done(new Error('Giving up on home position CPR'));
}, 3000); // 3s
const giveUpTimer = setTimeout( () => {
return done(Errors.General('Giving up on home position CPR'));
}, 3000); // 3s
client.term.write(`${ansi.goHome()}${ansi.queryPos()}`); // go home, query pos
client.term.write(`${ansi.goHome()}${ansi.queryPos()}`); // go home, query pos
}
function ansiAttemptDetectUTF8(client, cb) {
//
// Trick to attempt and detect UTF-8. While there is a lot more than
// just UTF-8 and CP437, many those are the main concerns, when it comes
// terminals that for example tell us they are "xterm" but still want CP437.
//
// Try to detect UTF-8 by discovering the cursor position, writing some
// multi-byte UTF-8, and checking the position again. If the term is really
// UTF-8, we should get a proper position, otherwise we'll be further out.
//
// We currently only do this if the term hasn't already been ID'd as a
// "*nix" terminal -- that is, xterm, etc.
//
if(!client.term.isNixTerm()) {
return cb(null);
}
let posStage = 1;
let initialPosition;
let giveUpTimer;
const giveUp = () => {
client.removeListener('cursor position report', cprListener);
clearTimeout(giveUpTimer);
return cb(null);
};
const ASCIIPortion = ' Character encoding detection ';
const cprListener = (pos) => {
switch(posStage) {
case 1 :
posStage = 2;
initialPosition = pos;
clearTimeout(giveUpTimer);
giveUpTimer = setTimeout( () => {
return giveUp();
}, 2000);
client.once('cursor position report', cprListener);
client.term.rawWrite(`\u9760${ASCIIPortion}\u9760`); // Unicode skulls on each side
client.term.rawWrite(ansi.queryPos());
break;
case 2 :
{
clearTimeout(giveUpTimer);
const len = pos[1] - initialPosition[1];
if(!isNaN(len) && len >= ASCIIPortion.length + 6) { // CP437 displays 3 chars each Unicode skull
client.log.info('Terminal identified as UTF-8 but does not appear to be. Overriding to "ansi".');
client.setTermType('ansi');
}
}
return cb(null);
}
};
giveUpTimer = setTimeout( () => {
return giveUp();
}, 2000);
client.once('cursor position report', cprListener);
client.term.rawWrite(ansi.goHome() + ansi.queryPos());
}
function ansiQueryTermSizeIfNeeded(client, cb) {
if(client.term.termHeight > 0 || client.term.termWidth > 0) {
return cb(null);
}
if(client.term.termHeight > 0 || client.term.termWidth > 0) {
return cb(null);
}
const done = function(err) {
client.removeListener('cursor position report', cprListener);
clearTimeout(giveUpTimer);
return cb(err);
};
const done = function(err) {
client.removeListener('cursor position report', cprListener);
clearTimeout(giveUpTimer);
return cb(err);
};
const cprListener = function(pos) {
//
// If we've already found out, disregard
//
if(client.term.termHeight > 0 || client.term.termWidth > 0) {
return done(null);
}
const cprListener = function(pos) {
//
// If we've already found out, disregard
//
if(client.term.termHeight > 0 || client.term.termWidth > 0) {
return done(null);
}
const h = pos[0];
const w = pos[1];
const h = pos[0];
const w = pos[1];
//
// Netrunner for example gives us 1x1 here. Not really useful. Ignore
// values that seem obviously bad.
//
if(h < 10 || w < 10) {
client.log.warn(
{ height : h, width : w },
'Ignoring ANSI CPR screen size query response due to very small values');
return done(new Error('Term size <= 10 considered invalid'));
}
//
// NetRunner for example gives us 1x1 here. Not really useful. Ignore
// values that seem obviously bad. Included in the set is the explicit
// 999x999 values we asked to move to.
//
if(h < 10 || h === 999 || w < 10 || w === 999) {
client.log.warn(
{ height : h, width : w },
'Ignoring ANSI CPR screen size query response due to non-sane values');
return done(Errors.Invalid('Term size <= 10 considered invalid'));
}
client.term.termHeight = h;
client.term.termWidth = w;
client.term.termHeight = h;
client.term.termWidth = w;
client.log.debug(
{
termWidth : client.term.termWidth,
termHeight : client.term.termHeight,
source : 'ANSI CPR'
},
'Window size updated'
);
client.log.debug(
{
termWidth : client.term.termWidth,
termHeight : client.term.termHeight,
source : 'ANSI CPR'
},
'Window size updated'
);
return done(null);
};
return done(null);
};
client.once('cursor position report', cprListener);
client.once('cursor position report', cprListener);
// give up after 2s
const giveUpTimer = setTimeout( () => {
return done(new Error('No term size established by CPR within timeout'));
}, 2000);
// give up after 2s
const giveUpTimer = setTimeout( () => {
return done(Errors.General('No term size established by CPR within timeout'));
}, 2000);
// Start the process: Query for CPR
client.term.rawWrite(ansi.queryScreenSize());
// Start the process:
// 1 - Ask to goto 999,999 -- a very much "bottom right" (generally 80x25 for example
// is the real size)
// 2 - Query for screen size with bansi.txt style specialized Device Status Report (DSR)
// request. We expect a CPR of:
// a - Terms that support bansi.txt style: Screen size
// b - Terms that do not support bansi.txt style: Since we moved to the bottom right
// we should still be able to determine a screen size.
//
client.term.rawWrite(`${ansi.goto(999, 999)}${ansi.queryScreenSize()}`);
}
function prepareTerminal(term) {
term.rawWrite(ansi.normal());
//term.rawWrite(ansi.disableVT100LineWrapping());
// :TODO: set xterm stuff -- see x84/others
term.rawWrite(`${ansi.normal()}${ansi.clearScreen()}`);
}
function displayBanner(term) {
// note: intentional formatting:
term.pipeWrite(`
// note: intentional formatting:
term.pipeWrite(`
|06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN
|06Copyright (c) 2014-2018 Bryan Ashby |14- |12http://l33t.codes/
|06Copyright (c) 2014-2019 Bryan Ashby |14- |12http://l33t.codes/
|06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/
|00`
);
);
}
function connectEntry(client, nextMenu) {
const term = client.term;
const term = client.term;
async.series(
[
function basicPrepWork(callback) {
term.rawWrite(ansi.queryDeviceAttributes(0));
return callback(null);
},
function discoverHomePosition(callback) {
ansiDiscoverHomePosition(client, () => {
// :TODO: If CPR for home fully fails, we should bail out on the connection with an error, e.g. ANSI support required
return callback(null); // we try to continue anyway
});
},
function queryTermSizeByNonStandardAnsi(callback) {
ansiQueryTermSizeIfNeeded(client, err => {
if(err) {
//
// Check again; We may have got via NAWS/similar before CPR completed.
//
if(0 === term.termHeight || 0 === term.termWidth) {
//
// We still don't have something good for term height/width.
// Default to DOS size 80x25.
//
// :TODO: Netrunner is currenting hitting this and it feels wrong. Why is NAWS/ENV/CPR all failing???
client.log.warn( { reason : err.message }, 'Failed to negotiate term size; Defaulting to 80x25!');
async.series(
[
function basicPrepWork(callback) {
term.rawWrite(ansi.queryDeviceAttributes(0));
return callback(null);
},
function discoverHomePosition(callback) {
ansiDiscoverHomePosition(client, () => {
// :TODO: If CPR for home fully fails, we should bail out on the connection with an error, e.g. ANSI support required
return callback(null); // we try to continue anyway
});
},
function queryTermSizeByNonStandardAnsi(callback) {
ansiQueryTermSizeIfNeeded(client, err => {
if(err) {
//
// Check again; We may have got via NAWS/similar before CPR completed.
//
if(0 === term.termHeight || 0 === term.termWidth) {
//
// We still don't have something good for term height/width.
// Default to DOS size 80x25.
//
// :TODO: Netrunner is currently hitting this and it feels wrong. Why is NAWS/ENV/CPR all failing???
client.log.warn( { reason : err.message }, 'Failed to negotiate term size; Defaulting to 80x25!');
term.termHeight = 25;
term.termWidth = 80;
}
}
term.termHeight = 25;
term.termWidth = 80;
}
}
return callback(null);
});
},
],
() => {
prepareTerminal(term);
return callback(null);
});
},
function checkUtf8IfNeeded(callback) {
return ansiAttemptDetectUTF8(client, callback);
}
],
() => {
prepareTerminal(term);
//
// Always show an ENiGMA½ banner
//
displayBanner(term);
//
// Always show an ENiGMA½ banner
//
displayBanner(term);
// fire event
Events.emit('codes.l33t.enigma.system.term_detected', { client : client } );
// fire event
Events.emit(Events.getSystemEvents().TermDetected, { client : client } );
setTimeout( () => {
return client.menuStack.goto(nextMenu);
}, 500);
}
);
setTimeout( () => {
return client.menuStack.goto(nextMenu);
}, 500);
}
);
}

View File

@ -1,54 +1,91 @@
/* jslint node: true */
'use strict';
const CRC32_TABLE = new Int32Array(
'00000000 77073096 EE0E612C 990951BA 076DC419 706AF48F E963A535 9E6495A3 0EDB8832 79DCB8A4 E0D5E91E 97D2D988 09B64C2B 7EB17CBD E7B82D07 90BF1D91 1DB71064 6AB020F2 F3B97148 84BE41DE 1ADAD47D 6DDDE4EB F4D4B551 83D385C7 136C9856 646BA8C0 FD62F97A 8A65C9EC 14015C4F 63066CD9 FA0F3D63 8D080DF5 3B6E20C8 4C69105E D56041E4 A2677172 3C03E4D1 4B04D447 D20D85FD A50AB56B 35B5A8FA 42B2986C DBBBC9D6 ACBCF940 32D86CE3 45DF5C75 DCD60DCF ABD13D59 26D930AC 51DE003A C8D75180 BFD06116 21B4F4B5 56B3C423 CFBA9599 B8BDA50F 2802B89E 5F058808 C60CD9B2 B10BE924 2F6F7C87 58684C11 C1611DAB B6662D3D 76DC4190 01DB7106 98D220BC EFD5102A 71B18589 06B6B51F 9FBFE4A5 E8B8D433 7807C9A2 0F00F934 9609A88E E10E9818 7F6A0DBB 086D3D2D 91646C97 E6635C01 6B6B51F4 1C6C6162 856530D8 F262004E 6C0695ED 1B01A57B 8208F4C1 F50FC457 65B0D9C6 12B7E950 8BBEB8EA FCB9887C 62DD1DDF 15DA2D49 8CD37CF3 FBD44C65 4DB26158 3AB551CE A3BC0074 D4BB30E2 4ADFA541 3DD895D7 A4D1C46D D3D6F4FB 4369E96A 346ED9FC AD678846 DA60B8D0 44042D73 33031DE5 AA0A4C5F DD0D7CC9 5005713C 270241AA BE0B1010 C90C2086 5768B525 206F85B3 B966D409 CE61E49F 5EDEF90E 29D9C998 B0D09822 C7D7A8B4 59B33D17 2EB40D81 B7BD5C3B C0BA6CAD EDB88320 9ABFB3B6 03B6E20C 74B1D29A EAD54739 9DD277AF 04DB2615 73DC1683 E3630B12 94643B84 0D6D6A3E 7A6A5AA8 E40ECF0B 9309FF9D 0A00AE27 7D079EB1 F00F9344 8708A3D2 1E01F268 6906C2FE F762575D 806567CB 196C3671 6E6B06E7 FED41B76 89D32BE0 10DA7A5A 67DD4ACC F9B9DF6F 8EBEEFF9 17B7BE43 60B08ED5 D6D6A3E8 A1D1937E 38D8C2C4 4FDFF252 D1BB67F1 A6BC5767 3FB506DD 48B2364B D80D2BDA AF0A1B4C 36034AF6 41047A60 DF60EFC3 A867DF55 316E8EEF 4669BE79 CB61B38C BC66831A 256FD2A0 5268E236 CC0C7795 BB0B4703 220216B9 5505262F C5BA3BBE B2BD0B28 2BB45A92 5CB36A04 C2D7FFA7 B5D0CF31 2CD99E8B 5BDEAE1D 9B64C2B0 EC63F226 756AA39C 026D930A 9C0906A9 EB0E363F 72076785 05005713 95BF4A82 E2B87A14 7BB12BAE 0CB61B38 92D28E9B E5D5BE0D 7CDCEFB7 0BDBDF21 86D3D2D4 F1D4E242 68DDB3F8 1FDA836E 81BE16CD F6B9265B 6FB077E1 18B74777 88085AE6 FF0F6A70 66063BCA 11010B5C 8F659EFF F862AE69 616BFFD3 166CCF45 A00AE278 D70DD2EE 4E048354 3903B3C2 A7672661 D06016F7 4969474D 3E6E77DB AED16A4A D9D65ADC 40DF0B66 37D83BF0 A9BCAE53 DEBB9EC5 47B2CF7F 30B5FFE9 BDBDF21C CABAC28A 53B39330 24B4A3A6 BAD03605 CDD70693 54DE5729 23D967BF B3667A2E C4614AB8 5D681B02 2A6F2B94 B40BBE37 C30C8EA1 5A05DF1B 2D02EF8D'.split(' ').map(s => parseInt(s, 16)));
const CRC32_TABLE = new Int32Array([
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535,
0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd,
0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d,
0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec,
0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4,
0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c,
0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac,
0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab,
0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f,
0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb,
0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea,
0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce,
0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a,
0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409,
0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739,
0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8,
0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268,
0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0,
0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8,
0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef,
0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703,
0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7,
0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a,
0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae,
0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,
0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6,
0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d,
0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5,
0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605,
0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d
]);
exports.CRC32 = class CRC32 {
constructor() {
this.crc = -1;
}
constructor() {
this.crc = -1;
}
update(input) {
input = Buffer.isBuffer(input) ? input : Buffer.from(input, 'binary');
return input.length > 10240 ? this.update_8(input) : this.update_4(input);
}
update(input) {
input = Buffer.isBuffer(input) ? input : Buffer.from(input, 'binary');
return input.length > 10240 ? this.update_8(input) : this.update_4(input);
}
update_4(input) {
const len = input.length - 3;
let i = 0;
update_4(input) {
const len = input.length - 3;
let i = 0;
for(i = 0; i < len;) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
}
while(i < len + 3) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
}
}
for(i = 0; i < len;) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
}
while(i < len + 3) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
}
}
update_8(input) {
const len = input.length - 7;
let i = 0;
update_8(input) {
const len = input.length - 7;
let i = 0;
for(i = 0; i < len;) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
}
while(i < len + 7) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
}
}
finalize() {
return (this.crc ^ (-1)) >>> 0;
}
for(i = 0; i < len;) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
}
while(i < len + 7) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
}
}
finalize() {
return (this.crc ^ (-1)) >>> 0;
}
};

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,104 +1,88 @@
/* jslint node: true */
'use strict';
// enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule;
const stringFormat = require('./string_format.js');
const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas;
const StatLog = require('./stat_log.js');
// enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule;
const { getSortedAvailableFileAreas } = require('./file_base_area.js');
const StatLog = require('./stat_log.js');
const SysProps = require('./system_property.js');
// deps
const async = require('async');
// deps
const async = require('async');
exports.moduleInfo = {
name : 'File Area Selector',
desc : 'Select from available file areas',
author : 'NuSkooler',
name : 'File Area Selector',
desc : 'Select from available file areas',
author : 'NuSkooler',
};
const MciViewIds = {
areaList : 1,
areaList : 1,
};
exports.getModule = class FileAreaSelectModule extends MenuModule {
constructor(options) {
super(options);
constructor(options) {
super(options);
this.config = this.menuConfig.config || {};
this.menuMethods = {
selectArea : (formData, extraArgs, cb) => {
const filterCriteria = {
areaTag : formData.value.areaTag,
};
this.loadAvailAreas();
const menuOpts = {
extraArgs : {
filterCriteria : filterCriteria,
},
menuFlags : [ 'popParent', 'mergeFlags' ],
};
this.menuMethods = {
selectArea : (formData, extraArgs, cb) => {
const area = this.availAreas[formData.value.areaSelect] || 0;
return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
}
};
}
const filterCriteria = {
areaTag : area.areaTag,
};
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
const menuOpts = {
extraArgs : {
filterCriteria : filterCriteria,
},
menuFlags : [ 'popParent' ],
};
const self = this;
return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
}
};
}
async.waterfall(
[
function mergeAreaStats(callback) {
const areaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats) || { areas : {} };
loadAvailAreas() {
this.availAreas = getSortedAvailableFileAreas(this.client);
}
// we could use 'sort' alone, but area/conf sorting has some special properties; user can still override
const availAreas = getSortedAvailableFileAreas(self.client);
availAreas.forEach(area => {
const stats = areaStats.areas[area.areaTag];
area.totalFiles = stats ? stats.files : 0;
area.totalBytes = stats ? stats.bytes : 0;
});
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
return callback(null, availAreas);
},
function prepView(availAreas, callback) {
self.prepViewController('allViews', 0, mciData.menu, (err, vc) => {
if(err) {
return callback(err);
}
const self = this;
const areaListView = vc.getView(MciViewIds.areaList);
areaListView.setItems(availAreas.map(area => Object.assign(area, { text : area.name, data : area.areaTag } )));
areaListView.redraw();
async.series(
[
function mergeAreaStats(callback) {
const areaStats = StatLog.getSystemStat('file_base_area_stats') || { areas : {} };
self.availAreas.forEach(area => {
const stats = areaStats.areas[area.areaTag];
area.totalFiles = stats ? stats.files : 0;
area.totalBytes = stats ? stats.bytes : 0;
});
return callback(null);
},
function prepView(callback) {
self.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => {
if(err) {
return callback(err);
}
const areaListView = vc.getView(MciViewIds.areaList);
const areaListFormat = self.config.areaListFormat || '{name}';
areaListView.setItems(self.availAreas.map(a => stringFormat(areaListFormat, a) ) );
if(self.config.areaListFocusFormat) {
areaListView.setFocusItems(self.availAreas.map(a => stringFormat(self.config.areaListFocusFormat, a) ) );
}
areaListView.redraw();
return callback(null);
});
}
],
err => {
return cb(err);
}
);
});
}
return callback(null);
});
}
],
err => {
return cb(err);
}
);
});
}
};

View File

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

View File

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

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

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

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