diff --git a/.eslintrc.json b/.eslintrc.json index c7757f0d..5e9b45b6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -24,6 +24,7 @@ "error", "always" ], - "comma-dangle": 0 + "comma-dangle": 0, + "no-trailing-spaces" :"warn" } } \ No newline at end of file diff --git a/README.md b/README.md index d7053333..93752343 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ENiGMA½ BBS Software -![alt text](docs/images/enigma-bbs.png "ENiGMA½ BBS") +![ENiGMA½ BBS](docs/images/enigma-bbs.png "ENiGMA½ BBS") ENiGMA½ is a modern BBS software with a nostalgic flair! @@ -8,7 +8,7 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! ## Features Available Now * Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows) * Unlimited multi node support (for all those BBS "callers"!) - * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods + * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based [mods](docs/mods.md) * [MCI support](docs/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 @@ -17,7 +17,7 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! * 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 - * [Door support](docs/doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), and [Exodus](https://oddnetwork.org/exodus/) support! + * [Door support](docs/doors.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/msg_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/web_server.md). Legacy X/Y/Z modem also supported! diff --git a/UPGRADE.md b/UPGRADE.md index 802a8092..c7651fcd 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,65 +1,90 @@ -# Introduction -This document covers basic upgrade notes for major ENiGMA½ version updates. - - -# Before Upgrading -* 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: - -```hjson -general: { - menuFile: my_bbs.hjson -} -``` - -After updating code, use a program such as DiffMerge to merge in updates to -`my_bbs.hjson` from the shipping `menu.hjson`. - - -# Upgrading the Code -Upgrading from GitHub is easy: - -```bash -cd /path/to/enigma-bbs -git pull -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.1-alpha to 0.0.4-alpha -## Node.js 6.x+ LTS is now **required** -You will need to upgrade Node.js to [6.x+](https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V6.md). If using [nvm](https://github.com/creationix/nvm) (you should be!) the process will go something like this: -```bash -nvm install 6 -nvm alias default 6 -``` - -### ES6 -Newly written code will use ES6 and a lot of code has started the migration process. Of note is the `MenuModule` class. If you have created a mod that inherits from `MenuModule`, you will need to upgrade your class to ES6. - -## Manual Database Upgrade -A few upgrades need to be made to your SQLite databases: - -```bash -rm db/file.sqltie3 # safe to delete this time as it was not used previously -sqlite3 db/message.sqlite -sqlite> INSERT INTO message_fts(message_fts) VALUES('rebuild'); -``` - -## Archiver Changes -If you have overridden or made additions to archivers in your `config.hjson` you will need to update them. See [Archive Configuration](docs/archive.md) and `core/config.js` - -## File Base Configuration -As 0.0.4-alpha contains file bases, you'll want to create a suitable configuration if you wish to use the feature. See [File Base Configuration](docs/file_base.md). +# Introduction +This document covers basic upgrade notes for major ENiGMA½ version updates. + + +# Before Upgrading +* 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: + +```hjson +general: { + menuFile: my_bbs.hjson +} +``` + +After updating code, use a program such as DiffMerge to merge in updates to +`my_bbs.hjson` from the shipping `menu.hjson`. + + +# Upgrading the Code +Upgrading from GitHub is easy: + +```bash +cd /path/to/enigma-bbs +git pull +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.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** +* `./mods/art` has been moved to `./art/general` +* `./mods` is now reserved for actual user addon modules +* Themes have been moved from `./mods/themes` to `./art/themes` + +With the change to the `./mods` directory, `@systemModule` is now implied for `module` declarations in `menu.hjson`. To use a user module in `./mods` you must specify `@userModule`! + +With the above changes, you'll need to to at least: +* Move your `~/.config/enigma-bbs/config.hjson` to `./config/config.hjson` or utlize the `--config` option. +* Move your `prompt.hjson` and `menu.hjson` (e.g. `myboardname.hjson`) to `./config` +* Move any non-theme art files, and theme directories to their appropriate locations mentioned above +* Move any module directories such as `message_post_evt` to `./mods/` +* Move any certificates, pub/private keys, etc. from `./misc` to `./config` +* Specify user modules as `@userModule:my_module_name` + +# 0.0.6-alpha to 0.0.7-alpha +No issues + +# 0.0.5-alpha to 0.0.6-alpha +No issues + +# 0.0.4-alpha to 0.0.5-alpha +No issues + +# 0.0.1-alpha to 0.0.4-alpha +## Node.js 6.x+ LTS is now **required** +You will need to upgrade Node.js to [6.x+](https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V6.md). If using [nvm](https://github.com/creationix/nvm) (you should be!) the process will go something like this: +```bash +nvm install 6 +nvm alias default 6 +``` + +### ES6 +Newly written code will use ES6 and a lot of code has started the migration process. Of note is the `MenuModule` class. If you have created a mod that inherits from `MenuModule`, you will need to upgrade your class to ES6. + +## Manual Database Upgrade +A few upgrades need to be made to your SQLite databases: + +```bash +rm db/file.sqltie3 # safe to delete this time as it was not used previously +sqlite3 db/message.sqlite +sqlite> INSERT INTO message_fts(message_fts) VALUES('rebuild'); +``` + +## Archiver Changes +If you have overridden or made additions to archivers in your `config.hjson` you will need to update them. See [Archive Configuration](docs/archive.md) and `core/config.js` + +## File Base Configuration +As 0.0.4-alpha contains file bases, you'll want to create a suitable configuration if you wish to use the feature. See [File Base Configuration](docs/file_base.md). diff --git a/WHATSNEW.md b/WHATSNEW.md new file mode 100644 index 00000000..667c296f --- /dev/null +++ b/WHATSNEW.md @@ -0,0 +1,26 @@ +# Whats New +This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub. + +## 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. +* New menu stack flags: `noHistory` now works as expected, and a new addition of `popParent`. See the default `menu.hjson` for usage. +* File structure changes making ENiGMA½ much easier to maintain and run in Docker. Thanks to RiPuk ([Dave Stephens](https://github.com/davestephens))! See [UPGRADE.md](UPGRADE.md) for details. +* Switch to pure JS [xxhash](https://github.com/mscdex/node-xxhash) instead of farmhash. Too many issues on ARM and other less popular CPUs with farmhash ([Dave Stephens](https://github.com/davestephens)) +* Native [CombatNet](http://combatnet.us/) support! ([Dave Stephens](https://github.com/davestephens)) +* Fix various issues with legacy DOS Telnet terminals. Note that some may still have issues with extensive CPR usage by ENiGMA½ that will be addressed in a future release. +* Added web (http://, https://) based download manager including batch downloads. Clickable links if using [VTXClient](https://github.com/codewar65/VTX_ClientServer)! +* General VTX hyperlink support for web links +* DEL vs Backspace key differences in FSE +* Correly parse oddball `INTL`, `TOPT`, `FMPT`, `Via`, etc. FTN kludge lines +* NetMail support! You can now send and receive NetMail. To send a NetMail address a external user using `Name
` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`) +* `oputil.js`: Added `mb areafix` command to quickly send AreaFix messages from the command line. You can manually send them from personal mail as well. +* `oputil.js fb rm|remove|del|delete` functionality to remove file base entries +* 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. + +...LOTS more! + +## Pre 0.0.8-alpha +See GitHub \ No newline at end of file diff --git a/mods/art/CONNECT1.ANS b/art/general/CONNECT1.ANS similarity index 100% rename from mods/art/CONNECT1.ANS rename to art/general/CONNECT1.ANS diff --git a/mods/art/DOORMANY.ANS b/art/general/DOORMANY.ANS similarity index 100% rename from mods/art/DOORMANY.ANS rename to art/general/DOORMANY.ANS diff --git a/mods/art/GNSPMPT.ANS b/art/general/GNSPMPT.ANS similarity index 100% rename from mods/art/GNSPMPT.ANS rename to art/general/GNSPMPT.ANS diff --git a/mods/art/LOGPMPT.ANS b/art/general/LOGPMPT.ANS similarity index 100% rename from mods/art/LOGPMPT.ANS rename to art/general/LOGPMPT.ANS diff --git a/mods/art/NEWSCAN.ANS b/art/general/NEWSCAN.ANS similarity index 100% rename from mods/art/NEWSCAN.ANS rename to art/general/NEWSCAN.ANS diff --git a/mods/art/NEWUSER1.ANS b/art/general/NEWUSER1.ANS similarity index 77% rename from mods/art/NEWUSER1.ANS rename to art/general/NEWUSER1.ANS index 70edd11e..267c331c 100644 Binary files a/mods/art/NEWUSER1.ANS and b/art/general/NEWUSER1.ANS differ diff --git a/mods/art/ONEADD.ANS b/art/general/ONEADD.ANS similarity index 100% rename from mods/art/ONEADD.ANS rename to art/general/ONEADD.ANS diff --git a/mods/art/ONELINER.ANS b/art/general/ONELINER.ANS similarity index 100% rename from mods/art/ONELINER.ANS rename to art/general/ONELINER.ANS diff --git a/mods/art/PRELOGAD.ANS b/art/general/PRELOGAD.ANS similarity index 100% rename from mods/art/PRELOGAD.ANS rename to art/general/PRELOGAD.ANS diff --git a/mods/art/WELCOME1.ANS b/art/general/WELCOME1.ANS similarity index 100% rename from mods/art/WELCOME1.ANS rename to art/general/WELCOME1.ANS diff --git a/mods/art/WELCOME2.ANS b/art/general/WELCOME2.ANS similarity index 100% rename from mods/art/WELCOME2.ANS rename to art/general/WELCOME2.ANS diff --git a/mods/art/demo_edit_text_view.ans b/art/general/demo_edit_text_view.ans similarity index 100% rename from mods/art/demo_edit_text_view.ans rename to art/general/demo_edit_text_view.ans diff --git a/mods/art/demo_edit_text_view1.ans b/art/general/demo_edit_text_view1.ans similarity index 100% rename from mods/art/demo_edit_text_view1.ans rename to art/general/demo_edit_text_view1.ans diff --git a/mods/art/demo_fse_local_user.ans b/art/general/demo_fse_local_user.ans similarity index 100% rename from mods/art/demo_fse_local_user.ans rename to art/general/demo_fse_local_user.ans diff --git a/mods/art/demo_fse_netmail_body.ans b/art/general/demo_fse_netmail_body.ans similarity index 100% rename from mods/art/demo_fse_netmail_body.ans rename to art/general/demo_fse_netmail_body.ans diff --git a/mods/art/demo_fse_netmail_footer_edit.ans b/art/general/demo_fse_netmail_footer_edit.ans similarity index 100% rename from mods/art/demo_fse_netmail_footer_edit.ans rename to art/general/demo_fse_netmail_footer_edit.ans diff --git a/mods/art/demo_fse_netmail_footer_edit_menu.ans b/art/general/demo_fse_netmail_footer_edit_menu.ans similarity index 100% rename from mods/art/demo_fse_netmail_footer_edit_menu.ans rename to art/general/demo_fse_netmail_footer_edit_menu.ans diff --git a/mods/art/demo_fse_netmail_header.ans b/art/general/demo_fse_netmail_header.ans similarity index 100% rename from mods/art/demo_fse_netmail_header.ans rename to art/general/demo_fse_netmail_header.ans diff --git a/mods/art/demo_fse_netmail_help.ans b/art/general/demo_fse_netmail_help.ans similarity index 100% rename from mods/art/demo_fse_netmail_help.ans rename to art/general/demo_fse_netmail_help.ans diff --git a/mods/art/demo_horizontal_menu_view1.ans b/art/general/demo_horizontal_menu_view1.ans similarity index 100% rename from mods/art/demo_horizontal_menu_view1.ans rename to art/general/demo_horizontal_menu_view1.ans diff --git a/mods/art/demo_mask_edit_text_view1.ans b/art/general/demo_mask_edit_text_view1.ans similarity index 100% rename from mods/art/demo_mask_edit_text_view1.ans rename to art/general/demo_mask_edit_text_view1.ans diff --git a/mods/art/demo_multi_line_edit_text_view1.ans b/art/general/demo_multi_line_edit_text_view1.ans similarity index 100% rename from mods/art/demo_multi_line_edit_text_view1.ans rename to art/general/demo_multi_line_edit_text_view1.ans diff --git a/mods/art/demo_selection_vm.ans b/art/general/demo_selection_vm.ans similarity index 100% rename from mods/art/demo_selection_vm.ans rename to art/general/demo_selection_vm.ans diff --git a/mods/art/demo_spin_and_toggle.ans b/art/general/demo_spin_and_toggle.ans similarity index 100% rename from mods/art/demo_spin_and_toggle.ans rename to art/general/demo_spin_and_toggle.ans diff --git a/mods/art/demo_vertical_menu_view1.ans b/art/general/demo_vertical_menu_view1.ans similarity index 100% rename from mods/art/demo_vertical_menu_view1.ans rename to art/general/demo_vertical_menu_view1.ans diff --git a/mods/art/erc.ans b/art/general/erc.ans similarity index 100% rename from mods/art/erc.ans rename to art/general/erc.ans diff --git a/mods/art/menu_prompt.ans b/art/general/menu_prompt.ans similarity index 100% rename from mods/art/menu_prompt.ans rename to art/general/menu_prompt.ans diff --git a/mods/art/msg_area_footer_view.ans b/art/general/msg_area_footer_view.ans similarity index 100% rename from mods/art/msg_area_footer_view.ans rename to art/general/msg_area_footer_view.ans diff --git a/mods/art/msg_area_list.ans b/art/general/msg_area_list.ans similarity index 100% rename from mods/art/msg_area_list.ans rename to art/general/msg_area_list.ans diff --git a/mods/art/msg_area_post_header.ans b/art/general/msg_area_post_header.ans similarity index 100% rename from mods/art/msg_area_post_header.ans rename to art/general/msg_area_post_header.ans diff --git a/mods/art/msg_area_view_header.ans b/art/general/msg_area_view_header.ans similarity index 100% rename from mods/art/msg_area_view_header.ans rename to art/general/msg_area_view_header.ans diff --git a/mods/art/test.ans b/art/general/test.ans similarity index 100% rename from mods/art/test.ans rename to art/general/test.ans diff --git a/mods/themes/luciano_blocktronics/BBSADD.ANS b/art/themes/luciano_blocktronics/BBSADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/BBSADD.ANS rename to art/themes/luciano_blocktronics/BBSADD.ANS diff --git a/mods/themes/luciano_blocktronics/BBSLIST.ANS b/art/themes/luciano_blocktronics/BBSLIST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/BBSLIST.ANS rename to art/themes/luciano_blocktronics/BBSLIST.ANS diff --git a/mods/themes/luciano_blocktronics/CCHANGE.ANS b/art/themes/luciano_blocktronics/CCHANGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/CCHANGE.ANS rename to art/themes/luciano_blocktronics/CCHANGE.ANS diff --git a/mods/themes/luciano_blocktronics/CHANGE.ANS b/art/themes/luciano_blocktronics/CHANGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/CHANGE.ANS rename to art/themes/luciano_blocktronics/CHANGE.ANS diff --git a/mods/themes/luciano_blocktronics/CONFSCR.ANS b/art/themes/luciano_blocktronics/CONFSCR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/CONFSCR.ANS rename to art/themes/luciano_blocktronics/CONFSCR.ANS diff --git a/mods/themes/luciano_blocktronics/DONE.ANS b/art/themes/luciano_blocktronics/DONE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/DONE.ANS rename to art/themes/luciano_blocktronics/DONE.ANS diff --git a/mods/themes/luciano_blocktronics/DOORMNU.ANS b/art/themes/luciano_blocktronics/DOORMNU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/DOORMNU.ANS rename to art/themes/luciano_blocktronics/DOORMNU.ANS diff --git a/mods/themes/luciano_blocktronics/FAREASEL.ANS b/art/themes/luciano_blocktronics/FAREASEL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FAREASEL.ANS rename to art/themes/luciano_blocktronics/FAREASEL.ANS diff --git a/mods/themes/luciano_blocktronics/FBHELP.ANS b/art/themes/luciano_blocktronics/FBHELP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBHELP.ANS rename to art/themes/luciano_blocktronics/FBHELP.ANS diff --git a/mods/themes/luciano_blocktronics/FBNORES.ANS b/art/themes/luciano_blocktronics/FBNORES.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBNORES.ANS rename to art/themes/luciano_blocktronics/FBNORES.ANS diff --git a/mods/themes/luciano_blocktronics/FBRWSE.ANS b/art/themes/luciano_blocktronics/FBRWSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBRWSE.ANS rename to art/themes/luciano_blocktronics/FBRWSE.ANS diff --git a/mods/themes/luciano_blocktronics/FDETAIL.ANS b/art/themes/luciano_blocktronics/FDETAIL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETAIL.ANS rename to art/themes/luciano_blocktronics/FDETAIL.ANS diff --git a/mods/themes/luciano_blocktronics/FDETGEN.ANS b/art/themes/luciano_blocktronics/FDETGEN.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETGEN.ANS rename to art/themes/luciano_blocktronics/FDETGEN.ANS diff --git a/mods/themes/luciano_blocktronics/FDETLST.ANS b/art/themes/luciano_blocktronics/FDETLST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETLST.ANS rename to art/themes/luciano_blocktronics/FDETLST.ANS diff --git a/mods/themes/luciano_blocktronics/FDETNFO.ANS b/art/themes/luciano_blocktronics/FDETNFO.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETNFO.ANS rename to art/themes/luciano_blocktronics/FDETNFO.ANS diff --git a/mods/themes/luciano_blocktronics/FDLMGR.ANS b/art/themes/luciano_blocktronics/FDLMGR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDLMGR.ANS rename to art/themes/luciano_blocktronics/FDLMGR.ANS diff --git a/mods/themes/luciano_blocktronics/FEMPTYQ.ANS b/art/themes/luciano_blocktronics/FEMPTYQ.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FEMPTYQ.ANS rename to art/themes/luciano_blocktronics/FEMPTYQ.ANS diff --git a/mods/themes/luciano_blocktronics/FFILEDT.ANS b/art/themes/luciano_blocktronics/FFILEDT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FFILEDT.ANS rename to art/themes/luciano_blocktronics/FFILEDT.ANS diff --git a/mods/themes/luciano_blocktronics/FILPMPT.ANS b/art/themes/luciano_blocktronics/FILPMPT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FILPMPT.ANS rename to art/themes/luciano_blocktronics/FILPMPT.ANS diff --git a/mods/themes/luciano_blocktronics/FMENU.ANS b/art/themes/luciano_blocktronics/FMENU.ANS similarity index 85% rename from mods/themes/luciano_blocktronics/FMENU.ANS rename to art/themes/luciano_blocktronics/FMENU.ANS index a187491b..55879dd7 100644 Binary files a/mods/themes/luciano_blocktronics/FMENU.ANS and b/art/themes/luciano_blocktronics/FMENU.ANS differ diff --git a/mods/themes/luciano_blocktronics/FNEWBRWSE.ANS b/art/themes/luciano_blocktronics/FNEWBRWSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FNEWBRWSE.ANS rename to art/themes/luciano_blocktronics/FNEWBRWSE.ANS diff --git a/mods/themes/luciano_blocktronics/FORGOTPW.ANS b/art/themes/luciano_blocktronics/FORGOTPW.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FORGOTPW.ANS rename to art/themes/luciano_blocktronics/FORGOTPW.ANS diff --git a/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS b/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS rename to art/themes/luciano_blocktronics/FORGOTPWSENT.ANS diff --git a/mods/themes/luciano_blocktronics/FPROSEL.ANS b/art/themes/luciano_blocktronics/FPROSEL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FPROSEL.ANS rename to art/themes/luciano_blocktronics/FPROSEL.ANS diff --git a/mods/themes/luciano_blocktronics/FSEARCH.ANS b/art/themes/luciano_blocktronics/FSEARCH.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FSEARCH.ANS rename to art/themes/luciano_blocktronics/FSEARCH.ANS diff --git a/art/themes/luciano_blocktronics/FWDLMGR.ANS b/art/themes/luciano_blocktronics/FWDLMGR.ANS new file mode 100644 index 00000000..b0e7fffc Binary files /dev/null and b/art/themes/luciano_blocktronics/FWDLMGR.ANS differ diff --git a/mods/themes/luciano_blocktronics/IDLELOG.ANS b/art/themes/luciano_blocktronics/IDLELOG.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/IDLELOG.ANS rename to art/themes/luciano_blocktronics/IDLELOG.ANS diff --git a/mods/themes/luciano_blocktronics/LASTCALL.ANS b/art/themes/luciano_blocktronics/LASTCALL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/LASTCALL.ANS rename to art/themes/luciano_blocktronics/LASTCALL.ANS diff --git a/mods/themes/luciano_blocktronics/LETTER.ANS b/art/themes/luciano_blocktronics/LETTER.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/LETTER.ANS rename to art/themes/luciano_blocktronics/LETTER.ANS diff --git a/mods/themes/luciano_blocktronics/MAILMNU.ANS b/art/themes/luciano_blocktronics/MAILMNU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MAILMNU.ANS rename to art/themes/luciano_blocktronics/MAILMNU.ANS diff --git a/mods/themes/luciano_blocktronics/MATRIX.ANS b/art/themes/luciano_blocktronics/MATRIX.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MATRIX.ANS rename to art/themes/luciano_blocktronics/MATRIX.ANS diff --git a/mods/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MMENU.ANS rename to art/themes/luciano_blocktronics/MMENU.ANS diff --git a/mods/themes/luciano_blocktronics/MNUPRMT.ANS b/art/themes/luciano_blocktronics/MNUPRMT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MNUPRMT.ANS rename to art/themes/luciano_blocktronics/MNUPRMT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGBODY.ANS b/art/themes/luciano_blocktronics/MSGBODY.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGBODY.ANS rename to art/themes/luciano_blocktronics/MSGBODY.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEFTR.ANS b/art/themes/luciano_blocktronics/MSGEFTR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEFTR.ANS rename to art/themes/luciano_blocktronics/MSGEFTR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEHDR.ANS b/art/themes/luciano_blocktronics/MSGEHDR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEHDR.ANS rename to art/themes/luciano_blocktronics/MSGEHDR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEHLP.ANS b/art/themes/luciano_blocktronics/MSGEHLP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEHLP.ANS rename to art/themes/luciano_blocktronics/MSGEHLP.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEMFT.ANS b/art/themes/luciano_blocktronics/MSGEMFT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEMFT.ANS rename to art/themes/luciano_blocktronics/MSGEMFT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGLIST.ANS b/art/themes/luciano_blocktronics/MSGLIST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGLIST.ANS rename to art/themes/luciano_blocktronics/MSGLIST.ANS diff --git a/art/themes/luciano_blocktronics/MSGMNU.ANS b/art/themes/luciano_blocktronics/MSGMNU.ANS new file mode 100644 index 00000000..ce6f4815 Binary files /dev/null and b/art/themes/luciano_blocktronics/MSGMNU.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGPMPT.ANS b/art/themes/luciano_blocktronics/MSGPMPT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGPMPT.ANS rename to art/themes/luciano_blocktronics/MSGPMPT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGQUOT.ANS b/art/themes/luciano_blocktronics/MSGQUOT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGQUOT.ANS rename to art/themes/luciano_blocktronics/MSGQUOT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVFTR.ANS b/art/themes/luciano_blocktronics/MSGVFTR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVFTR.ANS rename to art/themes/luciano_blocktronics/MSGVFTR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVHDR.ANS b/art/themes/luciano_blocktronics/MSGVHDR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVHDR.ANS rename to art/themes/luciano_blocktronics/MSGVHDR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVHLP.ANS b/art/themes/luciano_blocktronics/MSGVHLP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVHLP.ANS rename to art/themes/luciano_blocktronics/MSGVHLP.ANS diff --git a/mods/themes/luciano_blocktronics/NEWMSGS.ANS b/art/themes/luciano_blocktronics/NEWMSGS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/NEWMSGS.ANS rename to art/themes/luciano_blocktronics/NEWMSGS.ANS diff --git a/mods/themes/luciano_blocktronics/NUA.ANS b/art/themes/luciano_blocktronics/NUA.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/NUA.ANS rename to art/themes/luciano_blocktronics/NUA.ANS diff --git a/mods/themes/luciano_blocktronics/ONEADD.ANS b/art/themes/luciano_blocktronics/ONEADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ONEADD.ANS rename to art/themes/luciano_blocktronics/ONEADD.ANS diff --git a/mods/themes/luciano_blocktronics/ONELINER.ANS b/art/themes/luciano_blocktronics/ONELINER.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ONELINER.ANS rename to art/themes/luciano_blocktronics/ONELINER.ANS diff --git a/mods/themes/luciano_blocktronics/PAUSE.ANS b/art/themes/luciano_blocktronics/PAUSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/PAUSE.ANS rename to art/themes/luciano_blocktronics/PAUSE.ANS diff --git a/mods/themes/luciano_blocktronics/RATEFILE.ANS b/art/themes/luciano_blocktronics/RATEFILE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RATEFILE.ANS rename to art/themes/luciano_blocktronics/RATEFILE.ANS diff --git a/mods/themes/luciano_blocktronics/RUMORADD.ANS b/art/themes/luciano_blocktronics/RUMORADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RUMORADD.ANS rename to art/themes/luciano_blocktronics/RUMORADD.ANS diff --git a/mods/themes/luciano_blocktronics/RUMORS.ANS b/art/themes/luciano_blocktronics/RUMORS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RUMORS.ANS rename to art/themes/luciano_blocktronics/RUMORS.ANS diff --git a/art/themes/luciano_blocktronics/SETFNSDATE.ANS b/art/themes/luciano_blocktronics/SETFNSDATE.ANS new file mode 100644 index 00000000..8294a6f7 Binary files /dev/null and b/art/themes/luciano_blocktronics/SETFNSDATE.ANS differ diff --git a/art/themes/luciano_blocktronics/SETMNSDATE.ANS b/art/themes/luciano_blocktronics/SETMNSDATE.ANS new file mode 100644 index 00000000..4d3b43a3 Binary files /dev/null and b/art/themes/luciano_blocktronics/SETMNSDATE.ANS differ diff --git a/mods/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/STATUS.ANS rename to art/themes/luciano_blocktronics/STATUS.ANS diff --git a/mods/themes/luciano_blocktronics/SYSSTAT.ANS b/art/themes/luciano_blocktronics/SYSSTAT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/SYSSTAT.ANS rename to art/themes/luciano_blocktronics/SYSSTAT.ANS diff --git a/mods/themes/luciano_blocktronics/TBRIDGE.ANS b/art/themes/luciano_blocktronics/TBRIDGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/TBRIDGE.ANS rename to art/themes/luciano_blocktronics/TBRIDGE.ANS diff --git a/mods/themes/luciano_blocktronics/TOONODE.ANS b/art/themes/luciano_blocktronics/TOONODE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/TOONODE.ANS rename to art/themes/luciano_blocktronics/TOONODE.ANS diff --git a/mods/themes/luciano_blocktronics/ULCHECK.ANS b/art/themes/luciano_blocktronics/ULCHECK.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULCHECK.ANS rename to art/themes/luciano_blocktronics/ULCHECK.ANS diff --git a/mods/themes/luciano_blocktronics/ULDETAIL.ANS b/art/themes/luciano_blocktronics/ULDETAIL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULDETAIL.ANS rename to art/themes/luciano_blocktronics/ULDETAIL.ANS diff --git a/mods/themes/luciano_blocktronics/ULDUPES.ANS b/art/themes/luciano_blocktronics/ULDUPES.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULDUPES.ANS rename to art/themes/luciano_blocktronics/ULDUPES.ANS diff --git a/mods/themes/luciano_blocktronics/ULNOAREA.ANS b/art/themes/luciano_blocktronics/ULNOAREA.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULNOAREA.ANS rename to art/themes/luciano_blocktronics/ULNOAREA.ANS diff --git a/mods/themes/luciano_blocktronics/ULOPTS.ANS b/art/themes/luciano_blocktronics/ULOPTS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULOPTS.ANS rename to art/themes/luciano_blocktronics/ULOPTS.ANS diff --git a/mods/themes/luciano_blocktronics/USERLOG.ANS b/art/themes/luciano_blocktronics/USERLOG.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/USERLOG.ANS rename to art/themes/luciano_blocktronics/USERLOG.ANS diff --git a/mods/themes/luciano_blocktronics/USERLST.ANS b/art/themes/luciano_blocktronics/USERLST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/USERLST.ANS rename to art/themes/luciano_blocktronics/USERLST.ANS diff --git a/mods/themes/luciano_blocktronics/WHOSON.ANS b/art/themes/luciano_blocktronics/WHOSON.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/WHOSON.ANS rename to art/themes/luciano_blocktronics/WHOSON.ANS diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson similarity index 96% rename from mods/themes/luciano_blocktronics/theme.hjson rename to art/themes/luciano_blocktronics/theme.hjson index d553ed83..19f63194 100644 --- a/mods/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -787,6 +787,27 @@ } } + 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}" + } + + 0: { + mci: { + VM1: { + height: 8 + } + HM2: { + width: 50 + focusTextStyle: first lower + } + } + } + } + fileBaseUploadFiles: { config: { // processing diff --git a/mods/menu.hjson b/config/menu.hjson similarity index 95% rename from mods/menu.hjson rename to config/menu.hjson index 15a59cb8..386c6f89 100644 --- a/mods/menu.hjson +++ b/config/menu.hjson @@ -215,16 +215,14 @@ desc: Applying options: { pause: true - cls: true + cls: true + menuFlags: [ "noHistory" ] } } newUserApplication: { module: nua art: NUA - options: { - menuFlags: [ "noHistory" ] - } next: [ { // Initial SysOp does not send feedback to themselves @@ -333,6 +331,7 @@ options: { pause: true cls: true + menuFlags: [ "noHistory" ] } } @@ -344,9 +343,6 @@ module: nua art: NUA fallback: logoff - options: { - menuFlags: [ "noHistory" ] - } next: newUserFeedbackToSysOpPreamble form: { 0: { @@ -709,7 +705,7 @@ fullLoginSequenceNewScan: { desc: Performing New Scan - module: @systemModule:new_scan + module: new_scan art: NEWSCAN next: fullLoginSequenceSysStats config: { @@ -1066,7 +1062,7 @@ } mainMenuUserConfig: { - module: @systemModule:user_config + module: user_config art: CONFSCR form: { 0: { @@ -1157,7 +1153,7 @@ mainMenuGlobalNewScan: { desc: Performing New Scan - module: @systemModule:new_scan + module: new_scan art: NEWSCAN config: { messageListMenu: newScanMessageList @@ -1646,6 +1642,10 @@ action: @menu:doorParty } { + value: { command: "CN" } + action: @menu:combatNet + } + { value: { command: "AGENT" } action: @menu:telnetBridgeAgency } @@ -1689,10 +1689,10 @@ } } - // DoorParty! support. You'll need to registger to obtain credentials + // DoorParty! support. You'll need to register to obtain credentials doorParty: { desc: Using DoorParty! - module: @systemModule:door_party + module: door_party config: { username: XXXXXXXX password: XXXXXXXX @@ -1700,6 +1700,16 @@ } } + // CombatNet support. You'll need to register at http://combatnet.us/ to obtain credentials + combatNet: { + desc: Using CombatNet + module: combatnet + config: { + bbsTag: CBNxxx + password: XXXXXXXXX + } + } + telnetBridgeAgency: { desc: Connected to HappyLand BBS module: telnet_bridge @@ -1756,6 +1766,10 @@ value: { command: "]" } action: @systemMethod:nextArea } + { + value: { command: "D" } + action: @menu:messageAreaSetNewScanDate + } { value: 1 action: @menu:messageArea @@ -1793,6 +1807,47 @@ } } + messageAreaSetNewScanDate: { + module: set_newscan_date + desc: Message Base + art: SETMNSDATE + config: { + target: message + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + SM2: { + argName: targetSelection + submit: false + justify: right + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + messageAreaChangeCurrentArea: { // :TODO: rename this art to ACHANGE art: CHANGE @@ -2314,7 +2369,7 @@ ET2: { argName: to focus: true - validate: @systemMethod:validateUserNameExists + validate: @systemMethod:validateGeneralMailAddressedTo } ET3: { argName: subject @@ -2475,6 +2530,10 @@ value: { menuOption: "D" } action: @menu:fileBaseDownloadManager } + { + value: { menuOption: "W" } + action: @menu:fileBaseWebDownloadManager + } { value: { menuOption: "U" } action: @menu:fileBaseUploadFiles @@ -2483,9 +2542,49 @@ value: { menuOption: "S" } action: @menu:fileBaseSearch } + { + value: { menuOption: "P" } + action: @menu:fileBaseSetNewScanDate + } ] } + fileBaseSetNewScanDate: { + module: set_newscan_date + desc: File Base + art: SETFNSDATE + config: { + target: file + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + fileBaseListEntries: { module: file_area_list desc: Browsing Files @@ -2706,7 +2805,7 @@ art: FBNORES options: { pause: true - menuFlags: [ "noHistory" ] + menuFlags: [ "noHistory", "popParent" ] } } @@ -2742,6 +2841,7 @@ "rating", "estimated year", "size", + "filename", ] argName: sortByIndex } @@ -2933,12 +3033,70 @@ } } + fileBaseWebDownloadManager: { + desc: Web D/L Manager + module: file_base_web_download_manager + config: { + art: { + queueManager: FWDLMGR + batchList: BATDLINF + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "get batch link", "quit", "help" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:getBatchLink + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "b", "shift + b" ] + action: @method:getBatchLink + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + fileBaseDownloadManagerEmptyQueue: { desc: Empty Download Queue art: FEMPTYQ options: { pause: true - menuFlags: [ "noHistory" ] + menuFlags: [ "noHistory", "popParent" ] } } @@ -3108,13 +3266,13 @@ art: ULNOAREA options: { pause: true - menuFlags: [ "noHistory" ] + menuFlags: [ "noHistory", "popParent" ] } } sendFilesToUser: { desc: Downloading - module: @systemModule:file_transfer + module: file_transfer config: { // defaults - generally use extraArgs protocol: zmodem8kSexyz @@ -3124,7 +3282,7 @@ recvFilesFromUser: { desc: Uploading - module: @systemModule:file_transfer + module: file_transfer config: { // defaults - generally use extraArgs protocol: zmodem8kSexyz @@ -3496,7 +3654,7 @@ "art" : "test.ans" }, "demoFullScreenEditor" : { - "module" : "@systemModule:fse", + "module" : "fse", "config" : { "editorType" : "netMail", "art" : { diff --git a/mods/prompt.hjson b/config/prompt.hjson similarity index 95% rename from mods/prompt.hjson rename to config/prompt.hjson index 83f01ec5..b6bf6691 100644 --- a/mods/prompt.hjson +++ b/config/prompt.hjson @@ -72,6 +72,20 @@ } } + loginSequenceFlavorSelect: { + art: LOGINSEL + mci: { + TM1: { + argName: promptValue + items: [ "yes", "no" ] + focus: true + focusItemIndex: 1 + hotKeys: { Y: 0, N: 1 } + hotKeySubmit: true + } + } + } + loginGlobalNewScan: { art: GNSPMPT mci: { diff --git a/mods/abracadabra.js b/core/abracadabra.js similarity index 94% rename from mods/abracadabra.js rename to core/abracadabra.js index a84d2c63..85d1e205 100644 --- a/mods/abracadabra.js +++ b/core/abracadabra.js @@ -1,11 +1,11 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const DropFile = require('../core/dropfile.js').DropFile; -const door = require('../core/door.js'); -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const DropFile = require('./dropfile.js').DropFile; +const door = require('./door.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); const async = require('async'); const assert = require('assert'); diff --git a/core/ansi_prep.js b/core/ansi_prep.js index 29bd5ee4..45b93d32 100644 --- a/core/ansi_prep.js +++ b/core/ansi_prep.js @@ -23,6 +23,8 @@ module.exports = function ansiPrep(input, options, cb) { options.rows = options.rows || options.termHeight || 'auto'; options.startCol = options.startCol || 1; options.exportMode = options.exportMode || false; + options.fillLines = _.get(options, 'fillLines', true); + options.indent = options.indent || 0; // in auto we start out at 25 rows, but can always expand for more const canvas = Array.from( { length : 'auto' === options.rows ? 25 : options.rows }, () => Array.from( { length : options.cols}, () => new Object() ) ); @@ -111,15 +113,18 @@ module.exports = function ansiPrep(input, options, cb) { const lastCol = getLastPopulatedColumn(row) + 1; let i; - line = ''; + line = options.indent ? + output.length > 0 ? ' '.repeat(options.indent) : '' : + ''; + for(i = 0; i < lastCol; ++i) { const col = row[i]; - sgr = 0 === i ? + sgr = !options.asciiMode && 0 === i ? col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' : ''; - if(col.sgr) { + if(!options.asciiMode && col.sgr) { sgr += ANSI.getSGRFromGraphicRendition(col.sgr); } @@ -129,7 +134,10 @@ module.exports = function ansiPrep(input, options, cb) { output += line; if(i < row.length) { - output += `${ANSI.blackBG()}${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`; + output += `${options.asciiMode ? '' : ANSI.blackBG()}`; + if(options.fillLines) { + output += `${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`; + } } if(options.startCol + i < options.termWidth || options.forceLineTerm) { diff --git a/core/ansi_term.js b/core/ansi_term.js index 8b4094f1..7eb10ec2 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -56,7 +56,7 @@ exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias; exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias; exports.setCursorStyle = setCursorStyle; exports.setEmulatedBaudRate = setEmulatedBaudRate; - +exports.vtxHyperlink = vtxHyperlink; // // See also @@ -485,3 +485,14 @@ function setEmulatedBaudRate(rate) { }[rate] || 0; return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed); } + +function vtxHyperlink(client, url, len) { + if(!client.terminalSupports('vtx_hyperlink')) { + return ''; + } + + len = len || url.length; + + url = url.split('').map(c => c.charCodeAt(0)).join(';'); + return `${ESC_CSI}1;${len};1;1;${url}\\`; +} \ No newline at end of file diff --git a/core/art.js b/core/art.js index 28657fc0..19e0bafe 100644 --- a/core/art.js +++ b/core/art.js @@ -14,7 +14,7 @@ const paths = require('path'); const assert = require('assert'); const iconv = require('iconv-lite'); const _ = require('lodash'); -const farmhash = require('farmhash'); +const xxhash = require('xxhash'); exports.getArt = getArt; exports.getArtFromPath = getArtFromPath; @@ -288,7 +288,7 @@ function display(client, art, options, cb) { } if(!options.disableMciCache) { - artHash = farmhash.hash32(art); + artHash = xxhash.hash(new Buffer(art), 0xCAFEBABE); // see if we have a mciMap cached for this art if(client.mciCache) { diff --git a/core/asset.js b/core/asset.js index 0731a1b7..9f2831b7 100644 --- a/core/asset.js +++ b/core/asset.js @@ -21,7 +21,7 @@ const ALL_ASSETS = [ 'art', 'menu', 'method', - 'module', + 'userModule', 'systemMethod', 'systemModule', 'prompt', @@ -58,12 +58,12 @@ function getAssetWithShorthand(spec, defaultType) { assert(_.isString(asset.type)); return asset; - } else { - return { - type : defaultType, - asset : spec, - }; } + + return { + type : defaultType, + asset : spec, + }; } function getArtAsset(spec) { @@ -78,13 +78,14 @@ function getArtAsset(spec) { } function getModuleAsset(spec) { - const asset = getAssetWithShorthand(spec, 'module'); + const asset = getAssetWithShorthand(spec, 'systemModule'); if(!asset) { return null; } - assert( ['module', 'systemModule' ].indexOf(asset.type) > -1); + assert( ['userModule', 'systemModule' ].includes(asset.type) ); + return asset; } diff --git a/core/bbs.js b/core/bbs.js index c43d63a3..43bf7cf3 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -29,11 +29,12 @@ const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby'; const HELP = `${ENIGMA_COPYRIGHT} usage: main.js +eg : main.js --config /enigma_install_path/config/ valid args: --version : display version --help : displays this help - --config PATH : override default config.hjson path + --config PATH : override default config path `; function printHelpAndExit() { @@ -56,7 +57,8 @@ function main() { return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath)); }, function initConfig(configPath, configPathSupplied, callback) { - conf.init(resolvePath(configPath), function configInit(err) { + const configFile = configPath + 'config.hjson'; + conf.init(resolvePath(configFile), function configInit(err) { // // If the user supplied a path and we can't read/parse it @@ -65,7 +67,7 @@ function main() { if(err) { if('ENOENT' === err.code) { if(configPathSupplied) { - console.error('Configuration file does not exist: ' + configPath); + console.error('Configuration file does not exist: ' + configFile); } else { configPathSupplied = null; // make non-fatal; we'll go with defaults } @@ -234,6 +236,17 @@ function initialize(cb) { } ); }, + function initFileAreaStats(callback) { + const getAreaStats = require('./file_base_area.js').getAreaStats; + getAreaStats( (err, stats) => { + if(!err) { + const StatLog = require('./stat_log.js'); + StatLog.setNonPeristentSystemStat('file_base_area_stats', stats); + } + + return callback(null); + }); + }, function initMCI(callback) { return require('./predefined_mci.js').init(callback); }, diff --git a/mods/bbs_link.js b/core/bbs_link.js similarity index 97% rename from mods/bbs_link.js rename to core/bbs_link.js index 0cf0a5db..be341115 100644 --- a/mods/bbs_link.js +++ b/core/bbs_link.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; +const MenuModule = require('./menu_module.js').MenuModule; +const resetScreen = require('./ansi_term.js').resetScreen; const async = require('async'); const _ = require('lodash'); diff --git a/mods/bbs_list.js b/core/bbs_list.js similarity index 95% rename from mods/bbs_list.js rename to core/bbs_list.js index e24beba6..33a7ff59 100644 --- a/mods/bbs_list.js +++ b/core/bbs_list.js @@ -2,13 +2,18 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const getModDatabasePath = require('../core/database.js').getModDatabasePath; -const ViewController = require('../core/view_controller.js').ViewController; -const ansi = require('../core/ansi_term.js'); -const theme = require('../core/theme.js'); -const User = require('../core/user.js'); -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; + +const { + getModDatabasePath, + getTransactionDatabase +} = require('./database.js'); + +const ViewController = require('./view_controller.js').ViewController; +const ansi = require('./ansi_term.js'); +const theme = require('./theme.js'); +const User = require('./user.js'); +const stringFormat = require('./string_format.js'); // deps const async = require('async'); @@ -392,10 +397,10 @@ exports.getModule = class BBSListModule extends MenuModule { async.series( [ function openDatabase(callback) { - self.database = new sqlite3.Database( + self.database = getTransactionDatabase(new sqlite3.Database( getModDatabasePath(moduleInfo), callback - ); + )); }, function createTables(callback) { self.database.serialize( () => { diff --git a/core/client.js b/core/client.js index 4501396d..424748a6 100644 --- a/core/client.js +++ b/core/client.js @@ -306,7 +306,21 @@ function Client(input, output) { key.name = 'line feed'; } else if('\t' === s) { key.name = 'tab'; - } else if ('\b' === s || '\x7f' === s || '\x1b\x7f' === s || '\x1b\b' === s) { + } else if('\x7f' === s) { + // + // Backspace vs delete is a crazy thing, especially in *nix. + // - ANSI-BBS uses 0x7f for DEL + // - xterm et. al clients send 0x7f for backspace... ugg. + // + // See http://www.hypexr.org/linux_ruboff.php + // And a great discussion @ https://lists.debian.org/debian-i18n/1998/04/msg00015.html + // + if(self.term.isNixTerm()) { + key.name = 'backspace'; + } else { + key.name = 'delete'; + } + } else if ('\b' === s || '\x1b\x7f' === s || '\x1b\b' === s) { // backspace, CTRL-H key.name = 'backspace'; key.meta = ('\x1b' === s.charAt(0)); @@ -493,10 +507,15 @@ Client.prototype.defaultHandlerMissingMod = function(err) { }; Client.prototype.terminalSupports = function(query) { + const termClient = this.term.termClient; + switch(query) { case 'vtx_audio' : // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt - return this.termClient === 'vtx'; + return 'vtx' === termClient; + + case 'vtx_hyperlink' : + return 'vtx' === termClient; default : return false; diff --git a/core/client_term.js b/core/client_term.js index 9992f9f3..b313841e 100644 --- a/core/client_term.js +++ b/core/client_term.js @@ -110,6 +110,17 @@ ClientTerminal.prototype.disconnect = function() { this.output = null; }; +ClientTerminal.prototype.isNixTerm = function() { + // + // Standard *nix type terminals + // + if(this.termType.startsWith('xterm')) { + return true; + } + + return [ 'xterm', 'linux', 'screen', 'dumb', 'rxvt', 'konsole', 'gnome', 'x11 terminal emulator' ].includes(this.termType); +}; + ClientTerminal.prototype.isANSI = function() { // // ANSI terminals should be encoded to CP437 @@ -142,7 +153,7 @@ ClientTerminal.prototype.isANSI = function() { // linux: // * JuiceSSH (note: TERM=linux also) // - return [ 'ansi', 'pcansi', 'pc-ansi', 'ansi-bbs', 'qansi', 'scoansi', 'syncterm' ].indexOf(this.termType) > -1; + return [ 'ansi', 'pcansi', 'pc-ansi', 'ansi-bbs', 'qansi', 'scoansi', 'syncterm' ].includes(this.termType); }; // :TODO: probably need to update these to convert IAC (0xff) -> IACIAC (escape it) diff --git a/core/color_codes.js b/core/color_codes.js index 4dc8da99..2e368aa3 100644 --- a/core/color_codes.js +++ b/core/color_codes.js @@ -11,11 +11,13 @@ exports.enigmaToAnsi = enigmaToAnsi; exports.stripPipeCodes = exports.stripEnigmaCodes = stripEnigmaCodes; exports.pipeStrLen = exports.enigmaStrLen = enigmaStrLen; exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi; +exports.controlCodesToAnsi = controlCodesToAnsi; // :TODO: Not really happy with the module name of "color_codes". Would like something better + // Also add: // * fromCelerity(): | // * fromPCBoard(): (@X) @@ -85,6 +87,46 @@ function enigmaStrLen(s) { return stripEnigmaCodes(s).length; } +function ansiSgrFromRenegadeColorCode(cc) { + return ansi.sgr({ + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'bold', 'black' ], + 9 : [ 'bold', 'blue' ], + 10 : [ 'bold', 'green' ], + 11 : [ 'bold', 'cyan' ], + 12 : [ 'bold', 'red' ], + 13 : [ 'bold', 'magenta' ], + 14 : [ 'bold', 'yellow' ], + 15 : [ 'bold', 'white' ], + + 16 : [ 'blackBG' ], + 17 : [ 'blueBG' ], + 18 : [ 'greenBG' ], + 19 : [ 'cyanBG' ], + 20 : [ 'redBG' ], + 21 : [ 'magentaBG' ], + 22 : [ 'yellowBG' ], + 23 : [ 'whiteBG' ], + + 24 : [ 'bold', 'blackBG' ], + 25 : [ 'bold', 'blueBG' ], + 26 : [ 'bold', 'greenBG' ], + 27 : [ 'bold', 'cyanBG' ], + 28 : [ 'bold', 'redBG' ], + 29 : [ 'bold', 'magentaBG' ], + 30 : [ 'bold', 'yellowBG' ], + 31 : [ 'bold', 'whiteBG' ], + }[cc] || 'normal'); +} + function renegadeToAnsi(s, client) { if(-1 == s.indexOf('|')) { return s; // no pipe codes present @@ -111,35 +153,7 @@ function renegadeToAnsi(s, client) { if(_.isString(val)) { result += s.substr(lastIndex, m.index - lastIndex) + val; } else { - var attr = ansi.sgr({ - 0 : [ 'reset', 'black' ], - 1 : [ 'reset', 'blue' ], - 2 : [ 'reset', 'green' ], - 3 : [ 'reset', 'cyan' ], - 4 : [ 'reset', 'red' ], - 5 : [ 'reset', 'magenta' ], - 6 : [ 'reset', 'yellow' ], - 7 : [ 'reset', 'white' ], - - 8 : [ 'bold', 'black' ], - 9 : [ 'bold', 'blue' ], - 10 : [ 'bold', 'green' ], - 11 : [ 'bold', 'cyan' ], - 12 : [ 'bold', 'red' ], - 13 : [ 'bold', 'magenta' ], - 14 : [ 'bold', 'yellow' ], - 15 : [ 'bold', 'white' ], - - 16 : [ 'blackBG' ], - 17 : [ 'blueBG' ], - 18 : [ 'greenBG' ], - 19 : [ 'cyanBG' ], - 20 : [ 'redBG' ], - 21 : [ 'magentaBG' ], - 22 : [ 'yellowBG' ], - 23 : [ 'whiteBG' ], - }[val] || 'normal'); - + const attr = ansiSgrFromRenegadeColorCode(val); result += s.substr(lastIndex, m.index - lastIndex) + attr; } @@ -148,3 +162,131 @@ function renegadeToAnsi(s, client) { return (0 === result.length ? s : result + s.substr(lastIndex)); } + +// +// Converts various control codes popular in BBS packages +// to ANSI escape sequences. Additionaly supports ENiGMA style +// MCI codes. +// +// Supported control code formats: +// * Renegade : |## +// * PCBoard : @X## where the first number/char is FG color, and second is BG +// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix +// * WWIV : ^# +// +// TODO: Add Synchronet and Celerity format support +// +// Resources: +// * http://wiki.synchro.net/custom:colors +// +function controlCodesToAnsi(s, client) { + const RE = /(\|([A-Z0-9]{2})|\|)|(\@X([0-9A-F]{2}))|(\@([0-9A-F]{2})\@)|(\x03[0-9]|\x03)/g; // eslint-disable-line no-control-regex + + let m; + let result = ''; + let lastIndex = 0; + let v; + let fg; + let bg; + + while((m = RE.exec(s))) { + switch(m[0].charAt(0)) { + case '|' : + // Renegade or ENiGMA MCI + v = parseInt(m[2], 10); + + if(isNaN(v)) { + v = getPredefinedMCIValue(client, m[2]) || m[0]; // value itself or literal + } + + if(_.isString(v)) { + result += s.substr(lastIndex, m.index - lastIndex) + v; + } else { + v = ansiSgrFromRenegadeColorCode(v); + result += s.substr(lastIndex, m.index - lastIndex) + v; + } + break; + + case '@' : + // PCBoard @X## or Wildcat! @##@ + if('@' === m[0].substr(-1)) { + // Wildcat! + v = m[6]; + } else { + v = m[4]; + } + + fg = { + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 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' ], + 2 : [ 'greenBG' ], + 3 : [ 'cyanBG' ], + 4 : [ 'redBG' ], + 5 : [ 'magentaBG' ], + 6 : [ 'yellowBG' ], + 7 : [ 'whiteBG' ], + + 8 : [ 'bold', 'blackBG' ], + 9 : [ 'bold', 'blueBG' ], + A : [ 'bold', 'greenBG' ], + B : [ 'bold', 'cyanBG' ], + C : [ 'bold', 'redBG' ], + D : [ 'bold', 'magentaBG' ], + E : [ 'bold', 'yellowBG' ], + F : [ 'bold', 'whiteBG' ], + }[v.charAt(1)] || [ 'normal' ]; + + v = ansi.sgr(fg.concat(bg)); + result += s.substr(lastIndex, m.index - lastIndex) + v; + break; + + case '\x03' : + v = parseInt(m[8], 10); + + if(isNaN(v)) { + v += m[0]; + } else { + v = ansi.sgr({ + 0 : [ 'reset', 'black' ], + 1 : [ 'bold', 'cyan' ], + 2 : [ 'bold', 'yellow' ], + 3 : [ 'reset', 'magenta' ], + 4 : [ 'bold', 'white', 'blueBG' ], + 5 : [ 'reset', 'green' ], + 6 : [ 'bold', 'blink', 'red' ], + 7 : [ 'bold', 'blue' ], + 8 : [ 'reset', 'blue' ], + 9 : [ 'reset', 'cyan' ], + }[v] || 'normal'); + } + + result += s.substr(lastIndex, m.index - lastIndex) + v; + + break; + } + + lastIndex = RE.lastIndex; + } + + return (0 === result.length ? s : result + s.substr(lastIndex)); +} \ No newline at end of file diff --git a/core/combatnet.js b/core/combatnet.js new file mode 100644 index 00000000..6cde9c7b --- /dev/null +++ b/core/combatnet.js @@ -0,0 +1,115 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const MenuModule = require('../core/menu_module.js').MenuModule; +const resetScreen = require('../core/ansi_term.js').resetScreen; + +// deps +const async = require('async'); +const _ = require('lodash'); +const RLogin = require('rlogin'); + +exports.moduleInfo = { + name : 'CombatNet', + desc : 'CombatNet Access Module', + author : 'Dave Stephens', +}; + +exports.getModule = class CombatNetModule extends MenuModule { + constructor(options) { + super(options); + + // establish defaults + this.config = options.menuConfig.config; + this.config.host = this.config.host || 'bbs.combatnet.us'; + this.config.rloginPort = this.config.rloginPort || 4513; + } + + initSequence() { + const self = this; + + async.series( + [ + function validateConfig(callback) { + if(!_.isString(self.config.password)) { + return callback(new Error('Config requires "password"!')); + } + if(!_.isString(self.config.bbsTag)) { + return callback(new Error('Config requires "bbsTag"!')); + } + return callback(null); + }, + function establishRloginConnection(callback) { + self.client.term.write(resetScreen()); + self.client.term.write('Connecting to CombatNet, please wait...\n'); + + const restorePipeToNormal = function() { + self.client.term.output.removeListener('data', sendToRloginBuffer); + }; + + 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 + } + ); + + // If there was an error ... + rlogin.on('error', err => { + self.client.log.info(`CombatNet rlogin client error: ${err.message}`); + restorePipeToNormal(); + callback(err); + }); + + // If we've been disconnected ... + rlogin.on('disconnect', () => { + self.client.log.info(`Disconnected from CombatNet`); + restorePipeToNormal(); + callback(null); + }); + + function sendToRloginBuffer(buffer) { + rlogin.send(buffer); + }; + + rlogin.on("connect", + /* The 'connect' event handler will be supplied with one argument, + a boolean indicating whether or not the connection was established. */ + + function(state) { + if(state) { + self.client.log.info('Connected to CombatNet'); + self.client.term.output.on('data', sendToRloginBuffer); + + } else { + return callback(new Error('Failed to establish establish CombatNet connection')); + } + } + ); + + // If data (a Buffer) has been received from the server ... + rlogin.on("data", (data) => { + self.client.term.rawWrite(data); + }); + + // connect... + rlogin.connect(); + + // note: no explicit callback() until we're finished! + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'CombatNet error'); + } + + // if the client is still here, go to previous + self.prevMenu(); + } + ); + } +}; diff --git a/core/config.js b/core/config.js index 449a5c98..62ce6032 100644 --- a/core/config.js +++ b/core/config.js @@ -2,7 +2,6 @@ 'use strict'; // ENiGMA½ -const miscUtil = require('./misc_util.js'); // deps const fs = require('graceful-fs'); @@ -19,7 +18,7 @@ function hasMessageConferenceAndArea(config) { assert(_.isObject(config.messageConferences)); // we create one ourself! const nonInternalConfs = Object.keys(config.messageConferences).filter(confTag => { - return 'system_internal' !== confTag; + return 'system_internal' !== confTag; }); if(0 === nonInternalConfs.length) { @@ -32,7 +31,7 @@ function hasMessageConferenceAndArea(config) { _.forEach(nonInternalConfs, confTag => { if(_.has(config.messageConferences[confTag], 'areas') && Object.keys(config.messageConferences[confTag].areas) > 0) - { + { result = true; return false; // stop iteration } @@ -53,12 +52,12 @@ function init(configPath, options, cb) { 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); @@ -67,12 +66,12 @@ function init(configPath, options, cb) { } return callback(null, configJson); - }); + }); }, function mergeWithDefaultConfig(configJson, callback) { - + const mergedConfig = _.mergeWith( - getDefaultConfig(), + getDefaultConfig(), configJson, (conf1, conf2) => { // Arrays should always concat if(_.isArray(conf1)) { @@ -111,11 +110,8 @@ function init(configPath, options, cb) { } function getDefaultPath() { - const base = miscUtil.resolvePath('~/'); - if(base) { - // e.g. /home/users/joeuser/.config/enigma-bbs/config.hjson - return paths.join(base, '.config', 'enigma-bbs', 'config.hjson'); - } + // e.g. /enigma-bbs-install-path/config/ + return './config/'; } function getDefaultConfig() { @@ -127,8 +123,8 @@ function getDefaultConfig() { loginAttempts : 3, - menuFile : 'menu.hjson', // Override to use something else, e.g. demo.hjson. Can be a full path (defaults to ./mods) - promptFile : 'prompt.hjson', // Override to use soemthing else, e.g. myprompt.hjson. Can be a full path (defaults to ./mods) + 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) }, // :TODO: see notes below about 'theme' section - move this! @@ -150,15 +146,16 @@ function getDefaultConfig() { webMax : 255, requireActivation : false, // require SysOp activation? false = auto-activate - invalidUsernames : [], groups : [ 'users', 'sysops' ], // built in groups defaultGroups : [ 'users' ], // default groups new users belong to newUserNames : [ 'new', 'apply' ], // Names reserved for applying - // :TODO: Mystic uses TRASHCAN.DAT for this -- is there a reason to support something like that? - badUserNames : [ 'sysop', 'admin', 'administrator', 'root', 'all' ], + badUserNames : [ + 'sysop', 'admin', 'administrator', 'root', 'all', + 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix' + ], }, // :TODO: better name for "defaults"... which is redundant here! @@ -187,9 +184,10 @@ function getDefaultConfig() { menus : { cls : true, // Clear screen before each menu by default? - }, + }, paths : { + config : paths.join(__dirname, './../config/'), mods : paths.join(__dirname, './../mods/'), loginServers : paths.join(__dirname, './servers/login/'), contentServers : paths.join(__dirname, './servers/content/'), @@ -197,15 +195,15 @@ function getDefaultConfig() { scannerTossers : paths.join(__dirname, './scanner_tossers/'), mailers : paths.join(__dirname, './mailers/') , - art : paths.join(__dirname, './../mods/art/'), - themes : paths.join(__dirname, './../mods/themes/'), + art : paths.join(__dirname, './../art/general/'), + themes : paths.join(__dirname, './../art/themes/'), logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such db : paths.join(__dirname, './../db/'), - modsDb : paths.join(__dirname, './../db/mods/'), + modsDb : paths.join(__dirname, './../db/mods/'), dropFiles : paths.join(__dirname, './../dropfiles/'), // + "/node/ misc : paths.join(__dirname, './../misc/'), }, - + loginServers : { telnet : { port : 8888, @@ -214,18 +212,18 @@ function getDefaultConfig() { }, ssh : { port : 8889, - enabled : false, // defualt to false as PK/pass in config.hjson are required + enabled : false, // default to false as PK/pass in config.hjson are required // // Private key in PEM format - // + // // Generating your PK: - // > openssl genrsa -des3 -out ./misc/ssh_private_key.pem 2048 + // > openssl genrsa -des3 -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, './../misc/ssh_private_key.pem'), + privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'), firstMenu : 'sshConnected', firstMenuNewUser : 'sshConnectedNewUser', }, @@ -233,8 +231,8 @@ function getDefaultConfig() { port : 8810, // ws:// enabled : false, securePort : 8811, // wss:// - must provide certPem and keyPem - certPem : paths.join(__dirname, './../misc/https_cert.pem'), - keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'), + certPem : paths.join(__dirname, './../config/https_cert.pem'), + keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), }, }, @@ -247,7 +245,7 @@ function getDefaultConfig() { resetPassword : { // // The following templates have these variables available to them: - // + // // * %BOARDNAME% : Name of BBS // * %USERNAME% : Username of whom to reset password // * %TOKEN% : Reset token @@ -262,16 +260,16 @@ function getDefaultConfig() { // resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'), }, - + http : { enabled : false, - port : 8080, + port : 8080, }, https : { enabled : false, port : 8443, - certPem : paths.join(__dirname, './../misc/https_cert.pem'), - keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'), + certPem : paths.join(__dirname, './../config/https_cert.pem'), + keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), } } }, @@ -282,10 +280,10 @@ function getDefaultConfig() { }, Exiftool : { cmd : 'exiftool', - args : [ + args : [ '-charset', 'utf8', '{filePath}', // exclude the following: - '--directory', '--filepermissions', '--exiftoolversion', '--filename', '--filesize', + '--directory', '--filepermissions', '--exiftoolversion', '--filename', '--filesize', '--filemodifydate', '--fileaccessdate', '--fileinodechangedate', '--createdate', '--modifydate', '--metadatadate', '--xmptoolkit' ] @@ -304,7 +302,7 @@ function getDefaultConfig() { // // // :TODO: text/x-ansi -> SAUCE extraction for .ans uploads - // :TODO: textual : bool -- if text, we can view. + // :TODO: textual : bool -- if text, we can view. // :TODO: asText : { cmd, args[] } -> viewable text // @@ -387,7 +385,7 @@ function getDefaultConfig() { sig : '526172211a0700', offset : 0, archiveHandler : 'Rar', - }, + }, 'application/gzip' : { desc : 'Gzip Archive', sig : '1f8b', @@ -399,28 +397,28 @@ function getDefaultConfig() { desc : 'BZip2 Archive', sig : '425a68', offset : 0, - archiveHandler : '7Zip', + archiveHandler : '7Zip', }, 'application/x-lzh-compressed' : { desc : 'LHArc Archive', sig : '2d6c68', offset : 2, - archiveHandler : 'Lha', + archiveHandler : 'Lha', }, 'application/x-7z-compressed' : { desc : '7-Zip Archive', sig : '377abcaf271c', offset : 0, - archiveHandler : '7Zip', + archiveHandler : '7Zip', } // :TODO: update archives::formats to fall here // * archive handler -> archiveHandler (consider archive if archiveHandler present) // * sig, offset, ... // * mime-db -> exts lookup - // * + // * }, - + archives : { archivers : { '7Zip' : { @@ -513,7 +511,7 @@ function getDefaultConfig() { list : { cmd : 'tar', args : [ '-tvf', '{archivePath}' ], - entryMatch : '^[drwx\\-]{10}\\s[A-Za-z0-9\\/]+\\s+([0-9]+)\\s[0-9]{4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s([^\\r\\n]+)$', + entryMatch : '^[drwx\\-]{10}\\s[A-Za-z0-9\\/]+\\s+([0-9]+)\\s[0-9]{4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s([^\\r\\n]+)$', }, extract : { cmd : 'tar', @@ -522,7 +520,7 @@ function getDefaultConfig() { } }, }, - + fileTransferProtocols : { // // See http://www.synchro.net/docs/sexyz.txt for information on SEXYZ @@ -538,7 +536,7 @@ function getDefaultConfig() { recvCmd : 'sexyz', recvArgs : [ '-telnet', '-8', 'rz', '{uploadDir}' ], recvArgsNonBatch : [ '-telnet', '-8', 'rz', '{fileName}' ], - } + } }, xmodemSexyz : { @@ -569,7 +567,7 @@ function getDefaultConfig() { name : 'ZModem 8k', type : 'external', sort : 2, - external : { + external : { sendCmd : 'sz', // Avail on Debian/Ubuntu based systems as the package "lrzsz" sendArgs : [ // :TODO: try -q @@ -580,11 +578,11 @@ function getDefaultConfig() { '--zmodem', '--binary', '--restricted', '--keep-uppercase', // dumps to CWD which is set to {uploadDir} ], // :TODO: can we not just use --escape ? - escapeTelnet : true, // set to true to escape Telnet codes such as IAC - } + escapeTelnet : true, // set to true to escape Telnet codes such as IAC + } } }, - + messageAreaDefaults : { // // The following can be override per-area as well @@ -593,15 +591,16 @@ function getDefaultConfig() { maxAgeDays : 0, // 0 = unlimited }, - messageConferences : { + messageConferences : { system_internal : { name : 'System Internal', desc : 'Built in conference for private messages, bulletins, etc.', - + areas : { private_mail : { - name : 'Private Mail', - desc : 'Private user to user mail/email', + name : 'Private Mail', + desc : 'Private user to user mail/email', + maxExternalSentAgeDays : 30, // max external "outbox" item age }, local_bulletin : { @@ -611,14 +610,15 @@ function getDefaultConfig() { } } }, - + scannerTossers : { ftn_bso : { paths : { - outbound : paths.join(__dirname, './../mail/ftn_out/'), - inbound : paths.join(__dirname, './../mail/ftn_in/'), - secInbound : paths.join(__dirname, './../mail/ftn_secin/'), - reject : paths.join(__dirname, './../mail/reject/'), // bad pkt, bundles, TIC attachments that fail any check, etc. + outbound : paths.join(__dirname, './../mail/ftn_out/'), + inbound : paths.join(__dirname, './../mail/ftn_in/'), + secInbound : paths.join(__dirname, './../mail/ftn_secin/'), + reject : paths.join(__dirname, './../mail/reject/'), // bad pkt, bundles, TIC attachments that fail any check, etc. + //outboundNetMail : paths.join(__dirname, './../mail/ftn_netmail_out/'), // set 'retain' to a valid path to keep good pkt files }, @@ -636,12 +636,13 @@ function getDefaultConfig() { secureInOnly : true, // only bring in from secure inbound (|secInbound| path, password protected) uploadBy : 'ENiGMA TIC', // default upload by username (override @ network) allowReplace : false, // use "Replaces" TIC field + descPriority : 'diz', // May be diz=.DIZ/etc., or tic=from TIC Ldesc } } }, fileBase: { - // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|: + // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|: areaStoragePrefix : paths.join(__dirname, './../file_base/'), maxDescFileByteSize : 471859, // ~1/4 MB @@ -650,12 +651,13 @@ function getDefaultConfig() { fileNamePatterns: { // These are NOT case sensitive // FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ - desc : [ - '^[^/\]*FILE_ID\.DIZ$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$' + // 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$' ], // common README filename - https://en.wikipedia.org/wiki/README - descLong : [ + descLong : [ '^[^/\]*\.NFO$', '^[^/\]*README\.1ST$', '^[^/\]*README\.NOW$', '^[^/\]*README\.TXT$', '^[^/\]*READ\.ME$', '^[^/\]*README$', '^[^/\]*README\.md$' ], }, @@ -665,12 +667,17 @@ function getDefaultConfig() { // Patterns should produce the year in the first submatch. // The extracted year may be YY or YYYY // - '\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]|[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', // yyyy-mm-dd, m/d/yyyy, mm-dd-yyyy, etc. - "\\b('[1789][0-9])\\b", // eslint-disable-line quotes - '\\b[0-3]?[0-9][\\-\\/\\.](?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)[\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', + '\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]\\b', // yyyy-mm-dd, yyyy/mm/dd, ... + '\\b[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[1-2][0-9][0-9]{2}))\\b', // mm/dd/yyyy, mm.dd.yyyy, ... + '\\b((?:[1789][0-9]))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]\\b', // yy-mm-dd, yy-mm-dd, ... + '\\b[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[1789][0-9]))\\b', // mm-dd-yy, mm/dd/yy, ... + //'\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]|[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', // yyyy-mm-dd, m/d/yyyy, mm-dd-yyyy, etc. + //"\\b('[1789][0-9])\\b", // eslint-disable-line quotes + '\\b[0-3]?[0-9][\\-\\/\\.](?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)[\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', '\\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december),?\\s[0-9]+(?:st|nd|rd|th)?,?\\s((?:[0-9]{2})?[0-9]{2})\\b', // November 29th, 1997 - '\\(((?:19|20)[0-9]{2})\\)', // (19xx) or (20xx) -- do this before 19xx 20xx such that this has priority + '\\(((?:19|20)[0-9]{2})\\)', // (19xx) or (20xx) -- with parens -- do this before 19xx 20xx such that this has priority '\\b((?:19|20)[0-9]{2})\\b', // simple 19xx or 20xx with word boundaries + '\\b\'([17-9][0-9])\\b', // '95, '17, ... // :TODO: DD/MMM/YY, DD/MMMM/YY, DD/MMM/YYYY, etc. ], @@ -683,42 +690,54 @@ function getDefaultConfig() { // // File area storage location tag/value pairs. // Non-absolute paths are relative to |areaStoragePrefix|. - // + // storageTags : { - sys_msg_attach : 'msg_attach', + sys_msg_attach : 'sys_msg_attach', + sys_temp_download : 'sys_temp_download', }, areas: { system_message_attachment : { - name : 'Message attachments', + name : 'System Message Attachments', desc : 'File attachments to messages', - storageTags : 'sys_msg_attach', // may be string or array of strings + storageTags : [ 'sys_msg_attach' ], + }, + + system_temporary_download : { + name : 'System Temporary Downloads', + desc : 'Temporary downloadables', + storageTags : [ 'sys_temp_download' ], } } }, - + eventScheduler : { - + events : { 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) // - // - @execute:/path/to/something/executable.sh - // + // - @execute:/path/to/something/executable.sh + // action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', }, + updateFileAreaStats : { + schedule : 'every 1 hours', + action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent', + }, + forgotPasswordMaintenance : { schedule : 'every 24 hours', action : '@method:core/web_password_reset.js:performMaintenanceTask', args : [ '24 hours' ] // items older than this will be removed } - } + } }, misc : { diff --git a/core/config_util.js b/core/config_util.js index f078f758..40723d9a 100644 --- a/core/config_util.js +++ b/core/config_util.js @@ -1,16 +1,15 @@ /* jslint node: true */ 'use strict'; - -var configCache = require('./config_cache.js'); - -var paths = require('path'); +const Config = require('./config.js').config; +const configCache = require('./config_cache.js'); +const paths = require('path'); exports.getFullConfig = getFullConfig; function getFullConfig(filePath, cb) { - // |filePath| is assumed to be in 'mods' if it's only a file name + // |filePath| is assumed to be in the config path if it's only a file name if('.' === paths.dirname(filePath)) { - filePath = paths.join(__dirname, '../mods', filePath); + filePath = paths.join(Config.paths.config, filePath); } configCache.getConfig(filePath, function loaded(err, configJson) { diff --git a/core/database.js b/core/database.js index d4fb4795..14b3bf95 100644 --- a/core/database.js +++ b/core/database.js @@ -6,6 +6,7 @@ const conf = require('./config.js'); // deps const sqlite3 = require('sqlite3'); +const sqlite3Trans = require('sqlite3-trans'); const paths = require('path'); const async = require('async'); const _ = require('lodash'); @@ -13,14 +14,19 @@ const assert = require('assert'); const moment = require('moment'); // database handles -let dbs = {}; +const dbs = {}; +exports.getTransactionDatabase = getTransactionDatabase; exports.getModDatabasePath = getModDatabasePath; exports.getISOTimestampString = getISOTimestampString; exports.initializeDatabases = initializeDatabases; exports.dbs = dbs; +function getTransactionDatabase(db) { + return sqlite3Trans.wrap(db); +} + function getDatabasePath(name) { return paths.join(conf.config.paths.db, `${name}.sqlite3`); } @@ -55,7 +61,7 @@ function getISOTimestampString(ts) { function initializeDatabases(cb) { async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { - dbs[dbName] = new sqlite3.Database(getDatabasePath(dbName), err => { + dbs[dbName] = sqlite3Trans.wrap(new sqlite3.Database(getDatabasePath(dbName), err => { if(err) { return cb(err); } @@ -65,7 +71,7 @@ function initializeDatabases(cb) { return next(null); }); }); - }); + })); }, err => { return cb(err); }); @@ -368,6 +374,15 @@ const DB_INIT_TABLE = { );` ); + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_web_serve_batch ( + hash_id VARCHAR NOT NULL, + file_id INTEGER NOT NULL, + + UNIQUE(hash_id, file_id) + );` + ); + return cb(null); } }; \ No newline at end of file diff --git a/mods/erc_client.js b/core/erc_client.js similarity index 97% rename from mods/erc_client.js rename to core/erc_client.js index 02b42ad5..4fb549f6 100644 --- a/mods/erc_client.js +++ b/core/erc_client.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/file_area_filter_edit.js b/core/file_area_filter_edit.js similarity index 96% rename from mods/file_area_filter_edit.js rename to core/file_area_filter_edit.js index cb3322f9..4a53096c 100644 --- a/mods/file_area_filter_edit.js +++ b/core/file_area_filter_edit.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../core/file_base_filter.js'); -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('./file_base_filter.js'); +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/file_area_list.js b/core/file_area_list.js similarity index 90% rename from mods/file_area_list.js rename to core/file_area_list.js index 076d2a98..3bcfd7c2 100644 --- a/mods/file_area_list.js +++ b/core/file_area_list.js @@ -2,22 +2,23 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const ansi = require('../core/ansi_term.js'); -const theme = require('../core/theme.js'); -const FileEntry = require('../core/file_entry.js'); -const stringFormat = require('../core/string_format.js'); -const FileArea = require('../core/file_base_area.js'); -const Errors = require('../core/enig_error.js').Errors; -const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled; -const ArchiveUtil = require('../core/archive_util.js'); -const Config = require('../core/config.js').config; -const DownloadQueue = require('../core/download_queue.js'); -const FileAreaWeb = require('../core/file_area_web.js'); -const FileBaseFilters = require('../core/file_base_filter.js'); -const resolveMimeType = require('../core/mime_util.js').resolveMimeType; -const isAnsi = require('../core/string_util.js').isAnsi; +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const ansi = require('./ansi_term.js'); +const theme = require('./theme.js'); +const FileEntry = require('./file_entry.js'); +const stringFormat = require('./string_format.js'); +const FileArea = require('./file_base_area.js'); +const Errors = require('./enig_error.js').Errors; +const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; +const ArchiveUtil = require('./archive_util.js'); +const Config = require('./config.js').config; +const DownloadQueue = require('./download_queue.js'); +const FileAreaWeb = require('./file_area_web.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const resolveMimeType = require('./mime_util.js').resolveMimeType; +const isAnsi = require('./string_util.js').isAnsi; +const controlCodesToAnsi = require('./color_codes.js').controlCodesToAnsi; // deps const async = require('async'); @@ -274,7 +275,7 @@ exports.getModule = class FileAreaList extends MenuModule { } else { const webDlExpireTimeFormat = config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - entryInfo.webDlLink = serveItem.url; + entryInfo.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); } @@ -385,9 +386,19 @@ exports.getModule = class FileAreaList extends MenuModule { if(_.isString(self.currentFileEntry.desc)) { const descView = self.viewControllers.browse.getView(MciViewIds.browse.desc); if(descView) { - if(isAnsi(self.currentFileEntry.desc)) { + // + // For descriptions we want to support as many color code systems + // as we can for coverage of what is found in the while (e.g. Renegade + // pipes, PCB @X##, etc.) + // + // MLTEV doesn't support all of this, so convert. If we produced ANSI + // esc sequences, we'll proceed with specialization, else just treat + // it as text. + // + const desc = controlCodesToAnsi(self.currentFileEntry.desc); + if(desc.length != self.currentFileEntry.desc.length || isAnsi(desc)) { descView.setAnsi( - self.currentFileEntry.desc, + desc, { prepped : false, forceLineTerm : true @@ -497,7 +508,7 @@ exports.getModule = class FileAreaList extends MenuModule { const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - self.currentFileEntry.entryInfo.webDlLink = url; + self.currentFileEntry.entryInfo.webDlLink = ansi.vtxHyperlink(self.client, url) + url; self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat); return callback(null); @@ -675,7 +686,13 @@ exports.getModule = class FileAreaList extends MenuModule { loadFileIds(force, cb) { if(force || (_.isUndefined(this.fileList) || _.isUndefined(this.fileListPosition))) { this.fileListPosition = 0; - FileEntry.findFiles(this.filterCriteria, (err, fileIds) => { + + const filterCriteria = Object.assign({}, this.filterCriteria); + if(!filterCriteria.areaTag) { + filterCriteria.areaTag = FileArea.getAvailableFileAreaTags(this.client); + } + + FileEntry.findFiles(filterCriteria, (err, fileIds) => { this.fileList = fileIds; return cb(err); }); diff --git a/core/file_area_web.js b/core/file_area_web.js index bac85de7..b12f4f7d 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -22,14 +22,7 @@ const paths = require('path'); const async = require('async'); const fs = require('graceful-fs'); const mimeTypes = require('mime-types'); -const _ = require('lodash'); - - /* - :TODO: - * Load temp download URLs @ startup & set expire timers via scheduler. - * At creation, set expire timer via scheduler - * - */ +const yazl = require('yazl'); function notEnabledError() { return Errors.General('Web server is not enabled', ErrNotEnabled); @@ -59,7 +52,7 @@ class FileAreaWebAccess { const routeAdded = self.webServer.instance.addRoute({ method : 'GET', path : Config.fileBase.web.routePath, - handler : self.routeWebRequestForFile.bind(self), + handler : self.routeWebRequest.bind(self), }); return callback(routeAdded ? null : Errors.General('Failed adding route')); } else { @@ -81,6 +74,13 @@ class FileAreaWebAccess { return this.webServer.instance.isEnabled(); } + static getHashIdTypes() { + return { + SingleFile : 0, + BatchArchive : 1, + }; + } + load(cb) { // // Load entries, register expiration timers @@ -141,68 +141,53 @@ class FileAreaWebAccess { WHERE hash_id = ?`, [ hashId ], (err, result) => { - if(err) { - return cb(err); + if(err || !result) { + return cb(err ? err : Errors.DoesNotExist('Invalid or missing hash ID')); } const decoded = this.hashids.decode(hashId); - if(!result || 2 !== decoded.length) { + + // decode() should provide an array of [ userId, hashIdType, id, ... ] + if(!Array.isArray(decoded) || decoded.length < 3) { return cb(Errors.Invalid('Invalid or unknown hash ID')); } - return cb( - null, - { - hashId : hashId, - userId : decoded[0], - fileId : decoded[1], - expireTimestamp : moment(result.expire_timestamp), - } - ); + const servedItem = { + hashId : hashId, + userId : decoded[0], + hashIdType : decoded[1], + expireTimestamp : moment(result.expire_timestamp), + }; + + if(FileAreaWebAccess.getHashIdTypes().SingleFile === servedItem.hashIdType) { + servedItem.fileIds = decoded.slice(2); + } + + return cb(null, servedItem); } ); } - getHashId(client, fileEntry) { - // - // Hashid is a unique combination of userId & fileId - // - return this.hashids.encode(client.user.userId, fileEntry.fileId); + getSingleFileHashId(client, fileEntry) { + return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().SingleFile, [ fileEntry.fileId ] ); } - buildTempDownloadLink(client, fileEntry, hashId) { - hashId = hashId || this.getHashId(client, fileEntry); + getBatchArchiveHashId(client, batchId) { + return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().BatchArchive, batchId); + } + + getHashId(client, hashIdType, identifier) { + return this.hashids.encode(client.user.userId, hashIdType, identifier); + } + + buildSingleFileTempDownloadLink(client, fileEntry, hashId) { + hashId = hashId || this.getSingleFileHashId(client, fileEntry); return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); - /* - - // - // Create a URL such as - // https://l33t.codes:44512/f/qFdxyZr - // - // Prefer HTTPS over HTTP. Be explicit about the port - // only if non-standard. - // - let schema; - let port; - if(_.isString(Config.contentServers.web.overrideUrlPrefix)) { - return `${Config.contentServers.web.overrideUrlPrefix}${Config.fileBase.web.path}${hashId}`; - } else { - if(Config.contentServers.web.https.enabled) { - schema = 'https://'; - port = (443 === Config.contentServers.web.https.port) ? - '' : - `:${Config.contentServers.web.https.port}`; - } else { - schema = 'http://'; - port = (80 === Config.contentServers.web.http.port) ? - '' : - `:${Config.contentServers.web.http.port}`; - } - - return `${schema}${Config.contentServers.web.domain}${port}${Config.fileBase.web.path}${hashId}`; - } - */ + } + + buildBatchArchiveTempDownloadLink(client, hashId) { + return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); } getExistingTempDownloadServeItem(client, fileEntry, cb) { @@ -210,95 +195,266 @@ class FileAreaWebAccess { return cb(notEnabledError()); } - const hashId = this.getHashId(client, fileEntry); + const hashId = this.getSingleFileHashId(client, fileEntry); this.loadServedHashId(hashId, (err, servedItem) => { if(err) { return cb(err); } - servedItem.url = this.buildTempDownloadLink(client, fileEntry); + servedItem.url = this.buildSingleFileTempDownloadLink(client, fileEntry); return cb(null, servedItem); }); } + _addOrUpdateHashIdRecord(dbOrTrans, hashId, expireTime, cb) { + // add/update rec with hash id and (latest) timestamp + dbOrTrans.run( + `REPLACE INTO file_web_serve (hash_id, expire_timestamp) + VALUES (?, ?);`, + [ hashId, getISOTimestampString(expireTime) ], + err => { + if(err) { + return cb(err); + } + + this.scheduleExpire(hashId, expireTime); + + return cb(null); + } + ); + } + createAndServeTempDownload(client, fileEntry, options, cb) { if(!this.isEnabled()) { return cb(notEnabledError()); } - const hashId = this.getHashId(client, fileEntry); - const url = this.buildTempDownloadLink(client, fileEntry, hashId); + const hashId = this.getSingleFileHashId(client, fileEntry); + const url = this.buildSingleFileTempDownloadLink(client, fileEntry, hashId); options.expireTime = options.expireTime || moment().add(2, 'days'); - // add/update rec with hash id and (latest) timestamp - FileDb.run( - `REPLACE INTO file_web_serve (hash_id, expire_timestamp) - VALUES (?, ?);`, - [ hashId, getISOTimestampString(options.expireTime) ], - err => { + this._addOrUpdateHashIdRecord(FileDb, hashId, options.expireTime, err => { + return cb(err, url); + }); + } + + createAndServeTempBatchDownload(client, fileEntries, options, cb) { + if(!this.isEnabled()) { + return cb(notEnabledError()); + } + + const batchId = moment().utc().unix(); + const hashId = this.getBatchArchiveHashId(client, batchId); + const url = this.buildBatchArchiveTempDownloadLink(client, hashId); + options.expireTime = options.expireTime || moment().add(2, 'days'); + + FileDb.beginTransaction( (err, trans) => { + if(err) { + return cb(err); + } + + this._addOrUpdateHashIdRecord(trans, hashId, options.expireTime, err => { if(err) { - return cb(err); + return trans.rollback( () => { + return cb(err); + }); } - this.scheduleExpire(hashId, options.expireTime); - - return cb(null, url); - } - ); + async.eachSeries(fileEntries, (entry, nextEntry) => { + trans.run( + `INSERT INTO file_web_serve_batch (hash_id, file_id) + VALUES (?, ?);`, + [ hashId, entry.fileId ], + err => { + return nextEntry(err); + } + ); + }, err => { + trans[err ? 'rollback' : 'commit']( () => { + return cb(err, url); + }); + }); + }); + }); } fileNotFound(resp) { return this.webServer.instance.fileNotFound(resp); } - routeWebRequestForFile(req, resp) { + routeWebRequest(req, resp) { const hashId = paths.basename(req.url); + Log.debug( { hashId : hashId, url : req.url }, 'File area web request'); + this.loadServedHashId(hashId, (err, servedItem) => { if(err) { return this.fileNotFound(resp); } - const fileEntry = new FileEntry(); - fileEntry.load(servedItem.fileId, err => { + const hashIdTypes = FileAreaWebAccess.getHashIdTypes(); + switch(servedItem.hashIdType) { + case hashIdTypes.SingleFile : + return this.routeWebRequestForSingleFile(servedItem, req, resp); + + case hashIdTypes.BatchArchive : + return this.routeWebRequestForBatchArchive(servedItem, req, resp); + + default : + return this.fileNotFound(resp); + } + }); + } + + routeWebRequestForSingleFile(servedItem, req, resp) { + Log.debug( { servedItem : servedItem }, 'Single file web request'); + + const fileEntry = new FileEntry(); + + servedItem.fileId = servedItem.fileIds[0]; + + fileEntry.load(servedItem.fileId, err => { + if(err) { + return this.fileNotFound(resp); + } + + const filePath = fileEntry.filePath; + if(!filePath) { + return this.fileNotFound(resp); + } + + fs.stat(filePath, (err, stats) => { if(err) { return this.fileNotFound(resp); } - const filePath = fileEntry.filePath; - if(!filePath) { + resp.on('close', () => { + // connection closed *before* the response was fully sent + // :TODO: Log and such + }); + + resp.on('finish', () => { + // transfer completed fully + this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size); + }); + + const headers = { + 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), + 'Content-Length' : stats.size, + 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, + }; + + const readStream = fs.createReadStream(filePath); + resp.writeHead(200, headers); + return readStream.pipe(resp); + }); + }); + } + + routeWebRequestForBatchArchive(servedItem, req, resp) { + Log.debug( { servedItem : servedItem }, 'Batch file web request'); + + // + // We are going to build an on-the-fly zip file stream of 1:n + // files in the batch. + // + // First, collect all file IDs + // + const self = this; + + async.waterfall( + [ + function fetchFileIds(callback) { + FileDb.all( + `SELECT file_id + FROM file_web_serve_batch + WHERE hash_id = ?;`, + [ servedItem.hashId ], + (err, fileIdRows) => { + if(err || !Array.isArray(fileIdRows) || 0 === fileIdRows.length) { + return callback(Errors.DoesNotExist('Could not get file IDs for batch')); + } + + return callback(null, fileIdRows.map(r => r.file_id)); + } + ); + }, + function loadFileEntries(fileIds, callback) { + const filePaths = []; + async.eachSeries(fileIds, (fileId, nextFileId) => { + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + if(!err) { + filePaths.push(fileEntry.filePath); + } + return nextFileId(err); + }); + }, err => { + if(err) { + return callback(Errors.DoesNotExist('Coudl not load file IDs for batch')); + } + + return callback(null, filePaths); + }); + }, + function createAndServeStream(filePaths, callback) { + Log.trace( { filePaths : filePaths }, 'Creating zip archive for batch web request'); + + const zipFile = new yazl.ZipFile(); + + zipFile.on('error', err => { + Log.warn( { error : err.message }, 'Error adding file to batch web request archive'); + }); + + filePaths.forEach(fp => { + zipFile.addFile( + fp, // path to physical file + paths.basename(fp), // filename/path *stored in archive* + { + compress : false, // :TODO: do this smartly - if ext is in set = false, else true via isArchive() or such... mimeDB has this for us. + } + ); + }); + + zipFile.end( finalZipSize => { + if(-1 === finalZipSize) { + return callback(Errors.UnexpectedState('Unable to acquire final zip size')); + } + + resp.on('close', () => { + // connection closed *before* the response was fully sent + // :TODO: Log and such + }); + + resp.on('finish', () => { + // transfer completed fully + self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize); + }); + + const batchFileName = `batch_${servedItem.hashId}.zip`; + + const headers = { + 'Content-Type' : mimeTypes.contentType(batchFileName) || mimeTypes.contentType('.bin'), + 'Content-Length' : finalZipSize, + 'Content-Disposition' : `attachment; filename="${batchFileName}"`, + }; + + resp.writeHead(200, headers); + return zipFile.outputStream.pipe(resp); + }); + } + ], + err => { + if(err) { + // :TODO: Log me! return this.fileNotFound(resp); } - fs.stat(filePath, (err, stats) => { - if(err) { - return this.fileNotFound(resp); - } - - resp.on('close', () => { - // connection closed *before* the response was fully sent - // :TODO: Log and such - }); - - resp.on('finish', () => { - // transfer completed fully - this.updateDownloadStatsForUserIdAndSystemAndSystem(servedItem.userId, stats.size); - }); - - const headers = { - 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), - 'Content-Length' : stats.size, - 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, - }; - - const readStream = fs.createReadStream(filePath); - resp.writeHead(200, headers); - return readStream.pipe(resp); - }); - }); - }); + // ...otherwise, we would have called resp() already. + } + ); } updateDownloadStatsForUserIdAndSystem(userId, dlBytes, cb) { diff --git a/core/file_base_area.js b/core/file_base_area.js index 79b91ce8..f3845cc1 100644 --- a/core/file_base_area.js +++ b/core/file_base_area.js @@ -13,6 +13,7 @@ const Log = require('./logger.js').log; const resolveMimeType = require('./mime_util.js').resolveMimeType; const stringFormat = require('./string_format.js'); const wordWrapText = require('./word_wrap.js').wordWrapText; +const StatLog = require('./stat_log.js'); // deps const _ = require('lodash'); @@ -27,6 +28,7 @@ const moment = require('moment'); exports.isInternalArea = isInternalArea; exports.getAvailableFileAreas = getAvailableFileAreas; +exports.getAvailableFileAreaTags = getAvailableFileAreaTags; exports.getSortedAvailableFileAreas = getSortedAvailableFileAreas; exports.isValidStorageTag = isValidStorageTag; exports.getAreaStorageDirectoryByTag = getAreaStorageDirectoryByTag; @@ -39,14 +41,19 @@ exports.changeFileAreaWithOptions = changeFileAreaWithOptions; exports.scanFile = scanFile; exports.scanFileAreaForChanges = scanFileAreaForChanges; exports.getDescFromFileName = getDescFromFileName; +exports.getAreaStats = getAreaStats; + +// for scheduler: +exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent; const WellKnownAreaTags = exports.WellKnownAreaTags = { Invalid : '', MessageAreaAttach : 'system_message_attachment', + TempDownloads : 'system_temporary_download', }; function isInternalArea(areaTag) { - return areaTag === WellKnownAreaTags.MessageAreaAttach; + return [ WellKnownAreaTags.MessageAreaAttach, WellKnownAreaTags.TempDownloads ].includes(areaTag); } function getAvailableFileAreas(client, options) { @@ -60,6 +67,10 @@ function getAvailableFileAreas(client, options) { return true; } + if(options.skipAcsCheck) { + return false; // no ACS checks (below) + } + if(options.writeAcs && !client.acs.hasFileAreaWrite(areaInfo)) { return true; // omit } @@ -68,6 +79,10 @@ function getAvailableFileAreas(client, options) { }); } +function getAvailableFileAreaTags(client, options) { + return _.map(getAvailableFileAreas(client, options), area => area.areaTag); +} + function getSortedAvailableFileAreas(client, options) { const areas = _.map(getAvailableFileAreas(client, options), v => v); sortAreasOrConfs(areas); @@ -341,7 +356,8 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) { // Assume FILE_ID.DIZ, NFO files, etc. are CP437. // // :TODO: This isn't really always the case - how to handle this? We could do a quick detection... - fileEntry[descType] = iconv.decode(sliceAtSauceMarker(data, 0x1a), 'cp437'); + fileEntry[descType] = iconv.decode(sliceAtSauceMarker(data, 0x1a), 'cp437'); + fileEntry[`${descType}Src`] = 'descFile'; return next(null); }); }); @@ -389,7 +405,8 @@ function extractAndProcessSingleArchiveEntry(fileEntry, filePath, archiveEntries function processSingleExtractedFile(extractedFile, callback) { populateFileEntryInfoFromFile(fileEntry, extractedFile, err => { if(!fileEntry.desc) { - fileEntry.desc = getDescFromFileName(filePath); + fileEntry.desc = getDescFromFileName(filePath); + fileEntry.descSrc = 'fileName'; } return callback(err); }); @@ -514,7 +531,8 @@ function populateFileEntryInfoFromFile(fileEntry, filePath, cb) { stdout = (wordWrapText( stdout, { width : 45 } ).wrapped || []).join('\n'); } - fileEntry[key] = stdout; + fileEntry[key] = stdout; + fileEntry[`${key}Src`] = 'infoTool'; } } @@ -536,7 +554,8 @@ function populateFileEntryNonArchive(fileEntry, filePath, stepInfo, iterator, cb function getDescriptions(callback) { populateFileEntryInfoFromFile(fileEntry, filePath, err => { if(!fileEntry.desc) { - fileEntry.desc = getDescFromFileName(filePath); + fileEntry.desc = getDescFromFileName(filePath); + fileEntry.descSrc = 'fileName'; } return callback(err); }); @@ -856,4 +875,64 @@ function getDescFromFileName(fileName) { const name = paths.basename(fileName, ext); return _.upperFirst(name.replace(/[\-_.+]/g, ' ').replace(/\s+/g, ' ')); +} + +// +// Return an object of stats about an area(s) +// +// { +// +// totalFiles : , +// totalBytes : , +// areas : { +// : { +// files : , +// bytes : +// } +// } +// } +// +function getAreaStats(cb) { + FileDb.all( + `SELECT DISTINCT f.area_tag, COUNT(f.file_id) AS total_files, SUM(m.meta_value) AS total_byte_size + FROM file f, file_meta m + WHERE f.file_id = m.file_id AND m.meta_name='byte_size' + GROUP BY f.area_tag;`, + (err, statRows) => { + if(err) { + return cb(err); + } + + if(!statRows || 0 === statRows.length) { + return cb(Errors.DoesNotExist('No file areas to acquire stats from')); + } + + return cb( + null, + statRows.reduce( (stats, v) => { + stats.totalFiles = (stats.totalFiles || 0) + v.total_files; + stats.totalBytes = (stats.totalBytes || 0) + v.total_byte_size; + + stats.areas = stats.areas || {}; + + stats.areas[v.area_tag] = { + files : v.total_files, + bytes : v.total_byte_size, + }; + return stats; + }, {}) + ); + } + ); +} + +// method exposed for event scheduler +function updateAreaStatsScheduledEvent(args, cb) { + getAreaStats( (err, stats) => { + if(!err) { + StatLog.setNonPeristentSystemStat('file_base_area_stats', stats); + } + + return cb(err); + }); } \ No newline at end of file diff --git a/core/file_base_area_select.js b/core/file_base_area_select.js new file mode 100644 index 00000000..5ec266fd --- /dev/null +++ b/core/file_base_area_select.js @@ -0,0 +1,104 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const MenuModule = require('./menu_module.js').MenuModule; +const stringFormat = require('./string_format.js'); +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const StatLog = require('./stat_log.js'); + +// deps +const async = require('async'); + +exports.moduleInfo = { + name : 'File Area Selector', + desc : 'Select from available file areas', + author : 'NuSkooler', +}; + +const MciViewIds = { + areaList : 1, +}; + +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, + }; + + const menuOpts = { + extraArgs : { + filterCriteria : filterCriteria, + }, + menuFlags : [ 'popParent' ], + }; + + return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); + } + }; + } + + loadAvailAreas() { + this.availAreas = getSortedAvailableFileAreas(this.client); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + + async.series( + [ + function mergeAreaStats(callback) { + const areaStats = StatLog.getSystemStat('file_base_area_stats') || { areas : {} }; + + self.availAreas.forEach(area => { + const stats = areaStats.areas[area.areaTag]; + area.totalFiles = stats ? stats.files : 0; + area.totalBytes = stats ? stats.bytes : 0; + }); + + return callback(null); + }, + function prepView(callback) { + self.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => { + if(err) { + return callback(err); + } + + const areaListView = vc.getView(MciViewIds.areaList); + + const areaListFormat = self.config.areaListFormat || '{name}'; + + areaListView.setItems(self.availAreas.map(a => stringFormat(areaListFormat, a) ) ); + + if(self.config.areaListFocusFormat) { + areaListView.setFocusItems(self.availAreas.map(a => stringFormat(self.config.areaListFocusFormat, a) ) ); + } + + areaListView.redraw(); + + return callback(null); + }); + } + ], + err => { + return cb(err); + } + ); + }); + } +}; diff --git a/mods/file_base_download_manager.js b/core/file_base_download_manager.js similarity index 77% rename from mods/file_base_download_manager.js rename to core/file_base_download_manager.js index 812a2422..7444af56 100644 --- a/mods/file_base_download_manager.js +++ b/core/file_base_download_manager.js @@ -2,17 +2,19 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const DownloadQueue = require('../core/download_queue.js'); -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); -const Errors = require('../core/enig_error.js').Errors; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const DownloadQueue = require('./download_queue.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const Errors = require('./enig_error.js').Errors; +const stringFormat = require('./string_format.js'); +const FileAreaWeb = require('./file_area_web.js'); // deps const async = require('async'); const _ = require('lodash'); +const moment = require('moment'); exports.moduleInfo = { name : 'File Base Download Queue Manager', @@ -22,17 +24,15 @@ exports.moduleInfo = { const FormIds = { queueManager : 0, - details : 1, }; const MciViewIds = { queueManager : { - queue : 1, - navMenu : 2, - }, - details : { + queue : 1, + navMenu : 2, - } + customRangeStart : 10, + }, }; exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { @@ -126,6 +126,26 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { return cb(null); } + displayWebDownloadLinkForFileEntry(fileEntry) { + FileAreaWeb.getExistingTempDownloadServeItem(this.client, fileEntry, (err, serveItem) => { + if(serveItem && serveItem.url) { + const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + fileEntry.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; + fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + } else { + fileEntry.webDlLink = ''; + fileEntry.webDlExpire = ''; + } + + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, fileEntry, + { filter : [ '{webDlLink}', '{webDlExpire}' ] } + ); + }); + } + updateDownloadQueueView(cb) { const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); if(!queueView) { @@ -138,7 +158,13 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); + queueView.on('index update', idx => { + const fileEntry = this.dlQueue.items[idx]; + this.displayWebDownloadLinkForFileEntry(fileEntry); + }); + queueView.redraw(); + this.displayWebDownloadLinkForFileEntry(this.dlQueue.items[0]); return cb(null); } diff --git a/core/file_base_filter.js b/core/file_base_filter.js index fadd41fd..320d36d3 100644 --- a/core/file_base_filter.js +++ b/core/file_base_filter.js @@ -24,6 +24,7 @@ module.exports = class FileBaseFilters { 'user_rating', 'est_release_year', 'byte_size', + 'file_name', ]; } @@ -135,9 +136,14 @@ module.exports = class FileBaseFilters { return parseInt((user.properties.user_file_base_last_viewed || 0)); } - static setFileBaseLastViewedFileIdForUser(user, fileId, cb) { + static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) { + if(!cb && _.isFunction(allowOlder)) { + cb = allowOlder; + allowOlder = false; + } + const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user); - if(fileId < current) { + if(!allowOlder && fileId < current) { if(cb) { cb(null); } diff --git a/mods/file_base_search.js b/core/file_base_search.js similarity index 88% rename from mods/file_base_search.js rename to core/file_base_search.js index e984e1a4..27656123 100644 --- a/mods/file_base_search.js +++ b/core/file_base_search.js @@ -2,10 +2,10 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../core/file_base_filter.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('./file_base_filter.js'); // deps const async = require('async'); @@ -56,7 +56,7 @@ exports.getModule = class FileBaseSearch extends MenuModule { }, function populateAreas(callback) { self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); - + const areasView = vc.getView(MciViewIds.search.area); areasView.setItems( self.availAreas.map( a => a.name ) ); areasView.redraw(); @@ -112,7 +112,7 @@ exports.getModule = class FileBaseSearch extends MenuModule { extraArgs : { filterCriteria : filterCriteria, }, - menuFlags : [ 'noHistory' ], + menuFlags : [ 'popParent' ], }; return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); diff --git a/core/file_base_web_download_manager.js b/core/file_base_web_download_manager.js new file mode 100644 index 00000000..dea7c5a8 --- /dev/null +++ b/core/file_base_web_download_manager.js @@ -0,0 +1,287 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const DownloadQueue = require('./download_queue.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const Errors = require('./enig_error.js').Errors; +const stringFormat = require('./string_format.js'); +const FileAreaWeb = require('./file_area_web.js'); +const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; +const Config = require('./config.js').config; + +// deps +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); + +exports.moduleInfo = { + name : 'File Base Download Web Queue Manager', + desc : 'Module for interacting with web backed download queue/batch', + author : 'NuSkooler', +}; + +const FormIds = { + queueManager : 0 +}; + +const MciViewIds = { + queueManager : { + queue : 1, + navMenu : 2, + + customRangeStart : 10, + } +}; + +exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule { + + constructor(options) { + super(options); + + this.dlQueue = new DownloadQueue(this.client); + + this.menuMethods = { + removeItem : (formData, extraArgs, cb) => { + const selectedItem = this.dlQueue.items[formData.value.queueItem]; + if(!selectedItem) { + return cb(null); + } + + this.dlQueue.removeItems(selectedItem.fileId); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb); + }, + clearQueue : (formData, extraArgs, cb) => { + this.dlQueue.clear(); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView('all', cb); + }, + getBatchLink : (formData, extraArgs, cb) => { + return this.generateAndDisplayBatchLink(cb); + } + }; + } + + initSequence() { + if(0 === this.dlQueue.items.length) { + return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); + } + + const self = this; + + async.series( + [ + function beforeArt(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + return self.displayQueueManagerPage(false, callback); + } + ], + () => { + return self.finishedLoading(); + } + ); + } + + removeItemsFromDownloadQueueView(itemIndex, cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + return cb(Errors.DoesNotExist('Queue view does not exist')); + } + + if('all' === itemIndex) { + queueView.setItems([]); + queueView.setFocusItems([]); + } else { + queueView.removeItem(itemIndex); + } + + queueView.redraw(); + return cb(null); + } + + displayFileInfoForFileEntry(fileEntry) { + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, fileEntry, + { filter : [ '{webDlLink}', '{webDlExpire}', '{fileName}' ] } // :TODO: Others.... + ); + } + + updateDownloadQueueView(cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + 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.on('index update', idx => { + const fileEntry = this.dlQueue.items[idx]; + this.displayFileInfoForFileEntry(fileEntry); + }); + + queueView.redraw(); + this.displayFileInfoForFileEntry(this.dlQueue.items[0]); + + return cb(null); + } + + generateAndDisplayBatchLink(cb) { + const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempBatchDownload( + this.client, + this.dlQueue.items, + { + expireTime : expireTime + }, + (err, webBatchDlLink) => { + // :TODO: handle not enabled -> display such + if(err) { + return cb(err); + } + + const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + const formatObj = { + webBatchDlLink : ansi.vtxHyperlink(this.client, webBatchDlLink) + webBatchDlLink, + webBatchDlExpire : expireTime.format(webDlExpireTimeFormat), + }; + + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, + formatObj, + { filter : Object.keys(formatObj).map(k => '{' + k + '}' ) } + ); + + return cb(null); + } + ); + } + + displayQueueManagerPage(clearScreen, cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback); + }, + function prepareQueueDownloadLinks(callback) { + const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => { + FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => { + if(err) { + if(ErrNotEnabled === err.reasonCode) { + return nextFileEntry(err); // we should have caught this prior + } + + const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempDownload( + self.client, + fileEntry, + { expireTime : expireTime }, + (err, url) => { + if(err) { + return nextFileEntry(err); + } + + fileEntry.webDlLinkRaw = url; + fileEntry.webDlLink = ansi.vtxHyperlink(self.client, url) + url; + fileEntry.webDlExpire = expireTime.format(webDlExpireTimeFormat); + + return nextFileEntry(null); + } + ); + } else { + fileEntry.webDlLinkRaw = serveItem.url; + fileEntry.webDlLink = ansi.vtxHyperlink(self.client, serveItem.url) + serveItem.url; + fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + return nextFileEntry(null); + } + }); + }, err => { + return callback(err); + }); + }, + function populateViews(callback) { + return self.updateDownloadQueueView(callback); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayArtAndPrepViewController(name, options, cb) { + const self = this; + const config = this.menuConfig.config; + + async.waterfall( + [ + function readyAndDisplayArt(callback) { + if(options.clearScreen) { + self.client.term.rawWrite(ansi.resetScreen()); + } + + theme.displayThemedAsset( + config.art[name], + self.client, + { font : self.menuConfig.font, trailingLF : false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function prepeareViewController(artData, callback) { + if(_.isUndefined(self.viewControllers[name])) { + const vcOpts = { + client : self.client, + formId : FormIds[name], + }; + + if(!_.isUndefined(options.noInput)) { + vcOpts.noInput = options.noInput; + } + + const vc = self.addViewController(name, new ViewController(vcOpts)); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds[name], + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } + + self.viewControllers[name].setFocus(true); + return callback(null); + + }, + ], + err => { + return cb(err); + } + ); + } +}; + \ No newline at end of file diff --git a/core/file_entry.js b/core/file_entry.js index b88d41a0..8bf7a69d 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -11,10 +11,13 @@ const async = require('async'); const _ = require('lodash'); const paths = require('path'); const fse = require('fs-extra'); +const { unlink, readFile } = require('graceful-fs'); +const crypto = require('crypto'); +const moment = require('moment'); const FILE_TABLE_MEMBERS = [ 'file_id', 'area_tag', 'file_sha256', 'file_name', 'storage_tag', - 'desc', 'desc_long', 'upload_timestamp' + 'desc', 'desc_long', 'upload_timestamp' ]; const FILE_WELL_KNOWN_META = { @@ -110,9 +113,8 @@ module.exports = class FileEntry { } const self = this; - let inTransaction = false; - async.series( + async.waterfall( [ function check(callback) { if(isUpdate && !self.fileId) { @@ -120,23 +122,41 @@ module.exports = class FileEntry { } return callback(null); }, - function startTrans(callback) { - return fileDb.run('BEGIN;', callback); - }, - function storeEntry(callback) { - inTransaction = true; + function calcSha256IfNeeded(callback) { + if(self.fileSha256) { + return callback(null); + } if(isUpdate) { - fileDb.run( + return callback(Errors.MissingParam('fileSha256 property must be set for updates!')); + } + + readFile(self.filePath, (err, data) => { + if(err) { + return callback(err); + } + + const sha256 = crypto.createHash('sha256'); + sha256.update(data); + self.fileSha256 = sha256.digest('hex'); + return callback(null); + }); + }, + function startTrans(callback) { + return fileDb.beginTransaction(callback); + }, + function storeEntry(trans, callback) { + if(isUpdate) { + trans.run( `REPLACE INTO file (file_id, area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, [ self.fileId, self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], err => { - return callback(err); + return callback(err, trans); } ); } else { - fileDb.run( + trans.run( `REPLACE INTO file (area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) VALUES(?, ?, ?, ?, ?, ?, ?);`, [ self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], @@ -144,35 +164,35 @@ module.exports = class FileEntry { if(!err) { self.fileId = this.lastID; } - return callback(err); + return callback(err, trans); } ); } }, - function storeMeta(callback) { + function storeMeta(trans, callback) { async.each(Object.keys(self.meta), (n, next) => { const v = self.meta[n]; - return FileEntry.persistMetaValue(self.fileId, n, v, next); + return FileEntry.persistMetaValue(self.fileId, n, v, trans, next); }, err => { - return callback(err); + return callback(err, trans); }); }, - function storeHashTags(callback) { + function storeHashTags(trans, callback) { const hashTagsArray = Array.from(self.hashTags); async.each(hashTagsArray, (hashTag, next) => { - return FileEntry.persistHashTag(self.fileId, hashTag, next); + return FileEntry.persistHashTag(self.fileId, hashTag, trans, next); }, err => { - return callback(err); + return callback(err, trans); }); } ], - err => { + (err, trans) => { // :TODO: Log orig err - if(inTransaction) { - fileDb.run(err ? 'ROLLBACK;' : 'COMMIT;', err => { - return cb(err); + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(transErr ? transErr : err); }); } else { return cb(err); @@ -207,8 +227,13 @@ module.exports = class FileEntry { ); } - static persistMetaValue(fileId, name, value, cb) { - return fileDb.run( + static persistMetaValue(fileId, name, value, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = fileDb; + } + + return transOrDb.run( `REPLACE INTO file_meta (file_id, meta_name, meta_value) VALUES (?, ?, ?);`, [ fileId, name, value ], @@ -249,15 +274,20 @@ module.exports = class FileEntry { ); } - static persistHashTag(fileId, hashTag, cb) { - fileDb.serialize( () => { - fileDb.run( + static persistHashTag(fileId, hashTag, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = fileDb; + } + + transOrDb.serialize( () => { + transOrDb.run( `INSERT OR IGNORE INTO hash_tag (hash_tag) VALUES (?);`, [ hashTag ] ); - fileDb.run( + transOrDb.run( `REPLACE INTO file_hash_tag (hash_tag_id, file_id) VALUES ( (SELECT hash_tag_id @@ -395,7 +425,11 @@ module.exports = class FileEntry { let sqlWhere = ''; let sqlOrderBy; const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC'; - + + if(moment.isMoment(filter.newerThanTimestamp)) { + filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp); + } + function getOrderByWithCast(ob) { if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) { return `ORDER BY CAST(${ob} AS INTEGER)`; @@ -415,7 +449,7 @@ module.exports = class FileEntry { if(filter.sort && filter.sort.length > 0) { if(Object.keys(FILE_WELL_KNOWN_META).indexOf(filter.sort) > -1) { // sorting via a meta value? - sql = + sql = `SELECT DISTINCT f.file_id FROM file f, file_meta m`; @@ -432,7 +466,7 @@ module.exports = class FileEntry { WHERE file_id = f.file_id) AS avg_rating FROM file f`; - + sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`; } else { sql = @@ -443,7 +477,7 @@ module.exports = class FileEntry { } } } else { - sql = + sql = `SELECT DISTINCT f.file_id FROM file f`; @@ -451,7 +485,12 @@ module.exports = class FileEntry { } if(filter.areaTag && filter.areaTag.length > 0) { - appendWhereClause(`f.area_tag = "${filter.areaTag}"`); + if(Array.isArray(filter.areaTag)) { + const areaList = filter.areaTag.map(t => `"${t}"`).join(', '); + appendWhereClause(`f.area_tag IN(${areaList})`); + } else { + appendWhereClause(`f.area_tag = "${filter.areaTag}"`); + } } if(filter.metaPairs && filter.metaPairs.length > 0) { @@ -494,8 +533,8 @@ module.exports = class FileEntry { } if(filter.tags && filter.tags.length > 0) { - // build list of quoted tags; filter.tags comes in as a space separated values - const tags = filter.tags.split(' ').map( tag => `"${tag}"` ).join(','); + // build list of quoted tags; filter.tags comes in as a space and/or comma separated values + const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${tag}"` ).join(','); appendWhereClause( `f.file_id IN ( @@ -518,7 +557,13 @@ module.exports = class FileEntry { appendWhereClause(`f.file_id > ${filter.newerThanFileId}`); } - sql += `${sqlWhere} ${sqlOrderBy};`; + sql += `${sqlWhere} ${sqlOrderBy}`; + + if(_.isNumber(filter.limit)) { + sql += ` LIMIT ${filter.limit}`; + } + + sql += ';'; const matchingFileIds = []; fileDb.each(sql, (err, fileId) => { @@ -530,6 +575,40 @@ module.exports = class FileEntry { }); } + static removeEntry(srcFileEntry, options, cb) { + if(!_.isFunction(cb) && _.isFunction(options)) { + cb = options; + options = {}; + } + + async.series( + [ + function removeFromDatabase(callback) { + fileDb.run( + `DELETE FROM file + WHERE file_id = ?;`, + [ srcFileEntry.fileId ], + err => { + return callback(err); + } + ); + }, + function optionallyRemovePhysicalFile(callback) { + if(true !== options.removePhysFile) { + return callback(null); + } + + unlink(srcFileEntry.filePath, err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + static moveEntry(srcFileEntry, destAreaTag, destStorageTag, destFileName, cb) { if(!cb && _.isFunction(destFileName)) { cb = destFileName; @@ -539,7 +618,6 @@ module.exports = class FileEntry { const srcPath = srcFileEntry.filePath; const dstDir = FileEntry.getAreaStorageDirectoryByTag(destStorageTag); - if(!dstDir) { return cb(Errors.Invalid('Invalid storage tag')); } diff --git a/mods/file_transfer_protocol_select.js b/core/file_transfer_protocol_select.js similarity index 93% rename from mods/file_transfer_protocol_select.js rename to core/file_transfer_protocol_select.js index 6efa5a93..f1b3dbed 100644 --- a/mods/file_transfer_protocol_select.js +++ b/core/file_transfer_protocol_select.js @@ -2,10 +2,10 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const Config = require('../core/config.js').config; -const stringFormat = require('../core/string_format.js'); -const ViewController = require('../core/view_controller.js').ViewController; +const MenuModule = require('./menu_module.js').MenuModule; +const Config = require('./config.js').config; +const stringFormat = require('./string_format.js'); +const ViewController = require('./view_controller.js').ViewController; // deps const async = require('async'); diff --git a/core/fse.js b/core/fse.js index ed9d3b13..b266a9c9 100644 --- a/core/fse.js +++ b/core/fse.js @@ -15,6 +15,7 @@ const stringFormat = require('./string_format.js'); const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher; const { isAnsi, cleanControlCodes, insert } = require('./string_util.js'); const Config = require('./config.js').config; +const { getAddressedToInfo } = require('./mail_util.js'); // deps const async = require('async'); @@ -264,12 +265,12 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul isEditMode() { return 'edit' === this.editorMode; } - + isViewMode() { return 'view' === this.editorMode; } - isLocalEmail() { + isPrivateMail() { return Message.WellKnownAreaTags.Private === this.messageAreaTag; } @@ -342,8 +343,6 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul // really don't like ANSI messages in UTF-8 encoding (they should!) // msgOpts.meta = { System : { 'explicit_encoding' : Config.scannerTossers.ftn_bso.packetAnsiMsgEncoding || 'cp437' } }; - // :TODO: change to \r\nESC[A - //msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}${msgOpts.message}`; msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}\r\n${ansi.up()}${msgOpts.message}`; } } @@ -411,30 +410,54 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul return callback(null); }, function populateLocalUserInfo(callback) { - if(self.isLocalEmail()) { - self.message.setLocalFromUserId(self.client.user.userId); - - if(self.toUserId > 0) { - self.message.setLocalToUserId(self.toUserId); - callback(null); - } else { - // we need to look it up - User.getUserIdAndName(self.message.toUserName, function userInfo(err, toUserId) { - if(err) { - callback(err); - } else { - self.message.setLocalToUserId(toUserId); - callback(null); - } - }); - } - } else { - callback(null); + if(!self.isPrivateMail()) { + return callback(null); } + + // :TODO: shouldn't local from user ID be set for all mail? + self.message.setLocalFromUserId(self.client.user.userId); + + if(self.toUserId > 0) { + self.message.setLocalToUserId(self.toUserId); + return callback(null); + } + + // + // If the message we're replying to is from a remote user + // don't try to look up the local user ID. Instead, mark the mail + // for export with the remote to address. + // + if(self.replyToMessage && self.replyToMessage.isFromRemoteUser()) { + self.message.setRemoteToUser(self.replyToMessage.meta.System[Message.SystemMetaNames.RemoteFromUser]); + self.message.setExternalFlavor(self.replyToMessage.meta.System[Message.SystemMetaNames.ExternalFlavor]); + return callback(null); + } + + // + // Detect if the user is attempting to send to a remote mail type that we support + // + // :TODO: how to plug in support without tying to various types here? isSupportedExteranlType() or such + const addressedToInfo = getAddressedToInfo(self.message.toUserName); + if(addressedToInfo.name && Message.AddressFlavor.FTN === addressedToInfo.flavor) { + self.message.setRemoteToUser(addressedToInfo.remote); + self.message.setExternalFlavor(addressedToInfo.flavor); + self.message.toUserName = addressedToInfo.name; + return callback(null); + } + + // we need to look it up + User.getUserIdAndNameByLookup(self.message.toUserName, (err, toUserId) => { + if(err) { + return callback(err); + } + + self.message.setLocalToUserId(toUserId); + return callback(null); + }); } ], - function complete(err) { - cb(err, self.message); + err => { + return cb(err, self.message); } ); } @@ -967,7 +990,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul this.viewControllers.body.switchFocus(1); this.observeEditorEvents(); - }; + } switchToFooter() { this.viewControllers.header.setFocus(false); @@ -995,14 +1018,14 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul const quoteMsgView = this.viewControllers.quoteBuilder.getView(1); const msgView = this.viewControllers.body.getView(1); - let quoteLines = quoteMsgView.getData(); + let quoteLines = quoteMsgView.getData().trim(); - if(quoteLines.trim().length > 0) { + if(quoteLines.length > 0) { if(this.replyIsAnsi) { const bodyMessageView = this.viewControllers.body.getView(1); quoteLines += `${ansi.normal()}${bodyMessageView.getSGRFor('text')}`; } - msgView.addText(`${quoteLines}\n`); + msgView.addText(`${quoteLines}\n\n`); } quoteMsgView.setText(''); diff --git a/core/ftn_address.js b/core/ftn_address.js index 9edb3819..f0936e1d 100644 --- a/core/ftn_address.js +++ b/core/ftn_address.js @@ -20,6 +20,15 @@ module.exports = class Address { } } + static isValidAddress(addr) { + return addr && addr.isValid(); + } + + isValid() { + // FTN address is valid if we have at least a net/node + return _.isNumber(this.net) && _.isNumber(this.node); + } + isEqual(other) { if(_.isString(other)) { other = Address.fromString(other); diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index 095628ea..d84b4a69 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -19,15 +19,6 @@ const moment = require('moment'); exports.Packet = Packet; -/* - :TODO: things - * Test SAUCE ignore/extraction - * FSP-1010 for netmail (see SBBS) - * Syncronet apparently uses odd origin lines - * Origin lines starting with "#" instead of "*" ? - -*/ - const FTN_PACKET_HEADER_SIZE = 58; // fixed header size const FTN_PACKET_HEADER_TYPE = 2; const FTN_PACKET_MESSAGE_TYPE = 2; @@ -63,7 +54,7 @@ class PacketHeader { this.capWord = 0x0001; this.capWordValidate = ((this.capWord & 0xff) << 8) | ((this.capWord >> 8) & 0xff); // swap - + this.prodCodeHi = 0xfe; // see above this.prodRevHi = 0; } @@ -358,9 +349,9 @@ function Packet(options) { buffer.writeUInt16LE(packetHeader.origPoint, 50); buffer.writeUInt16LE(packetHeader.destPoint, 52); buffer.writeUInt32LE(packetHeader.prodData, 54); - + ws.write(buffer); - + return buffer.length; }; @@ -393,9 +384,19 @@ function Packet(options) { }; function addKludgeLine(line) { - const sepIndex = line.indexOf(':'); - const key = line.substr(0, sepIndex).toUpperCase(); - const value = line.substr(sepIndex + 1).trim(); + // + // We have to special case INTL/TOPT/FMPT as they don't contain + // a ':' name/value separator like the rest of the kludge lines... because stupdity. + // + let key = line.substr(0, 4).trim(); + let value; + if( ['INTL', 'TOPT', 'FMPT', 'Via' ].includes(key)) { + value = line.substr(key.length).trim(); + } else { + const sepIndex = line.indexOf(':'); + key = line.substr(0, sepIndex).toUpperCase(); + value = line.substr(sepIndex + 1).trim(); + } // // Allow mapped value to be either a key:value if there is only @@ -636,10 +637,30 @@ function Packet(options) { }); }); }; - + + this.sanatizeFtnProperties = function(message) { + [ + Message.FtnPropertyNames.FtnOrigNode, + Message.FtnPropertyNames.FtnDestNode, + Message.FtnPropertyNames.FtnOrigNetwork, + Message.FtnPropertyNames.FtnDestNetwork, + Message.FtnPropertyNames.FtnAttrFlags, + Message.FtnPropertyNames.FtnCost, + Message.FtnPropertyNames.FtnOrigZone, + Message.FtnPropertyNames.FtnDestZone, + Message.FtnPropertyNames.FtnOrigPoint, + Message.FtnPropertyNames.FtnDestPoint, + Message.FtnPropertyNames.FtnAttribute, + ].forEach( propName => { + if(message.meta.FtnProperty[propName]) { + message.meta.FtnProperty[propName] = parseInt(message.meta.FtnProperty[propName]) || 0; + } + }); + }; + this.getMessageEntryBuffer = function(message, options, cb) { - - function getAppendMeta(k, m) { + + function getAppendMeta(k, m, sepChar=':') { let append = ''; if(m) { let a = m; @@ -647,7 +668,7 @@ function Packet(options) { a = [ a ]; } a.forEach(v => { - append += `${k}: ${v}\r`; + append += `${k}${sepChar} ${v}\r`; }); } return append; @@ -657,7 +678,10 @@ function Packet(options) { [ function prepareHeaderAndKludges(callback) { const basicHeader = new Buffer(34); - + + // ensure address FtnProperties are numbers + self.sanatizeFtnProperties(message); + basicHeader.writeUInt16LE(FTN_PACKET_MESSAGE_TYPE, 0); basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_node, 2); basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_node, 4); @@ -693,10 +717,22 @@ function Packet(options) { msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) } + // :TODO: DRY with similar function in this file! Object.keys(message.meta.FtnKludge).forEach(k => { - // we want PATH to be last - if('PATH' !== k) { - msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + switch(k) { + case 'PATH' : + break; // skip & save for last + + case 'Via' : + case 'FMPT' : + case 'TOPT' : + case 'INTL' : + msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); // no sepChar + break; + + default : + msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + break; } }); @@ -810,14 +846,14 @@ function Packet(options) { // :TODO: Put this in it's own method let msgBody = ''; - function appendMeta(k, m) { + function appendMeta(k, m, sepChar=':') { if(m) { let a = m; if(!_.isArray(a)) { a = [ a ]; } a.forEach(v => { - msgBody += `${k}: ${v}\r`; + msgBody += `${k}${sepChar} ${v}\r`; }); } } @@ -832,9 +868,15 @@ function Packet(options) { } Object.keys(message.meta.FtnKludge).forEach(k => { - // we want PATH to be last - if('PATH' !== k) { - appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + switch(k) { + case 'PATH' : break; // skip & save for last + + case 'Via' : + case 'FMPT' : + case 'TOPT' : + case 'INTL' : appendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); break; // no sepChar + + default : appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); break; } }); diff --git a/core/ftn_util.js b/core/ftn_util.js index 4d779c4a..39093fab 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -26,6 +26,7 @@ exports.getUTCTimeZoneOffset = getUTCTimeZoneOffset; exports.getOrigin = getOrigin; exports.getTearLine = getTearLine; exports.getVia = getVia; +exports.getIntl = getIntl; exports.getAbbreviatedNetNodeList = getAbbreviatedNetNodeList; exports.parseAbbreviatedNetNodeList = parseAbbreviatedNetNodeList; exports.getUpdatedSeenByEntries = getUpdatedSeenByEntries; @@ -134,9 +135,20 @@ function getMessageSerialNumber(messageId) { // // ENiGMA½: .@<5dFtnAddress> // -function getMessageIdentifier(message, address) { +// 0.0.8-alpha: +// Made compliant with FTN spec *when exporting NetMail* due to +// Mystic rejecting messages with the true-unique version. +// Strangely, Synchronet uses the unique format and Mystic does +// OK with it. Will need to research further. Note also that +// g00r00 was kind enough to fix Mystic to allow for the Sync/Enig +// format, but that will only help when using newer Mystic versions. +// +function getMessageIdentifier(message, address, isNetMail = false) { const addrStr = new Address(address).toString('5D'); - return `${message.messageId}.${message.areaTag.toLowerCase()}@${addrStr} ${getMessageSerialNumber(message.messageId)}`; + return isNetMail ? + `${addrStr} ${getMessageSerialNumber(message.messageId)}` : + `${message.messageId}.${message.areaTag.toLowerCase()}@${addrStr} ${getMessageSerialNumber(message.messageId)}` + ; } // @@ -188,7 +200,7 @@ function getQuotePrefix(name) { // function getOrigin(address) { const origin = _.has(Config, 'messageNetworks.originLine') ? - Config.messageNetworks.originLine : + Config.messageNetworks.originLine : Config.general.boardName; const addrStr = new Address(address).toString('5D'); @@ -222,6 +234,20 @@ function getVia(address) { return `${addrStr} @${dateTime} ENiGMA1/2 ${version}`; } +// +// Creates a INTL kludge value as per FTS-4001 +// http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac +// +function getIntl(toAddress, fromAddress) { + // + // INTL differs from 'standard' kludges in that there is no ':' after "INTL" + // + // ""INTL "" "" + // "...These addresses shall be given on the form :/" + // + return `${toAddress.toString('3D')} ${fromAddress.toString('3D')}`; +} + function getAbbreviatedNetNodeList(netNodes) { let abbrList = ''; let currNet; diff --git a/mods/last_callers.js b/core/last_callers.js similarity index 92% rename from mods/last_callers.js rename to core/last_callers.js index afb429d8..3a889468 100644 --- a/mods/last_callers.js +++ b/core/last_callers.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const StatLog = require('../core/stat_log.js'); -const User = require('../core/user.js'); -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const StatLog = require('./stat_log.js'); +const User = require('./user.js'); +const stringFormat = require('./string_format.js'); // deps const moment = require('moment'); diff --git a/core/logger.js b/core/logger.js index 3b0b47e2..f90aec41 100644 --- a/core/logger.js +++ b/core/logger.js @@ -62,7 +62,7 @@ module.exports = class Log { // Use a regexp -- we don't know how nested fields we want to seek and destroy may be // return JSON.parse( - JSON.stringify(obj).replace(/"(password|key|authCode)"\s?:\s?"([^"]+)"/, (match, valueName) => { + JSON.stringify(obj).replace(/"(password|passwordConfirm|key|authCode)"\s?:\s?"([^"]+)"/, (match, valueName) => { return `"${valueName}":"********"`; }) ); diff --git a/core/mail_util.js b/core/mail_util.js new file mode 100644 index 00000000..654b1617 --- /dev/null +++ b/core/mail_util.js @@ -0,0 +1,81 @@ +/* jslint node: true */ +'use strict'; + +const Address = require('./ftn_address.js'); +const Message = require('./message.js'); + +exports.getAddressedToInfo = getAddressedToInfo; + +const EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +/* + Input Output + ---------------------------------------------------------------------------------------------------- + User { name : 'User', flavor : 'local' } + Some User { name : 'Some User', flavor : 'local' } + JoeUser @ 1:103/75 { name : 'JoeUser', flavor : 'ftn', remote : '1:103/75' } + Bob@1:103/705@fidonet.org { name : 'Bob', flavor : 'ftn', remote : '1:103/705@fidonet.org' } + 1:103/705@fidonet.org { flavor : 'ftn', remote : '1:103/705@fidonet.org' } + Jane <23:4/100> { name : 'Jane', flavor : 'ftn', remote : '23:4/100' } + 43:20/100.2 { flavor : 'ftn', remote : '43:20/100.2' } + foo@host.com { name : 'foo', flavor : 'email', remote : 'foo@host.com' } + Bar { name : 'Bar', flavor : 'email', remote : 'baz@foobar.com' } +*/ +function getAddressedToInfo(input) { + input = input.trim(); + + const firstAtPos = input.indexOf('@'); + + if(firstAtPos < 0) { + let addr = Address.fromString(input); + if(Address.isValidAddress(addr)) { + return { flavor : Message.AddressFlavor.FTN, remote : input }; + } + + const lessThanPos = input.indexOf('<'); + if(lessThanPos < 0) { + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + const greaterThanPos = input.indexOf('>'); + if(greaterThanPos < lessThanPos) { + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + addr = Address.fromString(input.slice(lessThanPos + 1, greaterThanPos)); + if(Address.isValidAddress(addr)) { + return { name : input.slice(0, lessThanPos).trim(), flavor : Message.AddressFlavor.FTN, remote : addr.toString() }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + const lessThanPos = input.indexOf('<'); + const greaterThanPos = input.indexOf('>'); + if(lessThanPos > 0 && greaterThanPos > lessThanPos) { + const addr = input.slice(lessThanPos + 1, greaterThanPos); + const m = addr.match(EMAIL_REGEX); + if(m) { + return { name : input.slice(0, lessThanPos).trim(), flavor : Message.AddressFlavor.Email, remote : addr }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + let m = input.match(EMAIL_REGEX); + if(m) { + return { name : input.slice(0, firstAtPos), flavor : Message.AddressFlavor.Email, remote : input }; + } + + let addr = Address.fromString(input); // 5D? + if(Address.isValidAddress(addr)) { + return { flavor : Message.AddressFlavor.FTN, remote : addr.toString() } ; + } + + addr = Address.fromString(input.slice(firstAtPos + 1).trim()); + if(Address.isValidAddress(addr)) { + return { name : input.slice(0, firstAtPos).trim(), flavor : Message.AddressFlavor.FTN, remote : addr.toString() }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; +} diff --git a/core/menu_stack.js b/core/menu_stack.js index f4b29460..b4bebea6 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -129,15 +129,19 @@ module.exports = class MenuStack { } else { self.client.log.debug( { menuName : name }, 'Goto menu module'); + const menuFlags = (options && Array.isArray(options.menuFlags)) ? options.menuFlags : modInst.menuConfig.options.menuFlags; + if(currentModuleInfo) { // save stack state currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState(); currentModuleInfo.instance.leave(); - const menuFlags = (options && Array.isArray(options.menuFlags)) ? options.menuFlags : modInst.menuConfig.options.menuFlags; + if(currentModuleInfo.menuFlags.includes('noHistory')) { + this.pop(); + } - if(menuFlags.includes('noHistory')) { + if(menuFlags.includes('popParent')) { this.pop().instance.leave(); // leave & remove current } } @@ -146,6 +150,7 @@ module.exports = class MenuStack { name : name, instance : modInst, extraArgs : loadOpts.extraArgs, + menuFlags : menuFlags, }); // restore previous state if requested diff --git a/core/message.js b/core/message.js index 710444ce..5d3c8db9 100644 --- a/core/message.js +++ b/core/message.js @@ -76,6 +76,10 @@ function Message(options) { this.isPrivate = function() { return Message.isPrivateAreaTag(this.areaTag); }; + + this.isFromRemoteUser = function() { + return null !== _.get(this, 'meta.System.remote_from_user', null); + }; } Message.WellKnownAreaTags = { @@ -93,6 +97,16 @@ Message.SystemMetaNames = { LocalFromUserID : 'local_from_user_id', StateFlags0 : 'state_flags0', // See Message.StateFlags0 ExplicitEncoding : 'explicit_encoding', // Explicitly set encoding when exporting/etc. + ExternalFlavor : 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.AddressFlavor + RemoteToUser : 'remote_to_user', // Opaque value depends on external system, e.g. FTN address + RemoteFromUser : 'remote_from_user', // Opaque value depends on external system, e.g. FTN address +}; + +// Types for Message.SystemMetaNames.ExternalFlavor meta +Message.AddressFlavor = { + Local : 'local', // local / non-remote addressing + FTN : 'ftn', // FTN style + Email : 'email', }; Message.StateFlags0 = { @@ -112,7 +126,7 @@ Message.FtnPropertyNames = { FtnDestZone : 'ftn_dest_zone', FtnOrigPoint : 'ftn_orig_point', FtnDestPoint : 'ftn_dest_point', - + FtnAttribute : 'ftn_attribute', FtnTearLine : 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001 @@ -124,11 +138,23 @@ Message.FtnPropertyNames = { // Note: kludges are stored with their names as-is Message.prototype.setLocalToUserId = function(userId) { - this.meta.System.local_to_user_id = userId; + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalToUserID] = userId; }; Message.prototype.setLocalFromUserId = function(userId) { - this.meta.System.local_from_user_id = userId; + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId; +}; + +Message.prototype.setRemoteToUser = function(remoteTo) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo; +}; + +Message.prototype.setExternalFlavor = function(flavor) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor; }; Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) { @@ -321,8 +347,13 @@ Message.prototype.load = function(options, cb) { ); }; -Message.prototype.persistMetaValue = function(category, name, value, cb) { - const metaStmt = msgDb.prepare( +Message.prototype.persistMetaValue = function(category, name, value, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = msgDb; + } + + const metaStmt = transOrDb.prepare( `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) VALUES (?, ?, ?, ?);`); @@ -341,18 +372,6 @@ Message.prototype.persistMetaValue = function(category, name, value, cb) { }); }; -Message.startTransaction = function(cb) { - msgDb.run('BEGIN;', err => { - cb(err); - }); -}; - -Message.endTransaction = function(hadError, cb) { - msgDb.run(hadError ? 'ROLLBACK;' : 'COMMIT;', err => { - cb(err); - }); -}; - Message.prototype.persist = function(cb) { if(!this.isValid()) { @@ -361,14 +380,12 @@ Message.prototype.persist = function(cb) { const self = this; - async.series( + async.waterfall( [ function beginTransaction(callback) { - Message.startTransaction(err => { - return callback(err); - }); + return msgDb.beginTransaction(callback); }, - function storeMessage(callback) { + function storeMessage(trans, callback) { // generate a UUID for this message if required (general case) const msgTimestamp = moment(); if(!self.uuid) { @@ -379,7 +396,7 @@ Message.prototype.persist = function(cb) { self.message); } - msgDb.run( + trans.run( `INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, [ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, getISOTimestampString(msgTimestamp) ], @@ -388,13 +405,13 @@ Message.prototype.persist = function(cb) { self.messageId = this.lastID; } - return callback(err); + return callback(err, trans); } ); }, - function storeMeta(callback) { + function storeMeta(trans, callback) { if(!self.meta) { - return callback(null); + return callback(null, trans); } /* Example of self.meta: @@ -410,7 +427,7 @@ Message.prototype.persist = function(cb) { */ async.each(Object.keys(self.meta), (category, nextCat) => { async.each(Object.keys(self.meta[category]), (name, nextName) => { - self.persistMetaValue(category, name, self.meta[category][name], err => { + self.persistMetaValue(category, name, self.meta[category][name], trans, err => { nextName(err); }); }, err => { @@ -418,18 +435,22 @@ Message.prototype.persist = function(cb) { }); }, err => { - callback(err); + callback(err, trans); }); }, - function storeHashTags(callback) { + function storeHashTags(trans, callback) { // :TODO: hash tag support - return callback(null); + return callback(null, trans); } ], - err => { - Message.endTransaction(err, transErr => { - return cb(err ? err : transErr, self.messageId); - }); + (err, trans) => { + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr, self.messageId); + }); + } else { + return cb(err); + } } ); }; diff --git a/core/message_area.js b/core/message_area.js index 484d22ec..53dd3086 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -2,17 +2,19 @@ 'use strict'; // ENiGMA½ -const msgDb = require('./database.js').dbs.message; -const Config = require('./config.js').config; -const Message = require('./message.js'); -const Log = require('./logger.js').log; -const msgNetRecord = require('./msg_network.js').recordMessage; -const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +const msgDb = require('./database.js').dbs.message; +const Config = require('./config.js').config; +const Message = require('./message.js'); +const Log = require('./logger.js').log; +const msgNetRecord = require('./msg_network.js').recordMessage; +const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +const { getISOTimestampString } = require('./database.js'); // deps const async = require('async'); const _ = require('lodash'); const assert = require('assert'); +const moment = require('moment'); exports.getAvailableMessageConferences = getAvailableMessageConferences; exports.getSortedAvailMessageConferences = getSortedAvailMessageConferences; @@ -28,6 +30,7 @@ exports.tempChangeMessageConfAndArea = tempChangeMessageConfAndArea; exports.getMessageListForArea = getMessageListForArea; exports.getNewMessageCountInAreaForUser = getNewMessageCountInAreaForUser; exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; +exports.getMessageIdNewerThanTimestampByArea = getMessageIdNewerThanTimestampByArea; exports.getMessageAreaLastReadId = getMessageAreaLastReadId; exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId; exports.persistMessage = persistMessage; @@ -347,13 +350,13 @@ function getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, what) 'COUNT() AS count' : 'message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count'; - let sql = + let sql = `SELECT ${selectWhat} FROM message WHERE area_tag = "${areaTag}" AND message_id > ${lastMessageId}`; if(Message.isPrivateAreaTag(areaTag)) { - sql += + sql += ` AND message_id in ( SELECT message_id FROM message_meta @@ -482,6 +485,28 @@ function getMessageListForArea(options, areaTag, cb) { ); } +function getMessageIdNewerThanTimestampByArea(areaTag, newerThanTimestamp, cb) { + if(moment.isMoment(newerThanTimestamp)) { + newerThanTimestamp = getISOTimestampString(newerThanTimestamp); + } + + msgDb.get( + `SELECT message_id + FROM message + WHERE area_tag = ? AND DATETIME(modified_timestamp) > DATETIME("${newerThanTimestamp}", "+1 seconds") + ORDER BY modified_timestamp ASC + LIMIT 1;`, + [ areaTag ], + (err, row) => { + if(err) { + return cb(err); + } + + return cb(null, row ? row.message_id : null); + } + ); +} + function getMessageAreaLastReadId(userId, areaTag, cb) { msgDb.get( 'SELECT message_id ' + @@ -494,7 +519,12 @@ function getMessageAreaLastReadId(userId, areaTag, cb) { ); } -function updateMessageAreaLastReadId(userId, areaTag, messageId, cb) { +function updateMessageAreaLastReadId(userId, areaTag, messageId, allowOlder, cb) { + if(!cb && _.isFunction(allowOlder)) { + cb = allowOlder; + allowOlder = false; + } + // :TODO: likely a better way to do this... async.waterfall( [ @@ -505,7 +535,7 @@ function updateMessageAreaLastReadId(userId, areaTag, messageId, cb) { }); }, function update(lastId, callback) { - if(messageId > lastId) { + if(allowOlder || messageId > lastId) { msgDb.run( 'REPLACE INTO user_message_area_last_read (user_id, area_tag, message_id) ' + 'VALUES (?, ?, ?);', @@ -568,11 +598,11 @@ function trimMessageAreasScheduledEvent(args, cb) { LIMIT -1 OFFSET ${areaInfo.maxMessages} );`, [ areaInfo.areaTag.toLowerCase() ], - err => { + function result(err) { // no arrow func; need this if(err) { - Log.error( { areaInfo : areaInfo, err : err, type : 'maxMessages' }, 'Error trimming message area'); + Log.error( { areaInfo : areaInfo, error : err.message, type : 'maxMessages' }, 'Error trimming message area'); } else { - Log.debug( { areaInfo : areaInfo, type : 'maxMessages' }, 'Area trimmed successfully'); + Log.debug( { areaInfo : areaInfo, type : 'maxMessages', count : this.changes }, 'Area trimmed successfully'); } return cb(err); } @@ -588,21 +618,25 @@ function trimMessageAreasScheduledEvent(args, cb) { `DELETE FROM message WHERE area_tag = ? AND modified_timestamp < date('now', '-${areaInfo.maxAgeDays} days');`, [ areaInfo.areaTag ], - err => { + function result(err) { // no arrow func; need this if(err) { - Log.warn( { areaInfo : areaInfo, err : err, type : 'maxAgeDays' }, 'Error trimming message area'); + Log.warn( { areaInfo : areaInfo, error : err.message, type : 'maxAgeDays' }, 'Error trimming message area'); } else { - Log.debug( { areaInfo : areaInfo, type : 'maxAgeDays' }, 'Area trimmed successfully'); + Log.debug( { areaInfo : areaInfo, type : 'maxAgeDays', count : this.changes }, 'Area trimmed successfully'); } return cb(err); } ); } - + async.waterfall( - [ + [ function getAreaTags(callback) { - let areaTags = []; + const areaTags = []; + + // + // We use SQL here vs API such that no-longer-used tags are picked up + // msgDb.each( `SELECT DISTINCT area_tag FROM message;`, @@ -610,7 +644,11 @@ function trimMessageAreasScheduledEvent(args, cb) { if(err) { return callback(err); } - areaTags.push(row.area_tag); + + // We treat private mail special + if(!Message.isPrivateAreaTag(row.area_tag)) { + areaTags.push(row.area_tag); + } }, err => { return callback(err, areaTags); @@ -622,30 +660,26 @@ function trimMessageAreasScheduledEvent(args, cb) { // determine maxMessages & maxAgeDays per area areaTags.forEach(areaTag => { - + let maxMessages = Config.messageAreaDefaults.maxMessages; let maxAgeDays = Config.messageAreaDefaults.maxAgeDays; - + const area = getMessageAreaByTag(areaTag); // note: we don't know the conf here if(area) { - if(area.maxMessages) { - maxMessages = area.maxMessages; - } - if(area.maxAgeDays) { - maxAgeDays = area.maxAgeDays; - } + maxMessages = area.maxMessages || maxMessages; + maxAgeDays = area.maxAgeDays || maxAgeDays; } areaInfos.push( { areaTag : areaTag, maxMessages : maxMessages, maxAgeDays : maxAgeDays, - } ); + } ); }); return callback(null, areaInfos); }, - function trimAreas(areaInfos, callback) { + function trimGeneralAreas(areaInfos, callback) { async.each( areaInfos, (areaInfo, next) => { @@ -661,11 +695,50 @@ function trimMessageAreasScheduledEvent(args, cb) { }, callback ); - } + }, + function trimExternalPrivateSentMail(callback) { + // + // *External* (FTN, email, ...) outgoing is cleaned up *after export* + // if it is older than the configured |maxExternalSentAgeDays| days + // + // Outgoing externally exported private mail is: + // - In the 'private_mail' area + // - Marked exported (state_flags0 exported bit set) + // - Marked with any external flavor (we don't mark local) + // + const maxExternalSentAgeDays = _.get( + Config, + 'messageConferences.system_internal.areas.private_mail.maxExternalSentAgeDays', + 30 + ); + + msgDb.run( + `DELETE FROM message + WHERE message_id IN ( + SELECT m.message_id + FROM message m + JOIN message_meta mms + ON m.message_id = mms.message_id AND + (mms.meta_category='System' AND mms.meta_name='${Message.SystemMetaNames.StateFlags0}' AND (mms.meta_value & ${Message.StateFlags0.Exported} = ${Message.StateFlags0.Exported})) + JOIN message_meta mmf + ON m.message_id = mmf.message_id AND + (mmf.meta_category='System' AND mmf.meta_name='${Message.SystemMetaNames.ExternalFlavor}') + WHERE m.area_tag='${Message.WellKnownAreaTags.Private}' AND DATETIME('now') > DATETIME(m.modified_timestamp, '+${maxExternalSentAgeDays} days') + );`, + function results(err) { // no arrow func; need this + if(err) { + Log.warn( { error : err.message }, 'Error trimming private externally sent messages'); + } else { + Log.debug( { count : this.changes }, 'Private externally sent messages trimmed successfully'); + } + } + ); + + return callback(null); + } ], err => { return cb(err); } ); - } \ No newline at end of file diff --git a/core/mime_util.js b/core/mime_util.js index bdb88a53..1e2bd32c 100644 --- a/core/mime_util.js +++ b/core/mime_util.js @@ -14,7 +14,6 @@ function startup(cb) { // Add in types (not yet) supported by mime-db -- and therefor, mime-types // const ADDITIONAL_EXT_MIMETYPES = { - arj : 'application/x-arj', ans : 'text/x-ansi', gz : 'application/gzip', // not in mime-types 2.1.15 :( }; diff --git a/mods/msg_area_list.js b/core/msg_area_list.js similarity index 91% rename from mods/msg_area_list.js rename to core/msg_area_list.js index a6a0df4c..eaedbef8 100644 --- a/mods/msg_area_list.js +++ b/core/msg_area_list.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const displayThemeArt = require('../core/theme.js').displayThemeArt; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const messageArea = require('./message_area.js'); +const displayThemeArt = require('./theme.js').displayThemeArt; +const resetScreen = require('./ansi_term.js').resetScreen; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/msg_area_post_fse.js b/core/msg_area_post_fse.js similarity index 90% rename from mods/msg_area_post_fse.js rename to core/msg_area_post_fse.js index 21b5d068..c13f39a6 100644 --- a/mods/msg_area_post_fse.js +++ b/core/msg_area_post_fse.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; -const persistMessage = require('../core/message_area.js').persistMessage; +const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; +const persistMessage = require('./message_area.js').persistMessage; const _ = require('lodash'); const async = require('async'); diff --git a/mods/msg_area_reply_fse.js b/core/msg_area_reply_fse.js similarity index 82% rename from mods/msg_area_reply_fse.js rename to core/msg_area_reply_fse.js index d1cb5faa..24ee5377 100644 --- a/mods/msg_area_reply_fse.js +++ b/core/msg_area_reply_fse.js @@ -1,7 +1,7 @@ /* jslint node: true */ 'use strict'; -var FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; +var FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; exports.getModule = AreaReplyFSEModule; diff --git a/mods/msg_area_view_fse.js b/core/msg_area_view_fse.js similarity index 96% rename from mods/msg_area_view_fse.js rename to core/msg_area_view_fse.js index de4657f1..02915f79 100644 --- a/mods/msg_area_view_fse.js +++ b/core/msg_area_view_fse.js @@ -2,8 +2,8 @@ 'use strict'; // ENiGMA½ -const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; -const Message = require('../core/message.js'); +const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; +const Message = require('./message.js'); // deps const _ = require('lodash'); diff --git a/mods/msg_conf_list.js b/core/msg_conf_list.js similarity index 89% rename from mods/msg_conf_list.js rename to core/msg_conf_list.js index 91c24de4..6f42cf36 100644 --- a/mods/msg_conf_list.js +++ b/core/msg_conf_list.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const displayThemeArt = require('../core/theme.js').displayThemeArt; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const messageArea = require('./message_area.js'); +const displayThemeArt = require('./theme.js').displayThemeArt; +const resetScreen = require('./ansi_term.js').resetScreen; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/msg_list.js b/core/msg_list.js similarity index 95% rename from mods/msg_list.js rename to core/msg_list.js index bc80e27b..e5a69e80 100644 --- a/mods/msg_list.js +++ b/core/msg_list.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const stringFormat = require('../core/string_format.js'); -const MessageAreaConfTempSwitcher = require('../core/mod_mixins.js').MessageAreaConfTempSwitcher; +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const messageArea = require('./message_area.js'); +const stringFormat = require('./string_format.js'); +const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher; // deps const async = require('async'); diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js index 9d46e8a1..68d8b3d6 100644 --- a/core/multi_line_edit_text_view.js +++ b/core/multi_line_edit_text_view.js @@ -67,7 +67,7 @@ const SPECIAL_KEY_MAP_DEFAULT = { 'line feed' : [ 'return' ], exit : [ 'esc' ], backspace : [ 'backspace' ], - delete : [ 'del' ], + delete : [ 'delete' ], tab : [ 'tab' ], up : [ 'up arrow' ], down : [ 'down arrow' ], @@ -354,21 +354,14 @@ function MultiLineEditTextView(options) { }; this.removeCharactersFromText = function(index, col, operation, count) { - if('right' === operation) { + if('delete' === operation) { self.textLines[index].text = - self.textLines[index].text.slice(col, count) + + self.textLines[index].text.slice(0, col) + self.textLines[index].text.slice(col + count); - self.cursorPos.col -= count; - self.updateTextWordWrap(index); self.redrawRows(self.cursorPos.row, self.dimens.height); - - if(0 === self.textLines[index].text) { - - } else { - self.redrawRows(self.cursorPos.row, self.dimens.height); - } + self.moveClientCursorToCursorPos(); } else if ('backspace' === operation) { // :TODO: method for splicing text self.textLines[index].text = @@ -868,11 +861,25 @@ function MultiLineEditTextView(options) { }; this.keyPressDelete = function() { - self.removeCharactersFromText( - self.getTextLinesIndex(), - self.cursorPos.col, - 'right', - 1); + const lineIndex = self.getTextLinesIndex(); + + if(0 === self.cursorPos.col && 0 === self.textLines[lineIndex].text.length && self.textLines.length > 0) { + // + // Start of line and nothing left. Just delete the line + // + self.removeCharactersFromText( + lineIndex, + 0, + 'delete line' + ); + } else { + self.removeCharactersFromText( + lineIndex, + self.cursorPos.col, + 'delete', + 1 + ); + } self.emitEditPosition(); }; @@ -1068,9 +1075,9 @@ MultiLineEditTextView.prototype.setFocus = function(focused) { MultiLineEditTextView.super_.prototype.setFocus.call(this, focused); }; -MultiLineEditTextView.prototype.setText = function(text) { +MultiLineEditTextView.prototype.setText = function(text, options = { scrollMode : 'default' } ) { this.textLines = [ ]; - this.addText(text); + this.addText(text, options); /*this.insertRawText(text); if(this.isEditMode()) { @@ -1085,13 +1092,27 @@ MultiLineEditTextView.prototype.setAnsi = function(ansi, options = { prepped : f return this.setAnsiWithOptions(ansi, options, cb); }; -MultiLineEditTextView.prototype.addText = function(text) { +MultiLineEditTextView.prototype.addText = function(text, options = { scrollMode : 'default' }) { this.insertRawText(text); - if(this.isEditMode() || this.autoScroll) { - this.cursorEndOfDocument(); - } else { - this.cursorStartOfDocument(); + switch(options.scrollMode) { + case 'default' : + if(this.isEditMode() || this.autoScroll) { + this.cursorEndOfDocument(); + } else { + this.cursorStartOfDocument(); + } + break; + + case 'top' : + case 'start' : + this.cursorStartOfDocument(); + break; + + case 'end' : + case 'bottom' : + this.cursorEndOfDocument(); + break; } }; @@ -1129,7 +1150,7 @@ const HANDLED_SPECIAL_KEYS = [ 'line feed', 'insert', 'tab', - 'backspace', 'del', + 'backspace', 'delete', 'delete line', ]; diff --git a/core/new_scan.js b/core/new_scan.js index 3c3f1371..f3e851fb 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -2,13 +2,14 @@ 'use strict'; // ENiGMA½ -const msgArea = require('./message_area.js'); -const MenuModule = require('./menu_module.js').MenuModule; -const ViewController = require('./view_controller.js').ViewController; -const stringFormat = require('./string_format.js'); -const FileEntry = require('./file_entry.js'); -const FileBaseFilters = require('./file_base_filter.js'); -const Errors = require('./enig_error.js').Errors; +const msgArea = require('./message_area.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const stringFormat = require('./string_format.js'); +const FileEntry = require('./file_entry.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const Errors = require('./enig_error.js').Errors; +const { getAvailableFileAreaTags } = require('./file_base_area.js'); // deps const _ = require('lodash'); @@ -166,18 +167,24 @@ exports.getModule = class NewScanModule extends MenuModule { newScanFileBase(cb) { // :TODO: add in steps + const filterCriteria = { + newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user), + areaTag : getAvailableFileAreaTags(this.client), + order : 'ascending', // oldest first + }; + FileEntry.findFiles( - { newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user) }, + filterCriteria, (err, fileIds) => { if(err || 0 === fileIds.length) { return cb(err ? err : Errors.DoesNotExist('No more new files')); } - FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[0] ); + FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[fileIds.length - 1] ); const menuOpts = { extraArgs : { - fileList : fileIds, + fileList : fileIds, }, }; diff --git a/mods/nua.js b/core/nua.js similarity index 92% rename from mods/nua.js rename to core/nua.js index 878e0581..7939e739 100644 --- a/mods/nua.js +++ b/core/nua.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const User = require('../core/user.js'); -const theme = require('../core/theme.js'); -const login = require('../core/system_menu_method.js').login; -const Config = require('../core/config.js').config; -const messageArea = require('../core/message_area.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const User = require('./user.js'); +const theme = require('./theme.js'); +const login = require('./system_menu_method.js').login; +const Config = require('./config.js').config; +const messageArea = require('./message_area.js'); exports.moduleInfo = { name : 'NUA', diff --git a/mods/onelinerz.js b/core/onelinerz.js similarity index 94% rename from mods/onelinerz.js rename to core/onelinerz.js index 335c25ce..9e89addf 100644 --- a/mods/onelinerz.js +++ b/core/onelinerz.js @@ -2,12 +2,17 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const getModDatabasePath = require('../core/database.js').getModDatabasePath; -const ViewController = require('../core/view_controller.js').ViewController; -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; + +const { + getModDatabasePath, + getTransactionDatabase +} = require('./database.js'); + +const ViewController = require('./view_controller.js').ViewController; +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const stringFormat = require('./string_format.js'); // deps const sqlite3 = require('sqlite3'); @@ -263,12 +268,12 @@ exports.getModule = class OnelinerzModule extends MenuModule { async.series( [ function openDatabase(callback) { - self.db = new sqlite3.Database( + self.db = getTransactionDatabase(new sqlite3.Database( getModDatabasePath(exports.moduleInfo), err => { return callback(err); } - ); + )); }, function createTables(callback) { self.db.run( diff --git a/core/oputil/oputil_common.js b/core/oputil/oputil_common.js index 21e1a6d0..e175a166 100644 --- a/core/oputil/oputil_common.js +++ b/core/oputil/oputil_common.js @@ -45,11 +45,12 @@ function printUsageAndSetExitCode(errMsg, exitCode) { } function getDefaultConfigPath() { - return resolvePath('~/.config/enigma-bbs/config.hjson'); + return './config/'; } function getConfigPath() { - return argv.config ? argv.config : config.getDefaultPath(); + const baseConfigPath = argv.config ? argv.config : config.getDefaultPath(); + return baseConfigPath + 'config.hjson'; } function initConfig(cb) { diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index 5cbe10e3..d6a18026 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -8,7 +8,7 @@ const argv = require('./oputil_common.js').argv; const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; const getHelpFor = require('./oputil_help.js').getHelpFor; const getAreaAndStorage = require('./oputil_common.js').getAreaAndStorage; -const Errors = require('../../core/enig_error.js').Errors; +const Errors = require('../enig_error.js').Errors; const async = require('async'); const fs = require('graceful-fs'); @@ -34,7 +34,7 @@ exports.handleFileBaseCommand = handleFileBaseCommand; let fileArea; // required during init -function finalizeEntryAndPersist(fileEntry, descHandler, cb) { +function finalizeEntryAndPersist(isUpdate, fileEntry, descHandler, cb) { async.series( [ function getDescFromHandlerIfNeeded(callback) { @@ -53,18 +53,24 @@ function finalizeEntryAndPersist(fileEntry, descHandler, cb) { return callback(null); }, function getDescFromUserIfNeeded(callback) { - if(false === argv.prompt || ( fileEntry.desc && fileEntry.desc.length > 0 ) ) { + if(fileEntry.desc && fileEntry.desc.length > 0 ) { return callback(null); } - const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; + const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; + const descFromFile = getDescFromFileName(fileEntry.fileName); + + if(false === argv.prompt) { + fileEntry.desc = descFromFile; + return callback(null); + } const questions = [ { name : 'desc', message : `Description for ${fileEntry.fileName}:`, type : 'input', - default : getDescFromFileName(fileEntry.fileName), + default : descFromFile, } ]; @@ -74,7 +80,7 @@ function finalizeEntryAndPersist(fileEntry, descHandler, cb) { }); }, function persist(callback) { - fileEntry.persist( err => { + fileEntry.persist(isUpdate, err => { return callback(err); }); } @@ -104,6 +110,12 @@ function scanFileAreaForChanges(areaInfo, options, cb) { return !asi.storageTag || sl.storageTag === asi.storageTag; }); }); + + function updateTags(fe) { + if(Array.isArray(options.tags)) { + fe.hashTags = new Set(options.tags); + } + } async.eachSeries(storageLocations, (storageLoc, nextLocation) => { async.waterfall( @@ -153,27 +165,68 @@ function scanFileAreaForChanges(areaInfo, options, cb) { }, (err, fileEntry, dupeEntries) => { if(err) { - // :TODO: Log me!!! console.info(`Error: ${err.message}`); return nextFile(null); // try next anyway - } + } - if(dupeEntries.length > 0) { - // :TODO: Handle duplidates -- what to do here??? + // + // We'll update the entry if the following conditions are met: + // * We have a single duplicate, and: + // * --update was passed or the existing entry's desc, + // longDesc, or est_release_year meta are blank/empty + // + if(argv.update && 1 === dupeEntries.length) { + const FileEntry = require('../../core/file_entry.js'); + const existingEntry = new FileEntry(); + + return existingEntry.load(dupeEntries[0].fileId, err => { + if(err) { + console.info('Dupe (cannot update)'); + return nextFile(null); + } + + // + // Update only if tags or desc changed + // + const optTags = Array.isArray(options.tags) ? new Set(options.tags) : existingEntry.hashTags; + const tagsEq = _.isEqual(optTags, existingEntry.hashTags); + + if( tagsEq && + fileEntry.desc === existingEntry.desc && + fileEntry.descLong == existingEntry.descLong && + fileEntry.meta.est_release_year == existingEntry.meta.est_release_year) + { + console.info('Dupe'); + return nextFile(null); + } + + console.info('Dupe (updating)'); + + // don't allow overwrite of values if new version is blank + existingEntry.desc = fileEntry.desc || existingEntry.desc; + existingEntry.descLong = fileEntry.descLong || existingEntry.descLong; + + if(fileEntry.meta.est_release_year) { + existingEntry.meta.est_release_year = fileEntry.meta.est_release_year; + } + + updateTags(existingEntry); + + finalizeEntryAndPersist(true, existingEntry, descHandler, err => { + return nextFile(err); + }); + }); + } else if(dupeEntries.length > 0) { console.info('Dupe'); return nextFile(null); - } else { - console.info('Done!'); - if(Array.isArray(options.tags)) { - options.tags.forEach(tag => { - fileEntry.hashTags.add(tag); - }); - } - - finalizeEntryAndPersist(fileEntry, descHandler, err => { - return nextFile(err); - }); } + + console.info('Done!'); + updateTags(fileEntry); + + finalizeEntryAndPersist(false, fileEntry, descHandler, err => { + return nextFile(err); + }); } ); }); @@ -395,6 +448,62 @@ function scanFileAreas() { ); } +function expandFileTargets(targets, cb) { + let entries = []; + + // Each entry may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] + const FileEntry = require('../../core/file_entry.js'); + + async.eachSeries(targets, (areaAndStorage, next) => { + const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); + + if(areaInfo) { + // AREA_TAG[@STORAGE_TAG] - all files in area@tag + const findFilter = { + areaTag : areaAndStorage.areaTag, + }; + + if(areaAndStorage.storageTag) { + findFilter.storageTag = areaAndStorage.storageTag; + } + + FileEntry.findFiles(findFilter, (err, fileIds) => { + if(err) { + return next(err); + } + + async.each(fileIds, (fileId, nextFileId) => { + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + if(!err) { + entries.push(fileEntry); + } + return nextFileId(err); + }); + }, + err => { + return next(err); + }); + }); + + } else { + // FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA + // :TODO: FULL_PATH -> entries + getFileEntries(areaAndStorage.pattern, (err, fileEntries) => { + if(err) { + return next(err); + } + + entries = entries.concat(fileEntries); + return next(null); + }); + } + }, + err => { + return cb(err, entries); + }); +} + function moveFiles() { // // oputil fb move SRC [SRC2 ...] DST @@ -407,8 +516,9 @@ function moveFiles() { } const moveArgs = argv._.slice(2); - let src = getAreaAndStorage(moveArgs.slice(0, -1)); - let dst = getAreaAndStorage(moveArgs.slice(-1))[0]; + const src = getAreaAndStorage(moveArgs.slice(0, -1)); + const dst = getAreaAndStorage(moveArgs.slice(-1))[0]; + let FileEntry; async.waterfall( @@ -422,8 +532,6 @@ function moveFiles() { }); }, function validateAndExpandSourceAndDest(callback) { - let srcEntries = []; - const areaInfo = fileArea.getFileAreaByTag(dst.areaTag); if(areaInfo) { dst.areaInfo = areaInfo; @@ -431,57 +539,9 @@ function moveFiles() { return callback(Errors.DoesNotExist('Invalid or unknown destination area')); } - // Each SRC may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] FileEntry = require('../../core/file_entry.js'); - async.eachSeries(src, (areaAndStorage, next) => { - const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); - - if(areaInfo) { - // AREA_TAG[@STORAGE_TAG] - all files in area@tag - src.areaInfo = areaInfo; - - const findFilter = { - areaTag : areaAndStorage.areaTag, - }; - - if(areaAndStorage.storageTag) { - findFilter.storageTag = areaAndStorage.storageTag; - } - - FileEntry.findFiles(findFilter, (err, fileIds) => { - if(err) { - return next(err); - } - - async.each(fileIds, (fileId, nextFileId) => { - const fileEntry = new FileEntry(); - fileEntry.load(fileId, err => { - if(!err) { - srcEntries.push(fileEntry); - } - return nextFileId(err); - }); - }, - err => { - return next(err); - }); - }); - - } else { - // FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA - // :TODO: FULL_PATH -> entries - getFileEntries(areaAndStorage.pattern, (err, entries) => { - if(err) { - return next(err); - } - - srcEntries = srcEntries.concat(entries); - return next(null); - }); - } - }, - err => { + expandFileTargets(src, (err, srcEntries) => { return callback(err, srcEntries); }); }, @@ -512,13 +572,80 @@ function moveFiles() { return callback(err); }); } - ] + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } ); } function removeFiles() { // - // REMOVE SHA|FILE_ID [SHA|FILE_ID ...] + // oputil fb rm|remove|del|delete SRC [SRC2 ...] + // + // SRC: FILENAME_WC|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] + // + // AREA_TAG[@STORAGE_TAG] remove all entries matching + // supplied area/storage tags + // + // --phys-file removes backing physical file(s) + // + if(argv._.length < 3) { + return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR); + } + + const removePhysFile = argv['phys-file']; + + const src = getAreaAndStorage(argv._.slice(2)); + + async.waterfall( + [ + function init(callback) { + return initConfigAndDatabases( err => { + if(!err) { + fileArea = require('../../core/file_base_area.js'); + } + return callback(err); + }); + }, + function expandSources(callback) { + expandFileTargets(src, (err, srcEntries) => { + return callback(err, srcEntries); + }); + }, + function removeEntries(srcEntries, callback) { + const FileEntry = require('../../core/file_entry.js'); + + const extraOutput = removePhysFile ? ' (including physical file)' : ''; + + async.eachSeries(srcEntries, (entry, nextEntry) => { + + process.stdout.write(`Removing ${entry.filePath}${extraOutput}... `); + + FileEntry.removeEntry(entry, { removePhysFile }, err => { + if(err) { + console.info(`Failed: ${err.message}`); + } else { + console.info('Done'); + } + + return nextEntry(err); + }); + }, err => { + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } + ); } function handleFileBaseCommand() { @@ -539,7 +666,13 @@ function handleFileBaseCommand() { return ({ info : displayFileAreaInfo, scan : scanFileAreas, + + mv : moveFiles, move : moveFiles, + + rm : removeFiles, remove : removeFiles, + del : removeFiles, + delete : removeFiles, }[action] || errUsage)(); } \ No newline at end of file diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index 5c20e3a5..b4c48267 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -45,7 +45,7 @@ import-areas args: --type TYPE specifies area import type. valid options are "bbs" and "na" `, FileBase : -`usage: oputil.js fb [] [] +`usage: oputil.js fb [] actions: scan AREA_TAG[@STORAGE_TAG] scan specified area @@ -53,24 +53,27 @@ actions: info AREA_TAG|SHA|FILE_ID display information about areas and/or files SHA may be a full or partial SHA-256 - move SRC [SRC...]] DST move entry(s) from SRC to DST - * SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG] - * DST: AREA_TAG[@STORAGE_TAG] + mv SRC [SRC...] DST move entry(s) from SRC to DST + SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG] + DST: AREA_TAG[@STORAGE_TAG] - remove SHA|FILE_ID removes a entry from the system + rm SRC [SRC...] remove entry(s) from the system matching SRC + SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG] scan args: --tags TAG1,TAG2,... specify tag(s) to assign to discovered entries + --desc-file [PATH] prefer file descriptions from DESCRIPT.ION file over other sources such as FILE_ID.DIZ. if PATH is specified, use DESCRIPT.ION at PATH instead of looking in specific storage locations + --update attempt to update information for existing entries info args: --show-desc display short description, if any remove args: - --delete also remove underlying physical file + --phys-file also remove underlying physical file `, FileOpsInfo : ` @@ -81,6 +84,14 @@ general information: FILENAME_WC filename with * and ? wildcard support. may match 0:n entries SHA full or partial SHA-256 FILE_ID a file identifier. see file.sqlite3 +`, + MessageBase : + `usage: oputil.js mb [] + + actions: + areafix CMD1 CMD2 ... ADDR sends an AreaFix NetMail to ADDR with the supplied command(s) + one or more commands may be supplied. commands that are multi + part such as "%COMPRESS ZIP" should be quoted. ` }; diff --git a/core/oputil/oputil_main.js b/core/oputil/oputil_main.js index 83af6b5e..aa373ef2 100644 --- a/core/oputil/oputil_main.js +++ b/core/oputil/oputil_main.js @@ -7,6 +7,7 @@ const argv = require('./oputil_common.js').argv; const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; const handleUserCommand = require('./oputil_user.js').handleUserCommand; const handleFileBaseCommand = require('./oputil_file_base.js').handleFileBaseCommand; +const handleMessageBaseCommand = require('./oputil_message_base.js').handleMessageBaseCommand; const handleConfigCommand = require('./oputil_config.js').handleConfigCommand; const getHelpFor = require('./oputil_help.js').getHelpFor; @@ -26,19 +27,10 @@ module.exports = function() { } switch(argv._[0]) { - case 'user' : - handleUserCommand(); - break; - - case 'config' : - handleConfigCommand(); - break; - - case 'fb' : - handleFileBaseCommand(); - break; - - default: - return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.BAD_COMMAND); + case 'user' : return handleUserCommand(); + case 'config' : return handleConfigCommand(); + case 'fb' : return handleFileBaseCommand(); + case 'mb' : return handleMessageBaseCommand(); + default : return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.BAD_COMMAND); } }; diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js new file mode 100644 index 00000000..6e89cf73 --- /dev/null +++ b/core/oputil/oputil_message_base.js @@ -0,0 +1,142 @@ +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; +const ExitCodes = require('./oputil_common.js').ExitCodes; +const argv = require('./oputil_common.js').argv; +const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; +const getHelpFor = require('./oputil_help.js').getHelpFor; +const Address = require('../ftn_address.js'); +const Errors = require('../enig_error.js').Errors; + +// deps +const async = require('async'); + +exports.handleMessageBaseCommand = handleMessageBaseCommand; + +function areaFix() { + // + // oputil mb areafix CMD1 CMD2 ... ADDR [--password PASS] + // + if(argv._.length < 3) { + return printUsageAndSetExitCode( + getHelpFor('MessageBase'), + ExitCodes.ERROR + ); + } + + async.waterfall( + [ + function init(callback) { + return initConfigAndDatabases(callback); + }, + function validateAddress(callback) { + const addrArg = argv._.slice(-1)[0]; + const ftnAddr = Address.fromString(addrArg); + + if(!ftnAddr) { + return callback(Errors.Invalid(`"${addrArg}" is not a valid FTN address`)); + } + + // + // We need to validate the address targets a system we know unless + // the --force option is used + // + // :TODO: + return callback(null, ftnAddr); + }, + function fetchFromUser(ftnAddr, callback) { + // + // --from USER || +op from system + // + // If possible, we want the user ID of the supplied user as well + // + const User = require('../user.js'); + + if(argv.from) { + User.getUserIdAndNameByLookup(argv.from, (err, userId, fromName) => { + if(err) { + return callback(null, ftnAddr, argv.from, 0); + } + + // fromName is the same as argv.from, but case may be differnet (yet correct) + return callback(null, ftnAddr, fromName, userId); + }); + } else { + User.getUserName(User.RootUserID, (err, fromName) => { + return callback(null, ftnAddr, fromName || 'SysOp', err ? 0 : User.RootUserID); + }); + } + }, + function createMessage(ftnAddr, fromName, fromUserId, callback) { + // + // Build message as commands separated by line feed + // + // We need to remove quotes from arguments. These are required + // in the case of e.g. removing an area: "-SOME_AREA" would end + // up confusing minimist, therefor they must be quoted: "'-SOME_AREA'" + // + const messageBody = argv._.slice(2, -1).map(arg => { + return arg.replace(/["']/g, ''); + }).join('\r\n') + '\n'; + + const Message = require('../message.js'); + + const message = new Message({ + toUserName : argv.to || 'AreaFix', + fromUserName : fromName, + subject : argv.password || '', + message : messageBody, + areaTag : Message.WellKnownAreaTags.Private, // mark private + meta : { + System : { + [ Message.SystemMetaNames.RemoteToUser ] : ftnAddr.toString(), // where to send it + [ Message.SystemMetaNames.ExternalFlavor ] : Message.AddressFlavor.FTN, // on FTN-style network + } + } + }); + + if(0 !== fromUserId) { + message.setLocalFromUserId(fromUserId); + } + + return callback(null, message); + }, + function persistMessage(message, callback) { + message.persist(err => { + if(!err) { + console.log('AreaFix message persisted and will be exported at next scheduled scan'); + } + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(`${err.message}${err.reason ? ': ' + err.reason : ''}`); + } + } + ); +} + +function handleMessageBaseCommand() { + + function errUsage() { + return printUsageAndSetExitCode( + getHelpFor('MessageBase'), + ExitCodes.ERROR + ); + } + + if(true === argv.help) { + return errUsage(); + } + + const action = argv._[1]; + + return({ + areafix : areaFix, + }[action] || errUsage)(); +} \ No newline at end of file diff --git a/core/predefined_mci.js b/core/predefined_mci.js index afebd24c..7fe921b3 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -45,11 +45,11 @@ function getUserRatio(client, propA, propB) { } function userStatAsString(client, statName, defaultValue) { - return (StatLog.getUserStat(client.user, statName) || defaultValue).toString(); + return (StatLog.getUserStat(client.user, statName) || defaultValue).toLocaleString(); } function sysStatAsString(statName, defaultValue) { - return (StatLog.getSystemStat(statName) || defaultValue).toString(); + return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString(); } const PREDEFINED_MCI_GENERATORS = { @@ -177,7 +177,7 @@ const PREDEFINED_MCI_GENERATORS = { AN : function activeNodes() { return clientConnections.getActiveConnections().length.toString(); }, - TC : function totalCalls() { return StatLog.getSystemStat('login_count').toString(); }, + TC : function totalCalls() { return StatLog.getSystemStat('login_count').toLocaleString(); }, RR : function randomRumor() { // start the process of picking another random one @@ -201,12 +201,20 @@ const PREDEFINED_MCI_GENERATORS = { const byteSize = StatLog.getSystemStatNum('ul_total_bytes'); return formatByteSize(byteSize, true); // true=withAbbr }, + TF : function totalFilesOnSystem() { + const areaStats = StatLog.getSystemStat('file_base_area_stats'); + return _.get(areaStats, 'totalFiles', 0).toLocaleString(); + }, + TB : function totalBytesOnSystem() { + const areaStats = StatLog.getSystemStat('file_base_area_stats'); + const totalBytes = parseInt(_.get(areaStats, 'totalBytes', 0)); + return formatByteSize(totalBytes, true); // true=withAbbr + }, // :TODO: PT - Messages posted *today* (Obv/2) // -> Include FTN/etc. // :TODO: NT - New users today (Obv/2) // :TODO: CT - Calls *today* (Obv/2) - // :TODO: TF - Total files on the system (Obv/2) // :TODO: FT - Files uploaded/added *today* (Obv/2) // :TODO: DD - Files downloaded *today* (iNiQUiTY) // :TODO: TP - total message/posts on the system (Obv/2) diff --git a/mods/rumorz.js b/core/rumorz.js similarity index 93% rename from mods/rumorz.js rename to core/rumorz.js index 20aace03..b83853f0 100644 --- a/mods/rumorz.js +++ b/core/rumorz.js @@ -2,13 +2,13 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const theme = require('../core/theme.js'); -const resetScreen = require('../core/ansi_term.js').resetScreen; -const StatLog = require('../core/stat_log.js'); -const renderStringLength = require('../core/string_util.js').renderStringLength; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const theme = require('./theme.js'); +const resetScreen = require('./ansi_term.js').resetScreen; +const StatLog = require('./stat_log.js'); +const renderStringLength = require('./string_util.js').renderStringLength; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 57ff0525..8da71d02 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -20,6 +20,7 @@ const getDescFromFileName = require('../file_base_area.js').getDescFromFileNa const copyFileWithCollisionHandling = require('../file_util.js').copyFileWithCollisionHandling; const getAreaStorageDirectoryByTag = require('../file_base_area.js').getAreaStorageDirectoryByTag; const isValidStorageTag = require('../file_base_area.js').isValidStorageTag; +const User = require('../user.js'); // deps const moment = require('moment'); @@ -43,14 +44,10 @@ exports.moduleInfo = { /* :TODO: - * Support (approx) max bundle size - * Support NetMail - * NetMail needs explicit isNetMail() check - * NetMail filename / location / etc. is still unknown - need to post on groups & get real answers + * Support (approx) max bundle size * Validate packet passwords!!!! => secure vs insecure landing areas - -*/ +*/ exports.getModule = FTNMessageScanTossModule; @@ -58,32 +55,31 @@ const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:|@immediate)([^\0]+)?$/; function FTNMessageScanTossModule() { MessageScanTossModule.call(this); - - let self = this; + + const self = this; this.archUtil = ArchiveUtil.getInstance(); if(_.has(Config, 'scannerTossers.ftn_bso')) { - this.moduleConfig = Config.scannerTossers.ftn_bso; + this.moduleConfig = Config.scannerTossers.ftn_bso; } - + this.getDefaultNetworkName = function() { if(this.moduleConfig.defaultNetwork) { return this.moduleConfig.defaultNetwork.toLowerCase(); } - + const networkNames = Object.keys(Config.messageNetworks.ftn.networks); if(1 === networkNames.length) { return networkNames[0].toLowerCase(); } }; - - + this.getDefaultZone = function(networkName) { if(_.isNumber(Config.messageNetworks.ftn.networks[networkName].defaultZone)) { return Config.messageNetworks.ftn.networks[networkName].defaultZone; } - + // non-explicit: default to local address zone const networkLocalAddress = Config.messageNetworks.ftn.networks[networkName].localAddress; if(networkLocalAddress) { @@ -91,45 +87,45 @@ function FTNMessageScanTossModule() { return addr.zone; } }; - + /* this.isDefaultDomainZone = function(networkName, address) { - const defaultNetworkName = this.getDefaultNetworkName(); + const defaultNetworkName = this.getDefaultNetworkName(); return(networkName === defaultNetworkName && address.zone === this.moduleConfig.defaultZone); }; */ - + this.getNetworkNameByAddress = function(remoteAddress) { return _.findKey(Config.messageNetworks.ftn.networks, network => { - const localAddress = Address.fromString(network.localAddress); + const localAddress = Address.fromString(network.localAddress); return !_.isUndefined(localAddress) && localAddress.isEqual(remoteAddress); }); }; - + this.getNetworkNameByAddressPattern = function(remoteAddressPattern) { return _.findKey(Config.messageNetworks.ftn.networks, network => { - const localAddress = Address.fromString(network.localAddress); + const localAddress = Address.fromString(network.localAddress); return !_.isUndefined(localAddress) && localAddress.isPatternMatch(remoteAddressPattern); - }); + }); }; - + this.getLocalAreaTagByFtnAreaTag = function(ftnAreaTag) { ftnAreaTag = ftnAreaTag.toUpperCase(); // always compare upper return _.findKey(Config.messageNetworks.ftn.areas, areaConf => { return areaConf.tag.toUpperCase() === ftnAreaTag; }); }; - + this.getExportType = function(nodeConfig) { - return _.isString(nodeConfig.exportType) ? nodeConfig.exportType.toLowerCase() : 'crash'; + return _.isString(nodeConfig.exportType) ? nodeConfig.exportType.toLowerCase() : 'crash'; }; - + /* this.getSeenByAddresses = function(messageSeenBy) { if(!_.isArray(messageSeenBy)) { messageSeenBy = [ messageSeenBy ]; } - + let seenByAddrs = []; messageSeenBy.forEach(sb => { seenByAddrs = seenByAddrs.concat(ftnUtil.parseAbbreviatedNetNodeList(sb)); @@ -137,13 +133,13 @@ function FTNMessageScanTossModule() { return seenByAddrs; }; */ - + this.messageHasValidMSGID = function(msg) { - return _.isString(msg.meta.FtnKludge.MSGID) && msg.meta.FtnKludge.MSGID.length > 0; + return _.isString(msg.meta.FtnKludge.MSGID) && msg.meta.FtnKludge.MSGID.length > 0; }; - + /* - this.getOutgoingPacketDir = function(networkName, destAddress) { + this.getOutgoingEchoMailPacketDir = function(networkName, destAddress) { let dir = this.moduleConfig.paths.outbound; if(!this.isDefaultDomainZone(networkName, destAddress)) { const hexZone = `000${destAddress.zone.toString(16)}`.substr(-3); @@ -152,31 +148,31 @@ function FTNMessageScanTossModule() { return dir; }; */ - - this.getOutgoingPacketDir = function(networkName, destAddress) { + + this.getOutgoingEchoMailPacketDir = function(networkName, destAddress) { networkName = networkName.toLowerCase(); - + let dir = this.moduleConfig.paths.outbound; - - const defaultNetworkName = this.getDefaultNetworkName(); + + const defaultNetworkName = this.getDefaultNetworkName(); const defaultZone = this.getDefaultZone(networkName); - + let zoneExt; if(defaultZone !== destAddress.zone) { zoneExt = '.' + `000${destAddress.zone.toString(16)}`.substr(-3); } else { zoneExt = ''; } - + if(defaultNetworkName === networkName) { dir = paths.join(dir, `outbound${zoneExt}`); } else { dir = paths.join(dir, `${networkName}${zoneExt}`); } - + return dir; }; - + this.getOutgoingPacketFileName = function(basePath, messageId, isTemp, fileCase) { // // Generating an outgoing packet file name comes with a few issues: @@ -189,15 +185,15 @@ function FTNMessageScanTossModule() { // There are a lot of systems in use here for the name: // * HEX CRC16/32 of data // * HEX UNIX timestamp - // * Mystic at least at one point, used Hex8(day of month + seconds past midnight + hundredths of second) + // * Mystic at least at one point, used Hex8(day of month + seconds past midnight + hundredths of second) // See https://groups.google.com/forum/#!searchin/alt.bbs.mystic/netmail$20filename/alt.bbs.mystic/m1xLnY8i1pU/YnG2excdl6MJ // * SBBSEcho uses DDHHMMSS - see https://github.com/ftnapps/pkg-sbbs/blob/master/docs/fidonet.txt // * We already have a system for 8-character serial number gernation that is // used for e.g. in FTS-0009.001 MSGIDs... let's use that! - // + // const name = ftnUtil.getMessageSerialNumber(messageId); const ext = (true === isTemp) ? 'pk_' : 'pkt'; - + let fileName = `${name}.${ext}`; if('upper' === fileCase) { fileName = fileName.toUpperCase(); @@ -205,10 +201,10 @@ function FTNMessageScanTossModule() { return paths.join(basePath, fileName); }; - + this.getOutgoingFlowFileExtension = function(destAddress, flowType, exportType, fileCase) { let ext; - + switch(flowType) { case 'mail' : ext = `${exportType.toLowerCase()[0]}ut`; break; case 'ref' : ext = `${exportType.toLowerCase()[0]}lo`; break; @@ -220,20 +216,20 @@ function FTNMessageScanTossModule() { if('upper' === fileCase) { ext = ext.toUpperCase(); } - - return ext; + + return ext; }; this.getOutgoingFlowFileName = function(basePath, destAddress, flowType, exportType, fileCase) { - let basename; - + let basename; + const ext = self.getOutgoingFlowFileExtension( - destAddress, - flowType, - exportType, + destAddress, + flowType, + exportType, fileCase ); - + if(destAddress.point) { } else { @@ -242,32 +238,32 @@ function FTNMessageScanTossModule() { // node. This seems to match what Mystic does // basename = - `0000${destAddress.net.toString(16)}`.substr(-4) + - `0000${destAddress.node.toString(16)}`.substr(-4); + `0000${destAddress.net.toString(16)}`.substr(-4) + + `0000${destAddress.node.toString(16)}`.substr(-4); } if('upper' === fileCase) { basename = basename.toUpperCase(); } - + return paths.join(basePath, `${basename}.${ext}`); }; - + this.flowFileAppendRefs = function(filePath, fileRefs, directive, cb) { const appendLines = fileRefs.reduce( (content, ref) => { return content + `${directive}${ref}\n`; }, ''); - + fs.appendFile(filePath, appendLines, err => { cb(err); }); }; - + this.getOutgoingBundleFileName = function(basePath, sourceAddress, destAddress, cb) { // // Base filename is constructed as such: - // * If this |destAddress| is *not* a point address, we use NNNNnnnn where - // NNNN is 0 padded hex of dest net - source net and and nnnn is 0 padded + // * If this |destAddress| is *not* a point address, we use NNNNnnnn where + // NNNN is 0 padded hex of dest net - source net and and nnnn is 0 padded // hex of dest node - source node. // * If |destAddress| is a point, NNNN becomes 0000 and nnnn becomes 'p' + // 3 digit 0 padded hex point @@ -279,19 +275,19 @@ function FTNMessageScanTossModule() { const pointHex = `000${destAddress.point}`.substr(-3); basename = `0000p${pointHex}`; } else { - basename = - `0000${Math.abs(sourceAddress.net - destAddress.net).toString(16)}`.substr(-4) + - `0000${Math.abs(sourceAddress.node - destAddress.node).toString(16)}`.substr(-4); + basename = + `0000${Math.abs(sourceAddress.net - destAddress.net).toString(16)}`.substr(-4) + + `0000${Math.abs(sourceAddress.node - destAddress.node).toString(16)}`.substr(-4); } - + // // We need to now find the first entry that does not exist starting // with dd0 to ddz // - const EXT_SUFFIXES = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); + const EXT_SUFFIXES = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); let fileName = `${basename}.${moment().format('dd').toLowerCase()}`; async.detectSeries(EXT_SUFFIXES, (suffix, callback) => { - const checkFileName = fileName + suffix; + const checkFileName = fileName + suffix; fs.stat(paths.join(basePath, checkFileName), err => { callback(null, (err && 'ENOENT' === err.code) ? true : false); }); @@ -299,42 +295,68 @@ function FTNMessageScanTossModule() { if(finalSuffix) { return cb(null, paths.join(basePath, fileName + finalSuffix)); } - - return cb(new Error('Could not acquire a bundle filename!')); + + return cb(new Error('Could not acquire a bundle filename!')); }); }; - + this.prepareMessage = function(message, options) { // // Set various FTN kludges/etc. // + const localAddress = new Address(options.network.localAddress); // ensure we have an Address obj not a string version + message.meta.FtnProperty = message.meta.FtnProperty || {}; message.meta.FtnKludge = message.meta.FtnKludge || {}; - - message.meta.FtnProperty.ftn_orig_node = options.network.localAddress.node; - message.meta.FtnProperty.ftn_dest_node = options.destAddress.node; - message.meta.FtnProperty.ftn_orig_network = options.network.localAddress.net; - message.meta.FtnProperty.ftn_dest_network = options.destAddress.net; - message.meta.FtnProperty.ftn_cost = 0; - message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); - // :TODO: Need an explicit isNetMail() check - let ftnAttribute = - ftnMailPacket.Packet.Attribute.Local; // message from our system - - if(message.isPrivate()) { + message.meta.FtnProperty.ftn_orig_node = localAddress.node; + message.meta.FtnProperty.ftn_orig_network = localAddress.net; + message.meta.FtnProperty.ftn_cost = 0; + + // tear line and origin can both go in EchoMail & NetMail + message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); + message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(localAddress); + + let ftnAttribute = ftnMailPacket.Packet.Attribute.Local; // message from our system + + if(self.isNetMailMessage(message)) { + // These should be set for Private/NetMail already + assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_node))); + assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_network))); + ftnAttribute |= ftnMailPacket.Packet.Attribute.Private; - + // // NetMail messages need a FRL-1005.001 "Via" line // http://ftsc.org/docs/frl-1005.001 // + // :TODO: We need to do this when FORWARDING NetMail + /* if(_.isString(message.meta.FtnKludge.Via)) { message.meta.FtnKludge.Via = [ message.meta.FtnKludge.Via ]; } message.meta.FtnKludge.Via = message.meta.FtnKludge.Via || []; message.meta.FtnKludge.Via.push(ftnUtil.getVia(options.network.localAddress)); + */ + + // + // We need to set INTL, and possibly FMPT and/or TOPT + // See http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac + // + message.meta.FtnKludge.INTL = ftnUtil.getIntl(options.destAddress, localAddress); + + if(_.isNumber(localAddress.point) && localAddress.point > 0) { + message.meta.FtnKludge.FMPT = localAddress.point; + } + + if(_.isNumber(options.destAddress.point) && options.destAddress.point > 0) { + message.meta.FtnKludge.TOPT = options.destAddress.point; + } } else { + // We need to set some destination info for EchoMail + message.meta.FtnProperty.ftn_dest_node = options.destAddress.node; + message.meta.FtnProperty.ftn_dest_network = options.destAddress.net; + // // Set appropriate attribute flag for export type // @@ -343,52 +365,54 @@ function FTNMessageScanTossModule() { case 'hold' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Hold; break; // :TODO: Others? } - + // // EchoMail requires some additional properties & kludges - // - message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(options.network.localAddress); - message.meta.FtnProperty.ftn_area = Config.messageNetworks.ftn.areas[message.areaTag].tag; - + // + message.meta.FtnProperty.ftn_area = Config.messageNetworks.ftn.areas[message.areaTag].tag; + // // When exporting messages, we should create/update SEEN-BY // with remote address(s) we are exporting to. // - const seenByAdditions = - [ `${options.network.localAddress.net}/${options.network.localAddress.node}` ].concat(Config.messageNetworks.ftn.areas[message.areaTag].uplinks); - message.meta.FtnProperty.ftn_seen_by = + const seenByAdditions = + [ `${localAddress.net}/${localAddress.node}` ].concat(Config.messageNetworks.ftn.areas[message.areaTag].uplinks); + message.meta.FtnProperty.ftn_seen_by = ftnUtil.getUpdatedSeenByEntries(message.meta.FtnProperty.ftn_seen_by, seenByAdditions); // // And create/update PATH for ourself // - message.meta.FtnKludge.PATH = - ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, options.network.localAddress); + message.meta.FtnKludge.PATH = ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, localAddress); } - + message.meta.FtnProperty.ftn_attr_flags = ftnAttribute; - + // // Additional kludges // // Check for existence of MSGID as we may already have stored it from a previous // export that failed to finish - // + // if(!message.meta.FtnKludge.MSGID) { - message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier(message, options.network.localAddress); + message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier( + message, + localAddress, + message.isPrivate() // true = isNetMail + ); } - + message.meta.FtnKludge.TZUTC = ftnUtil.getUTCTimeZoneOffset(); - + // // According to FSC-0046: - // + // // "When a Conference Mail processor adds a TID to a message, it may not // add a PID. An existing TID should, however, be replaced. TIDs follow // the same format used for PIDs, as explained above." // - message.meta.FtnKludge.TID = ftnUtil.getProductIdentifier(); - + message.meta.FtnKludge.TID = ftnUtil.getProductIdentifier(); + // // Determine CHRS and actual internal encoding name. If the message has an // explicit encoding set, use it. Otherwise, try to preserve any CHRS/encoding already set. @@ -403,77 +427,76 @@ function FTNMessageScanTossModule() { encoding = encFromChars; } } - + // // Ensure we ended up with something useable. If not, back to utf8! // if(!iconv.encodingExists(encoding)) { Log.debug( { encoding : encoding }, 'Unknown encoding. Falling back to utf8'); encoding = 'utf8'; - } - + } + options.encoding = encoding; // save for later message.meta.FtnKludge.CHRS = ftnUtil.getCharacterSetIdentifierByEncoding(encoding); - // :TODO: FLAGS kludge? }; - + this.setReplyKludgeFromReplyToMsgId = function(message, cb) { // // Look up MSGID kludge for |message.replyToMsgId|, if any. // If found, we can create a REPLY kludge with the previously // discovered MSGID. // - + if(0 === message.replyToMsgId) { return cb(null); // nothing to do } - - Message.getMetaValuesByMessageId(message.replyToMsgId, 'FtnKludge', 'MSGID', (err, msgIdVal) => { + + Message.getMetaValuesByMessageId(message.replyToMsgId, 'FtnKludge', 'MSGID', (err, msgIdVal) => { if(!err) { - assert(_.isString(msgIdVal), 'Expected string but got ' + (typeof msgIdVal) + ' (' + msgIdVal + ')'); + assert(_.isString(msgIdVal), 'Expected string but got ' + (typeof msgIdVal) + ' (' + msgIdVal + ')'); // got a MSGID - create a REPLY message.meta.FtnKludge.REPLY = msgIdVal; } - + cb(null); // this method always passes - }); + }); }; - + // check paths, Addresses, etc. this.isAreaConfigValid = function(areaConfig) { if(!areaConfig || !_.isString(areaConfig.tag) || !_.isString(areaConfig.network)) { return false; } - + if(_.isString(areaConfig.uplinks)) { areaConfig.uplinks = areaConfig.uplinks.split(' '); } - + return (_.isArray(areaConfig.uplinks)); }; - - + + this.hasValidConfiguration = function() { if(!_.has(this, 'moduleConfig.nodes') || !_.has(Config, 'messageNetworks.ftn.areas')) { return false; } - + // :TODO: need to check more! - + return true; }; - + this.parseScheduleString = function(schedStr) { if(!schedStr) { return; // nothing to parse! } - + let schedule = {}; - + const m = SCHEDULE_REGEXP.exec(schedStr); if(m) { schedStr = schedStr.substr(0, m.index).trim(); - + if('@watch:' === m[1]) { schedule.watchFile = m[2]; } else if('@immediate' === m[1]) { @@ -485,46 +508,111 @@ function FTNMessageScanTossModule() { const sched = later.parse.text(schedStr); if(-1 === sched.error) { schedule.sched = sched; - } + } } - + // return undefined if we couldn't parse out anything useful if(!_.isEmpty(schedule)) { return schedule; - } + } }; - + this.getAreaLastScanId = function(areaTag, cb) { - const sql = + const sql = `SELECT area_tag, message_id FROM message_area_last_scan WHERE scan_toss = "ftn_bso" AND area_tag = ? LIMIT 1;`; - + msgDb.get(sql, [ areaTag ], (err, row) => { - cb(err, row ? row.message_id : 0); + return cb(err, row ? row.message_id : 0); }); }; - + this.setAreaLastScanId = function(areaTag, lastScanId, cb) { const sql = `REPLACE INTO message_area_last_scan (scan_toss, area_tag, message_id) VALUES ("ftn_bso", ?, ?);`; - + msgDb.run(sql, [ areaTag, lastScanId ], err => { - cb(err); + return cb(err); }); }; - + + this.getNodeConfigByAddress = function(addr) { + addr = _.isString(addr) ? Address.fromString(addr) : addr; + + // :TODO: sort wildcard nodes{} entries by most->least explicit according to FTN hierarchy + return _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { + return addr.isPatternMatch(nodeAddrWildcard); + }); + }; + + // :TODO: deprecate this in favor of getNodeConfigByAddress() this.getNodeConfigKeyByAddress = function(uplink) { - // :TODO: sort by least # of '*' & take top? const nodeKey = _.filter(Object.keys(this.moduleConfig.nodes), addr => { return Address.fromString(addr).isPatternMatch(uplink); })[0]; return nodeKey; }; - + + this.exportNetMailMessagePacket = function(message, exportOpts, cb) { + // + // For NetMail, we always create a *single* packet per message. + // + async.series( + [ + function generalPrep(callback) { + self.prepareMessage(message, exportOpts); + + return self.setReplyKludgeFromReplyToMsgId(message, callback); + }, + function createPacket(callback) { + const packet = new ftnMailPacket.Packet(); + + const packetHeader = new ftnMailPacket.PacketHeader( + exportOpts.network.localAddress, + exportOpts.destAddress, + exportOpts.nodeConfig.packetType + ); + + packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; + + // use current message ID for filename seed + exportOpts.pktFileName = self.getOutgoingPacketFileName( + self.exportTempDir, + message.messageId, + false, // createTempPacket=false + exportOpts.fileCase + ); + + const ws = fs.createWriteStream(exportOpts.pktFileName); + + packet.writeHeader(ws, packetHeader); + + packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => { + if(err) { + return callback(err); + } + + ws.write(msgBuf); + + packet.writeTerminator(ws); + + ws.end(); + ws.once('finish', () => { + return callback(null); + }); + }); + } + ], + err => { + return cb(err); + } + ); + }; + this.exportMessagesByUuid = function(messageUuids, exportOpts, cb) { // // This method has a lot of madness going on: @@ -538,7 +626,7 @@ function FTNMessageScanTossModule() { let ws; let remainMessageBuf; let remainMessageId; - const createTempPacket = !_.isString(exportOpts.nodeConfig.archiveType) || 0 === exportOpts.nodeConfig.archiveType.length; + const createTempPacket = !_.isString(exportOpts.nodeConfig.archiveType) || 0 === exportOpts.nodeConfig.archiveType.length; function finalizePacket(cb) { packet.writeTerminator(ws); @@ -547,10 +635,10 @@ function FTNMessageScanTossModule() { return cb(null); }); } - + async.each(messageUuids, (msgUuid, nextUuid) => { let message = new Message(); - + async.series( [ function finalizePrevious(callback) { @@ -565,47 +653,47 @@ function FTNMessageScanTossModule() { if(err) { return callback(err); } - + // General preperation self.prepareMessage(message, exportOpts); - + self.setReplyKludgeFromReplyToMsgId(message, err => { callback(err); }); }); }, - function createNewPacket(callback) { - if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { + function createNewPacket(callback) { + if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { packet = new ftnMailPacket.Packet(); - + const packetHeader = new ftnMailPacket.PacketHeader( exportOpts.network.localAddress, exportOpts.destAddress, exportOpts.nodeConfig.packetType); packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; - + // use current message ID for filename seed const pktFileName = self.getOutgoingPacketFileName( - self.exportTempDir, - message.messageId, + self.exportTempDir, + message.messageId, createTempPacket, exportOpts.fileCase ); exportedFiles.push(pktFileName); - + ws = fs.createWriteStream(pktFileName); - + currPacketSize = packet.writeHeader(ws, packetHeader); - + if(remainMessageBuf) { currPacketSize += packet.writeMessageEntry(ws, remainMessageBuf); remainMessageBuf = null; - } + } } - - callback(null); + + callback(null); }, function appendMessage(callback) { packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => { @@ -614,16 +702,16 @@ function FTNMessageScanTossModule() { } currPacketSize += msgBuf.length; - + if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { - remainMessageBuf = msgBuf; // save for next packet - remainMessageId = message.messageId; + remainMessageBuf = msgBuf; // save for next packet + remainMessageId = message.messageId; } else { ws.write(msgBuf); } - + return callback(null); - }); + }); }, function storeStateFlags0Meta(callback) { message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), err => { @@ -641,9 +729,9 @@ function FTNMessageScanTossModule() { }); } else { callback(null); - } + } } - ], + ], err => { nextUuid(err); } @@ -665,26 +753,26 @@ function FTNMessageScanTossModule() { if(remainMessageBuf) { // :TODO: DRY this with the code above -- they are basically identical packet = new ftnMailPacket.Packet(); - + const packetHeader = new ftnMailPacket.PacketHeader( exportOpts.network.localAddress, exportOpts.destAddress, exportOpts.nodeConfig.packetType); packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; - + // use current message ID for filename seed const pktFileName = self.getOutgoingPacketFileName( - self.exportTempDir, - remainMessageId, + self.exportTempDir, + remainMessageId, createTempPacket, exportOpts.filleCase ); exportedFiles.push(pktFileName); - + ws = fs.createWriteStream(pktFileName); - + packet.writeHeader(ws, packetHeader); ws.write(remainMessageBuf); return finalizePacket(callback); @@ -696,18 +784,176 @@ function FTNMessageScanTossModule() { err => { cb(err, exportedFiles); } - ); - } + ); + } }); }; - - this.exportMessagesToUplinks = function(messageUuids, areaConfig, cb) { + + this.getNetMailRoute = function(dstAddr) { + // + // messageNetworks.ftn.netMail.routes{} full|wildcard -> full adddress lookup + // + const routes = _.get(Config, 'messageNetworks.ftn.netMail.routes'); + if(!routes) { + return; + } + + return _.find(routes, (route, addrWildcard) => { + return dstAddr.isPatternMatch(addrWildcard); + }); + + /* + const route = _.find(routes, (route, addrWildcard) => { + return dstAddr.isPatternMatch(addrWildcard); + }); + + if(route && route.address) { + return Address.fromString(route.address); + } + */ + }; + + this.getAcceptableNetMailNetworkInfoFromAddress = function(dstAddr, cb) { + // + // Attempt to find an acceptable network configuration using the following + // lookup order (most to least explicit config): + // + // 1) Routes: messageNetworks.ftn.netMail.routes{} -> scannerTossers.ftn_bso.nodes{} -> config + // - Where we send may not be where dstAddress is (it's routed!) + // 2) Direct to nodes: scannerTossers.ftn_bso.nodes{} -> config + // - Where we send is direct to dstAddr + // + // In both cases, attempt to look up Zone:Net/* to discover local "from" network/address + // falling back to Config.scannerTossers.ftn_bso.defaultNetwork + // + const route = this.getNetMailRoute(dstAddr); + + let routeAddress; + let networkName; + if(route) { + routeAddress = Address.fromString(route.address); + networkName = route.network; + } else { + routeAddress = dstAddr; + } + + networkName = networkName || + this.getNetworkNameByAddressPattern(`${routeAddress.zone}:${routeAddress.net}/*`) || + Config.scannerTossers.ftn_bso.defaultNetwork + ; + + const config = _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { + return routeAddress.isPatternMatch(nodeAddrWildcard); + }) || { + packetType : '2+', + encoding : Config.scannerTossers.ftn_bso.packetMsgEncoding, + }; + + return cb( + config ? null : Errors.DoesNotExist(`No configuration found for ${dstAddr.toString()}`), + config, routeAddress, networkName + ); + }; + + this.exportNetMailMessagesToUplinks = function(messagesOrMessageUuids, cb) { + // for each message/UUID, find where to send the thing + async.each(messagesOrMessageUuids, (msgOrUuid, nextMessageOrUuid) => { + + const exportOpts = {}; + const message = new Message(); + + async.series( + [ + function loadMessage(callback) { + if(_.isString(msgOrUuid)) { + message.load( { uuid : msgOrUuid }, err => { + return callback(err, message); + }); + } else { + return callback(null, msgOrUuid); + } + }, + function discoverUplink(callback) { + const dstAddr = new Address(message.meta.System[Message.SystemMetaNames.RemoteToUser]); + + return self.getAcceptableNetMailNetworkInfoFromAddress(dstAddr, (err, config, routeAddress, networkName) => { + if(err) { + return callback(err); + } + + exportOpts.nodeConfig = config; + exportOpts.destAddress = routeAddress; + exportOpts.fileCase = config.fileCase || 'lower'; + exportOpts.network = Config.messageNetworks.ftn.networks[networkName]; + exportOpts.networkName = networkName; + exportOpts.outgoingDir = self.getOutgoingEchoMailPacketDir(exportOpts.networkName, exportOpts.destAddress); + exportOpts.exportType = self.getExportType(config); + + if(!exportOpts.network) { + return callback(Errors.DoesNotExist(`No configuration found for network ${networkName}`)); + } + + return callback(null); + }); + }, + function createOutgoingDir(callback) { + // ensure outgoing NetMail directory exists + return fse.mkdirs(exportOpts.outgoingDir, callback); + }, + function exportPacket(callback) { + return self.exportNetMailMessagePacket(message, exportOpts, callback); + }, + function moveToOutgoing(callback) { + const newExt = exportOpts.fileCase === 'lower' ? '.pkt' : '.PKT'; + exportOpts.exportedToPath = paths.join( + exportOpts.outgoingDir, + `${paths.basename(exportOpts.pktFileName, paths.extname(exportOpts.pktFileName))}${newExt}` + ); + + return fse.move(exportOpts.pktFileName, exportOpts.exportedToPath, callback); + }, + function prepareFloFile(callback) { + const flowFilePath = self.getOutgoingFlowFileName( + exportOpts.outgoingDir, + exportOpts.destAddress, + 'ref', + exportOpts.exportType, + exportOpts.fileCase + ); + + return self.flowFileAppendRefs(flowFilePath, [ exportOpts.exportedToPath ], '^', callback); + }, + function storeStateFlags0Meta(callback) { + return message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), callback); + }, + function storeMsgIdMeta(callback) { + // Store meta as if we had imported this message -- for later reference + if(message.meta.FtnKludge.MSGID) { + return message.persistMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, callback); + } + + return callback(null); + } + ], + err => { + if(err) { + Log.warn( { error :err.message }, 'Error exporting message' ); + } + return nextMessageOrUuid(null); + } + ); + }, err => { + return cb(err); + }); + }; + + this.exportEchoMailMessagesToUplinks = function(messageUuids, areaConfig, cb) { async.each(areaConfig.uplinks, (uplink, nextUplink) => { const nodeConfigKey = self.getNodeConfigKeyByAddress(uplink); if(!nodeConfigKey) { return nextUplink(); } - + const exportOpts = { nodeConfig : self.moduleConfig.nodes[nodeConfigKey], network : Config.messageNetworks.ftn.networks[areaConfig.network], @@ -715,14 +961,14 @@ function FTNMessageScanTossModule() { networkName : areaConfig.network, fileCase : self.moduleConfig.nodes[nodeConfigKey].fileCase || 'lower', }; - + if(_.isString(exportOpts.network.localAddress)) { - exportOpts.network.localAddress = Address.fromString(exportOpts.network.localAddress); + exportOpts.network.localAddress = Address.fromString(exportOpts.network.localAddress); } - - const outgoingDir = self.getOutgoingPacketDir(exportOpts.networkName, exportOpts.destAddress); + + const outgoingDir = self.getOutgoingEchoMailPacketDir(exportOpts.networkName, exportOpts.destAddress); const exportType = self.getExportType(exportOpts.nodeConfig); - + async.waterfall( [ function createOutgoingDir(callback) { @@ -746,17 +992,17 @@ function FTNMessageScanTossModule() { if(err) { return callback(err); } - + // adjust back to temp path const tempBundlePath = paths.join(self.exportTempDir, paths.basename(bundlePath)); - + self.archUtil.compressTo( - exportOpts.nodeConfig.archiveType, + exportOpts.nodeConfig.archiveType, tempBundlePath, exportedFileNames, err => { callback(err, [ tempBundlePath ] ); } - ); + ); }); } else { callback(null, exportedFileNames); @@ -769,18 +1015,18 @@ function FTNMessageScanTossModule() { // // For a given temporary .pk_ file, we need to move it to the outoing // directory with the appropriate BSO style filename. - // + // const newExt = self.getOutgoingFlowFileExtension( exportOpts.destAddress, - 'mail', + 'mail', exportType, exportOpts.fileCase ); - + const newPath = paths.join( - outgoingDir, + outgoingDir, `${paths.basename(oldPath, ext)}${newExt}`); - + fse.move(oldPath, newPath, nextFile); } else { const newPath = paths.join(outgoingDir, paths.basename(oldPath)); @@ -789,25 +1035,25 @@ function FTNMessageScanTossModule() { Log.warn( { oldPath : oldPath, newPath : newPath, error : err.toString() }, 'Failed moving temporary bundle file!'); - + return nextFile(); } - + // // For bundles, we need to append to the appropriate flow file // const flowFilePath = self.getOutgoingFlowFileName( - outgoingDir, + outgoingDir, exportOpts.destAddress, 'ref', exportType, exportOpts.fileCase ); - + // directive of '^' = delete file after transfer self.flowFileAppendRefs(flowFilePath, [ newPath ], '^', err => { if(err) { - Log.warn( { path : flowFilePath }, 'Failed appending flow reference record!'); + Log.warn( { path : flowFilePath }, 'Failed appending flow reference record!'); } nextFile(); }); @@ -823,10 +1069,10 @@ function FTNMessageScanTossModule() { } nextUplink(); } - ); + ); }, cb); // complete }; - + this.setReplyToMsgIdFtnReplyKludge = function(message, cb) { // // Given a FTN REPLY kludge, set |message.replyToMsgId|, if possible, @@ -838,12 +1084,12 @@ function FTNMessageScanTossModule() { // nothing to do return cb(); } - + Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.REPLY, (err, msgIds) => { if(msgIds && msgIds.length > 0) { // expect a single match, but dupe checking is not perfect - warn otherwise if(1 === msgIds.length) { - message.replyToMsgId = msgIds[0]; + message.replyToMsgId = msgIds[0]; } else { Log.warn( { msgIds : msgIds, replyKludge : message.meta.FtnKludge.REPLY }, 'Found 2:n MSGIDs matching REPLY kludge!'); } @@ -851,15 +1097,56 @@ function FTNMessageScanTossModule() { cb(); }); }; - - this.importEchoMailToArea = function(localAreaTag, header, message, cb) { + + this.getLocalUserNameFromAlias = function(lookup) { + lookup = lookup.toLowerCase(); + + const aliases = _.get(Config, 'messageNetworks.ftn.netMail.aliases'); + if(!aliases) { + return lookup; // keep orig + } + + const alias = _.find(aliases, (localName, alias) => { + return alias.toLowerCase() === lookup; + }); + + return alias || lookup; + }; + + this.getAddressesFromNetMailMessage = function(message) { + const intlKludge = _.get(message, 'meta.FtnKludge.INTL'); + + if(!intlKludge) { + return {}; + } + + let [ to, from ] = intlKludge.split(' '); + if(!to || !from) { + return {}; + } + + const fromPoint = _.get(message, 'meta.FtnKludge.FMPT'); + const toPoint = _.get(message, 'meta.FtnKludge.TOPT'); + + if(fromPoint) { + from += `.${fromPoint}`; + } + + if(toPoint) { + to += `.${toPoint}`; + } + + return { to : Address.fromString(to), from : Address.fromString(from) }; + }; + + this.importMailToArea = function(config, header, message, cb) { async.series( [ - function validateDestinationAddress(callback) { - const localNetworkPattern = `${message.meta.FtnProperty.ftn_dest_network}/${message.meta.FtnProperty.ftn_dest_node}`; + function validateDestinationAddress(callback) { + const localNetworkPattern = `${message.meta.FtnProperty.ftn_dest_network}/${message.meta.FtnProperty.ftn_dest_node}`; const localNetworkName = self.getNetworkNameByAddressPattern(localNetworkPattern); - - callback(_.isString(localNetworkName) ? null : new Error('Packet destination is not us')); + + return callback(_.isString(localNetworkName) ? null : new Error('Packet destination is not us')); }, function checkForDupeMSGID(callback) { // @@ -880,37 +1167,77 @@ function FTNMessageScanTossModule() { }); }, function basicSetup(callback) { - message.areaTag = localAreaTag; - + message.areaTag = config.localAreaTag; + + // indicate this was imported from FTN + message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.AddressFlavor.FTN; + // // If we *allow* dupes (disabled by default), then just generate // a random UUID. Otherwise, don't assign the UUID just yet. It will be // generated at persist() time and should be consistent across import/exports // - if(Config.messageNetworks.ftn.areas[localAreaTag].allowDupes) { + if(true === _.get(Config, [ 'messageNetworks', 'ftn', 'areas', config.localAreaTag, 'allowDupes' ], false)) { // just generate a UUID & therefor always allow for dupes message.uuid = uuidV4(); } - - callback(null); + + return callback(null); }, function setReplyToMessageId(callback) { self.setReplyToMsgIdFtnReplyKludge(message, () => { - callback(null); + return callback(null); + }); + }, + function setupPrivateMessage(callback) { + // + // If this is a private message (e.g. NetMail) we set the local user ID + // + if(Message.WellKnownAreaTags.Private !== config.localAreaTag) { + return callback(null); + } + + // + // Create a meta value for the *remote* from user. In the case here with FTN, + // their fully qualified FTN from address + // + const { from } = self.getAddressesFromNetMailMessage(message); + + if(!from) { + return callback(Errors.Invalid('Cannot import FTN NetMail without valid INTL line')); + } + + message.meta.System[Message.SystemMetaNames.RemoteFromUser] = from.toString(); + + const lookupName = self.getLocalUserNameFromAlias(message.toUserName); + + User.getUserIdAndNameByLookup(lookupName, (err, localToUserId, localUserName) => { + if(err) { + return callback(Errors.DoesNotExist(`Could not get local user ID for "${message.toUserName}": ${err.message}`)); + } + + // we do this after such that error cases can be preseved above + if(lookupName !== message.toUserName) { + message.toUserName = localUserName; + } + + // set the meta information - used elsehwere for retrieval + message.meta.System[Message.SystemMetaNames.LocalToUserID] = localToUserId; + return callback(null); }); }, function persistImport(callback) { // mark as imported message.meta.System.state_flags0 = Message.StateFlags0.Imported.toString(); - + // save to disc message.persist(err => { - callback(err); + return callback(err); }); } - ], + ], err => { - cb(err); + cb(err); } ); }; @@ -924,94 +1251,102 @@ function FTNMessageScanTossModule() { message.message += `${message.meta.FtnProperty.ftn_origin}\r\n`; } }; - + // - // Ref. implementations on import: + // Ref. implementations on import: // * https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/pkt.c // https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/handle.c // this.importMessagesFromPacketFile = function(packetPath, password, cb) { let packetHeader; - + const packetOpts = { keepTearAndOrigin : false }; // needed so we can calc message UUID without these; we'll add later - + let importStats = { areaSuccess : {}, // areaTag->count areaFail : {}, // areaTag->count otherFail : 0, }; - + new ftnMailPacket.Packet(packetOpts).read(packetPath, (entryType, entryData, next) => { if('header' === entryType) { packetHeader = entryData; - + const localNetworkName = self.getNetworkNameByAddress(packetHeader.destAddress); if(!_.isString(localNetworkName)) { const addrString = new Address(packetHeader.destAddress).toString(); return next(new Error(`No local configuration for packet addressed to ${addrString}`)); } else { - - // :TODO: password needs validated - need to determine if it will use the same node config (which can have wildcards) or something else?! + + // :TODO: password needs validated - need to determine if it will use the same node config (which can have wildcards) or something else?! return next(null); } - + } else if('message' === entryType) { const message = entryData; const areaTag = message.meta.FtnProperty.ftn_area; + let localAreaTag; if(areaTag) { - // - // EchoMail - // - const localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); - if(localAreaTag) { - message.uuid = Message.createMessageUUID( - localAreaTag, - message.modTimestamp, - message.subject, - message.message); + localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); - self.appendTearAndOrigin(message); - - self.importEchoMailToArea(localAreaTag, packetHeader, message, err => { - if(err) { - // bump area fail stats - importStats.areaFail[localAreaTag] = (importStats.areaFail[localAreaTag] || 0) + 1; - - if('SQLITE_CONSTRAINT' === err.code || 'DUPE_MSGID' === err.code) { - const msgId = _.has(message.meta, 'FtnKludge.MSGID') ? message.meta.FtnKludge.MSGID : 'N/A'; - Log.info( - { area : localAreaTag, subject : message.subject, uuid : message.uuid, MSGID : msgId }, - 'Not importing non-unique message'); - - return next(null); - } - } else { - // bump area success - importStats.areaSuccess[localAreaTag] = (importStats.areaSuccess[localAreaTag] || 0) + 1; - } - - return next(err); - }); - } else { + if(!localAreaTag) { // // No local area configured for this import // - // :TODO: Handle the "catch all" case, if configured + // :TODO: Handle the "catch all" area bucket case if configured Log.warn( { areaTag : areaTag }, 'No local area configured for this packet file!'); - + // bump generic failure importStats.otherFail += 1; - + return next(null); } } else { // - // NetMail + // No area tag: If marked private in attributes, this is a NetMail // - Log.warn('NetMail import not yet implemented!'); - return next(null); + if(message.meta.FtnProperty.ftn_attr_flags & ftnMailPacket.Packet.Attribute.Private) { + localAreaTag = Message.WellKnownAreaTags.Private; + } else { + Log.warn('Non-private message without area tag'); + importStats.otherFail += 1; + return next(null); + } } + + message.uuid = Message.createMessageUUID( + localAreaTag, + message.modTimestamp, + message.subject, + message.message); + + self.appendTearAndOrigin(message); + + const importConfig = { + localAreaTag : localAreaTag, + }; + + self.importMailToArea(importConfig, packetHeader, message, err => { + if(err) { + // bump area fail stats + importStats.areaFail[localAreaTag] = (importStats.areaFail[localAreaTag] || 0) + 1; + + if('SQLITE_CONSTRAINT' === err.code || 'DUPE_MSGID' === err.code) { + const msgId = _.has(message.meta, 'FtnKludge.MSGID') ? message.meta.FtnKludge.MSGID : 'N/A'; + Log.info( + { area : localAreaTag, subject : message.subject, uuid : message.uuid, MSGID : msgId }, + 'Not importing non-unique message'); + + return next(null); + } + } else { + // bump area success + importStats.areaSuccess[localAreaTag] = (importStats.areaSuccess[localAreaTag] || 0) + 1; + } + + return next(err); + }); } }, err => { // @@ -1027,7 +1362,7 @@ function FTNMessageScanTossModule() { } else { Log.info(finalStats, 'Import complete'); } - + cb(err); }); }; @@ -1048,7 +1383,7 @@ function FTNMessageScanTossModule() { if(!_.isString(self.moduleConfig.paths.retain)) { return cb(null); } - + archivePath = paths.join(self.moduleConfig.paths.retain, `good-pkt-${ts}--${fn}`); } else if('good' !== status) { archivePath = paths.join(self.moduleConfig.paths.reject, `${status}-${type}--${ts}-${fn}`); @@ -1066,7 +1401,7 @@ function FTNMessageScanTossModule() { return cb(null); // never fatal }); }; - + this.importPacketFilesFromDirectory = function(importDir, password, cb) { async.waterfall( [ @@ -1081,12 +1416,12 @@ function FTNMessageScanTossModule() { function importPacketFiles(packetFiles, callback) { let rejects = []; async.eachSeries(packetFiles, (packetFile, nextFile) => { - self.importMessagesFromPacketFile(paths.join(importDir, packetFile), '', err => { + self.importMessagesFromPacketFile(paths.join(importDir, packetFile), '', err => { if(err) { - Log.debug( - { path : paths.join(importDir, packetFile), error : err.toString() }, + Log.debug( + { path : paths.join(importDir, packetFile), error : err.toString() }, 'Failed to import packet file'); - + rejects.push(packetFile); } nextFile(); @@ -1097,13 +1432,13 @@ function FTNMessageScanTossModule() { }); }, function handleProcessedFiles(packetFiles, rejects, callback) { - async.each(packetFiles, (packetFile, nextFile) => { + async.each(packetFiles, (packetFile, nextFile) => { // possibly archive, then remove original const fullPath = paths.join(importDir, packetFile); self.maybeArchiveImportFile( - fullPath, - 'pkt', - rejects.includes(packetFile) ? 'reject' : 'good', + fullPath, + 'pkt', + rejects.includes(packetFile) ? 'reject' : 'good', () => { fs.unlink(fullPath, () => { return nextFile(null); @@ -1120,7 +1455,7 @@ function FTNMessageScanTossModule() { } ); }; - + this.importFromDirectory = function(inboundType, importDir, cb) { async.waterfall( [ @@ -1138,7 +1473,7 @@ function FTNMessageScanTossModule() { const fext = paths.extname(f); return bundleRegExp.test(fext); }); - + async.map(files, (file, transform) => { const fullPath = paths.join(importDir, file); self.archUtil.detectType(fullPath, (err, archName) => { @@ -1151,48 +1486,48 @@ function FTNMessageScanTossModule() { }, function importBundles(bundleFiles, callback) { let rejects = []; - + async.each(bundleFiles, (bundleFile, nextFile) => { if(_.isUndefined(bundleFile.archName)) { - Log.warn( + Log.warn( { fileName : bundleFile.path }, 'Unknown bundle archive type'); - + rejects.push(bundleFile.path); - + return nextFile(); // unknown archive type } Log.debug( { bundleFile : bundleFile }, 'Processing bundle' ); - + self.archUtil.extractTo( bundleFile.path, self.importTempDir, bundleFile.archName, err => { - if(err) { + if(err) { Log.warn( { path : bundleFile.path, error : err.message }, 'Failed to extract bundle'); - + rejects.push(bundleFile.path); } - - nextFile(); + + nextFile(); } ); }, err => { if(err) { return callback(err); } - + // // All extracted - import .pkt's // self.importPacketFilesFromDirectory(self.importTempDir, '', err => { // :TODO: handle |err| callback(null, bundleFiles, rejects); - }); + }); }); }, function handleProcessedBundleFiles(bundleFiles, rejects, callback) { @@ -1209,7 +1544,7 @@ function FTNMessageScanTossModule() { return nextFile(null); }); } - ); + ); }, err => { callback(err); }); @@ -1225,41 +1560,45 @@ function FTNMessageScanTossModule() { } ); }; - + this.createTempDirectories = function(cb) { temptmp.mkdir( { prefix : 'enigftnexport-' }, (err, tempDir) => { if(err) { return cb(err); } - + self.exportTempDir = tempDir; - + temptmp.mkdir( { prefix : 'enigftnimport-' }, (err, tempDir) => { self.importTempDir = tempDir; - + cb(err); }); }); }; - + // Starts an export block - returns true if we can proceed this.exportingStart = function() { if(!this.exportRunning) { this.exportRunning = true; return true; } - + return false; }; // ends an export block - this.exportingEnd = function() { - this.exportRunning = false; + this.exportingEnd = function(cb) { + this.exportRunning = false; + + if(cb) { + return cb(null); + } }; this.copyTicAttachment = function(src, dst, isUpdate, cb) { if(isUpdate) { - fse.copy(src, dst, err => { + fse.copy(src, dst, { overwrite : true }, err => { return cb(err, dst); }); } else { @@ -1274,8 +1613,6 @@ function FTNMessageScanTossModule() { }; this.processSingleTicFile = function(ticFileInfo, cb) { - const self = this; - Log.debug( { tic : ticFileInfo.path, file : ticFileInfo.getAsString('File') }, 'Processing TIC file'); async.waterfall( @@ -1289,6 +1626,7 @@ function FTNMessageScanTossModule() { return ticFileInfo.validate(config, (err, localInfo) => { if(err) { + Log.trace( { reason : err.message }, 'Validation failure'); return callback(err); } @@ -1320,7 +1658,7 @@ function FTNMessageScanTossModule() { // Lastly, we will only replace if the item is in the same/specified area // and that come from the same origin as a previous entry. // - const allowReplace = _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'allowReplace' ] ) || Config.scannerTossers.ftn_bso.tic.allowReplace; + const allowReplace = _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'allowReplace' ], Config.scannerTossers.ftn_bso.tic.allowReplace); const replaces = ticFileInfo.getAsString('Replaces'); if(!allowReplace || !replaces) { @@ -1349,10 +1687,17 @@ function FTNMessageScanTossModule() { localInfo.existingFileId = fileIds[0]; // fetch old filename - we may need to remove it if replacing with a new name - FileEntry.loadBasicEntry(localInfo.existingFileId, {}, (cb, info) => { - localInfo.oldFileName = info.fileName; - localInfo.oldStorageTag = info.storageTag; - return callback(null, localInfo); + FileEntry.loadBasicEntry(localInfo.existingFileId, {}, (err, info) => { + if(info) { + Log.trace( + { fileId : localInfo.existingFileId, oldFileName : info.fileName, oldStorageTag : info.storageTag }, + 'Existing TIC file target to be replaced' + ); + + localInfo.oldFileName = info.fileName; + localInfo.oldStorageTag = info.storageTag; + } + return callback(null, localInfo); // continue even if we couldn't find an old match }); } else if(fileIds.legnth > 1) { return callback(Errors.General(`More than one existing entry for TIC in ${localInfo.areaTag} ([${fileIds.join(', ')}])`)); @@ -1369,7 +1714,7 @@ function FTNMessageScanTossModule() { short_file_name : ticFileInfo.getAsString('File').toUpperCase(), // upper to ensure no case issues later; this should be a DOS 8.3 name tic_origin : ticFileInfo.getAsString('Origin'), tic_desc : ticFileInfo.getAsString('Desc'), - upload_by_username : _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'uploadBy' ]) || Config.scannerTossers.ftn_bso.tic.uploadBy, + upload_by_username : _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'uploadBy' ], Config.scannerTossers.ftn_bso.tic.uploadBy), } }; @@ -1381,8 +1726,8 @@ function FTNMessageScanTossModule() { // // We may have TIC auto-tagging for this node and/or specific (remote) area // - const hashTags = - localInfo.hashTags || + const hashTags = + localInfo.hashTags || _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'hashTags' ] ); // catch-all*/ if(hashTags) { @@ -1397,6 +1742,10 @@ function FTNMessageScanTossModule() { ticFileInfo.filePath, scanOpts, (err, fileEntry) => { + if(err) { + Log.trace( { reason : err.message }, 'Scanning failed'); + } + localInfo.fileEntry = fileEntry; return callback(err, localInfo); } @@ -1420,9 +1769,25 @@ function FTNMessageScanTossModule() { localInfo.fileEntry.areaTag = localInfo.areaTag; localInfo.fileEntry.fileName = ticFileInfo.longFileName; - // we default to .DIZ/etc. desc, but use from TIC if needed - if(!localInfo.fileEntry.desc || 0 === localInfo.fileEntry.desc.length) { - localInfo.fileEntry.desc = ticFileInfo.getAsString('Ldesc') || ticFileInfo.getAsString('Desc') || getDescFromFileName(ticFileInfo.filePath); + // + // We may now have two descriptions: from .DIZ/etc. or the TIC itself. + // Determine which one to use using |descPriority| and availability. + // + // We will still fallback as needed from -> -> + // + const descPriority = _.get( + Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'descPriority' ], + Config.scannerTossers.ftn_bso.tic.descPriority + ); + + if('tic' === descPriority) { + const origDesc = localInfo.fileEntry.desc; + localInfo.fileEntry.desc = ticFileInfo.getAsString('Ldesc') || origDesc || getDescFromFileName(ticFileInfo.filePath); + } else { + // see if we got desc from .DIZ/etc. + const fromDescFile = 'descFile' === localInfo.fileEntry.descSrc; + localInfo.fileEntry.desc = fromDescFile ? localInfo.fileEntry.desc : ticFileInfo.getAsString('Ldesc'); + localInfo.fileEntry.desc = localInfo.fileEntry.desc || getDescFromFileName(ticFileInfo.filePath); } const areaStorageDir = getAreaStorageDirectoryByTag(storageTag); @@ -1434,26 +1799,27 @@ function FTNMessageScanTossModule() { if(isUpdate) { // we need to *update* an existing record/file - localInfo.fileEntry.fileId = localInfo.existingFileId; + localInfo.fileEntry.fileId = localInfo.existingFileId; } const dst = paths.join(areaStorageDir, localInfo.fileEntry.fileName); self.copyTicAttachment(ticFileInfo.filePath, dst, isUpdate, (err, finalPath) => { if(err) { + Log.info( { reason : err.message }, 'Failed to copy TIC attachment'); return callback(err); } if(dst !== finalPath) { localInfo.fileEntry.fileName = paths.basename(finalPath); } - + localInfo.fileEntry.persist(isUpdate, err => { return callback(err, localInfo); }); - }); + }); }, - // :TODO: from here, we need to re-toss files if needed, before they are removed + // :TODO: from here, we need to re-toss files if needed, before they are removed function cleanupOldFile(localInfo, callback) { if(!localInfo.existingFileId) { return callback(null, localInfo); @@ -1461,7 +1827,7 @@ function FTNMessageScanTossModule() { const oldStorageDir = getAreaStorageDirectoryByTag(localInfo.oldStorageTag); const oldPath = paths.join(oldStorageDir, localInfo.oldFileName); - + fs.unlink(oldPath, err => { if(err) { Log.warn( { error : err.message, oldPath : oldPath }, 'Failed removing old physical file during TIC replacement'); @@ -1474,7 +1840,7 @@ function FTNMessageScanTossModule() { ], (err, localInfo) => { if(err) { - Log.error( { error : err.message, reason : err.reason, tic : ticFileInfo.path }, 'Failed import/update TIC record' ); + Log.error( { error : err.message, reason : err.reason, tic : ticFileInfo.filePath }, 'Failed import/update TIC record' ); } else { Log.debug( { tic : ticFileInfo.path, file : ticFileInfo.filePath, area : localInfo.areaTag }, @@ -1498,6 +1864,153 @@ function FTNMessageScanTossModule() { return cb(err); }); }; + + + this.performEchoMailExport = function(cb) { + // + // Select all messages with a |message_id| > |lastScanId|. + // Additionally exclude messages with the System state_flags0 which will be present for + // imported or already exported messages + // + // NOTE: If StateFlags0 starts to use additional bits, we'll likely need to check them here! + // + const getNewUuidsSql = + `SELECT message_id, message_uuid + FROM message m + WHERE area_tag = ? AND message_id > ? AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id AND meta_category = 'System' AND meta_name = 'state_flags0') = 0 + ORDER BY message_id;` + ; + + // we shouldn't, but be sure we don't try to pick up private mail here + const areaTags = Object.keys(Config.messageNetworks.ftn.areas) + .filter(areaTag => Message.WellKnownAreaTags.Private !== areaTag); + + async.each(areaTags, (areaTag, nextArea) => { + const areaConfig = Config.messageNetworks.ftn.areas[areaTag]; + if(!this.isAreaConfigValid(areaConfig)) { + return nextArea(); + } + + // + // For each message that is newer than that of the last scan + // we need to export to each configured associated uplink(s) + // + async.waterfall( + [ + function getLastScanId(callback) { + self.getAreaLastScanId(areaTag, callback); + }, + function getNewUuids(lastScanId, callback) { + msgDb.all(getNewUuidsSql, [ areaTag, lastScanId ], (err, rows) => { + if(err) { + callback(err); + } else { + if(0 === rows.length) { + let nothingToDoErr = new Error('Nothing to do!'); + nothingToDoErr.noRows = true; + callback(nothingToDoErr); + } else { + callback(null, rows); + } + } + }); + }, + function exportToConfiguredUplinks(msgRows, callback) { + const uuidsOnly = msgRows.map(r => r.message_uuid); // convert to array of UUIDs only + self.exportEchoMailMessagesToUplinks(uuidsOnly, areaConfig, err => { + const newLastScanId = msgRows[msgRows.length - 1].message_id; + + Log.info( + { areaTag : areaTag, messagesExported : msgRows.length, newLastScanId : newLastScanId }, + 'Export complete'); + + callback(err, newLastScanId); + }); + }, + function updateLastScanId(newLastScanId, callback) { + self.setAreaLastScanId(areaTag, newLastScanId, callback); + } + ], + () => { + return nextArea(); + } + ); + }, + err => { + return cb(err); + }); + }; + + this.performNetMailExport = function(cb) { + // + // Select all messages with a |message_id| > |lastScanId| in the private area + // that are schedule for export to FTN-style networks. + // + // Just like EchoMail, we additionally exclude messages with the System state_flags0 + // which will be present for imported or already exported messages + // + // + // :TODO: fill out the rest of the consts here + // :TODO: this statement is crazy ugly -- use JOIN / NOT EXISTS for state_flags & 0x02 + const getNewUuidsSql = + `SELECT message_id, message_uuid + FROM message m + WHERE area_tag = '${Message.WellKnownAreaTags.Private}' AND message_id > ? AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id + AND meta_category = 'System' + AND (meta_name = 'state_flags0' OR meta_name = 'local_to_user_id') + ) = 0 + AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id + AND meta_category = 'System' + AND meta_name = '${Message.SystemMetaNames.ExternalFlavor}' + AND meta_value = '${Message.AddressFlavor.FTN}' + ) = 1 + ORDER BY message_id; + `; + + async.waterfall( + [ + function getLastScanId(callback) { + return self.getAreaLastScanId(Message.WellKnownAreaTags.Private, callback); + }, + function getNewUuids(lastScanId, callback) { + msgDb.all(getNewUuidsSql, [ lastScanId ], (err, rows) => { + if(err) { + return callback(err); + } + + if(0 === rows.length) { + return cb(null); // note |cb| -- early bail out! + } + + return callback(null, rows); + }); + }, + function exportMessages(rows, callback) { + const messageUuids = rows.map(r => r.message_uuid); + return self.exportNetMailMessagesToUplinks(messageUuids, callback); + } + ], + err => { + return cb(err); + } + ); + }; + + this.isNetMailMessage = function(message) { + return message.isPrivate() && + null === _.get(message, 'meta.System.LocalToUserID', null) && + Message.AddressFlavor.FTN === _.get(message, 'meta.System.external_flavor', null) + ; + }; } require('util').inherits(FTNMessageScanTossModule, MessageScanTossModule); @@ -1545,19 +2058,19 @@ FTNMessageScanTossModule.prototype.processTicFilesInDirectory = function(importD if(err) { // archive rejected TIC stuff (.TIC + attach) async.each( [ ticFileInfo.path, ticFileInfo.filePath ], (path, nextPath) => { - if(!path) { // possibly rejected due to "File" not existing/etc. + if(!path) { // possibly rejected due to "File" not existing/etc. return nextPath(null); } self.maybeArchiveImportFile( - path, - 'tic', + path, + 'tic', 'reject', () => { return nextPath(null); } ); - }, + }, () => { self.removeAssocTicFiles(ticFileInfo, () => { return nextTicInfo(null); @@ -1567,7 +2080,7 @@ FTNMessageScanTossModule.prototype.processTicFilesInDirectory = function(importD self.removeAssocTicFiles(ticFileInfo, () => { return nextTicInfo(null); }); - } + } }); }, err => { return callback(err); @@ -1582,59 +2095,59 @@ FTNMessageScanTossModule.prototype.processTicFilesInDirectory = function(importD FTNMessageScanTossModule.prototype.startup = function(cb) { Log.info(`${exports.moduleInfo.name} Scanner/Tosser starting up`); - + let importing = false; - + let self = this; - - function tryImportNow(reasonDesc) { + + function tryImportNow(reasonDesc, extraInfo) { if(!importing) { importing = true; - - Log.info( { module : exports.moduleInfo.name }, reasonDesc); - + + Log.info( Object.assign({ module : exports.moduleInfo.name }, extraInfo), reasonDesc); + self.performImport( () => { importing = false; }); } } - + this.createTempDirectories(err => { if(err) { Log.warn( { error : err.toStrong() }, 'Failed creating temporary directories!'); return cb(err); } - + if(_.isObject(this.moduleConfig.schedule)) { const exportSchedule = this.parseScheduleString(this.moduleConfig.schedule.export); if(exportSchedule) { Log.debug( - { - schedule : this.moduleConfig.schedule.export, + { + schedule : this.moduleConfig.schedule.export, schedOK : -1 === exportSchedule.sched.error, next : moment(later.schedule(exportSchedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a'), immediate : exportSchedule.immediate ? true : false, }, 'Export schedule loaded' ); - + if(exportSchedule.sched) { this.exportTimer = later.setInterval( () => { if(this.exportingStart()) { Log.info( { module : exports.moduleInfo.name }, 'Performing scheduled message scan/export...'); - + this.performExport( () => { this.exportingEnd(); }); } }, exportSchedule.sched); } - + if(_.isBoolean(exportSchedule.immediate)) { this.exportImmediate = exportSchedule.immediate; } } - + const importSchedule = this.parseScheduleString(this.moduleConfig.schedule.import); if(importSchedule) { Log.debug( @@ -1646,13 +2159,13 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { }, 'Import schedule loaded' ); - - if(importSchedule.sched) { + + if(importSchedule.sched) { this.importTimer = later.setInterval( () => { - tryImportNow('Performing scheduled message import/toss...'); + tryImportNow('Performing scheduled message import/toss...'); }, importSchedule.sched); } - + if(_.isString(importSchedule.watchFile)) { const watcher = sane( paths.dirname(importSchedule.watchFile), @@ -1665,7 +2178,7 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { watcher.on(event, (fileName, fileRoot) => { const eventPath = paths.join(fileRoot, fileName); if(paths.join(fileRoot, fileName) === importSchedule.watchFile) { - tryImportNow(`Performing import/toss due to @watch: ${eventPath} (${event})`); + tryImportNow('Performing import/toss due to @watch', { eventPath, event } ); } }); }); @@ -1676,28 +2189,28 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { // fse.exists(importSchedule.watchFile, exists => { if(exists) { - tryImportNow(`Performing import/toss due to @watch: ${importSchedule.watchFile} (initial exists)`); + tryImportNow('Performing import/toss due to @watch', { eventPath : importSchedule.watchFile, event : 'initial exists' } ); } }); } } } - + FTNMessageScanTossModule.super_.prototype.startup.call(this, cb); }); }; FTNMessageScanTossModule.prototype.shutdown = function(cb) { Log.info('FidoNet Scanner/Tosser shutting down'); - + if(this.exportTimer) { this.exportTimer.clear(); } - + if(this.importTimer) { this.importTimer.clear(); } - + // // Clean up temp dir/files we created // @@ -1708,9 +2221,9 @@ FTNMessageScanTossModule.prototype.shutdown = function(cb) { paths : paths, sessionId : temptmp.sessionId, }; - + Log.trace(fullStats, 'Temporary directories cleaned up'); - + FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); }); @@ -1721,9 +2234,9 @@ FTNMessageScanTossModule.prototype.performImport = function(cb) { if(!this.hasValidConfiguration()) { return cb(new Error('Missing or invalid configuration')); } - + const self = this; - + async.each( [ 'inbound', 'secInbound' ], (inboundType, nextDir) => { self.importFromDirectory(inboundType, self.moduleConfig.paths[inboundType], () => { return nextDir(null); @@ -1739,77 +2252,18 @@ FTNMessageScanTossModule.prototype.performExport = function(cb) { if(!this.hasValidConfiguration()) { return cb(new Error('Missing or invalid configuration')); } - - // - // Select all messages with a |message_id| > |lastScanId|. - // Additionally exclude messages with the System state_flags0 which will be present for - // imported or already exported messages - // - // NOTE: If StateFlags0 starts to use additional bits, we'll likely need to check them here! - // - const getNewUuidsSql = - `SELECT message_id, message_uuid - FROM message m - WHERE area_tag = ? AND message_id > ? AND - (SELECT COUNT(message_id) - FROM message_meta - WHERE message_id = m.message_id AND meta_category = 'System' AND meta_name = 'state_flags0') = 0 - ORDER BY message_id;`; - - let self = this; - - async.each(Object.keys(Config.messageNetworks.ftn.areas), (areaTag, nextArea) => { - const areaConfig = Config.messageNetworks.ftn.areas[areaTag]; - if(!this.isAreaConfigValid(areaConfig)) { - return nextArea(); - } - - // - // For each message that is newer than that of the last scan - // we need to export to each configured associated uplink(s) - // - async.waterfall( - [ - function getLastScanId(callback) { - self.getAreaLastScanId(areaTag, callback); - }, - function getNewUuids(lastScanId, callback) { - msgDb.all(getNewUuidsSql, [ areaTag, lastScanId ], (err, rows) => { - if(err) { - callback(err); - } else { - if(0 === rows.length) { - let nothingToDoErr = new Error('Nothing to do!'); - nothingToDoErr.noRows = true; - callback(nothingToDoErr); - } else { - callback(null, rows); - } - } - }); - }, - function exportToConfiguredUplinks(msgRows, callback) { - const uuidsOnly = msgRows.map(r => r.message_uuid); // convert to array of UUIDs only - self.exportMessagesToUplinks(uuidsOnly, areaConfig, err => { - const newLastScanId = msgRows[msgRows.length - 1].message_id; - - Log.info( - { areaTag : areaTag, messagesExported : msgRows.length, newLastScanId : newLastScanId }, - 'Export complete'); - - callback(err, newLastScanId); - }); - }, - function updateLastScanId(newLastScanId, callback) { - self.setAreaLastScanId(areaTag, newLastScanId, callback); - } - ], - () => { - return nextArea(); + + const self = this; + + async.eachSeries( [ 'EchoMail', 'NetMail' ], (type, nextType) => { + self[`perform${type}Export`]( err => { + if(err) { + Log.warn( { error : err.message, type : type }, 'Error(s) during export' ); } - ); - }, err => { - return cb(err); + return nextType(null); // try next, always + }); + }, () => { + return cb(null); }); }; @@ -1820,27 +2274,37 @@ FTNMessageScanTossModule.prototype.record = function(message) { if(true !== this.exportImmediate || !this.hasValidConfiguration()) { return; } - - if(message.isPrivate()) { - // :TODO: support NetMail + + const info = { uuid : message.uuid, subject : message.subject }; + + function exportLog(err) { + if(err) { + Log.warn(info, 'Failed exporting message'); + } else { + Log.info(info, 'Message exported'); + } + } + + if(this.isNetMailMessage(message)) { + Object.assign(info, { type : 'NetMail' } ); + + if(this.exportingStart()) { + this.exportNetMailMessagesToUplinks( [ message.uuid ], err => { + this.exportingEnd( () => exportLog(err) ); + }); + } } else if(message.areaTag) { + Object.assign(info, { type : 'EchoMail' } ); + const areaConfig = Config.messageNetworks.ftn.areas[message.areaTag]; if(!this.isAreaConfigValid(areaConfig)) { return; } - + if(this.exportingStart()) { - this.exportMessagesToUplinks( [ message.uuid ], areaConfig, err => { - const info = { uuid : message.uuid, subject : message.subject }; - - if(err) { - Log.warn(info, 'Failed exporting message'); - } else { - Log.info(info, 'Message exported'); - } - - this.exportingEnd(); + this.exportEchoMailMessagesToUplinks( [ message.uuid ], areaConfig, err => { + this.exportingEnd( () => exportLog(err) ); }); - } - } + } + } }; diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 31c617e2..8a9903b4 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -148,7 +148,7 @@ exports.getModule = class WebServerModule extends ServerModule { const routeKey = route.getRouteKey(); if(routeKey in this.routes) { - Log.warn( { route : route }, 'Cannot add route: duplicate method/path combination exists' ); + Log.warn( { route : route, routeKey : routeKey }, 'Cannot add route: duplicate method/path combination exists' ); return false; } @@ -158,6 +158,11 @@ exports.getModule = class WebServerModule extends ServerModule { routeRequest(req, resp) { const route = _.find(this.routes, r => r.matchesRequest(req) ); + + if(!route && '/' === req.url) { + return this.routeIndex(req, resp); + } + return route ? route.handler(req, resp) : this.accessDenied(resp); } @@ -196,9 +201,20 @@ exports.getModule = class WebServerModule extends ServerModule { return this.respondWithError(resp, 404, 'File not found.', 'File Not Found'); } + routeIndex(req, resp) { + const filePath = paths.join(Config.contentServers.web.staticRoot, 'index.html'); + + return this.returnStaticPage(filePath, resp); + } + routeStaticFile(req, resp) { const fileName = req.url.substr(req.url.indexOf('/', 1)); const filePath = paths.join(Config.contentServers.web.staticRoot, fileName); + + return this.returnStaticPage(filePath, resp); + } + + returnStaticPage(filePath, resp) { const self = this; fs.stat(filePath, (err, stats) => { @@ -207,7 +223,7 @@ exports.getModule = class WebServerModule extends ServerModule { } const headers = { - 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), + 'Content-Type' : mimeTypes.contentType(paths.basename(filePath)) || mimeTypes.contentType('.bin'), 'Content-Length' : stats.size, }; diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index 4377f029..a6fa0deb 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -88,6 +88,7 @@ const SB_COMMANDS = { // // Resources // * http://mars.netanya.ac.il/~unesco/cdrom/booklet/HTML/NETWORKING/node300.html +// * http://www.networksorcery.com/enp/protocol/telnet.htm // const OPTIONS = { TRANSMIT_BINARY : 0, // http://tools.ietf.org/html/rfc856 @@ -186,6 +187,8 @@ const OPTION_NAMES = Object.keys(OPTIONS).reduce(function(names, name) { function unknownOption(bufs, i, event) { Log.warn( { bufs : bufs, i : i, event : event }, 'Unknown Telnet option'); + event.buf = bufs.splice(0, i).toBuffer(); + return event; } const OPTION_IMPLS = {}; @@ -538,6 +541,13 @@ function TelnetClient(input, output) { const logger = self.log || Log; return logger.warn(info, `Telnet: ${msg}`); }; + + this.readyNow = () => { + if(!this.didReady) { + this.didReady = true; + this.emit('ready', { firstMenu : Config.loginServers.telnet.firstMenu } ); + } + }; } util.inherits(TelnetClient, baseClient.Client); @@ -630,10 +640,7 @@ TelnetClient.prototype.handleSbCommand = function(evt) { self.negotiationsComplete = true; // :TODO: throw in a array of what we've taken care. Complete = array satisified or timeout - if(!self.didReady) { - self.didReady = true; - self.emit('ready', { firstMenu : Config.loginServers.telnet.firstMenu } ); - } + self.readyNow(); } else if('new environment' === evt.option) { // // Handling is as follows: @@ -829,6 +836,18 @@ exports.getModule = class TelnetServerModule extends LoginServerModule { client.banner(); this.handleNewClient(client, sock, ModuleInfo); + + // + // Set a timeout and attempt to proceed even if we don't know + // the term type yet, which is the preferred trigger + // for moving along + // + setTimeout( () => { + if(!client.didReady) { + Log.info('Proceeding after 3s without knowing term type'); + client.readyNow(); + } + }, 3000); }); this.server.on('error', err => { diff --git a/core/set_newscan_date.js b/core/set_newscan_date.js new file mode 100644 index 00000000..0e29e999 --- /dev/null +++ b/core/set_newscan_date.js @@ -0,0 +1,261 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const Errors = require('./enig_error.js').Errors; +const FileEntry = require('./file_entry.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const { getAvailableFileAreaTags } = require('./file_base_area.js'); +const { + getSortedAvailMessageConferences, + getSortedAvailMessageAreasByConfTag, + updateMessageAreaLastReadId, + getMessageIdNewerThanTimestampByArea +} = require('./message_area.js'); +const stringFormat = require('./string_format.js'); + +// deps +const async = require('async'); +const moment = require('moment'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Set New Scan Date', + desc : 'Sets new scan date for applicable scans', + author : 'NuSkooler', +}; + +const MciViewIds = { + main : { + scanDate : 1, + targetSelection : 2, + } +}; + +// :TODO: for messages, we could insert "conf - all areas" into targets, and allow such + +exports.getModule = class SetNewScanDate extends MenuModule { + constructor(options) { + super(options); + + const config = this.menuConfig.config; + + this.target = config.target || 'message'; + this.scanDateFormat = config.scanDateFormat || 'YYYYMMDD'; + + this.menuMethods = { + scanDateSubmit : (formData, extraArgs, cb) => { + let scanDate = _.get(formData, 'value.scanDate'); + if(!scanDate) { + return cb(Errors.MissingParam('"scanDate" missing from form data')); + } + + scanDate = moment(scanDate, this.scanDateFormat); + if(!scanDate.isValid()) { + return cb(Errors.Invalid(`"${_.get(formData, 'value.scanDate')}" is not a valid date`)); + } + + const targetSelection = _.get(formData, 'value.targetSelection'); // may be undefined if N/A + + this[`setNewScanDateFor${_.capitalize(this.target)}Base`](targetSelection, scanDate, () => { + return this.prevMenu(cb); + }); + }, + }; + } + + setNewScanDateForMessageBase(targetSelection, scanDate, cb) { + const target = this.targetSelections[targetSelection]; + if(!target) { + return cb(Errors.UnexpectedState('Unable to get target in which to set new scan')); + } + + // selected area, or all of 'em + let updateAreaTags; + if('' === target.area.areaTag) { + updateAreaTags = this.targetSelections + .map( targetSelection => targetSelection.area.areaTag ) + .filter( areaTag => areaTag ); // remove the blank 'all' entry + } else { + updateAreaTags = [ target.area.areaTag ]; + } + + async.each(updateAreaTags, (areaTag, nextAreaTag) => { + getMessageIdNewerThanTimestampByArea(areaTag, scanDate, (err, messageId) => { + if(err) { + return nextAreaTag(err); + } + + if(!messageId) { + return nextAreaTag(null); // nothing to do + } + + messageId = Math.max(messageId - 1, 0); + + return updateMessageAreaLastReadId( + this.client.user.userId, + areaTag, + messageId, + true, // allowOlder + nextAreaTag + ); + }); + }, err => { + return cb(err); + }); + } + + setNewScanDateForFileBase(targetSelection, scanDate, cb) { + // + // ENiGMA doesn't currently have the concept of per-area + // scan pointers for users, so we use all areas avail + // to the user. + // + const filterCriteria = { + areaTag : getAvailableFileAreaTags(this.client), + newerThanTimestamp : scanDate, + limit : 1, + orderBy : 'upload_timestamp', + order : 'ascending', + }; + + FileEntry.findFiles(filterCriteria, (err, fileIds) => { + if(err) { + return cb(err); + } + + if(!fileIds || 0 === fileIds.length) { + // nothing to do + return cb(null); + } + + const pointerFileId = Math.max(fileIds[0] - 1, 0); + + return FileBaseFilters.setFileBaseLastViewedFileIdForUser( + this.client.user, + pointerFileId, + true, // allowOlder + cb + ); + }); + } + + loadAvailMessageBaseSelections(cb) { + // + // Create an array of objects with conf/area information per entry, + // sorted naturally or via the 'sort' member in config + // + const selections = []; + getSortedAvailMessageConferences(this.client).forEach(conf => { + getSortedAvailMessageAreasByConfTag(conf.confTag, { client : this.client } ).forEach(area => { + selections.push({ + conf : { + confTag : conf.confTag, + name : conf.conf.name, + desc : conf.conf.desc, + }, + area : { + areaTag : area.areaTag, + name : area.area.name, + desc : area.area.desc, + } + }); + }); + }); + + selections.unshift({ + conf : { + confTag : '', + name : 'All conferences', + desc : 'All conferences', + }, + area : { + areaTag : '', + name : 'All areas', + desc : 'All areas', + } + }); + + // Find current conf/area & move it directly under "All" + const currConfTag = this.client.user.properties.message_conf_tag; + const currAreaTag = this.client.user.properties.message_area_tag; + if(currConfTag && currAreaTag) { + const confAreaIndex = selections.findIndex( confArea => { + return confArea.conf.confTag === currConfTag && confArea.area.areaTag === currAreaTag; + }); + + if(confAreaIndex > -1) { + selections.splice(1, 0, selections.splice(confAreaIndex, 1)[0]); + } + } + + this.targetSelections = selections; + + return cb(null); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.addViewController( 'main', new ViewController( { client : this.client } ) ); + + async.series( + [ + function validateConfig(callback) { + if(![ 'message', 'file' ].includes(self.target)) { + return callback(Errors.Invalid(`Invalid "target" in config: ${self.target}`)); + } + // :TOD0: validate scanDateFormat + return callback(null); + }, + function loadFromConfig(callback) { + return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function loadAvailSelections(callback) { + switch(self.target) { + case 'message' : + return self.loadAvailMessageBaseSelections(callback); + + default : + return callback(null); + } + }, + function populateForm(callback) { + const today = moment(); + + const scanDateView = vc.getView(MciViewIds.main.scanDate); + + // :TODO: MaskTextEditView needs some love: If setText() with input that matches the mask, we should ignore the non-mask chars! Hack in place for now + const scanDateFormat = self.scanDateFormat.replace(/[\/\-. ]/g, ''); + scanDateView.setText(today.format(scanDateFormat)); + + if('message' === self.target) { + const messageSelectionsFormat = self.menuConfig.config.messageSelectionsFormat || '{conf.name} - {area.name}'; + const messageSelectionFocusFormat = self.menuConfig.config.messageSelectionFocusFormat || messageSelectionsFormat; + + const targetSelectionView = vc.getView(MciViewIds.main.targetSelection); + + targetSelectionView.setItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection))); + targetSelectionView.setFocusItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection))); + + targetSelectionView.setFocusItemIndex(0); + } + + self.viewControllers.main.resetInitialFocus(); + //vc.switchFocus(MciViewIds.main.scanDate); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } +}; diff --git a/core/string_format.js b/core/string_format.js index dd1ece78..7fb7109a 100644 --- a/core/string_format.js +++ b/core/string_format.js @@ -2,12 +2,15 @@ 'use strict'; const EnigError = require('./enig_error.js').EnigError; -const pad = require('./string_util.js').pad; -const stylizeString = require('./string_util.js').stylizeString; -const renderStringLength = require('./string_util.js').renderStringLength; -const renderSubstr = require('./string_util.js').renderSubstr; -const formatByteSize = require('./string_util.js').formatByteSize; -const formatByteSizeAbbr = require('./string_util.js').formatByteSizeAbbr; + +const { + pad, + stylizeString, + renderStringLength, + renderSubstr, + formatByteSize, formatByteSizeAbbr, + formatCount, formatCountAbbr, +} = require('./string_util.js'); // deps const _ = require('lodash'); @@ -268,11 +271,16 @@ const transformers = { styleMixed : (s) => stylizeString(s, 'mixed'), styleL33t : (s) => stylizeString(s, 'l33t'), + // :TODO: // toMegs(), toKilobytes(), ... // toList(), toCommaList(), + sizeWithAbbr : (n) => formatByteSize(n, true, 2), sizeWithoutAbbr : (n) => formatByteSize(n, false, 2), sizeAbbr : (n) => formatByteSizeAbbr(n), + countWithAbbr : (n) => formatCount(n, true, 0), + countWithoutAbbr : (n) => formatCount(n, false, 0), + countAbbr : (n) => formatCountAbbr(n), }; function transformValue(transformerName, value) { diff --git a/core/string_util.js b/core/string_util.js index ed6df231..238aeeee 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -23,6 +23,8 @@ exports.renderSubstr = renderSubstr; exports.renderStringLength = renderStringLength; exports.formatByteSizeAbbr = formatByteSizeAbbr; exports.formatByteSize = formatByteSize; +exports.formatCountAbbr = formatCountAbbr; +exports.formatCount = formatCount; exports.cleanControlCodes = cleanControlCodes; exports.isAnsi = isAnsi; exports.isAnsiLine = isAnsiLine; @@ -316,23 +318,40 @@ function renderStringLength(s) { return len; } -const SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :) +const BYTE_SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :) function formatByteSizeAbbr(byteSize) { if(0 === byteSize) { - return SIZE_ABBRS[0]; // B + return BYTE_SIZE_ABBRS[0]; // B } - return SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; + return BYTE_SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; } -function formatByteSize(byteSize, withAbbr, decimals) { - withAbbr = withAbbr || false; - decimals = decimals || 3; +function formatByteSize(byteSize, withAbbr = false, decimals = 2) { const i = 0 === byteSize ? byteSize : Math.floor(Math.log(byteSize) / Math.log(1024)); let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)); if(withAbbr) { - result += ` ${SIZE_ABBRS[i]}`; + result += ` ${BYTE_SIZE_ABBRS[i]}`; + } + return result; +} + +const COUNT_ABBRS = [ '', 'K', 'M', 'B', 'T', 'P', 'E', 'Z', 'Y' ]; + +function formatCountAbbr(count) { + if(count < 1000) { + return ''; + } + + return COUNT_ABBRS[Math.floor(Math.log(count) / Math.log(1000))]; +} + +function formatCount(count, withAbbr = false, decimals = 2) { + const i = 0 === count ? count : Math.floor(Math.log(count) / Math.log(1000)); + let result = parseFloat((count / Math.pow(1000, i)).toFixed(decimals)); + if(withAbbr) { + result += `${COUNT_ABBRS[i]}`; } return result; } @@ -604,6 +623,10 @@ function isFormattedLine(line) { } function isAnsi(input) { + if(!input || 0 === input.length) { + return false; + } + // // * ANSI found - limited, just colors // * Full ANSI art diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 63c7b2bc..e2a5b2e0 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -2,20 +2,24 @@ 'use strict'; // ENiGMA½ -const User = require('./user.js'); -const Config = require('./config.js').config; -const Log = require('./logger.js').log; +const User = require('./user.js'); +const Config = require('./config.js').config; +const Log = require('./logger.js').log; +const { getAddressedToInfo } = require('./mail_util.js'); +const Message = require('./message.js'); // deps const fs = require('graceful-fs'); -exports.validateNonEmpty = validateNonEmpty; -exports.validateMessageSubject = validateMessageSubject; -exports.validateUserNameAvail = validateUserNameAvail; -exports.validateUserNameExists = validateUserNameExists; -exports.validateEmailAvail = validateEmailAvail; -exports.validateBirthdate = validateBirthdate; -exports.validatePasswordSpec = validatePasswordSpec; +exports.validateNonEmpty = validateNonEmpty; +exports.validateMessageSubject = validateMessageSubject; +exports.validateUserNameAvail = validateUserNameAvail; +exports.validateUserNameExists = validateUserNameExists; +exports.validateUserNameOrRealNameExists = validateUserNameOrRealNameExists; +exports.validateGeneralMailAddressedTo = validateGeneralMailAddressedTo; +exports.validateEmailAvail = validateEmailAvail; +exports.validateBirthdate = validateBirthdate; +exports.validatePasswordSpec = validatePasswordSpec; function validateNonEmpty(data, cb) { return cb(data && data.length > 0 ? null : new Error('Field cannot be empty')); @@ -42,29 +46,56 @@ function validateUserNameAvail(data, cb) { } else if(/^[0-9]+$/.test(data)) { return cb(new Error('Username cannot be a number')); } else { - User.getUserIdAndName(data, function userIdAndName(err) { + // a new user name cannot be an existing user name or an existing real name + User.getUserIdAndNameByLookup(data, function userIdAndName(err) { if(!err) { // err is null if we succeeded -- meaning this user exists already return cb(new Error('Username unavailable')); } - + return cb(null); }); } } } -function validateUserNameExists(data, cb) { - const invalidUserNameError = new Error('Invalid username'); +const invalidUserNameError = () => new Error('Invalid username'); +function validateUserNameExists(data, cb) { if(0 === data.length) { - return cb(invalidUserNameError); + return cb(invalidUserNameError()); } User.getUserIdAndName(data, (err) => { - return cb(err ? invalidUserNameError : null); + return cb(err ? invalidUserNameError() : null); }); } +function validateUserNameOrRealNameExists(data, cb) { + if(0 === data.length) { + return cb(invalidUserNameError()); + } + + User.getUserIdAndNameByLookup(data, err => { + return cb(err ? invalidUserNameError() : null); + }); +} + +function validateGeneralMailAddressedTo(data, cb) { + // + // Allow any supported addressing: + // - Local username or real name + // - Supported remote flavors such as FTN, email, ... + // + // :TODO: remove hard-coded FTN check here. We need a decent way to register global supported flavors with modules. + const addressedToInfo = getAddressedToInfo(data); + + if(Message.AddressFlavor.FTN === addressedToInfo.flavor) { + return cb(null); + } + + return validateUserNameOrRealNameExists(data, cb); +} + function validateEmailAvail(data, cb) { // // This particular method allows empty data - e.g. no email entered diff --git a/mods/telnet_bridge.js b/core/telnet_bridge.js similarity index 93% rename from mods/telnet_bridge.js rename to core/telnet_bridge.js index 1dbb1ae9..fa1754a5 100644 --- a/mods/telnet_bridge.js +++ b/core/telnet_bridge.js @@ -2,9 +2,9 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const setSyncTermFontWithAlias = require('../core/ansi_term.js').setSyncTermFontWithAlias; +const MenuModule = require('./menu_module.js').MenuModule; +const resetScreen = require('./ansi_term.js').resetScreen; +const setSyncTermFontWithAlias = require('./ansi_term.js').setSyncTermFontWithAlias; // deps const async = require('async'); @@ -48,7 +48,7 @@ class TelnetClientConnection extends EventEmitter { this.pipeRestored = true; // client may have bailed - if(_.has(this, 'client.term.output')) { + if(null !== _.get(this, 'client.term.output', null)) { if(this.bridgeConnection) { this.client.term.output.unpipe(this.bridgeConnection); } diff --git a/mods/upload.js b/core/upload.js similarity index 90% rename from mods/upload.js rename to core/upload.js index 5c5fd5b2..5a49a0ca 100644 --- a/mods/upload.js +++ b/core/upload.js @@ -2,19 +2,20 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const stringFormat = require('../core/string_format.js'); -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const getAreaDefaultStorageDirectory = require('../core/file_base_area.js').getAreaDefaultStorageDirectory; -const scanFile = require('../core/file_base_area.js').scanFile; -const getFileAreaByTag = require('../core/file_base_area.js').getFileAreaByTag; -const getDescFromFileName = require('../core/file_base_area.js').getDescFromFileName; -const ansiGoto = require('../core/ansi_term.js').goto; -const moveFileWithCollisionHandling = require('../core/file_util.js').moveFileWithCollisionHandling; -const pathWithTerminatingSeparator = require('../core/file_util.js').pathWithTerminatingSeparator; -const Log = require('../core/logger.js').log; -const Errors = require('../core/enig_error.js').Errors; -const FileEntry = require('../core/file_entry.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const stringFormat = require('./string_format.js'); +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const getAreaDefaultStorageDirectory = require('./file_base_area.js').getAreaDefaultStorageDirectory; +const scanFile = require('./file_base_area.js').scanFile; +const getFileAreaByTag = require('./file_base_area.js').getFileAreaByTag; +const getDescFromFileName = require('./file_base_area.js').getDescFromFileName; +const ansiGoto = require('./ansi_term.js').goto; +const moveFileWithCollisionHandling = require('./file_util.js').moveFileWithCollisionHandling; +const pathWithTerminatingSeparator = require('./file_util.js').pathWithTerminatingSeparator; +const Log = require('./logger.js').log; +const Errors = require('./enig_error.js').Errors; +const FileEntry = require('./file_entry.js'); +const isAnsi = require('./string_util.js').isAnsi; // deps const async = require('async'); @@ -421,9 +422,8 @@ exports.getModule = class UploadModule extends MenuModule { return nextEntry(err); } - // if the file entry did *not* have a desc, take the user desc - if(!this.fileEntryHasDetectedDesc(newEntry)) { - newEntry.desc = newValues.shortDesc.trim(); + if(!newEntry.descIsAnsi) { + newEntry.desc = _.trimEnd(newValues.shortDesc); } if(newValues.estYear.length > 0) { @@ -659,14 +659,16 @@ exports.getModule = class UploadModule extends MenuModule { displayFileDetailsPageForUploadEntry(fileEntry, cb) { const self = this; - async.series( + async.waterfall( [ function prepArtAndViewController(callback) { return self.prepViewControllerWithArt( 'fileDetails', FormIds.fileDetails, { clearScreen : true, trailingLF : false }, - callback + err => { + return callback(err); + } ); }, function populateViews(callback) { @@ -679,18 +681,32 @@ exports.getModule = class UploadModule extends MenuModule { tagsView.setText( Array.from(fileEntry.hashTags).join(',') ); // :TODO: optional 'hashTagsSep' like file list/browse yearView.setText(fileEntry.meta.est_release_year || ''); - if(self.fileEntryHasDetectedDesc(fileEntry)) { - descView.setPropertyValue('mode', 'preview'); - descView.setText(fileEntry.desc); - descView.acceptsFocus = false; - self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.tags); - } else { - descView.setPropertyValue('mode', 'edit'); - descView.setText(getDescFromFileName(fileEntry.fileName)); // try to come up with something good as a default - descView.acceptsFocus = true; - self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.desc); - } + if(isAnsi(fileEntry.desc)) { + fileEntry.descIsAnsi = true; + return descView.setAnsi( + fileEntry.desc, + { + prepped : false, + forceLineTerm : true, + }, + () => { + return callback(null, descView, 'preview', MciViewIds.fileDetails.tags); + } + ); + } else { + const hasDesc = self.fileEntryHasDetectedDesc(fileEntry); + descView.setText( + hasDesc ? fileEntry.desc : getDescFromFileName(fileEntry.fileName), + { scrollMode : 'top' } // override scroll mode; we want to be @ top + ); + return callback(null, descView, 'edit', hasDesc ? MciViewIds.fileDetails.tags : MciViewIds.fileDetails.desc); + } + }, + function finalizeViews(descView, descViewMode, focusId, callback) { + descView.setPropertyValue('mode', descViewMode); + descView.acceptsFocus = 'preview' === descViewMode ? false : true; + self.viewControllers.fileDetails.switchFocus(focusId); return callback(null); } ], diff --git a/core/user.js b/core/user.js index 15e5a844..09d26163 100644 --- a/core/user.js +++ b/core/user.js @@ -189,15 +189,13 @@ module.exports = class User { // :TODO: set various defaults, e.g. default activation status, etc. self.properties.account_status = Config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active; - async.series( + async.waterfall( [ function beginTransaction(callback) { - userDb.run('BEGIN;', err => { - return callback(err); - }); + return userDb.beginTransaction(callback); }, - function createUserRec(callback) { - userDb.run( + function createUserRec(trans, callback) { + trans.run( `INSERT INTO user (user_name) VALUES (?);`, [ self.username ], @@ -213,11 +211,11 @@ module.exports = class User { self.properties.account_status = User.AccountStatus.active; } - return callback(null); + return callback(null, trans); } ); }, - function genAuthCredentials(callback) { + function genAuthCredentials(trans, callback) { User.generatePasswordDerivedKeyAndSalt(password, (err, info) => { if(err) { return callback(err); @@ -225,85 +223,56 @@ module.exports = class User { self.properties.pw_pbkdf2_salt = info.salt; self.properties.pw_pbkdf2_dk = info.dk; - return callback(null); + return callback(null, trans); }); }, - function setInitialGroupMembership(callback) { + function setInitialGroupMembership(trans, callback) { self.groups = Config.users.defaultGroups; if(User.RootUserID === self.userId) { // root/SysOp? self.groups.push('sysops'); } - return callback(null); + return callback(null, trans); }, - function saveAll(callback) { - self.persist(false, err => { - return callback(err); + function saveAll(trans, callback) { + self.persistWithTransaction(trans, err => { + return callback(err, trans); }); } ], - err => { - if(err) { - const originalError = err; - userDb.run('ROLLBACK;', err => { - assert(!err); - return cb(originalError); + (err, trans) => { + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr); }); } else { - userDb.run('COMMIT;', err => { - return cb(err); - }); + return cb(err); } } ); } - persist(useTransaction, cb) { + persistWithTransaction(trans, cb) { assert(this.userId > 0); const self = this; async.series( [ - function beginTransaction(callback) { - if(useTransaction) { - userDb.run('BEGIN;', err => { - return callback(err); - }); - } else { - return callback(null); - } - }, function saveProps(callback) { - self.persistProperties(self.properties, err => { + self.persistProperties(self.properties, trans, err => { return callback(err); }); }, function saveGroups(callback) { - userGroup.addUserToGroups(self.userId, self.groups, err => { + userGroup.addUserToGroups(self.userId, self.groups, trans, err => { return callback(err); }); } ], err => { - if(err) { - if(useTransaction) { - userDb.run('ROLLBACK;', err => { - return cb(err); - }); - } else { - return cb(err); - } - } else { - if(useTransaction) { - userDb.run('COMMIT;', err => { - return cb(err); - }); - } else { - return cb(null); - } - } + return cb(err); } ); } @@ -340,13 +309,18 @@ module.exports = class User { ); } - persistProperties(properties, cb) { + persistProperties(properties, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = userDb; + } + const self = this; // update live props _.merge(this.properties, properties); - const stmt = userDb.prepare( + const stmt = transOrDb.prepare( `REPLACE INTO user_property (user_id, prop_name, prop_value) VALUES (?, ?, ?);` ); @@ -440,12 +414,48 @@ module.exports = class User { if(row) { return cb(null, row.id, row.user_name); } - + return cb(Errors.DoesNotExist('No matching username')); } ); } + static getUserIdAndNameByRealName(realName, cb) { + userDb.get( + `SELECT id, user_name + FROM user + WHERE id = ( + SELECT user_id + FROM user_property + WHERE prop_name='real_name' AND prop_value LIKE ? + );`, + [ realName ], + (err, row) => { + if(err) { + return cb(err); + } + + if(row) { + return cb(null, row.id, row.user_name); + } + + return cb(Errors.DoesNotExist('No matching real name')); + } + ); + } + + static getUserIdAndNameByLookup(lookup, cb) { + User.getUserIdAndName(lookup, (err, userId, userName) => { + if(err) { + User.getUserIdAndNameByRealName(lookup, (err, userId, userName) => { + return cb(err, userId, userName); + }); + } else { + return cb(null, userId, userName); + } + }); + } + static getUserName(userId, cb) { userDb.get( `SELECT user_name diff --git a/core/user_group.js b/core/user_group.js index 2fcaacf3..3903f2c3 100644 --- a/core/user_group.js +++ b/core/user_group.js @@ -33,8 +33,13 @@ function getGroupsForUser(userId, cb) { }); } -function addUserToGroup(userId, groupName, cb) { - userDb.run( +function addUserToGroup(userId, groupName, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = userDb; + } + + transOrDb.run( 'REPLACE INTO user_group_member (group_name, user_id) ' + 'VALUES(?, ?);', [ groupName, userId ], @@ -44,10 +49,10 @@ function addUserToGroup(userId, groupName, cb) { ); } -function addUserToGroups(userId, groups, cb) { +function addUserToGroups(userId, groups, transOrDb, cb) { async.each(groups, function item(groupName, next) { - addUserToGroup(userId, groupName, next); + addUserToGroup(userId, groupName, transOrDb, next); }, function complete(err) { cb(err); }); diff --git a/mods/user_list.js b/core/user_list.js similarity index 91% rename from mods/user_list.js rename to core/user_list.js index b2a88e79..be85c586 100644 --- a/mods/user_list.js +++ b/core/user_list.js @@ -1,10 +1,10 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const User = require('../core/user.js'); -const ViewController = require('../core/view_controller.js').ViewController; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const User = require('./user.js'); +const ViewController = require('./view_controller.js').ViewController; +const stringFormat = require('./string_format.js'); const moment = require('moment'); const async = require('async'); diff --git a/mods/whos_online.js b/core/whos_online.js similarity index 87% rename from mods/whos_online.js rename to core/whos_online.js index a0a87829..6abd76ef 100644 --- a/mods/whos_online.js +++ b/core/whos_online.js @@ -2,10 +2,10 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getActiveNodeList = require('../core/client_connections.js').getActiveNodeList; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getActiveNodeList = require('./client_connections.js').getActiveNodeList; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/docs/config.md b/docs/config.md index fba48119..98a6730f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,15 +2,18 @@ Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error. ## System Configuration -The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `~/.config/enigma-bbs/config.hjson` though you can override this with the `--config` parameter when invoking `main.js`. Values found in core/config.js may be overridden by simply providing the object members you wish replace. +The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace. -**Windows note**: **~** resolves to *C:\Users\YOURLOGINNAME\* on modern Windows installations, e.g. `C:\Users\NuSkooler\.config\enigma-bbs\config.hjson` +### Creating a Configuration +Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory: +``` +./oputil.js config new +``` -### oputil.js -Please see `oputil.js config` for configuration generation options. +You will be asked a series of questions to create an initial configuration. -### Example: System Name -`core/config.js` provides the default system name as follows: +### Overriding Defaults +The file `core/config.js` provides various defaults to the system that you can override via `config.hjson`. For example, the default system name is defined as follows: ```javascript general : { boardName : 'Another Fine ENiGMA½ System' @@ -26,17 +29,14 @@ general: { (Note the very slightly different syntax. **You can use standard JSON if you wish**) +While not everything that is available in your `config.hjson` file can be found defaulted in `core/config.js`, a lot is. [Poke around and see what you can find](https://github.com/NuSkooler/enigma-bbs/blob/master/core/config.js)! + ### Specific Areas of Interest -* [Menu System](menu_system.md) * [Message Conferences](msg_conf_area.md) * [Message Networks](msg_networks.md) * [File Base](file_base.md) * [File Archives & Archivers](archives.md) -* [Doors](doors.md) -* [MCI Codes](mci.md) * [Web Server](web_server.md) -...and other stuff [in the /docs directory](./) - ### A Sample Configuration Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked. @@ -126,5 +126,8 @@ Below is a **sample** `config.hjson` illustrating various (but certainly not all } ``` -## Menus -See [the menu system docs](menu_system.md) +## See Also +* [Modding](modding.md) +* [Doors](doors.md) +* [MCI Codes](mci.md) +* [Menu System docs](menu_system.md) diff --git a/docs/doors.md b/docs/doors.md index 702d75d9..5f72d761 100644 --- a/docs/doors.md +++ b/docs/doors.md @@ -183,7 +183,7 @@ The module `door_party` provides native support for [DoorParty!](http://www.thro ```hjson doorParty: { desc: Using DoorParty! - module: @systemModule:door_party + module: door_party config: { username: XXXXXXXX password: XXXXXXXX @@ -194,6 +194,22 @@ doorParty: { Fill in `username`, `password`, and `bbsTag` with credentials provided to you and you should be in business! +## The CombatNet Module +The `combatnet` module provides native support for [CombatNet](http://combatnet.us/). Add the following to your menu config: + +````hjson +combatNet: { + desc: Using CombatNet + module: combatnet + config: { + bbsTag: CBNxxx + password: XXXXXXXXX + } +} +```` +Update `bbsTag` (in the format CBNxxx) and `password` with the details provided when you register, then +you should be ready to rock! + # Resources ### DOSBox diff --git a/docs/file_base.md b/docs/file_base.md index bfd802b9..893a93de 100644 --- a/docs/file_base.md +++ b/docs/file_base.md @@ -1,7 +1,7 @@ # File Bases Starting with version 0.0.4-alpha, ENiGMA½ has support for File Bases! Documentation below covers setup of file area(s), but first some information on what to expect: -## A Different Appoach +## A Different Approach ENiGMA½ has strayed away from the old familure setup here and instead takes a more modern approach: * [Gazelle](https://whatcd.github.io/Gazelle/) inspired system for searching & browsing files * No File Conferences (just areas!) diff --git a/docs/images/vtxclient.png b/docs/images/vtxclient.png new file mode 100644 index 00000000..99261ced Binary files /dev/null and b/docs/images/vtxclient.png differ diff --git a/docs/index.md b/docs/index.md index b57fac84..2320829c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ For Windows environments or if you simply like to do things manually, read on... ### New to Node -If you're new to Node.js and/or do not care about Node itself and just want to get ENiGMA½ running these steps should get you going on most \*nix type enviornments (Please consider the `install.sh` approach unless you really want to manually install!): +If you're new to Node.js and/or do not care about Node itself and just want to get ENiGMA½ running these steps should get you going on most \*nix type environments (Please consider the `install.sh` approach unless you really want to manually install!): ```bash curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.0/install.sh | bash @@ -51,11 +51,11 @@ npm install ## Generate a SSH Private Key To utilize the SSH server, a SSH Private Key will need generated. This step can be skipped if you do not wish to enable SSH access. ```bash -openssl genrsa -des3 -out ./misc/ssh_private_key.pem 2048 +openssl genrsa -des3 -out ./config/ssh_private_key.pem 2048 ``` ### Create a Minimal Config -The main system configuration is handled via `~/.config/enigma-bbs/config.hjson`. This is a [HJSON](http://hjson.org/) file (compiliant JSON is also OK). See [Configuration](config.md) for more information. +The main system configuration is handled via `/enigma-bbs-install-path/config/config.hjson`. This is a [HJSON](http://hjson.org/) file (compiliant JSON is also OK). See [Configuration](config.md) for more information. #### Via oputil.js `oputil.js` can be utilized to generate your **initial** configuration. **This is the recommended way for all new users**: @@ -64,7 +64,7 @@ The main system configuration is handled via `~/.config/enigma-bbs/config.hjson` ./oputil.js config new ``` -(You wil be asked a series of basic questions) +(You will be asked a series of basic questions) #### Example Starting Configuration Below is an _example_ configuration. It is recommended that you at least **start with a generated configuration using oputil.js described above**. @@ -110,7 +110,7 @@ Below is an _example_ configuration. It is recommended that you at least **start Read the Points of Interest below for more info. Also check-out all the other documentation files in the [docs](.) directory. ## Points of Interest -* **The first user you create via register/applying (user ID = 1) will be automatically be added to the `sysops` group. And thus becomes SysOp.** (aka root) +* **The first user you create via register/applying (user ID = 1) will be automatically be added to the `sysops` group, and thus becomes SysOp.** (aka root) * Default port for Telnet is 8888 and for SSH 8889 * Note that on *nix systems port such as telnet/23 are privileged (e.g. require root). See [this SO article](http://stackoverflow.com/questions/16573668/best-practices-when-running-node-js-with-port-80-ubuntu-linode) for some tips on using these ports on your system if desired. * All data is stored by default in Sqlite3 database files, within the `db` sub folder. Including user data, messages, system logs and file meta data. diff --git a/docs/menu_system.md b/docs/menu_system.md index 1dea637c..aef51199 100644 --- a/docs/menu_system.md +++ b/docs/menu_system.md @@ -1,17 +1,26 @@ # Menu System -ENiGMA½'s menu system is highly flexible and moddable. The possibilities are almost endless! By modifying your `menu.hjson` you will be able to create a custom look and feel unique to your board. +ENiGMA½'s menu system is highly flexible and moddable. The possibilities are almost endless! -The default `menu.hjson` file lives within the `mods` directory. It is **highly recommended** to specify another file by setting the `menuFile` property in your `config.hjson` file: +This document and others will refer to `menu.hjson`. This should be seen as an alias to `yourboardname.hjson` (or whatever you reference in `config.hjson` using the `menuFile` property — see below). By modifying your `menu.hjson` you will be able to create a custom experience unique to your board. + +The default `menu.hjson` file lives within the `config` directory. It is **highly recommended** to specify another file by setting the `menuFile` property in your `config.hjson` file: ```hjson general: { /* Can also specify a full path */ - menuFile: mybbs.hjson + menuFile: yourboardname.hjson } ``` -(You can start by copying the default `menu.hjson` to `mybbs.hjson`) + +You can start by copying the default `mods/menu.hjson` to `yourboardname.hjson`. ## The Basics -Like all configuration within ENiGMA½, menu configuration is done via a HJSON file. This file is located in the `mods` directory: `mods/menu.hjson`. +Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format. + +Entries in `menu.hjson` are objects defining a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to: +* Classical Main, Messages, and File menus +* Art file display +* Module driven menus such as door launchers + Each entry in `menu.hjson` defines an object that represents a menu. These objects live within the `menus` parent object. Each object's *key* is a menu name you can reference within other menus in the system. @@ -26,9 +35,9 @@ telnetConnected: { } ``` -The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the server's config). +The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config). -An art pattern of `CONNECT` is set telling the system to look for `CONNECT` in the current theme location, then in the common `mods/art` directory where `` represents a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. You can be explicit here if desired, by specifying a file extension. +An art pattern of `CONNECT` is set telling the system to look for `CONNECT.*` where `` represents a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. If desired, you can also be explicit by supplying a full filename with an extention such as `CONNECT.ANS`. The entry `next` sets up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`. @@ -47,20 +56,21 @@ matrix: { submit: true focus: true items: [ "login", "apply", "log off" ] + argName: matrixSubmit } } submit: { *: [ { - value: { 1: 0 } + value: { matrixSubmit: 0 } action: @menu:login } { - value: { 1: 1 }, + value: { matrixSubmit: 1 }, action: @menu:newUserApplication } { - value: { 1: 2 }, + value: { matrixSubmit: 2 }, action: @menu:logoff } ] @@ -71,6 +81,6 @@ matrix: { } ``` -In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. +In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` for this action as `matrixSubmit`. -The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ 1: 0 }` or view ID 1, value 0 will match causing `action` of `@menu:login` to be executed (go to `login` menu). \ No newline at end of file +The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ matrixSubmit: 0 }` will match causing `action` of `@menu:login` to be executed (go to `login` menu). diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 00000000..609729b4 --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,15 @@ +# Modding + +## General Configuraiton +See [Configuration](config.md) + +## Menus +See [Menu System](menu_system.md) + +## Theming +Take a look at how the default `luciano_blocktronics` theme found under `art/themes` works! + +TODO document me! + +## Add-On Modules +See [Mods](mods.md) \ No newline at end of file diff --git a/docs/mods.md b/docs/mods.md new file mode 100644 index 00000000..f73a3fc6 --- /dev/null +++ b/docs/mods.md @@ -0,0 +1,9 @@ +# Mods +Custom mods should be added to `/enigma-install-path/mods`. + +## Existing Mods +* **Married Bob Fetch Event**: An event for fetching the latest Married Bob ANSI's for display on you board. ACiDic release [ACD-MB4E.ZIP](https://l33t.codes/outgoing/ACD/ACD-MB4E.ZIP). Can also be [found on GitHub](https://github.com/NuSkooler/enigma-bbs-married_bob_evt) +* **Latest Files Announcement**: An event for posting the latest file arrivals of your board to message areas such as FTN style networks. ACiDic release [ACD-LFA1.ZIP](https://l33t.codes/outgoing/ACD/ACD-LFA1.ZIP) Also [found on GitHub](https://github.com/NuSkooler/enigma-bbs-latest_files_announce_evt) +* **Message Post Event**: An event for posting messages/ads to networks. ACiDic release [ACD-MP4E.ZIP](https://l33t.codes/outgoing/ACD/ACD-MP4E.ZIP) + +See also [ACiDic BBS Mods by Myself](https://l33t.codes/acidic-mods-by-myself/) \ No newline at end of file diff --git a/docs/msg_networks.md b/docs/msg_networks.md index ca0afe17..11fa9202 100644 --- a/docs/msg_networks.md +++ b/docs/msg_networks.md @@ -6,7 +6,7 @@ Message networks are configured in `messageNetworks` section of `config.hjson`. * `originLine` (optional): Overrwrite the default origin line for networks that support it. For example: `originLine: Xibalba - xibalba.l33t.codes:44510` ## FidoNet Technology Network (FTN) -FTN networks are configured under the `messageNetworks::ftn` section of `config.hjson`. +FTN networks are configured under the `messageNetworks.ftn` section of `config.hjson`. ### Networks The `networks` section contains a sub section for network(s) you wish you join your board with. Each entry's key name can be referenced elsewhere in `config.hjson` for FTN oriented configurations. @@ -30,7 +30,7 @@ The `networks` section contains a sub section for network(s) you wish you join y ``` ### Areas -The `areas` section describes a mapping of local **area tags** found in your `messageConferences` to a message network (from `networks` described previously), a FTN specific area tag, and remote uplink address(s). This section can be thought of similar to the *AREAS.BBS* file used by other BBS packages. +The `areas` section describes a mapping of local **area tags** found in your `messageConferences` to a message network (from `networks` described previously), a FTN specific area tag, and remote uplink address(s). This section can be thought of similar to the *AREAS.BBS* file used by other BBS packages (In fact you can import AREAS.BBS using `oputil.js`!) When importing, messages will be placed in the local area that matches key under `areas`. @@ -57,11 +57,11 @@ When importing, messages will be placed in the local area that matches key under ``` ### BSO Import / Export -The scanner/tosser module `ftn_bso` provides **B**inkley **S**tyle **O**utbound (BSO) import/toss & scan/export of messages EchoMail and NetMail messages. Configuration is supplied in `config.hjson` under `scannerTossers::ftn_bso`. +The scanner/tosser module `ftn_bso` provides **B**inkley **S**tyle **O**utbound (BSO) import/toss & scan/export of messages EchoMail and NetMail messages. Configuration is supplied in `config.hjson` under `scannerTossers.ftn_bso`. **Members**: * `defaultZone` (required): Sets the default BSO outbound zone - * `defaultNetwork` (optional): Sets the default network name from `messageNetworks::ftn::networks`. **Required if more than one network is defined**. + * `defaultNetwork` (optional): Sets the default network name from `messageNetworks.ftn.networks`. **Required if more than one network is defined**. * `paths` (optional): Override default paths set by the system. This section may contain `outbound`, `inbound`, and `secInbound`. * `packetTargetByteSize` (optional): Overrides the system *target* packet (.pkt) size of 512000 bytes (512k) * `bundleTargetByteSize` (optional): Overrides the system *target* ArcMail bundle size of 2048000 bytes (2M) @@ -85,7 +85,7 @@ A node entry starts with a FTN style address (up to 5D) **as a key** in `config. scannerTossers: { ftn_bso: { nodes: { - "46:*: { + "46:*": { packetType: 2+ packetPassword: mypass encoding: cp437 @@ -97,6 +97,78 @@ A node entry starts with a FTN style address (up to 5D) **as a key** in `config. } ``` +#### TIC Support +ENiGMA½ supports TIC files. This is handled by mapping TIC areas to local file areas. + +Under a given node (like the one configured above), TIC configuration may be supplied: + +```hjson +{ + scannerTossers: { + ftn_bso: { + nodes: { + "46:*": { + packetType: 2+ + packetPassword: mypass + encoding: cp437 + archiveType: zip + tic: { + password: TESTY-TEST + uploadBy: Agoranet TIC + allowReplace: true + } + } + } + } + } +} +``` + +You then need to configure the mapping between TIC areas you want to carry, and the file +base area for them to be tossed to. Start by creating a storage tag and file base, if you haven't +already: + +````hjson +fileBase: { + areaStoragePrefix: /home/bbs/file_areas/ + + storageTags: { + msg_network: "msg_network" + } + + areas: { + msgNetworks: { + name: Message Networks + desc: Message networks news & info + storageTags: [ + "msg_network" + ] + } + } +} + +```` +and then create the mapping between the TIC area and the file area created: + +````hjson +ticAreas: { + agn_node: { + areaTag: msgNetworks + hashTags: agoranet,nodelist + storageTag: msg_network + } + + agn_info: { + areaTag: msgNetworks + hashTags: agoranet,infopack + storageTag: msg_network + } +} + +```` +Multiple TIC areas can be mapped to a single file base area. + + #### Scheduling Schedules can be defined for importing and exporting via `import` and `export` under `schedule`. Each entry is allowed a "free form" text and/or special indicators for immediate export or watch file triggers. diff --git a/docs/rpi.md b/docs/rpi.md index 4794bbcf..f474e959 100644 --- a/docs/rpi.md +++ b/docs/rpi.md @@ -3,39 +3,32 @@ ENiGMA½ can run under your Linux / RPi installation! The following instructions should help get you started. ## Tested RPi Models -###Model A -Works, but fairly slow (Node itself is not the fastest on this device). May work better overlocked, etc. +### Model A +Works, but fairly slow when browsing message areas (Node itself is not the fastest on this device). May work better overlocked, etc. -###v2 Model B -Works well with default rasbian, follow the normal quickstart install procedure, except for installing nodejs. To install nodejs do the following: - - curl -sL https://deb.nodesource.com/setup_4.x | sudo -E bash - - sudo apt-get install -y nodejs +### v2 Model B +Works well with Raspbian! Keep in mind, compiling the dependencies with `npm install` will take some time and appear to hang. Just be patient. -##Example Configuration: RPi Model A + Minibian +## Example Configuration: RPi Model A + Raspbian Stretch Lite ### Basic Instructions -1. Download and `dd` the Minibian .img file from https://minibianpi.wordpress.com/ to a SDCARD. Cards >= 16GB recommended. -2. After booting Minibian, expand your file system. See http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi for information. -3. Update & upgrade: `apt-get update && apt-get upgrade` -4. It is recommended that you install `sudo` and create an admin user: `apt-get install sudo`, `adduser `, `adduser sudo` (reboot & login as the user your just created) -5. We want to build dependencies with a updated version of GCC. The following works to install GCC 4.9 on Minibian "wheezy": -a. Update */etc/apt/sources.list* replacing all "wheezy" with "jessie" -b. `sudo apt-get update` -c. `sudo apt-get install gcc-4.9 g++-4.9` -d. Update */etc/apt/sources.list* reverting all "jessie" back to "wheezy" -e. `sudo apt-get update` -f. Update alternatives: `sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.9 60 --slave /usr/bin/g++ g++ /usr/bin/g++-4.9` -6. Install dependencies: `sudo apt-get install make python libicu-dev libssl-dev git` -7. Install the latest Node.js from here: http://node-arm.herokuapp.com/ (**only download the .dep and dpkg install it!**) -8. The RPi A has very low memory, we'll need a swap file: -a. `sudo dd if=/dev/zero of=tmpswap bs=1024 count=1M` -b. `sudo mkswap tmpswap` -c. `sudo swapon tmpswap` -9. Clone enigma-bbs.git -10. Install dependencies. Here we will force GCC 4.9 for compilation: `CC=gcc-4.9 npm install` -11. Follow generic setup for creating a config.hjson, etc. and you should be ready to go! +1. Download [Raspbian Stretch Lite](https://www.raspberrypi.org/downloads/raspbian/). Follow the instructions +on the [Raspbian site](https://www.raspberrypi.org/documentation/installation/installing-images/README.md) regarding how +to get it written to an SD card. + +2. Run `sudo raspi-config`, then: + 1. Set your timezone (option 4, option I2) + 2. Enable SSH (option 5, option P2) + 3. Expand the filesystem to use the entire SD card (option 7, option A1) + +3. Update & upgrade all packages: `apt-get update && apt-get upgrade` + +4. Install required packages: `sudo apt install lrzsz p7zip-full` + +5. Follow the [Quickstart](docs/index.md) instructions to install ENiGMA½. + +6. Profit! diff --git a/docs/vtx_web_client.md b/docs/vtx_web_client.md new file mode 100644 index 00000000..99c45f93 --- /dev/null +++ b/docs/vtx_web_client.md @@ -0,0 +1,87 @@ +# VTX Web Client +ENiGMA supports the VTX websocket client for connecting to your BBS from a web page. Example usage can be found at +[Xibalba](https://l33t.codes/vtx/xibalba.html) and [fORCE9](https://bbs.force9.org/vtx/force9.html). + +## Before You Start + +There are a few things out of scope of this document: + + - You'll need a web server for hosting the files - this can be anywhere, but it obviously makes sense to host it + somewhere with a hostname relevant to your BBS! + + - It's not required, but you should use SSL certificates to secure your website, and for supplying to ENiGMA to + secure the websocket connections. [Let's Encrypt](https://letsencrypt.org/) provide a free well-respected service. + + - How you make the websocket service available on the internet is up to you, but it'll likely by forwarding ports on + your router to the box hosting ENiGMA. Use the same method you did for forwarding the telnet port. + +## Setup + +1. Enable the websocket in ENiGMA, by adding `webSocket` configuration to the `loginServers` block (create it if you +don't already have it defined). + + ````hjson + loginServers: { + webSocket : { + port: 8810 + enabled: true + securePort: 8811 + certPem: /path/to/https_cert.pem + keyPem: /path/to/https_cert_key.pem + } + } + ```` + +2. Restart ENiGMA and check the logs to ensure the websocket service starts successfully, you'll see something like the +following: + + ```` + [2017-10-29T12:13:30.668Z] INFO: ENiGMA½ BBS/30978 on force9: Listening for connections (server="WebSocket (insecure)", port=8810) + [2017-10-29T12:13:30.669Z] INFO: ENiGMA½ BBS/30978 on force9: Listening for connections (server="WebSocket (secure)", port=8811) + ```` + +3. Download the [VTX_ClientServer](https://github.com/codewar65/VTX_ClientServer/archive/master.zip) to your +webserver, and unpack it to a temporary directory. + +4. Download the example [VTX client HTML file](/misc/vtx/vtx.html) and save it to your webserver root. + +5. Create an `assets/vtx` directory within your webserver root, so you have a structure like the following: + + ````text + ├── assets + │   └── vtx + └── vtx.html + ```` + +6. From the VTX_ClientServer package unpacked earlier, copy the contents of the `www` directory into `assets/vtx` directory. + +7. Create a vtxdata.js file, and save it to `assets/vtx`: + + ````javascript + var vtxdata = { + sysName: "Your Awesome BBS", + wsConnect: "wss://your-hostname.here:8811" + term: "ansi-bbs", + codePage: "CP437", + fontName: "UVGA16", + fontSize: "24px", + crtCols: 80, + crtRows: 25, + crtHistory: 500, + xScale: 1, + initStr: "", + defPageAttr: 0x1010, + defCrsrAttr: 0x0207, + defCellAttr: 0x0007, + telnet: 1, + autoConnect: 0 + }; + ```` + +8. Update `sysName` and `wsConnect` accordingly. Use `wss://` if you set up the websocket service with SSL, `ws://` +otherwise. + +9. If you navigate to http://your-hostname.here/vtx.html, you should see a splash screen like the following: + ![VTXClient](images/vtxclient.png "VTXClient") + + \ No newline at end of file diff --git a/docs/web_server.md b/docs/web_server.md index 8c341191..1f87cd80 100644 --- a/docs/web_server.md +++ b/docs/web_server.md @@ -1,8 +1,10 @@ # Web Server -ENiGMA½ comes with a built in *content server* for supporting both HTTP and HTTPS. Currently the [File Bases](file_base.md) registers routes for file downloads, and static files can also be served for your BBS. Other features will likely come in the future or you can easily write your own! +ENiGMA½ comes with a built in *content server* for supporting both HTTP and HTTPS. Currently the +[File Bases](file_base.md) registers routes for file downloads, and static files can also be served for your BBS. Other features will likely come in the future or you can easily write your own! ## Configuration -By default the web server is not enabled. To enable it, you will need to at a minimum configure two keys in the `contentServers::web` section of `config.hjson`: +By default the web server is not enabled. To enable it, you will need to at a minimum configure two keys in +the `contentServers::web` section of `config.hjson`: ```hjson contentServers: { @@ -16,12 +18,17 @@ contentServers: { } ``` -This will configure HTTP for port 8080 (override with `port`). To additionally enable HTTPS, you will need a PEM encoded SSL certificate and private key. Once obtained, simply enable the HTTPS server: +This will configure HTTP for port 8080 (override with `port`). To additionally enable HTTPS, you will need a +PEM encoded SSL certificate and private key. [LetsEncrypt](https://letsencrypt.org/) supply free trusted +certificates that work perfectly with ENiGMA½. + +Once obtained, simply enable the HTTPS server: + ```hjson contentServers: { web: { domain: bbs.yourdomain.com - // set 'overrideUrlPrefix' if for example, you use a transparent proxy in front of ENiGMA and need to be explicit about URLs the system hands out + // set 'overrideUrlPrefix' if for example, you use a transparent proxy in front of ENiGMA and need to be explicit about URLs the system hands out overrideUrlPrefix: https://bbs.yourdomain.com https: { enabled: true @@ -37,4 +44,5 @@ contentServers: { Static files live relative to the `contentServers::web::staticRoot` path which defaults to `enigma-bbs/www`. ### Custom Error Pages -Customized error pages can be created for [HTTP error codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_Client_Error) by providing a `.html` file in the *static routes* area. For example: `404.html`. +Customized error pages can be created for [HTTP error codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_Client_Error) +by providing a `.html` file in the *static routes* area. For example: `404.html`. diff --git a/main.js b/main.js index 0bc7bee9..53a320f6 100755 --- a/main.js +++ b/main.js @@ -7,6 +7,6 @@ ENiGMA½ entry point If this file does not run directly, ensure it's executable: - > chmod u+x main.js + > chmod u+x main.js */ require('./core/bbs.js').main(); \ No newline at end of file diff --git a/misc/vtx/vtx.html b/misc/vtx/vtx.html new file mode 100644 index 00000000..156246ed --- /dev/null +++ b/misc/vtx/vtx.html @@ -0,0 +1,27 @@ + + + + + + + + + + +
+
+ +
+
+ + \ No newline at end of file diff --git a/mods/.keep b/mods/.keep new file mode 100644 index 00000000..e69de29b diff --git a/mods/file_base_area_select.js b/mods/file_base_area_select.js deleted file mode 100644 index 5eef583b..00000000 --- a/mods/file_base_area_select.js +++ /dev/null @@ -1,84 +0,0 @@ -/* jslint node: true */ -'use strict'; - -// enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const Config = require('../core/config.js').config; -const stringFormat = require('../core/string_format.js'); -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; - -// deps -const async = require('async'); -const _ = require('lodash'); - -exports.moduleInfo = { - name : 'File Area Selector', - desc : 'Select from available file areas', - author : 'NuSkooler', -}; - -const MciViewIds = { - areaList : 1, -}; - -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, - }; - - const menuOpts = { - extraArgs : { - filterCriteria : filterCriteria, - }, - menuFlags : [ 'noHistory' ], - }; - - return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); - } - }; - } - - loadAvailAreas() { - this.availAreas = getSortedAvailableFileAreas(this.client); - } - - mciReady(mciData, cb) { - super.mciReady(mciData, err => { - if(err) { - return cb(err); - } - - this.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => { - if(err) { - return cb(err); - } - - const areaListView = vc.getView(MciViewIds.areaList); - - const areaListFormat = this.config.areaListFormat || '{name}'; - - areaListView.setItems(this.availAreas.map(a => stringFormat(areaListFormat, a) ) ); - - if(this.config.areaListFocusFormat) { - areaListView.setFocusItems(this.availAreas.map(a => stringFormat(this.config.areaListFocusFormat, a) ) ); - } - - areaListView.redraw(); - - return cb(null); - }); - }); - } -}; diff --git a/mods/themes/luciano_blocktronics/MSGMNU.ANS b/mods/themes/luciano_blocktronics/MSGMNU.ANS deleted file mode 100644 index e27fed73..00000000 Binary files a/mods/themes/luciano_blocktronics/MSGMNU.ANS and /dev/null differ diff --git a/package.json b/package.json index fb04a0f4..678628f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "enigma-bbs", - "version": "0.0.7-alpha", + "version": "0.0.8-alpha", "description": "ENiGMA½ Bulletin Board System", "author": "Bryan Ashby ", "license": "BSD-2-Clause", @@ -22,34 +22,37 @@ "retro" ], "dependencies": { - "async": "^2.4.0", + "async": "^2.5.0", "binary": "0.3.x", "buffers": "NuSkooler/node-buffers", - "bunyan": "^1.8.10", + "bunyan": "^1.8.12", "exiftool": "^0.0.3", - "farmhash": "^1.2.1", - "fs-extra": "^3.0.1", + "fs-extra": "^5.0.0", + "glob": "^7.1.2", "graceful-fs": "^4.1.11", "hashids": "^1.1.1", - "hjson": "^2.4.2", - "iconv-lite": "^0.4.17", - "inquirer": "^3.0.6", + "hjson": "^3.1.0", + "iconv-lite": "^0.4.18", + "inquirer": "^4.0.1", "later": "1.2.0", "lodash": "^4.17.4", - "mime-types": "^2.1.15", + "mime-types": "^2.1.17", "minimist": "1.2.x", - "moment": "^2.18.1", - "node-glob": "^1.2.0", - "nodemailer": "^4.0.1", + "moment": "^2.20.0", + "nodemailer": "^4.4.1", "ptyw.js": "NuSkooler/ptyw.js", + "rlogin": "^1.0.0", "sane": "^2.2.0", "sanitize-filename": "^1.6.1", - "sqlite3": "^3.1.1", + "sqlite3": "^3.1.9", + "sqlite3-trans": "^1.2.0", "ssh2": "^0.5.5", "temptmp": "^1.0.0", - "uuid": "^3.0.1", + "uuid": "^3.1.0", "uuid-parse": "^1.0.0", - "ws": "^3.0.0" + "ws": "^3.3.3", + "xxhash": "^0.2.4", + "yazl": "^2.4.2" }, "devDependencies": {}, "engines": { diff --git a/util/dump_ftn_packet.js b/util/dump_ftn_packet.js new file mode 100755 index 00000000..144d18f3 --- /dev/null +++ b/util/dump_ftn_packet.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const { Packet } = require('../core/ftn_mail_packet.js'); + +const argv = require('minimist')(process.argv.slice(2)); + +function main() { + if(0 === argv._.length) { + console.error('usage: dump_ftn_packet.js PATH'); + process.exitCode = -1; + return; + } + + const packet = new Packet(); + const packetPath = argv._[0]; + + packet.read( + packetPath, + (dataType, data, next) => { + if('header' === dataType) { + console.info('--- header ---'); + console.info(`Created : ${data.created.format('dddd, MMMM Do YYYY, h:mm:ss a')}`); + console.info(`Dst. Addr : ${data.destAddress.toString()}`); + console.info(`Src. Addr : ${data.origAddress.toString()}`); + console.info('--- raw header ---'); + console.info(data); + console.info('--------------'); + console.info(''); + } else if('message' === dataType) { + console.info('--- message ---'); + console.info(`To : ${data.toUserName}`); + console.info(`From : ${data.fromUserName}`); + console.info(`Subject : ${data.subject}`); + console.info('--- raw message ---'); + console.info(data); + console.info('---------------'); + } + + return next(null); + }, + () => { + console.info(''); + console.info('--- EOF --- '); + console.info(''); + } + ); +} + +main(); diff --git a/util/exiftool2desc.js b/util/exiftool2desc.js index 210c800d..4b3b350f 100755 --- a/util/exiftool2desc.js +++ b/util/exiftool2desc.js @@ -43,12 +43,12 @@ function documentFile(metadata) { return; } - let desc = `${metadata.author||'Unknown Author'} - ${metadata.title||'Unknown'}`; - const created = moment(metadata.createdate); - if(created.isValid()) { - desc += ` (${created.format('YYYY')})`; + let result = metadata.author || ''; + if(result) { + result += ' - '; } - return desc; + result += metadata.title || 'Unknown Title'; + return result; } function imageFile(metadata) {