Merge 0.0.9-alpha into develop

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

2
.eslintignore Normal file
View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

469
config/achievements.hjson Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,22 @@
/* jslint node: true */
'use strict';
const MenuModule = require('./menu_module.js').MenuModule;
const DropFile = require('./dropfile.js').DropFile;
const door = require('./door.js');
const { MenuModule } = require('./menu_module.js');
const DropFile = require('./dropfile.js');
const Door = require('./door.js');
const theme = require('./theme.js');
const ansi = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
const assert = require('assert');
const paths = require('path');
const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs;
// :TODO: This should really be a system module... needs a little work to allow for such
const paths = require('path');
const activeDoorNodeInstances = {};
@ -65,6 +68,7 @@ exports.getModule = class AbracadabraModule extends MenuModule {
this.config = options.menuConfig.config;
// :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... }
// .. and/or EnigAssert
assert(_.isString(this.config.name, 'Config \'name\' is required'));
assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required'));
assert(_.isString(this.config.cmd, 'Config \'cmd\' is required'));
@ -100,7 +104,7 @@ exports.getModule = class AbracadabraModule extends MenuModule {
if(_.isString(self.config.tooManyArt)) {
theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() {
self.pausePrompt( () => {
callback(new Error('Too many active instances'));
return callback(Errors.AccessDenied('Too many active instances'));
});
});
} else {
@ -108,7 +112,7 @@ exports.getModule = class AbracadabraModule extends MenuModule {
// :TODO: Use MenuModule.pausePrompt()
self.pausePrompt( () => {
callback(new Error('Too many active instances'));
return callback(Errors.AccessDenied('Too many active instances'));
});
}
} else {
@ -122,19 +126,17 @@ exports.getModule = class AbracadabraModule extends MenuModule {
callback(null);
}
},
function prepareDoor(callback) {
self.doorInstance = new Door(self.client);
return self.doorInstance.prepare(self.config.io || 'stdio', callback);
},
function generateDropfile(callback) {
self.dropFile = new DropFile(self.client, self.config.dropFileType);
var fullPath = self.dropFile.fullPath;
const dropFileOpts = {
fileType : self.config.dropFileType,
};
mkdirs(paths.dirname(fullPath), function dirCreated(err) {
if(err) {
callback(err);
} else {
self.dropFile.createFile(function created(err) {
callback(err);
});
}
});
self.dropFile = new DropFile(self.client, dropFileOpts);
return self.dropFile.createFile(callback);
}
],
function complete(err) {
@ -150,20 +152,24 @@ exports.getModule = class AbracadabraModule extends MenuModule {
}
runDoor() {
this.client.term.write(ansi.resetScreen());
const exeInfo = {
cmd : this.config.cmd,
cwd : this.config.cwd || paths.dirname(this.config.cmd),
args : this.config.args,
io : this.config.io || 'stdio',
encoding : this.config.encoding || this.client.term.outputEncoding,
encoding : this.config.encoding || 'cp437',
dropFile : this.dropFile.fileName,
dropFilePath : this.dropFile.fullPath,
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
// have been set within the door
@ -178,10 +184,6 @@ exports.getModule = class AbracadabraModule extends MenuModule {
this.prevMenu();
});
this.client.term.write(ansi.resetScreen());
doorInstance.run();
}
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');
class ACS {
constructor(client) {
this.client = client;
constructor(subject) {
this.subject = subject;
}
check(acs, scope, defaultAcs) {
acs = acs ? acs[scope] : defaultAcs;
acs = acs || defaultAcs;
try {
return checkAcs(acs, { client : this.client } );
return checkAcs(acs, { subject : this.subject } );
} catch(e) {
Log.warn( { exception : e, acs : acs }, 'Exception caught checking ACS');
return false;
@ -51,20 +51,37 @@ class ACS {
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) {
assert(_.isArray(condArray));
if(!Array.isArray(condArray)) {
// no cond array, just use the value
return condArray;
}
assert(_.isString(memberName));
const matchCond = condArray.find( cond => {
if(_.has(cond, 'acs')) {
try {
return checkAcs(cond.acs, { client : this.client } );
return checkAcs(cond.acs, { subject : this.subject } );
} catch(e) {
Log.warn( { exception : e, acs : cond }, 'Exception caught checking ACS');
return false;
}
} else {
return true; // no acs check req.
return true; // no ACS check req.
}
});

View File

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

View File

@ -3,7 +3,9 @@
const miscUtil = require('./misc_util.js');
const ansi = require('./ansi_term.js');
const Log = require('./logger.js').log;
// deps
const events = require('events');
const util = require('util');
const _ = require('lodash');
@ -24,7 +26,7 @@ function ANSIEscapeParser(options) {
this.graphicRendition = {};
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, {
@ -63,7 +65,7 @@ function ANSIEscapeParser(options) {
delete self.savedPosition;
self.positionUpdated();
// self.rowUpdated();
// self.rowUpdated();
};
self.clearScreen = function() {
@ -71,7 +73,7 @@ function ANSIEscapeParser(options) {
self.emit('clear screen');
};
/*
/*
self.rowUpdated = function() {
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) {
// :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 match;
var mciCode;
@ -197,16 +191,12 @@ function ANSIEscapeParser(options) {
if(self.mciReplaceChar.length > 0) {
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));
} else {
literal(match[0]);
}
//literal(getProcessedMCI(match[0]));
//self.emit('chunk', getProcessedMCI(match[0]));
}
} while(0 !== mciRe.lastIndex);
@ -220,7 +210,7 @@ function ANSIEscapeParser(options) {
self.parseState = {
// ignore anything past EOF marker, if any
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,
};
};
@ -291,13 +281,13 @@ function ANSIEscapeParser(options) {
}
}
parseMCI(lastBit)
parseMCI(lastBit);
}
self.emit('complete');
};
/*
/*
self.parse = function(buffer, savedRe) {
// :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc.
// :TODO: move this to "constants" section @ top
@ -448,7 +438,7 @@ function ANSIEscapeParser(options) {
break;
default :
console.log('Unknown attribute: ' + arg); // :TODO: Log properly
Log.trace( { attribute : arg }, 'Unknown attribute while parsing ANSI');
break;
}
}

View File

@ -17,12 +17,20 @@
// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt
// * 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
// * https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt
//
// General
// * http://en.wikipedia.org/wiki/ANSI_escape_code
// * http://www.inwap.com/pdp10/ansicode.txt
// * Excellent information with many standards covered (for hterm):
// https://chromium.googlesource.com/apps/libapps/+/master/hterm/doc/ControlSequences.md
//
// Other Implementations
// * https://github.com/chjj/term.js/blob/master/src/term.js

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

View File

@ -2,11 +2,12 @@
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
const Config = require('./config.js').get;
const miscUtil = require('./misc_util.js');
const ansi = require('./ansi_term.js');
const aep = require('./ansi_escape_parser.js');
const sauce = require('./sauce.js');
const { Errors } = require('./enig_error.js');
// deps
const fs = require('graceful-fs');
@ -126,7 +127,7 @@ function getArtFromPath(path, options, cb) {
function getArt(name, options, cb) {
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);
// :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 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) {
return cb(new Error('Empty art'));
return cb(Errors.Invalid('No art supplied!'));
}
options.mciReplaceChar = options.mciReplaceChar || ' ';
@ -288,7 +289,7 @@ function display(client, art, options, cb) {
}
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
if(client.mciCache) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,9 +35,12 @@
const term = require('./client_term.js');
const ansi = require('./ansi_term.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 ACS = require('./acs.js');
const Events = require('./events.js');
const UserInterruptQueue = require('./user_interrupt_queue.js');
const UserProps = require('./user_property.js');
// deps
const stream = require('stream');
@ -52,8 +55,9 @@ exports.Client = Client;
// Resources & Standards:
// * http://www.ansi-bbs.org/ansi-bbs-core-server.html
//
const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9\;]+)(R)/;
const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[\=\?]([0-9a-zA-Z\;]+)(c)/;
/* eslint-disable no-control-regex */
const RE_DSR_RESPONSE_ANYWHERE = /(?:\u001b\[)([0-9;]+)(R)/;
const RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[=?]([0-9a-zA-Z;]+)(c)/;
const RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/;
const RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$');
const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [
@ -61,6 +65,7 @@ const RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:'
'(?:M([@ #!a`])(.)(.))', // mouse stuff
'(?:1;)?(\\d+)?([a-zA-Z@])'
].join('|') + ')');
/* eslint-enable no-control-regex */
const RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source);
const RE_ESC_CODE_ANYWHERE = new RegExp( [
@ -68,11 +73,11 @@ const RE_ESC_CODE_ANYWHERE = new RegExp( [
RE_META_KEYCODE_ANYWHERE.source,
RE_DSR_RESPONSE_ANYWHERE.source,
RE_DEV_ATTR_RESPONSE_ANYWHERE.source,
/\u001b./.source
/\u001b./.source // eslint-disable-line no-control-regex
].join('|'));
function Client(input, output) {
function Client(/*input, output*/) {
stream.call(this);
const self = this;
@ -81,8 +86,9 @@ function Client(input, output) {
this.currentTheme = { info : { name : 'N/A', description : 'None' } };
this.lastKeyPressMs = Date.now();
this.menuStack = new MenuStack(this);
this.acs = new ACS(this);
this.acs = new ACS( { client : this, user : this.user } );
this.mciCache = {};
this.interruptQueue = new UserInterruptQueue(this);
this.clearMciCache = function() {
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
@ -138,6 +161,7 @@ function Client(input, output) {
return termClient;
};
/* eslint-disable no-control-regex */
this.isMouseInput = function(data) {
return /\x1b\[M/.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\[(O|I)/.test(data);
};
/* eslint-enable no-control-regex */
this.getKeyComponentsFromCode = function(code) {
return {
@ -383,7 +408,7 @@ function Client(input, output) {
}
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
}
@ -414,47 +439,86 @@ Client.prototype.setTermType = function(termType) {
};
Client.prototype.startIdleMonitor = function() {
var self = this;
self.lastKeyPressMs = Date.now();
this.lastKeyPressMs = Date.now();
//
// 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 idleLogoutSeconds = self.user.isAuthenticated() ?
Config.misc.idleLogoutSeconds :
Config.misc.preAuthIdleLogoutSeconds;
let idleLogoutSeconds;
if(this.user.isAuthenticated()) {
idleLogoutSeconds = Config().users.idleLogoutSeconds;
if(nowMs - self.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
self.emit('idle timeout');
//
// 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);
};
Client.prototype.stopIdleMonitor = function() {
clearInterval(this.idleCheck);
};
Client.prototype.end = function () {
if(this.term) {
this.term.disconnect();
}
var currentModule = this.menuStack.getCurrentModule;
Events.removeListener(Events.getSystemEvents().ThemeChanged, this.themeChangedListener);
const currentModule = this.menuStack.getCurrentModule;
if(currentModule) {
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 {
//
// 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);
}
} catch(e) {
// TypeError
// ie TypeError
}
};
@ -473,8 +537,8 @@ Client.prototype.waitForKeyPress = function(cb) {
};
Client.prototype.isLocal = function() {
// :TODO: return rather client is a local connection or not
return false;
// :TODO: Handle ipv6 better
return [ '127.0.0.1', '::ffff:127.0.0.1' ].includes(this.remoteAddress);
};
///////////////////////////////////////////////////////////////////////////////
@ -482,7 +546,7 @@ Client.prototype.isLocal = function() {
///////////////////////////////////////////////////////////////////////////////
// :TODO: getDefaultHandler(name) -- handlers in default_handlers.js or something
Client.prototype.defaultHandlerMissingMod = function(err) {
Client.prototype.defaultHandlerMissingMod = function() {
var self = this;
function handler(err) {

View File

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

View File

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

View File

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

View File

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

View File

@ -2,17 +2,19 @@
'use strict';
// ENiGMA½
const Errors = require('./enig_error.js').Errors;
// deps
const fs = require('graceful-fs');
const paths = require('path');
const async = require('async');
const _ = require('lodash');
const hjson = require('hjson');
const assert = require('assert');
exports.init = init;
exports.getDefaultPath = getDefaultPath;
exports.getDefaultConfig = getDefaultConfig;
let currentConfiguration = {};
function hasMessageConferenceAndArea(config) {
assert(_.isObject(config.messageConferences)); // we create one ourself!
@ -40,43 +42,53 @@ function hasMessageConferenceAndArea(config) {
return result;
}
function init(configPath, options, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
const ArrayReplaceKeyPaths = [
'loginServers.ssh.algorithms.kex',
'loginServers.ssh.algorithms.cipher',
'loginServers.ssh.algorithms.hmac',
'loginServers.ssh.algorithms.compress',
];
const ArrayReplaceKeys = [
'args',
'sendArgs', 'recvArgs', 'recvArgsNonBatch',
];
function mergeValidateAndFinalize(config, cb) {
const defaultConfig = getDefaultConfig();
const arrayReplaceKeyPathsMutable = _.clone(ArrayReplaceKeyPaths);
const shouldReplaceArray = (arr, key) => {
if(ArrayReplaceKeys.includes(key)) {
return true;
}
for(let i = 0; i < arrayReplaceKeyPathsMutable.length; ++i) {
const o = _.get(defaultConfig, arrayReplaceKeyPathsMutable[i]);
if(_.isEqual(o, arr)) {
arrayReplaceKeyPathsMutable.splice(i, 1);
return true;
}
}
return false;
};
async.waterfall(
[
function loadUserConfig(callback) {
if(!_.isString(configPath)) {
return callback(null, { } );
}
fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => {
if(err) {
return callback(err);
}
let configJson;
try {
configJson = hjson.parse(configData, options);
} catch(e) {
return callback(e);
}
return callback(null, configJson);
});
},
function mergeWithDefaultConfig(configJson, callback) {
function mergeWithDefaultConfig(callback) {
const mergedConfig = _.mergeWith(
getDefaultConfig(),
configJson, (conf1, conf2) => {
// Arrays should always concat
if(_.isArray(conf1)) {
// :TODO: look for collisions & override dupes
return conf1.concat(conf2);
defaultConfig,
config,
(defConfig, userConfig, key) => {
if(Array.isArray(defConfig) && Array.isArray(userConfig)) {
//
// Arrays are special: Some we merge, while others
// we simply replace.
//
if(shouldReplaceArray(defConfig, key)) {
return userConfig;
} else {
return _.uniq(defConfig.concat(userConfig));
}
}
}
);
@ -89,26 +101,65 @@ function init(configPath, options, cb) {
//
// :TODO: Logic is broken here:
if(hasMessageConferenceAndArea(mergedConfig)) {
var msgAreasErr = new Error('Please create at least one message conference and area!');
msgAreasErr.code = 'EBADCONFIG';
return callback(msgAreasErr);
} else {
return callback(null, mergedConfig);
return callback(Errors.MissingConfig('Please create at least one message conference and area!'));
}
return callback(null, mergedConfig);
},
function setIt(mergedConfig, callback) {
// :TODO: .config property is to be deprecated once conversions are done
exports.config = currentConfiguration = mergedConfig;
exports.get = () => currentConfiguration;
return callback(null);
}
],
function complete(err, mergedConfig) {
exports.config = mergedConfig;
exports.config.get = function(path) {
return _.get(exports.config, path);
};
err => {
if(cb) {
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() {
// e.g. /enigma-bbs-install-path/config/
return './config/';
@ -119,25 +170,32 @@ function getDefaultConfig() {
general : {
boardName : 'Another Fine ENiGMA½ BBS',
// :TODO: closedSystem prob belongs under users{}?
closedSystem : false, // is the system closed to new users?
loginAttempts : 3,
menuFile : 'menu.hjson', // Override to use something else, e.g. demo.hjson. Can be a full path (defaults to ./config)
promptFile : 'prompt.hjson', // Override to use soemthing else, e.g. myprompt.hjson. Can be a full path (defaults to ./config)
menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
achievementFile : 'achievements.hjson',
},
// :TODO: see notes below about 'theme' section - move this!
preLoginTheme : 'luciano_blocktronics',
users : {
usernameMin : 2,
usernameMax : 16, // Note that FidoNet wants 36 max
usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+ ]+$',
usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+ .]+$',
passwordMin : 6,
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,
locationMax : 32,
@ -156,29 +214,33 @@ function getDefaultConfig() {
'sysop', 'admin', 'administrator', 'root', 'all',
'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!
/*
Concept
"theme" : {
"default" : "defaultThemeName", // or "*"
"preLogin" : "*",
"passwordChar" : "*",
...
}
*/
defaults : {
theme : 'luciano_blocktronics',
passwordChar : '*', // TODO: move to user ?
theme : {
default : 'luciano_blocktronics',
preLogin : 'luciano_blocktronics',
passwordChar : '*',
dateFormat : {
short : 'MM/DD/YYYY',
long : 'ddd, MMMM Do, YYYY',
},
timeFormat : {
short : 'h:mm a',
},
dateTimeFormat : {
short : 'MM/DD/YYYY h:mm a',
long : 'ddd, MMMM Do, YYYY, h:mm a',
}
},
@ -200,7 +262,7 @@ function getDefaultConfig() {
logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such
db : paths.join(__dirname, './../db/'),
modsDb : paths.join(__dirname, './../db/mods/'),
dropFiles : paths.join(__dirname, './../dropfiles/'), // + "/node<x>/
dropFiles : paths.join(__dirname, './../drop/'), // + "/node<x>/
misc : paths.join(__dirname, './../misc/'),
},
@ -213,37 +275,97 @@ function getDefaultConfig() {
ssh : {
port : 8889,
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:
// Choose a cipher (3DES, AES128, or AES256) and a bit strength (2048 or 4096)
// Ciphers:
// 3des: older, most compatible, least secure
// aes128: newer, widely compatible, fairly secure
// aes256: newest, least compatible, best security
// Bit strength:
// 2048: most compatible, decent strength
// 4096: stronger, but some software is completely incompatible
// 1 - Choose a cipher (3DES, AES128, or AES256)
// 3des : older, most compatible, least secure
// aes128 : newer, widely compatible, fairly secure
// aes256 : newest, least compatible, best security
//
// 2 - Choose a bit strength (2048 or 4096)
// 2048 : most compatible, decent strength
// 4096 : stronger, but some software is completely incompatible
//
// Sample command:
// openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048
//
// Then, set servers.ssh.privateKeyPass to the password you use above
// in your config.hjson
//
//
privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'),
firstMenu : 'sshConnected',
firstMenuNewUser : 'sshConnectedNewUser',
//
// 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 : {
port : 8810, // ws://
ws : {
// non-secure ws://
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'),
keyPem : paths.join(__dirname, './../config/https_cert_key.pem'),
},
},
},
contentServers : {
web : {
@ -280,6 +402,46 @@ function getDefaultConfig() {
certPem : paths.join(__dirname, './../config/https_cert.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',
'--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 : {
@ -391,7 +563,7 @@ function getDefaultConfig() {
},
'application/x-rar-compressed' : {
desc : 'RAR Archive',
sig : '526172211a0700',
sig : '526172211a07',
offset : 0,
archiveHandler : 'Rar',
},
@ -414,18 +586,37 @@ function getDefaultConfig() {
offset : 2,
archiveHandler : 'Lha',
},
'application/x-lzx' : {
desc : 'LZX Archive',
sig : '4c5a5800',
offset : 0,
archiveHandler : 'Lzx',
},
'application/x-7z-compressed' : {
desc : '7-Zip Archive',
sig : '377abcaf271c',
offset : 0,
archiveHandler : '7Zip',
}
},
// :TODO: update archives::formats to fall here
// * archive handler -> archiveHandler (consider archive if archiveHandler present)
// * sig, offset, ...
// * mime-db -> exts lookup
// *
//
// Generics that need further mapping
//
'application/octet-stream' : [
{
desc : 'Amiga DISKMASHER',
sig : '444d5321', // DMS!
ext : '.dms',
shortDescUtil : 'XDMS2Desc',
longDescUtil : 'XDMS2LongDesc',
},
{
desc : 'SIO2PC Atari Disk Image',
sig : '9602', // 16bit sum of "NICKATARI"
ext : '.atr',
archiveHandler : 'Atr',
}
]
},
archives : {
@ -459,7 +650,7 @@ function getDefaultConfig() {
//
decompress : {
cmd : 'lha',
args : [ '-ew={extractPath}', '{archivePath}' ],
args : [ '-efw={extractPath}', '{archivePath}' ],
},
list : {
cmd : 'lha',
@ -468,7 +659,26 @@ function getDefaultConfig() {
},
extract : {
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 : {
cmd : 'unrar',
args : [ 'l', '{archivePath}' ],
entryMatch : '^\\s+[\\.A-Z]+\\s+([\\d]+)\\s{2}[0-9]{2}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s{2}([^\\r\\n]+)$',
entryMatch : '^\\s+[\\.A-Z]+\\s+([\\d]+)\\s{2}[0-9]{2,4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s{2}([^\\r\\n]+)$',
},
extract : {
cmd : 'unrar',
@ -526,6 +736,23 @@ function getDefaultConfig() {
cmd : 'tar',
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,
external : {
// :TODO: Look into shipping sexyz binaries or at least hosting them somewhere for common systems
// Linux x86_64 binary: https://l33t.codes/outgoing/sexyz
sendCmd : 'sexyz',
sendArgs : [ '-telnet', '-8', 'sz', '@{fileListPath}' ],
recvCmd : 'sexyz',
@ -662,12 +890,24 @@ function getDefaultConfig() {
// FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ
// Some groups include a FILE_ID.ANS. We try to use that over FILE_ID.DIZ if available.
desc : [
'^[^/\]*FILE_ID\.ANS$', '^[^/\]*FILE_ID\.DIZ$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$'
'^.*FILE_ID\.ANS$', '^.*FILE_ID\.DIZ$', // eslint-disable-line no-useless-escape
'^.*DESC\.SDI$', // eslint-disable-line no-useless-escape
'^.*DESCRIPT\.ION$', // eslint-disable-line no-useless-escape
'^.*FILE\.DES$', // eslint-disable-line no-useless-escape
'^.*FILE\.SDI$', // eslint-disable-line no-useless-escape
'^.*DISK\.ID$' // eslint-disable-line no-useless-escape
],
// common README filename - https://en.wikipedia.org/wiki/README
descLong : [
'^[^/\]*\.NFO$', '^[^/\]*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 : {
events : {
dailyMaintenance : {
schedule : 'at 11:59pm',
action : '@method:core/misc_scheduled_events.js:dailyMaintenanceScheduledEvent',
},
trimMessageAreas : {
// may optionally use [or ]@watch:/path/to/file
schedule : 'every 24 hours',
// action:
// - @method:path/to/module.js:theMethodName
// (path is relative to engima base dir)
// (path is relative to ENiGMA base dir)
//
// - @execute:/path/to/something/executable.sh
//
action : '@method:core/message_area.js:trimMessageAreasScheduledEvent',
},
nntpMaintenance : {
schedule : 'every 12 hours', // should generally be < trimMessageAreas interval
action : '@method:core/servers/content/nntp.js:performMaintenanceTask',
},
updateFileAreaStats : {
schedule : 'every 1 hours',
action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent',
@ -745,18 +994,22 @@ function getDefaultConfig() {
schedule : 'every 24 hours',
action : '@method:core/web_password_reset.js:performMaintenanceTask',
args : [ '24 hours' ] // items older than this will be removed
}
}
},
misc : {
preAuthIdleLogoutSeconds : 60 * 3, // 2m
idleLogoutSeconds : 60 * 6, // 6m
//
// Enable the following entry in your config.hjson to periodically create/update
// DESCRIPT.ION files for your file base
//
/*
updateDescriptIonFiles : {
schedule : 'on the last day of the week',
action : '@method:core/file_base_list_export.js:updateFileBaseDescFilesScheduledEvent',
}
*/
}
},
logging : {
level : 'debug',
rotatingFile : { // set to 'disabled' or false to disable
type : 'rotating-file',
fileName : 'enigma-bbs.log',
@ -770,6 +1023,12 @@ function getDefaultConfig() {
debug : {
assertsEnabled : false,
},
statLog : {
systemEvents : {
loginHistoryMax: -1, // set to -1 for forever
}
},
};
}

View File

@ -1,85 +1,76 @@
/* jslint node: true */
'use strict';
var Config = require('./config.js').config;
var Log = require('./logger.js').log;
// deps
const paths = require('path');
const fs = require('graceful-fs');
const hjson = require('hjson');
const sane = require('sane');
var paths = require('path');
var fs = require('graceful-fs');
var events = require('events');
var util = require('util');
var assert = require('assert');
var hjson = require('hjson');
var _ = require('lodash');
module.exports = new class ConfigCache
{
constructor() {
this.cache = new Map(); // path->parsed config
}
function ConfigCache() {
events.EventEmitter.call(this);
getConfigWithOptions(options, cb) {
const cached = this.cache.has(options.filePath);
var self = this;
this.cache = {}; // filePath -> HJSON
//this.gaze = new Gaze();
if(options.forceReCache || !cached) {
this.recacheConfigFromFile(options.filePath, (err, config) => {
if(!err && !cached) {
if(!options.noWatch) {
const watcher = sane(
paths.dirname(options.filePath),
{
glob : `**/${paths.basename(options.filePath)}`
}
);
this.reCacheConfigFromFile = function(filePath, cb) {
fs.readFile(filePath, { encoding : 'utf-8' }, function fileRead(err, data) {
try {
self.cache[filePath] = hjson.parse(data);
cb(null, self.cache[filePath]);
} catch(e) {
Log.error( { filePath : filePath, error : e.toString() }, 'Failed recaching');
cb(e);
watcher.on('change', (fileName, fileRoot) => {
require('./logger.js').log.info( { fileName, fileRoot }, 'Configuration file changed; re-caching');
this.recacheConfigFromFile(paths.join(fileRoot, fileName), err => {
if(!err) {
if(options.callback) {
options.callback( { fileName, fileRoot } );
}
}
});
};
/*
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) {
assert(filePath in self.cache);
getConfig(filePath, cb) {
return this.getConfigWithOptions( { filePath }, cb);
}
Log.info( { path : filePath }, 'Configuration file changed; re-caching');
self.reCacheConfigFromFile(filePath, function reCached(err) {
recacheConfigFromFile(path, cb) {
fs.readFile(path, { encoding : 'utf-8' }, (err, data) => {
if(err) {
Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration');
} else {
self.emit('recached', filePath);
return cb(err);
}
});
});
*/
}
util.inherits(ConfigCache, events.EventEmitter);
ConfigCache.prototype.getConfigWithOptions = function(options, cb) {
assert(_.isString(options.filePath));
// var self = this;
var isCached = (options.filePath in this.cache);
if(options.forceReCache || !isCached) {
this.reCacheConfigFromFile(options.filePath, function fileCached(err, config) {
if(!err && !isCached) {
//self.gaze.add(options.filePath);
let parsed;
try {
parsed = hjson.parse(data);
this.cache.set(path, parsed);
} catch(e) {
try {
require('./logger.js').log.error( { filePath : path, error : e.message }, 'Failed to re-cache' );
} catch(ignored) {
// nothing - we may be failing to parse the config in which we can't log here!
}
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 */
'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;
function getFullConfig(filePath, cb) {
function getConfigPath(filePath) {
// |filePath| is assumed to be in the config path if it's only a file name
if('.' === paths.dirname(filePath)) {
filePath = paths.join(Config.paths.config, filePath);
filePath = paths.join(Config().paths.config, filePath);
}
return filePath;
}
configCache.getConfig(filePath, function loaded(err, configJson) {
cb(err, configJson);
function init(cb) {
// pre-cache menu.hjson and prompt.hjson + establish events
const changed = ( { fileName, fileRoot } ) => {
const reCachedPath = paths.join(fileRoot, fileName);
if(reCachedPath === getConfigPath(Config().general.menuFile)) {
Events.emit(Events.getSystemEvents().MenusChanged);
} else if(reCachedPath === getConfigPath(Config().general.promptFile)) {
Events.emit(Events.getSystemEvents().PromptsChanged);
}
};
const config = Config();
async.series(
[
function menu(callback) {
return ConfigCache.getConfigWithOptions(
{
filePath : getConfigPath(config.general.menuFile),
callback : changed,
},
callback
);
},
function prompt(callback) {
return ConfigCache.getConfigWithOptions(
{
filePath : getConfigPath(config.general.promptFile),
callback : changed,
},
callback
);
}
],
err => {
return cb(err);
}
);
}
function getFullConfig(filePath, cb) {
ConfigCache.getConfig(getConfigPath(filePath), (err, config) => {
return cb(err, config);
});
}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
/* jslint node: true */
'use strict';
const { Errors } = require('./enig_error.js');
// deps
const fs = require('graceful-fs');
const iconv = require('iconv-lite');
@ -64,7 +66,10 @@ module.exports = class DescriptIonFile {
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 */
'use strict';
const stringFormat = require('./string_format.js');
const { Errors } = require('./enig_error.js');
const events = require('events');
const _ = require('lodash');
const pty = require('ptyw.js');
// deps
const pty = require('node-pty');
const decode = require('iconv-lite').decode;
const createServer = require('net').createServer;
const paths = require('path');
exports.Door = Door;
function Door(client, exeInfo) {
events.EventEmitter.call(this);
const self = this;
module.exports = class Door {
constructor(client) {
this.client = client;
this.exeInfo = exeInfo;
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.restored = false;
}
};
this.restoreIo = function(piped) {
if(!restored && self.client.term.output) {
self.client.term.output.unpipe(piped);
self.client.term.output.resume();
restored = true;
prepare(ioType, cb) {
this.io = ioType;
// we currently only have to do any real setup for 'socket'
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', () => {
return self.restoreIo(conn);
return this.restoreIo(conn);
});
conn.once('error', err => {
self.client.log.info( { error : err.toString() }, 'Door socket server connection');
return self.restoreIo(conn);
this.client.log.info( { error : err.message }, 'Door socket server connection');
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, () => {
return cb(null, sockServer);
});
} else {
this.sockServer.listen(0, () => {
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, {
cols : self.client.term.termWidth,
rows : self.client.term.termHeight,
// :TODO: cwd
env : self.exeInfo.env,
run(exeInfo, cb) {
this.encoding = (exeInfo.encoding || 'cp437').toLowerCase();
if('socket' === this.io && !this.sockServer) {
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) {
self.client.log.debug('Using stdio for door I/O');
if('stdio' === this.io) {
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', () => {
return self.restoreIo(door);
return this.restoreIo(door);
});
} else if('socket' === self.exeInfo.io) {
self.client.log.debug( { port : sockServer.address().port }, 'Using temporary socket server for door I/O');
} else if('socket' === this.io) {
this.client.log.debug(
{ srvPort : this.sockServer.address().port, srvSocket : this.sockServerSocket },
'Using temporary socket server for door I/O'
);
}
door.once('exit', exitCode => {
self.client.log.info( { exitCode : exitCode }, 'Door exited');
this.client.log.info( { exitCode : exitCode }, 'Door exited');
if(sockServer) {
sockServer.close();
if(this.sockServer) {
this.sockServer.close();
}
// we may not get a close
if('stdio' === self.exeInfo.io) {
self.restoreIo(door);
if('stdio' === this.io) {
this.restoreIo(door);
}
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';
// enigma-bbs
const MenuModule = require('../core/menu_module.js').MenuModule;
const resetScreen = require('../core/ansi_term.js').resetScreen;
const { MenuModule } = require('./menu_module.js');
const { resetScreen } = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
const _ = require('lodash');
const SSHClient = require('ssh2').Client;
exports.moduleInfo = {
@ -34,16 +38,17 @@ exports.getModule = class DoorPartyModule extends MenuModule {
async.series(
[
function validateConfig(callback) {
if(!_.isString(self.config.username)) {
return callback(new Error('Config requires "username"!'));
}
if(!_.isString(self.config.password)) {
return callback(new Error('Config requires "password"!'));
}
if(!_.isString(self.config.bbsTag)) {
return callback(new Error('Config requires "bbsTag"!'));
}
return callback(null);
return self.validateConfigFields(
{
host : 'string',
username : 'string',
password : 'string',
bbsTag : 'string',
sshPort : 'number',
rloginPort : 'number',
},
callback
);
},
function establishSecureConnection(callback) {
self.client.term.write(resetScreen());
@ -53,10 +58,16 @@ exports.getModule = class DoorPartyModule extends MenuModule {
let pipeRestored = false;
let pipedStream;
let doorTracking;
const restorePipe = function() {
if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream);
self.client.term.output.resume();
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
}
};
@ -71,9 +82,11 @@ exports.getModule = class DoorPartyModule extends MenuModule {
// establish tunnel for rlogin
sshClient.forwardOut('127.0.0.1', self.config.sshPort, self.config.host, self.config.rloginPort, (err, stream) => {
if(err) {
return callback(new Error('Failed to establish tunnel'));
return callback(Errors.General('Failed to establish tunnel'));
}
doorTracking = trackDoorRunBegin(self.client);
//
// Send rlogin
// DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g.
@ -99,6 +112,7 @@ exports.getModule = class DoorPartyModule extends MenuModule {
sshClient.on('error', err => {
self.client.log.info(`DoorParty SSH client error: ${err.message}`);
trackDoorRunEnd(doorTracking);
});
sshClient.on('close', () => {
@ -121,7 +135,7 @@ exports.getModule = class DoorPartyModule extends MenuModule {
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) {
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';
const FileEntry = require('./file_entry.js');
const UserProps = require('./user_property.js');
// deps
const { partition } = require('lodash');
module.exports = class DownloadQueue {
constructor(client) {
this.client = client;
if(!Array.isArray(this.client.user.downloadQueue)) {
if(this.client.user.properties.dl_queue) {
this.loadFromProperty(this.client.user.properties.dl_queue);
if(this.client.user.properties[UserProps.DownloadQueue]) {
this.loadFromProperty(this.client.user.properties[UserProps.DownloadQueue]);
} else {
this.client.user.downloadQueue = [];
}
@ -24,21 +28,22 @@ module.exports = class DownloadQueue {
this.client.user.downloadQueue = [];
}
toggle(fileEntry) {
toggle(fileEntry, systemFile=false) {
if(this.isQueued(fileEntry)) {
this.client.user.downloadQueue = this.client.user.downloadQueue.filter(e => fileEntry.fileId !== e.fileId);
} else {
this.add(fileEntry);
this.add(fileEntry, systemFile);
}
}
add(fileEntry) {
add(fileEntry, systemFile=false) {
this.client.user.downloadQueue.push({
fileId : fileEntry.fileId,
areaTag : fileEntry.areaTag,
fileName : fileEntry.fileName,
path : fileEntry.filePath,
byteSize : fileEntry.meta.byte_size || 0,
systemFile : systemFile,
});
}
@ -47,7 +52,9 @@ module.exports = class DownloadQueue {
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) {

View File

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

View File

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

View File

@ -2,7 +2,7 @@
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors;
const Log = require('./logger.js').log;
@ -13,13 +13,14 @@ const nodeMailer = require('nodemailer');
exports.sendMail = sendMail;
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'));
}
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,
});

View File

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

View File

@ -2,14 +2,14 @@
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
const Config = require('./config.js').get;
const Log = require('./logger.js').log;
// deps
const assert = require('assert');
module.exports = function(condition, message) {
if(Config.debug.assertsEnabled) {
if(Config().debug.assertsEnabled) {
assert.apply(this, arguments);
} else if(!(condition)) {
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½
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 { Errors } = require('./enig_error.js');
const _ = require('lodash');
const later = require('later');
const path = require('path');
const pty = require('ptyw.js');
const pty = require('node-pty');
const sane = require('sane');
const moment = require('moment');
const paths = require('path');
@ -24,8 +25,8 @@ exports.moduleInfo = {
author : 'NuSkooler',
};
const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:)([^\0]+)?$/;
const ACTION_REGEXP = /\@(method|execute)\:([^\0]+)?$/;
const SCHEDULE_REGEXP = /(?:^|or )?(@watch:)([^\0]+)?$/;
const ACTION_REGEXP = /@(method|execute):([^\0]+)?$/;
class ScheduledEvent {
constructor(events, name) {
@ -116,7 +117,7 @@ class ScheduledEvent {
methodModule[this.action.what](this.action.args, err => {
if(err) {
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');
}
@ -124,7 +125,7 @@ class ScheduledEvent {
});
} catch(e) {
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');
return cb(e);
@ -138,7 +139,22 @@ class ScheduledEvent {
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 => {
if(exitCode) {
@ -146,7 +162,7 @@ class ScheduledEvent {
{ eventName : this.name, action : this.action, exitCode : exitCode },
'Bad exit code while performing scheduled event action');
}
return cb(exitCode ? new Error(`Bad exit code while performing scheduled event action: ${exitCode}`) : null);
return cb(exitCode ? Errors.ExternalProcess(`Bad exit code while performing scheduled event action: ${exitCode}`) : null);
});
}
}
@ -155,8 +171,9 @@ class ScheduledEvent {
function EventSchedulerModule(options) {
PluginModule.call(this, options);
if(_.has(Config, 'eventScheduler')) {
this.moduleConfig = Config.eventScheduler;
const config = Config();
if(_.has(config, 'eventScheduler')) {
this.moduleConfig = config.eventScheduler;
}
const self = this;

View File

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

View File

@ -2,12 +2,18 @@
'use strict';
// ENiGMA½
const MenuModule = require('../core/menu_module.js').MenuModule;
const resetScreen = require('../core/ansi_term.js').resetScreen;
const Config = require('./config.js').config;
const Errors = require('./enig_error.js').Errors;
const { MenuModule } = require('./menu_module.js');
const { resetScreen } = require('./ansi_term.js');
const Config = require('./config.js').get;
const { Errors } = require('./enig_error.js');
const Log = require('./logger.js').log;
const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent;
const {
getEnigmaUserAgent
} = require('./misc_util.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
@ -17,7 +23,7 @@ const crypto = require('crypto');
const moment = require('moment');
const https = require('https');
const querystring = require('querystring');
const fs = require('fs');
const fs = require('fs-extra');
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.sshPort = this.config.sshPort || 22;
this.config.sshUser = this.config.sshUser || 'exodus_server';
this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config.paths.misc, 'exodus.id_rsa');
this.config.sshKeyPem = this.config.sshKeyPem || joinPath(Config().paths.misc, 'exodus.id_rsa');
}
initSequence() {
@ -151,11 +157,16 @@ exports.getModule = class ExodusModule extends MenuModule {
let pipeRestored = false;
let pipedStream;
let doorTracking;
function restorePipe() {
if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream);
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) => {
doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`);
pipedStream = stream; // :TODO: ewwwwwwwww hack
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 FileBaseFilters = require('./file_base_filter.js');
const stringFormat = require('./string_format.js');
const UserProps = require('./user_property.js');
// deps
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(filterUuid === this.client.user.properties.file_base_filter_active_uuid) {
if(filterUuid === this.client.user.properties[UserProps.FileBaseFilterActiveUuid]) {
const newActive = this.filtersArray[this.currentFilterIndex];
if(newActive) {
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 ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled;
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 FileAreaWeb = require('./file_area_web.js');
const FileBaseFilters = require('./file_base_filter.js');
@ -24,6 +24,7 @@ const controlCodesToAnsi = require('./color_codes.js').controlCodesToAnsi;
const async = require('async');
const _ = require('lodash');
const moment = require('moment');
const paths = require('path');
exports.moduleInfo = {
name : 'File Area List',
@ -75,6 +76,7 @@ exports.getModule = class FileAreaList extends MenuModule {
this.filterCriteria = _.get(options, 'extraArgs.filterCriteria');
this.fileList = _.get(options, 'extraArgs.fileList');
this.lastFileNextExit = _.get(options, 'extraArgs.lastFileNextExit', true);
if(this.fileList) {
// we'll need to adjust position as well!
@ -103,6 +105,10 @@ exports.getModule = class FileAreaList extends MenuModule {
return this.displayBrowsePage(true, cb); // true=clerarScreen
}
if(this.lastFileNextExit) {
return this.prevMenu(cb);
}
return cb(null);
},
prevFile : (formData, extraArgs, cb) => {
@ -212,7 +218,7 @@ exports.getModule = class FileAreaList extends MenuModule {
const config = this.menuConfig.config;
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 hashTagsSep = config.hashTagsSep || ', ';
const isQueuedIndicator = config.isQueuedIndicator || 'Y';
@ -247,12 +253,22 @@ exports.getModule = class FileAreaList extends MenuModule {
if(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 {
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)';
// create a rating string, e.g. "**---"
@ -273,7 +289,7 @@ exports.getModule = class FileAreaList extends MenuModule {
entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated';
}
} 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.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat);
@ -397,16 +413,23 @@ exports.getModule = class FileAreaList extends MenuModule {
//
const desc = controlCodesToAnsi(self.currentFileEntry.desc);
if(desc.length != self.currentFileEntry.desc.length || isAnsi(desc)) {
descView.setAnsi(
desc,
{
const opts = {
prepped : false,
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 {
descView.setText(self.currentFileEntry.desc);
return callback(null);
@ -493,7 +516,7 @@ exports.getModule = class FileAreaList extends MenuModule {
return callback(null);
}
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes');
const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes');
FileAreaWeb.createAndServeTempDownload(
self.client,
@ -567,34 +590,38 @@ exports.getModule = class FileAreaList extends MenuModule {
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');
});
}
setFileListNoListing(text) {
const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList);
if(fileListView) {
fileListView.complexItems = false;
fileListView.setItems( [ text ] );
fileListView.redraw();
}
}
populateFileListing() {
const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList);
if(this.currentFileEntry.entryInfo.archiveType) {
this.cacheArchiveEntries( (err, cacheStatus) => {
if(err) {
// :TODO: Handle me!!!
fileListView.setItems( [ 'Failed getting file listing' ] ); // :TODO: make this not suck
return;
return this.setFileListNoListing('Failed to get file listing');
}
if('re-cached' === cacheStatus) {
const fileListEntryFormat = this.menuConfig.config.fileListEntryFormat || '{fileName} {fileSize}'; // :TODO: use byteSize here?
const focusFileListEntryFormat = this.menuConfig.config.focusFileListEntryFormat || fileListEntryFormat;
fileListView.setItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(fileListEntryFormat, entry) ) );
fileListView.setFocusItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(focusFileListEntryFormat, entry) ) );
fileListView.setItems(this.currentFileEntry.archiveEntries);
fileListView.redraw();
}
});
} 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';
// ENiGMA½
const Config = require('./config.js').config;
const Config = require('./config.js').get;
const FileDb = require('./database.js').dbs.file;
const getISOTimestampString = require('./database.js').getISOTimestampString;
const FileEntry = require('./file_entry.js');
@ -14,6 +14,9 @@ const User = require('./user.js');
const Log = require('./logger.js').log;
const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId;
const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
const Events = require('./events.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_menu_method.js');
// deps
const hashids = require('hashids');
@ -30,7 +33,7 @@ function notEnabledError() {
class FileAreaWebAccess {
constructor() {
this.hashids = new hashids(Config.general.boardName);
this.hashids = new hashids(Config().general.boardName);
this.expireTimers = {}; // hashId->timer
}
@ -51,7 +54,7 @@ class FileAreaWebAccess {
if(self.isEnabled()) {
const routeAdded = self.webServer.instance.addRoute({
method : 'GET',
path : Config.fileBase.web.routePath,
path : Config().fileBase.web.routePath,
handler : self.routeWebRequest.bind(self),
});
return callback(routeAdded ? null : Errors.General('Failed adding route'));
@ -183,11 +186,11 @@ class FileAreaWebAccess {
buildSingleFileTempDownloadLink(client, fileEntry, hashId) {
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) {
return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`);
return this.webServer.instance.buildUrl(`${Config().fileBase.web.path}${hashId}`);
}
getExistingTempDownloadServeItem(client, fileEntry, cb) {
@ -337,7 +340,7 @@ class FileAreaWebAccess {
resp.on('finish', () => {
// transfer completed fully
this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size);
this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size, [ fileEntry ]);
});
const headers = {
@ -382,24 +385,21 @@ class FileAreaWebAccess {
);
},
function loadFileEntries(fileIds, callback) {
const filePaths = [];
async.eachSeries(fileIds, (fileId, nextFileId) => {
async.map(fileIds, (fileId, nextFileId) => {
const fileEntry = new FileEntry();
fileEntry.load(fileId, err => {
if(!err) {
filePaths.push(fileEntry.filePath);
}
return nextFileId(err);
return nextFileId(err, fileEntry);
});
}, err => {
}, (err, fileEntries) => {
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');
const zipFile = new yazl.ZipFile();
@ -430,7 +430,7 @@ class FileAreaWebAccess {
resp.on('finish', () => {
// transfer completed fully
self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize);
self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize, fileEntries);
});
const batchFileName = `batch_${servedItem.hashId}.zip`;
@ -457,7 +457,7 @@ class FileAreaWebAccess {
);
}
updateDownloadStatsForUserIdAndSystem(userId, dlBytes, cb) {
updateDownloadStatsForUserIdAndSystem(userId, dlBytes, fileEntries) {
async.waterfall(
[
function fetchActiveUser(callback) {
@ -472,19 +472,25 @@ class FileAreaWebAccess {
});
},
function updateStats(user, callback) {
StatLog.incrementUserStat(user, 'dl_total_count', 1);
StatLog.incrementUserStat(user, 'dl_total_bytes', dlBytes);
StatLog.incrementSystemStat('dl_total_count', 1);
StatLog.incrementSystemStat('dl_total_bytes', dlBytes);
StatLog.incrementUserStat(user, UserProps.FileDlTotalCount, 1);
StatLog.incrementUserStat(user, UserProps.FileDlTotalBytes, dlBytes);
StatLog.incrementSystemStat(SysProps.FileDlTotalCount, 1);
StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, dlBytes);
return callback(null, user);
},
function sendEvent(user, callback) {
Events.emit(
Events.getSystemEvents().UserDownload,
{
user : user,
files : fileEntries,
}
);
return callback(null);
}
],
err => {
if(cb) {
return cb(err);
}
}
]
);
}
}

View File

@ -2,7 +2,7 @@
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors;
const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs;
const FileEntry = require('./file_entry.js');
@ -14,6 +14,9 @@ const resolveMimeType = require('./mime_util.js').resolveMimeType;
const stringFormat = require('./string_format.js');
const wordWrapText = require('./word_wrap.js').wordWrapText;
const StatLog = require('./stat_log.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
const SAUCE = require('./sauce.js');
// deps
const _ = require('lodash');
@ -26,6 +29,7 @@ const iconv = require('iconv-lite');
const execFile = require('child_process').execFile;
const moment = require('moment');
exports.startup = startup;
exports.isInternalArea = isInternalArea;
exports.getAvailableFileAreas = getAvailableFileAreas;
exports.getAvailableFileAreaTags = getAvailableFileAreaTags;
@ -42,6 +46,7 @@ exports.scanFile = scanFile;
exports.scanFileAreaForChanges = scanFileAreaForChanges;
exports.getDescFromFileName = getDescFromFileName;
exports.getAreaStats = getAreaStats;
exports.cleanUpTempSessionItems = cleanUpTempSessionItems;
// for scheduler:
exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent;
@ -52,6 +57,28 @@ const WellKnownAreaTags = exports.WellKnownAreaTags = {
TempDownloads : 'system_temporary_download',
};
function startup(cb) {
async.series(
[
(callback) => {
return cleanUpTempSessionItems(callback);
},
(callback) => {
getAreaStats( (err, stats) => {
if(!err) {
StatLog.setNonPersistentSystemStat(SysProps.FileBaseAreaStats, stats);
}
return callback(null);
});
}
],
err => {
return cb(err);
}
);
}
function isInternalArea(areaTag) {
return [ WellKnownAreaTags.MessageAreaAttach, WellKnownAreaTags.TempDownloads ].includes(areaTag);
}
@ -60,7 +87,7 @@ function getAvailableFileAreas(client, options) {
options = options || { };
// perform ACS check per conf & omit internal if desired
const allAreas = _.map(Config.fileBase.areas, (areaInfo, areaTag) => Object.assign(areaInfo, { areaTag : areaTag } ));
const allAreas = _.map(Config().fileBase.areas, (areaInfo, areaTag) => Object.assign(areaInfo, { areaTag : areaTag } ));
return _.omitBy(allAreas, areaInfo => {
if(!options.includeSystemInternal && isInternalArea(areaInfo.areaTag)) {
@ -90,16 +117,17 @@ function getSortedAvailableFileAreas(client, options) {
}
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) {
const area = Config.fileBase.areas[defaultArea];
const area = config.fileBase.areas[defaultArea];
if(true === disableAcsCheck || client.acs.hasFileAreaRead(area)) {
return defaultArea;
}
}
// just use anything we can
defaultArea = _.findKey(Config.fileBase.areas, (area, areaTag) => {
defaultArea = _.findKey(config.fileBase.areas, (area, areaTag) => {
return WellKnownAreaTags.MessageAreaAttach !== areaTag && (true === disableAcsCheck || client.acs.hasFileAreaRead(area));
});
@ -107,7 +135,7 @@ function getDefaultFileAreaTag(client, disableAcsCheck) {
}
function getFileAreaByTag(areaTag) {
const areaInfo = Config.fileBase.areas[areaTag];
const areaInfo = Config().fileBase.areas[areaTag];
if(areaInfo) {
areaInfo.areaTag = areaTag; // convienence!
areaInfo.storage = getAreaStorageLocations(areaInfo);
@ -129,11 +157,11 @@ function changeFileAreaWithOptions(client, areaTag, options, cb) {
},
function changeArea(area, callback) {
if(true === options.persist) {
client.user.persistProperty('file_area_tag', areaTag, err => {
client.user.persistProperty(UserProps.FileAreaTag, areaTag, err => {
return callback(err, area);
});
} else {
client.user.properties['file_area_tag'] = areaTag;
client.user.properties[UserProps.FileAreaTag] = areaTag;
return callback(null, area);
}
}
@ -151,13 +179,14 @@ function changeFileAreaWithOptions(client, areaTag, options, cb) {
}
function isValidStorageTag(storageTag) {
return storageTag in Config.fileBase.storageTags;
return storageTag in Config().fileBase.storageTags;
}
function getAreaStorageDirectoryByTag(storageTag) {
const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]);
const config = Config();
const storageLocation = (storageTag && config.fileBase.storageTags[storageTag]);
return paths.resolve(Config.fileBase.areaStoragePrefix, storageLocation || '');
return paths.resolve(config.fileBase.areaStoragePrefix, storageLocation || '');
}
function getAreaDefaultStorageDirectory(areaInfo) {
@ -170,7 +199,7 @@ function getAreaStorageLocations(areaInfo) {
areaInfo.storageTags :
[ areaInfo.storageTags || '' ];
const avail = Config.fileBase.storageTags;
const avail = Config().fileBase.storageTags;
return _.compact(storageTags.map(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) {
let eof = data.length;
const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE)
@ -227,7 +256,7 @@ function sliceAtSauceMarker(data) {
function attemptSetEstimatedReleaseDate(fileEntry) {
// :TODO: yearEstPatterns RegExp's should be cached - we can do this @ Config (re)load time
const patterns = Config.fileBase.yearEstPatterns.map( p => new RegExp(p, 'gmi'));
const patterns = Config().fileBase.yearEstPatterns.map( p => new RegExp(p, 'gmi'));
function getMatch(input) {
if(input) {
@ -272,11 +301,17 @@ function attemptSetEstimatedReleaseDate(fileEntry) {
}
// a simple log proxy for when we call from oputil.js
function logDebug(obj, msg) {
const maybeLog = (obj, msg, level) => {
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) {
async.waterfall(
@ -284,11 +319,11 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
function extractDescFiles(callback) {
// :TODO: would be nice if these RegExp's were cached
// :TODO: this is long winded...
const config = Config();
const extractList = [];
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) {
@ -296,7 +331,7 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
}
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) {
@ -319,8 +354,8 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
}
const descFiles = {
desc : shortDescFile ? paths.join(tempDir, shortDescFile.fileName) : null,
descLong : longDescFile ? paths.join(tempDir, longDescFile.fileName) : null,
desc : shortDescFile ? paths.join(tempDir, paths.basename(shortDescFile.fileName)) : null,
descLong : longDescFile ? paths.join(tempDir, paths.basename(longDescFile.fileName)) : null,
};
return callback(null, descFiles);
@ -328,6 +363,7 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
});
},
function readDescFiles(descFiles, callback) {
const config = Config();
async.each(Object.keys(descFiles), (descType, next) => {
const path = descFiles[descType];
if(!path) {
@ -341,9 +377,8 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
// skip entries that are too large
const maxFileSizeKey = `max${_.upperFirst(descType)}FileByteSize`;
if(Config.fileBase[maxFileSizeKey] && stats.size > Config.fileBase[maxFileSizeKey]) {
logDebug( { byteSize : stats.size, maxByteSize : Config.fileBase[maxFileSizeKey] }, `Skipping "${descType}"; Too large` );
if(config.fileBase[maxFileSizeKey] && stats.size > config.fileBase[maxFileSizeKey]) {
logDebug( { byteSize : stats.size, maxByteSize : config.fileBase[maxFileSizeKey] }, `Skipping "${descType}"; Too large` );
return next(null);
}
@ -352,20 +387,31 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
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...
fileEntry[descType] = iconv.decode(sliceAtSauceMarker(data, 0x1a), 'cp437');
const decodedData = iconv.decode(data, 'cp437');
fileEntry[descType] = sliceAtSauceMarker(decodedData, 0x1a);
fileEntry[`${descType}Src`] = 'descFile';
return next(null);
});
});
});
}, () => {
// cleanup but don't wait
temptmp.cleanup( paths => {
// note: don't use client logger here - may not be avail
logDebug( { paths : paths, sessionId : temptmp.sessionId }, 'Cleaned up temporary files' );
logTrace( { paths : paths, sessionId : temptmp.sessionId }, 'Cleaned up temporary files' );
});
return callback(null);
});
@ -481,13 +527,25 @@ function populateFileEntryWithArchive(fileEntry, filePath, stepInfo, iterator, c
);
}
function getInfoExtractUtilForDesc(mimeType, descType) {
let util = _.get(Config, [ 'fileTypes', mimeType, `${descType}DescUtil` ]);
function getInfoExtractUtilForDesc(mimeType, filePath, descType) {
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)) {
return;
}
util = _.get(Config, [ 'infoExtractUtils', util ]);
util = _.get(config, [ 'infoExtractUtils', util ]);
if(!util || !_.isString(util.cmd)) {
return;
}
@ -502,7 +560,7 @@ function populateFileEntryInfoFromFile(fileEntry, filePath, cb) {
}
async.eachSeries( [ 'short', 'long' ], (descType, nextDesc) => {
const util = getInfoExtractUtilForDesc(mimeType, descType);
const util = getInfoExtractUtilForDesc(mimeType, filePath, descType);
if(!util) {
return nextDesc(null);
}
@ -586,10 +644,6 @@ function addNewFileEntry(fileEntry, filePath, cb) {
);
}
function updateFileEntry(fileEntry, filePath, cb) {
}
const HASH_NAMES = [ 'sha1', 'sha256', 'md5', 'crc32' ];
function scanFile(filePath, options, iterator, cb) {
@ -617,23 +671,18 @@ function scanFile(filePath, options, iterator, cb) {
fileName : paths.basename(filePath),
};
function callIter(next) {
if(iterator) {
return iterator(stepInfo, next);
} else {
return next(null);
}
}
const callIter = (next) => {
return iterator ? iterator(stepInfo, next) : next(null);
};
function readErrorCallIter(origError, next) {
const readErrorCallIter = (origError, next) => {
stepInfo.step = 'read_error';
stepInfo.error = origError.message;
callIter( () => {
return next(origError);
});
}
};
let lastCalcHashPercent;
@ -676,47 +725,42 @@ function scanFile(filePath, options, iterator, cb) {
}
});
const stream = fs.createReadStream(filePath);
function updateHashes(data) {
async.each(hashesToCalc, (hashName, nextHash) => {
hashes[hashName].update(data);
return nextHash(null);
}, () => {
return stream.resume();
});
const updateHashes = (data) => {
for(let i = 0; i < hashesToCalc.length; ++i) {
hashes[hashesToCalc[i]].update(data);
}
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) {
updateHashes(data);
} else {
lastCalcHashPercent = stepInfo.calcHashPercent;
stepInfo.step = 'hash_update';
const chunkSize = 1024 * 64;
const buffer = Buffer.allocUnsafe(chunkSize);
callIter(err => {
fs.open(filePath, 'r', (err, fd) => {
if(err) {
stream.destroy(); // cancel read
return callback(err);
return readErrorCallIter(err, callback);
}
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;
async.each(hashesToCalc, (hashName, nextHash) => {
for(let i = 0; i < hashesToCalc.length; ++i) {
const hashName = hashesToCalc[i];
if('sha256' === hashName) {
stepInfo.sha256 = fileEntry.fileSha256 = hashes.sha256.digest('hex');
} else if('sha1' === hashName || 'md5' === hashName) {
@ -724,16 +768,44 @@ function scanFile(filePath, options, iterator, cb) {
} else if('crc32' === hashName) {
stepInfo.crc32 = fileEntry.meta.file_crc32 = hashes.crc32.finalize().toString(16);
}
}
return nextHash(null);
}, () => {
stepInfo.step = 'hash_finish';
return fs.close(fd, closeErr => {
if(closeErr) {
logError( { filePath, error : err.message }, 'Failed to close file');
}
return callIter(callback);
});
});
}
stream.on('error', err => {
return readErrorCallIter(err, callback);
stepInfo.bytesProcessed += bytesRead;
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) {
@ -747,7 +819,9 @@ function scanFile(filePath, options, iterator, cb) {
populateFileEntryWithArchive(fileEntry, filePath, stepInfo, callIter, err => {
if(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
});
} else {
@ -756,7 +830,9 @@ function scanFile(filePath, options, iterator, cb) {
});
} else {
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
});
}
@ -870,11 +946,48 @@ function scanFileAreaForChanges(areaInfo, options, iterator, cb) {
}
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 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) {
getAreaStats( (err, stats) => {
if(!err) {
StatLog.setNonPeristentSystemStat('file_base_area_stats', stats);
StatLog.setNonPersistentSystemStat(SysProps.FileBaseAreaStats, stats);
}
return cb(err);
});
}
function cleanUpTempSessionItems(cb) {
// find (old) temporary session items and nuke 'em
const filter = {
areaTag : WellKnownAreaTags.TempDownloads,
metaPairs : [
{
name : 'session_temp_dl',
value : 1
}
]
};
FileEntry.findFiles(filter, (err, fileIds) => {
if(err) {
return cb(err);
}
async.each(fileIds, (fileId, nextFileId) => {
const fileEntry = new FileEntry();
fileEntry.load(fileId, err => {
if(err) {
Log.warn( { fileId }, 'Failed loading temporary session download item for cleanup');
return nextFileId(null);
}
FileEntry.removeEntry(fileEntry, { removePhysFile : true }, err => {
if(err) {
Log.warn( { fileId : fileEntry.fileId, filePath : fileEntry.filePath }, 'Failed to clean up temporary session download item');
}
return nextFileId(null);
});
});
}, () => {
return cb(null);
});
});
}

View File

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

View File

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

View File

@ -1,6 +1,8 @@
/* jslint node: true */
'use strict';
const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
const uuidV4 = require('uuid/v4');
@ -64,7 +66,7 @@ module.exports = class FileBaseFilters {
}
load() {
let filtersProperty = this.client.user.properties.file_base_filters;
let filtersProperty = this.client.user.properties[UserProps.FileBaseFilters];
let defaulted;
if(!filtersProperty) {
filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters());
@ -90,11 +92,11 @@ module.exports = class FileBaseFilters {
}
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) {
return tags.toLowerCase().replace(/,?\s+|\,/g, ' ').trim();
return tags.toLowerCase().replace(/,?\s+|,/g, ' ').trim();
}
setActive(filterUuid) {
@ -102,7 +104,7 @@ module.exports = class FileBaseFilters {
if(activeFilter) {
this.activeFilter = activeFilter;
this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid);
this.client.user.persistProperty(UserProps.FileBaseFilterActiveUuid, filterUuid);
return true;
}
@ -129,11 +131,11 @@ module.exports = class FileBaseFilters {
}
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) {
return parseInt((user.properties.user_file_base_last_viewed || 0));
return parseInt((user.properties[UserProps.FileBaseLastViewedId] || 0));
}
static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) {
@ -150,6 +152,6 @@ module.exports = class FileBaseFilters {
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 ansi = require('./ansi_term.js');
const Errors = require('./enig_error.js').Errors;
const stringFormat = require('./string_format.js');
const FileAreaWeb = require('./file_area_web.js');
const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled;
const Config = require('./config.js').config;
const Config = require('./config.js').get;
// deps
const async = require('async');
@ -121,11 +120,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
return cb(Errors.DoesNotExist('Queue view does not exist'));
}
const queueListFormat = this.menuConfig.config.queueListFormat || '{webDlLink}';
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.setItems(this.dlQueue.items);
queueView.on('index update', idx => {
const fileEntry = this.dlQueue.items[idx];
@ -139,7 +134,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
}
generateAndDisplayBatchLink(cb) {
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes');
const expireTime = moment().add(Config().fileBase.web.expireMinutes, 'minutes');
FileAreaWeb.createAndServeTempBatchDownload(
this.client,
@ -183,6 +178,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
function prepareQueueDownloadLinks(callback) {
const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
const config = Config();
async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => {
FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => {
if(err) {
@ -190,7 +186,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
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(
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 Errors = require('./enig_error.js').Errors;
const getISOTimestampString = require('./database.js').getISOTimestampString;
const Config = require('./config.js').config;
const {
getISOTimestampString,
sanitizeString
} = require('./database.js');
const Config = require('./config.js').get;
// deps
const async = require('async');
@ -35,6 +38,9 @@ const FILE_WELL_KNOWN_META = {
tic_origin : null, // TIC "Origin"
tic_desc : null, // TIC "Desc"
tic_ldesc : null, // TIC "Ldesc" joined by '\n'
session_temp_dl : (v) => parseInt(v) ? true : false,
desc_sauce : (s) => JSON.parse(s) || {},
desc_long_sauce : (s) => JSON.parse(s) || {},
};
module.exports = class FileEntry {
@ -43,11 +49,7 @@ module.exports = class FileEntry {
this.fileId = options.fileId || 0;
this.areaTag = options.areaTag || '';
this.meta = options.meta || {
// values we always want
dl_count : 0,
};
this.meta = Object.assign( { dl_count : 0 }, options.meta);
this.hashTags = options.hashTags || new Set();
this.fileName = options.fileName;
this.storageTag = options.storageTag;
@ -202,7 +204,8 @@ module.exports = class FileEntry {
}
static getAreaStorageDirectoryByTag(storageTag) {
const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]);
const config = Config();
const storageLocation = (storageTag && config.fileBase.storageTags[storageTag]);
// absolute paths as-is
if(storageLocation && '/' === storageLocation.charAt(0)) {
@ -210,7 +213,7 @@ module.exports = class FileEntry {
}
// relative to |areaStoragePrefix|
return paths.join(Config.fileBase.areaStoragePrefix, storageLocation || '');
return paths.join(config.fileBase.areaStoragePrefix, storageLocation || '');
}
get filePath() {
@ -218,6 +221,19 @@ module.exports = class FileEntry {
return paths.join(storageDir, this.fileName);
}
static quickCheckExistsByPath(fullPath, cb) {
fileDb.get(
`SELECT COUNT() AS count
FROM file
WHERE file_name = ?
LIMIT 1;`,
[ paths.basename(fullPath) ],
(err, rows) => {
return err ? cb(err) : cb(null, rows.count > 0 ? true : false);
}
);
}
static persistUserRating(fileId, userId, rating, cb) {
return fileDb.run(
`REPLACE INTO file_user_rating (file_id, user_id, rating)
@ -355,7 +371,7 @@ module.exports = class FileEntry {
return Object.keys(FILE_WELL_KNOWN_META);
}
static findFileBySha(sha, cb) {
static findBySha(sha, cb) {
// full or partial SHA-256
fileDb.all(
`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) {
// convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
wc = wc.replace(/\*/g, '%').replace(/\?/g, '_');
@ -470,7 +509,7 @@ module.exports = class FileEntry {
sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`;
} else {
sql =
`SELECT DISTINCT f.file_id, f.${filter.sort}
`SELECT DISTINCT f.file_id
FROM file f`;
sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir;
@ -496,7 +535,7 @@ module.exports = class FileEntry {
if(filter.metaPairs && filter.metaPairs.length > 0) {
filter.metaPairs.forEach(mp => {
if(mp.wcValue) {
if(mp.wildcards) {
// convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
mp.value = mp.value.replace(/\*/g, '%').replace(/\?/g, '_');
appendWhereClause(
@ -523,18 +562,19 @@ module.exports = class FileEntry {
}
if(filter.terms && filter.terms.length > 0) {
// note the ':' in MATCH expr., see https://www.sqlite.org/cvstrac/wiki?p=FullTextIndex
appendWhereClause(
`f.file_id IN (
SELECT rowid
FROM file_fts
WHERE file_fts MATCH "${filter.terms.replace(/"/g,'""')}"
WHERE file_fts MATCH ":${sanitizeString(filter.terms)}"
)`
);
}
if(filter.tags && filter.tags.length > 0) {
// build list of quoted tags; filter.tags comes in as a space and/or comma separated values
const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${tag}"` ).join(',');
const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${sanitizeString(tag)}"` ).join(',');
appendWhereClause(
`f.file_id IN (
@ -565,13 +605,14 @@ module.exports = class FileEntry {
sql += ';';
const matchingFileIds = [];
fileDb.each(sql, (err, fileId) => {
if(fileId) {
matchingFileIds.push(fileId.file_id);
fileDb.all(sql, (err, rows) => {
if(err) {
return cb(err);
}
}, err => {
return cb(err, matchingFileIds);
if(!rows || 0 === rows.length) {
return cb(null, []); // no matches
}
return cb(null, rows.map(r => r.file_id));
});
}

View File

@ -3,18 +3,21 @@
// enigma-bbs
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 Errors = require('./enig_error.js').Errors;
const DownloadQueue = require('./download_queue.js');
const StatLog = require('./stat_log.js');
const FileEntry = require('./file_entry.js');
const Log = require('./logger.js').log;
const Events = require('./events.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
// deps
const async = require('async');
const _ = require('lodash');
const pty = require('ptyw.js');
const pty = require('node-pty');
const temptmp = require('temptmp').createTrackedSession('transfer_file');
const paths = require('path');
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
//
const config = Config();
if(options.extraArgs) {
if(options.extraArgs.protocol) {
this.protocolConfig = Config.fileTransferProtocols[options.extraArgs.protocol];
this.protocolConfig = config.fileTransferProtocols[options.extraArgs.protocol];
}
if(options.extraArgs.direction) {
@ -77,7 +81,7 @@ exports.getModule = class TransferFileModule extends MenuModule {
}
} else {
if(this.config.protocol) {
this.protocolConfig = Config.fileTransferProtocols[this.config.protocol];
this.protocolConfig = config.fileTransferProtocols[this.config.protocol];
}
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.sendQueue = this.sendQueue || [];
@ -312,11 +316,15 @@ exports.getModule = class TransferFileModule extends MenuModule {
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 => {
return callback(err, tempFileInfo.path);
});
});
});
},
function createArgs(tempFileListPath, callback) {
// initial args: ignore {filePaths} as we must break that into it's own sep array items
@ -361,17 +369,20 @@ exports.getModule = class TransferFileModule extends MenuModule {
'Executing external protocol'
);
const externalProc = pty.spawn(cmd, args, {
const spawnOpts = {
cols : this.client.term.termWidth,
rows : this.client.term.termHeight,
cwd : this.recvDirectory,
});
encoding : null, // don't bork our data!
};
const externalProc = pty.spawn(cmd, args, spawnOpts);
this.client.setTemporaryDirectDataHandler(data => {
// needed for things like sz/rz
if(external.escapeTelnet) {
const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape
externalProc.write(new Buffer(tmp, 'binary'));
externalProc.write(Buffer.from(tmp, 'binary'));
} else {
externalProc.write(data);
}
@ -381,7 +392,7 @@ exports.getModule = class TransferFileModule extends MenuModule {
// needed for things like sz/rz
if(external.escapeTelnet) {
const tmp = data.toString('binary').replace(/\xff/g, '\xff\xff'); // escape
this.client.term.rawWrite(new Buffer(tmp, 'binary'));
this.client.term.rawWrite(Buffer.from(tmp, 'binary'));
} else {
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
StatLog.incrementUserStat(this.client.user, 'dl_total_count', downloadCount);
StatLog.incrementUserStat(this.client.user, 'dl_total_bytes', downloadBytes);
StatLog.incrementSystemStat('dl_total_count', downloadCount);
StatLog.incrementSystemStat('dl_total_bytes', downloadBytes);
StatLog.incrementUserStat(this.client.user, UserProps.FileDlTotalCount, downloadCount);
StatLog.incrementUserStat(this.client.user, UserProps.FileDlTotalBytes, downloadBytes);
StatLog.incrementSystemStat(SysProps.FileDlTotalCount, downloadCount);
StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, downloadBytes);
fileIds.forEach(fileId => {
FileEntry.incrementAndPersistMetaValue(fileId, 'dl_count', 1);
@ -500,10 +512,11 @@ exports.getModule = class TransferFileModule extends MenuModule {
return next(null);
});
}, () => {
StatLog.incrementUserStat(this.client.user, 'ul_total_count', uploadCount);
StatLog.incrementUserStat(this.client.user, 'ul_total_bytes', uploadBytes);
StatLog.incrementSystemStat('ul_total_count', uploadCount);
StatLog.incrementSystemStat('ul_total_bytes', uploadBytes);
StatLog.incrementUserStat(this.client.user, UserProps.FileUlTotalCount, uploadCount);
StatLog.incrementUserStat(this.client.user, UserProps.FileUlTotalBytes, uploadBytes);
StatLog.incrementSystemStat(SysProps.FileUlTotalCount, uploadCount);
StatLog.incrementSystemStat(SysProps.FileUlTotalBytes, uploadBytes);
return cb(null);
});
@ -542,7 +555,16 @@ exports.getModule = class TransferFileModule extends MenuModule {
if(sentFileIds.length > 0) {
// remove items we sent from the D/L queue
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;
}

View File

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

View File

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