Merge 0.0.9-alpha into develop

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

2
.eslintignore Normal file
View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

469
config/achievements.hjson Normal file
View File

@ -0,0 +1,469 @@
/*
./\/\." ENiGMA½ Achievement Configuration -/--/-------- - -- -
_____________________ _____ ____________________ __________\_ /
\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp!
// __|___// | \// |// | \// | | \// \ /___ /_____
/____ _____| __________ ___|__| ____| \ / _____ \
---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/
/__ _\
<*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/
*-----------------------------------------------------------------------------*
General Information
------------------------------- - -
This configuration is in HJSON (http://hjson.org/) format. Strict to-spec
JSON is also perfectly valid. Use "hjson" from npm to convert to/from JSON.
See http://hjson.org/ for more information and syntax.
Various editors and IDEs such as Sublime Text 3 Visual Studio Code and so
on have syntax highlighting for the HJSON format which are highly recommended.
------------------------------- -- - -
Achievement Configuration
------------------------------- - -
Achievements are currently fairly limited in what can trigger them. This is
being expanded upon and more will be available in the near future. For now
you should mostly be interested in:
- Perhaps adding additional *levels* of triggers & points
- Applying customizations via the achievements section in theme.hjson
Some tips:
- For 'userStatSet' types, see user_property.js
Don"t forget to RTFM ...er uh... see the documentation for more information and
don"t be shy to ask for help:
BBS : Xibalba @ xibalba.l33t.codes
FTN : BBS Discussion on fsxNet or ArakNet
IRC : #enigma-bbs / FreeNode
Email : bryan@l33t.codes
*/
{
// Set to false to disable the achievement system
enabled : true
art : {
localHeader: achievement_local_header
localFooter: achievement_local_footer
globalHeader: achievement_global_header
globalFooter: achievement_global_footer
}
achievements: {
user_login_count: {
type: userStatSet
statName: login_count
match: {
2: {
title: "Return Caller"
globalText: "{userName} has returned to {boardName}!"
text: "You've returned to {boardName}!"
points: 5
}
10: {
title: "Curious Caller"
globalText: "{userName} has logged into {boardName} {achievedValue} times!"
text: "You've logged into {boardName} {achievedValue} times!"
points: 10
}
25: {
title: "Inquisitive"
globalText: "{userName} has logged into {boardName} {achievedValue} times!"
text: "You've logged into {boardName} {achievedValue} times!"
points: 10
}
75: {
title: "Still Interested!"
globalText: "{userName} has logged into {boardName} {achievedValue} times!"
text: "You've logged into {boardName} {achievedValue} times!"
points: 15
}
100: {
title: "Regular Customer"
globalText: "{userName} has logged into {boardName} {achievedValue} times!"
text: "You've logged into {boardName} {achievedValue} times!"
points: 25
}
250: {
title: "Speed Dial",
globalText: "{userName} has logged into {boardName} {achievedValue} times!"
text: "You've logged into {boardName} {achievedValue} times!"
points: 50
}
500: {
title: "System Addict"
globalText: "{userName} the BBS {boardName} addict has logged in {achievedValue} times!"
text: "You're a {boardName} addict! You've logged in {achievedValue} times!"
points: 50
}
}
}
user_post_count: {
type: userStatSet
statName: post_count
match: {
2: {
title: "Poster"
globalText: "{userName} has posted {achievedValue} messages!"
text: "You've posted {achievedValue} messages!"
points: 5
}
5: {
title: "Poster... again!",
globalText: "{userName} has posted {achievedValue} messages!"
text: "You've posted {achievedValue} messages!"
points: 5
}
20: {
title: "Just Want to Talk",
globalText: "{userName} has posted {achievedValue} messages!"
text: "You've posted {achievedValue} messages!"
points: 10
}
100: {
title: "Probably Just Spam",
globalText: "{userName} has posted {achievedValue} messages!"
text: "You've posted {achievedValue} messages!"
points: 25
}
250: {
title: "Scribe"
globalText: "{userName} the scribe has posted {achievedValue} messages!"
text: "Such a scribe! You've posted {achievedValue} messages!"
points: 50
}
500: {
title: "Writing a Book"
globalText: "{userName} is writing a book and has posted {achievedValue} messages!"
text: "You've posted {achievedValue} messages!"
points: 50
}
}
}
user_upload_count: {
type: userStatSet
statName: ul_total_count
match: {
1: {
title: "Uploader"
globalText: "{userName} has uploaded a file!"
text: "You've uploaded somthing!"
points: 5
}
10: {
title: "Moar Uploads!"
globalText: "{userName} has uploaded {achievedValue} files!"
text: "You've uploaded {achievedValue} files!"
points: 10
}
50: {
title: "Contributor"
globalText: "{userName} has uploaded {achievedValue} files!"
text: "You've uploaded {achievedValue} files!"
points: 25
}
100: {
title: "Courier"
globalText: "Courier {userName} has uploaded {achievedValue} files!"
text: "You've uploaded {achievedValue} files!"
points: 50
}
200: {
title: "Must Be a Drop Site"
globalText: "{userName} has uploaded a whomping {achievedValue} files!"
text: "You've uploaded a whomping {achievedValue} files!"
points: 55
}
}
}
user_upload_bytes: {
type: userStatSet
statName: ul_total_bytes
match: {
10240: {
text: "UNIVAC Drum"
globalText: "{userName} has uploaded 10k. Enough to fill a UNIVAC drum!"
text: "You've uploaded 10k. Enough to fill a UNIVAC drum!"
points: 5
}
524288: {
title: "Kickstart"
globalText: "{userName} has uploaded 512KB, enough for a Kickstart!"
text: "You've uploaded 512KB, enough for a Kickstart!"
points: 10
}
1474560: {
title: "AOL Disk Anyone?"
globalText: "{userName} has uploaded 1.44M worth of data. Hopefully it's not AOL!"
title: "You've uploaded 1.44M worth of data. Hopefully it's not AOL!"
points: 10
}
6291456: {
title: "A Quake of a Upload"
globalText: "{userName} has uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!"
text: "You've uploaded 6 x 1.44MB disks worth of data. That's the size of Quake for DOS!"
points: 20
}
104857600: {
title: "Zip 100"
globalText: "{userName} has uploaded a Zip 100 disk's worth of data!"
text: "You've uploaded a Zip 100 disk's worth of data!"
points: 25
}
1073741824: {
title: "Gigabyte!"
globalText: "{userName} has uploaded a Gigabyte worth of data!"
text: "You've uploaded a Gigabyte worth of data!"
points: 50
}
3407872000: {
title: "Encarta"
globalText: "{userName} has uploaded 5xCD discs worth of data. That's the size of Encarta!"
text: "You've uploaded 5xCD discs worth of data. That's the size of Encarta!"
points: 50
}
7025459200: {
title: "NFL_Madden_2007_USA_BLURAY_DIRFIX_PS3-PARADOX"
globalText: "{userName} has uploaded 67x100 MiB worth of data, the size of the worlds first PS3 rip!"
text: "You've uploaded 67x100 MiB worth of data, the size of the world first PS3 rip!"
points: 100
}
25018184499: {
title: "WaYsTeD"
globalText: "{userName} has uploaded 23.3 GiB of data, the size of the first PS4 rip: Watch.Dogs.PS4-WaYsTeD!"
text: "You've uploaded 23.3 GiB of data, the size of the first PS4 rip: Watch.Dogs.PS4-WaYsTeD!"
points: 150
}
}
}
user_download_count: {
type: userStatSet
statName: dl_total_count
match: {
1: {
title: "Downloader"
globalText: "{userName} has downloaded a file!"
text: "You've downloaded somthing!"
points: 5
}
10: {
title: "Moar Downloads!"
globalText: "{userName} has downloaded {achievedValue} files!"
text: "You've downloaded {achievedValue} files!"
points: 10
}
50: {
title: "Leecher"
globalText: "{userName} has leeched {achievedValue} files!"
text: "You've leeched... er... downloaded {achievedValue} files!"
points: 15
}
100: {
title: "Hoarder"
globalText: "{userName} has downloaded {achievedValue} files!"
text: "Hoarding files? You've downloaded {achievedValue} files!"
points: 20
}
200: {
title: "Digital Archivist"
globalText: "{userName} the digital archivist has {achievedValue} files!"
text: "Building an archive? You've downloaded {achievedValue} files!"
points: 25
}
}
}
user_download_bytes: {
type: userStatSet
statName: dl_total_bytes
match: {
655360: {
title: "Ought to be Enough"
globalText: "{userName} has downloaded 640K. Ought to be enough for anyone!"
text: "You've downloaded 640K. Ought to be enough for anyone!"
points: 5
}
1474560: {
title: "Fits on a Floppy"
globalText: "{userName} has downloaded 1.44MB worth of data!"
text: "You've downloaded 1.44MB of data!"
points: 5
}
104857600: {
title: "Click of Death"
globalText: "{userName} has downloaded 100MB... perhaps to a Zip Disk?"
text: "You've downloaded 100MB of data... perhaps to a Zip Disk?"
points: 10
}
681574400: {
title: "CD Rip"
globalText: "{userName} has downloaded a CD-ROM's worth of data!"
text: "You've downloaded a CD-ROM's worth of data!"
points: 15
}
1073741824: {
title: "Like One Hundred Floppys, Man"
globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!"
text: "You've downloaded {achievedValue!sizeWithAbbr} of data!"
points: 25
}
5368709120: {
title: "That's a Lot of Bits!"
globalText: "{userName} has downloaded {achievedValue!sizeWithAbbr} of data!"
text: "You've downloaded {achievedValue!sizeWithAbbr} of data!"
}
}
}
user_door_runs: {
type: userStatSet
statName: door_run_total_count
match: {
1: {
title: "Nostalgia Toe Dip",
globalText: "{userName} ran a door!"
text: "You ran a door!"
points: 5
},
10: {
title: "This is Kinda Fun"
globalText: "{userName} ran {achievedValue} doors!"
text: "You've run {achievedValue} doors!"
points: 10
}
50: {
title: "Gamer"
globalText: "{userName} ran {achievedValue} doors!"
text: "You've run {achievedValue} doors!"
points: 20
}
100: {
title: "Trying Them All"
globalText: "{userName} must really like textmode and has run {achievedValue} doors!"
text: "You've run {achievedValue} doors! You must really like textmode!"
points: 50
}
200: {
title: "Dropfile Enthusiast"
globalText: "{userName} the dropfile enthusiast ran {achievedValue} doors!"
text: "You're a dropfile enthusiast! You've run {achievedValue} doors!"
points: 55
}
}
}
user_individual_door_run_minutes: {
type: userStatInc
statName: door_run_total_minutes
retroactive: false
match: {
1: {
title: "Nevermind!"
globalText: "{userName} ran a door for {achievedValue!durationMinutes}. Guess it's not their thing!"
text: "You ran a door for only {achievedValue!durationMinutes}. Not your thing?"
points: 5
}
10: {
title: "It's OK I Guess"
globalText: "{userName} ran a door for {achievedValue!durationMinutes}!"
text: "You ran a door for {achievedValue!durationMinutes}!"
points: 10
}
30: {
title: "Good Game"
globalText: "{userName} ran a door for {achievedValue!durationMinutes}!"
text: "You ran a door for {achievedValue!durationMinutes}!"
points: 20
}
60: {
title: "What? Limited Turns?!"
globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!"
text: "You've spent {achievedValue!durationMinutes} in a door!"
points: 25
}
120: {
title: "It's the Only One I Know!"
globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!"
text: "You've spent {achievedValue!durationMinutes} in a door!"
points: 50
}
240: {
title: "Possible Addict"
globalText: "{userName} has spent {achievedValue!durationMinutes} in a door!"
text: "You've spent {achievedValue!durationMinutes} in a door!"
points: 55
}
}
}
user_door_run_total_minutes: {
type: userStatIncNewVal
statName: door_run_total_minutes
match: {
10: {
title: "Enough for the Instructions"
globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!"
text: "You've spent {achievedValue!durationMinutes} playing doors!"
points: 10
}
30: {
title: "Probably Just L.O.R.D."
globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!"
text: "You've spent {achievedValue!durationMinutes} playing doors!"
points: 20
}
60: {
title: "Retro or Bust"
globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!"
text: "You've spent {achievedValue!durationMinutes} playing doors!"
points: 25
}
240: {
title: "Textmode Dragon Slayer"
globalText: "{userName} has spent {achievedValue!durationMinutes} playing doors!"
text: "You've spent {achievedValue!durationMinutes} playing doors!"
points: 50
}
}
}
user_total_system_online_minutes: {
type: userStatSet
statName: minutes_online_total_count
match: {
30: {
title: "Just Poking Around"
globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!"
text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!"
points: 5
}
60: {
title: "Mildly Interesting"
globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!"
text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!"
points: 15
}
120: {
title: "Nothing Better to Do"
globalText: "{userName} has spent {achievedValue!durationMinutes} on {boardName}!"
text: "You've been on {boardName} for a total of {achievedValue!durationMinutes}!"
points: 25
}
1440: {
title: "Idle Bot"
globalText: "{userName} is probably a bot. They've spent {achievedValue!durationMinutes} on {boardName}!"
text: "You're a bot, aren't you? You've been on {boardName} for a total of {achievedValue!durationMinutes}!"
points: 55
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,22 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const MenuModule = require('./menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const DropFile = require('./dropfile.js').DropFile; const DropFile = require('./dropfile.js');
const door = require('./door.js'); const Door = require('./door.js');
const theme = require('./theme.js'); const theme = require('./theme.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async'); const async = require('async');
const assert = require('assert'); const assert = require('assert');
const paths = require('path');
const _ = require('lodash'); const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs; const paths = require('path');
// :TODO: This should really be a system module... needs a little work to allow for such
const activeDoorNodeInstances = {}; const activeDoorNodeInstances = {};
@ -65,6 +68,7 @@ exports.getModule = class AbracadabraModule extends MenuModule {
this.config = options.menuConfig.config; this.config = options.menuConfig.config;
// :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... } // :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... }
// .. and/or EnigAssert
assert(_.isString(this.config.name, 'Config \'name\' is required')); assert(_.isString(this.config.name, 'Config \'name\' is required'));
assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required')); assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required'));
assert(_.isString(this.config.cmd, 'Config \'cmd\' is required')); assert(_.isString(this.config.cmd, 'Config \'cmd\' is required'));
@ -100,7 +104,7 @@ exports.getModule = class AbracadabraModule extends MenuModule {
if(_.isString(self.config.tooManyArt)) { if(_.isString(self.config.tooManyArt)) {
theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() { theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() {
self.pausePrompt( () => { self.pausePrompt( () => {
callback(new Error('Too many active instances')); return callback(Errors.AccessDenied('Too many active instances'));
}); });
}); });
} else { } else {
@ -108,7 +112,7 @@ exports.getModule = class AbracadabraModule extends MenuModule {
// :TODO: Use MenuModule.pausePrompt() // :TODO: Use MenuModule.pausePrompt()
self.pausePrompt( () => { self.pausePrompt( () => {
callback(new Error('Too many active instances')); return callback(Errors.AccessDenied('Too many active instances'));
}); });
} }
} else { } else {
@ -122,19 +126,17 @@ exports.getModule = class AbracadabraModule extends MenuModule {
callback(null); callback(null);
} }
}, },
function prepareDoor(callback) {
self.doorInstance = new Door(self.client);
return self.doorInstance.prepare(self.config.io || 'stdio', callback);
},
function generateDropfile(callback) { function generateDropfile(callback) {
self.dropFile = new DropFile(self.client, self.config.dropFileType); const dropFileOpts = {
var fullPath = self.dropFile.fullPath; fileType : self.config.dropFileType,
};
mkdirs(paths.dirname(fullPath), function dirCreated(err) { self.dropFile = new DropFile(self.client, dropFileOpts);
if(err) { return self.dropFile.createFile(callback);
callback(err);
} else {
self.dropFile.createFile(function created(err) {
callback(err);
});
}
});
} }
], ],
function complete(err) { function complete(err) {
@ -150,20 +152,24 @@ exports.getModule = class AbracadabraModule extends MenuModule {
} }
runDoor() { runDoor() {
this.client.term.write(ansi.resetScreen());
const exeInfo = { const exeInfo = {
cmd : this.config.cmd, cmd : this.config.cmd,
cwd : this.config.cwd || paths.dirname(this.config.cmd),
args : this.config.args, args : this.config.args,
io : this.config.io || 'stdio', io : this.config.io || 'stdio',
encoding : this.config.encoding || this.client.term.outputEncoding, encoding : this.config.encoding || 'cp437',
dropFile : this.dropFile.fileName, dropFile : this.dropFile.fileName,
dropFilePath : this.dropFile.fullPath,
node : this.client.node, node : this.client.node,
//inhSocket : this.client.output._handle.fd,
}; };
const doorInstance = new door.Door(this.client, exeInfo); const doorTracking = trackDoorRunBegin(this.client, this.config.name);
this.doorInstance.run(exeInfo, () => {
trackDoorRunEnd(doorTracking);
doorInstance.once('finished', () => {
// //
// Try to clean up various settings such as scroll regions that may // Try to clean up various settings such as scroll regions that may
// have been set within the door // have been set within the door
@ -178,10 +184,6 @@ exports.getModule = class AbracadabraModule extends MenuModule {
this.prevMenu(); this.prevMenu();
}); });
this.client.term.write(ansi.resetScreen());
doorInstance.run();
} }
leave() { leave() {

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

@ -10,15 +10,15 @@ const assert = require('assert');
const _ = require('lodash'); const _ = require('lodash');
class ACS { class ACS {
constructor(client) { constructor(subject) {
this.client = client; this.subject = subject;
} }
check(acs, scope, defaultAcs) { check(acs, scope, defaultAcs) {
acs = acs ? acs[scope] : defaultAcs; acs = acs ? acs[scope] : defaultAcs;
acs = acs || defaultAcs; acs = acs || defaultAcs;
try { try {
return checkAcs(acs, { client : this.client } ); return checkAcs(acs, { subject : this.subject } );
} catch(e) { } catch(e) {
Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS'); Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS');
return false; return false;
@ -51,20 +51,37 @@ class ACS {
return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload); return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload);
} }
hasMenuModuleAccess(modInst) {
const acs = _.get(modInst, 'menuConfig.config.acs');
if(!_.isString(acs)) {
return true; // no ACS check req.
}
try {
return checkAcs(acs, { subject : this.subject } );
} catch(e) {
Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS');
return false;
}
}
getConditionalValue(condArray, memberName) { getConditionalValue(condArray, memberName) {
assert(_.isArray(condArray)); if(!Array.isArray(condArray)) {
// no cond array, just use the value
return condArray;
}
assert(_.isString(memberName)); assert(_.isString(memberName));
const matchCond = condArray.find( cond => { const matchCond = condArray.find( cond => {
if(_.has(cond, 'acs')) { if(_.has(cond, 'acs')) {
try { try {
return checkAcs(cond.acs, { client : this.client } ); return checkAcs(cond.acs, { subject : this.subject } );
} catch(e) { } catch(e) {
Log.warn( { exception : e, acs : cond }, 'Exception caught checking ACS'); Log.warn( { exception : e, acs : cond }, 'Exception caught checking ACS');
return false; return false;
} }
} else { } else {
return true; // no acs check req. return true; // no ACS check req.
} }
}); });

View File

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

View File

@ -3,7 +3,9 @@
const miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const Log = require('./logger.js').log;
// deps
const events = require('events'); const events = require('events');
const util = require('util'); const util = require('util');
const _ = require('lodash'); const _ = require('lodash');
@ -24,7 +26,7 @@ function ANSIEscapeParser(options) {
this.graphicRendition = {}; this.graphicRendition = {};
this.parseState = { this.parseState = {
re : /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, re : /(?:\x1b\x5b)([?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, // eslint-disable-line no-control-regex
}; };
options = miscUtil.valueWithDefault(options, { options = miscUtil.valueWithDefault(options, {
@ -63,7 +65,7 @@ function ANSIEscapeParser(options) {
delete self.savedPosition; delete self.savedPosition;
self.positionUpdated(); self.positionUpdated();
// self.rowUpdated(); // self.rowUpdated();
}; };
self.clearScreen = function() { self.clearScreen = function() {
@ -71,7 +73,7 @@ function ANSIEscapeParser(options) {
self.emit('clear screen'); self.emit('clear screen');
}; };
/* /*
self.rowUpdated = function() { self.rowUpdated = function() {
self.emit('row update', self.row + self.scrollBack); self.emit('row update', self.row + self.scrollBack);
};*/ };*/
@ -142,17 +144,9 @@ function ANSIEscapeParser(options) {
} }
} }
function getProcessedMCI(mci) {
if(self.mciReplaceChar.length > 0) {
return ansi.getSGRFromGraphicRendition(self.graphicRendition, true) + new Array(mci.length + 1).join(self.mciReplaceChar);
} else {
return mci;
}
}
function parseMCI(buffer) { function parseMCI(buffer) {
// :TODO: move this to "constants" seciton @ top // :TODO: move this to "constants" seciton @ top
var mciRe = /\%([A-Z]{2})([0-9]{1,2})?(?:\(([0-9A-Za-z,]+)\))*/g; var mciRe = /%([A-Z]{2})([0-9]{1,2})?(?:\(([0-9A-Za-z,]+)\))*/g;
var pos = 0; var pos = 0;
var match; var match;
var mciCode; var mciCode;
@ -197,16 +191,12 @@ function ANSIEscapeParser(options) {
if(self.mciReplaceChar.length > 0) { if(self.mciReplaceChar.length > 0) {
const sgrCtrl = ansi.getSGRFromGraphicRendition(self.graphicRenditionForErase); const sgrCtrl = ansi.getSGRFromGraphicRendition(self.graphicRenditionForErase);
self.emit('control', sgrCtrl, 'm', sgrCtrl.slice(2).split(/[\;m]/).slice(0, 3)); self.emit('control', sgrCtrl, 'm', sgrCtrl.slice(2).split(/[;m]/).slice(0, 3));
literal(new Array(match[0].length + 1).join(self.mciReplaceChar)); literal(new Array(match[0].length + 1).join(self.mciReplaceChar));
} else { } else {
literal(match[0]); literal(match[0]);
} }
//literal(getProcessedMCI(match[0]));
//self.emit('chunk', getProcessedMCI(match[0]));
} }
} while(0 !== mciRe.lastIndex); } while(0 !== mciRe.lastIndex);
@ -220,7 +210,7 @@ function ANSIEscapeParser(options) {
self.parseState = { self.parseState = {
// ignore anything past EOF marker, if any // ignore anything past EOF marker, if any
buffer : input.split(String.fromCharCode(0x1a), 1)[0], buffer : input.split(String.fromCharCode(0x1a), 1)[0],
re : /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, re : /(?:\x1b\x5b)([?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, // eslint-disable-line no-control-regex
stop : false, stop : false,
}; };
}; };
@ -291,13 +281,13 @@ function ANSIEscapeParser(options) {
} }
} }
parseMCI(lastBit) parseMCI(lastBit);
} }
self.emit('complete'); self.emit('complete');
}; };
/* /*
self.parse = function(buffer, savedRe) { self.parse = function(buffer, savedRe) {
// :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc. // :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc.
// :TODO: move this to "constants" section @ top // :TODO: move this to "constants" section @ top
@ -448,7 +438,7 @@ function ANSIEscapeParser(options) {
break; break;
default : default :
console.log('Unknown attribute: ' + arg); // :TODO: Log properly Log.trace( { attribute : arg }, 'Unknown attribute while parsing ANSI');
break; break;
} }
} }

View File

@ -17,12 +17,20 @@
// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt // * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt
// * http://academic.evergreen.edu/projects/biophysics/technotes/program/ansi_esc.htm // * http://academic.evergreen.edu/projects/biophysics/technotes/program/ansi_esc.htm
// //
// Modern Windows (Win10+)
// * https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
//
// VT100
// * http://www.noah.org/python/pexpect/ANSI-X3.64.htm
//
// VTX // VTX
// * https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt // * https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt
// //
// General // General
// * http://en.wikipedia.org/wiki/ANSI_escape_code // * http://en.wikipedia.org/wiki/ANSI_escape_code
// * http://www.inwap.com/pdp10/ansicode.txt // * 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 // Other Implementations
// * https://github.com/chjj/term.js/blob/master/src/term.js // * https://github.com/chjj/term.js/blob/master/src/term.js

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

@ -2,15 +2,17 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const resolveMimeType = require('./mime_util.js').resolveMimeType; const resolveMimeType = require('./mime_util.js').resolveMimeType;
const Events = require('./events.js');
// base/modules // base/modules
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const _ = require('lodash'); const _ = require('lodash');
const pty = require('ptyw.js'); const pty = require('node-pty');
const paths = require('path');
let archiveUtil; let archiveUtil;
@ -48,22 +50,29 @@ module.exports = class ArchiveUtil {
} }
// singleton access // singleton access
static getInstance() { static getInstance(noWatch = false) {
if(!archiveUtil) { if(!archiveUtil) {
archiveUtil = new ArchiveUtil(); archiveUtil = new ArchiveUtil();
archiveUtil.init(); archiveUtil.init(noWatch);
} }
return archiveUtil; return archiveUtil;
} }
init() { init(noWatch = false) {
// this.reloadConfig();
// Load configuration if(!noWatch) {
// Events.on(Events.getSystemEvents().ConfigChanged, () => {
if(_.has(Config, 'archives.archivers')) { this.reloadConfig();
Object.keys(Config.archives.archivers).forEach(archKey => { });
}
}
const archConfig = Config.archives.archivers[archKey]; reloadConfig() {
const config = Config();
if(_.has(config, 'archives.archivers')) {
Object.keys(config.archives.archivers).forEach(archKey => {
const archConfig = config.archives.archivers[archKey];
const archiver = new Archiver(archConfig); const archiver = new Archiver(archConfig);
if(!archiver.ok()) { if(!archiver.ok()) {
@ -74,33 +83,58 @@ module.exports = class ArchiveUtil {
}); });
} }
if(_.isObject(Config.fileTypes)) { if(_.isObject(config.fileTypes)) {
Object.keys(Config.fileTypes).forEach(mimeType => { const updateSig = (ft) => {
const fileType = Config.fileTypes[mimeType]; ft.sig = Buffer.from(ft.sig, 'hex');
if(fileType.sig) { ft.offset = ft.offset || 0;
fileType.sig = new Buffer(fileType.sig, 'hex');
fileType.offset = fileType.offset || 0;
// :TODO: this is broken: sig is NOT this long, it's sig.length long; offset needs to allow for -negative values as well // :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; const sigLen = ft.offset + ft.sig.length;
if(sigLen > this.longestSignature) { if(sigLen > this.longestSignature) {
this.longestSignature = sigLen; this.longestSignature = sigLen;
} }
};
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);
} }
}); });
} }
} }
getArchiver(mimeTypeOrExtension) { getArchiver(mimeTypeOrExtension, justExtention) {
mimeTypeOrExtension = resolveMimeType(mimeTypeOrExtension); const mimeType = resolveMimeType(mimeTypeOrExtension);
if(!mimeTypeOrExtension) { // lookup returns false on failure if(!mimeType) { // lookup returns false on failure
return; return;
} }
const archiveHandler = _.get( Config, [ 'fileTypes', mimeTypeOrExtension, 'archiveHandler'] ); const config = Config();
if(archiveHandler) { let fileType = _.get(config, [ 'fileTypes', mimeType ] );
return _.get( Config, [ 'archives', 'archivers', archiveHandler ] );
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);
}
if(!_.isObject(fileType)) {
return;
}
if(fileType.archiveHandler) {
return _.get( config, [ 'archives', 'archivers', fileType.archiveHandler ] );
} }
} }
@ -108,37 +142,48 @@ module.exports = class ArchiveUtil {
return this.getArchiver(archType) ? true : false; return this.getArchiver(archType) ? true : false;
} }
// :TODO: implement me:
/*
detectTypeWithBuf(buf, cb) { detectTypeWithBuf(buf, cb) {
// :TODO: implement me!
} }
*/
detectType(path, cb) { detectType(path, cb) {
const closeFile = (fd) => {
fs.close(fd, () => { /* sadface */ });
};
fs.open(path, 'r', (err, fd) => { fs.open(path, 'r', (err, fd) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
const buf = new Buffer(this.longestSignature); const buf = Buffer.alloc(this.longestSignature);
fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => { fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => {
if(err) { if(err) {
closeFile(fd);
return cb(err); return cb(err);
} }
const archFormat = _.findKey(Config.fileTypes, fileTypeInfo => { const archFormat = _.findKey(Config().fileTypes, fileTypeInfo => {
if(!fileTypeInfo.sig) { const fileTypeInfos = Array.isArray(fileTypeInfo) ? fileTypeInfo : [ fileTypeInfo ];
return fileTypeInfos.find(fti => {
if(!fti.sig || !fti.archiveHandler) {
return false; return false;
} }
const lenNeeded = fileTypeInfo.offset + fileTypeInfo.sig.length; const lenNeeded = fti.offset + fti.sig.length;
if(bytesRead < lenNeeded) { if(bytesRead < lenNeeded) {
return false; return false;
} }
const comp = buf.slice(fileTypeInfo.offset, fileTypeInfo.offset + fileTypeInfo.sig.length); const comp = buf.slice(fti.offset, fti.offset + fti.sig.length);
return (fileTypeInfo.sig.equals(comp)); return (fti.sig.equals(comp));
});
}); });
closeFile(fd);
return cb(archFormat ? null : Errors.General('Unknown type'), archFormat); return cb(archFormat ? null : Errors.General('Unknown type'), archFormat);
}); });
}); });
@ -160,7 +205,7 @@ module.exports = class ArchiveUtil {
} }
compressTo(archType, archivePath, files, cb) { compressTo(archType, archivePath, files, cb) {
const archiver = this.getArchiver(archType); const archiver = this.getArchiver(archType, paths.extname(archivePath));
if(!archiver) { if(!archiver) {
return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); return cb(Errors.Invalid(`Unknown archive type: ${archType}`));
@ -177,7 +222,9 @@ module.exports = class ArchiveUtil {
try { try {
proc = pty.spawn(archiver.compress.cmd, args, this.getPtyOpts()); proc = pty.spawn(archiver.compress.cmd, args, this.getPtyOpts());
} catch(e) { } catch(e) {
return cb(e); return cb(Errors.ExternalProcess(
`Error spawning archiver process "${archiver.compress.cmd}" with args "${args.join(' ')}": ${e.message}`)
);
} }
return this.spawnHandler(proc, 'Compression', cb); return this.spawnHandler(proc, 'Compression', cb);
@ -194,7 +241,7 @@ module.exports = class ArchiveUtil {
haveFileList = true; haveFileList = true;
} }
const archiver = this.getArchiver(archType); const archiver = this.getArchiver(archType, paths.extname(archivePath));
if(!archiver) { if(!archiver) {
return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); return cb(Errors.Invalid(`Unknown archive type: ${archType}`));
@ -205,7 +252,12 @@ module.exports = class ArchiveUtil {
extractPath : extractPath, extractPath : extractPath,
}; };
const action = haveFileList ? 'extract' : 'decompress'; 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 // we need to treat {fileList} special in that it should be broken up to 0:n args
const args = archiver[action].args.map( arg => { const args = archiver[action].args.map( arg => {
@ -220,16 +272,18 @@ module.exports = class ArchiveUtil {
let proc; let proc;
try { try {
proc = pty.spawn(archiver[action].cmd, args, this.getPtyOpts()); proc = pty.spawn(archiver[action].cmd, args, this.getPtyOpts(extractPath));
} catch(e) { } catch(e) {
return cb(e); return cb(Errors.ExternalProcess(
`Error spawning archiver process "${archiver[action].cmd}" with args "${args.join(' ')}": ${e.message}`)
);
} }
return this.spawnHandler(proc, (haveFileList ? 'Extraction' : 'Decompression'), cb); return this.spawnHandler(proc, (haveFileList ? 'Extraction' : 'Decompression'), cb);
} }
listEntries(archivePath, archType, cb) { listEntries(archivePath, archType, cb) {
const archiver = this.getArchiver(archType); const archiver = this.getArchiver(archType, paths.extname(archivePath));
if(!archiver) { if(!archiver) {
return cb(Errors.Invalid(`Unknown archive type: ${archType}`)); return cb(Errors.Invalid(`Unknown archive type: ${archType}`));
@ -245,7 +299,9 @@ module.exports = class ArchiveUtil {
try { try {
proc = pty.spawn(archiver.list.cmd, args, this.getPtyOpts()); proc = pty.spawn(archiver.list.cmd, args, this.getPtyOpts());
} catch(e) { } catch(e) {
return cb(e); return cb(Errors.ExternalProcess(
`Error spawning archiver process "${archiver.list.cmd}" with args "${args.join(' ')}": ${e.message}`)
);
} }
let output = ''; let output = '';
@ -276,13 +332,17 @@ module.exports = class ArchiveUtil {
}); });
} }
getPtyOpts() { getPtyOpts(extractPath) {
return { const opts = {
// :TODO: cwd
name : 'enigma-archiver', name : 'enigma-archiver',
cols : 80, cols : 80,
rows : 24, rows : 24,
env : process.env, env : process.env,
}; };
if(extractPath) {
opts.cwd = extractPath;
}
// :TODO: set cwd to supplied temp path if not sepcific extract
return opts;
} }
}; };

View File

@ -2,11 +2,12 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const aep = require('./ansi_escape_parser.js'); const aep = require('./ansi_escape_parser.js');
const sauce = require('./sauce.js'); const sauce = require('./sauce.js');
const { Errors } = require('./enig_error.js');
// deps // deps
const fs = require('graceful-fs'); const fs = require('graceful-fs');
@ -126,7 +127,7 @@ function getArtFromPath(path, options, cb) {
function getArt(name, options, cb) { function getArt(name, options, cb) {
const ext = paths.extname(name); const ext = paths.extname(name);
options.basePath = miscUtil.valueWithDefault(options.basePath, Config.paths.art); options.basePath = miscUtil.valueWithDefault(options.basePath, Config().paths.art);
options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true); options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true);
// :TODO: make use of asAnsi option and convert from supported -> ansi // :TODO: make use of asAnsi option and convert from supported -> ansi
@ -209,7 +210,7 @@ function getArt(name, options, cb) {
return getArtFromPath(readPath, options, cb); return getArtFromPath(readPath, options, cb);
} }
return cb(new Error(`No matching art for supplied criteria: ${name}`)); return cb(Errors.DoesNotExist(`No matching art for supplied criteria: ${name}`));
}); });
} }
@ -236,7 +237,7 @@ function display(client, art, options, cb) {
} }
if(!art || !art.length) { if(!art || !art.length) {
return cb(new Error('Empty art')); return cb(Errors.Invalid('No art supplied!'));
} }
options.mciReplaceChar = options.mciReplaceChar || ' '; options.mciReplaceChar = options.mciReplaceChar || ' ';
@ -288,7 +289,7 @@ function display(client, art, options, cb) {
} }
if(!options.disableMciCache) { if(!options.disableMciCache) {
artHash = xxhash.hash(new Buffer(art), 0xCAFEBABE); artHash = xxhash.hash(Buffer.from(art), 0xCAFEBABE);
// see if we have a mciMap cached for this art // see if we have a mciMap cached for this art
if(client.mciCache) { if(client.mciCache) {

View File

@ -2,7 +2,7 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
// deps // deps
@ -29,17 +29,21 @@ const ALL_ASSETS = [
'sysStat', 'sysStat',
]; ];
const ASSET_RE = new RegExp('\\@(' + ALL_ASSETS.join('|') + ')\\:([\\w\\d\\.]*)(?:\\/([\\w\\d\\_]+))*'); const ASSET_RE = new RegExp(
'^@(' + ALL_ASSETS.join('|') + ')' +
/:(?:([^:]+):)?([A-Za-z0-9_\-.]+)$/.source
);
function parseAsset(s) { function parseAsset(s) {
const m = ASSET_RE.exec(s); const m = ASSET_RE.exec(s);
if(m) { if(m) {
let result = { type : m[1] }; const result = { type : m[1] };
if(m[3]) { if(m[3]) {
result.location = m[2];
result.asset = m[3]; result.asset = m[3];
if(m[2]) {
result.location = m[2];
}
} else { } else {
result.asset = m[2]; result.asset = m[2];
} }
@ -95,7 +99,7 @@ function resolveConfigAsset(spec) {
assert('config' === asset.type); assert('config' === asset.type);
const path = asset.asset.split('.'); const path = asset.asset.split('.');
let conf = Config; let conf = Config();
for(let i = 0; i < path.length; ++i) { for(let i = 0; i < path.length; ++i) {
if(_.isUndefined(conf[path[i]])) { if(_.isUndefined(conf[path[i]])) {
return spec; return spec;

View File

@ -10,6 +10,9 @@ const conf = require('./config.js');
const logger = require('./logger.js'); const logger = require('./logger.js');
const database = require('./database.js'); const database = require('./database.js');
const resolvePath = require('./misc_util.js').resolvePath; const resolvePath = require('./misc_util.js').resolvePath;
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
const SysLogKeys = require('./system_log.js');
// deps // deps
const async = require('async'); const async = require('async');
@ -18,6 +21,7 @@ const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs; const mkdirs = require('fs-extra').mkdirs;
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const paths = require('path'); const paths = require('path');
const moment = require('moment');
// our main entry point // our main entry point
exports.main = main; exports.main = main;
@ -25,9 +29,12 @@ exports.main = main;
// object with various services we want to de-init/shutdown cleanly if possible // object with various services we want to de-init/shutdown cleanly if possible
const initServices = {}; const initServices = {};
const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby'; // only include bbs.js once @ startup; this should be fine
const COPYRIGHT = fs.readFileSync(paths.join(__dirname, '../LICENSE.TXT'), 'utf8').split(/\r?\n/g)[0];
const FULL_COPYRIGHT = `ENiGMA½ ${COPYRIGHT}`;
const HELP = const HELP =
`${ENIGMA_COPYRIGHT} `${FULL_COPYRIGHT}
usage: main.js <args> usage: main.js <args>
eg : main.js --config /enigma_install_path/config/ eg : main.js --config /enigma_install_path/config/
@ -42,6 +49,10 @@ function printHelpAndExit() {
process.exit(); process.exit();
} }
function printVersionAndExit() {
console.info(require('../package.json').version);
}
function main() { function main() {
async.waterfall( async.waterfall(
[ [
@ -49,7 +60,11 @@ function main() {
const argv = require('minimist')(process.argv.slice(2)); const argv = require('minimist')(process.argv.slice(2));
if(argv.help) { if(argv.help) {
printHelpAndExit(); return printHelpAndExit();
}
if(argv.version) {
return printVersionAndExit();
} }
const configOverridePath = argv.config; const configOverridePath = argv.config;
@ -72,10 +87,10 @@ function main() {
configPathSupplied = null; // make non-fatal; we'll go with defaults configPathSupplied = null; // make non-fatal; we'll go with defaults
} }
} else { } else {
console.error(err.toString()); console.error(err.message);
} }
} }
callback(err); return callback(err);
}); });
}, },
function initSystem(callback) { function initSystem(callback) {
@ -88,14 +103,16 @@ function main() {
} }
], ],
function complete(err) { function complete(err) {
if(!err) {
// note this is escaped: // note this is escaped:
fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => { fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => {
console.info(ENIGMA_COPYRIGHT); console.info(FULL_COPYRIGHT);
if(!err) { if(!err) {
console.info(banner); console.info(banner);
} }
console.info('System started!'); console.info('System started!');
}); });
}
if(err) { if(err) {
console.error('Error initializing: ' + util.inspect(err)); console.error('Error initializing: ' + util.inspect(err));
@ -116,7 +133,10 @@ function shutdownSystem() {
const activeConnections = ClientConns.getActiveConnections(); const activeConnections = ClientConns.getActiveConnections();
let i = activeConnections.length; let i = activeConnections.length;
while(i--) { while(i--) {
activeConnections[i].term.write('\n\nServer is shutting down NOW! Disconnecting...\n\n'); const activeTerm = activeConnections[i].term;
if(activeTerm) {
activeTerm.write('\n\nServer is shutting down NOW! Disconnecting...\n\n');
}
ClientConns.removeClient(activeConnections[i]); ClientConns.removeClient(activeConnections[i]);
} }
callback(null); callback(null);
@ -187,48 +207,55 @@ function initialize(cb) {
function initStatLog(callback) { function initStatLog(callback) {
return require('./stat_log.js').init(callback); return require('./stat_log.js').init(callback);
}, },
function initConfigs(callback) {
return require('./config_util.js').init(callback);
},
function initThemes(callback) { function initThemes(callback) {
// Have to pull in here so it's after Config init // Have to pull in here so it's after Config init
require('./theme.js').initAvailableThemes(function onThemesInit(err, themeCount) { require('./theme.js').initAvailableThemes( (err, themeCount) => {
logger.log.info({ themeCount : themeCount }, 'Themes initialized'); logger.log.info({ themeCount }, 'Themes initialized');
return callback(err); return callback(err);
}); });
}, },
function loadSysOpInformation(callback) { function loadSysOpInformation(callback) {
// //
// Copy over some +op information from the user DB -> system propertys. // Copy over some +op information from the user DB -> system properties.
// * Makes this accessible for MCI codes, easy non-blocking access, etc. // * 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 // * We do this every time as the op is free to change this information just
// like any other user // like any other user
// //
const User = require('./user.js'); const User = require('./user.js');
const propLoadOpts = {
names : [
UserProps.RealName, UserProps.Sex, UserProps.EmailAddress,
UserProps.Location, UserProps.Affiliations,
],
};
async.waterfall( async.waterfall(
[ [
function getOpUserName(next) { function getOpUserName(next) {
return User.getUserName(1, next); return User.getUserName(1, next);
}, },
function getOpProps(opUserName, next) { function getOpProps(opUserName, next) {
const propLoadOpts = {
names : [ 'real_name', 'sex', 'email_address', 'location', 'affiliation' ],
};
User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => { User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => {
return next(err, opUserName, opProps); return next(err, opUserName, opProps);
}); });
} },
], ],
(err, opUserName, opProps) => { (err, opUserName, opProps) => {
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
if(err) { if(err) {
[ 'username', 'real_name', 'sex', 'email_address', 'location', 'affiliation' ].forEach(v => { propLoadOpts.names.concat('username').forEach(v => {
StatLog.setNonPeristentSystemStat(`sysop_${v}`, 'N/A'); StatLog.setNonPersistentSystemStat(`sysop_${v}`, 'N/A');
}); });
} else { } else {
opProps.username = opUserName; opProps.username = opUserName;
_.each(opProps, (v, k) => { _.each(opProps, (v, k) => {
StatLog.setNonPeristentSystemStat(`sysop_${k}`, v); StatLog.setNonPersistentSystemStat(`sysop_${k}`, v);
}); });
} }
@ -236,17 +263,24 @@ function initialize(cb) {
} }
); );
}, },
function initFileAreaStats(callback) { function initCallsToday(callback) {
const getAreaStats = require('./file_base_area.js').getAreaStats;
getAreaStats( (err, stats) => {
if(!err) {
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
StatLog.setNonPeristentSystemStat('file_base_area_stats', stats); const filter = {
} logName : SysLogKeys.UserLoginHistory,
resultType : 'count',
date : moment(),
};
StatLog.findSystemLogEntries(filter, (err, callsToday) => {
if(!err) {
StatLog.setNonPersistentSystemStat(SysProps.LoginsToday, callsToday);
}
return callback(null); return callback(null);
}); });
}, },
function initMessageStats(callback) {
return require('./message_area.js').startup(callback);
},
function initMCI(callback) { function initMCI(callback) {
return require('./predefined_mci.js').init(callback); return require('./predefined_mci.js').init(callback);
}, },
@ -256,9 +290,15 @@ function initialize(cb) {
function readyEvents(callback) { function readyEvents(callback) {
return require('./events.js').startup(callback); return require('./events.js').startup(callback);
}, },
function genericModulesInit(callback) {
return require('./module_util.js').initializeModules(callback);
},
function listenConnections(callback) { function listenConnections(callback) {
return require('./listening_server.js').startup(callback); return require('./listening_server.js').startup(callback);
}, },
function readyFileBaseArea(callback) {
return require('./file_base_area.js').startup(callback);
},
function readyFileAreaWeb(callback) { function readyFileAreaWeb(callback) {
return require('./file_area_web.js').startup(callback); return require('./file_area_web.js').startup(callback);
}, },
@ -272,6 +312,9 @@ function initialize(cb) {
initServices.eventScheduler = modInst; initServices.eventScheduler = modInst;
return callback(err); return callback(err);
}); });
},
function listenUserEventsForStatLog(callback) {
return require('./stat_log.js').initUserEvents(callback);
} }
], ],
function onComplete(err) { function onComplete(err) {

View File

@ -1,11 +1,16 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const MenuModule = require('./menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const resetScreen = require('./ansi_term.js').resetScreen; const { resetScreen } = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async'); const async = require('async');
const _ = require('lodash');
const http = require('http'); const http = require('http');
const net = require('net'); const net = require('net');
const crypto = require('crypto'); const crypto = require('crypto');
@ -60,15 +65,17 @@ exports.getModule = class BBSLinkModule extends MenuModule {
async.series( async.series(
[ [
function validateConfig(callback) { function validateConfig(callback) {
if(_.isString(self.config.sysCode) && return self.validateConfigFields(
_.isString(self.config.authCode) &&
_.isString(self.config.schemeCode) &&
_.isString(self.config.door))
{ {
callback(null); host : 'string',
} else { sysCode : 'string',
callback(new Error('Configuration is missing option(s)')); authCode : 'string',
} schemeCode : 'string',
door : 'string',
port : 'number',
},
callback
);
}, },
function acquireToken(callback) { function acquireToken(callback) {
// //
@ -95,7 +102,7 @@ exports.getModule = class BBSLinkModule extends MenuModule {
// //
// Authenticate the token we acquired previously // Authenticate the token we acquired previously
// //
var headers = { const headers = {
'X-User' : self.client.user.userId.toString(), 'X-User' : self.client.user.userId.toString(),
'X-System' : self.config.sysCode, 'X-System' : self.config.sysCode,
'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'), 'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'),
@ -112,10 +119,9 @@ exports.getModule = class BBSLinkModule extends MenuModule {
var status = body.trim(); var status = body.trim();
if('complete' === status) { if('complete' === status) {
callback(null); return callback(null);
} else {
callback(new Error('Bad authentication status: ' + status));
} }
return callback(Errors.AccessDenied(`Bad authentication status: ${status}`));
}); });
}, },
function createTelnetBridge(callback) { function createTelnetBridge(callback) {
@ -123,17 +129,19 @@ exports.getModule = class BBSLinkModule extends MenuModule {
// Authentication with BBSLink successful. Now, we need to create a telnet // Authentication with BBSLink successful. Now, we need to create a telnet
// bridge from us to them // bridge from us to them
// //
var connectOpts = { const connectOpts = {
port : self.config.port, port : self.config.port,
host : self.config.host, host : self.config.host,
}; };
var clientTerminated; let clientTerminated;
self.client.term.write(resetScreen()); self.client.term.write(resetScreen());
self.client.term.write(' Connecting to BBSLink.net, please wait...\n'); self.client.term.write(' Connecting to BBSLink.net, please wait...\n');
var bridgeConnection = net.createConnection(connectOpts, function connected() { const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`);
const bridgeConnection = net.createConnection(connectOpts, function connected() {
self.client.log.info(connectOpts, 'BBSLink bridge connection established'); self.client.log.info(connectOpts, 'BBSLink bridge connection established');
self.client.term.output.pipe(bridgeConnection); self.client.term.output.pipe(bridgeConnection);
@ -145,9 +153,11 @@ exports.getModule = class BBSLinkModule extends MenuModule {
}); });
}); });
var restorePipe = function() { const restorePipe = function() {
self.client.term.output.unpipe(bridgeConnection); self.client.term.output.unpipe(bridgeConnection);
self.client.term.output.resume(); self.client.term.output.resume();
trackDoorRunEnd(doorTracking);
}; };
bridgeConnection.on('data', function incomingData(data) { bridgeConnection.on('data', function incomingData(data) {
@ -158,7 +168,7 @@ exports.getModule = class BBSLinkModule extends MenuModule {
bridgeConnection.on('end', function connectionEnd() { bridgeConnection.on('end', function connectionEnd() {
restorePipe(); restorePipe();
callback(clientTerminated ? new Error('Client connection terminated') : null); return callback(clientTerminated ? Errors.General('Client connection terminated') : null);
}); });
bridgeConnection.on('error', function error(err) { bridgeConnection.on('error', function error(err) {

View File

@ -99,6 +99,10 @@ exports.getModule = class BBSListModule extends MenuModule {
self.displayAddScreen(cb); self.displayAddScreen(cb);
}, },
deleteBBS : function(formData, extraArgs, cb) { deleteBBS : function(formData, extraArgs, cb) {
if(!_.isNumber(self.selectedBBS) || 0 === self.entries.length) {
return cb(null);
}
const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList); const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList);
if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) { if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) {
@ -214,12 +218,7 @@ exports.getModule = class BBSListModule extends MenuModule {
} }
setEntries(entriesView) { setEntries(entriesView) {
const config = this.menuConfig.config; return entriesView.setItems(this.entries);
const listFormat = config.listFormat || '{bbsName}';
const focusListFormat = config.focusListFormat || '{bbsName}';
entriesView.setItems(this.entries.map( e => stringFormat(listFormat, e) ) );
entriesView.setFocusItems(this.entries.map( e => stringFormat(focusListFormat, e) ) );
} }
displayBBSList(clearScreen, cb) { displayBBSList(clearScreen, cb) {
@ -273,6 +272,7 @@ exports.getModule = class BBSListModule extends MenuModule {
(err, row) => { (err, row) => {
if (!err) { if (!err) {
self.entries.push({ self.entries.push({
text : row.bbs_name, // standard field
id : row.id, id : row.id,
bbsName : row.bbs_name, bbsName : row.bbs_name,
sysOp : row.sysop, sysOp : row.sysop,
@ -323,6 +323,7 @@ exports.getModule = class BBSListModule extends MenuModule {
entriesView.setFocusItemIndex(self.selectedBBS); entriesView.setFocusItemIndex(self.selectedBBS);
self.drawSelectedEntry(self.entries[self.selectedBBS]); self.drawSelectedEntry(self.entries[self.selectedBBS]);
} else if (self.entries.length > 0) { } else if (self.entries.length > 0) {
self.selectedBBS = 0;
entriesView.setFocusItemIndex(0); entriesView.setFocusItemIndex(0);
self.drawSelectedEntry(self.entries[0]); self.drawSelectedEntry(self.entries[0]);
} }

View File

@ -14,6 +14,8 @@ function ButtonView(options) {
options.cursor = miscUtil.valueWithDefault(options.cursor, 'hide'); options.cursor = miscUtil.valueWithDefault(options.cursor, 'hide');
TextView.call(this, options); TextView.call(this, options);
this.initDefaultWidth();
} }
util.inherits(ButtonView, TextView); util.inherits(ButtonView, TextView);

View File

@ -35,9 +35,12 @@
const term = require('./client_term.js'); const term = require('./client_term.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const User = require('./user.js'); const User = require('./user.js');
const Config = require('./config.js').config; const Config = require('./config.js').get;
const MenuStack = require('./menu_stack.js'); const MenuStack = require('./menu_stack.js');
const ACS = require('./acs.js'); const ACS = require('./acs.js');
const Events = require('./events.js');
const UserInterruptQueue = require('./user_interrupt_queue.js');
const UserProps = require('./user_property.js');
// deps // deps
const stream = require('stream'); const stream = require('stream');
@ -52,8 +55,9 @@ exports.Client = Client;
// Resources & Standards: // Resources & Standards:
// * http://www.ansi-bbs.org/ansi-bbs-core-server.html // * http://www.ansi-bbs.org/ansi-bbs-core-server.html
// //
const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9\;]+)(R)/; /* eslint-disable no-control-regex */
const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[\=\?]([0-9a-zA-Z\;]+)(c)/; const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9;]+)(R)/;
const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[=?]([0-9a-zA-Z;]+)(c)/;
const RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/; const RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/;
const RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$'); const RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$');
const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [ const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [
@ -61,6 +65,7 @@ const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:'
'(?:M([@ #!a`])(.)(.))', // mouse stuff '(?:M([@ #!a`])(.)(.))', // mouse stuff
'(?:1;)?(\\d+)?([a-zA-Z@])' '(?:1;)?(\\d+)?([a-zA-Z@])'
].join('|') + ')'); ].join('|') + ')');
/* eslint-enable no-control-regex */
const RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source); const RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source);
const RE_ESC_CODE_ANYWHERE = new RegExp( [ const RE_ESC_CODE_ANYWHERE = new RegExp( [
@ -68,11 +73,11 @@ const RE_ESC_CODE_ANYWHERE = new RegExp( [
RE_META_KEYCODE_ANYWHERE.source, RE_META_KEYCODE_ANYWHERE.source,
RE_DSR_RESPONSE_ANYWHERE.source, RE_DSR_RESPONSE_ANYWHERE.source,
RE_DEV_ATTR_RESPONSE_ANYWHERE.source, RE_DEV_ATTR_RESPONSE_ANYWHERE.source,
/\u001b./.source /\u001b./.source // eslint-disable-line no-control-regex
].join('|')); ].join('|'));
function Client(input, output) { function Client(/*input, output*/) {
stream.call(this); stream.call(this);
const self = this; const self = this;
@ -81,8 +86,9 @@ function Client(input, output) {
this.currentTheme = { info : { name : 'N/A', description : 'None' } }; this.currentTheme = { info : { name : 'N/A', description : 'None' } };
this.lastKeyPressMs = Date.now(); this.lastKeyPressMs = Date.now();
this.menuStack = new MenuStack(this); this.menuStack = new MenuStack(this);
this.acs = new ACS(this); this.acs = new ACS( { client : this, user : this.user } );
this.mciCache = {}; this.mciCache = {};
this.interruptQueue = new UserInterruptQueue(this);
this.clearMciCache = function() { this.clearMciCache = function() {
this.mciCache = {}; this.mciCache = {};
@ -100,6 +106,23 @@ function Client(input, output) {
} }
}); });
this.setTemporaryDirectDataHandler = function(handler) {
this.input.removeAllListeners('data');
this.input.on('data', handler);
};
this.restoreDataHandler = function() {
this.input.removeAllListeners('data');
this.input.on('data', this.dataHandler);
};
this.themeChangedListener = function( { themeId } ) {
if(_.get(self.currentTheme, 'info.themeId') === themeId) {
self.currentTheme = require('./theme.js').getAvailableThemes().get(themeId);
}
};
Events.on(Events.getSystemEvents().ThemeChanged, this.themeChangedListener);
// //
// Peek at incoming |data| and emit events for any special // Peek at incoming |data| and emit events for any special
@ -138,6 +161,7 @@ function Client(input, output) {
return termClient; return termClient;
}; };
/* eslint-disable no-control-regex */
this.isMouseInput = function(data) { this.isMouseInput = function(data) {
return /\x1b\[M/.test(data) || return /\x1b\[M/.test(data) ||
/\u001b\[M([\x00\u0020-\uffff]{3})/.test(data) || /\u001b\[M([\x00\u0020-\uffff]{3})/.test(data) ||
@ -147,6 +171,7 @@ function Client(input, output) {
/\u001b\[24([0135])~\[(\d+),(\d+)\]\r/.test(data) || /\u001b\[24([0135])~\[(\d+),(\d+)\]\r/.test(data) ||
/\u001b\[(O|I)/.test(data); /\u001b\[(O|I)/.test(data);
}; };
/* eslint-enable no-control-regex */
this.getKeyComponentsFromCode = function(code) { this.getKeyComponentsFromCode = function(code) {
return { return {
@ -383,7 +408,7 @@ function Client(input, output) {
} }
if(key || ch) { if(key || ch) {
if(Config.logging.traceUserKeyboardInput) { if(Config().logging.traceUserKeyboardInput) {
self.log.trace( { key : key, ch : escape(ch) }, 'User keyboard input'); // jshint ignore:line self.log.trace( { key : key, ch : escape(ch) }, 'User keyboard input'); // jshint ignore:line
} }
@ -414,47 +439,86 @@ Client.prototype.setTermType = function(termType) {
}; };
Client.prototype.startIdleMonitor = function() { Client.prototype.startIdleMonitor = function() {
var self = this; this.lastKeyPressMs = Date.now();
self.lastKeyPressMs = Date.now();
// //
// Every 1m, check for idle. // Every 1m, check for idle.
// We also update minutes spent online the system here,
// if we have a authenticated user.
// //
self.idleCheck = setInterval(function checkForIdle() { this.idleCheck = setInterval( () => {
const nowMs = Date.now(); const nowMs = Date.now();
const idleLogoutSeconds = self.user.isAuthenticated() ? let idleLogoutSeconds;
Config.misc.idleLogoutSeconds : if(this.user.isAuthenticated()) {
Config.misc.preAuthIdleLogoutSeconds; idleLogoutSeconds = Config().users.idleLogoutSeconds;
if(nowMs - self.lastKeyPressMs >= (idleLogoutSeconds * 1000)) { //
self.emit('idle timeout'); // 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 - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
this.emit('idle timeout');
} }
}, 1000 * 60); }, 1000 * 60);
}; };
Client.prototype.stopIdleMonitor = function() {
clearInterval(this.idleCheck);
};
Client.prototype.end = function () { Client.prototype.end = function () {
if(this.term) { if(this.term) {
this.term.disconnect(); this.term.disconnect();
} }
var currentModule = this.menuStack.getCurrentModule; Events.removeListener(Events.getSystemEvents().ThemeChanged, this.themeChangedListener);
const currentModule = this.menuStack.getCurrentModule;
if(currentModule) { if(currentModule) {
currentModule.leave(); currentModule.leave();
} }
clearInterval(this.idleCheck); // persist time online for authenticated users
if(this.user.isAuthenticated()) {
this.user.persistProperty(
UserProps.MinutesOnlineTotalCount,
this.user.getProperty(UserProps.MinutesOnlineTotalCount)
);
}
this.stopIdleMonitor();
try { try {
// //
// We can end up calling 'end' before TTY/etc. is established, e.g. with SSH // We can end up calling 'end' before TTY/etc. is established, e.g. with SSH
// //
// :TODO: is this OK? if(_.isFunction(this.disconnect)) {
return this.disconnect();
} else {
// legacy fallback
return this.output.end.apply(this.output, arguments); return this.output.end.apply(this.output, arguments);
}
} catch(e) { } catch(e) {
// TypeError // ie TypeError
} }
}; };
@ -473,8 +537,8 @@ Client.prototype.waitForKeyPress = function(cb) {
}; };
Client.prototype.isLocal = function() { Client.prototype.isLocal = function() {
// :TODO: return rather client is a local connection or not // :TODO: Handle ipv6 better
return false; return [ '127.0.0.1', '::ffff:127.0.0.1' ].includes(this.remoteAddress);
}; };
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
@ -482,7 +546,7 @@ Client.prototype.isLocal = function() {
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// :TODO: getDefaultHandler(name) -- handlers in default_handlers.js or something // :TODO: getDefaultHandler(name) -- handlers in default_handlers.js or something
Client.prototype.defaultHandlerMissingMod = function(err) { Client.prototype.defaultHandlerMissingMod = function() {
var self = this; var self = this;
function handler(err) { function handler(err) {

View File

@ -4,23 +4,30 @@
// ENiGMA½ // ENiGMA½
const logger = require('./logger.js'); const logger = require('./logger.js');
const Events = require('./events.js'); const Events = require('./events.js');
const UserProps = require('./user_property.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const moment = require('moment'); const moment = require('moment');
const hashids = require('hashids');
exports.getActiveConnections = getActiveConnections; exports.getActiveConnections = getActiveConnections;
exports.getActiveNodeList = getActiveNodeList; exports.getActiveConnectionList = getActiveConnectionList;
exports.addNewClient = addNewClient; exports.addNewClient = addNewClient;
exports.removeClient = removeClient; exports.removeClient = removeClient;
exports.getConnectionByUserId = getConnectionByUserId; exports.getConnectionByUserId = getConnectionByUserId;
exports.getConnectionByNodeId = getConnectionByNodeId;
const clientConnections = []; const clientConnections = [];
exports.clientConnections = clientConnections; exports.clientConnections = clientConnections;
function getActiveConnections() { return clientConnections; } function getActiveConnections(authUsersOnly = false) {
return clientConnections.filter(conn => {
return ((authUsersOnly && conn.user.isAuthenticated()) || !authUsersOnly);
});
}
function getActiveNodeList(authUsersOnly) { function getActiveConnectionList(authUsersOnly) {
if(!_.isBoolean(authUsersOnly)) { if(!_.isBoolean(authUsersOnly)) {
authUsersOnly = true; authUsersOnly = true;
@ -28,16 +35,12 @@ function getActiveNodeList(authUsersOnly) {
const now = moment(); const now = moment();
const activeConnections = getActiveConnections().filter(ac => { return _.map(getActiveConnections(authUsersOnly), ac => {
return ((authUsersOnly && ac.user.isAuthenticated()) || !authUsersOnly);
});
return _.map(activeConnections, ac => {
const entry = { const entry = {
node : ac.node, node : ac.node,
authenticated : ac.user.isAuthenticated(), authenticated : ac.user.isAuthenticated(),
userId : ac.user.userId, userId : ac.user.userId,
action : _.has(ac, 'currentMenuModule.menuConfig.desc') ? ac.currentMenuModule.menuConfig.desc : 'Unknown', action : _.get(ac, 'currentMenuModule.menuConfig.desc', 'Unknown'),
}; };
// //
@ -45,11 +48,11 @@ function getActiveNodeList(authUsersOnly) {
// //
if(ac.user.isAuthenticated()) { if(ac.user.isAuthenticated()) {
entry.userName = ac.user.username; entry.userName = ac.user.username;
entry.realName = ac.user.properties.real_name; entry.realName = ac.user.properties[UserProps.RealName];
entry.location = ac.user.properties.location; entry.location = ac.user.properties[UserProps.Location];
entry.affils = ac.user.properties.affiliation; entry.affils = entry.affiliation = ac.user.properties[UserProps.Affiliations];
const diff = now.diff(moment(ac.user.properties.last_login_timestamp), 'minutes'); const diff = now.diff(moment(ac.user.properties[UserProps.LastLoginTs]), 'minutes');
entry.timeOn = moment.duration(diff, 'minutes'); entry.timeOn = moment.duration(diff, 'minutes');
} }
return entry; return entry;
@ -57,12 +60,28 @@ function getActiveNodeList(authUsersOnly) {
} }
function addNewClient(client, clientSock) { function addNewClient(client, clientSock) {
const id = client.session.id = clientConnections.push(client) - 1; //
// 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++;
}
client.session.id = id;
const remoteAddress = client.remoteAddress = clientSock.remoteAddress; 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() ]);
clientConnections.push(client);
clientConnections.sort( (c1, c2) => c1.session.id - c2.session.id);
// Create a client specific logger // Create a client specific logger
// Note that this will be updated @ login with additional information // Note that this will be updated @ login with additional information
client.log = logger.log.child( { clientId : id } ); client.log = logger.log.child( { clientId : id, sessionId : client.session.uniqueId } );
const connInfo = { const connInfo = {
remoteAddress : remoteAddress, remoteAddress : remoteAddress,
@ -77,7 +96,10 @@ function addNewClient(client, clientSock) {
client.log.info(connInfo, 'Client connected'); client.log.info(connInfo, 'Client connected');
Events.emit('codes.l33t.enigma.system.connected', { client : client, connectionCount : clientConnections.length } ); Events.emit(
Events.getSystemEvents().ClientConnected,
{ client : client, connectionCount : clientConnections.length }
);
return id; return id;
} }
@ -97,10 +119,22 @@ function removeClient(client) {
'Client disconnected' 'Client disconnected'
); );
Events.emit('codes.l33t.enigma.system.disconnected', { client : client, connectionCount : clientConnections.length } ); if(client.user && client.user.isValid()) {
const minutesOnline = moment().diff(moment(client.user.properties[UserProps.LastLoginTs]), 'minutes');
Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user, minutesOnline } );
}
Events.emit(
Events.getSystemEvents().ClientDisconnected,
{ client : client, connectionCount : clientConnections.length }
);
} }
} }
function getConnectionByUserId(userId) { function getConnectionByUserId(userId) {
return getActiveConnections().find( ac => userId === ac.user.userId ); return getActiveConnections().find( ac => userId === ac.user.userId );
} }
function getConnectionByNodeId(nodeId) {
return getActiveConnections().find( ac => nodeId == ac.node );
}

View File

@ -3,7 +3,6 @@
// ENiGMA½ // ENiGMA½
var Log = require('./logger.js').log; var Log = require('./logger.js').log;
var enigmaToAnsi = require('./color_codes.js').enigmaToAnsi;
var renegadeToAnsi = require('./color_codes.js').renegadeToAnsi; var renegadeToAnsi = require('./color_codes.js').renegadeToAnsi;
var iconv = require('iconv-lite'); var iconv = require('iconv-lite');
@ -15,8 +14,6 @@ exports.ClientTerminal = ClientTerminal;
function ClientTerminal(output) { function ClientTerminal(output) {
this.output = output; this.output = output;
var self = this;
var outputEncoding = 'cp437'; var outputEncoding = 'cp437';
assert(iconv.encodingExists(outputEncoding)); assert(iconv.encodingExists(outputEncoding));
@ -176,15 +173,8 @@ ClientTerminal.prototype.rawWrite = function(s, cb) {
} }
}; };
ClientTerminal.prototype.pipeWrite = function(s, spec, cb) { ClientTerminal.prototype.pipeWrite = function(s, cb) {
spec = spec || 'renegade'; this.write(renegadeToAnsi(s, this), null, cb); // null = use default for |convertLineFeeds|
var conv = {
enigma : enigmaToAnsi,
renegade : renegadeToAnsi,
}[spec] || renegadeToAnsi;
this.write(conv(s, this), null, cb); // null = use default for |convertLineFeeds|
}; };
ClientTerminal.prototype.encode = function(s, convertLineFeeds) { ClientTerminal.prototype.encode = function(s, convertLineFeeds) {

View File

@ -1,94 +1,29 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var ansi = require('./ansi_term.js'); const ANSI = require('./ansi_term.js');
var getPredefinedMCIValue = require('./predefined_mci.js').getPredefinedMCIValue; const { getPredefinedMCIValue } = require('./predefined_mci.js');
var assert = require('assert'); // deps
var _ = require('lodash'); const _ = require('lodash');
exports.enigmaToAnsi = enigmaToAnsi; exports.stripMciColorCodes = stripMciColorCodes;
exports.stripPipeCodes = exports.stripEnigmaCodes = stripEnigmaCodes; exports.pipeStringLength = pipeStringLength;
exports.pipeStrLen = exports.enigmaStrLen = enigmaStrLen;
exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi; exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi;
exports.controlCodesToAnsi = controlCodesToAnsi; exports.controlCodesToAnsi = controlCodesToAnsi;
// :TODO: Not really happy with the module name of "color_codes". Would like something better // :TODO: Not really happy with the module name of "color_codes". Would like something better ... control_code_string?
function stripMciColorCodes(s) {
// Also add:
// * fromCelerity(): |<case sensitive letter>
// * fromPCBoard(): (@X<bg><fg>)
// * fromWildcat(): (@<bg><fg>@ (same as PCBoard without 'X' prefix and '@' suffix)
// * fromWWIV(): <ctrl-c><0-7>
// * fromSyncronet(): <ctrl-a><colorCode>
// See http://wiki.synchro.net/custom:colors
// :TODO: rid of enigmaToAnsi() -- never really use. Instead, create bbsToAnsi() that supports renegade, PCB, WWIV, etc...
function enigmaToAnsi(s, client) {
if(-1 == s.indexOf('|')) {
return s; // no pipe codes present
}
var result = '';
var re = /\|([A-Z\d]{2}|\|)/g;
var m;
var lastIndex = 0;
while((m = re.exec(s))) {
var val = m[1];
if('|' == val) {
result += '|';
continue;
}
// convert to number
val = parseInt(val, 10);
if(isNaN(val)) {
//
// ENiGMA MCI code? Only available if |client|
// is supplied.
//
val = getPredefinedMCIValue(client, m[1]) || ('|' + m[1]); // value itself or literal
}
if(_.isString(val)) {
result += s.substr(lastIndex, m.index - lastIndex) + val;
} else {
assert(val >= 0 && val <= 47);
var attr = '';
if(7 == val) {
attr = ansi.sgr('normal');
} else if (val < 7 || val >= 16) {
attr = ansi.sgr(['normal', val]);
} else if (val <= 15) {
attr = ansi.sgr(['normal', val - 8, 'bold']);
}
result += s.substr(lastIndex, m.index - lastIndex) + attr;
}
lastIndex = re.lastIndex;
}
result = (0 === result.length ? s : result + s.substr(lastIndex));
return result;
}
function stripEnigmaCodes(s) {
return s.replace(/\|[A-Z\d]{2}/g, ''); return s.replace(/\|[A-Z\d]{2}/g, '');
} }
function enigmaStrLen(s) { function pipeStringLength(s) {
return stripEnigmaCodes(s).length; return stripMciColorCodes(s).length;
} }
function ansiSgrFromRenegadeColorCode(cc) { function ansiSgrFromRenegadeColorCode(cc) {
return ansi.sgr({ return ANSI.sgr({
0 : [ 'reset', 'black' ], 0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ], 1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ], 2 : [ 'reset', 'green' ],
@ -116,14 +51,45 @@ function ansiSgrFromRenegadeColorCode(cc) {
22 : [ 'yellowBG' ], 22 : [ 'yellowBG' ],
23 : [ 'whiteBG' ], 23 : [ 'whiteBG' ],
24 : [ 'bold', 'blackBG' ], 24 : [ 'blink', 'blackBG' ],
25 : [ 'bold', 'blueBG' ], 25 : [ 'blink', 'blueBG' ],
26 : [ 'bold', 'greenBG' ], 26 : [ 'blink', 'greenBG' ],
27 : [ 'bold', 'cyanBG' ], 27 : [ 'blink', 'cyanBG' ],
28 : [ 'bold', 'redBG' ], 28 : [ 'blink', 'redBG' ],
29 : [ 'bold', 'magentaBG' ], 29 : [ 'blink', 'magentaBG' ],
30 : [ 'bold', 'yellowBG' ], 30 : [ 'blink', 'yellowBG' ],
31 : [ 'bold', 'whiteBG' ], 31 : [ 'blink', 'whiteBG' ],
}[cc] || 'normal');
}
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'); }[cc] || 'normal');
} }
@ -132,29 +98,24 @@ function renegadeToAnsi(s, client) {
return s; // no pipe codes present return s; // no pipe codes present
} }
var result = ''; let result = '';
var re = /\|([A-Z\d]{2}|\|)/g; const re = /\|(?:(C[FBUD])([0-9]{1,2})|([0-9]{2})|([A-Z]{2})|(\|))/g;
var m; let m;
var lastIndex = 0; let lastIndex = 0;
while((m = re.exec(s))) { while((m = re.exec(s))) {
var val = m[1]; if(m[3]) {
// |## color
if('|' == val) { const val = parseInt(m[3], 10);
result += '|';
continue;
}
// 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); const attr = ansiSgrFromRenegadeColorCode(val);
result += s.substr(lastIndex, m.index - lastIndex) + attr; 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 += '|';
} }
lastIndex = re.lastIndex; lastIndex = re.lastIndex;
@ -170,9 +131,11 @@ function renegadeToAnsi(s, client) {
// //
// Supported control code formats: // Supported control code formats:
// * Renegade : |## // * Renegade : |##
// * PCBoard : @X## where the first number/char is FG color, and second is BG // * PCBoard : @X## where the first number/char is BG color, and second is FG
// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix // * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix
// * WWIV : ^# // * WWIV : ^#
// * CNET Y-Style : 0x19## where ## is a specific set of codes -- this is the older format
// * CNET Q-style : 0x11##} where ## is a specific set of codes -- this is the newer format
// //
// TODO: Add Synchronet and Celerity format support // TODO: Add Synchronet and Celerity format support
// //
@ -180,7 +143,7 @@ function renegadeToAnsi(s, client) {
// * http://wiki.synchro.net/custom:colors // * http://wiki.synchro.net/custom:colors
// //
function controlCodesToAnsi(s, client) { function controlCodesToAnsi(s, client) {
const RE = /(\|([A-Z0-9]{2})|\|)|(\@X([0-9A-F]{2}))|(\@([0-9A-F]{2})\@)|(\x03[0-9]|\x03)/g; // eslint-disable-line no-control-regex const RE = /(\|([A-Z0-9]{2})|\|)|(@X([0-9A-F]{2}))|(@([0-9A-F]{2})@)|(\x03[0-9]|\x03)|(\x19(c[0-9a-f]|z[0-7]|n1|f1)|\x19)|(\x11(c[0-9a-f]|z[0-7]|n1|f1)}|\x11)/g; // eslint-disable-line no-control-regex
let m; let m;
let result = ''; let result = '';
@ -192,7 +155,7 @@ function controlCodesToAnsi(s, client) {
while((m = RE.exec(s))) { while((m = RE.exec(s))) {
switch(m[0].charAt(0)) { switch(m[0].charAt(0)) {
case '|' : case '|' :
// Renegade or ENiGMA MCI // Renegade |##
v = parseInt(m[2], 10); v = parseInt(m[2], 10);
if(isNaN(v)) { if(isNaN(v)) {
@ -216,26 +179,6 @@ function controlCodesToAnsi(s, client) {
v = m[4]; 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' ],
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'];
bg = { bg = {
0 : [ 'blackBG' ], 0 : [ 'blackBG' ],
1 : [ 'blueBG' ], 1 : [ 'blueBG' ],
@ -254,19 +197,40 @@ function controlCodesToAnsi(s, client) {
D : [ 'bold', 'magentaBG' ], D : [ 'bold', 'magentaBG' ],
E : [ 'bold', 'yellowBG' ], E : [ 'bold', 'yellowBG' ],
F : [ 'bold', 'whiteBG' ], F : [ 'bold', 'whiteBG' ],
}[v.charAt(1)] || [ 'normal' ]; }[v.charAt(0)] || [ 'normal' ];
v = ansi.sgr(fg.concat(bg)); fg = {
0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ],
8 : [ 'blink', 'black' ],
9 : [ 'blink', 'blue' ],
A : [ 'blink', 'green' ],
B : [ 'blink', 'cyan' ],
C : [ 'blink', 'red' ],
D : [ 'blink', 'magenta' ],
E : [ 'blink', 'yellow' ],
F : [ 'blink', 'white' ],
}[v.charAt(1)] || ['normal'];
v = ANSI.sgr(fg.concat(bg));
result += s.substr(lastIndex, m.index - lastIndex) + v; result += s.substr(lastIndex, m.index - lastIndex) + v;
break; break;
case '\x03' : case '\x03' :
// WWIV
v = parseInt(m[8], 10); v = parseInt(m[8], 10);
if(isNaN(v)) { if(isNaN(v)) {
v += m[0]; v += m[0];
} else { } else {
v = ansi.sgr({ v = ANSI.sgr({
0 : [ 'reset', 'black' ], 0 : [ 'reset', 'black' ],
1 : [ 'bold', 'cyan' ], 1 : [ 'bold', 'cyan' ],
2 : [ 'bold', 'yellow' ], 2 : [ 'bold', 'yellow' ],
@ -281,7 +245,24 @@ function controlCodesToAnsi(s, client) {
} }
result += s.substr(lastIndex, m.index - lastIndex) + v; result += s.substr(lastIndex, m.index - lastIndex) + v;
break;
case '\x19' :
case '\0x11' :
// CNET "Y-Style" & "Q-Style"
v = m[9] || m[11];
if(v) {
if('n1' === v) {
v = '\n';
} else if('f1' === v) {
v = ANSI.clearScreen();
} else {
v = ansiSgrFromCnetStyleColorCode(v);
}
} else {
v = m[0];
}
result += s.substr(lastIndex, m.index - lastIndex) + v;
break; break;
} }

View File

@ -2,12 +2,16 @@
'use strict'; 'use strict';
// enigma-bbs // enigma-bbs
const MenuModule = require('../core/menu_module.js').MenuModule; const { MenuModule } = require('../core/menu_module.js');
const resetScreen = require('../core/ansi_term.js').resetScreen; const { resetScreen } = require('../core/ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash');
const RLogin = require('rlogin'); const RLogin = require('rlogin');
exports.moduleInfo = { exports.moduleInfo = {
@ -32,29 +36,40 @@ exports.getModule = class CombatNetModule extends MenuModule {
async.series( async.series(
[ [
function validateConfig(callback) { function validateConfig(callback) {
if(!_.isString(self.config.password)) { return self.validateConfigFields(
return callback(new Error('Config requires "password"!')); {
} host : 'string',
if(!_.isString(self.config.bbsTag)) { password : 'string',
return callback(new Error('Config requires "bbsTag"!')); bbsTag : 'string',
} rloginPort : 'number',
return callback(null); },
callback
);
}, },
function establishRloginConnection(callback) { function establishRloginConnection(callback) {
self.client.term.write(resetScreen()); self.client.term.write(resetScreen());
self.client.term.write('Connecting to CombatNet, please wait...\n'); self.client.term.write('Connecting to CombatNet, please wait...\n');
let doorTracking;
const restorePipeToNormal = function() { const restorePipeToNormal = function() {
if(self.client.term.output) {
self.client.term.output.removeListener('data', sendToRloginBuffer); self.client.term.output.removeListener('data', sendToRloginBuffer);
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
}
}; };
const rlogin = new RLogin( const rlogin = new RLogin(
{ 'clientUsername' : self.config.password, {
'serverUsername' : `${self.config.bbsTag}${self.client.user.username}`, clientUsername : self.config.password,
'host' : self.config.host, serverUsername : `${self.config.bbsTag}${self.client.user.username}`,
'port' : self.config.rloginPort, host : self.config.host,
'terminalType' : self.client.term.termClient, port : self.config.rloginPort,
'terminalSpeed' : 57600 terminalType : self.client.term.termClient,
terminalSpeed : 57600
} }
); );
@ -62,21 +77,21 @@ exports.getModule = class CombatNetModule extends MenuModule {
rlogin.on('error', err => { rlogin.on('error', err => {
self.client.log.info(`CombatNet rlogin client error: ${err.message}`); self.client.log.info(`CombatNet rlogin client error: ${err.message}`);
restorePipeToNormal(); restorePipeToNormal();
callback(err); return callback(err);
}); });
// If we've been disconnected ... // If we've been disconnected ...
rlogin.on('disconnect', () => { rlogin.on('disconnect', () => {
self.client.log.info(`Disconnected from CombatNet`); self.client.log.info('Disconnected from CombatNet');
restorePipeToNormal(); restorePipeToNormal();
callback(null); return callback(null);
}); });
function sendToRloginBuffer(buffer) { function sendToRloginBuffer(buffer) {
rlogin.send(buffer); rlogin.send(buffer);
}; }
rlogin.on("connect", rlogin.on('connect',
/* The 'connect' event handler will be supplied with one argument, /* The 'connect' event handler will be supplied with one argument,
a boolean indicating whether or not the connection was established. */ a boolean indicating whether or not the connection was established. */
@ -85,14 +100,15 @@ exports.getModule = class CombatNetModule extends MenuModule {
self.client.log.info('Connected to CombatNet'); self.client.log.info('Connected to CombatNet');
self.client.term.output.on('data', sendToRloginBuffer); self.client.term.output.on('data', sendToRloginBuffer);
doorTracking = trackDoorRunBegin(self.client);
} else { } else {
return callback(new Error('Failed to establish establish CombatNet connection')); return callback(Errors.General('Failed to establish establish CombatNet connection'));
} }
} }
); );
// If data (a Buffer) has been received from the server ... // If data (a Buffer) has been received from the server ...
rlogin.on("data", (data) => { rlogin.on('data', (data) => {
self.client.term.rawWrite(data); self.client.term.rawWrite(data);
}); });

View File

@ -2,17 +2,19 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Errors = require('./enig_error.js').Errors;
// deps // deps
const fs = require('graceful-fs');
const paths = require('path'); const paths = require('path');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const hjson = require('hjson');
const assert = require('assert'); const assert = require('assert');
exports.init = init; exports.init = init;
exports.getDefaultPath = getDefaultPath; exports.getDefaultPath = getDefaultPath;
exports.getDefaultConfig = getDefaultConfig;
let currentConfiguration = {};
function hasMessageConferenceAndArea(config) { function hasMessageConferenceAndArea(config) {
assert(_.isObject(config.messageConferences)); // we create one ourself! assert(_.isObject(config.messageConferences)); // we create one ourself!
@ -40,43 +42,53 @@ function hasMessageConferenceAndArea(config) {
return result; return result;
} }
function init(configPath, options, cb) { const ArrayReplaceKeyPaths = [
if(!cb && _.isFunction(options)) { 'loginServers.ssh.algorithms.kex',
cb = options; 'loginServers.ssh.algorithms.cipher',
options = {}; 'loginServers.ssh.algorithms.hmac',
'loginServers.ssh.algorithms.compress',
];
const ArrayReplaceKeys = [
'args',
'sendArgs', 'recvArgs', 'recvArgsNonBatch',
];
function mergeValidateAndFinalize(config, cb) {
const defaultConfig = getDefaultConfig();
const arrayReplaceKeyPathsMutable = _.clone(ArrayReplaceKeyPaths);
const shouldReplaceArray = (arr, key) => {
if(ArrayReplaceKeys.includes(key)) {
return true;
} }
for(let i = 0; i < arrayReplaceKeyPathsMutable.length; ++i) {
const o = _.get(defaultConfig, arrayReplaceKeyPathsMutable[i]);
if(_.isEqual(o, arr)) {
arrayReplaceKeyPathsMutable.splice(i, 1);
return true;
}
}
return false;
};
async.waterfall( async.waterfall(
[ [
function loadUserConfig(callback) { function mergeWithDefaultConfig(callback) {
if(!_.isString(configPath)) {
return callback(null, { } );
}
fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => {
if(err) {
return callback(err);
}
let configJson;
try {
configJson = hjson.parse(configData, options);
} catch(e) {
return callback(e);
}
return callback(null, configJson);
});
},
function mergeWithDefaultConfig(configJson, callback) {
const mergedConfig = _.mergeWith( const mergedConfig = _.mergeWith(
getDefaultConfig(), defaultConfig,
configJson, (conf1, conf2) => { config,
// Arrays should always concat (defConfig, userConfig, key) => {
if(_.isArray(conf1)) { if(Array.isArray(defConfig) && Array.isArray(userConfig)) {
// :TODO: look for collisions & override dupes //
return conf1.concat(conf2); // Arrays are special: Some we merge, while others
// we simply replace.
//
if(shouldReplaceArray(defConfig, key)) {
return userConfig;
} else {
return _.uniq(defConfig.concat(userConfig));
}
} }
} }
); );
@ -89,26 +101,65 @@ function init(configPath, options, cb) {
// //
// :TODO: Logic is broken here: // :TODO: Logic is broken here:
if(hasMessageConferenceAndArea(mergedConfig)) { if(hasMessageConferenceAndArea(mergedConfig)) {
var msgAreasErr = new Error('Please create at least one message conference and area!'); return callback(Errors.MissingConfig('Please create at least one message conference and area!'));
msgAreasErr.code = 'EBADCONFIG';
return callback(msgAreasErr);
} else {
return callback(null, mergedConfig);
} }
return callback(null, mergedConfig);
},
function setIt(mergedConfig, callback) {
// :TODO: .config property is to be deprecated once conversions are done
exports.config = currentConfiguration = mergedConfig;
exports.get = () => currentConfiguration;
return callback(null);
} }
], ],
function complete(err, mergedConfig) { err => {
exports.config = mergedConfig; if(cb) {
exports.config.get = function(path) {
return _.get(exports.config, path);
};
return cb(err); return cb(err);
} }
}
); );
} }
function init(configPath, options, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
}
const changed = ( { fileName, fileRoot } ) => {
const reCachedPath = paths.join(fileRoot, fileName);
ConfigCache.getConfig(reCachedPath, (err, config) => {
if(!err) {
mergeValidateAndFinalize(config, err => {
if(!err) {
const Events = require('./events.js');
Events.emit(Events.getSystemEvents().ConfigChanged);
}
});
} else {
console.stdout(`Configuration ${reCachedPath} is invalid: ${err.message}`); // eslint-disable-line no-console
}
});
};
const ConfigCache = require('./config_cache.js');
const getConfigOptions = {
filePath : configPath,
noWatch : options.noWatch,
};
if(!options.noWatch) {
getConfigOptions.callback = changed;
}
ConfigCache.getConfigWithOptions(getConfigOptions, (err, config) => {
if(err) {
return cb(err);
}
return mergeValidateAndFinalize(config, cb);
});
}
function getDefaultPath() { function getDefaultPath() {
// e.g. /enigma-bbs-install-path/config/ // e.g. /enigma-bbs-install-path/config/
return './config/'; return './config/';
@ -119,25 +170,32 @@ function getDefaultConfig() {
general : { general : {
boardName : 'Another Fine ENiGMA½ BBS', boardName : 'Another Fine ENiGMA½ BBS',
// :TODO: closedSystem prob belongs under users{}?
closedSystem : false, // is the system closed to new users? closedSystem : false, // is the system closed to new users?
loginAttempts : 3, menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
menuFile : 'menu.hjson', // Override to use something else, e.g. demo.hjson. Can be a full path (defaults to ./config) achievementFile : 'achievements.hjson',
promptFile : 'prompt.hjson', // Override to use soemthing else, e.g. myprompt.hjson. Can be a full path (defaults to ./config)
}, },
// :TODO: see notes below about 'theme' section - move this!
preLoginTheme : 'luciano_blocktronics',
users : { users : {
usernameMin : 2, usernameMin : 2,
usernameMax : 16, // Note that FidoNet wants 36 max usernameMax : 16, // Note that FidoNet wants 36 max
usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+ ]+$', usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+ .]+$',
passwordMin : 6, passwordMin : 6,
passwordMax : 128, passwordMax : 128,
badPassFile : paths.join(__dirname, '../misc/10_million_password_list_top_10000.txt'), // https://github.com/danielmiessler/SecLists
//
// The bad password list is a text file containing a password per line.
// Entries in this list are not allowed to be used on the system as they
// are known to be too common.
//
// A great resource can be found at https://github.com/danielmiessler/SecLists
//
// Current list source: https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/probable-v2-top12000.txt
//
badPassFile : paths.join(__dirname, '../misc/bad_passwords.txt'),
realNameMax : 32, realNameMax : 32,
locationMax : 32, locationMax : 32,
@ -156,29 +214,33 @@ function getDefaultConfig() {
'sysop', 'admin', 'administrator', 'root', 'all', 'sysop', 'admin', 'administrator', 'root', 'all',
'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix' 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix'
], ],
preAuthIdleLogoutSeconds : 60 * 3, // 3m
idleLogoutSeconds : 60 * 6, // 6m
failedLogin : {
disconnect : 3, // 0=disabled
lockAccount : 9, // 0=disabled; Mark user status as "locked" if >= N
autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes.
},
unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts
}, },
// :TODO: better name for "defaults"... which is redundant here! theme : {
/* default : 'luciano_blocktronics',
Concept preLogin : 'luciano_blocktronics',
"theme" : {
"default" : "defaultThemeName", // or "*" passwordChar : '*',
"preLogin" : "*",
"passwordChar" : "*",
...
}
*/
defaults : {
theme : 'luciano_blocktronics',
passwordChar : '*', // TODO: move to user ?
dateFormat : { dateFormat : {
short : 'MM/DD/YYYY', short : 'MM/DD/YYYY',
long : 'ddd, MMMM Do, YYYY',
}, },
timeFormat : { timeFormat : {
short : 'h:mm a', short : 'h:mm a',
}, },
dateTimeFormat : { dateTimeFormat : {
short : 'MM/DD/YYYY h:mm a', short : 'MM/DD/YYYY h:mm a',
long : 'ddd, MMMM Do, YYYY, h:mm a',
} }
}, },
@ -200,7 +262,7 @@ function getDefaultConfig() {
logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such
db : paths.join(__dirname, './../db/'), db : paths.join(__dirname, './../db/'),
modsDb : paths.join(__dirname, './../db/mods/'), modsDb : paths.join(__dirname, './../db/mods/'),
dropFiles : paths.join(__dirname, './../dropfiles/'), // + "/node<x>/ dropFiles : paths.join(__dirname, './../drop/'), // + "/node<x>/
misc : paths.join(__dirname, './../misc/'), misc : paths.join(__dirname, './../misc/'),
}, },
@ -213,37 +275,97 @@ function getDefaultConfig() {
ssh : { ssh : {
port : 8889, port : 8889,
enabled : false, // default to false as PK/pass in config.hjson are required enabled : false, // default to false as PK/pass in config.hjson are required
// //
// Private key in PEM format // Private Key (PK) in PEM format
// //
// Generating your PK: // Generating your PK:
// Choose a cipher (3DES, AES128, or AES256) and a bit strength (2048 or 4096) // 1 - Choose a cipher (3DES, AES128, or AES256)
// Ciphers: // 3des : older, most compatible, least secure
// 3des: older, most compatible, least secure // aes128 : newer, widely compatible, fairly secure
// aes128: newer, widely compatible, fairly secure // aes256 : newest, least compatible, best security
// aes256: newest, least compatible, best security //
// Bit strength: // 2 - Choose a bit strength (2048 or 4096)
// 2048: most compatible, decent strength // 2048 : most compatible, decent strength
// 4096: stronger, but some software is completely incompatible // 4096 : stronger, but some software is completely incompatible
//
// Sample command: // Sample command:
// openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048 // openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048
// //
// Then, set servers.ssh.privateKeyPass to the password you use above // Then, set servers.ssh.privateKeyPass to the password you use above
// in your config.hjson // in your config.hjson
// //
//
privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'), privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'),
firstMenu : 'sshConnected', firstMenu : 'sshConnected',
firstMenuNewUser : 'sshConnectedNewUser', firstMenuNewUser : 'sshConnectedNewUser',
//
// SSH details that can affect security. Stronger ciphers are better for example,
// but terminals such as SyncTERM require KEX diffie-hellman-group14-sha1,
// cipher 3des-cbc, etc.
//
// See https://github.com/mscdex/ssh2-streams for the full list of supported
// algorithms.
//
algorithms : {
kex : [
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group-exchange-sha1',
'diffie-hellman-group1-sha1',
],
cipher : [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm',
'aes128-gcm@openssh.com',
'aes256-gcm',
'aes256-gcm@openssh.com',
'aes256-cbc',
'aes192-cbc',
'aes128-cbc',
'blowfish-cbc',
'3des-cbc',
'arcfour256',
'arcfour128',
'cast128-cbc',
'arcfour',
],
hmac : [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5',
'hmac-sha2-256-96',
'hmac-sha2-512-96',
'hmac-ripemd160',
'hmac-sha1-96',
'hmac-md5-96',
],
// note that we disable compression by default due to issues with many clients. YMMV.
compress : [ 'none' ]
},
}, },
webSocket : { webSocket : {
port : 8810, // ws:// ws : {
// non-secure ws://
enabled : false, enabled : false,
securePort : 8811, // wss:// - must provide certPem and keyPem port : 8810,
},
wss : {
// secure ws://
// must provide valid certPem and keyPem
enabled : false,
port : 8811,
certPem : paths.join(__dirname, './../config/https_cert.pem'), certPem : paths.join(__dirname, './../config/https_cert.pem'),
keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), keyPem : paths.join(__dirname, './../config/https_cert_key.pem'),
}, },
}, },
},
contentServers : { contentServers : {
web : { web : {
@ -280,6 +402,46 @@ function getDefaultConfig() {
certPem : paths.join(__dirname, './../config/https_cert.pem'), certPem : paths.join(__dirname, './../config/https_cert.pem'),
keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), keyPem : paths.join(__dirname, './../config/https_cert_key.pem'),
} }
},
gopher : {
enabled : false,
port : 8070,
publicHostname : 'another-fine-enigma-bbs.org',
publicPort : 8070, // adjust if behind NAT/etc.
bannerFile : 'gopher_banner.asc',
//
// Set messageConferences{} to maps of confTag -> [ areaTag1, areaTag2, ... ]
// to export message confs/areas
//
},
nntp : {
// internal caching of groups, message lists, etc.
cache : {
maxItems : 200,
maxAge : 1000 * 30, // 30s
},
//
// Set publicMessageConferences{} to a map of confTag -> [ areaTag1, areaTag2, ... ]
// in order to export *public* conf/areas that are available to anonymous
// NNTP users. Other conf/areas: Standard ACS rules apply.
//
publicMessageConferences: {},
nntp : {
enabled : false,
port : 8119,
},
nntps : {
enabled : false,
port : 8563,
certPem : paths.join(__dirname, './../config/nntps_cert.pem'),
keyPem : paths.join(__dirname, './../config/nntps_key.pem'),
}
} }
}, },
@ -296,7 +458,17 @@ function getDefaultConfig() {
'--filemodifydate', '--fileaccessdate', '--fileinodechangedate', '--createdate', '--modifydate', '--filemodifydate', '--fileaccessdate', '--fileinodechangedate', '--createdate', '--modifydate',
'--metadatadate', '--xmptoolkit' '--metadatadate', '--xmptoolkit'
] ]
} },
XDMS2Desc : {
// http://manpages.ubuntu.com/manpages/trusty/man1/xdms.1.html
cmd : 'xdms',
args : [ 'd', '{filePath}' ]
},
XDMS2LongDesc : {
// http://manpages.ubuntu.com/manpages/trusty/man1/xdms.1.html
cmd : 'xdms',
args : [ 'f', '{filePath}' ]
},
}, },
fileTypes : { fileTypes : {
@ -391,7 +563,7 @@ function getDefaultConfig() {
}, },
'application/x-rar-compressed' : { 'application/x-rar-compressed' : {
desc : 'RAR Archive', desc : 'RAR Archive',
sig : '526172211a0700', sig : '526172211a07',
offset : 0, offset : 0,
archiveHandler : 'Rar', archiveHandler : 'Rar',
}, },
@ -414,18 +586,37 @@ function getDefaultConfig() {
offset : 2, offset : 2,
archiveHandler : 'Lha', archiveHandler : 'Lha',
}, },
'application/x-lzx' : {
desc : 'LZX Archive',
sig : '4c5a5800',
offset : 0,
archiveHandler : 'Lzx',
},
'application/x-7z-compressed' : { 'application/x-7z-compressed' : {
desc : '7-Zip Archive', desc : '7-Zip Archive',
sig : '377abcaf271c', sig : '377abcaf271c',
offset : 0, offset : 0,
archiveHandler : '7Zip', archiveHandler : '7Zip',
} },
// :TODO: update archives::formats to fall here //
// * archive handler -> archiveHandler (consider archive if archiveHandler present) // Generics that need further mapping
// * sig, offset, ... //
// * mime-db -> exts lookup 'application/octet-stream' : [
// * {
desc : 'Amiga DISKMASHER',
sig : '444d5321', // DMS!
ext : '.dms',
shortDescUtil : 'XDMS2Desc',
longDescUtil : 'XDMS2LongDesc',
},
{
desc : 'SIO2PC Atari Disk Image',
sig : '9602', // 16bit sum of "NICKATARI"
ext : '.atr',
archiveHandler : 'Atr',
}
]
}, },
archives : { archives : {
@ -459,7 +650,7 @@ function getDefaultConfig() {
// //
decompress : { decompress : {
cmd : 'lha', cmd : 'lha',
args : [ '-ew={extractPath}', '{archivePath}' ], args : [ '-efw={extractPath}', '{archivePath}' ],
}, },
list : { list : {
cmd : 'lha', cmd : 'lha',
@ -468,7 +659,26 @@ function getDefaultConfig() {
}, },
extract : { extract : {
cmd : 'lha', cmd : 'lha',
args : [ '-ew={extractPath}', '{archivePath}', '{fileList}' ] args : [ '-efw={extractPath}', '{archivePath}', '{fileList}' ]
}
},
Lzx : {
//
// 'unlzx' command can be obtained from:
// * Debian based: https://launchpad.net/~rzr/+archive/ubuntu/ppa/+build/2486127 (amd64/x86_64)
// * RedHat: https://fedora.pkgs.org/28/rpm-sphere/unlzx-1.1-4.1.x86_64.rpm.html
// * Source: http://xavprods.free.fr/lzx/
//
decompress : {
cmd : 'unlzx',
// unzlx doesn't have a output dir option, but we'll cwd to the temp output dir first
args : [ '-x', '{archivePath}' ],
},
list : {
cmd : 'unlzx',
args : [ '-v', '{archivePath}' ],
entryMatch : '^\\s+([0-9]+)\\s+[^\\s]+\\s+[0-9]{2}:[0-9]{2}:[0-9]{2}\\s+[0-9]{1,2}-[a-z]{3}-[0-9]{4}\\s+[a-z\\-]+\\s+\\"([^"]+)\\"$',
} }
}, },
@ -504,7 +714,7 @@ function getDefaultConfig() {
list : { list : {
cmd : 'unrar', cmd : 'unrar',
args : [ 'l', '{archivePath}' ], args : [ 'l', '{archivePath}' ],
entryMatch : '^\\s+[\\.A-Z]+\\s+([\\d]+)\\s{2}[0-9]{2}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s{2}([^\\r\\n]+)$', entryMatch : '^\\s+[\\.A-Z]+\\s+([\\d]+)\\s{2}[0-9]{2,4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s{2}([^\\r\\n]+)$',
}, },
extract : { extract : {
cmd : 'unrar', cmd : 'unrar',
@ -526,6 +736,23 @@ function getDefaultConfig() {
cmd : 'tar', cmd : 'tar',
args : [ '-xvf', '{archivePath}', '-C', '{extractPath}', '{fileList}' ], args : [ '-xvf', '{archivePath}', '-C', '{extractPath}', '{fileList}' ],
} }
},
Atr : {
decompress : {
cmd : 'atr',
args : [ '{archivePath}', 'x', '-a', '-o', '{extractPath}' ]
},
list : {
cmd : 'atr',
args : [ '{archivePath}', 'ls', '-la1' ],
entryMatch : '^[rwxs-]{5}\\s+([0-9]+)\\s\\([0-9\\s]+\\)\\s([^\\r\\n\\s]*)(?:[^\\r\\n]+)?$',
},
extract : {
cmd : 'atr',
// note: -l converts Atari 0x9b line feeds to 0x0a; not ideal if we're dealing with a binary of course.
args : [ '{archivePath}', 'x', '-a', '-l', '-o', '{extractPath}', '{fileList}' ]
}
} }
}, },
}, },
@ -540,6 +767,7 @@ function getDefaultConfig() {
sort : 1, sort : 1,
external : { external : {
// :TODO: Look into shipping sexyz binaries or at least hosting them somewhere for common systems // :TODO: Look into shipping sexyz binaries or at least hosting them somewhere for common systems
// Linux x86_64 binary: https://l33t.codes/outgoing/sexyz
sendCmd : 'sexyz', sendCmd : 'sexyz',
sendArgs : [ '-telnet', '-8', 'sz', '@{fileListPath}' ], sendArgs : [ '-telnet', '-8', 'sz', '@{fileListPath}' ],
recvCmd : 'sexyz', recvCmd : 'sexyz',
@ -662,12 +890,24 @@ function getDefaultConfig() {
// FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ // FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ
// Some groups include a FILE_ID.ANS. We try to use that over FILE_ID.DIZ if available. // Some groups include a FILE_ID.ANS. We try to use that over FILE_ID.DIZ if available.
desc : [ desc : [
'^[^/\]*FILE_ID\.ANS$', '^[^/\]*FILE_ID\.DIZ$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$' '^.*FILE_ID\.ANS$', '^.*FILE_ID\.DIZ$', // eslint-disable-line no-useless-escape
'^.*DESC\.SDI$', // eslint-disable-line no-useless-escape
'^.*DESCRIPT\.ION$', // eslint-disable-line no-useless-escape
'^.*FILE\.DES$', // eslint-disable-line no-useless-escape
'^.*FILE\.SDI$', // eslint-disable-line no-useless-escape
'^.*DISK\.ID$' // eslint-disable-line no-useless-escape
], ],
// common README filename - https://en.wikipedia.org/wiki/README // common README filename - https://en.wikipedia.org/wiki/README
descLong : [ descLong : [
'^[^/\]*\.NFO$', '^[^/\]*README\.1ST$', '^[^/\]*README\.NOW$', '^[^/\]*README\.TXT$', '^[^/\]*READ\.ME$', '^[^/\]*README$', '^[^/\]*README\.md$' '^[^/\]*\.NFO$', // eslint-disable-line no-useless-escape
'^.*README\.1ST$', // eslint-disable-line no-useless-escape
'^.*README\.NOW$', // eslint-disable-line no-useless-escape
'^.*README\.TXT$', // eslint-disable-line no-useless-escape
'^.*READ\.ME$', // eslint-disable-line no-useless-escape
'^.*README$', // eslint-disable-line no-useless-escape
'^.*README\.md$', // eslint-disable-line no-useless-escape
'^RELEASE-INFO.ASC$' // eslint-disable-line no-useless-escape
], ],
}, },
@ -723,19 +963,28 @@ function getDefaultConfig() {
eventScheduler : { eventScheduler : {
events : { events : {
dailyMaintenance : {
schedule : 'at 11:59pm',
action : '@method:core/misc_scheduled_events.js:dailyMaintenanceScheduledEvent',
},
trimMessageAreas : { trimMessageAreas : {
// may optionally use [or ]@watch:/path/to/file // may optionally use [or ]@watch:/path/to/file
schedule : 'every 24 hours', schedule : 'every 24 hours',
// action: // action:
// - @method:path/to/module.js:theMethodName // - @method:path/to/module.js:theMethodName
// (path is relative to engima base dir) // (path is relative to ENiGMA base dir)
// //
// - @execute:/path/to/something/executable.sh // - @execute:/path/to/something/executable.sh
// //
action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', action : '@method:core/message_area.js:trimMessageAreasScheduledEvent',
}, },
nntpMaintenance : {
schedule : 'every 12 hours', // should generally be < trimMessageAreas interval
action : '@method:core/servers/content/nntp.js:performMaintenanceTask',
},
updateFileAreaStats : { updateFileAreaStats : {
schedule : 'every 1 hours', schedule : 'every 1 hours',
action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent', action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent',
@ -745,18 +994,22 @@ function getDefaultConfig() {
schedule : 'every 24 hours', schedule : 'every 24 hours',
action : '@method:core/web_password_reset.js:performMaintenanceTask', action : '@method:core/web_password_reset.js:performMaintenanceTask',
args : [ '24 hours' ] // items older than this will be removed args : [ '24 hours' ] // items older than this will be removed
}
}
}, },
misc : { //
preAuthIdleLogoutSeconds : 60 * 3, // 2m // Enable the following entry in your config.hjson to periodically create/update
idleLogoutSeconds : 60 * 6, // 6m // DESCRIPT.ION files for your file base
//
/*
updateDescriptIonFiles : {
schedule : 'on the last day of the week',
action : '@method:core/file_base_list_export.js:updateFileBaseDescFilesScheduledEvent',
}
*/
}
}, },
logging : { logging : {
level : 'debug',
rotatingFile : { // set to 'disabled' or false to disable rotatingFile : { // set to 'disabled' or false to disable
type : 'rotating-file', type : 'rotating-file',
fileName : 'enigma-bbs.log', fileName : 'enigma-bbs.log',
@ -770,6 +1023,12 @@ function getDefaultConfig() {
debug : { debug : {
assertsEnabled : false, assertsEnabled : false,
},
statLog : {
systemEvents : {
loginHistoryMax: -1, // set to -1 for forever
} }
},
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,9 @@ const dbs = {};
exports.getTransactionDatabase = getTransactionDatabase; exports.getTransactionDatabase = getTransactionDatabase;
exports.getModDatabasePath = getModDatabasePath; exports.getModDatabasePath = getModDatabasePath;
exports.loadDatabaseForMod = loadDatabaseForMod;
exports.getISOTimestampString = getISOTimestampString; exports.getISOTimestampString = getISOTimestampString;
exports.sanitizeString = sanitizeString;
exports.initializeDatabases = initializeDatabases; exports.initializeDatabases = initializeDatabases;
exports.dbs = dbs; exports.dbs = dbs;
@ -37,7 +39,7 @@ function getModDatabasePath(moduleInfo, suffix) {
// We expect that moduleInfo defines packageName which will be the base of the modules // We expect that moduleInfo defines packageName which will be the base of the modules
// filename. An optional suffix may be supplied as well. // filename. An optional suffix may be supplied as well.
// //
const HOST_RE = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; const HOST_RE = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/;
assert(_.isObject(moduleInfo)); assert(_.isObject(moduleInfo));
assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!'); assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!');
@ -54,11 +56,47 @@ function getModDatabasePath(moduleInfo, suffix) {
return paths.join(conf.config.paths.modsDb, `${full}.sqlite3`); return paths.join(conf.config.paths.modsDb, `${full}.sqlite3`);
} }
function loadDatabaseForMod(modInfo, cb) {
const db = getTransactionDatabase(new sqlite3.Database(
getModDatabasePath(modInfo),
err => {
return cb(err, db);
}
));
}
function getISOTimestampString(ts) { function getISOTimestampString(ts) {
ts = ts || moment(); ts = ts || moment();
if(!moment.isMoment(ts)) {
if(_.isString(ts)) {
ts = ts.replace(/\//g, '-');
}
ts = moment(ts);
}
return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ');
} }
function sanitizeString(s) {
return s.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, c => { // eslint-disable-line no-control-regex
switch (c) {
case '\0' : return '\\0';
case '\x08' : return '\\b';
case '\x09' : return '\\t';
case '\x1a' : return '\\z';
case '\n' : return '\\n';
case '\r' : return '\\r';
case '"' :
case '\'' :
return `${c}${c}`;
case '\\' :
case '%' :
return `\\${c}`;
}
});
}
function initializeDatabases(cb) { function initializeDatabases(cb) {
async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => {
dbs[dbName] = sqlite3Trans.wrap(new sqlite3.Database(getDatabasePath(dbName), err => { dbs[dbName] = sqlite3Trans.wrap(new sqlite3.Database(getDatabasePath(dbName), err => {
@ -109,10 +147,11 @@ const DB_INIT_TABLE = {
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
timestamp DATETIME NOT NULL, timestamp DATETIME NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
session_id VARCHAR NOT NULL,
log_name VARCHAR NOT NULL, log_name VARCHAR NOT NULL,
log_value VARCHAR NOT NULL, log_value VARCHAR NOT NULL,
UNIQUE(timestamp, user_id, log_name) UNIQUE(timestamp, user_id, session_id, log_name)
);` );`
); );
@ -151,10 +190,16 @@ const DB_INIT_TABLE = {
); );
dbs.user.run( dbs.user.run(
`CREATE TABLE IF NOT EXISTS user_login_history ( `CREATE TABLE IF NOT EXISTS user_achievement (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
user_name VARCHAR NOT NULL, achievement_tag VARCHAR NOT NULL,
timestamp DATETIME NOT NULL timestamp DATETIME NOT NULL,
match VARCHAR NOT NULL,
title VARCHAR NOT NULL,
text VARCHAR NOT NULL,
points INTEGER NOT NULL,
UNIQUE(user_id, achievement_tag, match),
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);` );`
); );

View File

@ -1,6 +1,8 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const { Errors } = require('./enig_error.js');
// deps // deps
const fs = require('graceful-fs'); const fs = require('graceful-fs');
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
@ -64,7 +66,10 @@ module.exports = class DescriptIonFile {
return nextLine(null); return nextLine(null);
}, },
() => { () => {
return cb(null, descIonFile); return cb(
descIonFile.entries.size > 0 ? null : Errors.Invalid('Invalid or unrecognized DESCRIPT.ION format'),
descIonFile
);
}); });
}); });
} }

View File

@ -1,154 +1,137 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const { Errors } = require('./enig_error.js');
const events = require('events'); // deps
const _ = require('lodash'); const pty = require('node-pty');
const pty = require('ptyw.js');
const decode = require('iconv-lite').decode; const decode = require('iconv-lite').decode;
const createServer = require('net').createServer; const createServer = require('net').createServer;
const paths = require('path');
exports.Door = Door; module.exports = class Door {
constructor(client) {
function Door(client, exeInfo) {
events.EventEmitter.call(this);
const self = this;
this.client = client; this.client = client;
this.exeInfo = exeInfo; this.restored = false;
this.exeInfo.encoding = this.exeInfo.encoding || 'cp437';
this.exeInfo.encoding = this.exeInfo.encoding.toLowerCase();
let restored = false;
//
// Members of exeInfo:
// cmd
// args[]
// env{}
// cwd
// io
// encoding
// dropFile
// node
// inhSocket
//
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.restoreIo = function(piped) { prepare(ioType, cb) {
if(!restored && self.client.term.output) { this.io = ioType;
self.client.term.output.unpipe(piped);
self.client.term.output.resume(); // we currently only have to do any real setup for 'socket'
restored = true; if('socket' !== ioType) {
return cb(null);
} }
};
this.prepareSocketIoServer = function(cb) {
if('socket' === self.exeInfo.io) {
const sockServer = createServer(conn => {
sockServer.getConnections( (err, count) => {
// 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);
this.sockServer = createServer(conn => {
conn.once('end', () => { conn.once('end', () => {
return self.restoreIo(conn); return this.restoreIo(conn);
}); });
conn.once('error', err => { conn.once('error', err => {
self.client.log.info( { error : err.toString() }, 'Door socket server connection'); this.client.log.info( { error : err.message }, 'Door socket server connection');
return self.restoreIo(conn); return this.restoreIo(conn);
}); });
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));
} }
}); });
}); });
sockServer.listen(0, () => { this.sockServer.listen(0, () => {
return cb(null, sockServer);
});
} else {
return cb(null); return cb(null);
}
};
this.doorExited = function() {
self.emit('finished');
};
}
require('util').inherits(Door, events.EventEmitter);
Door.prototype.run = function() {
const self = this;
this.prepareSocketIoServer( (err, sockServer) => {
if(err) {
this.client.log.warn( { error : err.toString() }, 'Failed executing door');
return self.doorExited();
}
// 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
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,
}); });
} }
const door = pty.spawn(self.exeInfo.cmd, args, { run(exeInfo, cb) {
cols : self.client.term.termWidth, this.encoding = (exeInfo.encoding || 'cp437').toLowerCase();
rows : self.client.term.termHeight,
// :TODO: cwd if('socket' === this.io && !this.sockServer) {
env : self.exeInfo.env, return cb(Errors.UnexpectedState('Socket server is not running'));
}
const cwd = exeInfo.cwd || paths.dirname(exeInfo.cmd);
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,
};
const args = exeInfo.args.map( arg => stringFormat(arg, formatObj) );
this.client.log.debug(
{ cmd : exeInfo.cmd, args, io : this.io },
'Executing door'
);
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);
}
if('stdio' === self.exeInfo.io) { if('stdio' === this.io) {
self.client.log.debug('Using stdio for door I/O'); this.client.log.debug('Using stdio for door I/O');
self.client.term.output.pipe(door); this.client.term.output.pipe(door);
door.on('data', self.doorDataHandler); door.on('data', this.doorDataHandler.bind(this));
door.once('close', () => { door.once('close', () => {
return self.restoreIo(door); return this.restoreIo(door);
}); });
} else if('socket' === self.exeInfo.io) { } else if('socket' === this.io) {
self.client.log.debug( { port : sockServer.address().port }, 'Using temporary socket server for door I/O'); this.client.log.debug(
{ srvPort : this.sockServer.address().port, srvSocket : this.sockServerSocket },
'Using temporary socket server for door I/O'
);
} }
door.once('exit', exitCode => { door.once('exit', exitCode => {
self.client.log.info( { exitCode : exitCode }, 'Door exited'); this.client.log.info( { exitCode : exitCode }, 'Door exited');
if(sockServer) { if(this.sockServer) {
sockServer.close(); this.sockServer.close();
} }
// we may not get a close // we may not get a close
if('stdio' === self.exeInfo.io) { if('stdio' === this.io) {
self.restoreIo(door); this.restoreIo(door);
} }
door.removeAllListeners(); door.removeAllListeners();
return self.doorExited(); return cb(null);
});
}); });
}
doorDataHandler(data) {
this.client.term.write(decode(data, this.encoding));
}
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

@ -2,12 +2,16 @@
'use strict'; 'use strict';
// enigma-bbs // enigma-bbs
const MenuModule = require('../core/menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const resetScreen = require('../core/ansi_term.js').resetScreen; const { resetScreen } = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash');
const SSHClient = require('ssh2').Client; const SSHClient = require('ssh2').Client;
exports.moduleInfo = { exports.moduleInfo = {
@ -34,16 +38,17 @@ exports.getModule = class DoorPartyModule extends MenuModule {
async.series( async.series(
[ [
function validateConfig(callback) { function validateConfig(callback) {
if(!_.isString(self.config.username)) { return self.validateConfigFields(
return callback(new Error('Config requires "username"!')); {
} host : 'string',
if(!_.isString(self.config.password)) { username : 'string',
return callback(new Error('Config requires "password"!')); password : 'string',
} bbsTag : 'string',
if(!_.isString(self.config.bbsTag)) { sshPort : 'number',
return callback(new Error('Config requires "bbsTag"!')); rloginPort : 'number',
} },
return callback(null); callback
);
}, },
function establishSecureConnection(callback) { function establishSecureConnection(callback) {
self.client.term.write(resetScreen()); self.client.term.write(resetScreen());
@ -53,10 +58,16 @@ exports.getModule = class DoorPartyModule extends MenuModule {
let pipeRestored = false; let pipeRestored = false;
let pipedStream; let pipedStream;
let doorTracking;
const restorePipe = function() { const restorePipe = function() {
if(pipedStream && !pipeRestored && !clientTerminated) { if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream); self.client.term.output.unpipe(pipedStream);
self.client.term.output.resume(); self.client.term.output.resume();
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
} }
}; };
@ -71,9 +82,11 @@ exports.getModule = class DoorPartyModule extends MenuModule {
// establish tunnel for rlogin // establish tunnel for rlogin
sshClient.forwardOut('127.0.0.1', self.config.sshPort, self.config.host, self.config.rloginPort, (err, stream) => { sshClient.forwardOut('127.0.0.1', self.config.sshPort, self.config.host, self.config.rloginPort, (err, stream) => {
if(err) { if(err) {
return callback(new Error('Failed to establish tunnel')); return callback(Errors.General('Failed to establish tunnel'));
} }
doorTracking = trackDoorRunBegin(self.client);
// //
// Send rlogin // Send rlogin
// DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g. // DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g.
@ -99,6 +112,7 @@ exports.getModule = class DoorPartyModule extends MenuModule {
sshClient.on('error', err => { sshClient.on('error', err => {
self.client.log.info(`DoorParty SSH client error: ${err.message}`); self.client.log.info(`DoorParty SSH client error: ${err.message}`);
trackDoorRunEnd(doorTracking);
}); });
sshClient.on('close', () => { sshClient.on('close', () => {
@ -121,7 +135,7 @@ exports.getModule = class DoorPartyModule extends MenuModule {
self.client.log.warn( { error : err.message }, 'DoorParty error'); self.client.log.warn( { error : err.message }, 'DoorParty error');
} }
// if the client is stil here, go to previous // if the client is still here, go to previous
if(!clientTerminated) { if(!clientTerminated) {
self.prevMenu(); 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

@ -2,14 +2,18 @@
'use strict'; 'use strict';
const FileEntry = require('./file_entry.js'); const FileEntry = require('./file_entry.js');
const UserProps = require('./user_property.js');
// deps
const { partition } = require('lodash');
module.exports = class DownloadQueue { module.exports = class DownloadQueue {
constructor(client) { constructor(client) {
this.client = client; this.client = client;
if(!Array.isArray(this.client.user.downloadQueue)) { if(!Array.isArray(this.client.user.downloadQueue)) {
if(this.client.user.properties.dl_queue) { if(this.client.user.properties[UserProps.DownloadQueue]) {
this.loadFromProperty(this.client.user.properties.dl_queue); this.loadFromProperty(this.client.user.properties[UserProps.DownloadQueue]);
} else { } else {
this.client.user.downloadQueue = []; this.client.user.downloadQueue = [];
} }
@ -24,21 +28,22 @@ module.exports = class DownloadQueue {
this.client.user.downloadQueue = []; this.client.user.downloadQueue = [];
} }
toggle(fileEntry) { toggle(fileEntry, systemFile=false) {
if(this.isQueued(fileEntry)) { if(this.isQueued(fileEntry)) {
this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId); this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId);
} else { } else {
this.add(fileEntry); this.add(fileEntry, systemFile);
} }
} }
add(fileEntry) { add(fileEntry, systemFile=false) {
this.client.user.downloadQueue.push({ this.client.user.downloadQueue.push({
fileId : fileEntry.fileId, fileId : fileEntry.fileId,
areaTag : fileEntry.areaTag, areaTag : fileEntry.areaTag,
fileName : fileEntry.fileName, fileName : fileEntry.fileName,
path : fileEntry.filePath, path : fileEntry.filePath,
byteSize : fileEntry.meta.byte_size || 0, byteSize : fileEntry.meta.byte_size || 0,
systemFile : systemFile,
}); });
} }
@ -47,7 +52,9 @@ module.exports = class DownloadQueue {
fileIds = [ fileIds ]; fileIds = [ fileIds ];
} }
this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => ( -1 === fileIds.indexOf(e.fileId) ) ); const [ remain, removed ] = partition(this.client.user.downloadQueue, e => ( -1 === fileIds.indexOf(e.fileId) ));
this.client.user.downloadQueue = remain;
return removed;
} }
isQueued(entryOrId) { isQueued(entryOrId) {

View File

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

View File

@ -19,6 +19,8 @@ function EditTextView(options) {
TextView.call(this, options); TextView.call(this, options);
this.initDefaultWidth();
this.cursorPos = { row : 0, col : 0 }; this.cursorPos = { row : 0, col : 0 };
this.clientBackspace = function() { this.clientBackspace = function() {

View File

@ -2,7 +2,7 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
@ -13,13 +13,14 @@ const nodeMailer = require('nodemailer');
exports.sendMail = sendMail; exports.sendMail = sendMail;
function sendMail(message, cb) { function sendMail(message, cb) {
if(!_.has(Config, 'email.transport')) { const config = Config();
if(!_.has(config, 'email.transport')) {
return cb(Errors.MissingConfig('Email "email::transport" configuration missing')); return cb(Errors.MissingConfig('Email "email::transport" configuration missing'));
} }
message.from = message.from || Config.email.defaultFrom; message.from = message.from || config.email.defaultFrom;
const transportOptions = Object.assign( {}, Config.email.transport, { const transportOptions = Object.assign( {}, config.email.transport, {
logger : Log, logger : Log,
}); });

View File

@ -11,6 +11,10 @@ class EnigError extends Error {
this.reason = reason; this.reason = reason;
this.reasonCode = reasonCode; this.reasonCode = reasonCode;
if(this.reason) {
this.message += `: ${this.reason}`;
}
if(typeof Error.captureStackTrace === 'function') { if(typeof Error.captureStackTrace === 'function') {
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
} else { } else {
@ -30,7 +34,9 @@ exports.Errors = {
ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode), ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode),
MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode), MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode),
UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode), UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode),
MissingParam : (reason, reasonCode) => new EnigError('Missing paramater(s)', -32008, reason, reasonCode), MissingParam : (reason, reasonCode) => new EnigError('Missing paramter(s)', -32008, reason, reasonCode),
MissingMci : (reason, reasonCode) => new EnigError('Missing required MCI code(s)', -32009, reason, reasonCode),
BadLogin : (reason, reasonCode) => new EnigError('Bad login attempt', -32010, reason, reasonCode),
}; };
exports.ErrorReasons = { exports.ErrorReasons = {
@ -39,4 +45,10 @@ exports.ErrorReasons = {
NoPreviousMenu : 'NOPREV', NoPreviousMenu : 'NOPREV',
NoConditionMatch : 'NOCONDMATCH', NoConditionMatch : 'NOCONDMATCH',
NotEnabled : 'NOTENABLED', NotEnabled : 'NOTENABLED',
AlreadyLoggedIn : 'ALREADYLOGGEDIN',
TooMany : 'TOOMANY',
Disabled : 'DISABLED',
Inactive : 'INACTIVE',
Locked : 'LOCKED',
NotAllowed : 'NOTALLOWED',
}; };

View File

@ -2,14 +2,14 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
// deps // deps
const assert = require('assert'); const assert = require('assert');
module.exports = function(condition, message) { module.exports = function(condition, message) {
if(Config.debug.assertsEnabled) { if(Config().debug.assertsEnabled) {
assert.apply(this, arguments); assert.apply(this, arguments);
} else if(!(condition)) { } else if(!(condition)) {
const stack = new Error().stack; const stack = new Error().stack;

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

@ -3,13 +3,14 @@
// ENiGMA½ // ENiGMA½
const PluginModule = require('./plugin_module.js').PluginModule; const PluginModule = require('./plugin_module.js').PluginModule;
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const { Errors } = require('./enig_error.js');
const _ = require('lodash'); const _ = require('lodash');
const later = require('later'); const later = require('later');
const path = require('path'); const path = require('path');
const pty = require('ptyw.js'); const pty = require('node-pty');
const sane = require('sane'); const sane = require('sane');
const moment = require('moment'); const moment = require('moment');
const paths = require('path'); const paths = require('path');
@ -24,8 +25,8 @@ exports.moduleInfo = {
author : 'NuSkooler', author : 'NuSkooler',
}; };
const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:)([^\0]+)?$/; const SCHEDULE_REGEXP = /(?:^|or )?(@watch:)([^\0]+)?$/;
const ACTION_REGEXP = /\@(method|execute)\:([^\0]+)?$/; const ACTION_REGEXP = /@(method|execute):([^\0]+)?$/;
class ScheduledEvent { class ScheduledEvent {
constructor(events, name) { constructor(events, name) {
@ -116,7 +117,7 @@ class ScheduledEvent {
methodModule[this.action.what](this.action.args, err => { methodModule[this.action.what](this.action.args, err => {
if(err) { if(err) {
Log.debug( Log.debug(
{ error : err.toString(), eventName : this.name, action : this.action }, { error : err.message, eventName : this.name, action : this.action },
'Error performing scheduled event action'); 'Error performing scheduled event action');
} }
@ -124,7 +125,7 @@ class ScheduledEvent {
}); });
} catch(e) { } catch(e) {
Log.warn( Log.warn(
{ error : e.toString(), eventName : this.name, action : this.action }, { error : e.message, eventName : this.name, action : this.action },
'Failed to perform scheduled event action'); 'Failed to perform scheduled event action');
return cb(e); return cb(e);
@ -138,7 +139,22 @@ class ScheduledEvent {
env : process.env, env : process.env,
}; };
const proc = pty.spawn(this.action.what, this.action.args, opts); let proc;
try {
proc = pty.spawn(this.action.what, this.action.args, opts);
} catch(e) {
Log.warn(
{
error : 'Failed to spawn @execute process',
reason : e.message,
eventName : this.name,
action : this.action,
what : this.action.what,
args : this.action.args
}
);
return cb(e);
}
proc.once('exit', exitCode => { proc.once('exit', exitCode => {
if(exitCode) { if(exitCode) {
@ -146,7 +162,7 @@ class ScheduledEvent {
{ eventName : this.name, action : this.action, exitCode : exitCode }, { eventName : this.name, action : this.action, exitCode : exitCode },
'Bad exit code while performing scheduled event action'); 'Bad exit code while performing scheduled event action');
} }
return cb(exitCode ? new Error(`Bad exit code while performing scheduled event action: ${exitCode}`) : null); return cb(exitCode ? Errors.ExternalProcess(`Bad exit code while performing scheduled event action: ${exitCode}`) : null);
}); });
} }
} }
@ -155,8 +171,9 @@ class ScheduledEvent {
function EventSchedulerModule(options) { function EventSchedulerModule(options) {
PluginModule.call(this, options); PluginModule.call(this, options);
if(_.has(Config, 'eventScheduler')) { const config = Config();
this.moduleConfig = Config.eventScheduler; if(_.has(config, 'eventScheduler')) {
this.moduleConfig = config.eventScheduler;
} }
const self = this; const self = this;

View File

@ -1,18 +1,21 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const paths = require('path');
const events = require('events'); const events = require('events');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const SystemEvents = require('./system_events.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const async = require('async');
const glob = require('glob');
module.exports = new class Events extends events.EventEmitter { module.exports = new class Events extends events.EventEmitter {
constructor() { constructor() {
super(); super();
this.setMaxListeners(64); // :TODO: play with this...
}
getSystemEvents() {
return SystemEvents;
} }
addListener(event, listener) { addListener(event, listener) {
@ -22,7 +25,7 @@ module.exports = new class Events extends events.EventEmitter {
emit(event, ...args) { emit(event, ...args) {
Log.trace( { event : event }, 'Emitting event'); Log.trace( { event : event }, 'Emitting event');
return super.emit(event, args); return super.emit(event, ...args);
} }
on(event, listener) { on(event, listener) {
@ -35,39 +38,39 @@ module.exports = new class Events extends events.EventEmitter {
return super.once(event, listener); return super.once(event, listener);
} }
//
// 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');
const listeners = [];
events.forEach(eventName => {
const listenWrapper = _.partial(listener, _, eventName);
this.on(eventName, listenWrapper);
listeners.push( { eventName, listenWrapper } );
});
return listeners;
}
removeMultipleEventListener(listeners) {
Log.trace( { events }, 'Removing listeners');
listeners.forEach(listener => {
this.removeListener(listener.eventName, listener.listenWrapper);
});
}
removeListener(event, listener) { removeListener(event, listener) {
Log.trace( { event : event }, 'Removing listener'); Log.trace( { event : event }, 'Removing listener');
return super.removeListener(event, listener); return super.removeListener(event, listener);
} }
startup(cb) { startup(cb) {
async.each(require('./module_util.js').getModulePaths(), (modulePath, nextPath) => { return cb(null);
glob('*{.js,/*.js}', { cwd : modulePath }, (err, files) => {
if(err) {
return nextPath(err);
}
async.each(files, (moduleName, nextModule) => {
modulePath = paths.join(modulePath, moduleName);
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) {
}
return nextModule(null);
}, err => {
return nextPath(err);
});
});
}, err => {
return cb(err);
});
} }
}; };

View File

@ -2,12 +2,18 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('../core/menu_module.js').MenuModule; const { MenuModule } = require('./menu_module.js');
const resetScreen = require('../core/ansi_term.js').resetScreen; const { resetScreen } = require('./ansi_term.js');
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors; const { Errors } = require('./enig_error.js');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent; const {
getEnigmaUserAgent
} = require('./misc_util.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps // deps
const async = require('async'); const async = require('async');
@ -17,7 +23,7 @@ const crypto = require('crypto');
const moment = require('moment'); const moment = require('moment');
const https = require('https'); const https = require('https');
const querystring = require('querystring'); const querystring = require('querystring');
const fs = require('fs'); const fs = require('fs-extra');
const SSHClient = require('ssh2').Client; const SSHClient = require('ssh2').Client;
/* /*
@ -66,7 +72,7 @@ exports.getModule = class ExodusModule extends MenuModule {
this.config.sshHost = this.config.sshHost || this.config.ticketHost; this.config.sshHost = this.config.sshHost || this.config.ticketHost;
this.config.sshPort = this.config.sshPort || 22; this.config.sshPort = this.config.sshPort || 22;
this.config.sshUser = this.config.sshUser || 'exodus_server'; this.config.sshUser = this.config.sshUser || 'exodus_server';
this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config.paths.misc, 'exodus.id_rsa'); this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config().paths.misc, 'exodus.id_rsa');
} }
initSequence() { initSequence() {
@ -151,11 +157,16 @@ exports.getModule = class ExodusModule extends MenuModule {
let pipeRestored = false; let pipeRestored = false;
let pipedStream; let pipedStream;
let doorTracking;
function restorePipe() { function restorePipe() {
if(pipedStream && !pipeRestored && !clientTerminated) { if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream); self.client.term.output.unpipe(pipedStream);
self.client.term.output.resume(); self.client.term.output.resume();
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
} }
} }
@ -186,6 +197,8 @@ exports.getModule = class ExodusModule extends MenuModule {
}); });
sshClient.shell(window, options, (err, stream) => { sshClient.shell(window, options, (err, stream) => {
doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`);
pipedStream = stream; // :TODO: ewwwwwwwww hack pipedStream = stream; // :TODO: ewwwwwwwww hack
self.client.term.output.pipe(stream); self.client.term.output.pipe(stream);

View File

@ -7,6 +7,7 @@ const ViewController = require('./view_controller.js').ViewController;
const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas;
const FileBaseFilters = require('./file_base_filter.js'); const FileBaseFilters = require('./file_base_filter.js');
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const UserProps = require('./user_property.js');
// deps // deps
const async = require('async'); const async = require('async');
@ -111,7 +112,7 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule {
// //
// If the item was also the active filter, we need to make a new one active // If the item was also the active filter, we need to make a new one active
// //
if(filterUuid === this.client.user.properties.file_base_filter_active_uuid) { if(filterUuid === this.client.user.properties[UserProps.FileBaseFilterActiveUuid]) {
const newActive = this.filtersArray[this.currentFilterIndex]; const newActive = this.filtersArray[this.currentFilterIndex];
if(newActive) { if(newActive) {
filters.setActive(newActive.uuid); filters.setActive(newActive.uuid);

View File

@ -12,7 +12,7 @@ const FileArea = require('./file_base_area.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled;
const ArchiveUtil = require('./archive_util.js'); const ArchiveUtil = require('./archive_util.js');
const Config = require('./config.js').config; const Config = require('./config.js').get;
const DownloadQueue = require('./download_queue.js'); const DownloadQueue = require('./download_queue.js');
const FileAreaWeb = require('./file_area_web.js'); const FileAreaWeb = require('./file_area_web.js');
const FileBaseFilters = require('./file_base_filter.js'); const FileBaseFilters = require('./file_base_filter.js');
@ -24,6 +24,7 @@ const controlCodesToAnsi = require('./color_codes.js').controlCodesToAnsi;
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const moment = require('moment'); const moment = require('moment');
const paths = require('path');
exports.moduleInfo = { exports.moduleInfo = {
name : 'File Area List', name : 'File Area List',
@ -75,6 +76,7 @@ exports.getModule = class FileAreaList extends MenuModule {
this.filterCriteria = _.get(options, 'extraArgs.filterCriteria'); this.filterCriteria = _.get(options, 'extraArgs.filterCriteria');
this.fileList = _.get(options, 'extraArgs.fileList'); this.fileList = _.get(options, 'extraArgs.fileList');
this.lastFileNextExit = _.get(options, 'extraArgs.lastFileNextExit', true);
if(this.fileList) { if(this.fileList) {
// we'll need to adjust position as well! // we'll need to adjust position as well!
@ -103,6 +105,10 @@ exports.getModule = class FileAreaList extends MenuModule {
return this.displayBrowsePage(true, cb); // true=clerarScreen return this.displayBrowsePage(true, cb); // true=clerarScreen
} }
if(this.lastFileNextExit) {
return this.prevMenu(cb);
}
return cb(null); return cb(null);
}, },
prevFile : (formData, extraArgs, cb) => { prevFile : (formData, extraArgs, cb) => {
@ -212,7 +218,7 @@ exports.getModule = class FileAreaList extends MenuModule {
const config = this.menuConfig.config; const config = this.menuConfig.config;
const currEntry = this.currentFileEntry; const currEntry = this.currentFileEntry;
const uploadTimestampFormat = config.browseUploadTimestampFormat || config.uploadTimestampFormat || 'YYYY-MMM-DD'; const uploadTimestampFormat = config.uploadTimestampFormat || this.client.currentTheme.helpers.getDateFormat('short');
const area = FileArea.getFileAreaByTag(currEntry.areaTag); const area = FileArea.getFileAreaByTag(currEntry.areaTag);
const hashTagsSep = config.hashTagsSep || ', '; const hashTagsSep = config.hashTagsSep || ', ';
const isQueuedIndicator = config.isQueuedIndicator || 'Y'; const isQueuedIndicator = config.isQueuedIndicator || 'Y';
@ -247,12 +253,22 @@ exports.getModule = class FileAreaList extends MenuModule {
if(entryInfo.archiveType) { if(entryInfo.archiveType) {
const mimeType = resolveMimeType(entryInfo.archiveType); const mimeType = resolveMimeType(entryInfo.archiveType);
entryInfo.archiveTypeDesc = mimeType ? _.get(Config, [ 'fileTypes', mimeType, 'desc' ] ) || mimeType : entryInfo.archiveType; let desc;
if(mimeType) {
let fileType = _.get(Config(), [ 'fileTypes', mimeType ] );
if(Array.isArray(fileType)) {
// further refine by extention
fileType = fileType.find(ft => paths.extname(currEntry.fileName) === ft.ext);
}
desc = fileType && fileType.desc;
}
entryInfo.archiveTypeDesc = desc || mimeType || entryInfo.archiveType;
} else { } else {
entryInfo.archiveTypeDesc = 'N/A'; entryInfo.archiveTypeDesc = 'N/A';
} }
entryInfo.uploadByUsername = entryInfo.uploadByUsername || 'N/A'; // may be imported entryInfo.uploadByUsername = entryInfo.uploadByUserName = entryInfo.uploadByUsername || 'N/A'; // may be imported
entryInfo.hashTags = entryInfo.hashTags || '(none)'; entryInfo.hashTags = entryInfo.hashTags || '(none)';
// create a rating string, e.g. "**---" // create a rating string, e.g. "**---"
@ -273,7 +289,7 @@ exports.getModule = class FileAreaList extends MenuModule {
entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated'; entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated';
} }
} else { } else {
const webDlExpireTimeFormat = config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; const webDlExpireTimeFormat = config.webDlExpireTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat('short');
entryInfo.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; entryInfo.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url;
entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat);
@ -397,16 +413,23 @@ exports.getModule = class FileAreaList extends MenuModule {
// //
const desc = controlCodesToAnsi(self.currentFileEntry.desc); const desc = controlCodesToAnsi(self.currentFileEntry.desc);
if(desc.length != self.currentFileEntry.desc.length || isAnsi(desc)) { if(desc.length != self.currentFileEntry.desc.length || isAnsi(desc)) {
descView.setAnsi( const opts = {
desc,
{
prepped : false, prepped : false,
forceLineTerm : true forceLineTerm : true
}, };
() => {
return callback(null); //
// if SAUCE states a term width, honor it else we may see
// display corruption
//
const sauceTermWidth = _.get(self.currentFileEntry.meta, 'desc_sauce.Character.characterWidth');
if(_.isNumber(sauceTermWidth)) {
opts.termWidth = sauceTermWidth;
} }
);
descView.setAnsi(desc, opts, () => {
return callback(null);
});
} else { } else {
descView.setText(self.currentFileEntry.desc); descView.setText(self.currentFileEntry.desc);
return callback(null); return callback(null);
@ -493,7 +516,7 @@ exports.getModule = class FileAreaList extends MenuModule {
return callback(null); return callback(null);
} }
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes');
FileAreaWeb.createAndServeTempDownload( FileAreaWeb.createAndServeTempDownload(
self.client, self.client,
@ -567,34 +590,38 @@ exports.getModule = class FileAreaList extends MenuModule {
return cb(err); return cb(err);
} }
this.currentFileEntry.archiveEntries = entries; // assign and add standard "text" member for itemFormat
this.currentFileEntry.archiveEntries = entries.map(e => Object.assign(e, { text : `${e.fileName} (${e.byteSize})` } ));
return cb(null, 're-cached'); return cb(null, 're-cached');
}); });
} }
setFileListNoListing(text) {
const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList);
if(fileListView) {
fileListView.complexItems = false;
fileListView.setItems( [ text ] );
fileListView.redraw();
}
}
populateFileListing() { populateFileListing() {
const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList); const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList);
if(this.currentFileEntry.entryInfo.archiveType) { if(this.currentFileEntry.entryInfo.archiveType) {
this.cacheArchiveEntries( (err, cacheStatus) => { this.cacheArchiveEntries( (err, cacheStatus) => {
if(err) { if(err) {
// :TODO: Handle me!!! return this.setFileListNoListing('Failed to get file listing');
fileListView.setItems( [ 'Failed getting file listing' ] ); // :TODO: make this not suck
return;
} }
if('re-cached' === cacheStatus) { if('re-cached' === cacheStatus) {
const fileListEntryFormat = this.menuConfig.config.fileListEntryFormat || '{fileName} {fileSize}'; // :TODO: use byteSize here? fileListView.setItems(this.currentFileEntry.archiveEntries);
const focusFileListEntryFormat = this.menuConfig.config.focusFileListEntryFormat || fileListEntryFormat;
fileListView.setItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(fileListEntryFormat, entry) ) );
fileListView.setFocusItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(focusFileListEntryFormat, entry) ) );
fileListView.redraw(); fileListView.redraw();
} }
}); });
} else { } else {
fileListView.setItems( [ stringFormat(this.menuConfig.config.notAnArchiveFormat || 'Not an archive', { fileName : this.currentFileEntry.fileName } ) ] ); const notAnArchiveFileName = stringFormat(this.menuConfig.config.notAnArchiveFormat || 'Not an archive', { fileName : this.currentFileEntry.fileName } );
this.setFileListNoListing(notAnArchiveFileName);
} }
} }

View File

@ -2,7 +2,7 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const FileDb = require('./database.js').dbs.file; const FileDb = require('./database.js').dbs.file;
const getISOTimestampString = require('./database.js').getISOTimestampString; const getISOTimestampString = require('./database.js').getISOTimestampString;
const FileEntry = require('./file_entry.js'); const FileEntry = require('./file_entry.js');
@ -14,6 +14,9 @@ const User = require('./user.js');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId; const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId;
const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
const Events = require('./events.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_menu_method.js');
// deps // deps
const hashids = require('hashids'); const hashids = require('hashids');
@ -30,7 +33,7 @@ function notEnabledError() {
class FileAreaWebAccess { class FileAreaWebAccess {
constructor() { constructor() {
this.hashids = new hashids(Config.general.boardName); this.hashids = new hashids(Config().general.boardName);
this.expireTimers = {}; // hashId->timer this.expireTimers = {}; // hashId->timer
} }
@ -51,7 +54,7 @@ class FileAreaWebAccess {
if(self.isEnabled()) { if(self.isEnabled()) {
const routeAdded = self.webServer.instance.addRoute({ const routeAdded = self.webServer.instance.addRoute({
method : 'GET', method : 'GET',
path : Config.fileBase.web.routePath, path : Config().fileBase.web.routePath,
handler : self.routeWebRequest.bind(self), handler : self.routeWebRequest.bind(self),
}); });
return callback(routeAdded ? null : Errors.General('Failed adding route')); return callback(routeAdded ? null : Errors.General('Failed adding route'));
@ -183,11 +186,11 @@ class FileAreaWebAccess {
buildSingleFileTempDownloadLink(client, fileEntry, hashId) { buildSingleFileTempDownloadLink(client, fileEntry, hashId) {
hashId = hashId || this.getSingleFileHashId(client, fileEntry); hashId = hashId || this.getSingleFileHashId(client, fileEntry);
return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
} }
buildBatchArchiveTempDownloadLink(client, hashId) { buildBatchArchiveTempDownloadLink(client, hashId) {
return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
} }
getExistingTempDownloadServeItem(client, fileEntry, cb) { getExistingTempDownloadServeItem(client, fileEntry, cb) {
@ -337,7 +340,7 @@ class FileAreaWebAccess {
resp.on('finish', () => { resp.on('finish', () => {
// transfer completed fully // transfer completed fully
this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size); this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size, [ fileEntry ]);
}); });
const headers = { const headers = {
@ -382,24 +385,21 @@ class FileAreaWebAccess {
); );
}, },
function loadFileEntries(fileIds, callback) { function loadFileEntries(fileIds, callback) {
const filePaths = []; async.map(fileIds, (fileId, nextFileId) => {
async.eachSeries(fileIds, (fileId, nextFileId) => {
const fileEntry = new FileEntry(); const fileEntry = new FileEntry();
fileEntry.load(fileId, err => { fileEntry.load(fileId, err => {
if(!err) { return nextFileId(err, fileEntry);
filePaths.push(fileEntry.filePath);
}
return nextFileId(err);
}); });
}, err => { }, (err, fileEntries) => {
if(err) { if(err) {
return callback(Errors.DoesNotExist('Coudl not load file IDs for batch')); return callback(Errors.DoesNotExist('Could not load file IDs for batch'));
} }
return callback(null, filePaths); return callback(null, fileEntries);
}); });
}, },
function createAndServeStream(filePaths, callback) { function createAndServeStream(fileEntries, callback) {
const filePaths = fileEntries.map(fe => fe.filePath);
Log.trace( { filePaths : filePaths }, 'Creating zip archive for batch web request'); Log.trace( { filePaths : filePaths }, 'Creating zip archive for batch web request');
const zipFile = new yazl.ZipFile(); const zipFile = new yazl.ZipFile();
@ -430,7 +430,7 @@ class FileAreaWebAccess {
resp.on('finish', () => { resp.on('finish', () => {
// transfer completed fully // transfer completed fully
self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize); self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize, fileEntries);
}); });
const batchFileName = `batch_${servedItem.hashId}.zip`; const batchFileName = `batch_${servedItem.hashId}.zip`;
@ -457,7 +457,7 @@ class FileAreaWebAccess {
); );
} }
updateDownloadStatsForUserIdAndSystem(userId, dlBytes, cb) { updateDownloadStatsForUserIdAndSystem(userId, dlBytes, fileEntries) {
async.waterfall( async.waterfall(
[ [
function fetchActiveUser(callback) { function fetchActiveUser(callback) {
@ -472,19 +472,25 @@ class FileAreaWebAccess {
}); });
}, },
function updateStats(user, callback) { function updateStats(user, callback) {
StatLog.incrementUserStat(user, 'dl_total_count', 1); StatLog.incrementUserStat(user, UserProps.FileDlTotalCount, 1);
StatLog.incrementUserStat(user, 'dl_total_bytes', dlBytes); StatLog.incrementUserStat(user, UserProps.FileDlTotalBytes, dlBytes);
StatLog.incrementSystemStat('dl_total_count', 1);
StatLog.incrementSystemStat('dl_total_bytes', 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); return callback(null);
} }
], ]
err => {
if(cb) {
return cb(err);
}
}
); );
} }
} }

View File

@ -2,7 +2,7 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs;
const FileEntry = require('./file_entry.js'); const FileEntry = require('./file_entry.js');
@ -14,6 +14,9 @@ const resolveMimeType = require('./mime_util.js').resolveMimeType;
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const wordWrapText = require('./word_wrap.js').wordWrapText; const wordWrapText = require('./word_wrap.js').wordWrapText;
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
const SAUCE = require('./sauce.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
@ -26,6 +29,7 @@ const iconv = require('iconv-lite');
const execFile = require('child_process').execFile; const execFile = require('child_process').execFile;
const moment = require('moment'); const moment = require('moment');
exports.startup = startup;
exports.isInternalArea = isInternalArea; exports.isInternalArea = isInternalArea;
exports.getAvailableFileAreas = getAvailableFileAreas; exports.getAvailableFileAreas = getAvailableFileAreas;
exports.getAvailableFileAreaTags = getAvailableFileAreaTags; exports.getAvailableFileAreaTags = getAvailableFileAreaTags;
@ -42,6 +46,7 @@ exports.scanFile = scanFile;
exports.scanFileAreaForChanges = scanFileAreaForChanges; exports.scanFileAreaForChanges = scanFileAreaForChanges;
exports.getDescFromFileName = getDescFromFileName; exports.getDescFromFileName = getDescFromFileName;
exports.getAreaStats = getAreaStats; exports.getAreaStats = getAreaStats;
exports.cleanUpTempSessionItems = cleanUpTempSessionItems;
// for scheduler: // for scheduler:
exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent; exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent;
@ -52,6 +57,28 @@ const WellKnownAreaTags = exports.WellKnownAreaTags = {
TempDownloads : 'system_temporary_download', TempDownloads : 'system_temporary_download',
}; };
function startup(cb) {
async.series(
[
(callback) => {
return cleanUpTempSessionItems(callback);
},
(callback) => {
getAreaStats( (err, stats) => {
if(!err) {
StatLog.setNonPersistentSystemStat(SysProps.FileBaseAreaStats, stats);
}
return callback(null);
});
}
],
err => {
return cb(err);
}
);
}
function isInternalArea(areaTag) { function isInternalArea(areaTag) {
return [ WellKnownAreaTags.MessageAreaAttach, WellKnownAreaTags.TempDownloads ].includes(areaTag); return [ WellKnownAreaTags.MessageAreaAttach, WellKnownAreaTags.TempDownloads ].includes(areaTag);
} }
@ -60,7 +87,7 @@ function getAvailableFileAreas(client, options) {
options = options || { }; options = options || { };
// perform ACS check per conf & omit internal if desired // perform ACS check per conf & omit internal if desired
const allAreas = _.map(Config.fileBase.areas, (areaInfo, areaTag) => Object.assign(areaInfo, { areaTag : areaTag } )); const allAreas = _.map(Config().fileBase.areas, (areaInfo, areaTag) => Object.assign(areaInfo, { areaTag : areaTag } ));
return _.omitBy(allAreas, areaInfo => { return _.omitBy(allAreas, areaInfo => {
if(!options.includeSystemInternal && isInternalArea(areaInfo.areaTag)) { if(!options.includeSystemInternal && isInternalArea(areaInfo.areaTag)) {
@ -90,16 +117,17 @@ function getSortedAvailableFileAreas(client, options) {
} }
function getDefaultFileAreaTag(client, disableAcsCheck) { function getDefaultFileAreaTag(client, disableAcsCheck) {
let defaultArea = _.findKey(Config.fileBase, o => o.default); const config = Config();
let defaultArea = _.findKey(config.fileBase, o => o.default);
if(defaultArea) { if(defaultArea) {
const area = Config.fileBase.areas[defaultArea]; const area = config.fileBase.areas[defaultArea];
if(true === disableAcsCheck || client.acs.hasFileAreaRead(area)) { if(true === disableAcsCheck || client.acs.hasFileAreaRead(area)) {
return defaultArea; return defaultArea;
} }
} }
// just use anything we can // just use anything we can
defaultArea = _.findKey(Config.fileBase.areas, (area, areaTag) => { defaultArea = _.findKey(config.fileBase.areas, (area, areaTag) => {
return WellKnownAreaTags.MessageAreaAttach !== areaTag && (true === disableAcsCheck || client.acs.hasFileAreaRead(area)); return WellKnownAreaTags.MessageAreaAttach !== areaTag && (true === disableAcsCheck || client.acs.hasFileAreaRead(area));
}); });
@ -107,7 +135,7 @@ function getDefaultFileAreaTag(client, disableAcsCheck) {
} }
function getFileAreaByTag(areaTag) { function getFileAreaByTag(areaTag) {
const areaInfo = Config.fileBase.areas[areaTag]; const areaInfo = Config().fileBase.areas[areaTag];
if(areaInfo) { if(areaInfo) {
areaInfo.areaTag = areaTag; // convienence! areaInfo.areaTag = areaTag; // convienence!
areaInfo.storage = getAreaStorageLocations(areaInfo); areaInfo.storage = getAreaStorageLocations(areaInfo);
@ -129,11 +157,11 @@ function changeFileAreaWithOptions(client, areaTag, options, cb) {
}, },
function changeArea(area, callback) { function changeArea(area, callback) {
if(true === options.persist) { if(true === options.persist) {
client.user.persistProperty('file_area_tag', areaTag, err => { client.user.persistProperty(UserProps.FileAreaTag, areaTag, err => {
return callback(err, area); return callback(err, area);
}); });
} else { } else {
client.user.properties['file_area_tag'] = areaTag; client.user.properties[UserProps.FileAreaTag] = areaTag;
return callback(null, area); return callback(null, area);
} }
} }
@ -151,13 +179,14 @@ function changeFileAreaWithOptions(client, areaTag, options, cb) {
} }
function isValidStorageTag(storageTag) { function isValidStorageTag(storageTag) {
return storageTag in Config.fileBase.storageTags; return storageTag in Config().fileBase.storageTags;
} }
function getAreaStorageDirectoryByTag(storageTag) { function getAreaStorageDirectoryByTag(storageTag) {
const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]); const config = Config();
const storageLocation = (storageTag && config.fileBase.storageTags[storageTag]);
return paths.resolve(Config.fileBase.areaStoragePrefix, storageLocation || ''); return paths.resolve(config.fileBase.areaStoragePrefix, storageLocation || '');
} }
function getAreaDefaultStorageDirectory(areaInfo) { function getAreaDefaultStorageDirectory(areaInfo) {
@ -170,7 +199,7 @@ function getAreaStorageLocations(areaInfo) {
areaInfo.storageTags : areaInfo.storageTags :
[ areaInfo.storageTags || '' ]; [ areaInfo.storageTags || '' ];
const avail = Config.fileBase.storageTags; const avail = Config().fileBase.storageTags;
return _.compact(storageTags.map(storageTag => { return _.compact(storageTags.map(storageTag => {
if(avail[storageTag]) { if(avail[storageTag]) {
@ -211,7 +240,7 @@ function getExistingFileEntriesBySha256(sha256, cb) {
); );
} }
// :TODO: This is bascially sliceAtEOF() from art.js .... DRY! // :TODO: This is basically sliceAtEOF() from art.js .... DRY!
function sliceAtSauceMarker(data) { function sliceAtSauceMarker(data) {
let eof = data.length; let eof = data.length;
const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE) const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE)
@ -227,7 +256,7 @@ function sliceAtSauceMarker(data) {
function attemptSetEstimatedReleaseDate(fileEntry) { function attemptSetEstimatedReleaseDate(fileEntry) {
// :TODO: yearEstPatterns RegExp's should be cached - we can do this @ Config (re)load time // :TODO: yearEstPatterns RegExp's should be cached - we can do this @ Config (re)load time
const patterns = Config.fileBase.yearEstPatterns.map( p => new RegExp(p, 'gmi')); const patterns = Config().fileBase.yearEstPatterns.map( p => new RegExp(p, 'gmi'));
function getMatch(input) { function getMatch(input) {
if(input) { if(input) {
@ -272,11 +301,17 @@ function attemptSetEstimatedReleaseDate(fileEntry) {
} }
// a simple log proxy for when we call from oputil.js // a simple log proxy for when we call from oputil.js
function logDebug(obj, msg) { const maybeLog = (obj, msg, level) => {
if(Log) { if(Log) {
Log.debug(obj, msg); Log[level](obj, msg);
} else if ('error' === level) {
console.error(`${msg}: ${JSON.stringify(obj)}`); // eslint-disable-line no-console
} }
} };
const logDebug = (obj, msg) => maybeLog(obj, msg, 'debug');
const logTrace = (obj, msg) => maybeLog(obj, msg, 'trace');
const logError = (obj, msg) => maybeLog(obj, msg, 'error');
function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) { function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
async.waterfall( async.waterfall(
@ -284,11 +319,11 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
function extractDescFiles(callback) { function extractDescFiles(callback) {
// :TODO: would be nice if these RegExp's were cached // :TODO: would be nice if these RegExp's were cached
// :TODO: this is long winded... // :TODO: this is long winded...
const config = Config();
const extractList = []; const extractList = [];
const shortDescFile = archiveEntries.find( e => { const shortDescFile = archiveEntries.find( e => {
return Config.fileBase.fileNamePatterns.desc.find( pat => new RegExp(pat, 'i').test(e.fileName) ); return config.fileBase.fileNamePatterns.desc.find( pat => new RegExp(pat, 'i').test(e.fileName) );
}); });
if(shortDescFile) { if(shortDescFile) {
@ -296,7 +331,7 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
} }
const longDescFile = archiveEntries.find( e => { const longDescFile = archiveEntries.find( e => {
return Config.fileBase.fileNamePatterns.descLong.find( pat => new RegExp(pat, 'i').test(e.fileName) ); return config.fileBase.fileNamePatterns.descLong.find( pat => new RegExp(pat, 'i').test(e.fileName) );
}); });
if(longDescFile) { if(longDescFile) {
@ -319,8 +354,8 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
} }
const descFiles = { const descFiles = {
desc : shortDescFile ? paths.join(tempDir, shortDescFile.fileName) : null, desc : shortDescFile ? paths.join(tempDir, paths.basename(shortDescFile.fileName)) : null,
descLong : longDescFile ? paths.join(tempDir, longDescFile.fileName) : null, descLong : longDescFile ? paths.join(tempDir, paths.basename(longDescFile.fileName)) : null,
}; };
return callback(null, descFiles); return callback(null, descFiles);
@ -328,6 +363,7 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
}); });
}, },
function readDescFiles(descFiles, callback) { function readDescFiles(descFiles, callback) {
const config = Config();
async.each(Object.keys(descFiles), (descType, next) => { async.each(Object.keys(descFiles), (descType, next) => {
const path = descFiles[descType]; const path = descFiles[descType];
if(!path) { if(!path) {
@ -341,9 +377,8 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
// skip entries that are too large // skip entries that are too large
const maxFileSizeKey = `max${_.upperFirst(descType)}FileByteSize`; const maxFileSizeKey = `max${_.upperFirst(descType)}FileByteSize`;
if(config.fileBase[maxFileSizeKey] && stats.size > config.fileBase[maxFileSizeKey]) {
if(Config.fileBase[maxFileSizeKey] && stats.size > Config.fileBase[maxFileSizeKey]) { logDebug( { byteSize : stats.size, maxByteSize : config.fileBase[maxFileSizeKey] }, `Skipping "${descType}"; Too large` );
logDebug( { byteSize : stats.size, maxByteSize : Config.fileBase[maxFileSizeKey] }, `Skipping "${descType}"; Too large` );
return next(null); return next(null);
} }
@ -352,20 +387,31 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
return next(null); return next(null);
} }
SAUCE.readSAUCE(data, (err, sauce) => {
if(sauce) {
// if we have SAUCE, this information will be kept as well,
// but separate/pre-parsed.
const metaKey = `desc${'descLong' === descType ? '_long' : ''}_sauce`;
fileEntry.meta[metaKey] = JSON.stringify(sauce);
}
// //
// Assume FILE_ID.DIZ, NFO files, etc. are CP437. // Assume FILE_ID.DIZ, NFO files, etc. are CP437; we need
// to decode to a native format for storage
// //
// :TODO: This isn't really always the case - how to handle this? We could do a quick detection... // :TODO: This isn't really always the case - how to handle this? We could do a quick detection...
fileEntry[descType] = iconv.decode(sliceAtSauceMarker(data, 0x1a), 'cp437'); const decodedData = iconv.decode(data, 'cp437');
fileEntry[descType] = sliceAtSauceMarker(decodedData, 0x1a);
fileEntry[`${descType}Src`] = 'descFile'; fileEntry[`${descType}Src`] = 'descFile';
return next(null); return next(null);
}); });
}); });
});
}, () => { }, () => {
// cleanup but don't wait // cleanup but don't wait
temptmp.cleanup( paths => { temptmp.cleanup( paths => {
// note: don't use client logger here - may not be avail // note: don't use client logger here - may not be avail
logDebug( { paths : paths, sessionId : temptmp.sessionId }, 'Cleaned up temporary files' ); logTrace( { paths : paths, sessionId : temptmp.sessionId }, 'Cleaned up temporary files' );
}); });
return callback(null); return callback(null);
}); });
@ -481,13 +527,25 @@ function populateFileEntryWithArchive(fileEntry, filePath, stepInfo, iterator, c
); );
} }
function getInfoExtractUtilForDesc(mimeType, descType) { function getInfoExtractUtilForDesc(mimeType, filePath, descType) {
let util = _.get(Config, [ 'fileTypes', mimeType, `${descType}DescUtil` ]); const config = Config();
let fileType = _.get(config, [ 'fileTypes', mimeType ] );
if(Array.isArray(fileType)) {
// further refine by extention
fileType = fileType.find(ft => paths.extname(filePath) === ft.ext);
}
if(!_.isObject(fileType)) {
return;
}
let util = _.get(fileType, `${descType}DescUtil`);
if(!_.isString(util)) { if(!_.isString(util)) {
return; return;
} }
util = _.get(Config, [ 'infoExtractUtils', util ]); util = _.get(config, [ 'infoExtractUtils', util ]);
if(!util || !_.isString(util.cmd)) { if(!util || !_.isString(util.cmd)) {
return; return;
} }
@ -502,7 +560,7 @@ function populateFileEntryInfoFromFile(fileEntry, filePath, cb) {
} }
async.eachSeries( [ 'short', 'long' ], (descType, nextDesc) => { async.eachSeries( [ 'short', 'long' ], (descType, nextDesc) => {
const util = getInfoExtractUtilForDesc(mimeType, descType); const util = getInfoExtractUtilForDesc(mimeType, filePath, descType);
if(!util) { if(!util) {
return nextDesc(null); return nextDesc(null);
} }
@ -586,10 +644,6 @@ function addNewFileEntry(fileEntry, filePath, cb) {
); );
} }
function updateFileEntry(fileEntry, filePath, cb) {
}
const HASH_NAMES = [ 'sha1', 'sha256', 'md5', 'crc32' ]; const HASH_NAMES = [ 'sha1', 'sha256', 'md5', 'crc32' ];
function scanFile(filePath, options, iterator, cb) { function scanFile(filePath, options, iterator, cb) {
@ -617,23 +671,18 @@ function scanFile(filePath, options, iterator, cb) {
fileName : paths.basename(filePath), fileName : paths.basename(filePath),
}; };
function callIter(next) { const callIter = (next) => {
if(iterator) { return iterator ? iterator(stepInfo, next) : next(null);
return iterator(stepInfo, next); };
} else {
return next(null);
}
}
function readErrorCallIter(origError, next) { const readErrorCallIter = (origError, next) => {
stepInfo.step = 'read_error'; stepInfo.step = 'read_error';
stepInfo.error = origError.message; stepInfo.error = origError.message;
callIter( () => { callIter( () => {
return next(origError); return next(origError);
}); });
} };
let lastCalcHashPercent; let lastCalcHashPercent;
@ -676,47 +725,42 @@ function scanFile(filePath, options, iterator, cb) {
} }
}); });
const stream = fs.createReadStream(filePath); const updateHashes = (data) => {
for(let i = 0; i < hashesToCalc.length; ++i) {
function updateHashes(data) { hashes[hashesToCalc[i]].update(data);
async.each(hashesToCalc, (hashName, nextHash) => {
hashes[hashName].update(data);
return nextHash(null);
}, () => {
return stream.resume();
});
} }
};
stream.on('data', data => {
stream.pause(); // until iterator compeltes
stepInfo.bytesProcessed += data.length;
stepInfo.calcHashPercent = Math.round(((stepInfo.bytesProcessed / stepInfo.byteSize) * 100));
// //
// Only send 'hash_update' step update if we have a noticable percentage change in progress // Note that we are not using fs.createReadStream() here:
// While convenient, it is quite a bit slower -- which adds
// up to many seconds in time for larger files.
// //
if(stepInfo.calcHashPercent === lastCalcHashPercent) { const chunkSize = 1024 * 64;
updateHashes(data); const buffer = Buffer.allocUnsafe(chunkSize);
} else {
lastCalcHashPercent = stepInfo.calcHashPercent;
stepInfo.step = 'hash_update';
callIter(err => { fs.open(filePath, 'r', (err, fd) => {
if(err) { if(err) {
stream.destroy(); // cancel read return readErrorCallIter(err, callback);
return callback(err);
} }
updateHashes(data); const nextChunk = () => {
fs.read(fd, buffer, 0, chunkSize, null, (err, bytesRead) => {
if(err) {
return fs.close(fd, closeErr => {
if(closeErr) {
logError( { filePath, error : err.message }, 'Failed to close file');
}
return readErrorCallIter(err, callback);
}); });
} }
});
stream.on('end', () => { if(0 === bytesRead) {
// done - finalize
fileEntry.meta.byte_size = stepInfo.bytesProcessed; fileEntry.meta.byte_size = stepInfo.bytesProcessed;
async.each(hashesToCalc, (hashName, nextHash) => { for(let i = 0; i < hashesToCalc.length; ++i) {
const hashName = hashesToCalc[i];
if('sha256' === hashName) { if('sha256' === hashName) {
stepInfo.sha256 = fileEntry.fileSha256 = hashes.sha256.digest('hex'); stepInfo.sha256 = fileEntry.fileSha256 = hashes.sha256.digest('hex');
} else if('sha1' === hashName || 'md5' === hashName) { } else if('sha1' === hashName || 'md5' === hashName) {
@ -724,16 +768,44 @@ function scanFile(filePath, options, iterator, cb) {
} else if('crc32' === hashName) { } else if('crc32' === hashName) {
stepInfo.crc32 = fileEntry.meta.file_crc32 = hashes.crc32.finalize().toString(16); stepInfo.crc32 = fileEntry.meta.file_crc32 = hashes.crc32.finalize().toString(16);
} }
}
return nextHash(null);
}, () => {
stepInfo.step = 'hash_finish'; stepInfo.step = 'hash_finish';
return fs.close(fd, closeErr => {
if(closeErr) {
logError( { filePath, error : err.message }, 'Failed to close file');
}
return callIter(callback); return callIter(callback);
}); });
}); }
stream.on('error', err => { stepInfo.bytesProcessed += bytesRead;
return readErrorCallIter(err, callback); stepInfo.calcHashPercent = Math.round(((stepInfo.bytesProcessed / stepInfo.byteSize) * 100));
//
// Only send 'hash_update' step update if we have a noticable percentage change in progress
//
const data = bytesRead < chunkSize ? buffer.slice(0, bytesRead) : buffer;
if(!iterator || stepInfo.calcHashPercent === lastCalcHashPercent) {
updateHashes(data);
return nextChunk();
} else {
lastCalcHashPercent = stepInfo.calcHashPercent;
stepInfo.step = 'hash_update';
callIter(err => {
if(err) {
return callback(err);
}
updateHashes(data);
return nextChunk();
});
}
});
};
nextChunk();
}); });
}, },
function processPhysicalFileByType(callback) { function processPhysicalFileByType(callback) {
@ -747,7 +819,9 @@ function scanFile(filePath, options, iterator, cb) {
populateFileEntryWithArchive(fileEntry, filePath, stepInfo, callIter, err => { populateFileEntryWithArchive(fileEntry, filePath, stepInfo, callIter, err => {
if(err) { if(err) {
populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => { populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => {
// :TODO: log err if(err) {
logDebug( { error : err.message }, 'Non-archive file entry population failed');
}
return callback(null); // ignore err return callback(null); // ignore err
}); });
} else { } else {
@ -756,7 +830,9 @@ function scanFile(filePath, options, iterator, cb) {
}); });
} else { } else {
populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => { populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => {
// :TODO: log err if(err) {
logDebug( { error : err.message }, 'Non-archive file entry population failed');
}
return callback(null); // ignore err return callback(null); // ignore err
}); });
} }
@ -870,11 +946,48 @@ function scanFileAreaForChanges(areaInfo, options, iterator, cb) {
} }
function getDescFromFileName(fileName) { function getDescFromFileName(fileName) {
// :TODO: this method could use some more logic to really be nice. //
// Example filenames:
//
// input desired output
// -----------------------------------------------------------------------------------------
// Nintendo_Power_Issue_011_March-April_1990.cbr Nintendo Power Issue 011 March-April 1990
// Atari User Issue 3 (July 1985).pdf Atari User Issue 3 (July 1985)
// Out_Of_The_Shadows_010__1953_.cbz Out Of The Shadows 010 1953
// ABC A Basic Compiler 1.03 [pro].atr ABC A Basic Compiler 1.03 [pro]
// 221B Baker Street v1.0 (1987)(Datasoft)(Side B)[cr The Bounty].zip 221B Baker Street v1.0 (1987)(Datasoft)(Side B)[cr the Bounty]
//
// See also:
// * https://scenerules.org/
//
const ext = paths.extname(fileName); const ext = paths.extname(fileName);
const name = paths.basename(fileName, ext); const name = paths.basename(fileName, ext);
const asIsRe = /([vV]?(?:[0-9]{1,4})(?:\.[0-9]{1,4})+[-+]?(?:[a-z]{1,4})?)|(Incl\.)|(READ\.NFO)/g;
return _.upperFirst(name.replace(/[\-_.+]/g, ' ').replace(/\s+/g, ' ')); const normalize = (s) => {
return _.upperFirst(s.replace(/[-_.+]/g, ' ').replace(/\s+/g, ' '));
};
let out = '';
let m;
let pos;
do {
pos = asIsRe.lastIndex;
m = asIsRe.exec(name);
if(m) {
if(m.index > pos) {
out += normalize(name.slice(pos, m.index));
}
out += m[0]; // as-is
}
} while(0 != asIsRe.lastIndex);
if(pos < name.length) {
out += normalize(name.slice(pos));
}
return out;
} }
// //
@ -930,9 +1043,47 @@ function getAreaStats(cb) {
function updateAreaStatsScheduledEvent(args, cb) { function updateAreaStatsScheduledEvent(args, cb) {
getAreaStats( (err, stats) => { getAreaStats( (err, stats) => {
if(!err) { if(!err) {
StatLog.setNonPeristentSystemStat('file_base_area_stats', stats); StatLog.setNonPersistentSystemStat(SysProps.FileBaseAreaStats, stats);
} }
return cb(err); return cb(err);
}); });
} }
function cleanUpTempSessionItems(cb) {
// find (old) temporary session items and nuke 'em
const filter = {
areaTag : WellKnownAreaTags.TempDownloads,
metaPairs : [
{
name : 'session_temp_dl',
value : 1
}
]
};
FileEntry.findFiles(filter, (err, fileIds) => {
if(err) {
return cb(err);
}
async.each(fileIds, (fileId, nextFileId) => {
const fileEntry = new FileEntry();
fileEntry.load(fileId, err => {
if(err) {
Log.warn( { fileId }, 'Failed loading temporary session download item for cleanup');
return nextFileId(null);
}
FileEntry.removeEntry(fileEntry, { removePhysFile : true }, err => {
if(err) {
Log.warn( { fileId : fileEntry.fileId, filePath : fileEntry.filePath }, 'Failed to clean up temporary session download item');
}
return nextFileId(null);
});
});
}, () => {
return cb(null);
});
});
}

View File

@ -3,9 +3,9 @@
// enigma-bbs // enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const stringFormat = require('./string_format.js'); const { getSortedAvailableFileAreas } = require('./file_base_area.js');
const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas;
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
const SysProps = require('./system_property.js');
// deps // deps
const async = require('async'); const async = require('async');
@ -24,23 +24,17 @@ exports.getModule = class FileAreaSelectModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.config = this.menuConfig.config || {};
this.loadAvailAreas();
this.menuMethods = { this.menuMethods = {
selectArea : (formData, extraArgs, cb) => { selectArea : (formData, extraArgs, cb) => {
const area = this.availAreas[formData.value.areaSelect] || 0;
const filterCriteria = { const filterCriteria = {
areaTag : area.areaTag, areaTag : formData.value.areaTag,
}; };
const menuOpts = { const menuOpts = {
extraArgs : { extraArgs : {
filterCriteria : filterCriteria, filterCriteria : filterCriteria,
}, },
menuFlags : [ 'popParent' ], menuFlags : [ 'popParent', 'mergeFlags' ],
}; };
return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb);
@ -48,10 +42,6 @@ exports.getModule = class FileAreaSelectModule extends MenuModule {
}; };
} }
loadAvailAreas() {
this.availAreas = getSortedAvailableFileAreas(this.client);
}
mciReady(mciData, cb) { mciReady(mciData, cb) {
super.mciReady(mciData, err => { super.mciReady(mciData, err => {
if(err) { if(err) {
@ -60,35 +50,29 @@ exports.getModule = class FileAreaSelectModule extends MenuModule {
const self = this; const self = this;
async.series( async.waterfall(
[ [
function mergeAreaStats(callback) { function mergeAreaStats(callback) {
const areaStats = StatLog.getSystemStat('file_base_area_stats') || { areas : {} }; const areaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats) || { areas : {} };
self.availAreas.forEach(area => { // 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]; const stats = areaStats.areas[area.areaTag];
area.totalFiles = stats ? stats.files : 0; area.totalFiles = stats ? stats.files : 0;
area.totalBytes = stats ? stats.bytes : 0; area.totalBytes = stats ? stats.bytes : 0;
}); });
return callback(null); return callback(null, availAreas);
}, },
function prepView(callback) { function prepView(availAreas, callback) {
self.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => { self.prepViewController('allViews', 0, mciData.menu, (err, vc) => {
if(err) { if(err) {
return callback(err); return callback(err);
} }
const areaListView = vc.getView(MciViewIds.areaList); const areaListView = vc.getView(MciViewIds.areaList);
areaListView.setItems(availAreas.map(area => Object.assign(area, { text : area.name, data : area.areaTag } )));
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(); areaListView.redraw();
return callback(null); return callback(null);

View File

@ -8,7 +8,6 @@ const DownloadQueue = require('./download_queue.js');
const theme = require('./theme.js'); const theme = require('./theme.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const stringFormat = require('./string_format.js');
const FileAreaWeb = require('./file_area_web.js'); const FileAreaWeb = require('./file_area_web.js');
// deps // deps
@ -59,8 +58,6 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule {
return this.gotoMenu(this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', modOpts, cb); return this.gotoMenu(this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', modOpts, cb);
}, },
viewItemInfo : (formData, extraArgs, cb) => {
},
removeItem : (formData, extraArgs, cb) => { removeItem : (formData, extraArgs, cb) => {
const selectedItem = this.dlQueue.items[formData.value.queueItem]; const selectedItem = this.dlQueue.items[formData.value.queueItem];
if(!selectedItem) { if(!selectedItem) {
@ -152,11 +149,7 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule {
return cb(Errors.DoesNotExist('Queue view does not exist')); return cb(Errors.DoesNotExist('Queue view does not exist'));
} }
const queueListFormat = this.menuConfig.config.queueListFormat || '{fileName} {byteSize}'; queueView.setItems(this.dlQueue.items);
const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) );
queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) );
queueView.on('index update', idx => { queueView.on('index update', idx => {
const fileEntry = this.dlQueue.items[idx]; const fileEntry = this.dlQueue.items[idx];

View File

@ -1,6 +1,8 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const UserProps = require('./user_property.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const uuidV4 = require('uuid/v4'); const uuidV4 = require('uuid/v4');
@ -64,7 +66,7 @@ module.exports = class FileBaseFilters {
} }
load() { load() {
let filtersProperty = this.client.user.properties.file_base_filters; let filtersProperty = this.client.user.properties[UserProps.FileBaseFilters];
let defaulted; let defaulted;
if(!filtersProperty) { if(!filtersProperty) {
filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters()); filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters());
@ -90,11 +92,11 @@ module.exports = class FileBaseFilters {
} }
persist(cb) { persist(cb) {
return this.client.user.persistProperty('file_base_filters', JSON.stringify(this.filters), cb); return this.client.user.persistProperty(UserProps.FileBaseFilters, JSON.stringify(this.filters), cb);
} }
cleanTags(tags) { cleanTags(tags) {
return tags.toLowerCase().replace(/,?\s+|\,/g, ' ').trim(); return tags.toLowerCase().replace(/,?\s+|,/g, ' ').trim();
} }
setActive(filterUuid) { setActive(filterUuid) {
@ -102,7 +104,7 @@ module.exports = class FileBaseFilters {
if(activeFilter) { if(activeFilter) {
this.activeFilter = activeFilter; this.activeFilter = activeFilter;
this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid); this.client.user.persistProperty(UserProps.FileBaseFilterActiveUuid, filterUuid);
return true; return true;
} }
@ -129,11 +131,11 @@ module.exports = class FileBaseFilters {
} }
static getActiveFilter(client) { static getActiveFilter(client) {
return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid); return new FileBaseFilters(client).get(client.user.properties[UserProps.FileBaseFilterActiveUuid]);
} }
static getFileBaseLastViewedFileIdByUser(user) { static getFileBaseLastViewedFileIdByUser(user) {
return parseInt((user.properties.user_file_base_last_viewed || 0)); return parseInt((user.properties[UserProps.FileBaseLastViewedId] || 0));
} }
static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) { static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) {
@ -150,6 +152,6 @@ module.exports = class FileBaseFilters {
return; return;
} }
return user.persistProperty('user_file_base_last_viewed', fileId, cb); return user.persistProperty(UserProps.FileBaseLastViewedId, fileId, cb);
} }
}; };

View File

@ -0,0 +1,301 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const stringFormat = require('./string_format.js');
const FileEntry = require('./file_entry.js');
const FileArea = require('./file_base_area.js');
const Config = require('./config.js').get;
const { Errors } = require('./enig_error.js');
const {
splitTextAtTerms,
isAnsi,
} = require('./string_util.js');
const AnsiPrep = require('./ansi_prep.js');
const Log = require('./logger.js').log;
// deps
const _ = require('lodash');
const async = require('async');
const fs = require('graceful-fs');
const paths = require('path');
const iconv = require('iconv-lite');
const moment = require('moment');
exports.exportFileList = exportFileList;
exports.updateFileBaseDescFilesScheduledEvent = updateFileBaseDescFilesScheduledEvent;
function exportFileList(filterCriteria, options, cb) {
options.templateEncoding = options.templateEncoding || 'utf8';
options.entryTemplate = options.entryTemplate || 'descript_ion_export_entry_template.asc';
options.tsFormat = options.tsFormat || 'YYYY-MM-DD';
options.descWidth = options.descWidth || 45; // FILE_ID.DIZ spec
options.escapeDesc = _.get(options, 'escapeDesc', false); // escape \r and \n in desc?
if(true === options.escapeDesc) {
options.escapeDesc = '\\n';
}
const state = {
total : 0,
current : 0,
step : 'preparing',
status : 'Preparing',
};
const updateProgress = _.isFunction(options.progress) ?
progCb => {
return options.progress(state, progCb);
} :
progCb => {
return progCb(null);
}
;
async.waterfall(
[
function readTemplateFiles(callback) {
updateProgress(err => {
if(err) {
return callback(err);
}
const templateFiles = [
{ name : options.headerTemplate, req : false },
{ name : options.entryTemplate, req : true }
];
const config = Config();
async.map(templateFiles, (template, nextTemplate) => {
if(!template.name && !template.req) {
return nextTemplate(null, Buffer.from([]));
}
template.name = paths.isAbsolute(template.name) ? template.name : paths.join(config.paths.misc, template.name);
fs.readFile(template.name, (err, data) => {
return nextTemplate(err, data);
});
}, (err, templates) => {
if(err) {
return callback(Errors.General(err.message));
}
// decode + ensure DOS style CRLF
templates = templates.map(tmp => iconv.decode(tmp, options.templateEncoding).replace(/\r?\n/g, '\r\n') );
// Look for the first {fileDesc} (if any) in 'entry' template & find indentation requirements
let descIndent = 0;
if(!options.escapeDesc) {
splitTextAtTerms(templates[1]).some(line => {
const pos = line.indexOf('{fileDesc}');
if(pos > -1) {
descIndent = pos;
return true; // found it!
}
return false; // keep looking
});
}
return callback(null, templates[0], templates[1], descIndent);
});
});
},
function findFiles(headerTemplate, entryTemplate, descIndent, callback) {
state.step = 'gathering';
state.status = 'Gathering files for supplied criteria';
updateProgress(err => {
if(err) {
return callback(err);
}
FileEntry.findFiles(filterCriteria, (err, fileIds) => {
if(0 === fileIds.length) {
return callback(Errors.General('No results for criteria', 'NORESULTS'));
}
return callback(err, headerTemplate, entryTemplate, descIndent, fileIds);
});
});
},
function buildListEntries(headerTemplate, entryTemplate, descIndent, fileIds, callback) {
const formatObj = {
totalFileCount : fileIds.length,
};
let current = 0;
let listBody = '';
const totals = { fileCount : fileIds.length, bytes : 0 };
state.total = fileIds.length;
state.step = 'file';
async.eachSeries(fileIds, (fileId, nextFileId) => {
const fileInfo = new FileEntry();
current += 1;
fileInfo.load(fileId, err => {
if(err) {
return nextFileId(null); // failed, but try the next
}
totals.bytes += fileInfo.meta.byte_size;
const appendFileInfo = () => {
if(options.escapeDesc) {
formatObj.fileDesc = formatObj.fileDesc.replace(/\r?\n/g, options.escapeDesc);
}
if(options.maxDescLen) {
formatObj.fileDesc = formatObj.fileDesc.slice(0, options.maxDescLen);
}
listBody += stringFormat(entryTemplate, formatObj);
state.current = current;
state.status = `Processing ${fileInfo.fileName}`;
state.fileInfo = formatObj;
updateProgress(err => {
return nextFileId(err);
});
};
const area = FileArea.getFileAreaByTag(fileInfo.areaTag);
formatObj.fileId = fileId;
formatObj.areaName = _.get(area, 'name') || 'N/A';
formatObj.areaDesc = _.get(area, 'desc') || 'N/A';
formatObj.userRating = fileInfo.userRating || 0;
formatObj.fileName = fileInfo.fileName;
formatObj.fileSize = fileInfo.meta.byte_size;
formatObj.fileDesc = fileInfo.desc || '';
formatObj.fileDescShort = formatObj.fileDesc.slice(0, options.descWidth);
formatObj.fileSha256 = fileInfo.fileSha256;
formatObj.fileCrc32 = fileInfo.meta.file_crc32;
formatObj.fileMd5 = fileInfo.meta.file_md5;
formatObj.fileSha1 = fileInfo.meta.file_sha1;
formatObj.uploadBy = fileInfo.meta.upload_by_username || 'N/A';
formatObj.fileUploadTs = moment(fileInfo.uploadTimestamp).format(options.tsFormat);
formatObj.fileHashTags = fileInfo.hashTags.size > 0 ? Array.from(fileInfo.hashTags).join(', ') : 'N/A';
formatObj.currentFile = current;
formatObj.progress = Math.floor( (current / fileIds.length) * 100 );
if(isAnsi(fileInfo.desc)) {
AnsiPrep(
fileInfo.desc,
{
cols : Math.min(options.descWidth, 79 - descIndent),
forceLineTerm : true, // ensure each line is term'd
asciiMode : true, // export to ASCII
fillLines : false, // don't fill up to |cols|
indent : descIndent,
},
(err, desc) => {
if(desc) {
formatObj.fileDesc = desc;
}
return appendFileInfo();
}
);
} else {
const indentSpc = descIndent > 0 ? ' '.repeat(descIndent) : '';
formatObj.fileDesc = splitTextAtTerms(formatObj.fileDesc).join(`\r\n${indentSpc}`) + '\r\n';
return appendFileInfo();
}
});
}, err => {
return callback(err, listBody, headerTemplate, totals);
});
},
function buildHeader(listBody, headerTemplate, totals, callback) {
// header is built last such that we can have totals/etc.
let filterAreaName;
let filterAreaDesc;
if(filterCriteria.areaTag) {
const area = FileArea.getFileAreaByTag(filterCriteria.areaTag);
filterAreaName = _.get(area, 'name') || 'N/A';
filterAreaDesc = _.get(area, 'desc') || 'N/A';
} else {
filterAreaName = '-ALL-';
filterAreaDesc = 'All areas';
}
const headerFormatObj = {
nowTs : moment().format(options.tsFormat),
boardName : Config().general.boardName,
totalFileCount : totals.fileCount,
totalFileSize : totals.bytes,
filterAreaTag : filterCriteria.areaTag || '-ALL-',
filterAreaName : filterAreaName,
filterAreaDesc : filterAreaDesc,
filterTerms : filterCriteria.terms || '(none)',
filterHashTags : filterCriteria.tags || '(none)',
};
listBody = stringFormat(headerTemplate, headerFormatObj) + listBody;
return callback(null, listBody);
},
function done(listBody, callback) {
delete state.fileInfo;
state.step = 'finished';
state.status = 'Finished processing';
updateProgress( () => {
return callback(null, listBody);
});
}
], (err, listBody) => {
return cb(err, listBody);
}
);
}
function updateFileBaseDescFilesScheduledEvent(args, cb) {
//
// For each area, loop over storage locations and build
// DESCRIPT.ION file to store in the same directory.
//
// Standard-ish 4DOS spec is as such:
// * Entry: <QUOTED_LFN> <DESC>[0x04<AppData>]\r\n
// * Multi line descriptions are stored with *escaped* \r\n pairs
// * Default template uses 0x2c for <AppData> as per https://stackoverflow.com/questions/1810398/descript-ion-file-spec
//
const entryTemplate = args[0];
const headerTemplate = args[1];
const areas = FileArea.getAvailableFileAreas(null, { skipAcsCheck : true });
async.each(areas, (area, nextArea) => {
const storageLocations = FileArea.getAreaStorageLocations(area);
async.each(storageLocations, (storageLoc, nextStorageLoc) => {
const filterCriteria = {
areaTag : area.areaTag,
storageTag : storageLoc.storageTag,
};
const exportOpts = {
headerTemplate : headerTemplate,
entryTemplate : entryTemplate,
escapeDesc : true, // escape CRLF's
maxDescLen : 4096, // DESCRIPT.ION: "The line length limit is 4096 bytes"
};
exportFileList(filterCriteria, exportOpts, (err, listBody) => {
const descIonPath = paths.join(storageLoc.dir, 'DESCRIPT.ION');
fs.writeFile(descIonPath, iconv.encode(listBody, 'cp437'), err => {
if(err) {
Log.warn( { error : err.message, path : descIonPath }, 'Failed (re)creating DESCRIPT.ION');
} else {
Log.debug( { path : descIonPath }, '(Re)generated DESCRIPT.ION');
}
return nextStorageLoc(null);
});
});
}, () => {
return nextArea(null);
});
}, () => {
return cb(null);
});
}

View File

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

@ -8,10 +8,9 @@ const DownloadQueue = require('./download_queue.js');
const theme = require('./theme.js'); const theme = require('./theme.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const stringFormat = require('./string_format.js');
const FileAreaWeb = require('./file_area_web.js'); const FileAreaWeb = require('./file_area_web.js');
const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled;
const Config = require('./config.js').config; const Config = require('./config.js').get;
// deps // deps
const async = require('async'); const async = require('async');
@ -121,11 +120,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
return cb(Errors.DoesNotExist('Queue view does not exist')); return cb(Errors.DoesNotExist('Queue view does not exist'));
} }
const queueListFormat = this.menuConfig.config.queueListFormat || '{webDlLink}'; queueView.setItems(this.dlQueue.items);
const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) );
queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) );
queueView.on('index update', idx => { queueView.on('index update', idx => {
const fileEntry = this.dlQueue.items[idx]; const fileEntry = this.dlQueue.items[idx];
@ -139,7 +134,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
} }
generateAndDisplayBatchLink(cb) { generateAndDisplayBatchLink(cb) {
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes');
FileAreaWeb.createAndServeTempBatchDownload( FileAreaWeb.createAndServeTempBatchDownload(
this.client, this.client,
@ -183,6 +178,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
function prepareQueueDownloadLinks(callback) { function prepareQueueDownloadLinks(callback) {
const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
const config = Config();
async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => { async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => {
FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => { FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => {
if(err) { if(err) {
@ -190,7 +186,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
return nextFileEntry(err); // we should have caught this prior return nextFileEntry(err); // we should have caught this prior
} }
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); const expireTime = moment().add(config.fileBase.web.expireMinutes, 'minutes');
FileAreaWeb.createAndServeTempDownload( FileAreaWeb.createAndServeTempDownload(
self.client, self.client,
@ -284,4 +280,3 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
); );
} }
}; };

View File

@ -3,8 +3,11 @@
const fileDb = require('./database.js').dbs.file; const fileDb = require('./database.js').dbs.file;
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const getISOTimestampString = require('./database.js').getISOTimestampString; const {
const Config = require('./config.js').config; getISOTimestampString,
sanitizeString
} = require('./database.js');
const Config = require('./config.js').get;
// deps // deps
const async = require('async'); const async = require('async');
@ -35,6 +38,9 @@ const FILE_WELL_KNOWN_META = {
tic_origin : null, // TIC "Origin" tic_origin : null, // TIC "Origin"
tic_desc : null, // TIC "Desc" tic_desc : null, // TIC "Desc"
tic_ldesc : null, // TIC "Ldesc" joined by '\n' tic_ldesc : null, // TIC "Ldesc" joined by '\n'
session_temp_dl : (v) => parseInt(v) ? true : false,
desc_sauce : (s) => JSON.parse(s) || {},
desc_long_sauce : (s) => JSON.parse(s) || {},
}; };
module.exports = class FileEntry { module.exports = class FileEntry {
@ -43,11 +49,7 @@ module.exports = class FileEntry {
this.fileId = options.fileId || 0; this.fileId = options.fileId || 0;
this.areaTag = options.areaTag || ''; this.areaTag = options.areaTag || '';
this.meta = options.meta || { this.meta = Object.assign( { dl_count : 0 }, options.meta);
// values we always want
dl_count : 0,
};
this.hashTags = options.hashTags || new Set(); this.hashTags = options.hashTags || new Set();
this.fileName = options.fileName; this.fileName = options.fileName;
this.storageTag = options.storageTag; this.storageTag = options.storageTag;
@ -202,7 +204,8 @@ module.exports = class FileEntry {
} }
static getAreaStorageDirectoryByTag(storageTag) { static getAreaStorageDirectoryByTag(storageTag) {
const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]); const config = Config();
const storageLocation = (storageTag && config.fileBase.storageTags[storageTag]);
// absolute paths as-is // absolute paths as-is
if(storageLocation && '/' === storageLocation.charAt(0)) { if(storageLocation && '/' === storageLocation.charAt(0)) {
@ -210,7 +213,7 @@ module.exports = class FileEntry {
} }
// relative to |areaStoragePrefix| // relative to |areaStoragePrefix|
return paths.join(Config.fileBase.areaStoragePrefix, storageLocation || ''); return paths.join(config.fileBase.areaStoragePrefix, storageLocation || '');
} }
get filePath() { get filePath() {
@ -218,6 +221,19 @@ module.exports = class FileEntry {
return paths.join(storageDir, this.fileName); return paths.join(storageDir, this.fileName);
} }
static quickCheckExistsByPath(fullPath, cb) {
fileDb.get(
`SELECT COUNT() AS count
FROM file
WHERE file_name = ?
LIMIT 1;`,
[ paths.basename(fullPath) ],
(err, rows) => {
return err ? cb(err) : cb(null, rows.count > 0 ? true : false);
}
);
}
static persistUserRating(fileId, userId, rating, cb) { static persistUserRating(fileId, userId, rating, cb) {
return fileDb.run( return fileDb.run(
`REPLACE INTO file_user_rating (file_id, user_id, rating) `REPLACE INTO file_user_rating (file_id, user_id, rating)
@ -355,7 +371,7 @@ module.exports = class FileEntry {
return Object.keys(FILE_WELL_KNOWN_META); return Object.keys(FILE_WELL_KNOWN_META);
} }
static findFileBySha(sha, cb) { static findBySha(sha, cb) {
// full or partial SHA-256 // full or partial SHA-256
fileDb.all( fileDb.all(
`SELECT file_id `SELECT file_id
@ -383,6 +399,29 @@ module.exports = class FileEntry {
); );
} }
// Attempt to fine a file by an *existing* full path.
// Checkums may have changed and are not validated here.
static findByFullPath(fullPath, cb) {
// first, basic by-filename lookup.
FileEntry.findByFileNameWildcard(paths.basename(fullPath), (err, entries) => {
if(err) {
return cb(err);
}
if(!entries || !entries.length || entries.length > 1) {
return cb(Errors.DoesNotExist('No matches'));
}
// ensure the *full* path has not changed
// :TODO: if FS is case-insensitive, we probably want a better check here
const possibleMatch = entries[0];
if(possibleMatch.fullPath === fullPath) {
return cb(null, possibleMatch);
}
return cb(Errors.DoesNotExist('No matches'));
});
}
static findByFileNameWildcard(wc, cb) { static findByFileNameWildcard(wc, cb) {
// convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
wc = wc.replace(/\*/g, '%').replace(/\?/g, '_'); wc = wc.replace(/\*/g, '%').replace(/\?/g, '_');
@ -470,7 +509,7 @@ module.exports = class FileEntry {
sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`; sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`;
} else { } else {
sql = sql =
`SELECT DISTINCT f.file_id, f.${filter.sort} `SELECT DISTINCT f.file_id
FROM file f`; FROM file f`;
sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir; sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir;
@ -496,7 +535,7 @@ module.exports = class FileEntry {
if(filter.metaPairs && filter.metaPairs.length > 0) { if(filter.metaPairs && filter.metaPairs.length > 0) {
filter.metaPairs.forEach(mp => { filter.metaPairs.forEach(mp => {
if(mp.wcValue) { if(mp.wildcards) {
// convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html // convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
mp.value = mp.value.replace(/\*/g, '%').replace(/\?/g, '_'); mp.value = mp.value.replace(/\*/g, '%').replace(/\?/g, '_');
appendWhereClause( appendWhereClause(
@ -523,18 +562,19 @@ module.exports = class FileEntry {
} }
if(filter.terms && filter.terms.length > 0) { if(filter.terms && filter.terms.length > 0) {
// note the ':' in MATCH expr., see https://www.sqlite.org/cvstrac/wiki?p=FullTextIndex
appendWhereClause( appendWhereClause(
`f.file_id IN ( `f.file_id IN (
SELECT rowid SELECT rowid
FROM file_fts FROM file_fts
WHERE file_fts MATCH "${filter.terms.replace(/"/g,'""')}" WHERE file_fts MATCH ":${sanitizeString(filter.terms)}"
)` )`
); );
} }
if(filter.tags && filter.tags.length > 0) { if(filter.tags && filter.tags.length > 0) {
// build list of quoted tags; filter.tags comes in as a space and/or comma separated values // build list of quoted tags; filter.tags comes in as a space and/or comma separated values
const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${tag}"` ).join(','); const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${sanitizeString(tag)}"` ).join(',');
appendWhereClause( appendWhereClause(
`f.file_id IN ( `f.file_id IN (
@ -565,13 +605,14 @@ module.exports = class FileEntry {
sql += ';'; sql += ';';
const matchingFileIds = []; fileDb.all(sql, (err, rows) => {
fileDb.each(sql, (err, fileId) => { if(err) {
if(fileId) { return cb(err);
matchingFileIds.push(fileId.file_id);
} }
}, err => { if(!rows || 0 === rows.length) {
return cb(err, matchingFileIds); return cb(null, []); // no matches
}
return cb(null, rows.map(r => r.file_id));
}); });
} }

View File

@ -3,18 +3,21 @@
// enigma-bbs // enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const Config = require('./config.js').config; const Config = require('./config.js').get;
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const Errors = require('./enig_error.js').Errors; const Errors = require('./enig_error.js').Errors;
const DownloadQueue = require('./download_queue.js'); const DownloadQueue = require('./download_queue.js');
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
const FileEntry = require('./file_entry.js'); const FileEntry = require('./file_entry.js');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const Events = require('./events.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const pty = require('ptyw.js'); const pty = require('node-pty');
const temptmp = require('temptmp').createTrackedSession('transfer_file'); const temptmp = require('temptmp').createTrackedSession('transfer_file');
const paths = require('path'); const paths = require('path');
const fs = require('graceful-fs'); const fs = require('graceful-fs');
@ -55,9 +58,10 @@ exports.getModule = class TransferFileModule extends MenuModule {
// //
// Most options can be set via extraArgs or config block // Most options can be set via extraArgs or config block
// //
const config = Config();
if(options.extraArgs) { if(options.extraArgs) {
if(options.extraArgs.protocol) { if(options.extraArgs.protocol) {
this.protocolConfig = Config.fileTransferProtocols[options.extraArgs.protocol]; this.protocolConfig = config.fileTransferProtocols[options.extraArgs.protocol];
} }
if(options.extraArgs.direction) { if(options.extraArgs.direction) {
@ -77,7 +81,7 @@ exports.getModule = class TransferFileModule extends MenuModule {
} }
} else { } else {
if(this.config.protocol) { if(this.config.protocol) {
this.protocolConfig = Config.fileTransferProtocols[this.config.protocol]; this.protocolConfig = config.fileTransferProtocols[this.config.protocol];
} }
if(this.config.direction) { if(this.config.direction) {
@ -97,7 +101,7 @@ exports.getModule = class TransferFileModule extends MenuModule {
} }
} }
this.protocolConfig = this.protocolConfig || Config.fileTransferProtocols.zmodem8kSz; // try for *something* this.protocolConfig = this.protocolConfig || config.fileTransferProtocols.zmodem8kSz; // try for *something*
this.direction = this.direction || 'send'; this.direction = this.direction || 'send';
this.sendQueue = this.sendQueue || []; this.sendQueue = this.sendQueue || [];
@ -312,11 +316,15 @@ exports.getModule = class TransferFileModule extends MenuModule {
return callback(err); // failed to create it return callback(err); // failed to create it
} }
fs.write(tempFileInfo.fd, filePaths.join(SYSTEM_EOL)); fs.write(tempFileInfo.fd, filePaths.join(SYSTEM_EOL), err => {
if(err) {
return callback(err);
}
fs.close(tempFileInfo.fd, err => { fs.close(tempFileInfo.fd, err => {
return callback(err, tempFileInfo.path); return callback(err, tempFileInfo.path);
}); });
}); });
});
}, },
function createArgs(tempFileListPath, callback) { function createArgs(tempFileListPath, callback) {
// initial args: ignore {filePaths} as we must break that into it's own sep array items // initial args: ignore {filePaths} as we must break that into it's own sep array items
@ -361,17 +369,20 @@ exports.getModule = class TransferFileModule extends MenuModule {
'Executing external protocol' 'Executing external protocol'
); );
const externalProc = pty.spawn(cmd, args, { const spawnOpts = {
cols : this.client.term.termWidth, cols : this.client.term.termWidth,
rows : this.client.term.termHeight, rows : this.client.term.termHeight,
cwd : this.recvDirectory, cwd : this.recvDirectory,
}); encoding : null, // don't bork our data!
};
const externalProc = pty.spawn(cmd, args, spawnOpts);
this.client.setTemporaryDirectDataHandler(data => { this.client.setTemporaryDirectDataHandler(data => {
// needed for things like sz/rz // needed for things like sz/rz
if(external.escapeTelnet) { if(external.escapeTelnet) {
const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape
externalProc.write(new Buffer(tmp, 'binary')); externalProc.write(Buffer.from(tmp, 'binary'));
} else { } else {
externalProc.write(data); externalProc.write(data);
} }
@ -381,7 +392,7 @@ exports.getModule = class TransferFileModule extends MenuModule {
// needed for things like sz/rz // needed for things like sz/rz
if(external.escapeTelnet) { if(external.escapeTelnet) {
const tmp = data.toString('binary').replace(/\xff/g, '\xff\xff'); // escape const tmp = data.toString('binary').replace(/\xff/g, '\xff\xff'); // escape
this.client.term.rawWrite(new Buffer(tmp, 'binary')); this.client.term.rawWrite(Buffer.from(tmp, 'binary'));
} else { } else {
this.client.term.rawWrite(data); this.client.term.rawWrite(data);
} }
@ -470,10 +481,11 @@ exports.getModule = class TransferFileModule extends MenuModule {
}); });
}, () => { }, () => {
// All stats/meta currently updated via fire & forget - if this is ever a issue, we can wait for callbacks // All stats/meta currently updated via fire & forget - if this is ever a issue, we can wait for callbacks
StatLog.incrementUserStat(this.client.user, 'dl_total_count', downloadCount); StatLog.incrementUserStat(this.client.user, UserProps.FileDlTotalCount, downloadCount);
StatLog.incrementUserStat(this.client.user, 'dl_total_bytes', downloadBytes); StatLog.incrementUserStat(this.client.user, UserProps.FileDlTotalBytes, downloadBytes);
StatLog.incrementSystemStat('dl_total_count', downloadCount);
StatLog.incrementSystemStat('dl_total_bytes', downloadBytes); StatLog.incrementSystemStat(SysProps.FileDlTotalCount, downloadCount);
StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, downloadBytes);
fileIds.forEach(fileId => { fileIds.forEach(fileId => {
FileEntry.incrementAndPersistMetaValue(fileId, 'dl_count', 1); FileEntry.incrementAndPersistMetaValue(fileId, 'dl_count', 1);
@ -500,10 +512,11 @@ exports.getModule = class TransferFileModule extends MenuModule {
return next(null); return next(null);
}); });
}, () => { }, () => {
StatLog.incrementUserStat(this.client.user, 'ul_total_count', uploadCount); StatLog.incrementUserStat(this.client.user, UserProps.FileUlTotalCount, uploadCount);
StatLog.incrementUserStat(this.client.user, 'ul_total_bytes', uploadBytes); StatLog.incrementUserStat(this.client.user, UserProps.FileUlTotalBytes, uploadBytes);
StatLog.incrementSystemStat('ul_total_count', uploadCount);
StatLog.incrementSystemStat('ul_total_bytes', uploadBytes); StatLog.incrementSystemStat(SysProps.FileUlTotalCount, uploadCount);
StatLog.incrementSystemStat(SysProps.FileUlTotalBytes, uploadBytes);
return cb(null); return cb(null);
}); });
@ -542,7 +555,16 @@ exports.getModule = class TransferFileModule extends MenuModule {
if(sentFileIds.length > 0) { if(sentFileIds.length > 0) {
// remove items we sent from the D/L queue // remove items we sent from the D/L queue
const dlQueue = new DownloadQueue(self.client); const dlQueue = new DownloadQueue(self.client);
dlQueue.removeItems(sentFileIds); const dlFileEntries = dlQueue.removeItems(sentFileIds);
// fire event for downloaded entries
Events.emit(
Events.getSystemEvents().UserDownload,
{
user : self.client.user,
files : dlFileEntries
}
);
self.sentFileIds = sentFileIds; self.sentFileIds = sentFileIds;
} }

View File

@ -3,8 +3,7 @@
// enigma-bbs // enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
const Config = require('./config.js').config; const Config = require('./config.js').get;
const stringFormat = require('./string_format.js');
const ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
// deps // deps
@ -110,12 +109,7 @@ exports.getModule = class FileTransferProtocolSelectModule extends MenuModule {
function populateList(callback) { function populateList(callback) {
const protListView = vc.getView(MciViewIds.protList); const protListView = vc.getView(MciViewIds.protList);
const protListFormat = self.config.protListFormat || '{name}'; protListView.setItems(self.protocols);
const protListFocusFormat = self.config.protListFocusFormat || protListFormat;
protListView.setItems(self.protocols.map(p => stringFormat(protListFormat, p) ) );
protListView.setFocusItems(self.protocols.map(p => stringFormat(protListFocusFormat, p) ) );
protListView.redraw(); protListView.redraw();
return callback(null); return callback(null);
@ -129,8 +123,9 @@ exports.getModule = class FileTransferProtocolSelectModule extends MenuModule {
} }
loadAvailProtocols() { loadAvailProtocols() {
this.protocols = _.map(Config.fileTransferProtocols, (protInfo, protocol) => { this.protocols = _.map(Config().fileTransferProtocols, (protInfo, protocol) => {
return { return {
text : protInfo.name, // standard
protocol : protocol, protocol : protocol,
name : protInfo.name, name : protInfo.name,
hasBatch : _.has(protInfo, 'external.recvArgs'), hasBatch : _.has(protInfo, 'external.recvArgs'),

View File

@ -51,7 +51,7 @@ function moveOrCopyFileWithCollisionHandling(src, dst, operation, cb) {
if(err) { if(err) {
// for some reason fs-extra copy doesn't pass err.code // for some reason fs-extra copy doesn't pass err.code
// :TODO: this is dangerous: submit a PR to fs-extra to set EEXIST // :TODO: this is dangerous: submit a PR to fs-extra to set EEXIST
if('EEXIST' === err.code || 'copy' === operation) { if('EEXIST' === err.code || 'dest already exists.' === err.message) {
renameIndex += 1; renameIndex += 1;
return cb(null); // keep trying return cb(null); // keep trying
} }

311
core/files_bbs_file.js Normal file
View File

@ -0,0 +1,311 @@
/* jslint node: true */
'use strict';
const { Errors } = require('./enig_error.js');
// deps
const fs = require('graceful-fs');
const iconv = require('iconv-lite');
const moment = require('moment');
// Descriptions found in the wild that mean "no description" /facepalm.
const IgnoredDescriptions = [
'No description available',
'No ID File Found For This Archive File.',
];
module.exports = class FilesBBSFile {
constructor() {
this.entries = new Map();
}
get(fileName) {
return this.entries.get(fileName);
}
getDescription(fileName) {
const entry = this.get(fileName);
if(entry) {
return entry.desc;
}
}
static createFromFile(path, cb) {
fs.readFile(path, (err, descData) => {
if(err) {
return cb(err);
}
// :TODO: encoding should be default to CP437, but allowed to change - ie for Amiga/etc.
const lines = iconv.decode(descData, 'cp437').split(/\r?\n/g);
const filesBbs = new FilesBBSFile();
const isBadDescription = (desc) => {
return IgnoredDescriptions.find(d => desc.startsWith(d)) ? true : false;
};
//
// Contrary to popular belief, there is not a FILES.BBS standard. Instead,
// many formats have been used over the years. We'll try to support as much
// as we can within reason.
//
// Resources:
// - Great info from Mystic @ http://wiki.mysticbbs.com/doku.php?id=mutil_import_files.bbs
// - https://alt.bbs.synchronet.narkive.com/I6Vrxq6q/format-of-files-bbs
//
// Example files:
// - https://github.com/NuSkooler/ansi-bbs/tree/master/ancient_formats/files_bbs
//
const detectDecoder = () => {
// helpers
const regExpTestUpTo = (n, re) => {
return lines
.slice(0, n)
.some(l => re.test(l));
};
//
// Try to figure out which decoder to use
//
const decoders = [
{
// I've been told this is what Syncrhonet uses
lineRegExp : /^([^ ]{1,12})\s{1,11}([0-3][0-9]\/[0-3][0-9]\/[1789][0-9]) ([^\r\n]+)$/,
detect : function() {
return regExpTestUpTo(10, this.lineRegExp);
},
extract : function() {
for(let i = 0; i < lines.length; ++i) {
let line = lines[i];
const hdr = line.match(this.lineRegExp);
if(!hdr) {
continue;
}
const long = [];
for(let j = i + 1; j < lines.length; ++j) {
line = lines[j];
if(!line.startsWith(' ')) {
break;
}
long.push(line.trim());
++i;
}
const desc = long.join('\r\n') || hdr[3] || '';
const fileName = hdr[1];
const timestamp = moment(hdr[2], 'MM/DD/YY');
if(isBadDescription(desc) || !timestamp.isValid()) {
continue;
}
filesBbs.entries.set(fileName, { timestamp, desc } );
}
}
},
{
//
// Examples:
// - Night Owl CD #7, 1992
//
lineRegExp : /^([^\s]{1,12})\s{2,14}\[0\]\s\s([^\r\n]+)$/,
detect : function() {
return regExpTestUpTo(10, this.lineRegExp);
},
extract : function() {
for(let i = 0; i < lines.length; ++i) {
let line = lines[i];
const hdr = line.match(this.lineRegExp);
if(!hdr) {
continue;
}
const long = [ hdr[2].trim() ];
for(let j = i + 1; j < lines.length; ++j) {
line = lines[j];
// -------------------------------------------------v 32
if(!line.startsWith(' | ')) {
break;
}
long.push(line.substr(33));
++i;
}
const desc = long.join('\r\n');
const fileName = hdr[1];
if(isBadDescription(desc)) {
continue;
}
filesBbs.entries.set(fileName, { desc } );
}
}
},
{
//
// Simple first line with partial description,
// secondary description lines tabbed out.
//
// Examples
// - GUS archive @ dk.toastednet.org
//
lineRegExp : /^([^\s]{1,12})\s+\[00\]\s([^\r\n]+)$/,
detect : function() {
return regExpTestUpTo(10, this.lineRegExp);
},
extract : function() {
for(let i = 0; i < lines.length; ++i) {
let line = lines[i];
const hdr = line.match(this.lineRegExp);
if(!hdr) {
continue;
}
const long = [ hdr[2].trimRight() ];
for(let j = i + 1; j < lines.length; ++j) {
line = lines[j];
if(!line.startsWith('\t\t ')) {
break;
}
long.push(line.substr(4));
++i;
}
const desc = long.join('\r\n');
const fileName = hdr[1];
if(isBadDescription(desc)) {
continue;
}
filesBbs.entries.set(fileName, { desc } );
}
}
},
{
//
// <8.3FileName> <size> <MM-DD-YY> <desc first line>
// <desc...>
// Examples:
// - Expanding Your BBS CD by David Wolfe, 1995
//
lineRegExp : /^([^ ]{1,12})\s{1,20}([0-9]+)\s\s([0-3][0-9]-[0-3][0-9]-[1789][0-9])\s\s([^\r\n]+)$/,
detect : function() {
return regExpTestUpTo(10, this.lineRegExp);
},
extract : function() {
for(let i = 0; i < lines.length; ++i) {
let line = lines[i];
const hdr = line.match(this.lineRegExp);
if(!hdr) {
continue;
}
const firstDescLine = hdr[4].trimRight();
const long = [ firstDescLine ];
for(let j = i + 1; j < lines.length; ++j) {
line = lines[j];
if(!line.startsWith(' '.repeat(34))) {
break;
}
long.push(line.substr(34).trimRight());
++i;
}
const desc = long.join('\r\n');
const fileName = hdr[1];
const size = parseInt(hdr[2]);
const timestamp = moment(hdr[3], 'MM-DD-YY');
if(isBadDescription(desc) || isNaN(size) || !timestamp.isValid()) {
continue;
}
filesBbs.entries.set(fileName, { desc, size, timestamp });
}
}
},
{
//
// Examples:
// - Aminet Amiga CDROM, March 1994. Walnut Creek CDROM.
// - CP/M CDROM, Sep. 1994. Walnut Creek CDROM.
// - ...and many others.
//
// Basically: <8.3 filename> <description>
//
// May contain headers, but we'll just skip 'em.
//
lineRegExp : /^([^ ]{1,12})\s{1,11}([^\r\n]+)$/,
detect : function() {
return regExpTestUpTo(10, this.lineRegExp);
},
extract : function() {
lines.forEach(line => {
const hdr = line.match(this.lineRegExp);
if(!hdr) {
return; // forEach
}
const fileName = hdr[1].trim();
const desc = hdr[2].trim();
if(desc && !isBadDescription(desc)) {
filesBbs.entries.set(fileName, { desc } );
}
});
}
},
{
//
// Examples:
// - AMINET CD's & similar
//
lineRegExp : /^(.{1,22}) ([0-9]+)K ([^\r\n]+)$/,
detect : function() {
return regExpTestUpTo(10, this.lineRegExp);
},
extract : function() {
lines.forEach(line => {
const hdr = line.match(this.tester);
if(!hdr) {
return; // forEach
}
const fileName = hdr[1].trim();
let size = parseInt(hdr[2]);
const desc = hdr[3].trim();
if(isNaN(size)) {
return; // forEach
}
size *= 1024; // K->bytes.
if(desc) { // omit empty entries
filesBbs.entries.set(fileName, { size, desc } );
}
});
}
},
];
const decoder = decoders.find(d => d.detect());
return decoder;
};
const decoder = detectDecoder();
if(!decoder) {
return cb(Errors.Invalid('Invalid or unrecognized FILES.BBS format'));
}
decoder.extract(decoder);
return cb(
filesBbs.entries.size > 0 ? null : Errors.Invalid('Invalid or unrecognized FILES.BBS format'),
filesBbs
);
});
}
};

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