Merge branch 'FILE_BASE' !!!!

This commit is contained in:
Bryan Ashby 2017-02-15 21:48:27 -07:00
commit 0fb147dec8
130 changed files with 11256 additions and 3916 deletions

View File

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

View File

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

View File

@ -6,27 +6,28 @@ ENiGMA½ is a modern BBS software with a nostalgic flair!
## Features Available Now ## Features Available Now
* Multi platform: Anywhere Node.js runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows) * Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows)
* Multi node support * 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
* MCI support for lightbars, toggles, input areas, and so on plus many other other bells and whistles * MCI support for lightbars, toggles, input areas, and so on plus many other other bells and whistles
* Telnet & **SSH** access built in. Additional servers are easy to implement & plug in * Telnet & **SSH** access built in. Additional servers are easy to implement
* [CP437](http://www.ascii-codes.com/) and UTF-8 output * [CP437](http://www.ascii-codes.com/) and UTF-8 output
* [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior * [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior
* [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support * [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support
* Pipe codes (ala Renegade) * Renegade style pipe color codes
* [SQLite](http://sqlite.org/) storage of users and message areas * [SQLite](http://sqlite.org/) storage of users, message areas, and so on
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption
* Door support including common dropfile formats and legacy DOS doors, [BBSLink](http://bbslink.net/), and [DoorParty](http://forums.throwbackbbs.com/)! (See [Doors](docs/doors.md)) * [Door support](docs/doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), and [DoorParty](http://forums.throwbackbbs.com/) support!
* [Bunyan](https://github.com/trentm/node-bunyan) logging * [Bunyan](https://github.com/trentm/node-bunyan) logging
* FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export * [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!
* Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more!
## In the Works ## In the Works
* More ES6+ usage, and **documentation**! * More ES6+ usage, and **documentation**!
* File areas
* More ACS support coverage * More ACS support coverage
* SysOp dashboard (ye ol' WFC) * SysOp dashboard (ye ol' WFC)
* Missing functionality such as searching, message area coloring, etc. * Missing functionality such as message FTS, user coloring of messages in the FST, etc.
* String localization * String localization
* A lot more! Feel free to request features via [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) * A lot more! Feel free to request features via [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues)
@ -37,11 +38,11 @@ See [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) for more
## Support ## Support
* Use [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) * Use [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues)
* **Discussion on a ENiGMA BBS!** * **Discussion on a ENiGMA BBS!** (see Boards below)
* IRC: **#enigma-bbs** on **chat.freenode.net** * IRC: **#enigma-bbs** on **chat.freenode.net**
* Email: bryan -at- l33t.codes * Email: bryan -at- l33t.codes
* Facebook ENiGMA½ group * [Facebook ENiGMA½ group](https://www.facebook.com/groups/enigmabbs/)
* ENiGMA discussion on [HappyNet](http://andrew.homeunix.org/doku.php?id=happynet) * ENiGMA discussion on [fsxNet](http://bbs.geek.nz/#fsxNet)
## Terminal Clients ## Terminal Clients
ENiGMA has been tested with many terminals. However, the following are suggested for BBSing: ENiGMA has been tested with many terminals. However, the following are suggested for BBSing:
@ -66,17 +67,17 @@ curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/inst
* [M. Brutman](http://www.brutman.com/), author of [mTCP](http://www.brutman.com/mTCP/mTCP.html) (Interwebs for DOS!) * [M. Brutman](http://www.brutman.com/), author of [mTCP](http://www.brutman.com/mTCP/mTCP.html) (Interwebs for DOS!)
* [M. Griffin](https://github.com/M-griffin), author of [Enthral BBS](https://github.com/M-griffin/Enthral), [Oblivion/2 XRM](https://github.com/M-griffin/Oblivion2-XRM) and [EtherTerm](https://github.com/M-griffin/EtherTerm)! * [M. Griffin](https://github.com/M-griffin), author of [Enthral BBS](https://github.com/M-griffin/Enthral), [Oblivion/2 XRM](https://github.com/M-griffin/Oblivion2-XRM) and [EtherTerm](https://github.com/M-griffin/EtherTerm)!
* [Caphood](http://www.reddit.com/user/Caphood), supreme SysOp of [BLACK ƒlag](http://www.bbsnexus.com/directory/listing/blackflag.html) BBS * [Caphood](http://www.reddit.com/user/Caphood), supreme SysOp of [BLACK ƒlag](http://www.bbsnexus.com/directory/listing/blackflag.html) BBS
* Luciano Ayres of [Blocktronics](http://blocktronics.org/), creator of the "Mystery Skulls" default ENiGMA½ theme! * [Luciano Ayres](http://www.lucianoayres.com.br/) of [Blocktronics](http://blocktronics.org/), creator of the "Mystery Skulls" default ENiGMA½ theme!
* Sudndeath for Xibalba ANSI work! * Sudndeath for Xibalba ANSI work!
* Jack Phlash for kick ass ENiGMA½ and Xibalba ASCII (Check out [IMPURE60](http://pc.textmod.es/pack/impure60/)!!) * Jack Phlash for kick ass ENiGMA½ and Xibalba ASCII (Check out [IMPURE60](http://pc.textmod.es/pack/impure60/)!!)
* Avon of [Agency BBS](http://bbs.geek.nz/) and fsxNet * Avon of [Agency BBS](http://bbs.geek.nz/) and [fsxNet](http://bbs.geek.nz/#fsxNet)
* Maskreet of [Throwback BBS](http://www.throwbackbbs.com/) hosting [DoorParty](http://forums.throwbackbbs.com/)! * Maskreet of [Throwback BBS](http://www.throwbackbbs.com/) hosting [DoorParty](http://forums.throwbackbbs.com/)!
* [Apam](https://github.com/apamment) of HappyLand BBS and [HappyNet](http://andrew.homeunix.org/doku.php?id=happynet)! * [Apam](https://github.com/apamment) of HappyLand BBS and [HappyNet](http://andrew.homeunix.org/doku.php?id=happynet)!
## License ## License
Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license: Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license:
Copyright (c) 2015-2016, Bryan D. Ashby Copyright (c) 2015-2017, Bryan D. Ashby
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

65
UPGRADE.md Normal file
View File

@ -0,0 +1,65 @@
# 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).

View File

@ -25,6 +25,9 @@ class ACS {
} }
} }
//
// Message Conferences & Areas
//
hasMessageConfRead(conf) { hasMessageConfRead(conf) {
return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead); return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead);
} }
@ -33,6 +36,21 @@ class ACS {
return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead); return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead);
} }
//
// File Base / Areas
//
hasFileAreaRead(area) {
return this.check(area.acs, 'read', ACS.Defaults.FileAreaRead);
}
hasFileAreaWrite(area) {
return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite);
}
hasFileAreaDownload(area) {
return this.check(area.acs, 'download', ACS.Defaults.FileAreaDownload);
}
getConditionalValue(condArray, memberName) { getConditionalValue(condArray, memberName) {
assert(_.isArray(condArray)); assert(_.isArray(condArray));
assert(_.isString(memberName)); assert(_.isString(memberName));
@ -59,6 +77,10 @@ class ACS {
ACS.Defaults = { ACS.Defaults = {
MessageAreaRead : 'GM[users]', MessageAreaRead : 'GM[users]',
MessageConfRead : 'GM[users]', MessageConfRead : 'GM[users]',
FileAreaRead : 'GM[users]',
FileAreaWrite : 'GM[sysops]',
FileAreaDownload : 'GM[users]',
}; };
module.exports = ACS; module.exports = ACS;

View File

@ -48,8 +48,10 @@ function ANSIEscapeParser(options) {
self.row = Math.max(self.row, 1); self.row = Math.max(self.row, 1);
self.row = Math.min(self.row, self.termHeight); self.row = Math.min(self.row, self.termHeight);
self.emit('move cursor', self.column, self.row); // self.emit('move cursor', self.column, self.row);
self.rowUpdated();
self.positionUpdated();
//self.rowUpdated();
}; };
self.saveCursorPosition = function() { self.saveCursorPosition = function() {
@ -63,7 +65,9 @@ function ANSIEscapeParser(options) {
self.row = self.savedPosition.row; self.row = self.savedPosition.row;
self.column = self.savedPosition.column; self.column = self.savedPosition.column;
delete self.savedPosition; delete self.savedPosition;
self.rowUpdated();
self.positionUpdated();
// self.rowUpdated();
}; };
self.clearScreen = function() { self.clearScreen = function() {
@ -71,11 +75,76 @@ function ANSIEscapeParser(options) {
self.emit('clear screen'); self.emit('clear screen');
}; };
/*
self.rowUpdated = function() { self.rowUpdated = function() {
self.emit('row update', self.row + self.scrollBack); self.emit('row update', self.row + self.scrollBack);
};*/
self.positionUpdated = function() {
self.emit('position update', self.row, self.column);
}; };
function literal(text) { function literal(text) {
let charCode;
let pos;
let start = 0;
const len = text.length;
function emitLiteral() {
self.emit('literal', text.slice(start, pos));
start = pos;
}
for(pos = 0; pos < len; ++pos) {
charCode = text.charCodeAt(pos) & 0xff; // ensure 8bit clean
switch(charCode) {
case CR :
emitLiteral();
self.column = 1;
self.positionUpdated();
break;
case LF :
emitLiteral();
self.row += 1;
self.positionUpdated();
break;
default :
if(self.column > self.termWidth) {
//
// Emit data up to this point so it can be drawn before the postion update
//
emitLiteral();
self.column = 1;
self.row += 1;
self.positionUpdated();
} else {
self.column += 1;
}
break;
}
}
self.emit('literal', text.slice(start));
if(self.column > self.termWidth) {
self.column = 1;
self.row += 1;
self.positionUpdated();
}
}
function literal2(text) {
var charCode; var charCode;
var len = text.length; var len = text.length;
@ -88,29 +157,31 @@ function ANSIEscapeParser(options) {
case LF : case LF :
self.row++; self.row++;
self.rowUpdated(); self.positionUpdated();
//self.rowUpdated();
break; break;
default : default :
// wrap // wrap
if(self.column === self.termWidth) { if(self.column > self.termWidth) {
self.column = 1; self.column = 1;
self.row++; self.row++;
self.rowUpdated(); //self.rowUpdated();
self.positionUpdated();
} else { } else {
self.column++; self.column += 1;
} }
break; break;
} }
if(self.row === 26) { // :TODO: should be termHeight + 1 ? if(self.row === self.termHeight) {
self.scrollBack++; self.scrollBack += 1;
self.row--; self.row -= 1;
self.rowUpdated();
self.positionUpdated();
} }
} }
//self.emit('chunk', text);
self.emit('literal', text); self.emit('literal', text);
} }
@ -155,7 +226,7 @@ function ANSIEscapeParser(options) {
self.lastMciCode = fullMciCode; self.lastMciCode = fullMciCode;
self.graphicRenditionForErase = _.clone(self.graphicRendition, true); self.graphicRenditionForErase = _.clone(self.graphicRendition);
} }
@ -188,10 +259,10 @@ function ANSIEscapeParser(options) {
} }
} }
self.reset = function(buffer) { self.reset = function(input) {
self.parseState = { self.parseState = {
// ignore anything past EOF marker, if any // ignore anything past EOF marker, if any
buffer : buffer.split(String.fromCharCode(0x1a), 1)[0], buffer : input.split(String.fromCharCode(0x1a), 1)[0],
re : /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g, re : /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g,
stop : false, stop : false,
}; };
@ -201,7 +272,11 @@ function ANSIEscapeParser(options) {
self.parseState.stop = true; self.parseState.stop = true;
}; };
self.parse = function() { self.parse = function(input) {
if(input) {
self.reset(input);
}
// :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc. // :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc.
var pos; var pos;
var match; var match;
@ -308,40 +383,45 @@ function ANSIEscapeParser(options) {
*/ */
function escape(opCode, args) { function escape(opCode, args) {
var arg; let arg;
var i;
var len;
switch(opCode) { switch(opCode) {
// cursor up // cursor up
case 'A' : case 'A' :
arg = args[0] || 1; //arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(0, -arg); self.moveCursor(0, -arg);
break; break;
// cursor down // cursor down
case 'B' : case 'B' :
arg = args[0] || 1; //arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(0, arg); self.moveCursor(0, arg);
break; break;
// cursor forward/right // cursor forward/right
case 'C' : case 'C' :
arg = args[0] || 1; //arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(arg, 0); self.moveCursor(arg, 0);
break; break;
// cursor back/left // cursor back/left
case 'D' : case 'D' :
arg = args[0] || 1; //arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(-arg, 0); self.moveCursor(-arg, 0);
break; break;
case 'f' : // horiz & vertical case 'f' : // horiz & vertical
case 'H' : // cursor position case 'H' : // cursor position
self.row = args[0] || 1; //self.row = args[0] || 1;
self.column = args[1] || 1; //self.column = args[1] || 1;
self.rowUpdated(); self.row = isNaN(args[0]) ? 1 : args[0];
self.column = isNaN(args[1]) ? 1 : args[1];
//self.rowUpdated();
self.positionUpdated();
break; break;
// save position // save position
@ -356,7 +436,7 @@ function ANSIEscapeParser(options) {
// set graphic rendition // set graphic rendition
case 'm' : case 'm' :
for(i = 0, len = args.length; i < len; ++i) { for(let i = 0, len = args.length; i < len; ++i) {
arg = args[i]; arg = args[i];
if(ANSIEscapeParser.foregroundColors[arg]) { if(ANSIEscapeParser.foregroundColors[arg]) {
@ -410,12 +490,13 @@ function ANSIEscapeParser(options) {
} }
} }
} }
break; // m
break; // :TODO: s, u, K
// erase display/screen // erase display/screen
case 'J' : case 'J' :
// :TODO: Handle others // :TODO: Handle other 'J' types!
if(2 === args[0]) { if(2 === args[0]) {
self.clearScreen(); self.clearScreen();
} }

View File

@ -4,12 +4,44 @@
// ENiGMA½ // ENiGMA½
const Config = require('./config.js').config; const Config = require('./config.js').config;
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const Errors = require('./enig_error.js').Errors;
// base/modules // base/modules
const fs = require('fs'); const fs = require('fs');
const _ = require('lodash'); const _ = require('lodash');
const pty = require('ptyw.js'); const pty = require('ptyw.js');
let archiveUtil;
class Archiver {
constructor(config) {
this.compress = config.compress;
this.decompress = config.decompress;
this.list = config.list;
this.extract = config.extract;
/*this.sig = new Buffer(config.sig, 'hex');
this.offset = config.offset || 0;*/
}
ok() {
return this.canCompress() && this.canDecompress();
}
can(what) {
if(!_.has(this, [ what, 'cmd' ]) || !_.has(this, [ what, 'args' ])) {
return false;
}
return _.isString(this[what].cmd) && Array.isArray(this[what].args) && this[what].args.length > 0;
}
canCompress() { return this.can('compress'); }
canDecompress() { return this.can('decompress'); }
canList() { return this.can('list'); } // :TODO: validate entryMatch
canExtract() { return this.can('extract'); }
}
module.exports = class ArchiveUtil { module.exports = class ArchiveUtil {
constructor() { constructor() {
@ -17,95 +49,120 @@ module.exports = class ArchiveUtil {
this.longestSignature = 0; this.longestSignature = 0;
} }
// singleton access
static getInstance() {
if(!archiveUtil) {
archiveUtil = new ArchiveUtil();
archiveUtil.init();
}
return archiveUtil;
}
init() { init() {
// //
// Load configuration // Load configuration
// //
if(_.has(Config, 'archivers')) { if(_.has(Config, 'archives.archivers')) {
Object.keys(Config.archivers).forEach(archKey => { Object.keys(Config.archives.archivers).forEach(archKey => {
const arch = Config.archivers[archKey];
if(!_.isString(arch.sig) || const archConfig = Config.archives.archivers[archKey];
!_.isString(arch.compressCmd) || const archiver = new Archiver(archConfig);
!_.isString(arch.decompressCmd) ||
!_.isArray(arch.compressArgs) || if(!archiver.ok()) {
!_.isArray(arch.decompressArgs)) // :TODO: Log warning - bad archiver/config
{
// :TODO: log warning
return;
} }
const archiver = {
compressCmd : arch.compressCmd,
compressArgs : arch.compressArgs,
decompressCmd : arch.decompressCmd,
decompressArgs : arch.decompressArgs,
sig : new Buffer(arch.sig, 'hex'),
offset : arch.offset || 0,
};
this.archivers[archKey] = archiver; this.archivers[archKey] = archiver;
});
if(archiver.offset + archiver.sig.length > this.longestSignature) { }
this.longestSignature = archiver.offset + archiver.sig.length;
} if(_.has(Config, 'archives.formats')) {
Object.keys(Config.archives.formats).forEach(fmtKey => {
Config.archives.formats[fmtKey].sig = new Buffer(Config.archives.formats[fmtKey].sig, 'hex');
Config.archives.formats[fmtKey].offset = Config.archives.formats[fmtKey].offset || 0;
const sigLen = Config.archives.formats[fmtKey].offset + Config.archives.formats[fmtKey].sig.length;
if(sigLen > this.longestSignature) {
this.longestSignature = sigLen;
}
}); });
} }
} }
/*
getArchiver(archType) { getArchiver(archType) {
if(!archType) { if(!archType || 0 === archType.length) {
return; return;
} }
archType = archType.toLowerCase(); archType = archType.toLowerCase();
return this.archivers[archType]; return this.archivers[archType];
}*/
getArchiver(archType) {
if(!archType || 0 === archType.length) {
return;
}
if(_.has(Config, [ 'archives', 'formats', archType, 'handler' ] ) &&
_.has(Config, [ 'archives', 'archivers', Config.archives.formats[archType].handler ] ))
{
return Config.archives.archivers[ Config.archives.formats[archType].handler ];
}
} }
haveArchiver(archType) { haveArchiver(archType) {
return this.getArchiver(archType) ? true : false; return this.getArchiver(archType) ? true : false;
} }
detectTypeWithBuf(buf, cb) {
// :TODO: implement me!
}
detectType(path, cb) { detectType(path, cb) {
if(!_.has(Config, 'archives.formats')) {
return cb(Errors.DoesNotExist('No formats configured'));
}
fs.open(path, 'r', (err, fd) => { fs.open(path, 'r', (err, fd) => {
if(err) { if(err) {
cb(err); return cb(err);
return;
} }
let buf = new Buffer(this.longestSignature); const buf = new Buffer(this.longestSignature);
fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => { fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
// return first match const archFormat = _.findKey(Config.archives.formats, archFormat => {
const detected = _.findKey(this.archivers, arch => { const lenNeeded = archFormat.offset + archFormat.sig.length;
const lenNeeded = arch.offset + arch.sig.length;
if(bytesRead < lenNeeded) { if(bytesRead < lenNeeded) {
return false; return false;
} }
const comp = buf.slice(arch.offset, arch.offset + arch.sig.length); const comp = buf.slice(archFormat.offset, archFormat.offset + archFormat.sig.length);
return (arch.sig.equals(comp)); return (archFormat.sig.equals(comp));
}); });
cb(detected ? null : new Error('Unknown type'), detected); return cb(archFormat ? null : Errors.General('Unknown type'), archFormat);
}); });
}); });
} }
spawnHandler(comp, action, cb) { spawnHandler(proc, action, cb) {
// pty.js doesn't currently give us a error when things fail, // pty.js doesn't currently give us a error when things fail,
// so we have this horrible, horrible hack: // so we have this horrible, horrible hack:
let err; let err;
comp.once('data', d => { proc.once('data', d => {
if(_.isString(d) && d.startsWith('execvp(3) failed.: No such file or directory')) { if(_.isString(d) && d.startsWith('execvp(3) failed.: No such file or directory')) {
err = new Error(`${action} failed: ${d.trim()}`); err = new Error(`${action} failed: ${d.trim()}`);
} }
}); });
comp.once('exit', exitCode => { proc.once('exit', exitCode => {
if(exitCode) { if(exitCode) {
return cb(new Error(`${action} failed with exit code: ${exitCode}`)); return cb(new Error(`${action} failed with exit code: ${exitCode}`));
} }
@ -123,37 +180,97 @@ module.exports = class ArchiveUtil {
return cb(new Error(`Unknown archive type: ${archType}`)); return cb(new Error(`Unknown archive type: ${archType}`));
} }
let args = _.clone(archiver.compressArgs); // don't muck with orig const fmtObj = {
for(let i = 0; i < args.length; ++i) { archivePath : archivePath,
args[i] = stringFormat(args[i], { fileList : files.join(' '), // :TODO: probably need same hack as extractTo here!
archivePath : archivePath, };
fileList : files.join(' '),
});
}
let comp = pty.spawn(archiver.compressCmd, args, this.getPtyOpts()); const args = archiver.compress.args.map( arg => stringFormat(arg, fmtObj) );
const proc = pty.spawn(archiver.compress.cmd, args, this.getPtyOpts());
return this.spawnHandler(comp, 'Compression', cb); return this.spawnHandler(proc, 'Compression', cb);
} }
extractTo(archivePath, extractPath, archType, cb) { extractTo(archivePath, extractPath, archType, fileList, cb) {
let haveFileList;
if(!cb && _.isFunction(fileList)) {
cb = fileList;
fileList = [];
haveFileList = false;
} else {
haveFileList = true;
}
const archiver = this.getArchiver(archType); const archiver = this.getArchiver(archType);
if(!archiver) { if(!archiver) {
return cb(new Error(`Unknown archive type: ${archType}`)); return cb(new Error(`Unknown archive type: ${archType}`));
} }
let args = _.clone(archiver.decompressArgs); // don't muck with orig
for(let i = 0; i < args.length; ++i) {
args[i] = stringFormat(args[i], {
archivePath : archivePath,
extractPath : extractPath,
});
}
let comp = pty.spawn(archiver.decompressCmd, args, this.getPtyOpts());
return this.spawnHandler(comp, 'Decompression', cb); const fmtObj = {
archivePath : archivePath,
extractPath : extractPath,
};
const action = haveFileList ? 'extract' : 'decompress';
// we need to treat {fileList} special in that it should be broken up to 0:n args
const args = archiver[action].args.map( arg => {
return '{fileList}' === arg ? arg : stringFormat(arg, fmtObj);
});
const fileListPos = args.indexOf('{fileList}');
if(fileListPos > -1) {
// replace {fileList} with 0:n sep file list arguments
args.splice.apply(args, [fileListPos, 1].concat(fileList));
}
const proc = pty.spawn(archiver[action].cmd, args, this.getPtyOpts());
return this.spawnHandler(proc, (haveFileList ? 'Extraction' : 'Decompression'), cb);
}
listEntries(archivePath, archType, cb) {
const archiver = this.getArchiver(archType);
if(!archiver) {
return cb(new Error(`Unknown archive type: ${archType}`));
}
const fmtObj = {
archivePath : archivePath,
};
const args = archiver.list.args.map( arg => stringFormat(arg, fmtObj) );
const proc = pty.spawn(archiver.list.cmd, args, this.getPtyOpts());
let output = '';
proc.on('data', data => {
// :TODO: hack for: execvp(3) failed.: No such file or directory
output += data;
});
proc.once('exit', exitCode => {
if(exitCode) {
return cb(new Error(`List failed with exit code: ${exitCode}`));
}
const entryGroupOrder = archiver.list.entryGroupOrder || { byteSize : 1, fileName : 2 };
const entries = [];
const entryMatchRe = new RegExp(archiver.list.entryMatch, 'gm');
let m;
while((m = entryMatchRe.exec(output))) {
entries.push({
byteSize : parseInt(m[entryGroupOrder.byteSize]),
fileName : m[entryGroupOrder.fileName],
});
}
return cb(null, entries);
});
} }
getPtyOpts() { getPtyOpts() {

View File

@ -7,7 +7,6 @@ const miscUtil = require('./misc_util.js');
const ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
const aep = require('./ansi_escape_parser.js'); const aep = require('./ansi_escape_parser.js');
const sauce = require('./sauce.js'); const sauce = require('./sauce.js');
const farmhash = require('farmhash');
// deps // deps
const fs = require('fs'); const fs = require('fs');
@ -15,6 +14,7 @@ const paths = require('path');
const assert = require('assert'); const assert = require('assert');
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
const _ = require('lodash'); const _ = require('lodash');
const farmhash = require('farmhash');
exports.getArt = getArt; exports.getArt = getArt;
exports.getArtFromPath = getArtFromPath; exports.getArtFromPath = getArtFromPath;
@ -78,7 +78,7 @@ function getArtFromPath(path, options, cb) {
return iconv.decode(data, encoding); return iconv.decode(data, encoding);
} else { } else {
const eofMarker = defaultEofFromExtension(ext); const eofMarker = defaultEofFromExtension(ext);
return iconv.decode(sliceAtEOF(data, eofMarker), encoding); return iconv.decode(eofMarker ? sliceAtEOF(data, eofMarker) : data, encoding);
} }
} }
@ -213,11 +213,15 @@ function getArt(name, options, cb) {
} }
function defaultEncodingFromExtension(ext) { function defaultEncodingFromExtension(ext) {
return SUPPORTED_ART_TYPES[ext.toLowerCase()].defaultEncoding; const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()];
return artType ? artType.defaultEncoding : 'utf8';
} }
function defaultEofFromExtension(ext) { function defaultEofFromExtension(ext) {
return SUPPORTED_ART_TYPES[ext.toLowerCase()].eof; const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()];
if(artType) {
return artType.eof;
}
} }
// :TODO: Implement the following // :TODO: Implement the following
@ -266,7 +270,7 @@ function display(client, art, options, cb) {
if(!options.disableMciCache && !mciMapFromCache) { if(!options.disableMciCache && !mciMapFromCache) {
// cache our MCI findings... // cache our MCI findings...
client.mciCache[artHash] = mciMap; client.mciCache[artHash] = mciMap;
client.log.trace( { artHash : artHash, mciMap : mciMap }, 'Added MCI map to cache'); client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Added MCI map to cache');
} }
ansiParser.removeAllListeners(); // :TODO: Necessary??? ansiParser.removeAllListeners(); // :TODO: Necessary???
@ -290,7 +294,7 @@ function display(client, art, options, cb) {
if(mciMap) { if(mciMap) {
mciMapFromCache = true; mciMapFromCache = true;
client.log.trace( { artHash : artHash, mciMap : mciMap }, 'Loaded MCI map from cache'); client.log.trace( { artHash : artHash.toString(16), mciMap : mciMap }, 'Loaded MCI map from cache');
} else { } else {
// no cached MCI info // no cached MCI info
mciMap = {}; mciMap = {};

View File

@ -10,12 +10,15 @@ const conf = require('./config.js');
const logger = require('./logger.js'); const logger = require('./logger.js');
const database = require('./database.js'); const database = require('./database.js');
const clientConns = require('./client_connections.js'); const clientConns = require('./client_connections.js');
const resolvePath = require('./misc_util.js').resolvePath;
// deps // deps
const async = require('async'); const async = require('async');
const util = require('util'); const util = require('util');
const _ = require('lodash'); const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs; const mkdirs = require('fs-extra').mkdirs;
const fs = require('fs');
const paths = require('path');
// our main entry point // our main entry point
exports.bbsMain = bbsMain; exports.bbsMain = bbsMain;
@ -23,30 +26,38 @@ exports.bbsMain = bbsMain;
// object with various services we want to de-init/shutdown cleanly if possible // object with various services we want to de-init/shutdown cleanly if possible
const initServices = {}; const initServices = {};
const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby';
const HELP =
`${ENIGMA_COPYRIGHT}
usage: main.js <args>
valid args:
--version : display version
--help : displays this help
--config PATH : override default config.hjson path
`;
function printHelpAndExit() {
console.info(HELP);
process.exit();
}
function bbsMain() { function bbsMain() {
async.waterfall( async.waterfall(
[ [
function processArgs(callback) { function processArgs(callback) {
const args = process.argv.slice(2); const argv = require('minimist')(process.argv.slice(2));
var configPath; if(argv.help) {
printHelpAndExit();
if(args.indexOf('--help') > 0) {
// :TODO: display help
} else {
let argCount = args.length;
for(let i = 0; i < argCount; ++i) {
const arg = args[i];
if('--config' === arg) {
configPath = args[i + 1];
}
}
} }
callback(null, configPath || conf.getDefaultPath(), _.isString(configPath)); const configOverridePath = argv.config;
return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath));
}, },
function initConfig(configPath, configPathSupplied, callback) { function initConfig(configPath, configPathSupplied, callback) {
conf.init(configPath, function configInit(err) { conf.init(resolvePath(configPath), function configInit(err) {
// //
// If the user supplied a path and we can't read/parse it // If the user supplied a path and we can't read/parse it
@ -71,14 +82,20 @@ function bbsMain() {
if(err) { if(err) {
console.error('Error initializing: ' + util.inspect(err)); console.error('Error initializing: ' + util.inspect(err));
} }
callback(err); return callback(err);
}); });
}, },
function listenConnections(callback) {
startListening(callback);
}
], ],
function complete(err) { function complete(err) {
// note this is escaped:
fs.readFile(paths.join(__dirname, '../misc/startup_banner.asc'), 'utf8', (err, banner) => {
console.info(ENIGMA_COPYRIGHT);
if(!err) {
console.info(banner);
}
console.info('System started!');
});
if(err) { if(err) {
console.error('Error initializing: ' + util.inspect(err)); console.error('Error initializing: ' + util.inspect(err));
} }
@ -87,7 +104,9 @@ function bbsMain() {
} }
function shutdownSystem() { function shutdownSystem() {
logger.log.info('Process interrupted, shutting down...'); const msg = 'Process interrupted. Shutting down...';
console.info(msg);
logger.log.info(msg);
async.series( async.series(
[ [
@ -100,21 +119,32 @@ function shutdownSystem() {
} }
callback(null); callback(null);
}, },
function stopListeningServers(callback) {
return require('./listening_server.js').shutdown( () => {
return callback(null); // ignore err
});
},
function stopEventScheduler(callback) { function stopEventScheduler(callback) {
if(initServices.eventScheduler) { if(initServices.eventScheduler) {
return initServices.eventScheduler.shutdown( () => { return initServices.eventScheduler.shutdown( () => {
callback(null); // ignore err return callback(null); // ignore err
}); });
} else { } else {
return callback(null); return callback(null);
} }
}, },
function stopFileAreaWeb(callback) {
require('./file_area_web.js').startup( () => {
return callback(null); // ignore err
});
},
function stopMsgNetwork(callback) { function stopMsgNetwork(callback) {
require('./msg_network.js').shutdown(callback); require('./msg_network.js').shutdown(callback);
} }
], ],
() => { () => {
process.exit(); console.info('Goodbye!');
return process.exit();
} }
); );
} }
@ -208,6 +238,12 @@ function initialize(cb) {
function readyMessageNetworkSupport(callback) { function readyMessageNetworkSupport(callback) {
return require('./msg_network.js').startup(callback); return require('./msg_network.js').startup(callback);
}, },
function listenConnections(callback) {
return require('./listening_server.js').startup(callback);
},
function readyFileAreaWeb(callback) {
return require('./file_area_web.js').startup(callback);
},
function readyEventScheduler(callback) { function readyEventScheduler(callback) {
const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule; const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule;
EventSchedulerModule.loadAndStart( (err, modInst) => { EventSchedulerModule.loadAndStart( (err, modInst) => {
@ -221,118 +257,3 @@ function initialize(cb) {
} }
); );
} }
function startListening(cb) {
if(!conf.config.loginServers) {
// :TODO: Log error ... output to stderr as well. We can do it all with the logger
return cb(new Error('No login servers configured'));
}
const moduleUtil = require('./module_util.js'); // late load so we get Config
moduleUtil.loadModulesForCategory('loginServers', (err, module) => {
if(err) {
if('EENIGMODDISABLED' === err.code) {
logger.log.debug(err.message);
} else {
logger.log.info( { err : err }, 'Failed loading module');
}
return;
}
const port = parseInt(module.runtime.config.port);
if(isNaN(port)) {
logger.log.error( { port : module.runtime.config.port, server : module.moduleInfo.name }, 'Cannot load server (Invalid port)');
return;
}
const moduleInst = new module.getModule();
let server;
try {
server = moduleInst.createServer();
} catch(e) {
logger.log.warn(e, 'Exception caught creating server!');
return;
}
// :TODO: handle maxConnections, e.g. conf.maxConnections
server.on('client', function newClient(client, clientSock) {
//
// Start tracking the client. We'll assign it an ID which is
// just the index in our connections array.
//
if(_.isUndefined(client.session)) {
client.session = {};
}
client.session.serverName = module.moduleInfo.name;
client.session.isSecure = module.moduleInfo.isSecure || false;
clientConns.addNewClient(client, clientSock);
client.on('ready', function clientReady(readyOptions) {
client.startIdleMonitor();
// Go to module -- use default error handler
prepareClient(client, function clientPrepared() {
require('./connect.js').connectEntry(client, readyOptions.firstMenu);
});
});
client.on('end', function onClientEnd() {
clientConns.removeClient(client);
});
client.on('error', function onClientError(err) {
logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message);
});
client.on('close', function onClientClose(hadError) {
const logFunc = hadError ? logger.log.info : logger.log.debug;
logFunc( { clientId : client.session.id }, 'Connection closed');
clientConns.removeClient(client);
});
client.on('idle timeout', function idleTimeout() {
client.log.info('User idle timeout expired');
client.menuStack.goto('idleLogoff', function goMenuRes(err) {
if(err) {
// likely just doesn't exist
client.term.write('\nIdle timeout expired. Goodbye!\n');
client.end();
}
});
});
});
server.on('error', function serverErr(err) {
logger.log.info(err); // 'close' should be handled after
});
server.listen(port);
logger.log.info(
{ server : module.moduleInfo.name, port : port }, 'Listening for connections');
}, err => {
cb(err);
});
}
function prepareClient(client, cb) {
const theme = require('./theme.js');
// :TODO: it feels like this should go somewhere else... and be a bit more elegant.
if('*' === conf.config.preLoginTheme) {
client.user.properties.theme_id = theme.getRandomTheme() || '';
} else {
client.user.properties.theme_id = conf.config.preLoginTheme;
}
theme.setClientTheme(client, client.user.properties.theme_id);
return cb(null); // note: currently useless to use cb here - but this may change...again...
}

View File

@ -1,10 +1,9 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var TextView = require('./text_view.js').TextView; const TextView = require('./text_view.js').TextView;
var miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
var util = require('util'); const util = require('util');
var assert = require('assert');
exports.ButtonView = ButtonView; exports.ButtonView = ButtonView;
@ -20,7 +19,8 @@ function ButtonView(options) {
util.inherits(ButtonView, TextView); util.inherits(ButtonView, TextView);
ButtonView.prototype.onKeyPress = function(ch, key) { ButtonView.prototype.onKeyPress = function(ch, key) {
if(' ' === ch) { // allow space = submit
if(' ' === ch) {
this.emit('action', 'accept'); this.emit('action', 'accept');
} }

View File

@ -12,6 +12,7 @@ exports.getActiveConnections = getActiveConnections;
exports.getActiveNodeList = getActiveNodeList; exports.getActiveNodeList = getActiveNodeList;
exports.addNewClient = addNewClient; exports.addNewClient = addNewClient;
exports.removeClient = removeClient; exports.removeClient = removeClient;
exports.getConnectionByUserId = getConnectionByUserId;
const clientConnections = []; const clientConnections = [];
exports.clientConnections = clientConnections; exports.clientConnections = clientConnections;
@ -93,3 +94,7 @@ function removeClient(client) {
); );
} }
} }
function getConnectionByUserId(userId) {
return getActiveConnections().find( ac => userId === ac.user.userId );
}

30
core/conf_area_util.js Normal file
View File

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

View File

@ -1,17 +1,19 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var miscUtil = require('./misc_util.js'); // ENiGMA½
const miscUtil = require('./misc_util.js');
var fs = require('fs'); // deps
var paths = require('path'); const fs = require('fs');
var async = require('async'); const paths = require('path');
var _ = require('lodash'); const async = require('async');
var hjson = require('hjson'); const _ = require('lodash');
var assert = require('assert'); const hjson = require('hjson');
const assert = require('assert');
exports.init = init; exports.init = init;
exports.getDefaultPath = getDefaultPath; exports.getDefaultPath = getDefaultPath;
function hasMessageConferenceAndArea(config) { function hasMessageConferenceAndArea(config) {
assert(_.isObject(config.messageConferences)); // we create one ourself! assert(_.isObject(config.messageConferences)); // we create one ourself!
@ -43,38 +45,45 @@ function init(configPath, cb) {
async.waterfall( async.waterfall(
[ [
function loadUserConfig(callback) { function loadUserConfig(callback) {
if(_.isString(configPath)) { if(!_.isString(configPath)) {
fs.readFile(configPath, { encoding : 'utf8' }, function configData(err, data) { return callback(null, { } );
if(err) {
callback(err);
} else {
try {
var configJson = hjson.parse(data);
callback(null, configJson);
} catch(e) {
callback(e);
}
}
});
} else {
callback(null, { } );
} }
fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => {
if(err) {
return callback(err);
}
let configJson;
try {
configJson = hjson.parse(configData);
} catch(e) {
return callback(e);
}
return callback(null, configJson);
});
}, },
function mergeWithDefaultConfig(configJson, callback) { function mergeWithDefaultConfig(configJson, callback) {
var mergedConfig = _.merge(getDefaultConfig(), configJson, function mergeCustomizer(conf1, conf2) {
// Arrays should always concat const mergedConfig = _.mergeWith(
if(_.isArray(conf1)) { getDefaultConfig(),
// :TODO: look for collisions & override dupes configJson, (conf1, conf2) => {
return conf1.concat(conf2); // Arrays should always concat
if(_.isArray(conf1)) {
// :TODO: look for collisions & override dupes
return conf1.concat(conf2);
}
} }
}); );
callback(null, mergedConfig); return callback(null, mergedConfig);
}, },
function validate(mergedConfig, callback) { function validate(mergedConfig, callback) {
// //
// Various sections must now exist in config // Various sections must now exist in config
// //
// :TODO: Logic is broken here:
if(hasMessageConferenceAndArea(mergedConfig)) { if(hasMessageConferenceAndArea(mergedConfig)) {
var msgAreasErr = new Error('Please create at least one message conference and area!'); var msgAreasErr = new Error('Please create at least one message conference and area!');
msgAreasErr.code = 'EBADCONFIG'; msgAreasErr.code = 'EBADCONFIG';
@ -92,7 +101,7 @@ function init(configPath, cb) {
} }
function getDefaultPath() { function getDefaultPath() {
var base = miscUtil.resolvePath('~/'); const base = miscUtil.resolvePath('~/');
if(base) { if(base) {
// e.g. /home/users/joeuser/.config/enigma-bbs/config.hjson // e.g. /home/users/joeuser/.config/enigma-bbs/config.hjson
return paths.join(base, '.config', 'enigma-bbs', 'config.hjson'); return paths.join(base, '.config', 'enigma-bbs', 'config.hjson');
@ -211,17 +220,212 @@ function getDefaultConfig() {
} }
}, },
archivers : { contentServers : {
zip : { web : {
sig : '504b0304', domain : 'another-fine-enigma-bbs.org',
offset : 0,
compressCmd : '7z', staticRoot : paths.join(__dirname, './../www'),
compressArgs : [ 'a', '-tzip', '{archivePath}', '{fileList}' ],
decompressCmd : '7z', http : {
decompressArgs : [ 'e', '-o{extractPath}', '{archivePath}' ] enabled : false,
port : 8080,
},
https : {
enabled : false,
port : 8443,
certPem : paths.join(__dirname, './../misc/https_cert.pem'),
keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'),
}
}
},
archives : {
archivers : {
'7Zip' : {
compress : {
cmd : '7za',
args : [ 'a', '-tzip', '{archivePath}', '{fileList}' ],
},
decompress : {
cmd : '7za',
args : [ 'e', '-o{extractPath}', '{archivePath}' ] // :TODO: should be 'x'?
},
list : {
cmd : '7za',
args : [ 'l', '{archivePath}' ],
entryMatch : '^[0-9]{4}-[0-9]{2}-[0-9]{2}\\s[0-9]{2}:[0-9]{2}:[0-9]{2}\\s[A-Za-z\\.]{5}\\s+([0-9]+)\\s+[0-9]+\\s+([^\\r\\n]+)$',
},
extract : {
cmd : '7za',
args : [ 'e', '-o{extractPath}', '{archivePath}', '{fileList}' ],
},
},
Lha : {
//
// 'lha' command can be obtained from:
// * apt-get: lhasa
//
// (compress not currently supported)
//
decompress : {
cmd : 'lha',
args : [ '-ew={extractPath}', '{archivePath}' ],
},
list : {
cmd : 'lha',
args : [ '-l', '{archivePath}' ],
entryMatch : '^[\\[a-z\\]]+(?:\\s+[0-9]+\\s+[0-9]+|\\s+)([0-9]+)\\s+[0-9]{2}\\.[0-9]\\%\\s+[A-Za-z]{3}\\s+[0-9]{1,2}\\s+[0-9]{4}\\s+([^\\r\\n]+)$',
},
extract : {
cmd : 'lha',
args : [ '-ew={extractPath}', '{archivePath}', '{fileList}' ]
}
},
Arj : {
//
// 'arj' command can be obtained from:
// * apt-get: arj
//
decompress : {
cmd : 'arj',
args : [ 'x', '{archivePath}', '{extractPath}' ],
},
list : {
cmd : 'arj',
args : [ 'l', '{archivePath}' ],
entryMatch : '^([^\\s]+)\\s+([0-9]+)\\s+[0-9]+\\s[0-9\\.]+\\s+[0-9]{2}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\:[0-9]{2}\\s+(?:[^\\r\\n]+)$',
entryGroupOrder : { // defaults to { byteSize : 1, fileName : 2 }
fileName : 1,
byteSize : 2,
}
},
extract : {
cmd : 'arj',
args : [ 'e', '{archivePath}', '{extractPath}', '{fileList}' ],
}
}
},
formats : {
//
// Resources
// * http://www.garykessler.net/library/file_sigs.html
//
zip : {
sig : '504b0304',
offset : 0,
exts : [ 'zip' ],
handler : '7Zip',
desc : 'ZIP Archive',
},
'7z' : {
sig : '377abcaf271c',
offset : 0,
exts : [ '7z' ],
handler : '7Zip',
desc : '7-Zip Archive',
},
arj : {
sig : '60ea',
offset : 0,
exts : [ 'arj' ],
handler : 'Arj',
desc : 'ARJ Archive',
},
rar : {
sig : '526172211a0700',
offset : 0,
exts : [ 'rar' ],
handler : '7Zip',
desc : 'RAR Archive',
},
gzip : {
sig : '1f8b',
offset : 0,
exts : [ 'gz' ],
handler : '7Zip',
desc : 'Gzip Archive',
},
bzip : {
sig : '425a68',
offset : 0,
exts : [ 'bz2' ],
handler : '7Zip',
desc : 'BZip2 Archive',
},
lzh : {
sig : '2d6c68',
offset : 2,
exts : [ 'lzh', 'ice' ],
handler : 'Lha',
desc : 'LHArc Archive',
}
} }
}, },
fileTransferProtocols : {
//
// See http://www.synchro.net/docs/sexyz.txt for information on SEXYZ
//
zmodem8kSexyz : {
name : 'ZModem 8k (SEXYZ)',
type : 'external',
sort : 1,
external : {
// :TODO: Look into shipping sexyz binaries or at least hosting them somewhere for common systems
sendCmd : 'sexyz',
sendArgs : [ '-telnet', '-8', 'sz', '@{fileListPath}' ],
recvCmd : 'sexyz',
recvArgs : [ '-telnet', '-8', 'rz', '{uploadDir}' ],
recvArgsNonBatch : [ '-telnet', '-8', 'rz', '{fileName}' ],
}
},
xmodemSexyz : {
name : 'XModem (SEXYZ)',
type : 'external',
sort : 3,
external : {
sendCmd : 'sexyz',
sendArgs : [ '-telnet', 'sX', '@{fileListPath}' ],
recvCmd : 'sexyz',
recvArgsNonBatch : [ '-telnet', 'rC', '{fileName}' ]
}
},
ymodemSexyz : {
name : 'YModem (SEXYZ)',
type : 'external',
sort : 4,
external : {
sendCmd : 'sexyz',
sendArgs : [ '-telnet', 'sY', '@{fileListPath}' ],
recvCmd : 'sexyz',
recvArgs : [ '-telnet', 'ry', '{uploadDir}' ],
}
},
zmodem8kSz : {
name : 'ZModem 8k',
type : 'external',
sort : 2,
external : {
sendCmd : 'sz', // Avail on Debian/Ubuntu based systems as the package "lrzsz"
sendArgs : [
// :TODO: try -q
'--zmodem', '--try-8k', '--binary', '--restricted', '{filePaths}'
],
recvCmd : 'rz', // Avail on Debian/Ubuntu based systems as the package "lrzsz"
recvArgs : [
'--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
}
}
},
messageAreaDefaults : { messageAreaDefaults : {
// //
@ -272,22 +476,60 @@ function getDefaultConfig() {
// 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/'), areaStoragePrefix : paths.join(__dirname, './../file_base/'),
maxDescFileByteSize : 471859, // ~1/4 MB
maxDescLongFileByteSize : 524288, // 1/2 MB
fileNamePatterns: { fileNamePatterns: {
shortDesc : [ '^FILE_ID\.DIZ$', '^DESC\.SDI$' ], // These are NOT case sensitive
longDesc : [ '^.*\.NFO$', '^README\.1ST$', '^README\.TXT$' ], // 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$'
],
// common README filename - https://en.wikipedia.org/wiki/README
descLong : [
'^.*\.NFO$', '^README\.1ST$', '^README\.NOW$', '^README\.TXT$', '^READ\.ME$', '^README$', '^README\.md$'
],
},
yearEstPatterns: [
//
// 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(?: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
// :TODO: DD/MMM/YY, DD/MMMM/YY, DD/MMM/YYYY, etc.
// :TODO: "Copyright YYYY someone"
],
web : {
path : '/f/',
routePath : '/f/[a-zA-Z0-9]+$',
expireMinutes : 1440, // 1 day
},
//
// File area storage location tag/value pairs.
// Non-absolute paths are relative to |areaStoragePrefix|.
//
storageTags : {
sys_msg_attach : 'msg_attach',
}, },
areas: { areas: {
message_attachment : { system_message_attachment : {
name : 'Message attachments', name : 'Message attachments',
desc : 'File attachments to messages', desc : 'File attachments to messages',
storageTags : 'sys_msg_attach', // may be string or array of strings
} }
} }
}, },
eventScheduler : { eventScheduler : {
events : { events : {
trimMessageAreas : { trimMessageAreas : {
// may optionally use [or ]@watch:/path/to/file // may optionally use [or ]@watch:/path/to/file

View File

@ -40,11 +40,11 @@ function ConfigCache() {
this.gaze.on('changed', function fileChanged(filePath) { this.gaze.on('changed', function fileChanged(filePath) {
assert(filePath in self.cache); assert(filePath in self.cache);
Log.info( { filePath : filePath }, 'Configuration file changed; recaching'); Log.info( { path : filePath }, 'Configuration file changed; re-caching');
self.reCacheConfigFromFile(filePath, function reCached(err) { self.reCacheConfigFromFile(filePath, function reCached(err) {
if(err) { if(err) {
Log.error( { error : err, filePath : filePath } , 'Error recaching HJSON config'); Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration');
} else { } else {
self.emit('recached', filePath); self.emit('recached', filePath);
} }

View File

@ -123,7 +123,7 @@ function displayBanner(term) {
// note: intentional formatting: // note: intentional formatting:
term.pipeWrite(` term.pipeWrite(`
|06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN |06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN
|06Copyright (c) 2014-2016 Bryan Ashby |14- |12http://l33t.codes/ |06Copyright (c) 2014-2017 Bryan Ashby |14- |12http://l33t.codes/
|06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/ |06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/
|00` |00`
); );

54
core/crc.js Normal file
View File

@ -0,0 +1,54 @@
/* jslint node: true */
'use strict';
const CRC32_TABLE = new Int32Array(
'00000000 77073096 EE0E612C 990951BA 076DC419 706AF48F E963A535 9E6495A3 0EDB8832 79DCB8A4 E0D5E91E 97D2D988 09B64C2B 7EB17CBD E7B82D07 90BF1D91 1DB71064 6AB020F2 F3B97148 84BE41DE 1ADAD47D 6DDDE4EB F4D4B551 83D385C7 136C9856 646BA8C0 FD62F97A 8A65C9EC 14015C4F 63066CD9 FA0F3D63 8D080DF5 3B6E20C8 4C69105E D56041E4 A2677172 3C03E4D1 4B04D447 D20D85FD A50AB56B 35B5A8FA 42B2986C DBBBC9D6 ACBCF940 32D86CE3 45DF5C75 DCD60DCF ABD13D59 26D930AC 51DE003A C8D75180 BFD06116 21B4F4B5 56B3C423 CFBA9599 B8BDA50F 2802B89E 5F058808 C60CD9B2 B10BE924 2F6F7C87 58684C11 C1611DAB B6662D3D 76DC4190 01DB7106 98D220BC EFD5102A 71B18589 06B6B51F 9FBFE4A5 E8B8D433 7807C9A2 0F00F934 9609A88E E10E9818 7F6A0DBB 086D3D2D 91646C97 E6635C01 6B6B51F4 1C6C6162 856530D8 F262004E 6C0695ED 1B01A57B 8208F4C1 F50FC457 65B0D9C6 12B7E950 8BBEB8EA FCB9887C 62DD1DDF 15DA2D49 8CD37CF3 FBD44C65 4DB26158 3AB551CE A3BC0074 D4BB30E2 4ADFA541 3DD895D7 A4D1C46D D3D6F4FB 4369E96A 346ED9FC AD678846 DA60B8D0 44042D73 33031DE5 AA0A4C5F DD0D7CC9 5005713C 270241AA BE0B1010 C90C2086 5768B525 206F85B3 B966D409 CE61E49F 5EDEF90E 29D9C998 B0D09822 C7D7A8B4 59B33D17 2EB40D81 B7BD5C3B C0BA6CAD EDB88320 9ABFB3B6 03B6E20C 74B1D29A EAD54739 9DD277AF 04DB2615 73DC1683 E3630B12 94643B84 0D6D6A3E 7A6A5AA8 E40ECF0B 9309FF9D 0A00AE27 7D079EB1 F00F9344 8708A3D2 1E01F268 6906C2FE F762575D 806567CB 196C3671 6E6B06E7 FED41B76 89D32BE0 10DA7A5A 67DD4ACC F9B9DF6F 8EBEEFF9 17B7BE43 60B08ED5 D6D6A3E8 A1D1937E 38D8C2C4 4FDFF252 D1BB67F1 A6BC5767 3FB506DD 48B2364B D80D2BDA AF0A1B4C 36034AF6 41047A60 DF60EFC3 A867DF55 316E8EEF 4669BE79 CB61B38C BC66831A 256FD2A0 5268E236 CC0C7795 BB0B4703 220216B9 5505262F C5BA3BBE B2BD0B28 2BB45A92 5CB36A04 C2D7FFA7 B5D0CF31 2CD99E8B 5BDEAE1D 9B64C2B0 EC63F226 756AA39C 026D930A 9C0906A9 EB0E363F 72076785 05005713 95BF4A82 E2B87A14 7BB12BAE 0CB61B38 92D28E9B E5D5BE0D 7CDCEFB7 0BDBDF21 86D3D2D4 F1D4E242 68DDB3F8 1FDA836E 81BE16CD F6B9265B 6FB077E1 18B74777 88085AE6 FF0F6A70 66063BCA 11010B5C 8F659EFF F862AE69 616BFFD3 166CCF45 A00AE278 D70DD2EE 4E048354 3903B3C2 A7672661 D06016F7 4969474D 3E6E77DB AED16A4A D9D65ADC 40DF0B66 37D83BF0 A9BCAE53 DEBB9EC5 47B2CF7F 30B5FFE9 BDBDF21C CABAC28A 53B39330 24B4A3A6 BAD03605 CDD70693 54DE5729 23D967BF B3667A2E C4614AB8 5D681B02 2A6F2B94 B40BBE37 C30C8EA1 5A05DF1B 2D02EF8D'.split(' ').map(s => parseInt(s, 16)));
exports.CRC32 = class CRC32 {
constructor() {
this.crc = -1;
}
update(input) {
input = Buffer.isBuffer(input) ? input : Buffer.from(input, 'binary');
return input.length > 10240 ? this.update_8(input) : this.update_4(input);
}
update_4(input) {
const len = input.length - 3;
let i = 0;
for(i = 0; i < len;) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
}
while(i < len + 3) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
}
}
update_8(input) {
const len = input.length - 7;
let i = 0;
for(i = 0; i < len;) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++]) & 0xff ];
}
while(i < len + 7) {
this.crc = (this.crc >>> 8) ^ CRC32_TABLE[ (this.crc ^ input[i++] ) & 0xff ];
}
}
finalize() {
return (this.crc ^ (-1)) >>> 0;
}
};

View File

@ -10,11 +10,13 @@ const paths = require('path');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const assert = require('assert'); const assert = require('assert');
const moment = require('moment');
// database handles // database handles
let dbs = {}; let dbs = {};
exports.getModDatabasePath = getModDatabasePath; exports.getModDatabasePath = getModDatabasePath;
exports.getISOTimestampString = getISOTimestampString;
exports.initializeDatabases = initializeDatabases; exports.initializeDatabases = initializeDatabases;
exports.dbs = dbs; exports.dbs = dbs;
@ -46,17 +48,22 @@ function getModDatabasePath(moduleInfo, suffix) {
return paths.join(conf.config.paths.modsDb, `${full}.sqlite3`); return paths.join(conf.config.paths.modsDb, `${full}.sqlite3`);
} }
function getISOTimestampString(ts) {
ts = ts || moment();
return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ');
}
function initializeDatabases(cb) { function initializeDatabases(cb) {
async.each( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => {
dbs[dbName] = new sqlite3.Database(getDatabasePath(dbName), err => { dbs[dbName] = new sqlite3.Database(getDatabasePath(dbName), err => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
dbs[dbName].serialize( () => { dbs[dbName].serialize( () => {
DB_INIT_TABLE[dbName](); DB_INIT_TABLE[dbName]( () => {
return next(null);
return next(null); });
}); });
}); });
}, err => { }, err => {
@ -65,7 +72,7 @@ function initializeDatabases(cb) {
} }
const DB_INIT_TABLE = { const DB_INIT_TABLE = {
system : () => { system : (cb) => {
dbs.system.run('PRAGMA foreign_keys = ON;'); dbs.system.run('PRAGMA foreign_keys = ON;');
// Various stat/event logging - see stat_log.js // Various stat/event logging - see stat_log.js
@ -98,9 +105,11 @@ const DB_INIT_TABLE = {
UNIQUE(timestamp, user_id, log_name) UNIQUE(timestamp, user_id, log_name)
);` );`
); );
return cb(null);
}, },
user : () => { user : (cb) => {
dbs.user.run('PRAGMA foreign_keys = ON;'); dbs.user.run('PRAGMA foreign_keys = ON;');
dbs.user.run( dbs.user.run(
@ -138,9 +147,11 @@ const DB_INIT_TABLE = {
timestamp DATETIME NOT NULL timestamp DATETIME NOT NULL
);` );`
); );
return cb(null);
}, },
message : () => { message : (cb) => {
dbs.message.run('PRAGMA foreign_keys = ON;'); dbs.message.run('PRAGMA foreign_keys = ON;');
dbs.message.run( dbs.message.run(
@ -175,17 +186,23 @@ const DB_INIT_TABLE = {
dbs.message.run( dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN `CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN
DELETE FROM message_fts WHERE docid=old.rowid; DELETE FROM message_fts WHERE docid=old.rowid;
END; END;`
);
CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN
DELETE FROM message_fts WHERE docid=old.rowid; DELETE FROM message_fts WHERE docid=old.rowid;
END; END;`
);
CREATE TRIGGER IF NOT EXISTS message_after_update AFTER UPDATE ON message BEGIN dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_after_update AFTER UPDATE ON message BEGIN
INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message);
END; END;`
);
CREATE TRIGGER IF NOT EXISTS message_after_insert AFTER INSERT ON message BEGIN dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_after_insert AFTER INSERT ON message BEGIN
INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message);
END;` END;`
); );
@ -201,6 +218,7 @@ const DB_INIT_TABLE = {
);` );`
); );
// :TODO: need SQL to ensure cleaned up if delete from message? // :TODO: need SQL to ensure cleaned up if delete from message?
/* /*
dbs.message.run( dbs.message.run(
@ -237,9 +255,11 @@ const DB_INIT_TABLE = {
UNIQUE(scan_toss, area_tag) UNIQUE(scan_toss, area_tag)
);` );`
); );
return cb(null);
}, },
file : () => { file : (cb) => {
dbs.file.run('PRAGMA foreign_keys = ON;'); dbs.file.run('PRAGMA foreign_keys = ON;');
dbs.file.run( dbs.file.run(
@ -247,11 +267,11 @@ const DB_INIT_TABLE = {
`CREATE TABLE IF NOT EXISTS file ( `CREATE TABLE IF NOT EXISTS file (
file_id INTEGER PRIMARY KEY, file_id INTEGER PRIMARY KEY,
area_tag VARCHAR NOT NULL, area_tag VARCHAR NOT NULL,
file_sha1 VARCHAR NOT NULL, file_sha256 VARCHAR NOT NULL,
file_name, /* FTS @ file_fts */ file_name, /* FTS @ file_fts */
storage_tag VARCHAR NOT NULL,
desc, /* FTS @ file_fts */ desc, /* FTS @ file_fts */
desc_long, /* FTS @ file_fts */ desc_long, /* FTS @ file_fts */
upload_by_username VARCHAR NOT NULL,
upload_timestamp DATETIME NOT NULL upload_timestamp DATETIME NOT NULL
);` );`
); );
@ -273,18 +293,24 @@ const DB_INIT_TABLE = {
dbs.file.run( dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_before_update BEFORE UPDATE ON file BEGIN `CREATE TRIGGER IF NOT EXISTS file_before_update BEFORE UPDATE ON file BEGIN
DELETE FROM file_fts WHERE docid=old.rowid; DELETE FROM file_fts WHERE docid=old.rowid;
END; END;`
);
CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN
dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN
DELETE FROM file_fts WHERE docid=old.rowid; DELETE FROM file_fts WHERE docid=old.rowid;
END; END;`
);
CREATE TRIGGER IF NOT EXISTS file_after_update AFTER UPDATE ON file BEGIN dbs.file.run(
INSERT INTO file_fts(docid, file_name, desc, long_desc) VALUES(new.rowid, new.file_name, new.desc, new.long_desc); `CREATE TRIGGER IF NOT EXISTS file_after_update AFTER UPDATE ON file BEGIN
END; INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long);
END;`
);
CREATE TRIGGER IF NOT EXISTS file_after_insert AFTER INSERT ON file BEGIN dbs.file.run(
INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.long_desc); `CREATE TRIGGER IF NOT EXISTS file_after_insert AFTER INSERT ON file BEGIN
INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long);
END;` END;`
); );
@ -315,5 +341,24 @@ const DB_INIT_TABLE = {
UNIQUE(hash_tag_id, file_id) UNIQUE(hash_tag_id, file_id)
);` );`
); );
dbs.file.run(
`CREATE TABLE IF NOT EXISTS file_user_rating (
file_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
rating INTEGER NOT NULL,
UNIQUE(file_id, user_id)
);`
);
dbs.file.run(
`CREATE TABLE IF NOT EXISTS file_web_serve (
hash_id VARCHAR NOT NULL PRIMARY KEY,
expire_timestamp DATETIME NOT NULL
);`
);
return cb(null);
} }
}; };

View File

@ -100,6 +100,7 @@ Door.prototype.run = function() {
} }
// Expand arg strings, e.g. {dropFile} -> DOOR32.SYS // Expand arg strings, e.g. {dropFile} -> DOOR32.SYS
// :TODO: Use .map() here
let args = _.clone(self.exeInfo.args); // we need a copy so the original is not modified let args = _.clone(self.exeInfo.args); // we need a copy so the original is not modified
for(let i = 0; i < args.length; ++i) { for(let i = 0; i < args.length; ++i) {

View File

@ -10,28 +10,26 @@ const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const SSHClient = require('ssh2').Client; const SSHClient = require('ssh2').Client;
exports.getModule = DoorPartyModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'DoorParty', name : 'DoorParty',
desc : 'DoorParty Access Module', desc : 'DoorParty Access Module',
author : 'NuSkooler', author : 'NuSkooler',
}; };
exports.getModule = class DoorPartyModule extends MenuModule {
constructor(options) {
super(options);
function DoorPartyModule(options) { // establish defaults
MenuModule.call(this, options); this.config = options.menuConfig.config;
this.config.host = this.config.host || 'dp.throwbackbbs.com';
this.config.sshPort = this.config.sshPort || 2022;
this.config.rloginPort = this.config.rloginPort || 513;
}
const self = this; initSequence() {
// establish defaults
this.config = options.menuConfig.config;
this.config.host = this.config.host || 'dp.throwbackbbs.com';
this.config.sshPort = this.config.sshPort || 2022;
this.config.rloginPort = this.config.rloginPort || 513;
this.initSequence = function() {
let clientTerminated; let clientTerminated;
const self = this;
async.series( async.series(
[ [
@ -116,7 +114,7 @@ function DoorPartyModule(options) {
], ],
err => { err => {
if(err) { if(err) {
self.client.log.warn( { error : err.toString() }, 'DoorParty error'); self.client.log.warn( { error : err.message }, 'DoorParty error');
} }
// if the client is stil here, go to previous // if the client is stil here, go to previous
@ -125,8 +123,5 @@ function DoorPartyModule(options) {
} }
} }
); );
}; }
};
}
require('util').inherits(DoorPartyModule, MenuModule);

72
core/download_queue.js Normal file
View File

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

View File

@ -19,12 +19,22 @@ class EnigError extends Error {
} }
} }
class EnigMenuError extends EnigError { }
exports.EnigError = EnigError; exports.EnigError = EnigError;
exports.EnigMenuError = EnigMenuError;
exports.Errors = { exports.Errors = {
General : (reason, reasonCode) => new EnigError('An error occurred', -33000, reason, reasonCode), General : (reason, reasonCode) => new EnigError('An error occurred', -33000, reason, reasonCode),
MenuStack : (reason, reasonCode) => new EnigMenuError('Menu stack error', -33001, reason, reasonCode), MenuStack : (reason, reasonCode) => new EnigError('Menu stack error', -33001, reason, reasonCode),
DoesNotExist : (reason, reasonCode) => new EnigError('Object does not exist', -33002, reason, reasonCode),
AccessDenied : (reason, reasonCode) => new EnigError('Access denied', -32003, reason, reasonCode),
Invalid : (reason, reasonCode) => new EnigError('Invalid', -32004, reason, reasonCode),
ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode),
MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode),
UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode),
}; };
exports.ErrorReasons = {
AlreadyThere : 'ALREADYTHERE',
InvalidNextMenu : 'BADNEXT',
NoPreviousMenu : 'NOPREV',
NoConditionMatch : 'NOCONDMATCH',
};

273
core/file_area_web.js Normal file
View File

@ -0,0 +1,273 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
const FileDb = require('./database.js').dbs.file;
const getISOTimestampString = require('./database.js').getISOTimestampString;
const FileEntry = require('./file_entry.js');
const getServer = require('./listening_server.js').getServer;
const Errors = require('./enig_error.js').Errors;
// deps
const hashids = require('hashids');
const moment = require('moment');
const paths = require('path');
const async = require('async');
const fs = require('fs');
const mimeTypes = require('mime-types');
const WEB_SERVER_PACKAGE_NAME = 'codes.l33t.enigma.web.server';
/*
:TODO:
* Load temp download URLs @ startup & set expire timers via scheduler.
* At creation, set expire timer via scheduler
*
*/
class FileAreaWebAccess {
constructor() {
this.hashids = new hashids(Config.general.boardName);
this.expireTimers = {}; // hashId->timer
}
startup(cb) {
const self = this;
async.series(
[
function initFromDb(callback) {
return self.load(callback);
},
function addWebRoute(callback) {
self.webServer = getServer(WEB_SERVER_PACKAGE_NAME);
if(!self.webServer) {
return callback(Errors.DoesNotExist(`Server with package name "${WEB_SERVER_PACKAGE_NAME}" does not exist`));
}
const routeAdded = self.webServer.instance.addRoute({
method : 'GET',
path : Config.fileBase.web.routePath,
handler : self.routeWebRequestForFile.bind(self),
});
return callback(routeAdded ? null : Errors.General('Failed adding route'));
}
],
err => {
return cb(err);
}
);
}
shutdown(cb) {
return cb(null);
}
load(cb) {
//
// Load entries, register expiration timers
//
FileDb.each(
`SELECT hash_id, expire_timestamp
FROM file_web_serve;`,
(err, row) => {
if(row) {
this.scheduleExpire(row.hash_id, moment(row.expire_timestamp));
}
},
err => {
return cb(err);
}
);
}
removeEntry(hashId) {
//
// Delete record from DB, and our timer
//
FileDb.run(
`DELETE FROM file_web_serve
WHERE hash_id = ?;`,
[ hashId ]
);
delete this.expireTimers[hashId];
}
scheduleExpire(hashId, expireTime) {
// remove any previous entry for this hashId
const previous = this.expireTimers[hashId];
if(previous) {
clearTimeout(previous);
delete this.expireTimers[hashId];
}
const timeoutMs = expireTime.diff(moment());
if(timeoutMs <= 0) {
setImmediate( () => {
this.removeEntry(hashId);
});
} else {
this.expireTimers[hashId] = setTimeout( () => {
this.removeEntry(hashId);
}, timeoutMs);
}
}
loadServedHashId(hashId, cb) {
FileDb.get(
`SELECT expire_timestamp FROM
file_web_serve
WHERE hash_id = ?`,
[ hashId ],
(err, result) => {
if(err) {
return cb(err);
}
const decoded = this.hashids.decode(hashId);
if(!result || 2 !== decoded.length) {
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),
}
);
}
);
}
getHashId(client, fileEntry) {
//
// Hashid is a unique combination of userId & fileId
//
return this.hashids.encode(client.user.userId, fileEntry.fileId);
}
buildTempDownloadLink(client, fileEntry, hashId) {
hashId = hashId || this.getHashId(client, fileEntry);
//
// 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(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}`;
}
getExistingTempDownloadServeItem(client, fileEntry, cb) {
const hashId = this.getHashId(client, fileEntry);
this.loadServedHashId(hashId, (err, servedItem) => {
if(err) {
return cb(err);
}
servedItem.url = this.buildTempDownloadLink(client, fileEntry);
return cb(null, servedItem);
});
}
createAndServeTempDownload(client, fileEntry, options, cb) {
const hashId = this.getHashId(client, fileEntry);
const url = this.buildTempDownloadLink(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 => {
if(err) {
return cb(err);
}
this.scheduleExpire(hashId, options.expireTime);
return cb(null, url);
}
);
}
fileNotFound(resp) {
this.webServer.instance.respondWithError(resp, 404, 'File not found.', 'File Not Found');
}
routeWebRequestForFile(req, resp) {
const hashId = paths.basename(req.url);
this.loadServedHashId(hashId, (err, servedItem) => {
if(err) {
return this.fileNotFound(resp);
}
const fileEntry = new FileEntry();
fileEntry.load(servedItem.fileId, err => {
if(err) {
return this.fileNotFound(resp);
}
const filePath = fileEntry.filePath;
if(!filePath) {
return this.fileNotFound(resp);
}
fs.stat(filePath, (err, stats) => {
if(err) {
return this.fileNotFound(resp);
}
resp.on('close', () => {
// connection closed *before* the response was fully sent
// :TODO: Log and such
});
resp.on('finish', () => {
// transfer completed fully
// :TODO: we need to update the users stats - bytes xferred, credit stuff, etc.
});
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);
});
});
});
}
}
module.exports = new FileAreaWebAccess();

671
core/file_base_area.js Normal file
View File

@ -0,0 +1,671 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
const Errors = require('./enig_error.js').Errors;
const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs;
const FileEntry = require('./file_entry.js');
const FileDb = require('./database.js').dbs.file;
const ArchiveUtil = require('./archive_util.js');
const CRC32 = require('./crc.js').CRC32;
const Log = require('./logger.js').log;
// deps
const _ = require('lodash');
const async = require('async');
const fs = require('fs');
const crypto = require('crypto');
const paths = require('path');
const temptmp = require('temptmp').createTrackedSession('file_area');
const iconv = require('iconv-lite');
exports.isInternalArea = isInternalArea;
exports.getAvailableFileAreas = getAvailableFileAreas;
exports.getSortedAvailableFileAreas = getSortedAvailableFileAreas;
exports.getAreaDefaultStorageDirectory = getAreaDefaultStorageDirectory;
exports.getAreaStorageLocations = getAreaStorageLocations;
exports.getDefaultFileAreaTag = getDefaultFileAreaTag;
exports.getFileAreaByTag = getFileAreaByTag;
exports.getFileEntryPath = getFileEntryPath;
exports.changeFileAreaWithOptions = changeFileAreaWithOptions;
exports.scanFile = scanFile;
exports.scanFileAreaForChanges = scanFileAreaForChanges;
const WellKnownAreaTags = exports.WellKnownAreaTags = {
Invalid : '',
MessageAreaAttach : 'system_message_attachment',
};
function isInternalArea(areaTag) {
return areaTag === WellKnownAreaTags.MessageAreaAttach;
}
function getAvailableFileAreas(client, options) {
options = options || { };
// perform ACS check per conf & omit internal if desired
const allAreas = _.map(Config.fileBase.areas, (areaInfo, areaTag) => Object.assign(areaInfo, { areaTag : areaTag } ));
return _.omitBy(allAreas, areaInfo => {
if(!options.includeSystemInternal && isInternalArea(areaInfo.areaTag)) {
return true;
}
if(options.writeAcs && !client.acs.hasFileAreaWrite(areaInfo)) {
return true; // omit
}
return !client.acs.hasFileAreaRead(areaInfo);
});
}
function getSortedAvailableFileAreas(client, options) {
const areas = _.map(getAvailableFileAreas(client, options), v => v);
sortAreasOrConfs(areas);
return areas;
}
function getDefaultFileAreaTag(client, disableAcsCheck) {
let defaultArea = _.findKey(Config.fileBase, o => o.default);
if(defaultArea) {
const area = Config.fileBase.areas[defaultArea];
if(true === disableAcsCheck || client.acs.hasFileAreaRead(area)) {
return defaultArea;
}
}
// just use anything we can
defaultArea = _.findKey(Config.fileBase.areas, (area, areaTag) => {
return WellKnownAreaTags.MessageAreaAttach !== areaTag && (true === disableAcsCheck || client.acs.hasFileAreaRead(area));
});
return defaultArea;
}
function getFileAreaByTag(areaTag) {
const areaInfo = Config.fileBase.areas[areaTag];
if(areaInfo) {
areaInfo.areaTag = areaTag; // convienence!
areaInfo.storage = getAreaStorageLocations(areaInfo);
return areaInfo;
}
}
function changeFileAreaWithOptions(client, areaTag, options, cb) {
async.waterfall(
[
function getArea(callback) {
const area = getFileAreaByTag(areaTag);
return callback(area ? null : Errors.Invalid('Invalid file areaTag'), area);
},
function validateAccess(area, callback) {
if(!client.acs.hasFileAreaRead(area)) {
return callback(Errors.AccessDenied('No access to this area'));
}
},
function changeArea(area, callback) {
if(true === options.persist) {
client.user.persistProperty('file_area_tag', areaTag, err => {
return callback(err, area);
});
} else {
client.user.properties['file_area_tag'] = areaTag;
return callback(null, area);
}
}
],
(err, area) => {
if(!err) {
client.log.info( { areaTag : areaTag, area : area }, 'Current file area changed');
} else {
client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change file area');
}
return cb(err);
}
);
}
function getAreaStorageDirectoryByTag(storageTag) {
const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]);
return paths.resolve(Config.fileBase.areaStoragePrefix, storageLocation || '');
}
function getAreaDefaultStorageDirectory(areaInfo) {
return getAreaStorageDirectoryByTag(areaInfo.storageTags[0]);
}
function getAreaStorageLocations(areaInfo) {
const storageTags = Array.isArray(areaInfo.storageTags) ?
areaInfo.storageTags :
[ areaInfo.storageTags || '' ];
const avail = Config.fileBase.storageTags;
return _.compact(storageTags.map(storageTag => {
if(avail[storageTag]) {
return {
storageTag : storageTag,
dir : getAreaStorageDirectoryByTag(storageTag),
};
}
}));
}
function getFileEntryPath(fileEntry) {
const areaInfo = getFileAreaByTag(fileEntry.areaTag);
if(areaInfo) {
return paths.join(areaInfo.storageDirectory, fileEntry.fileName);
}
}
function getExistingFileEntriesBySha256(sha256, cb) {
const entries = [];
FileDb.each(
`SELECT file_id, area_tag
FROM file
WHERE file_sha256=?;`,
[ sha256 ],
(err, fileRow) => {
if(fileRow) {
entries.push({
fileId : fileRow.file_id,
areaTag : fileRow.area_tag,
});
}
},
err => {
return cb(err, entries);
}
);
}
// :TODO: This is bascially sliceAtEOF() from art.js .... DRY!
function sliceAtSauceMarker(data) {
let eof = data.length;
const stopPos = Math.max(data.length - (256), 0); // 256 = 2 * sizeof(SAUCE)
for(let i = eof - 1; i > stopPos; i--) {
if(0x1a === data[i]) {
eof = i;
break;
}
}
return data.slice(0, eof);
}
function attemptSetEstimatedReleaseDate(fileEntry) {
// :TODO: yearEstPatterns RegExp's should be cached - we can do this @ Config (re)load time
const patterns = Config.fileBase.yearEstPatterns.map( p => new RegExp(p, 'gmi'));
function getMatch(input) {
if(input) {
let m;
for(let i = 0; i < patterns.length; ++i) {
m = patterns[i].exec(input);
if(m) {
return m;
}
}
}
}
//
// We attempt deteciton in short -> long order
//
const match = getMatch(fileEntry.desc) || getMatch(fileEntry.descLong);
if(match && match[1]) {
let year;
if(2 === match[1].length) {
year = parseInt(match[1]);
if(year) {
if(year > 70) {
year += 1900;
} else {
year += 2000;
}
}
} else {
year = parseInt(match[1]);
}
if(year) {
fileEntry.meta.est_release_year = year;
}
}
}
// a simple log proxy for when we call from oputil.js
function logDebug(obj, msg) {
if(Log) {
Log.debug(obj, msg);
}
}
function populateFileEntryWithArchive(fileEntry, filePath, stepInfo, iterator, cb) {
const archiveUtil = ArchiveUtil.getInstance();
const archiveType = fileEntry.meta.archive_type; // we set this previous to populateFileEntryWithArchive()
async.waterfall(
[
function getArchiveFileList(callback) {
stepInfo.step = 'archive_list_start';
iterator(err => {
if(err) {
return callback(err);
}
archiveUtil.listEntries(filePath, archiveType, (err, entries) => {
if(err) {
stepInfo.step = 'archive_list_failed';
} else {
stepInfo.step = 'archive_list_finish';
stepInfo.archiveEntries = entries || [];
}
iterator(iterErr => {
return callback( iterErr, entries || [] ); // ignore original |err| here
});
});
});
},
function processDescFilesStart(entries, callback) {
stepInfo.step = 'desc_files_start';
iterator(err => {
return callback(err, entries);
});
},
function extractDescFiles(entries, callback) {
// :TODO: would be nice if these RegExp's were cached
// :TODO: this is long winded...
const extractList = [];
const shortDescFile = entries.find( e => {
return Config.fileBase.fileNamePatterns.desc.find( pat => new RegExp(pat, 'i').test(e.fileName) );
});
if(shortDescFile) {
extractList.push(shortDescFile.fileName);
}
const longDescFile = entries.find( e => {
return Config.fileBase.fileNamePatterns.descLong.find( pat => new RegExp(pat, 'i').test(e.fileName) );
});
if(longDescFile) {
extractList.push(longDescFile.fileName);
}
if(0 === extractList.length) {
return callback(null, [] );
}
temptmp.mkdir( { prefix : 'enigextract-' }, (err, tempDir) => {
if(err) {
return callback(err);
}
archiveUtil.extractTo(filePath, tempDir, archiveType, extractList, err => {
if(err) {
return callback(err);
}
const descFiles = {
desc : shortDescFile ? paths.join(tempDir, shortDescFile.fileName) : null,
descLong : longDescFile ? paths.join(tempDir, longDescFile.fileName) : null,
};
return callback(null, descFiles);
});
});
},
function readDescFiles(descFiles, callback) {
async.each(Object.keys(descFiles), (descType, next) => {
const path = descFiles[descType];
if(!path) {
return next(null);
}
fs.stat(path, (err, stats) => {
if(err) {
return next(null);
}
// skip entries that are too large
const maxFileSizeKey = `max${_.upperFirst(descType)}FileByteSize`;
if(Config.fileBase[maxFileSizeKey] && stats.size > Config.fileBase[maxFileSizeKey]) {
logDebug( { byteSize : stats.size, maxByteSize : Config.fileBase[maxFileSizeKey] }, `Skipping "${descType}"; Too large` );
return next(null);
}
fs.readFile(path, (err, data) => {
if(err || !data) {
return next(null);
}
//
// 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');
return next(null);
});
});
}, () => {
// cleanup but don't wait
temptmp.cleanup( paths => {
// note: don't use client logger here - may not be avail
logDebug( { paths : paths, sessionId : temptmp.sessionId }, 'Cleaned up temporary files' );
});
return callback(null);
});
},
function attemptReleaseYearEstimation(callback) {
attemptSetEstimatedReleaseDate(fileEntry);
return callback(null);
},
function processDescFilesFinish(callback) {
stepInfo.step = 'desc_files_finish';
return iterator(callback);
},
],
err => {
return cb(err);
}
);
}
function populateFileEntryNonArchive(fileEntry, filePath, stepInfo, iterator, cb) {
// :TODO: implement me!
return cb(null);
}
function addNewFileEntry(fileEntry, filePath, cb) {
// :TODO: Use detectTypeWithBuf() once avail - we *just* read some file data
async.series(
[
function addNewDbRecord(callback) {
return fileEntry.persist(callback);
}
],
err => {
return cb(err);
}
);
}
function updateFileEntry(fileEntry, filePath, cb) {
}
const HASH_NAMES = [ 'sha1', 'sha256', 'md5', 'crc32' ];
function scanFile(filePath, options, iterator, cb) {
if(3 === arguments.length && _.isFunction(iterator)) {
cb = iterator;
iterator = null;
} else if(2 === arguments.length && _.isFunction(options)) {
cb = options;
iterator = null;
options = {};
}
const fileEntry = new FileEntry({
areaTag : options.areaTag,
meta : options.meta,
hashTags : options.hashTags, // Set() or Array
fileName : paths.basename(filePath),
storageTag : options.storageTag,
});
const stepInfo = {
filePath : filePath,
fileName : paths.basename(filePath),
};
function callIter(next) {
if(iterator) {
return iterator(stepInfo, next);
} else {
return next(null);
}
}
function readErrorCallIter(origError, next) {
stepInfo.step = 'read_error';
stepInfo.error = origError.message;
callIter( () => {
return next(origError);
});
}
let lastCalcHashPercent;
async.waterfall(
[
function startScan(callback) {
fs.stat(filePath, (err, stats) => {
if(err) {
return readErrorCallIter(err, callback);
}
stepInfo.step = 'start';
stepInfo.byteSize = fileEntry.meta.byte_size = stats.size;
return callIter(callback);
});
},
function processPhysicalFileGeneric(callback) {
stepInfo.bytesProcessed = 0;
const hashes = {
sha1 : crypto.createHash('sha1'),
sha256 : crypto.createHash('sha256'),
md5 : crypto.createHash('md5'),
crc32 : new CRC32(),
};
const stream = fs.createReadStream(filePath);
function updateHashes(data) {
async.each( HASH_NAMES, (hashName, nextHash) => {
hashes[hashName].update(data);
return nextHash(null);
}, () => {
return stream.resume();
});
}
stream.on('data', data => {
stream.pause(); // until iterator compeltes
stepInfo.bytesProcessed += data.length;
stepInfo.calcHashPercent = Math.round(((stepInfo.bytesProcessed / stepInfo.byteSize) * 100));
//
// Only send 'hash_update' step update if we have a noticable percentage change in progress
//
if(stepInfo.calcHashPercent === lastCalcHashPercent) {
updateHashes(data);
} else {
lastCalcHashPercent = stepInfo.calcHashPercent;
stepInfo.step = 'hash_update';
callIter(err => {
if(err) {
stream.destroy(); // cancel read
return callback(err);
}
updateHashes(data);
});
}
});
stream.on('end', () => {
fileEntry.meta.byte_size = stepInfo.bytesProcessed;
async.each(HASH_NAMES, (hashName, nextHash) => {
if('sha256' === hashName) {
stepInfo.sha256 = fileEntry.fileSha256 = hashes.sha256.digest('hex');
} else if('sha1' === hashName || 'md5' === hashName) {
stepInfo[hashName] = fileEntry.meta[`file_${hashName}`] = hashes[hashName].digest('hex');
} else if('crc32' === hashName) {
stepInfo.crc32 = fileEntry.meta.file_crc32 = hashes.crc32.finalize().toString(16);
}
return nextHash(null);
}, () => {
stepInfo.step = 'hash_finish';
return callIter(callback);
});
});
stream.on('error', err => {
return readErrorCallIter(err, callback);
});
},
function processPhysicalFileByType(callback) {
const archiveUtil = ArchiveUtil.getInstance();
archiveUtil.detectType(filePath, (err, archiveType) => {
if(archiveType) {
// save this off
fileEntry.meta.archive_type = archiveType;
populateFileEntryWithArchive(fileEntry, filePath, stepInfo, callIter, err => {
if(err) {
populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => {
// :TODO: log err
return callback(null); // ignore err
});
} else {
return callback(null);
}
});
} else {
populateFileEntryNonArchive(fileEntry, filePath, stepInfo, callIter, err => {
// :TODO: log err
return callback(null); // ignore err
});
}
});
},
function fetchExistingEntry(callback) {
getExistingFileEntriesBySha256(fileEntry.fileSha256, (err, dupeEntries) => {
return callback(err, dupeEntries);
});
},
function finished(dupeEntries, callback) {
stepInfo.step = 'finished';
callIter( () => {
return callback(null, dupeEntries);
});
}
],
(err, dupeEntries) => {
if(err) {
return cb(err);
}
return cb(null, fileEntry, dupeEntries);
}
);
}
function scanFileAreaForChanges(areaInfo, options, iterator, cb) {
if(3 === arguments.length && _.isFunction(iterator)) {
cb = iterator;
iterator = null;
} else if(2 === arguments.length && _.isFunction(options)) {
cb = options;
iterator = null;
options = {};
}
const storageLocations = getAreaStorageLocations(areaInfo);
async.eachSeries(storageLocations, (storageLoc, nextLocation) => {
async.series(
[
function scanPhysFiles(callback) {
const physDir = storageLoc.dir;
fs.readdir(physDir, (err, files) => {
if(err) {
return callback(err);
}
async.eachSeries(files, (fileName, nextFile) => {
const fullPath = paths.join(physDir, fileName);
fs.stat(fullPath, (err, stats) => {
if(err) {
// :TODO: Log me!
return nextFile(null); // always try next file
}
if(!stats.isFile()) {
return nextFile(null);
}
scanFile(
fullPath,
{
areaTag : areaInfo.areaTag,
storageTag : storageLoc.storageTag
},
iterator,
(err, fileEntry, dupeEntries) => {
if(err) {
// :TODO: Log me!!!
return nextFile(null); // try next anyway
}
if(dupeEntries.length > 0) {
// :TODO: Handle duplidates -- what to do here???
} else {
if(Array.isArray(options.tags)) {
options.tags.forEach(tag => {
fileEntry.hashTags.add(tag);
});
}
addNewFileEntry(fileEntry, fullPath, err => {
// pass along error; we failed to insert a record in our DB or something else bad
return nextFile(err);
});
}
}
);
});
}, err => {
return callback(err);
});
});
},
function scanDbEntries(callback) {
// :TODO: Look @ db entries for area that were *not* processed above
return callback(null);
}
],
err => {
return nextLocation(err);
}
);
},
err => {
return cb(err);
});
}

130
core/file_base_filter.js Normal file
View File

@ -0,0 +1,130 @@
/* jslint node: true */
'use strict';
const _ = require('lodash');
const uuidV4 = require('uuid/v4');
module.exports = class FileBaseFilters {
constructor(client) {
this.client = client;
this.load();
}
static get OrderByValues() {
return [ 'descending', 'ascending' ];
}
static get SortByValues() {
return [
'upload_timestamp',
'upload_by_username',
'dl_count',
'user_rating',
'est_release_year',
'byte_size',
];
}
toArray() {
return _.map(this.filters, (filter, uuid) => {
return Object.assign( { uuid : uuid }, filter );
});
}
get(filterUuid) {
return this.filters[filterUuid];
}
add(filterInfo) {
const filterUuid = uuidV4();
filterInfo.tags = this.cleanTags(filterInfo.tags);
this.filters[filterUuid] = filterInfo;
return filterUuid;
}
replace(filterUuid, filterInfo) {
const filter = this.get(filterUuid);
if(!filter) {
return false;
}
filterInfo.tags = this.cleanTags(filterInfo.tags);
this.filters[filterUuid] = filterInfo;
return true;
}
remove(filterUuid) {
delete this.filters[filterUuid];
}
load() {
let filtersProperty = this.client.user.properties.file_base_filters;
let defaulted;
if(!filtersProperty) {
filtersProperty = JSON.stringify(FileBaseFilters.getDefaultFilters());
defaulted = true;
}
try {
this.filters = JSON.parse(filtersProperty);
} catch(e) {
this.filters = FileBaseFilters.getDefaultFilters(); // something bad happened; reset everything back to defaults :(
defaulted = true;
this.client.log.error( { error : e.message, property : filtersProperty }, 'Failed parsing file base filters property' );
}
if(defaulted) {
this.persist( err => {
if(!err) {
const defaultActiveUuid = this.toArray()[0].uuid;
this.setActive(defaultActiveUuid);
}
});
}
}
persist(cb) {
return this.client.user.persistProperty('file_base_filters', JSON.stringify(this.filters), cb);
}
cleanTags(tags) {
return tags.toLowerCase().replace(/,?\s+|\,/g, ' ').trim();
}
setActive(filterUuid) {
const activeFilter = this.get(filterUuid);
if(activeFilter) {
this.activeFilter = activeFilter;
this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid);
return true;
}
return false;
}
static getDefaultFilters() {
const filters = {};
const uuid = uuidV4();
filters[uuid] = {
name : 'Default',
areaTag : '', // all
terms : '', // *
tags : '', // *
order : 'descending',
sort : 'upload_timestamp',
uuid : uuid,
};
return filters;
}
static getActiveFilter(client) {
return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid);
}
};

399
core/file_entry.js Normal file
View File

@ -0,0 +1,399 @@
/* jslint node: true */
'use strict';
const fileDb = require('./database.js').dbs.file;
const Errors = require('./enig_error.js').Errors;
const getISOTimestampString = require('./database.js').getISOTimestampString;
const Config = require('./config.js').config;
// deps
const async = require('async');
const _ = require('lodash');
const paths = require('path');
const FILE_TABLE_MEMBERS = [
'file_id', 'area_tag', 'file_sha256', 'file_name', 'storage_tag',
'desc', 'desc_long', 'upload_timestamp'
];
const FILE_WELL_KNOWN_META = {
// name -> *read* converter, if any
upload_by_username : null,
upload_by_user_id : (u) => parseInt(u) || 0,
file_md5 : null,
file_sha1 : null,
file_crc32 : null,
est_release_year : (y) => parseInt(y) || new Date().getFullYear(),
dl_count : (d) => parseInt(d) || 0,
byte_size : (b) => parseInt(b) || 0,
archive_type : null,
};
module.exports = class FileEntry {
constructor(options) {
options = options || {};
this.fileId = options.fileId || 0;
this.areaTag = options.areaTag || '';
this.meta = options.meta || {
// values we always want
dl_count : 0,
};
this.hashTags = options.hashTags || new Set();
this.fileName = options.fileName;
this.storageTag = options.storageTag;
}
static loadBasicEntry(fileId, dest, cb) {
if(!cb && _.isFunction(dest)) {
cb = dest;
dest = this;
}
fileDb.get(
`SELECT ${FILE_TABLE_MEMBERS.join(', ')}
FROM file
WHERE file_id=?
LIMIT 1;`,
[ fileId ],
(err, file) => {
if(err) {
return cb(err);
}
if(!file) {
return cb(Errors.DoesNotExist('No file is available by that ID'));
}
// assign props from |file|
FILE_TABLE_MEMBERS.forEach(prop => {
dest[_.camelCase(prop)] = file[prop];
});
return cb(null);
}
);
}
load(fileId, cb) {
const self = this;
async.series(
[
function loadBasicEntry(callback) {
FileEntry.loadBasicEntry(fileId, self, callback);
},
function loadMeta(callback) {
return self.loadMeta(callback);
},
function loadHashTags(callback) {
return self.loadHashTags(callback);
},
function loadUserRating(callback) {
return self.loadRating(callback);
}
],
err => {
return cb(err);
}
);
}
persist(cb) {
const self = this;
async.series(
[
function startTrans(callback) {
return fileDb.run('BEGIN;', callback);
},
function storeEntry(callback) {
fileDb.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() ],
function inserted(err) { // use non-arrow func for 'this' scope / lastID
if(!err) {
self.fileId = this.lastID;
}
return callback(err);
}
);
},
function storeMeta(callback) {
async.each(Object.keys(self.meta), (n, next) => {
const v = self.meta[n];
return FileEntry.persistMetaValue(self.fileId, n, v, next);
},
err => {
return callback(err);
});
},
function storeHashTags(callback) {
const hashTagsArray = Array.from(self.hashTags);
async.each(hashTagsArray, (hashTag, next) => {
return FileEntry.persistHashTag(self.fileId, hashTag, next);
},
err => {
return callback(err);
});
}
],
err => {
// :TODO: Log orig err
fileDb.run(err ? 'ROLLBACK;' : 'COMMIT;', err => {
return cb(err);
});
}
);
}
static getAreaStorageDirectoryByTag(storageTag) {
const storageLocation = (storageTag && Config.fileBase.storageTags[storageTag]);
// absolute paths as-is
if(storageLocation && '/' === storageLocation.charAt(0)) {
return storageLocation;
}
// relative to |areaStoragePrefix|
return paths.join(Config.fileBase.areaStoragePrefix, storageLocation || '');
}
get filePath() {
const storageDir = FileEntry.getAreaStorageDirectoryByTag(this.storageTag);
return paths.join(storageDir, this.fileName);
}
static persistUserRating(fileId, userId, rating, cb) {
return fileDb.run(
`REPLACE INTO file_user_rating (file_id, user_id, rating)
VALUES (?, ?, ?);`,
[ fileId, userId, rating ],
cb
);
}
static persistMetaValue(fileId, name, value, cb) {
return fileDb.run(
`REPLACE INTO file_meta (file_id, meta_name, meta_value)
VALUES (?, ?, ?);`,
[ fileId, name, value ],
cb
);
}
static incrementAndPersistMetaValue(fileId, name, incrementBy, cb) {
incrementBy = incrementBy || 1;
fileDb.run(
`UPDATE file_meta
SET meta_value = meta_value + ?
WHERE file_id = ? AND meta_name = ?;`,
[ incrementBy, fileId, name ],
err => {
if(cb) {
return cb(err);
}
}
);
}
loadMeta(cb) {
fileDb.each(
`SELECT meta_name, meta_value
FROM file_meta
WHERE file_id=?;`,
[ this.fileId ],
(err, meta) => {
if(meta) {
const conv = FILE_WELL_KNOWN_META[meta.meta_name];
this.meta[meta.meta_name] = conv ? conv(meta.meta_value) : meta.meta_value;
}
},
err => {
return cb(err);
}
);
}
static persistHashTag(fileId, hashTag, cb) {
fileDb.serialize( () => {
fileDb.run(
`INSERT OR IGNORE INTO hash_tag (hash_tag)
VALUES (?);`,
[ hashTag ]
);
fileDb.run(
`REPLACE INTO file_hash_tag (hash_tag_id, file_id)
VALUES (
(SELECT hash_tag_id
FROM hash_tag
WHERE hash_tag = ?),
?
);`,
[ hashTag, fileId ],
err => {
return cb(err);
}
);
});
}
loadHashTags(cb) {
fileDb.each(
`SELECT ht.hash_tag_id, ht.hash_tag
FROM hash_tag ht
WHERE ht.hash_tag_id IN (
SELECT hash_tag_id
FROM file_hash_tag
WHERE file_id=?
);`,
[ this.fileId ],
(err, hashTag) => {
if(hashTag) {
this.hashTags.add(hashTag.hash_tag);
}
},
err => {
return cb(err);
}
);
}
loadRating(cb) {
fileDb.get(
`SELECT AVG(fur.rating) AS avg_rating
FROM file_user_rating fur
INNER JOIN file f
ON f.file_id = fur.file_id
AND f.file_id = ?`,
[ this.fileId ],
(err, result) => {
if(result) {
this.userRating = result.avg_rating;
}
return cb(err);
}
);
}
setHashTags(hashTags) {
if(_.isString(hashTags)) {
this.hashTags = new Set(hashTags.split(/[\s,]+/));
} else if(Array.isArray(hashTags)) {
this.hashTags = new Set(hashTags);
} else if(hashTags instanceof Set) {
this.hashTags = hashTags;
}
}
static getWellKnownMetaValues() { return Object.keys(FILE_WELL_KNOWN_META); }
static findFiles(filter, cb) {
filter = filter || {};
let sql;
let sqlWhere = '';
let sqlOrderBy;
const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC';
function getOrderByWithCast(ob) {
if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) {
return `ORDER BY CAST(${ob} AS INTEGER)`;
}
return `ORDER BY ${ob}`;
}
function appendWhereClause(clause) {
if(sqlWhere) {
sqlWhere += ' AND ';
} else {
sqlWhere += ' WHERE ';
}
sqlWhere += clause;
}
if(filter.sort && filter.sort.length > 0) {
if(Object.keys(FILE_WELL_KNOWN_META).indexOf(filter.sort) > -1) { // sorting via a meta value?
sql =
`SELECT f.file_id
FROM file f, file_meta m`;
appendWhereClause(`f.file_id = m.file_id AND m.meta_name="${filter.sort}"`);
sqlOrderBy = `${getOrderByWithCast('m.meta_value')} ${sqlOrderDir}`;
} else {
// additional special treatment for user ratings: we need to average them
if('user_rating' === filter.sort) {
sql =
`SELECT f.file_id,
(SELECT IFNULL(AVG(rating), 0) rating
FROM file_user_rating
WHERE file_id = f.file_id)
AS avg_rating
FROM file f`;
sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`;
} else {
sql =
`SELECT f.file_id, f.${filter.sort}
FROM file f`;
sqlOrderBy = getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir;
}
}
} else {
sql =
`SELECT f.file_id
FROM file`;
sqlOrderBy = `${getOrderByWithCast('f.file_id')} ${sqlOrderDir}`;
}
if(filter.areaTag && filter.areaTag.length > 0) {
appendWhereClause(`f.area_tag="${filter.areaTag}"`);
}
if(filter.terms && filter.terms.length > 0) {
appendWhereClause(
`f.file_id IN (
SELECT rowid
FROM file_fts
WHERE file_fts MATCH "${filter.terms.replace(/"/g,'""')}"
)`
);
}
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(',');
appendWhereClause(
`f.file_id IN (
SELECT file_id
FROM file_hash_tag
WHERE hash_tag_id IN (
SELECT hash_tag_id
FROM hash_tag
WHERE hash_tag IN (${tags})
)
)`
);
}
sql += `${sqlWhere} ${sqlOrderBy};`;
const matchingFileIds = [];
fileDb.each(sql, (err, fileId) => {
if(fileId) {
matchingFileIds.push(fileId.file_id);
}
}, err => {
return cb(err, matchingFileIds);
});
}
};

582
core/file_transfer.js Normal file
View File

@ -0,0 +1,582 @@
/* jslint node: true */
'use strict';
// enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule;
const Config = require('./config.js').config;
const stringFormat = require('./string_format.js');
const Errors = require('./enig_error.js').Errors;
const DownloadQueue = require('./download_queue.js');
const StatLog = require('./stat_log.js');
const FileEntry = require('./file_entry.js');
const Log = require('./logger.js').log;
// deps
const async = require('async');
const _ = require('lodash');
const pty = require('ptyw.js');
const temptmp = require('temptmp').createTrackedSession('transfer_file');
const paths = require('path');
const fs = require('fs');
const fse = require('fs-extra');
// some consts
const SYSTEM_EOL = require('os').EOL;
const TEMP_SUFFIX = 'enigtf-'; // temp CWD/etc.
/*
Notes
-----------------------------------------------------------------------------
See core/config.js for external protocol configuration
Resources
-----------------------------------------------------------------------------
ZModem
* http://gallium.inria.fr/~doligez/zmodem/zmodem.txt
* https://github.com/protomouse/synchronet/blob/master/src/sbbs3/zmodem.c
*/
exports.moduleInfo = {
name : 'Transfer file',
desc : 'Sends or receives a file(s)',
author : 'NuSkooler',
};
exports.getModule = class TransferFileModule extends MenuModule {
constructor(options) {
super(options);
this.config = this.menuConfig.config || {};
//
// Most options can be set via extraArgs or config block
//
if(options.extraArgs) {
if(options.extraArgs.protocol) {
this.protocolConfig = Config.fileTransferProtocols[options.extraArgs.protocol];
}
if(options.extraArgs.direction) {
this.direction = options.extraArgs.direction;
}
if(options.extraArgs.sendQueue) {
this.sendQueue = options.extraArgs.sendQueue;
}
if(options.extraArgs.recvFileName) {
this.recvFileName = options.extraArgs.recvFileName;
}
if(options.extraArgs.recvDirectory) {
this.recvDirectory = options.extraArgs.recvDirectory;
}
} else {
if(this.config.protocol) {
this.protocolConfig = Config.fileTransferProtocols[this.config.protocol];
}
if(this.config.direction) {
this.direction = this.config.direction;
}
if(this.config.sendQueue) {
this.sendQueue = this.config.sendQueue;
}
if(this.config.recvFileName) {
this.recvFileName = this.config.recvFileName;
}
if(this.config.recvDirectory) {
this.recvDirectory = this.config.recvDirectory;
}
}
this.protocolConfig = this.protocolConfig || Config.fileTransferProtocols.zmodem8kSz; // try for *something*
this.direction = this.direction || 'send';
this.sendQueue = this.sendQueue || [];
// Ensure sendQueue is an array of objects that contain at least a 'path' member
this.sendQueue = this.sendQueue.map(item => {
if(_.isString(item)) {
return { path : item };
} else {
return item;
}
});
this.sentFileIds = [];
}
isSending() {
return ('send' === this.direction);
}
restorePipeAfterExternalProc() {
if(!this.pipeRestored) {
this.pipeRestored = true;
this.client.restoreDataHandler();
}
}
sendFiles(cb) {
// assume *sending* can always batch
// :TODO: Look into this further
const allFiles = this.sendQueue.map(f => f.path);
this.executeExternalProtocolHandlerForSend(allFiles, err => {
if(err) {
this.client.log.warn( { files : allFiles, error : err.message }, 'Error sending file(s)' );
} else {
const sentFiles = [];
this.sendQueue.forEach(f => {
f.sent = true;
sentFiles.push(f.path);
});
this.client.log.info( { sentFiles : sentFiles }, `Successfully sent ${sentFiles.length} file(s)` );
}
return cb(err);
});
}
/*
sendFiles(cb) {
// :TODO: built in/native protocol support
if(this.protocolConfig.external.supportsBatch) {
const allFiles = this.sendQueue.map(f => f.path);
this.executeExternalProtocolHandlerForSend(allFiles, err => {
if(err) {
this.client.log.warn( { files : allFiles, error : err.message }, 'Error sending file(s)' );
} else {
const sentFiles = [];
this.sendQueue.forEach(f => {
f.sent = true;
sentFiles.push(f.path);
});
this.client.log.info( { sentFiles : sentFiles }, `Successfully sent ${sentFiles.length} file(s)` );
}
return cb(err);
});
} else {
// :TODO: we need to prompt between entries such that users can prepare their clients
async.eachSeries(this.sendQueue, (queueItem, next) => {
this.executeExternalProtocolHandlerForSend(queueItem.path, err => {
if(err) {
this.client.log.warn( { file : queueItem.path, error : err.message }, 'Error sending file' );
} else {
queueItem.sent = true;
this.client.log.info( { sentFile : queueItem.path }, 'Successfully sent file' );
}
return next(err);
});
}, err => {
return cb(err);
});
}
}
*/
moveFileWithCollisionHandling(src, dst, cb) {
//
// Move |src| -> |dst| renaming to file(1).ext, file(2).ext, etc.
// in the case of collisions.
//
const dstPath = paths.dirname(dst);
const dstFileExt = paths.extname(dst);
const dstFileSuffix = paths.basename(dst, dstFileExt);
let renameIndex = 0;
let movedOk = false;
let tryDstPath;
async.until(
() => movedOk, // until moved OK
(cb) => {
if(0 === renameIndex) {
// try originally supplied path first
tryDstPath = dst;
} else {
tryDstPath = paths.join(dstPath, `${dstFileSuffix}(${renameIndex})${dstFileExt}`);
}
fse.move(src, tryDstPath, err => {
if(err) {
if('EEXIST' === err.code) {
renameIndex += 1;
return cb(null); // keep trying
}
return cb(err);
}
movedOk = true;
return cb(null, tryDstPath);
});
},
(err, finalPath) => {
return cb(err, finalPath);
}
);
}
recvFiles(cb) {
this.executeExternalProtocolHandlerForRecv(err => {
if(err) {
return cb(err);
}
this.recvFilePaths = [];
if(this.recvFileName) {
//
// file name specified - we expect a single file in |this.recvDirectory|
// by the name of |this.recvFileName|
//
const recvFullPath = paths.join(this.recvDirectory, this.recvFileName);
fs.stat(recvFullPath, (err, stats) => {
if(err) {
return cb(err);
}
if(!stats.isFile()) {
return cb(Errors.Invalid('Expected file entry in recv directory'));
}
this.recvFilePaths.push(recvFullPath);
return cb(null);
});
} else {
//
// Blind Upload (recv): files in |this.recvDirectory| should be named appropriately already
//
fs.readdir(this.recvDirectory, (err, files) => {
if(err) {
return cb(err);
}
// stat each to grab files only
async.each(files, (fileName, nextFile) => {
const recvFullPath = paths.join(this.recvDirectory, fileName);
fs.stat(recvFullPath, (err, stats) => {
if(err) {
this.client.log.warn('Failed to stat file', { path : recvFullPath } );
return nextFile(null); // just try the next one
}
if(stats.isFile()) {
this.recvFilePaths.push(recvFullPath);
}
return nextFile(null);
});
}, () => {
return cb(null);
});
});
}
});
}
pathWithTerminatingSeparator(path) {
if(path && paths.sep !== path.charAt(path.length - 1)) {
path = path + paths.sep;
}
return path;
}
prepAndBuildSendArgs(filePaths, cb) {
const externalArgs = this.protocolConfig.external['sendArgs'];
async.waterfall(
[
function getTempFileListPath(callback) {
const hasFileList = externalArgs.find(ea => (ea.indexOf('{fileListPath}') > -1) );
if(!hasFileList) {
return callback(null, null);
}
temptmp.open( { prefix : TEMP_SUFFIX, suffix : '.txt' }, (err, tempFileInfo) => {
if(err) {
return callback(err); // failed to create it
}
fs.write(tempFileInfo.fd, filePaths.join(SYSTEM_EOL));
fs.close(tempFileInfo.fd, err => {
return callback(err, tempFileInfo.path);
});
});
},
function createArgs(tempFileListPath, callback) {
// initial args: ignore {filePaths} as we must break that into it's own sep array items
const args = externalArgs.map(arg => {
return '{filePaths}' === arg ? arg : stringFormat(arg, {
fileListPath : tempFileListPath || '',
});
});
const filePathsPos = args.indexOf('{filePaths}');
if(filePathsPos > -1) {
// replace {filePaths} with 0:n individual entries in |args|
args.splice.apply( args, [ filePathsPos, 1 ].concat(filePaths) );
}
return callback(null, args);
}
],
(err, args) => {
return cb(err, args);
}
);
}
prepAndBuildRecvArgs(cb) {
const argsKey = this.recvFileName ? 'recvArgsNonBatch' : 'recvArgs';
const externalArgs = this.protocolConfig.external[argsKey];
const args = externalArgs.map(arg => stringFormat(arg, {
uploadDir : this.recvDirectory,
fileName : this.recvFileName || '',
}));
return cb(null, args);
}
executeExternalProtocolHandler(args, cb) {
const external = this.protocolConfig.external;
const cmd = external[`${this.direction}Cmd`];
this.client.log.debug(
{ cmd : cmd, args : args, tempDir : this.recvDirectory, direction : this.direction },
'Executing external protocol'
);
const externalProc = pty.spawn(cmd, args, {
cols : this.client.term.termWidth,
rows : this.client.term.termHeight,
cwd : this.recvDirectory,
});
this.client.setTemporaryDirectDataHandler(data => {
// needed for things like sz/rz
if(external.escapeTelnet) {
const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape
externalProc.write(new Buffer(tmp, 'binary'));
} else {
externalProc.write(data);
}
});
externalProc.on('data', data => {
// needed for things like sz/rz
if(external.escapeTelnet) {
const tmp = data.toString('binary').replace(/\xff/g, '\xff\xff'); // escape
this.client.term.rawWrite(new Buffer(tmp, 'binary'));
} else {
this.client.term.rawWrite(data);
}
});
externalProc.once('close', () => {
return this.restorePipeAfterExternalProc();
});
externalProc.once('exit', (exitCode) => {
this.client.log.debug( { cmd : cmd, args : args, exitCode : exitCode }, 'Process exited' );
this.restorePipeAfterExternalProc();
externalProc.removeAllListeners();
return cb(exitCode ? Errors.ExternalProcess(`Process exited with exit code ${exitCode}`, 'EBADEXIT') : null);
});
}
executeExternalProtocolHandlerForSend(filePaths, cb) {
if(!Array.isArray(filePaths)) {
filePaths = [ filePaths ];
}
this.prepAndBuildSendArgs(filePaths, (err, args) => {
if(err) {
return cb(err);
}
this.executeExternalProtocolHandler(args, err => {
return cb(err);
});
});
}
executeExternalProtocolHandlerForRecv(cb) {
this.prepAndBuildRecvArgs( (err, args) => {
if(err) {
return cb(err);
}
this.executeExternalProtocolHandler(args, err => {
return cb(err);
});
});
}
getMenuResult() {
if(this.isSending()) {
return { sentFileIds : this.sentFileIds };
} else {
return { recvFilePaths : this.recvFilePaths };
}
}
updateSendStats(cb) {
let downloadBytes = 0;
let downloadCount = 0;
let fileIds = [];
async.each(this.sendQueue, (queueItem, next) => {
if(!queueItem.sent) {
return next(null);
}
if(queueItem.fileId) {
fileIds.push(queueItem.fileId);
}
if(_.isNumber(queueItem.byteSize)) {
downloadCount += 1;
downloadBytes += queueItem.byteSize;
return next(null);
}
// we just have a path - figure it out
fs.stat(queueItem.path, (err, stats) => {
if(err) {
this.client.log.warn( { error : err.message, path : queueItem.path }, 'File stat failed' );
} else {
downloadCount += 1;
downloadBytes += stats.size;
}
return next(null);
});
}, () => {
// All stats/meta currently updated via fire & forget - if this is ever a issue, we can wait for callbacks
StatLog.incrementUserStat(this.client.user, 'dl_total_count', downloadCount);
StatLog.incrementUserStat(this.client.user, 'dl_total_bytes', downloadBytes);
StatLog.incrementSystemStat('dl_total_count', downloadCount);
StatLog.incrementSystemStat('dl_total_bytes', downloadBytes);
fileIds.forEach(fileId => {
FileEntry.incrementAndPersistMetaValue(fileId, 'dl_count', 1);
});
return cb(null);
});
}
updateRecvStats(cb) {
let uploadBytes = 0;
let uploadCount = 0;
async.each(this.recvFilePaths, (filePath, next) => {
// we just have a path - figure it out
fs.stat(filePath, (err, stats) => {
if(err) {
this.client.log.warn( { error : err.message, path : filePath }, 'File stat failed' );
} else {
uploadCount += 1;
uploadBytes += stats.size;
}
return next(null);
});
}, () => {
StatLog.incrementUserStat(this.client.user, 'ul_total_count', uploadCount);
StatLog.incrementUserStat(this.client.user, 'ul_total_bytes', uploadBytes);
StatLog.incrementSystemStat('ul_total_count', uploadCount);
StatLog.incrementSystemStat('ul_total_bytes', uploadBytes);
return cb(null);
});
}
initSequence() {
const self = this;
// :TODO: break this up to send|recv
async.series(
[
function validateConfig(callback) {
if(self.isSending()) {
if(!Array.isArray(self.sendQueue)) {
self.sendQueue = [ self.sendQueue ];
}
}
return callback(null);
},
function transferFiles(callback) {
if(self.isSending()) {
self.sendFiles( err => {
if(err) {
return callback(err);
}
const sentFileIds = [];
self.sendQueue.forEach(queueItem => {
if(queueItem.sent && queueItem.fileId) {
sentFileIds.push(queueItem.fileId);
}
});
if(sentFileIds.length > 0) {
// remove items we sent from the D/L queue
const dlQueue = new DownloadQueue(self.client);
dlQueue.removeItems(sentFileIds);
self.sentFileIds = sentFileIds;
}
return callback(null);
});
} else {
self.recvFiles( err => {
return callback(err);
});
}
},
function cleanupTempFiles(callback) {
temptmp.cleanup( paths => {
Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' );
});
return callback(null);
},
function updateUserAndSystemStats(callback) {
if(self.isSending()) {
return self.updateSendStats(callback);
} else {
return self.updateRecvStats(callback);
}
}
],
err => {
if(err) {
self.client.log.warn( { error : err.message }, 'File transfer error');
}
return self.prevMenu();
}
);
}
};

62
core/file_util.js Normal file
View File

@ -0,0 +1,62 @@
/* jslint node: true */
'use strict';
// ENiGMA½
// deps
const fse = require('fs-extra');
const paths = require('path');
const async = require('async');
exports.moveFileWithCollisionHandling = moveFileWithCollisionHandling;
exports.pathWithTerminatingSeparator = pathWithTerminatingSeparator;
//
// Move |src| -> |dst| renaming to file(1).ext, file(2).ext, etc.
// in the case of collisions.
//
function moveFileWithCollisionHandling(src, dst, cb) {
const dstPath = paths.dirname(dst);
const dstFileExt = paths.extname(dst);
const dstFileSuffix = paths.basename(dst, dstFileExt);
let renameIndex = 0;
let movedOk = false;
let tryDstPath;
async.until(
() => movedOk, // until moved OK
(cb) => {
if(0 === renameIndex) {
// try originally supplied path first
tryDstPath = dst;
} else {
tryDstPath = paths.join(dstPath, `${dstFileSuffix}(${renameIndex})${dstFileExt}`);
}
fse.move(src, tryDstPath, err => {
if(err) {
if('EEXIST' === err.code) {
renameIndex += 1;
return cb(null); // keep trying
}
return cb(err);
}
movedOk = true;
return cb(null, tryDstPath);
});
},
(err, finalPath) => {
return cb(err, finalPath);
}
);
}
function pathWithTerminatingSeparator(path) {
if(path && paths.sep !== path.charAt(path.length - 1)) {
path = path + paths.sep;
}
return path;
}

View File

@ -13,6 +13,7 @@ const getUserIdAndName = require('./user.js').getUserIdAndName;
const cleanControlCodes = require('./string_util.js').cleanControlCodes; const cleanControlCodes = require('./string_util.js').cleanControlCodes;
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
const stringFormat = require('./string_format.js'); const stringFormat = require('./string_format.js');
const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher;
// deps // deps
const async = require('async'); const async = require('async');
@ -20,12 +21,6 @@ const assert = require('assert');
const _ = require('lodash'); const _ = require('lodash');
const moment = require('moment'); const moment = require('moment');
exports.FullScreenEditorModule = FullScreenEditorModule;
// :TODO: clean this up:
exports.getModule = FullScreenEditorModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'Full Screen Editor (FSE)', name : 'Full Screen Editor (FSE)',
desc : 'A full screen editor/viewer', desc : 'A full screen editor/viewer',
@ -66,7 +61,7 @@ exports.moduleInfo = {
*/ */
var MCICodeIds = { const MciCodeIds = {
ViewModeHeader : { ViewModeHeader : {
From : 1, From : 1,
To : 2, To : 2,
@ -98,72 +93,192 @@ var MCICodeIds = {
}, },
}; };
function FullScreenEditorModule(options) { // :TODO: convert code in this class to newer styles, conventions, etc. There is a lot of experimental stuff here that has better (DRY) alternatives
MenuModule.call(this, options);
var self = this; exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModule extends MessageAreaConfTempSwitcher(MenuModule) {
var config = this.menuConfig.config;
// constructor(options) {
// menuConfig.config: super(options);
// editorType : email | area
// editorMode : view | edit | quote
//
// menuConfig.config or extraArgs
// messageAreaTag
// messageIndex / messageTotal
// toUserId
//
this.editorType = config.editorType;
this.editorMode = config.editorMode;
if(config.messageAreaTag) {
this.messageAreaTag = config.messageAreaTag;
}
this.messageIndex = config.messageIndex || 0;
this.messageTotal = config.messageTotal || 0;
this.toUserId = config.toUserId || 0;
// extraArgs can override some config const self = this;
if(_.isObject(options.extraArgs)) { const config = this.menuConfig.config;
if(options.extraArgs.messageAreaTag) {
this.messageAreaTag = options.extraArgs.messageAreaTag; //
// menuConfig.config:
// editorType : email | area
// editorMode : view | edit | quote
//
// menuConfig.config or extraArgs
// messageAreaTag
// messageIndex / messageTotal
// toUserId
//
this.editorType = config.editorType;
this.editorMode = config.editorMode;
if(config.messageAreaTag) {
this.messageAreaTag = config.messageAreaTag;
} }
if(options.extraArgs.messageIndex) {
this.messageIndex = options.extraArgs.messageIndex; this.messageIndex = config.messageIndex || 0;
this.messageTotal = config.messageTotal || 0;
this.toUserId = config.toUserId || 0;
// extraArgs can override some config
if(_.isObject(options.extraArgs)) {
if(options.extraArgs.messageAreaTag) {
this.messageAreaTag = options.extraArgs.messageAreaTag;
}
if(options.extraArgs.messageIndex) {
this.messageIndex = options.extraArgs.messageIndex;
}
if(options.extraArgs.messageTotal) {
this.messageTotal = options.extraArgs.messageTotal;
}
if(options.extraArgs.toUserId) {
this.toUserId = options.extraArgs.toUserId;
}
} }
if(options.extraArgs.messageTotal) {
this.messageTotal = options.extraArgs.messageTotal; this.isReady = false;
}
if(options.extraArgs.toUserId) { if(_.has(options, 'extraArgs.message')) {
this.toUserId = options.extraArgs.toUserId; this.setMessage(options.extraArgs.message);
} else if(_.has(options, 'extraArgs.replyToMessage')) {
this.replyToMessage = options.extraArgs.replyToMessage;
} }
this.menuMethods = {
//
// Validation stuff
//
viewValidationListener : function(err, cb) {
var errMsgView = self.viewControllers.header.getView(MciCodeIds.ReplyEditModeHeader.ErrorMsg);
var newFocusViewId;
if(errMsgView) {
if(err) {
errMsgView.setText(err.message);
if(MciCodeIds.ViewModeHeader.Subject === err.view.getId()) {
// :TODO: for "area" mode, should probably just bail if this is emtpy (e.g. cancel)
}
} else {
errMsgView.clearText();
}
}
cb(newFocusViewId);
},
headerSubmit : function(formData, extraArgs, cb) {
self.switchToBody();
return cb(null);
},
editModeEscPressed : function(formData, extraArgs, cb) {
self.footerMode = 'editor' === self.footerMode ? 'editorMenu' : 'editor';
self.switchFooter(function next(err) {
if(err) {
// :TODO:... what now?
console.log(err)
} else {
switch(self.footerMode) {
case 'editor' :
if(!_.isUndefined(self.viewControllers.footerEditorMenu)) {
//self.viewControllers.footerEditorMenu.setFocus(false);
self.viewControllers.footerEditorMenu.detachClientEvents();
}
self.viewControllers.body.switchFocus(1);
self.observeEditorEvents();
break;
case 'editorMenu' :
self.viewControllers.body.setFocus(false);
self.viewControllers.footerEditorMenu.switchFocus(1);
break;
default : throw new Error('Unexpected mode');
}
}
return cb(null);
});
},
editModeMenuQuote : function(formData, extraArgs, cb) {
self.viewControllers.footerEditorMenu.setFocus(false);
self.displayQuoteBuilder();
return cb(null);
},
appendQuoteEntry: function(formData, extraArgs, cb) {
// :TODO: Dont' use magic # ID's here
var quoteMsgView = self.viewControllers.quoteBuilder.getView(1);
if(self.newQuoteBlock) {
self.newQuoteBlock = false;
quoteMsgView.addText(self.getQuoteByHeader());
}
var quoteText = self.viewControllers.quoteBuilder.getView(3).getItem(formData.value.quote);
quoteMsgView.addText(quoteText);
//
// If this is *not* the last item, advance. Otherwise, do nothing as we
// don't want to jump back to the top and repeat already quoted lines
//
var quoteListView = self.viewControllers.quoteBuilder.getView(3);
if(quoteListView.getData() !== quoteListView.getCount() - 1) {
quoteListView.focusNext();
} else {
self.quoteBuilderFinalize();
}
return cb(null);
},
quoteBuilderEscPressed : function(formData, extraArgs, cb) {
self.quoteBuilderFinalize();
return cb(null);
},
/*
replyDiscard : function(formData, extraArgs) {
// :TODO: need to prompt yes/no
// :TODO: @method for fallback would be better
self.prevMenu();
},
*/
editModeMenuHelp : function(formData, extraArgs, cb) {
self.viewControllers.footerEditorMenu.setFocus(false);
return self.displayHelp(cb);
},
///////////////////////////////////////////////////////////////////////
// View Mode
///////////////////////////////////////////////////////////////////////
viewModeMenuHelp : function(formData, extraArgs, cb) {
self.viewControllers.footerView.setFocus(false);
return self.displayHelp(cb);
}
};
} }
this.isReady = false; isEditMode() {
return 'edit' === this.editorMode;
}
this.isEditMode = function() { isViewMode() {
return 'edit' === self.editorMode; return 'view' === this.editorMode;
}; }
this.isViewMode = function() {
return 'view' === self.editorMode;
};
this.isLocalEmail = function() { isLocalEmail() {
return Message.WellKnownAreaTags.Private === self.messageAreaTag; return Message.WellKnownAreaTags.Private === this.messageAreaTag;
}; }
this.isReply = function() { isReply() {
return !_.isUndefined(self.replyToMessage); return !_.isUndefined(this.replyToMessage);
}; }
this.getFooterName = function() { getFooterName() {
return 'footer' + _.capitalize(self.footerMode); // e.g. 'footerEditor', 'footerEditorMenu', ... return 'footer' + _.upperFirst(this.footerMode); // e.g. 'footerEditor', 'footerEditorMenu', ...
}; }
this.getFormId = function(name) { getFormId(name) {
return { return {
header : 0, header : 0,
body : 1, body : 1,
@ -174,27 +289,13 @@ function FullScreenEditorModule(options) {
help : 50, help : 50,
}[name]; }[name];
}; }
/*ViewModeHeader : {
From : 1,
To : 2,
Subject : 3,
DateTime : 5,
MsgNum : 6,
MsgTotal : 7,
ViewCount : 8,
HashTags : 9,
MessageID : 10,
ReplyToMsgID : 11
},*/
// :TODO: convert to something like this for all view acces: // :TODO: convert to something like this for all view acces:
this.getHeaderViews = function() { getHeaderViews() {
var vc = self.viewControllers.header; var vc = this.viewControllers.header;
if(self.isViewMode()) { if(this.isViewMode()) {
return { return {
from : vc.getView(1), from : vc.getView(1),
to : vc.getView(2), to : vc.getView(2),
@ -206,61 +307,55 @@ function FullScreenEditorModule(options) {
}; };
} }
}; }
this.setInitialFooterMode = function() { setInitialFooterMode() {
switch(self.editorMode) { switch(this.editorMode) {
case 'edit' : self.footerMode = 'editor'; break; case 'edit' : this.footerMode = 'editor'; break;
case 'view' : self.footerMode = 'view'; break; case 'view' : this.footerMode = 'view'; break;
} }
}; }
this.buildMessage = function() { buildMessage() {
var headerValues = self.viewControllers.header.getFormData().value; const headerValues = this.viewControllers.header.getFormData().value;
var msgOpts = { var msgOpts = {
areaTag : self.messageAreaTag, areaTag : this.messageAreaTag,
toUserName : headerValues.to, toUserName : headerValues.to,
fromUserName : headerValues.from, fromUserName : this.client.user.username,
subject : headerValues.subject, subject : headerValues.subject,
message : self.viewControllers.body.getFormData().value.message, message : this.viewControllers.body.getFormData().value.message,
}; };
if(self.isReply()) { if(this.isReply()) {
msgOpts.replyToMsgId = self.replyToMessage.messageId; msgOpts.replyToMsgId = this.replyToMessage.messageId;
} }
self.message = new Message(msgOpts); this.message = new Message(msgOpts);
}; }
/* setMessage(message) {
this.setBodyMessageViewText = function() { this.message = message;
self.bodyMessageView.setText(cleanControlCodes(self.message.message));
};
*/
this.setMessage = function(message) {
self.message = message;
updateMessageAreaLastReadId( updateMessageAreaLastReadId(
self.client.user.userId, self.messageAreaTag, self.message.messageId, () => { this.client.user.userId, this.messageAreaTag, this.message.messageId, () => {
if(self.isReady) { if(this.isReady) {
self.initHeaderViewMode(); this.initHeaderViewMode();
self.initFooterViewMode(); this.initFooterViewMode();
var bodyMessageView = self.viewControllers.body.getView(1); var bodyMessageView = this.viewControllers.body.getView(1);
if(bodyMessageView && _.has(self, 'message.message')) { if(bodyMessageView && _.has(this, 'message.message')) {
//self.setBodyMessageViewText(); bodyMessageView.setText(cleanControlCodes(this.message.message));
bodyMessageView.setText(cleanControlCodes(self.message.message));
//bodyMessageView.redraw();
} }
} }
} }
); );
}; }
getMessage(cb) {
const self = this;
this.getMessage = function(cb) {
async.series( async.series(
[ [
function buildIfNecessary(callback) { function buildIfNecessary(callback) {
@ -296,24 +391,22 @@ function FullScreenEditorModule(options) {
cb(err, self.message); cb(err, self.message);
} }
); );
}; }
this.updateUserStats = function(cb) { updateUserStats(cb) {
if(Message.isPrivateAreaTag(this.message.areaTag)) { if(Message.isPrivateAreaTag(this.message.areaTag)) {
if(cb) { if(cb) {
return cb(null); cb(null);
} }
return; // don't inc stats for private messages
} }
StatLog.incrementUserStat( return StatLog.incrementUserStat(this.client.user, 'post_count', 1, cb);
self.client.user, }
'post_count',
1, redrawFooter(options, cb) {
cb const self = this;
);
};
this.redrawFooter = function(options, cb) {
async.waterfall( async.waterfall(
[ [
function moveToFooterPosition(callback) { function moveToFooterPosition(callback) {
@ -339,7 +432,7 @@ function FullScreenEditorModule(options) {
callback(null); callback(null);
}, },
function displayFooterArt(callback) { function displayFooterArt(callback) {
var footerArt = self.menuConfig.config.art[options.footerName]; const footerArt = self.menuConfig.config.art[options.footerName];
theme.displayThemedAsset( theme.displayThemedAsset(
footerArt, footerArt,
@ -355,10 +448,11 @@ function FullScreenEditorModule(options) {
cb(err, artData); cb(err, artData);
} }
); );
}; }
this.redrawScreen = function(cb) { redrawScreen(cb) {
var comps = [ 'header', 'body' ]; var comps = [ 'header', 'body' ];
const self = this;
var art = self.menuConfig.config.art; var art = self.menuConfig.config.art;
self.client.term.rawWrite(ansi.resetScreen()); self.client.term.rawWrite(ansi.resetScreen());
@ -399,43 +493,44 @@ function FullScreenEditorModule(options) {
cb(err); cb(err);
} }
); );
}; }
switchFooter(cb) {
var footerName = this.getFooterName();
this.switchFooter = function(cb) { this.redrawFooter( { footerName : footerName, clear : true }, (err, artData) => {
var footerName = self.getFooterName();
self.redrawFooter( { footerName : footerName, clear : true }, function artDisplayed(err, artData) {
if(err) { if(err) {
cb(err); cb(err);
return; return;
} }
var formId = self.getFormId(footerName); var formId = this.getFormId(footerName);
if(_.isUndefined(self.viewControllers[footerName])) { if(_.isUndefined(this.viewControllers[footerName])) {
var menuLoadOpts = { var menuLoadOpts = {
callingMenu : self, callingMenu : this,
formId : formId, formId : formId,
mciMap : artData.mciMap mciMap : artData.mciMap
}; };
self.addViewController( this.addViewController(
footerName, footerName,
new ViewController( { client : self.client, formId : formId } ) new ViewController( { client : this.client, formId : formId } )
).loadFromMenuConfig(menuLoadOpts, function footerReady(err) { ).loadFromMenuConfig(menuLoadOpts, err => {
cb(err); cb(err);
}); });
} else { } else {
self.viewControllers[footerName].redrawAll(); this.viewControllers[footerName].redrawAll();
cb(null); cb(null);
} }
}); });
}; }
this.initSequence = function() { initSequence() {
var mciData = { }; var mciData = { };
const self = this;
var art = self.menuConfig.config.art; var art = self.menuConfig.config.art;
assert(_.isObject(art)); assert(_.isObject(art));
async.series( async.series(
@ -489,10 +584,10 @@ function FullScreenEditorModule(options) {
} }
} }
); );
}; }
this.createInitialViews = function(mciData, cb) { createInitialViews(mciData, cb) {
const self = this;
var menuLoadOpts = { callingMenu : self }; var menuLoadOpts = { callingMenu : self };
async.series( async.series(
@ -603,11 +698,11 @@ function FullScreenEditorModule(options) {
cb(err); cb(err);
} }
); );
}; }
this.mciReadyHandler = function(mciData, cb) { mciReadyHandler(mciData, cb) {
self.createInitialViews(mciData, function viewsCreated(err) { this.createInitialViews(mciData, err => {
// :TODO: Can probably be replaced with @systemMethod:validateUserNameExists when the framework is in // :TODO: Can probably be replaced with @systemMethod:validateUserNameExists when the framework is in
// place - if this is for existing usernames else validate spec // place - if this is for existing usernames else validate spec
@ -627,103 +722,94 @@ function FullScreenEditorModule(options) {
cb(err); cb(err);
}); });
}; }
this.updateEditModePosition = function(pos) { updateEditModePosition(pos) {
if(self.isEditMode()) { if(this.isEditMode()) {
var posView = self.viewControllers.footerEditor.getView(1); var posView = this.viewControllers.footerEditor.getView(1);
if(posView) { if(posView) {
self.client.term.rawWrite(ansi.savePos()); this.client.term.rawWrite(ansi.savePos());
posView.setText(_.padLeft(String(pos.row + 1), 2, '0') + ',' + _.padLeft(String(pos.col + 1), 2, '0')); // :TODO: Use new formatting techniques here, e.g. state.cursorPositionRow, cursorPositionCol and cursorPositionFormat
self.client.term.rawWrite(ansi.restorePos()); posView.setText(_.padStart(String(pos.row + 1), 2, '0') + ',' + _.padEnd(String(pos.col + 1), 2, '0'));
this.client.term.rawWrite(ansi.restorePos());
} }
} }
}; }
this.updateTextEditMode = function(mode) { updateTextEditMode(mode) {
if(self.isEditMode()) { if(this.isEditMode()) {
var modeView = self.viewControllers.footerEditor.getView(2); var modeView = this.viewControllers.footerEditor.getView(2);
if(modeView) { if(modeView) {
self.client.term.rawWrite(ansi.savePos()); this.client.term.rawWrite(ansi.savePos());
modeView.setText('insert' === mode ? 'INS' : 'OVR'); modeView.setText('insert' === mode ? 'INS' : 'OVR');
self.client.term.rawWrite(ansi.restorePos()); this.client.term.rawWrite(ansi.restorePos());
} }
} }
}; }
this.setHeaderText = function(id, text) { setHeaderText(id, text) {
var v = self.viewControllers.header.getView(id); this.setViewText('header', id, text);
if(v) { }
v.setText(text);
}
};
this.initHeaderViewMode = function() { initHeaderViewMode() {
assert(_.isObject(self.message)); assert(_.isObject(this.message));
self.setHeaderText(MCICodeIds.ViewModeHeader.From, self.message.fromUserName); this.setHeaderText(MciCodeIds.ViewModeHeader.From, this.message.fromUserName);
self.setHeaderText(MCICodeIds.ViewModeHeader.To, self.message.toUserName); this.setHeaderText(MciCodeIds.ViewModeHeader.To, this.message.toUserName);
self.setHeaderText(MCICodeIds.ViewModeHeader.Subject, self.message.subject); this.setHeaderText(MciCodeIds.ViewModeHeader.Subject, this.message.subject);
self.setHeaderText(MCICodeIds.ViewModeHeader.DateTime, moment(self.message.modTimestamp).format(self.client.currentTheme.helpers.getDateTimeFormat())); this.setHeaderText(MciCodeIds.ViewModeHeader.DateTime, moment(this.message.modTimestamp).format(this.client.currentTheme.helpers.getDateTimeFormat()));
self.setHeaderText(MCICodeIds.ViewModeHeader.MsgNum, (self.messageIndex + 1).toString()); this.setHeaderText(MciCodeIds.ViewModeHeader.MsgNum, (this.messageIndex + 1).toString());
self.setHeaderText(MCICodeIds.ViewModeHeader.MsgTotal, self.messageTotal.toString()); this.setHeaderText(MciCodeIds.ViewModeHeader.MsgTotal, this.messageTotal.toString());
self.setHeaderText(MCICodeIds.ViewModeHeader.ViewCount, self.message.viewCount); this.setHeaderText(MciCodeIds.ViewModeHeader.ViewCount, this.message.viewCount);
self.setHeaderText(MCICodeIds.ViewModeHeader.HashTags, 'TODO hash tags'); this.setHeaderText(MciCodeIds.ViewModeHeader.HashTags, 'TODO hash tags');
self.setHeaderText(MCICodeIds.ViewModeHeader.MessageID, self.message.messageId); this.setHeaderText(MciCodeIds.ViewModeHeader.MessageID, this.message.messageId);
self.setHeaderText(MCICodeIds.ViewModeHeader.ReplyToMsgID, self.message.replyToMessageId); this.setHeaderText(MciCodeIds.ViewModeHeader.ReplyToMsgID, this.message.replyToMessageId);
}; }
this.initHeaderReplyEditMode = function() { initHeaderReplyEditMode() {
assert(_.isObject(self.replyToMessage)); assert(_.isObject(this.replyToMessage));
self.setHeaderText(MCICodeIds.ReplyEditModeHeader.To, self.replyToMessage.fromUserName); this.setHeaderText(MciCodeIds.ReplyEditModeHeader.To, this.replyToMessage.fromUserName);
// //
// We want to prefix the subject with "RE: " only if it's not already // We want to prefix the subject with "RE: " only if it's not already
// that way -- avoid RE: RE: RE: RE: ... // that way -- avoid RE: RE: RE: RE: ...
// //
let newSubj = self.replyToMessage.subject; let newSubj = this.replyToMessage.subject;
if(false === /^RE:\s+/i.test(newSubj)) { if(false === /^RE:\s+/i.test(newSubj)) {
newSubj = `RE: ${newSubj}`; newSubj = `RE: ${newSubj}`;
} }
self.setHeaderText(MCICodeIds.ReplyEditModeHeader.Subject, newSubj); this.setHeaderText(MciCodeIds.ReplyEditModeHeader.Subject, newSubj);
}; }
this.initFooterViewMode = function() { initFooterViewMode() {
this.setViewText('footerView', MciCodeIds.ViewModeFooter.MsgNum, (this.messageIndex + 1).toString() );
function setFooterText(id, text) { this.setViewText('footerView', MciCodeIds.ViewModeFooter.MsgTotal, this.messageTotal.toString() );
var v = self.viewControllers.footerView.getView(id); }
if(v) {
v.setText(text);
}
}
setFooterText(MCICodeIds.ViewModeFooter.MsgNum, (self.messageIndex + 1).toString()); displayHelp(cb) {
setFooterText(MCICodeIds.ViewModeFooter.MsgTotal, self.messageTotal.toString()); this.client.term.rawWrite(ansi.resetScreen());
};
this.displayHelp = function(cb) {
self.client.term.rawWrite(ansi.resetScreen());
theme.displayThemeArt( theme.displayThemeArt(
{ name : self.menuConfig.config.art.help, client : self.client }, { name : this.menuConfig.config.art.help, client : this.client },
() => { () => {
self.client.waitForKeyPress( () => { this.client.waitForKeyPress( () => {
self.redrawScreen( () => { this.redrawScreen( () => {
self.viewControllers[self.getFooterName()].setFocus(true); this.viewControllers[this.getFooterName()].setFocus(true);
return cb(null); return cb(null);
}); });
}); });
} }
); );
}; }
this.displayQuoteBuilder = function() { displayQuoteBuilder() {
// //
// Clear body area // Clear body area
// //
self.newQuoteBlock = true; this.newQuoteBlock = true;
const self = this;
async.waterfall( async.waterfall(
[ [
@ -779,19 +865,19 @@ function FullScreenEditorModule(options) {
} }
} }
); );
}; }
this.observeEditorEvents = function() { observeEditorEvents() {
var bodyView = self.viewControllers.body.getView(1); const bodyView = this.viewControllers.body.getView(1);
bodyView.on('edit position', function cursorPosUpdate(pos) { bodyView.on('edit position', pos => {
self.updateEditModePosition(pos); this.updateEditModePosition(pos);
}); });
bodyView.on('text edit mode', function textEditMode(mode) { bodyView.on('text edit mode', mode => {
self.updateTextEditMode(mode); this.updateTextEditMode(mode);
}); });
}; }
/* /*
this.observeViewPosition = function() { this.observeViewPosition = function() {
@ -801,43 +887,43 @@ function FullScreenEditorModule(options) {
}; };
*/ */
this.switchToHeader = function() { switchToHeader() {
self.viewControllers.body.setFocus(false); this.viewControllers.body.setFocus(false);
self.viewControllers.header.switchFocus(2); // to this.viewControllers.header.switchFocus(2); // to
}
switchToBody() {
this.viewControllers.header.setFocus(false);
this.viewControllers.body.switchFocus(1);
this.observeEditorEvents();
}; };
this.switchToBody = function() { switchToFooter() {
self.viewControllers.header.setFocus(false); this.viewControllers.header.setFocus(false);
self.viewControllers.body.switchFocus(1); this.viewControllers.body.setFocus(false);
self.observeEditorEvents(); this.viewControllers[this.getFooterName()].switchFocus(1); // HM1
}; }
this.switchToFooter = function() { switchFromQuoteBuilderToBody() {
self.viewControllers.header.setFocus(false); this.viewControllers.quoteBuilder.setFocus(false);
self.viewControllers.body.setFocus(false); var body = this.viewControllers.body.getView(1);
self.viewControllers[self.getFooterName()].switchFocus(1); // HM1
};
this.switchFromQuoteBuilderToBody = function() {
self.viewControllers.quoteBuilder.setFocus(false);
var body = self.viewControllers.body.getView(1);
body.redraw(); body.redraw();
self.viewControllers.body.switchFocus(1); this.viewControllers.body.switchFocus(1);
// :TODO: create method (DRY) // :TODO: create method (DRY)
self.updateTextEditMode(body.getTextEditMode()); this.updateTextEditMode(body.getTextEditMode());
self.updateEditModePosition(body.getEditPosition()); this.updateEditModePosition(body.getEditPosition());
self.observeEditorEvents(); this.observeEditorEvents();
}; }
this.quoteBuilderFinalize = function() { quoteBuilderFinalize() {
// :TODO: fix magic #'s // :TODO: fix magic #'s
var quoteMsgView = self.viewControllers.quoteBuilder.getView(1); var quoteMsgView = this.viewControllers.quoteBuilder.getView(1);
var msgView = self.viewControllers.body.getView(1); var msgView = this.viewControllers.body.getView(1);
var quoteLines = quoteMsgView.getData(); var quoteLines = quoteMsgView.getData();
@ -848,164 +934,43 @@ function FullScreenEditorModule(options) {
quoteMsgView.setText(''); quoteMsgView.setText('');
var footerName = self.getFooterName(); this.footerMode = 'editor';
self.footerMode = 'editor'; this.switchFooter( () => {
this.switchFromQuoteBuilderToBody();
self.switchFooter(function switched(err) {
self.switchFromQuoteBuilderToBody();
}); });
}; }
this.getQuoteByHeader = function() { getQuoteByHeader() {
let quoteFormat = this.menuConfig.config.quoteFormats; let quoteFormat = this.menuConfig.config.quoteFormats;
if(Array.isArray(quoteFormat)) { if(Array.isArray(quoteFormat)) {
quoteFormat = quoteFormat[ Math.floor(Math.random() * quoteFormat.length) ]; quoteFormat = quoteFormat[ Math.floor(Math.random() * quoteFormat.length) ];
} else if(!_.isString(quoteFormat)) { } else if(!_.isString(quoteFormat)) {
quoteFormat = 'On {dateTime} {userName} said...'; quoteFormat = 'On {dateTime} {userName} said...';
} }
const dtFormat = this.menuConfig.config.quoteDateTimeFormat || self.client.currentTheme.helpers.getDateTimeFormat(); const dtFormat = this.menuConfig.config.quoteDateTimeFormat || this.client.currentTheme.helpers.getDateTimeFormat();
return stringFormat(quoteFormat, { return stringFormat(quoteFormat, {
dateTime : moment(self.replyToMessage.modTimestamp).format(dtFormat), dateTime : moment(this.replyToMessage.modTimestamp).format(dtFormat),
userName : self.replyToMessage.fromUserName, userName : this.replyToMessage.fromUserName,
}); });
}; }
this.menuMethods = { enter() {
// if(this.messageAreaTag) {
// Validation stuff this.tempMessageConfAndAreaSwitch(this.messageAreaTag);
//
viewValidationListener : function(err, cb) {
var errMsgView = self.viewControllers.header.getView(MCICodeIds.ReplyEditModeHeader.ErrorMsg);
var newFocusViewId;
if(errMsgView) {
if(err) {
errMsgView.setText(err.message);
if(MCICodeIds.ViewModeHeader.Subject === err.view.getId()) {
// :TODO: for "area" mode, should probably just bail if this is emtpy (e.g. cancel)
}
} else {
errMsgView.clearText();
}
}
cb(newFocusViewId);
},
headerSubmit : function(formData, extraArgs, cb) {
self.switchToBody();
return cb(null);
},
editModeEscPressed : function(formData, extraArgs, cb) {
self.footerMode = 'editor' === self.footerMode ? 'editorMenu' : 'editor';
self.switchFooter(function next(err) {
if(err) {
// :TODO:... what now?
console.log(err)
} else {
switch(self.footerMode) {
case 'editor' :
if(!_.isUndefined(self.viewControllers.footerEditorMenu)) {
//self.viewControllers.footerEditorMenu.setFocus(false);
self.viewControllers.footerEditorMenu.detachClientEvents();
}
self.viewControllers.body.switchFocus(1);
self.observeEditorEvents();
break;
case 'editorMenu' :
self.viewControllers.body.setFocus(false);
self.viewControllers.footerEditorMenu.switchFocus(1);
break;
default : throw new Error('Unexpected mode');
}
}
return cb(null);
});
},
editModeMenuQuote : function(formData, extraArgs, cb) {
self.viewControllers.footerEditorMenu.setFocus(false);
self.displayQuoteBuilder();
return cb(null);
},
appendQuoteEntry: function(formData, extraArgs, cb) {
// :TODO: Dont' use magic # ID's here
var quoteMsgView = self.viewControllers.quoteBuilder.getView(1);
if(self.newQuoteBlock) {
self.newQuoteBlock = false;
quoteMsgView.addText(self.getQuoteByHeader());
}
var quoteText = self.viewControllers.quoteBuilder.getView(3).getItem(formData.value.quote);
quoteMsgView.addText(quoteText);
//
// If this is *not* the last item, advance. Otherwise, do nothing as we
// don't want to jump back to the top and repeat already quoted lines
//
var quoteListView = self.viewControllers.quoteBuilder.getView(3);
if(quoteListView.getData() !== quoteListView.getCount() - 1) {
quoteListView.focusNext();
} else {
self.quoteBuilderFinalize();
}
return cb(null);
},
quoteBuilderEscPressed : function(formData, extraArgs, cb) {
self.quoteBuilderFinalize();
return cb(null);
},
/*
replyDiscard : function(formData, extraArgs) {
// :TODO: need to prompt yes/no
// :TODO: @method for fallback would be better
self.prevMenu();
},
*/
editModeMenuHelp : function(formData, extraArgs, cb) {
self.viewControllers.footerEditorMenu.setFocus(false);
return self.displayHelp(cb);
},
///////////////////////////////////////////////////////////////////////
// View Mode
///////////////////////////////////////////////////////////////////////
viewModeMenuHelp : function(formData, extraArgs, cb) {
self.viewControllers.footerView.setFocus(false);
return self.displayHelp(cb);
} }
};
if(_.has(options, 'extraArgs.message')) { super.enter();
this.setMessage(options.extraArgs.message);
} else if(_.has(options, 'extraArgs.replyToMessage')) {
this.replyToMessage = options.extraArgs.replyToMessage;
}
}
require('util').inherits(FullScreenEditorModule, MenuModule);
require('./mod_mixins.js').MessageAreaConfTempSwitcher.call(FullScreenEditorModule.prototype);
FullScreenEditorModule.prototype.enter = function() {
if(this.messageAreaTag) {
this.tempMessageConfAndAreaSwitch(this.messageAreaTag);
} }
FullScreenEditorModule.super_.prototype.enter.call(this); leave() {
}; this.tempMessageConfAndAreaRestore();
super.leave();
}
FullScreenEditorModule.prototype.leave = function() { mciReady(mciData, cb) {
this.tempMessageConfAndAreaRestore(); return this.mciReadyHandler(mciData, cb);
FullScreenEditorModule.super_.prototype.leave.call(this); }
};
FullScreenEditorModule.prototype.mciReady = function(mciData, cb) {
this.mciReadyHandler(mciData, cb);
}; };

View File

@ -8,7 +8,7 @@ let FNV1a = require('./fnv1a.js');
let _ = require('lodash'); let _ = require('lodash');
let iconv = require('iconv-lite'); let iconv = require('iconv-lite');
let moment = require('moment'); let moment = require('moment');
let uuid = require('node-uuid'); //let uuid = require('node-uuid');
let os = require('os'); let os = require('os');
let packageJson = require('../package.json'); let packageJson = require('../package.json');
@ -39,7 +39,7 @@ exports.getQuotePrefix = getQuotePrefix;
// Namespace for RFC-4122 name based UUIDs generated from // Namespace for RFC-4122 name based UUIDs generated from
// FTN kludges MSGID + AREA // FTN kludges MSGID + AREA
// //
const ENIGMA_FTN_MSGID_NAMESPACE = uuid.parse('a5c7ae11-420c-4469-a116-0e9a6d8d2654'); //const ENIGMA_FTN_MSGID_NAMESPACE = uuid.parse('a5c7ae11-420c-4469-a116-0e9a6d8d2654');
// See list here: https://github.com/Mithgol/node-fidonet-jam // See list here: https://github.com/Mithgol/node-fidonet-jam

View File

@ -113,30 +113,39 @@ HorizontalMenuView.prototype.setItems = function(items) {
this.positionCacheExpired = true; this.positionCacheExpired = true;
}; };
HorizontalMenuView.prototype.focusNext = function() {
if(this.items.length - 1 === this.focusedItemIndex) {
this.focusedItemIndex = 0;
} else {
this.focusedItemIndex++;
}
// :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes
this.redraw();
HorizontalMenuView.super_.prototype.focusNext.call(this);
};
HorizontalMenuView.prototype.focusPrevious = function() {
if(0 === this.focusedItemIndex) {
this.focusedItemIndex = this.items.length - 1;
} else {
this.focusedItemIndex--;
}
// :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes
this.redraw();
HorizontalMenuView.super_.prototype.focusPrevious.call(this);
};
HorizontalMenuView.prototype.onKeyPress = function(ch, key) { HorizontalMenuView.prototype.onKeyPress = function(ch, key) {
if(key) { if(key) {
var prevFocusedItemIndex = this.focusedItemIndex;
if(this.isKeyMapped('left', key.name)) { if(this.isKeyMapped('left', key.name)) {
if(0 === this.focusedItemIndex) { this.focusPrevious();
this.focusedItemIndex = this.items.length - 1;
} else {
this.focusedItemIndex--;
}
} else if(this.isKeyMapped('right', key.name)) { } else if(this.isKeyMapped('right', key.name)) {
if(this.items.length - 1 === this.focusedItemIndex) { this.focusNext();
this.focusedItemIndex = 0;
} else {
this.focusedItemIndex++;
}
}
if(prevFocusedItemIndex !== this.focusedItemIndex) {
// :TODO: Optimize this in cases where we only need to redraw two items. Always the case now, somtimes
// if this is changed to allow scrolling
this.redraw();
return;
} }
} }

View File

@ -15,22 +15,30 @@ module.exports = class KeyEntryView extends View {
super(options); super(options);
this.eatTabKey = options.eatTabKey || true; this.eatTabKey = options.eatTabKey || true;
this.caseInsensitive = options.caseInsensitive || true; this.caseInsensitive = options.caseInsensitive || true;
// :TODO: allow (by default) only supplied keys[] to even draw if(Array.isArray(options.keys)) {
if(this.caseInsensitive) {
this.keys = options.keys.map( k => k.toUpperCase() );
} else {
this.keys = options.keys;
}
}
} }
onKeyPress(ch, key) { onKeyPress(ch, key) {
if(ch && isPrintable(ch)) { const drawKey = ch;
this.redraw(); // sets position
this.client.term.write(stylizeString(ch, this.textStyle));
}
if(ch && this.caseInsensitive) { if(ch && this.caseInsensitive) {
ch = ch.toUpperCase(); ch = ch.toUpperCase();
} }
if(drawKey && isPrintable(drawKey) && (!this.keys || this.keys.indexOf(ch) > -1)) {
this.redraw(); // sets position
this.client.term.write(stylizeString(ch, this.textStyle));
}
this.keyEntered = ch || key.name; this.keyEntered = ch || key.name;
if(key && 'tab' === key.name && !this.eatTabKey) { if(key && 'tab' === key.name && !this.eatTabKey) {
@ -54,6 +62,12 @@ module.exports = class KeyEntryView extends View {
this.caseInsensitive = propValue; this.caseInsensitive = propValue;
} }
break; break;
case 'keys' :
if(Array.isArray(propValue)) {
this.keys = propValue;
}
break;
} }
super.setPropertyValue(propName, propValue); super.setPropertyValue(propName, propValue);

64
core/listening_server.js Normal file
View File

@ -0,0 +1,64 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const logger = require('./logger.js');
// deps
const async = require('async');
const listeningServers = {}; // packageName -> info
exports.startup = startup;
exports.shutdown = shutdown;
exports.getServer = getServer;
function startup(cb) {
return startListening(cb);
}
function shutdown(cb) {
return cb(null);
}
function getServer(packageName) {
return listeningServers[packageName];
}
function startListening(cb) {
const moduleUtil = require('./module_util.js'); // late load so we get Config
async.each( [ 'login', 'content' ], (category, next) => {
moduleUtil.loadModulesForCategory(`${category}Servers`, (err, module) => {
// :TODO: use enig error here!
if(err) {
if('EENIGMODDISABLED' === err.code) {
logger.log.debug(err.message);
} else {
logger.log.info( { err : err }, 'Failed loading module');
}
return;
}
const moduleInst = new module.getModule();
try {
moduleInst.createServer();
if(!moduleInst.listen()) {
throw new Error('Failed listening');
}
listeningServers[module.moduleInfo.packageName] = {
instance : moduleInst,
info : module.moduleInfo,
};
} catch(e) {
logger.log.error(e, 'Exception caught creating server!');
}
}, err => {
return next(err);
});
}, err => {
return cb(err);
});
}

View File

@ -0,0 +1,87 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const conf = require('./config.js');
const logger = require('./logger.js');
const ServerModule = require('./server_module.js').ServerModule;
const clientConns = require('./client_connections.js');
// deps
const _ = require('lodash');
module.exports = class LoginServerModule extends ServerModule {
constructor() {
super();
}
// :TODO: we need to max connections -- e.g. from config 'maxConnections'
prepareClient(client, cb) {
const theme = require('./theme.js');
//
// Choose initial theme before we have user context
//
if('*' === conf.config.preLoginTheme) {
client.user.properties.theme_id = theme.getRandomTheme() || '';
} else {
client.user.properties.theme_id = conf.config.preLoginTheme;
}
theme.setClientTheme(client, client.user.properties.theme_id);
return cb(null); // note: currently useless to use cb here - but this may change...again...
}
handleNewClient(client, clientSock, modInfo) {
//
// Start tracking the client. We'll assign it an ID which is
// just the index in our connections array.
//
if(_.isUndefined(client.session)) {
client.session = {};
}
client.session.serverName = modInfo.name;
client.session.isSecure = modInfo.isSecure || false;
clientConns.addNewClient(client, clientSock);
client.on('ready', readyOptions => {
client.startIdleMonitor();
// Go to module -- use default error handler
this.prepareClient(client, () => {
require('./connect.js').connectEntry(client, readyOptions.firstMenu);
});
});
client.on('end', () => {
clientConns.removeClient(client);
});
client.on('error', err => {
logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message);
});
client.on('close', err => {
const logFunc = err ? logger.log.info : logger.log.debug;
logFunc( { clientId : client.session.id }, 'Connection closed');
clientConns.removeClient(client);
});
client.on('idle timeout', () => {
client.log.info('User idle timeout expired');
client.menuStack.goto('idleLogoff', err => {
if(err) {
// likely just doesn't exist
client.term.write('\nIdle timeout expired. Goodbye!\n');
client.end();
}
});
});
}
};

View File

@ -1,324 +1,423 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var PluginModule = require('./plugin_module.js').PluginModule; const PluginModule = require('./plugin_module.js').PluginModule;
var theme = require('./theme.js'); const theme = require('./theme.js');
var art = require('./art.js'); const ansi = require('./ansi_term.js');
var Log = require('./logger.js').log; const ViewController = require('./view_controller.js').ViewController;
var ansi = require('./ansi_term.js'); const menuUtil = require('./menu_util.js');
var asset = require('./asset.js'); const Config = require('./config.js').config;
var ViewController = require('./view_controller.js').ViewController; const stringFormat = require('../core/string_format.js');
var menuUtil = require('./menu_util.js'); const MultiLineEditTextView = require('../core/multi_line_edit_text_view.js').MultiLineEditTextView;
var Config = require('./config.js').config; const Errors = require('../core/enig_error.js').Errors;
var async = require('async'); // deps
var assert = require('assert'); const async = require('async');
var _ = require('lodash'); const assert = require('assert');
const _ = require('lodash');
exports.MenuModule = MenuModule; exports.MenuModule = class MenuModule extends PluginModule {
constructor(options) {
super(options);
// :TODO: some of this is a bit off... should pause after finishedLoading() this.menuName = options.menuName;
this.menuConfig = options.menuConfig;
this.client = options.client;
this.menuConfig.options = options.menuConfig.options || {};
this.menuMethods = {}; // methods called from @method's
this.menuConfig.config = this.menuConfig.config || {};
this.cls = _.isBoolean(this.menuConfig.options.cls) ? this.menuConfig.options.cls : Config.menus.cls;
function MenuModule(options) { this.viewControllers = {};
PluginModule.call(this, options); }
var self = this; enter() {
this.menuName = options.menuName; this.initSequence();
this.menuConfig = options.menuConfig; }
this.client = options.client;
// :TODO: this and the line below with .config creates empty ({}) objects in the theme --
// ...which we really should not do. If they aren't there already, don't use 'em.
this.menuConfig.options = options.menuConfig.options || {};
this.menuMethods = {}; // methods called from @method's
this.cls = _.isBoolean(this.menuConfig.options.cls) ? leave() {
this.menuConfig.options.cls : this.detachViewControllers();
Config.menus.cls; }
this.menuConfig.config = this.menuConfig.config || {}; initSequence() {
const self = this;
const mciData = {};
let pausePosition;
this.initViewControllers(); async.series(
[
function beforeDisplayArt(callback) {
self.beforeArt(callback);
},
function displayMenuArt(callback) {
if(!_.isString(self.menuConfig.art)) {
return callback(null);
}
this.shouldPause = function() { self.displayAsset(
return 'end' === self.menuConfig.options.pause || true === self.menuConfig.options.pause; self.menuConfig.art,
}; self.menuConfig.options,
(err, artData) => {
if(err) {
self.client.log.trace('Could not display art', { art : self.menuConfig.art, reason : err.message } );
} else {
mciData.menu = artData.mciMap;
}
this.hasNextTimeout = function() { return callback(null); // any errors are non-fatal
return _.isNumber(self.menuConfig.options.nextTimeout); }
}; );
},
function moveToPromptLocation(callback) {
if(self.menuConfig.prompt) {
// :TODO: fetch and move cursor to prompt location, if supplied. See notes/etc. on placements
}
this.autoNextMenu = function(cb) { return callback(null);
function goNext() { },
if(_.isString(self.menuConfig.next) || _.isArray(self.menuConfig.next)) { function displayPromptArt(callback) {
if(!_.isString(self.menuConfig.prompt)) {
return callback(null);
}
if(!_.isObject(self.menuConfig.promptConfig)) {
return callback(Errors.MissingConfig('Prompt specified but no "promptConfig" block found'));
}
self.displayAsset(
self.menuConfig.promptConfig.art,
self.menuConfig.options,
(err, artData) => {
if(artData) {
mciData.prompt = artData.mciMap;
}
return callback(err); // pass err here; prompts *must* have art
}
);
},
function recordCursorPosition(callback) {
if(!self.shouldPause()) {
return callback(null); // cursor position not needed
}
self.client.once('cursor position report', pos => {
pausePosition = { row : pos[0], col : 1 };
self.client.log.trace('After art position recorded', pausePosition );
return callback(null);
});
self.client.term.rawWrite(ansi.queryPos());
},
function afterArtDisplayed(callback) {
return self.mciReady(mciData, callback);
},
function displayPauseIfRequested(callback) {
if(!self.shouldPause()) {
return callback(null);
}
return self.pausePrompt(pausePosition, callback);
},
function finishAndNext(callback) {
self.finishedLoading();
return self.autoNextMenu(callback);
}
],
err => {
if(err) {
self.client.log.warn('Error during init sequence', { error : err.message } );
return self.prevMenu( () => { /* dummy */ } );
}
}
);
}
beforeArt(cb) {
if(_.isNumber(this.menuConfig.options.baudRate)) {
// :TODO: some terminals not supporting cterm style emulated baud rate end up displaying a broken ESC sequence or a single "r" here
this.client.term.rawWrite(ansi.setEmulatedBaudRate(this.menuConfig.options.baudRate));
}
if(this.cls) {
this.client.term.rawWrite(ansi.resetScreen());
}
return cb(null);
}
mciReady(mciData, cb) {
// available for sub-classes
return cb(null);
}
finishedLoading() {
// nothing in base
}
getSaveState() {
// nothing in base
}
restoreSavedState(/*savedState*/) {
// nothing in base
}
getMenuResult() {
// default to the formData that was provided @ a submit, if any
return this.submitFormData;
}
nextMenu(cb) {
if(!this.haveNext()) {
return this.prevMenu(cb); // no next, go to prev
}
return this.client.menuStack.next(cb);
}
prevMenu(cb) {
return this.client.menuStack.prev(cb);
}
gotoMenu(name, options, cb) {
return this.client.menuStack.goto(name, options, cb);
}
addViewController(name, vc) {
assert(!this.viewControllers[name], `ViewController by the name of "${name}" already exists!`);
this.viewControllers[name] = vc;
return vc;
}
detachViewControllers() {
Object.keys(this.viewControllers).forEach( name => {
this.viewControllers[name].detachClientEvents();
});
}
shouldPause() {
return ('end' === this.menuConfig.options.pause || true === this.menuConfig.options.pause);
}
hasNextTimeout() {
return _.isNumber(this.menuConfig.options.nextTimeout);
}
haveNext() {
return (_.isString(this.menuConfig.next) || _.isArray(this.menuConfig.next));
}
autoNextMenu(cb) {
const self = this;
function gotoNextMenu() {
if(self.haveNext()) {
return menuUtil.handleNext(self.client, self.menuConfig.next, {}, cb); return menuUtil.handleNext(self.client, self.menuConfig.next, {}, cb);
} else { } else {
return self.prevMenu(cb); return self.prevMenu(cb);
} }
} }
if(_.has(self.menuConfig, 'runtime.autoNext') && true === self.menuConfig.runtime.autoNext) { if(_.has(this.menuConfig, 'runtime.autoNext') && true === this.menuConfig.runtime.autoNext) {
/* if(this.hasNextTimeout()) {
If 'next' is supplied, we'll use it. Otherwise, utlize fallback which
may be explicit (supplied) or non-explicit (previous menu)
'next' may be a simple asset, or a object with next.asset and
extrArgs
next: assetSpec
-or-
next: {
asset: assetSpec
extraArgs: ...
}
*/
if(self.hasNextTimeout()) {
setTimeout( () => { setTimeout( () => {
return goNext(); return gotoNextMenu();
}, this.menuConfig.options.nextTimeout); }, this.menuConfig.options.nextTimeout);
} else { } else {
goNext(); return gotoNextMenu();
} }
} }
};
this.haveNext = function() {
return (_.isString(this.menuConfig.next) || _.isArray(this.menuConfig.next));
};
}
require('util').inherits(MenuModule, PluginModule);
require('./mod_mixins.js').ViewControllerManagement.call(MenuModule.prototype);
MenuModule.prototype.enter = function() {
if(_.isString(this.menuConfig.desc)) {
this.client.currentStatus = this.menuConfig.desc;
} else {
this.client.currentStatus = 'Browsing menus';
} }
this.initSequence(); standardMCIReadyHandler(mciData, cb) {
}; //
// A quick rundown:
// * We may have mciData.menu, mciData.prompt, or both.
// * Prompt form is favored over menu form if both are present.
// * Standard/prefdefined MCI entries must load both (e.g. %BN is expected to resolve)
//
const self = this;
MenuModule.prototype.initSequence = function() { async.series(
var mciData = { }; [
const self = this; function addViewControllers(callback) {
_.forEach(mciData, (mciMap, name) => {
assert('menu' === name || 'prompt' === name);
self.addViewController(name, new ViewController( { client : self.client } ) );
});
async.series( return callback(null);
[ },
function beforeDisplayArt(callback) { function createMenu(callback) {
self.beforeArt(callback); if(!self.viewControllers.menu) {
}, return callback(null);
function displayMenuArt(callback) {
if(_.isString(self.menuConfig.art)) {
theme.displayThemedAsset(
self.menuConfig.art,
self.client,
self.menuConfig.options, // can include .font, .trailingLF, etc.
function displayed(err, artData) {
if(err) {
self.client.log.trace( { art : self.menuConfig.art, error : err.message }, 'Could not display art');
} else {
mciData.menu = artData.mciMap;
}
callback(null); // non-fatal
}
);
} else {
callback(null);
}
},
function moveToPromptLocation(callback) {
if(self.menuConfig.prompt) {
// :TODO: fetch and move cursor to prompt location, if supplied. See notes/etc. on placements
}
callback(null);
},
function displayPromptArt(callback) {
if(_.isString(self.menuConfig.prompt)) {
// If a prompt is specified, we need the configuration
if(!_.isObject(self.menuConfig.promptConfig)) {
callback(new Error('Prompt specified but configuraiton not found!'));
return;
} }
// Prompts *must* have art. If it's missing it's an error const menuLoadOpts = {
// :TODO: allow inline prompts in the future, e.g. @inline:memberName -> { "memberName" : { "text" : "stuff", ... } }
var promptConfig = self.menuConfig.promptConfig;
theme.displayThemedAsset(
promptConfig.art,
self.client,
self.menuConfig.options, // can include .font, .trailingLF, etc.
function displayed(err, artData) {
if(!err) {
mciData.prompt = artData.mciMap;
}
callback(err);
});
} else {
callback(null);
}
},
function recordCursorPosition(callback) {
if(self.shouldPause()) {
self.client.once('cursor position report', function cpr(pos) {
self.afterArtPos = pos;
self.client.log.trace( { position : pos }, 'After art position recorded');
callback(null);
});
self.client.term.write(ansi.queryPos());
} else {
callback(null);
}
},
function afterArtDisplayed(callback) {
self.mciReady(mciData, callback);
},
function displayPauseIfRequested(callback) {
if(self.shouldPause()) {
self.client.term.write(ansi.goto(self.afterArtPos[0], 1));
// :TODO: really need a client.term.pause() that uses the correct art/etc.
theme.displayThemedPause( { client : self.client }, function keyPressed() {
callback(null);
});
} else {
callback(null);
}
},
function finishAndNext(callback) {
self.finishedLoading();
self.autoNextMenu(callback);
}
],
function complete(err) {
if(err) {
console.log(err)
// :TODO: what to do exactly?????
return self.prevMenu( () => {
// dummy
});
}
}
);
};
MenuModule.prototype.getSaveState = function() {
// nothing in base
};
MenuModule.prototype.restoreSavedState = function(/*savedState*/) {
// nothing in base
};
MenuModule.prototype.nextMenu = function(cb) {
//
// If we don't actually have |next|, we'll go previous
//
if(!this.haveNext()) {
return this.prevMenu(cb);
}
this.client.menuStack.next(cb);
};
MenuModule.prototype.prevMenu = function(cb) {
this.client.menuStack.prev(cb);
};
MenuModule.prototype.gotoMenu = function(name, options, cb) {
this.client.menuStack.goto(name, options, cb);
};
MenuModule.prototype.leave = function() {
this.detachViewControllers();
};
MenuModule.prototype.beforeArt = function(cb) {
//
// Set emulated baud rate - note that some terminals will display
// part of the ESC sequence here (generally a single 'r') if they
// do not support cterm style baud rates
//
if(_.isNumber(this.menuConfig.options.baudRate)) {
this.client.term.write(ansi.setEmulatedBaudRate(this.menuConfig.options.baudRate));
}
if(this.cls) {
this.client.term.write(ansi.resetScreen());
}
return cb(null);
};
MenuModule.prototype.mciReady = function(mciData, cb) {
// Reserved for sub classes
cb(null);
};
MenuModule.prototype.standardMCIReadyHandler = function(mciData, cb) {
//
// A quick rundown:
// * We may have mciData.menu, mciData.prompt, or both.
// * Prompt form is favored over menu form if both are present.
// * Standard/prefdefined MCI entries must load both (e.g. %BN is expected to resolve)
//
var self = this;
async.series(
[
function addViewControllers(callback) {
_.forEach(mciData, function entry(mciMap, name) {
assert('menu' === name || 'prompt' === name);
self.addViewController(name, new ViewController( { client : self.client } ));
});
callback(null);
},
function createMenu(callback) {
if(self.viewControllers.menu) {
var menuLoadOpts = {
mciMap : mciData.menu, mciMap : mciData.menu,
callingMenu : self, callingMenu : self,
withoutForm : _.isObject(mciData.prompt), withoutForm : _.isObject(mciData.prompt),
}; };
self.viewControllers.menu.loadFromMenuConfig(menuLoadOpts, function menuLoaded(err) { self.viewControllers.menu.loadFromMenuConfig(menuLoadOpts, err => {
callback(err); return callback(err);
}); });
} else { },
callback(null); function createPrompt(callback) {
} if(!self.viewControllers.prompt) {
}, return callback(null);
function createPrompt(callback) { }
if(self.viewControllers.prompt) {
var promptLoadOpts = { const promptLoadOpts = {
callingMenu : self, callingMenu : self,
mciMap : mciData.prompt, mciMap : mciData.prompt,
}; };
self.viewControllers.prompt.loadFromPromptConfig(promptLoadOpts, function promptLoaded(err) { self.viewControllers.prompt.loadFromPromptConfig(promptLoadOpts, err => {
callback(err); return callback(err);
}); });
} else { }
callback(null); ],
err => {
return cb(err);
}
);
}
displayAsset(name, options, cb) {
if(_.isFunction(options)) {
cb = options;
options = {};
}
if(options.clearScreen) {
this.client.term.rawWrite(ansi.resetScreen());
}
return theme.displayThemedAsset(
name,
this.client,
Object.assign( { font : this.menuConfig.config.font }, options ),
(err, artData) => {
if(cb) {
return cb(err, artData);
} }
} }
], );
function complete(err) { }
cb(err);
prepViewController(name, formId, artData, cb) {
if(_.isUndefined(this.viewControllers[name])) {
const vcOpts = {
client : this.client,
formId : formId,
};
const vc = this.addViewController(name, new ViewController(vcOpts));
const loadOpts = {
callingMenu : this,
mciMap : artData.mciMap,
formId : formId,
};
return vc.loadFromMenuConfig(loadOpts, cb);
} }
);
};
MenuModule.prototype.finishedLoading = function() { this.viewControllers[name].setFocus(true);
}; return cb(null);
}
MenuModule.prototype.getMenuResult = function() { prepViewControllerWithArt(name, formId, options, cb) {
// nothing in base this.displayAsset(
this.menuConfig.config.art[name],
options,
(err, artData) => {
if(err) {
return cb(err);
}
return this.prepViewController(name, formId, artData, cb);
}
);
}
optionalMoveToPosition(position) {
if(position) {
position.x = position.row || position.x || 1;
position.y = position.col || position.y || 1;
this.client.term.rawWrite(ansi.goto(position.x, position.y));
}
}
pausePrompt(position, cb) {
if(!cb && _.isFunction(position)) {
cb = position;
position = null;
}
this.optionalMoveToPosition(position);
return theme.displayThemedPause(this.client, cb);
}
/*
:TODO: this needs quite a bit of work - but would be nice: promptForInput(..., (err, formData) => ... )
promptForInput(formName, name, options, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
}
options.viewController = this.viewControllers[formName];
this.optionalMoveToPosition(options.position);
return theme.displayThemedPrompt(name, this.client, options, cb);
}
*/
setViewText(formName, mciId, text, appendMultiLine) {
const view = this.viewControllers[formName].getView(mciId);
if(!view) {
return;
}
if(appendMultiLine && (view instanceof MultiLineEditTextView)) {
view.addText(text);
} else {
view.setText(text);
}
}
updateCustomViewTextsWithFilter(formName, startId, fmtObj, options) {
options = options || {};
let textView;
let customMciId = startId;
const config = this.menuConfig.config;
const endId = options.endId || 99; // we'll fail to get a view before 99
while(customMciId <= endId && (textView = this.viewControllers[formName].getView(customMciId)) ) {
const key = `${formName}InfoFormat${customMciId}`; // e.g. "mainInfoFormat10"
const format = config[key];
if(format && (!options.filter || options.filter.find(f => format.indexOf(f) > - 1))) {
const text = stringFormat(format, fmtObj);
if(options.appendMultiLine && (textView instanceof MultiLineEditTextView)) {
textView.addText(text);
} else {
textView.setText(text);
}
}
++customMciId;
}
}
}; };

View File

@ -99,6 +99,7 @@ module.exports = class MenuStack {
if(!cb && _.isFunction(options)) { if(!cb && _.isFunction(options)) {
cb = options; cb = options;
options = {};
} }
const self = this; const self = this;
@ -133,6 +134,12 @@ module.exports = class MenuStack {
currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState(); currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState();
currentModuleInfo.instance.leave(); currentModuleInfo.instance.leave();
const menuFlags = (options && Array.isArray(options.menuFlags)) ? options.menuFlags : modInst.menuConfig.options.menuFlags;
if(menuFlags.includes('noHistory')) {
this.pop().instance.leave(); // leave & remove current
}
} }
self.push({ self.push({
@ -146,11 +153,17 @@ module.exports = class MenuStack {
modInst.restoreSavedState(options.savedState); modInst.restoreSavedState(options.savedState);
} }
modInst.enter(); const stackEntries = self.stack.map(stackEntry => {
let name = stackEntry.name;
if(stackEntry.instance.menuConfig.options.menuFlags.length > 0) {
name += ` (${stackEntry.instance.menuConfig.options.menuFlags.join(', ')})`;
}
return name;
});
self.client.log.trace( self.client.log.trace( { stack : stackEntries }, 'Updated menu stack' );
{ stack : _.map(self.stack, stackEntry => stackEntry.name) },
'Updated menu stack'); modInst.enter();
if(cb) { if(cb) {
cb(null); cb(null);

View File

@ -64,6 +64,12 @@ function loadMenu(options, cb) {
}, },
function loadMenuModule(menuConfig, callback) { function loadMenuModule(menuConfig, callback) {
menuConfig.options = menuConfig.options || {};
menuConfig.options.menuFlags = menuConfig.options.menuFlags || [];
if(!Array.isArray(menuConfig.options.menuFlags)) {
menuConfig.options.menuFlags = [ menuConfig.options.menuFlags ];
}
const modAsset = asset.getModuleAsset(menuConfig.module); const modAsset = asset.getModuleAsset(menuConfig.module);
const modSupplied = null !== modAsset; const modSupplied = null !== modAsset;
@ -88,18 +94,20 @@ function loadMenu(options, cb) {
{ moduleName : modData.name, extraArgs : options.extraArgs, config : modData.config, info : modData.mod.modInfo }, { moduleName : modData.name, extraArgs : options.extraArgs, config : modData.config, info : modData.mod.modInfo },
'Creating menu module instance'); 'Creating menu module instance');
let moduleInstance;
try { try {
const moduleInstance = new modData.mod.getModule({ moduleInstance = new modData.mod.getModule({
menuName : options.name, menuName : options.name,
menuConfig : modData.config, menuConfig : modData.config,
extraArgs : options.extraArgs, extraArgs : options.extraArgs,
client : options.client, client : options.client,
lastMenuResult : options.lastMenuResult, lastMenuResult : options.lastMenuResult,
}); });
return callback(null, moduleInstance);
} catch(e) { } catch(e) {
return callback(e); return callback(e);
} }
return callback(null, moduleInstance);
} }
], ],
(err, modInst) => { (err, modInst) => {
@ -121,8 +129,8 @@ function getFormConfigByIDAndMap(menuConfig, formId, mciMap, cb) {
return; return;
} }
var formForId = menuConfig.form[formId]; const formForId = menuConfig.form[formId];
var mciReqKey = _.filter(_.pluck(_.sortBy(mciMap, 'code'), 'code'), function(mci) { const mciReqKey = _.filter(_.map(_.sortBy(mciMap, 'code'), 'code'), (mci) => {
return MCIViewFactory.UserViewCodes.indexOf(mci) > -1; return MCIViewFactory.UserViewCodes.indexOf(mci) > -1;
}).join(''); }).join('');
@ -142,8 +150,7 @@ function getFormConfigByIDAndMap(menuConfig, formId, mciMap, cb) {
// //
if(_.has(formForId, 'mci') || _.has(formForId, 'submit')) { if(_.has(formForId, 'mci') || _.has(formForId, 'submit')) {
Log.trace('Using generic configuration'); Log.trace('Using generic configuration');
cb(null, formForId); return cb(null, formForId);
return;
} }
cb(new Error('No matching form configuration found for key \'' + mciReqKey + '\'')); cb(new Error('No matching form configuration found for key \'' + mciReqKey + '\''));

View File

@ -77,6 +77,20 @@ MenuView.prototype.setItems = function(items) {
} }
}; };
MenuView.prototype.removeItem = function(index) {
this.items.splice(index, 1);
if(this.focusItems) {
this.focusItems.splice(index, 1);
}
if(this.focusedItemIndex >= index) {
this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, 0);
}
this.positionCacheExpired = true;
};
MenuView.prototype.getCount = function() { MenuView.prototype.getCount = function() {
return this.items.length; return this.items.length;
}; };
@ -92,12 +106,10 @@ MenuView.prototype.getItem = function(index) {
}; };
MenuView.prototype.focusNext = function() { MenuView.prototype.focusNext = function() {
// nothing @ base currently
this.emit('index update', this.focusedItemIndex); this.emit('index update', this.focusedItemIndex);
}; };
MenuView.prototype.focusPrevious = function() { MenuView.prototype.focusPrevious = function() {
// nothign @ base currently
this.emit('index update', this.focusedItemIndex); this.emit('index update', this.focusedItemIndex);
}; };
@ -143,12 +155,13 @@ MenuView.prototype.setItemSpacing = function(itemSpacing) {
MenuView.prototype.setPropertyValue = function(propName, value) { MenuView.prototype.setPropertyValue = function(propName, value) {
switch(propName) { switch(propName) {
case 'itemSpacing' : this.setItemSpacing(value); break; case 'itemSpacing' : this.setItemSpacing(value); break;
case 'items' : this.setItems(value); break; case 'items' : this.setItems(value); break;
case 'focusItems' : this.setFocusItems(value); break; case 'focusItems' : this.setFocusItems(value); break;
case 'hotKeys' : this.setHotKeys(value); break; case 'hotKeys' : this.setHotKeys(value); break;
case 'hotKeySubmit' : this.hotKeySubmit = value; break; case 'hotKeySubmit' : this.hotKeySubmit = value; break;
case 'justify' : this.justify = value; break; case 'justify' : this.justify = value; break;
case 'focusItemIndex' : this.focusedItemIndex = value; break;
} }
MenuView.super_.prototype.setPropertyValue.call(this, propName, value); MenuView.super_.prototype.setPropertyValue.call(this, propName, value);

View File

@ -1,21 +1,23 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
let msgDb = require('./database.js').dbs.message; const msgDb = require('./database.js').dbs.message;
let wordWrapText = require('./word_wrap.js').wordWrapText; const wordWrapText = require('./word_wrap.js').wordWrapText;
let ftnUtil = require('./ftn_util.js'); const ftnUtil = require('./ftn_util.js');
let createNamedUUID = require('./uuid_util.js').createNamedUUID; const createNamedUUID = require('./uuid_util.js').createNamedUUID;
const getISOTimestampString = require('./database.js').getISOTimestampString;
let uuid = require('node-uuid'); // deps
let async = require('async'); const uuidParse = require('uuid-parse');
let _ = require('lodash'); const async = require('async');
let assert = require('assert'); const _ = require('lodash');
let moment = require('moment'); const assert = require('assert');
const iconvEncode = require('iconv-lite').encode; const moment = require('moment');
const iconvEncode = require('iconv-lite').encode;
module.exports = Message; module.exports = Message;
const ENIGMA_MESSAGE_UUID_NAMESPACE = uuid.parse('154506df-1df8-46b9-98f8-ebb5815baaf8'); const ENIGMA_MESSAGE_UUID_NAMESPACE = uuidParse.parse('154506df-1df8-46b9-98f8-ebb5815baaf8');
function Message(options) { function Message(options) {
options = options || {}; options = options || {};
@ -64,11 +66,6 @@ function Message(options) {
this.isPrivate = function() { this.isPrivate = function() {
return Message.isPrivateAreaTag(this.areaTag); return Message.isPrivateAreaTag(this.areaTag);
}; };
this.getMessageTimestampString = function(ts) {
ts = ts || moment();
return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ');
};
} }
Message.WellKnownAreaTags = { Message.WellKnownAreaTags = {
@ -138,7 +135,7 @@ Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) {
subject = iconvEncode(subject.toUpperCase().trim(), 'CP437'); subject = iconvEncode(subject.toUpperCase().trim(), 'CP437');
body = iconvEncode(body.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437'); body = iconvEncode(body.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437');
return uuid.unparse(createNamedUUID(ENIGMA_MESSAGE_UUID_NAMESPACE, Buffer.concat( [ areaTag, modTimestamp, subject, body ] ))); return uuidParse.unparse(createNamedUUID(ENIGMA_MESSAGE_UUID_NAMESPACE, Buffer.concat( [ areaTag, modTimestamp, subject, body ] )));
}; };
Message.getMessageIdByUuid = function(uuid, cb) { Message.getMessageIdByUuid = function(uuid, cb) {
@ -374,7 +371,7 @@ Message.prototype.persist = function(cb) {
msgDb.run( msgDb.run(
`INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) `INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?, ?);`,
[ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, self.getMessageTimestampString(msgTimestamp) ], [ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, getISOTimestampString(msgTimestamp) ],
function inserted(err) { // use non-arrow function for 'this' scope function inserted(err) { // use non-arrow function for 'this' scope
if(!err) { if(!err) {
self.messageId = this.lastID; self.messageId = this.lastID;

View File

@ -2,11 +2,12 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const msgDb = require('./database.js').dbs.message; const msgDb = require('./database.js').dbs.message;
const Config = require('./config.js').config; const Config = require('./config.js').config;
const Message = require('./message.js'); const Message = require('./message.js');
const Log = require('./logger.js').log; const Log = require('./logger.js').log;
const msgNetRecord = require('./msg_network.js').recordMessage; const msgNetRecord = require('./msg_network.js').recordMessage;
const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs;
// deps // deps
const async = require('async'); const async = require('async');
@ -32,34 +33,11 @@ exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId;
exports.persistMessage = persistMessage; exports.persistMessage = persistMessage;
exports.trimMessageAreasScheduledEvent = trimMessageAreasScheduledEvent; exports.trimMessageAreasScheduledEvent = trimMessageAreasScheduledEvent;
//
// Method for sorting Message areas and conferences
// If the sort key is present and is a number, sort in numerical order;
// Otherwise, use a locale comparison on the sort key or name as a fallback
//
function sortAreasOrConfs(areasOrConfs, type) {
let entryA;
let entryB;
areasOrConfs.sort((a, b) => {
entryA = a[type];
entryB = b[type];
if(_.isNumber(entryA.sort) && _.isNumber(entryB.sort)) {
return entryA.sort - entryB.sort;
} else {
const keyA = entryA.sort ? entryA.sort.toString() : entryA.name;
const keyB = entryB.sort ? entryB.sort.toString() : entryB.name;
return keyA.localeCompare(keyB);
}
});
}
function getAvailableMessageConferences(client, options) { function getAvailableMessageConferences(client, options) {
options = options || { includeSystemInternal : false }; options = options || { includeSystemInternal : false };
// perform ACS check per conf & omit system_internal if desired // perform ACS check per conf & omit system_internal if desired
return _.omit(Config.messageConferences, (conf, confTag) => { return _.omitBy(Config.messageConferences, (conf, confTag) => {
if(!options.includeSystemInternal && 'system_internal' === confTag) { if(!options.includeSystemInternal && 'system_internal' === confTag) {
return true; return true;
} }
@ -95,7 +73,7 @@ function getAvailableMessageAreasByConfTag(confTag, options) {
return areas; return areas;
} else { } else {
// perform ACS check per area // perform ACS check per area
return _.omit(areas, area => { return _.omitBy(areas, area => {
return !options.client.acs.hasMessageAreaRead(area); return !options.client.acs.hasMessageAreaRead(area);
}); });
} }
@ -269,7 +247,7 @@ function changeMessageConference(client, confTag, cb) {
} }
function changeMessageAreaWithOptions(client, areaTag, options, cb) { function changeMessageAreaWithOptions(client, areaTag, options, cb) {
options = options || {}; options = options || {}; // :TODO: this is currently pointless... cb is required...
async.waterfall( async.waterfall(
[ [
@ -305,7 +283,7 @@ function changeMessageAreaWithOptions(client, areaTag, options, cb) {
client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change message area'); client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change message area');
} }
cb(err); return cb(err);
} }
); );
} }

View File

@ -1,56 +1,31 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
const messageArea = require('../core/message_area.js'); const messageArea = require('../core/message_area.js');
// deps
const assert = require('assert');
// exports.MessageAreaConfTempSwitcher = Sup => class extends Sup {
// A simple mixin for View Controller management
//
exports.ViewControllerManagement = function() {
this.initViewControllers = function() {
this.viewControllers = {};
};
this.detachViewControllers = function() {
var self = this;
Object.keys(this.viewControllers).forEach(function vc(name) {
self.viewControllers[name].detachClientEvents();
});
};
this.addViewController = function(name, vc) {
assert(this.viewControllers, 'initViewControllers() has not been called!');
assert(!this.viewControllers[name], 'ViewController by the name of \'' + name + '\' already exists!');
this.viewControllers[name] = vc;
return vc;
};
};
exports.MessageAreaConfTempSwitcher = function() {
this.tempMessageConfAndAreaSwitch = function(messageAreaTag) { tempMessageConfAndAreaSwitch(messageAreaTag) {
messageAreaTag = messageAreaTag || this.messageAreaTag; messageAreaTag = messageAreaTag || this.messageAreaTag;
if(!messageAreaTag) { if(!messageAreaTag) {
return; // nothing to do! return; // nothing to do!
} }
this.prevMessageConfAndArea = { this.prevMessageConfAndArea = {
confTag : this.client.user.properties.message_conf_tag, confTag : this.client.user.properties.message_conf_tag,
areaTag : this.client.user.properties.message_area_tag, areaTag : this.client.user.properties.message_area_tag,
}; };
if(!messageArea.tempChangeMessageConfAndArea(this.client, this.messageAreaTag)) { if(!messageArea.tempChangeMessageConfAndArea(this.client, this.messageAreaTag)) {
this.client.log.warn( { messageAreaTag : messageArea }, 'Failed to perform temporary message area/conf switch'); this.client.log.warn( { messageAreaTag : messageArea }, 'Failed to perform temporary message area/conf switch');
} }
}; }
this.tempMessageConfAndAreaRestore = function() { tempMessageConfAndAreaRestore() {
if(this.prevMessageConfAndArea) { if(this.prevMessageConfAndArea) {
this.client.user.properties.message_conf_tag = this.prevMessageConfAndArea.confTag; this.client.user.properties.message_conf_tag = this.prevMessageConfAndArea.confTag;
this.client.user.properties.message_area_tag = this.prevMessageConfAndArea.areaTag; this.client.user.properties.message_area_tag = this.prevMessageConfAndArea.areaTag;
} }
}; }
}; };

View File

@ -59,9 +59,6 @@ function loadModuleEx(options, cb) {
return cb(new Error('Invalid or missing "getModule" method for module!')); return cb(new Error('Invalid or missing "getModule" method for module!'));
} }
// Ref configuration, if any, for convience to the module
mod.runtime = { config : modConfig };
return cb(null, mod); return cb(null, mod);
} }

View File

@ -115,8 +115,10 @@ function MultiLineEditTextView(options) {
if ('preview' === this.mode) { if ('preview' === this.mode) {
this.autoScroll = options.autoScroll || true; this.autoScroll = options.autoScroll || true;
this.tabSwitchesView = true;
} else { } else {
this.autoScroll = options.autoScroll || false; this.autoScroll = options.autoScroll || false;
this.tabSwitchesView = options.tabSwitchesView || false;
} }
// //
// cursorPos represents zero-based row, col positions // cursorPos represents zero-based row, col positions
@ -261,30 +263,30 @@ function MultiLineEditTextView(options) {
return text; return text;
}; };
this.getTextLines = function(startIndex, endIndex) { this.getTextLines = function(startIndex, endIndex) {
var lines; var lines;
if(startIndex === endIndex) { if(startIndex === endIndex) {
lines = [ self.textLines[startIndex] ]; lines = [ self.textLines[startIndex] ];
} else { } else {
lines = self.textLines.slice(startIndex, endIndex + 1); // "slice extracts up to but not including end." lines = self.textLines.slice(startIndex, endIndex + 1); // "slice extracts up to but not including end."
} }
return lines; return lines;
}; };
this.getOutputText = function(startIndex, endIndex, eolMarker) { this.getOutputText = function(startIndex, endIndex, eolMarker) {
let lines = self.getTextLines(startIndex, endIndex); let lines = self.getTextLines(startIndex, endIndex);
let text = ''; let text = '';
var re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g'); var re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g');
lines.forEach(line => { lines.forEach(line => {
text += line.text.replace(re, '\t'); text += line.text.replace(re, '\t');
if(eolMarker && line.eol) { if(eolMarker && line.eol) {
text += eolMarker; text += eolMarker;
} }
}); });
return text; return text;
} };
this.getContiguousText = function(startIndex, endIndex, includeEol) { this.getContiguousText = function(startIndex, endIndex, includeEol) {
var lines = self.getTextLines(startIndex, endIndex); var lines = self.getTextLines(startIndex, endIndex);
@ -409,10 +411,10 @@ function MultiLineEditTextView(options) {
this.insertCharactersInText = function(c, index, col) { this.insertCharactersInText = function(c, index, col) {
self.textLines[index].text = [ self.textLines[index].text = [
self.textLines[index].text.slice(0, col), self.textLines[index].text.slice(0, col),
c, c,
self.textLines[index].text.slice(col) self.textLines[index].text.slice(col)
].join(''); ].join('');
//self.cursorPos.col++; //self.cursorPos.col++;
self.cursorPos.col += c.length; self.cursorPos.col += c.length;
@ -532,7 +534,7 @@ function MultiLineEditTextView(options) {
// before and and after column // before and and after column
// //
// :TODO: Need to clean this string (e.g. collapse tabs) // :TODO: Need to clean this string (e.g. collapse tabs)
text = self.textLines text = self.textLines;
// :TODO: Remove original line @ index // :TODO: Remove original line @ index
} }
@ -544,18 +546,18 @@ function MultiLineEditTextView(options) {
.replace(/\b/g, '') .replace(/\b/g, '')
.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g); .split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
var wrapped; let wrapped;
for(var i = 0; i < text.length; ++i) { for(let i = 0; i < text.length; ++i) {
wrapped = self.wordWrapSingleLine( wrapped = self.wordWrapSingleLine(
text[i], // input text[i], // input
'expand', // tabHandling 'expand', // tabHandling
self.dimens.width).wrapped; self.dimens.width).wrapped;
for(var j = 0; j < wrapped.length - 1; ++j) { for(let j = 0; j < wrapped.length - 1; ++j) {
self.textLines.splice(index++, 0, { text : wrapped[j] } ); self.textLines.splice(index++, 0, { text : wrapped[j] } );
} }
self.textLines.splice(index++, 0, { text : wrapped[wrapped.length - 1], eol : true }); self.textLines.splice(index++, 0, { text : wrapped[wrapped.length - 1], eol : true } );
} }
}; };
@ -1023,14 +1025,26 @@ MultiLineEditTextView.prototype.getData = function() {
MultiLineEditTextView.prototype.setPropertyValue = function(propName, value) { MultiLineEditTextView.prototype.setPropertyValue = function(propName, value) {
switch(propName) { switch(propName) {
case 'mode' : this.mode = value; break; case 'mode' :
this.mode = value;
if('preview' === value && !this.specialKeyMap.next) {
this.specialKeyMap.next = [ 'tab' ];
}
break;
case 'autoScroll' : this.autoScroll = value; break; case 'autoScroll' : this.autoScroll = value; break;
case 'tabSwitchesView' :
this.tabSwitchesView = value;
this.specialKeyMap.next = this.specialKeyMap.next || [];
this.specialKeyMap.next.push('tab');
break;
} }
MultiLineEditTextView.super_.prototype.setPropertyValue.call(this, propName, value); MultiLineEditTextView.super_.prototype.setPropertyValue.call(this, propName, value);
}; };
var HANDLED_SPECIAL_KEYS = [ const HANDLED_SPECIAL_KEYS = [
'up', 'down', 'left', 'right', 'up', 'down', 'left', 'right',
'home', 'end', 'home', 'end',
'page up', 'page down', 'page up', 'page down',
@ -1041,13 +1055,13 @@ var HANDLED_SPECIAL_KEYS = [
'delete line', 'delete line',
]; ];
var PREVIEW_MODE_KEYS = [ const PREVIEW_MODE_KEYS = [
'up', 'down', 'page up', 'page down' 'up', 'down', 'page up', 'page down'
]; ];
MultiLineEditTextView.prototype.onKeyPress = function(ch, key) { MultiLineEditTextView.prototype.onKeyPress = function(ch, key) {
var self = this; const self = this;
var handled; let handled;
if(key) { if(key) {
HANDLED_SPECIAL_KEYS.forEach(function aKey(specialKey) { HANDLED_SPECIAL_KEYS.forEach(function aKey(specialKey) {
@ -1057,8 +1071,10 @@ MultiLineEditTextView.prototype.onKeyPress = function(ch, key) {
return; return;
} }
self[_.camelCase('keyPress ' + specialKey)](); if('tab' !== key.name || !self.tabSwitchesView) {
handled = true; self[_.camelCase('keyPress ' + specialKey)]();
handled = true;
}
} }
}); });
} }

View File

@ -17,8 +17,6 @@ exports.moduleInfo = {
author : 'NuSkooler', author : 'NuSkooler',
}; };
exports.getModule = NewScanModule;
/* /*
* :TODO: * :TODO:
* * User configurable new scan: Area selection (avail from messages area) (sep module) * * User configurable new scan: Area selection (avail from messages area) (sep module)
@ -27,48 +25,45 @@ exports.getModule = NewScanModule;
*/ */
var MciCodeIds = { const MciCodeIds = {
ScanStatusLabel : 1, // TL1 ScanStatusLabel : 1, // TL1
ScanStatusList : 2, // VM2 (appends) ScanStatusList : 2, // VM2 (appends)
}; };
function NewScanModule(options) { exports.getModule = class NewScanModule extends MenuModule {
MenuModule.call(this, options); constructor(options) {
super(options);
var self = this; this.newScanFullExit = _.has(options, 'lastMenuResult.fullExit') ? options.lastMenuResult.fullExit : false;
var config = this.menuConfig.config;
this.newScanFullExit = _.has(options, 'lastMenuResult.fullExit') ? options.lastMenuResult.fullExit : false; this.currentStep = 'messageConferences';
this.currentScanAux = {};
this.currentStep = 'messageConferences'; // :TODO: Make this conf/area specific:
this.currentScanAux = {}; const config = this.menuConfig.config;
this.scanStartFmt = config.scanStartFmt || 'Scanning {confName} - {areaName}...';
this.scanFinishNoneFmt = config.scanFinishNoneFmt || 'Nothing new';
this.scanFinishNewFmt = config.scanFinishNewFmt || '{count} entries found';
this.scanCompleteMsg = config.scanCompleteMsg || 'Finished newscan';
}
// :TODO: Make this conf/area specific: updateScanStatus(statusText) {
this.scanStartFmt = config.scanStartFmt || 'Scanning {confName} - {areaName}...'; this.setViewText('allViews', MciCodeIds.ScanStatusLabel, statusText);
this.scanFinishNoneFmt = config.scanFinishNoneFmt || 'Nothing new';
this.scanFinishNewFmt = config.scanFinishNewFmt || '{count} entries found';
this.scanCompleteMsg = config.scanCompleteMsg || 'Finished newscan';
this.updateScanStatus = function(statusText) {
var vc = self.viewControllers.allViews;
var view = vc.getView(MciCodeIds.ScanStatusLabel);
if(view) {
view.setText(statusText);
}
/*
view = vc.getView(MciCodeIds.ScanStatusList); view = vc.getView(MciCodeIds.ScanStatusList);
// :TODO: MenuView needs appendItem() // :TODO: MenuView needs appendItem()
if(view) { if(view) {
} }
}; */
}
this.newScanMessageConference = function(cb) { newScanMessageConference(cb) {
// lazy init // lazy init
if(!self.sortedMessageConfs) { if(!this.sortedMessageConfs) {
const getAvailOpts = { includeSystemInternal : true }; // find new private messages, bulletins, etc. const getAvailOpts = { includeSystemInternal : true }; // find new private messages, bulletins, etc.
self.sortedMessageConfs = _.map(msgArea.getAvailableMessageConferences(self.client, getAvailOpts), (v, k) => { this.sortedMessageConfs = _.map(msgArea.getAvailableMessageConferences(this.client, getAvailOpts), (v, k) => {
return { return {
confTag : k, confTag : k,
conf : v, conf : v,
@ -80,19 +75,20 @@ function NewScanModule(options) {
// always come first such that we display private mails/etc. before // always come first such that we display private mails/etc. before
// other conferences & areas // other conferences & areas
// //
self.sortedMessageConfs.sort((a, b) => { this.sortedMessageConfs.sort((a, b) => {
if('system_internal' === a.confTag) { if('system_internal' === a.confTag) {
return -1; return -1;
} else { } else {
return a.conf.name.localeCompare(b.conf.name); return a.conf.name.localeCompare(b.conf.name, { sensitivity : false, numeric : true } );
} }
}); });
self.currentScanAux.conf = self.currentScanAux.conf || 0; this.currentScanAux.conf = this.currentScanAux.conf || 0;
self.currentScanAux.area = self.currentScanAux.area || 0; this.currentScanAux.area = this.currentScanAux.area || 0;
} }
const currentConf = self.sortedMessageConfs[self.currentScanAux.conf]; const currentConf = this.sortedMessageConfs[this.currentScanAux.conf];
const self = this;
async.series( async.series(
[ [
@ -113,19 +109,22 @@ function NewScanModule(options) {
}); });
} }
], ],
cb err => {
return cb(err);
}
); );
}; }
this.newScanMessageArea = function(conf, cb) { newScanMessageArea(conf, cb) {
// :TODO: it would be nice to cache this - must be done by conf! // :TODO: it would be nice to cache this - must be done by conf!
const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : self.client } ); const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : this.client } );
const currentArea = sortedAreas[self.currentScanAux.area]; const currentArea = sortedAreas[this.currentScanAux.area];
// //
// Scan and update index until we find something. If results are found, // Scan and update index until we find something. If results are found,
// we'll goto the list module & show them. // we'll goto the list module & show them.
// //
const self = this;
async.waterfall( async.waterfall(
[ [
function checkAndUpdateIndex(callback) { function checkAndUpdateIndex(callback) {
@ -165,73 +164,73 @@ function NewScanModule(options) {
} }
}; };
return self.gotoMenu(config.newScanMessageList || 'newScanMessageList', nextModuleOpts); return self.gotoMenu(self.menuConfig.config.newScanMessageList || 'newScanMessageList', nextModuleOpts);
} }
], ],
cb // no more areas err => {
return cb(err);
}
); );
};
}
require('util').inherits(NewScanModule, MenuModule);
NewScanModule.prototype.getSaveState = function() {
return {
currentStep : this.currentStep,
currentScanAux : this.currentScanAux,
};
};
NewScanModule.prototype.restoreSavedState = function(savedState) {
this.currentStep = savedState.currentStep;
this.currentScanAux = savedState.currentScanAux;
};
NewScanModule.prototype.mciReady = function(mciData, cb) {
if(this.newScanFullExit) {
// user has canceled the entire scan @ message list view
return cb(null);
} }
getSaveState() {
return {
currentStep : this.currentStep,
currentScanAux : this.currentScanAux,
};
}
var self = this; restoreSavedState(savedState) {
var vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); this.currentStep = savedState.currentStep;
this.currentScanAux = savedState.currentScanAux;
}
// :TODO: display scan step/etc. mciReady(mciData, cb) {
if(this.newScanFullExit) {
async.series( // user has canceled the entire scan @ message list view
[ return cb(null);
function callParentMciReady(callback) {
NewScanModule.super_.prototype.mciReady.call(self, mciData, callback);
},
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
noInput : true,
};
vc.loadFromMenuConfig(loadOpts, callback);
},
function performCurrentStepScan(callback) {
switch(self.currentStep) {
case 'messageConferences' :
self.newScanMessageConference( () => {
callback(null); // finished
});
break;
default : return callback(null);
}
}
],
function complete(err) {
if(err) {
self.client.log.error( { error : err.toString() }, 'Error during new scan');
}
cb(err);
} }
);
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
const self = this;
const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
// :TODO: display scan step/etc.
async.series(
[
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
noInput : true,
};
vc.loadFromMenuConfig(loadOpts, callback);
},
function performCurrentStepScan(callback) {
switch(self.currentStep) {
case 'messageConferences' :
self.newScanMessageConference( () => {
callback(null); // finished
});
break;
default : return callback(null);
}
}
],
err => {
if(err) {
self.client.log.error( { error : err.toString() }, 'Error during new scan');
}
return cb(err);
}
);
});
}
}; };

View File

@ -0,0 +1,76 @@
/* jslint node: true */
/* eslint-disable no-console */
'use strict';
const resolvePath = require('../misc_util.js').resolvePath;
const config = require('../../core/config.js');
const db = require('../../core/database.js');
const _ = require('lodash');
const async = require('async');
exports.printUsageAndSetExitCode = printUsageAndSetExitCode;
exports.getDefaultConfigPath = getDefaultConfigPath;
exports.initConfigAndDatabases = initConfigAndDatabases;
exports.getAreaAndStorage = getAreaAndStorage;
const exitCodes = exports.ExitCodes = {
SUCCESS : 0,
ERROR : -1,
BAD_COMMAND : -2,
BAD_ARGS : -3,
};
const argv = exports.argv = require('minimist')(process.argv.slice(2));
function printUsageAndSetExitCode(errMsg, exitCode) {
if(_.isUndefined(exitCode)) {
exitCode = exitCodes.ERROR;
}
process.exitCode = exitCode;
if(errMsg) {
console.error(errMsg);
}
}
function getDefaultConfigPath() {
return resolvePath('~/.config/enigma-bbs/config.hjson');
}
function initConfig(cb) {
const configPath = argv.config ? argv.config : config.getDefaultPath();
config.init(configPath, cb);
}
function initConfigAndDatabases(cb) {
async.series(
[
function init(callback) {
initConfig(callback);
},
function initDb(callback) {
db.initializeDatabases(callback);
},
],
err => {
return cb(err);
}
);
}
function getAreaAndStorage(tags) {
return tags.map(tag => {
const parts = tag.split('@');
const entry = {
areaTag : parts[0],
};
if(parts[1]) {
entry.storageTag = parts[1];
}
return entry;
});
}

View File

@ -0,0 +1,258 @@
/* jslint node: true */
/* eslint-disable no-console */
'use strict';
// ENiGMA½
const resolvePath = require('../../core/misc_util.js').resolvePath;
const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode;
const ExitCodes = require('./oputil_common.js').ExitCodes;
const argv = require('./oputil_common.js').argv;
const getDefaultConfigPath = require('./oputil_common.js').getDefaultConfigPath;
const getHelpFor = require('./oputil_help.js').getHelpFor;
// deps
const async = require('async');
const inq = require('inquirer');
const mkdirsSync = require('fs-extra').mkdirsSync;
const fs = require('fs');
const hjson = require('hjson');
const paths = require('path');
exports.handleConfigCommand = handleConfigCommand;
function getAnswers(questions, cb) {
inq.prompt(questions).then( answers => {
return cb(answers);
});
}
const QUESTIONS = {
Intro : [
{
name : 'createNewConfig',
message : 'Create a new configuration?',
type : 'confirm',
default : false,
},
{
name : 'configPath',
message : 'Configuration path:',
default : argv.config ? argv.config : getDefaultConfigPath(),
when : answers => answers.createNewConfig
},
],
OverwriteConfig : [
{
name : 'overwriteConfig',
message : 'Config file exists. Overwrite?',
type : 'confirm',
default : false,
}
],
Basic : [
{
name : 'boardName',
message : 'BBS name:',
default : 'New ENiGMA½ BBS',
},
],
Misc : [
{
name : 'loggingLevel',
message : 'Logging level:',
type : 'list',
choices : [ 'Error', 'Warn', 'Info', 'Debug', 'Trace' ],
default : 2,
filter : s => s.toLowerCase(),
},
{
name : 'sevenZipExe',
message : '7-Zip executable:',
type : 'list',
choices : [ '7z', '7za', 'None' ]
}
],
MessageConfAndArea : [
{
name : 'msgConfName',
message : 'First message conference:',
default : 'Local',
},
{
name : 'msgConfDesc',
message : 'Conference description:',
default : 'Local Areas',
},
{
name : 'msgAreaName',
message : 'First area in message conference:',
default : 'General',
},
{
name : 'msgAreaDesc',
message : 'Area description:',
default : 'General chit-chat',
}
]
};
function makeMsgConfAreaName(s) {
return s.toLowerCase().replace(/\s+/g, '_');
}
function askNewConfigQuestions(cb) {
const ui = new inq.ui.BottomBar();
let configPath;
let config;
async.waterfall(
[
function intro(callback) {
getAnswers(QUESTIONS.Intro, answers => {
if(!answers.createNewConfig) {
return callback('exit');
}
// adjust for ~ and the like
configPath = resolvePath(answers.configPath);
const configDir = paths.dirname(configPath);
mkdirsSync(configDir);
//
// Check if the file exists and can be written to
//
fs.access(configPath, fs.F_OK | fs.W_OK, err => {
if(err) {
if('EACCES' === err.code) {
ui.log.write(`${configPath} cannot be written to`);
callback('exit');
} else if('ENOENT' === err.code) {
callback(null, false);
}
} else {
callback(null, true); // exists + writable
}
});
});
},
function promptOverwrite(needPrompt, callback) {
if(needPrompt) {
getAnswers(QUESTIONS.OverwriteConfig, answers => {
callback(answers.overwriteConfig ? null : 'exit');
});
} else {
callback(null);
}
},
function basic(callback) {
getAnswers(QUESTIONS.Basic, answers => {
config = {
general : {
boardName : answers.boardName,
},
};
callback(null);
});
},
function msgConfAndArea(callback) {
getAnswers(QUESTIONS.MessageConfAndArea, answers => {
config.messageConferences = {};
const confName = makeMsgConfAreaName(answers.msgConfName);
const areaName = makeMsgConfAreaName(answers.msgAreaName);
config.messageConferences[confName] = {
name : answers.msgConfName,
desc : answers.msgConfDesc,
sort : 1,
default : true,
};
config.messageConferences.another_sample_conf = {
name : 'Another Sample Conference',
desc : 'Another conference example. Change me!',
sort : 2,
};
config.messageConferences[confName].areas = {};
config.messageConferences[confName].areas[areaName] = {
name : answers.msgAreaName,
desc : answers.msgAreaDesc,
sort : 1,
default : true,
};
config.messageConferences.another_sample_conf = {
name : 'Another Sample Conference',
desc : 'Another conf sample. Change me!',
areas : {
another_sample_area : {
name : 'Another Sample Area',
desc : 'Another area example. Change me!',
sort : 2
}
}
};
callback(null);
});
},
function misc(callback) {
getAnswers(QUESTIONS.Misc, answers => {
if('None' !== answers.sevenZipExe) {
config.archivers = {
zip : {
compressCmd : answers.sevenZipExe,
decompressCmd : answers.sevenZipExe,
}
};
}
config.logging = {
level : answers.loggingLevel,
};
callback(null);
});
}
],
err => {
cb(err, configPath, config);
}
);
}
function handleConfigCommand() {
if(true === argv.help) {
return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR);
}
if(argv.new) {
askNewConfigQuestions( (err, configPath, config) => {
if(err) {
return;
}
config = hjson.stringify(config, { bracesSameLine : true, spaces : '\t' } );
try {
fs.writeFileSync(configPath, config, 'utf8');
console.info('Configuration generated');
} catch(e) {
console.error('Exception attempting to create config: ' + e.toString());
}
});
} else {
return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR);
}
}

View File

@ -0,0 +1,175 @@
/* 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 getAreaAndStorage = require('./oputil_common.js').getAreaAndStorage;
const async = require('async');
const fs = require('fs');
const paths = require('path');
exports.handleFileBaseCommand = handleFileBaseCommand;
/*
:TODO:
Global options:
--yes: assume yes
--no-prompt: try to avoid user input
Prompt for import and description before scan
* Only after finding duplicate-by-path
* Default to filename -> desc if auto import
*/
let fileArea; // required during init
function scanFileAreaForChanges(areaInfo, options, cb) {
const storageLocations = fileArea.getAreaStorageLocations(areaInfo).filter(sl => {
return options.areaAndStorageInfo.find(asi => {
return !asi.storageTag || sl.storageTag === asi.storageTag;
});
});
async.eachSeries(storageLocations, (storageLoc, nextLocation) => {
async.series(
[
function scanPhysFiles(callback) {
const physDir = storageLoc.dir;
fs.readdir(physDir, (err, files) => {
if(err) {
return callback(err);
}
async.eachSeries(files, (fileName, nextFile) => {
const fullPath = paths.join(physDir, fileName);
fs.stat(fullPath, (err, stats) => {
if(err) {
// :TODO: Log me!
return nextFile(null); // always try next file
}
if(!stats.isFile()) {
return nextFile(null);
}
process.stdout.write(`* Scanning ${fullPath}... `);
fileArea.scanFile(
fullPath,
{
areaTag : areaInfo.areaTag,
storageTag : storageLoc.storageTag
},
(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???
console.info('Dupe');
return nextFile(null);
} else {
console.info('Done!');
if(Array.isArray(options.tags)) {
options.tags.forEach(tag => {
fileEntry.hashTags.add(tag);
});
}
fileEntry.persist( err => {
return nextFile(err);
});
}
}
);
});
}, err => {
return callback(err);
});
});
},
function scanDbEntries(callback) {
// :TODO: Look @ db entries for area that were *not* processed above
return callback(null);
}
],
err => {
return nextLocation(err);
}
);
},
err => {
return cb(err);
});
}
function scanFileAreas() {
const options = {};
const tags = argv.tags;
if(tags) {
options.tags = tags.split(',');
}
options.areaAndStorageInfo = getAreaAndStorage(argv._.slice(2));
async.series(
[
function init(callback) {
return initConfigAndDatabases(callback);
},
function scanAreas(callback) {
fileArea = require('../../core/file_base_area.js');
async.eachSeries(options.areaAndStorageInfo, (areaAndStorage, nextAreaTag) => {
const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag);
if(!areaInfo) {
return nextAreaTag(new Error(`Invalid file base area tag: ${areaAndStorage.areaTag}`));
}
console.info(`Processing area "${areaInfo.name}":`);
scanFileAreaForChanges(areaInfo, options, err => {
return callback(err);
});
}, err => {
return callback(err);
});
}
],
err => {
if(err) {
process.exitCode = ExitCodes.ERROR;
console.error(err.message);
}
}
);
}
function handleFileBaseCommand() {
if(true === argv.help) {
return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR);
}
const action = argv._[1];
switch(action) {
case 'scan' : return scanFileAreas();
}
}

View File

@ -0,0 +1,55 @@
/* jslint node: true */
/* eslint-disable no-console */
'use strict';
const getDefaultConfigPath = require('./oputil_common.js').getDefaultConfigPath;
exports.getHelpFor = getHelpFor;
const usageHelp = exports.USAGE_HELP = {
General :
`usage: optutil.js [--version] [--help]
<command> [<args>]
global args:
--config PATH : specify config path (${getDefaultConfigPath()})
where <command> is one of:
user : user utilities
config : config file management
file-base
fb : file base management
`,
User :
`usage: optutil.js user --user USERNAME <args>
valid args:
--user USERNAME : specify username for further actions
--password PASS : set new password
--delete : delete user
--activate : activate user
--deactivate : deactivate user
`,
Config :
`usage: optutil.js config <args>
valid args:
--new : generate a new/initial configuration
`,
FileBase :
`usage: oputil.js file-base <action> [<args>] [<action_specific>]
where <action> is one of:
scan <args> AREA_TAG : (re)scan area specified by AREA_TAG for new files
multiple area tags can be specified in form of AREA_TAG1 AREA_TAG2 ...
valid scan <args>:
--tags TAG1,TAG2,... : specify tag(s) to assign to discovered entries
`
};
function getHelpFor(command) {
return usageHelp[command];
}

View File

@ -0,0 +1,45 @@
/* jslint node: true */
/* eslint-disable no-console */
'use strict';
const ExitCodes = require('./oputil_common.js').ExitCodes;
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 handleConfigCommand = require('./oputil_config.js').handleConfigCommand;
const getHelpFor = require('./oputil_help.js').getHelpFor;
module.exports = function() {
process.exitCode = ExitCodes.SUCCESS;
if(true === argv.version) {
return console.info(require('../package.json').version);
}
if(0 === argv._.length ||
'help' === argv._[0])
{
printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.SUCCESS);
}
switch(argv._[0]) {
case 'user' :
handleUserCommand();
break;
case 'config' :
handleConfigCommand();
break;
case 'file-base' :
case 'fb' :
handleFileBaseCommand();
break;
default:
return printUsageAndSetExitCode('', ExitCodes.BAD_COMMAND);
}
};

112
core/oputil/oputil_user.js Normal file
View File

@ -0,0 +1,112 @@
/* 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 async = require('async');
exports.handleUserCommand = handleUserCommand;
function handleUserCommand() {
if(true === argv.help || !_.isString(argv.user) || 0 === argv.user.length) {
return printUsageAndSetExitCode('User', ExitCodes.ERROR);
}
if(_.isString(argv.password)) {
if(0 === argv.password.length) {
process.exitCode = ExitCodes.BAD_ARGS;
return console.error('Invalid password');
}
async.waterfall(
[
function init(callback) {
initAndGetUser(argv.user, callback);
},
function setNewPass(user, callback) {
user.setNewAuthCredentials(argv.password, function credsSet(err) {
if(err) {
process.exitCode = ExitCodes.ERROR;
callback(new Error('Failed setting password'));
} else {
callback(null);
}
});
}
],
function complete(err) {
if(err) {
console.error(err.message);
} else {
console.info('Password set');
}
}
);
} else if(argv.activate) {
setAccountStatus(argv.user, true);
} else if(argv.deactivate) {
setAccountStatus(argv.user, false);
}
}
function getUser(userName, cb) {
const user = require('./core/user.js');
user.getUserIdAndName(argv.user, function userNameAndId(err, userId) {
if(err) {
process.exitCode = ExitCodes.BAD_ARGS;
return cb(new Error('Failed to retrieve user'));
} else {
let u = new user.User();
u.userId = userId;
return cb(null, u);
}
});
}
function initAndGetUser(userName, cb) {
async.waterfall(
[
function init(callback) {
initConfigAndDatabases(callback);
},
function getUserObject(callback) {
getUser(argv.user, (err, user) => {
if(err) {
process.exitCode = ExitCodes.BAD_ARGS;
return callback(err);
}
return callback(null, user);
});
}
],
(err, user) => {
return cb(err, user);
}
);
}
function setAccountStatus(userName, active) {
async.waterfall(
[
function init(callback) {
initAndGetUser(argv.user, callback);
},
function activateUser(user, callback) {
const AccountStatus = require('./core/user.js').User.AccountStatus;
user.persistProperty('account_status', active ? AccountStatus.active : AccountStatus.inactive, callback);
}
],
err => {
if(err) {
console.error(err.message);
} else {
console.info('User ' + ((true === active) ? 'activated' : 'deactivated'));
}
}
);
}

View File

@ -8,6 +8,8 @@ const getMessageAreaByTag = require('./message_area.js').getMessageAreaByTag;
const getMessageConferenceByTag = require('./message_area.js').getMessageConferenceByTag; const getMessageConferenceByTag = require('./message_area.js').getMessageConferenceByTag;
const clientConnections = require('./client_connections.js'); const clientConnections = require('./client_connections.js');
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
const FileBaseFilters = require('./file_base_filter.js');
const formatByteSize = require('./string_util.js').formatByteSize;
// deps // deps
const packageJson = require('../package.json'); const packageJson = require('../package.json');
@ -35,6 +37,17 @@ function setNextRandomRumor(cb) {
}); });
} }
function getRatio(client, propA, propB) {
const a = StatLog.getUserStatNum(client.user, propA);
const b = StatLog.getUserStatNum(client.user, propB);
const ratio = ~~((a / b) * 100);
return `${ratio}%`;
}
function userStatAsString(client, statName, defaultValue) {
return (StatLog.getUserStat(client.user, statName) || defaultValue).toString();
}
function getPredefinedMCIValue(client, code) { function getPredefinedMCIValue(client, code) {
if(!client || !code) { if(!client || !code) {
@ -67,32 +80,43 @@ function getPredefinedMCIValue(client, code) {
UN : function userName() { return client.user.username; }, UN : function userName() { return client.user.username; },
UI : function userId() { return client.user.userId.toString(); }, UI : function userId() { return client.user.userId.toString(); },
UG : function groups() { return _.values(client.user.groups).join(', '); }, UG : function groups() { return _.values(client.user.groups).join(', '); },
UR : function realName() { return client.user.properties.real_name; }, UR : function realName() { return userStatAsString(client, 'real_name', ''); },
LO : function location() { return client.user.properties.location; }, LO : function location() { return userStatAsString(client, 'location', ''); },
UA : function age() { return client.user.getAge().toString(); }, UA : function age() { return client.user.getAge().toString(); },
UB : function birthdate() { return moment(client.user.properties.birthdate).format(client.currentTheme.helpers.getDateFormat()); }, BD : function birthdate() { return moment(client.user.properties.birthdate).format(client.currentTheme.helpers.getDateFormat()); }, // iNiQUiTY
US : function sex() { return client.user.properties.sex; }, US : function sex() { return userStatAsString(client, 'sex', ''); },
UE : function emailAddres() { return client.user.properties.email_address; }, UE : function emailAddres() { return userStatAsString(client, 'email_address', ''); },
UW : function webAddress() { return client.user.properties.web_address; }, UW : function webAddress() { return userStatAsString(client, 'web_address', ''); },
UF : function affils() { return client.user.properties.affiliation; }, UF : function affils() { return userStatAsString(client, 'affiliation', ''); },
UT : function themeId() { return client.user.properties.theme_id; }, UT : function themeId() { return userStatAsString(client, 'theme_id', ''); },
UC : function loginCount() { return client.user.properties.login_count.toString(); }, UC : function loginCount() { return userStatAsString(client, 'login_count', 0); },
ND : function connectedNode() { return client.node.toString(); }, ND : function connectedNode() { return client.node.toString(); },
IP : function clientIpAddress() { return client.address().address; }, IP : function clientIpAddress() { return client.address().address; },
ST : function serverName() { return client.session.serverName; }, ST : function serverName() { return client.session.serverName; },
FN : function activeFileBaseFilterName() {
const activeFilter = FileBaseFilters.getActiveFilter(client);
return activeFilter ? activeFilter.name : '';
},
DN : function userNumDownloads() { return userStatAsString(client, 'dl_total_count', 0); }, // Obv/2
DK : function userByteDownload() { // Obv/2 uses DK=downloaded Kbytes
const byteSize = StatLog.getUserStatNum(client.user, 'dl_total_bytes');
return formatByteSize(byteSize, true); // true=withAbbr
},
UP : function userNumUploads() { return userStatAsString(client, 'ul_total_count', 0); }, // Obv/2
UK : function userByteUpload() { // Obv/2 uses UK=uploaded Kbytes
const byteSize = StatLog.getUserStatNum(client.user, 'ul_total_bytes');
return formatByteSize(byteSize, true); // true=withAbbr
},
NR : function userUpDownRatio() { // Obv/2
return getRatio(client, 'ul_total_count', 'dl_total_count');
},
KR : function userUpDownByteRatio() { // Obv/2 uses KR=upload/download Kbyte ratio
return getRatio(client, 'ul_total_bytes', 'dl_total_bytes');
},
MS : function accountCreated() { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); }, MS : function accountCreated() { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); },
CS : function currentStatus() { return client.currentStatus; }, PS : function userPostCount() { return userStatAsString(client, 'post_count', 0); },
PS : function userPostCount() { PC : function userPostCallRatio() { return getRatio(client, 'post_count', 'login_count'); },
const postCount = client.user.properties.post_count || 0;
return postCount.toString();
},
PC : function userPostCallRatio() {
const postCount = client.user.properties.post_count || 0;
const callCount = client.user.properties.login_count;
const ratio = ~~((postCount / callCount) * 100);
return `${ratio}%`;
},
MD : function currentMenuDescription() { MD : function currentMenuDescription() {
return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : ''; return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : '';
@ -163,6 +187,26 @@ function getPredefinedMCIValue(client, code) {
return StatLog.getSystemStat('random_rumor'); return StatLog.getSystemStat('random_rumor');
}, },
//
// System File Base, Up/Download Info
//
// :TODO: DD - Today's # of downloads (iNiQUiTY)
//
// :TODO: System stat log for total ul/dl, total ul/dl bytes
// :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)
// -> Include FTN/etc.
// :TODO: LC - name of last caller to system (Obv/2)
// :TODO: TZ - Average *system* post/call ratio (iNiQUiTY)
// //
// Special handling for XY // Special handling for XY
// //

View File

@ -50,20 +50,17 @@ function readSAUCE(data, cb) {
.tap(function onVars(vars) { .tap(function onVars(vars) {
if(!SAUCE_ID.equals(vars.id)) { if(!SAUCE_ID.equals(vars.id)) {
cb(new Error('No SAUCE record present')); return cb(new Error('No SAUCE record present'));
return;
} }
var ver = iconv.decode(vars.version, 'cp437'); var ver = iconv.decode(vars.version, 'cp437');
if('00' !== ver) { if('00' !== ver) {
cb(new Error('Unsupported SAUCE version: ' + ver)); return cb(new Error('Unsupported SAUCE version: ' + ver));
return;
} }
if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(vars.dataType)) { if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(vars.dataType)) {
cb(new Error('Unsupported SAUCE DataType: ' + vars.dataType)); return cb(new Error('Unsupported SAUCE DataType: ' + vars.dataType));
return;
} }
var sauce = { var sauce = {

View File

@ -18,12 +18,12 @@ const paths = require('path');
const async = require('async'); const async = require('async');
const fs = require('fs'); const fs = require('fs');
const later = require('later'); const later = require('later');
const temp = require('temp').track(); // track() cleans up temp dir/files for us const temptmp = require('temptmp').createTrackedSession('ftn_bso');
const assert = require('assert'); const assert = require('assert');
const gaze = require('gaze'); const gaze = require('gaze');
const fse = require('fs-extra'); const fse = require('fs-extra');
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
const uuid = require('node-uuid'); const uuidV4 = require('uuid/v4');
exports.moduleInfo = { exports.moduleInfo = {
name : 'FTN BSO', name : 'FTN BSO',
@ -37,6 +37,8 @@ exports.moduleInfo = {
* Support NetMail * Support NetMail
* NetMail needs explicit isNetMail() check * NetMail needs explicit isNetMail() check
* NetMail filename / location / etc. is still unknown - need to post on groups & get real answers * NetMail filename / location / etc. is still unknown - need to post on groups & get real answers
* Validate packet passwords!!!!
=> secure vs insecure landing areas
*/ */
@ -49,9 +51,7 @@ function FTNMessageScanTossModule() {
let self = this; let self = this;
this.archUtil = new ArchiveUtil(); this.archUtil = ArchiveUtil.getInstance();
this.archUtil.init();
if(_.has(Config, 'scannerTossers.ftn_bso')) { if(_.has(Config, 'scannerTossers.ftn_bso')) {
this.moduleConfig = Config.scannerTossers.ftn_bso; this.moduleConfig = Config.scannerTossers.ftn_bso;
@ -871,7 +871,7 @@ function FTNMessageScanTossModule() {
// //
if(Config.messageNetworks.ftn.areas[localAreaTag].allowDupes) { if(Config.messageNetworks.ftn.areas[localAreaTag].allowDupes) {
// just generate a UUID & therefor always allow for dupes // just generate a UUID & therefor always allow for dupes
message.uuid = uuid.v1(); message.uuid = uuidV4();
} }
callback(null); callback(null);
@ -1135,6 +1135,8 @@ function FTNMessageScanTossModule() {
return nextFile(); // unknown archive type return nextFile(); // unknown archive type
} }
Log.debug( { bundleFile : bundleFile }, 'Processing bundle' );
self.archUtil.extractTo( self.archUtil.extractTo(
bundleFile.path, bundleFile.path,
@ -1188,14 +1190,14 @@ function FTNMessageScanTossModule() {
}; };
this.createTempDirectories = function(cb) { this.createTempDirectories = function(cb) {
temp.mkdir('enigftnexport-', (err, tempDir) => { temptmp.mkdir( { prefix : 'enigftnexport-' }, (err, tempDir) => {
if(err) { if(err) {
return cb(err); return cb(err);
} }
self.exportTempDir = tempDir; self.exportTempDir = tempDir;
temp.mkdir('enigftnimport-', (err, tempDir) => { temptmp.mkdir( { prefix : 'enigftnimport-' }, (err, tempDir) => {
self.importTempDir = tempDir; self.importTempDir = tempDir;
cb(err); cb(err);
@ -1325,17 +1327,20 @@ FTNMessageScanTossModule.prototype.shutdown = function(cb) {
// //
// Clean up temp dir/files we created // Clean up temp dir/files we created
// //
temp.cleanup((err, stats) => { temptmp.cleanup( paths => {
const fullStats = Object.assign(stats, { exportTemp : this.exportTempDir, importTemp : this.importTempDir } ); const fullStats = {
exportDir : this.exportTempDir,
importTemp : this.importTempDir,
paths : paths,
sessionId : temptmp.sessionId,
};
if(err) { Log.trace(fullStats, 'Temporary directories cleaned up');
Log.warn(fullStats, 'Failed cleaning up temporary directories!');
} else {
Log.trace(fullStats, 'Temporary directories cleaned up');
}
FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb);
}); });
FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb);
}; };
FTNMessageScanTossModule.prototype.performImport = function(cb) { FTNMessageScanTossModule.prototype.performImport = function(cb) {

179
core/servers/content/web.js Normal file
View File

@ -0,0 +1,179 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Log = require('../../logger.js').log;
const ServerModule = require('../../server_module.js').ServerModule;
const Config = require('../../config.js').config;
// deps
const http = require('http');
const https = require('https');
const _ = require('lodash');
const fs = require('fs');
const paths = require('path');
const mimeTypes = require('mime-types');
const ModuleInfo = exports.moduleInfo = {
name : 'Web',
desc : 'Web Server',
author : 'NuSkooler',
packageName : 'codes.l33t.enigma.web.server',
};
class Route {
constructor(route) {
Object.assign(this, route);
if(this.method) {
this.method = this.method.toUpperCase();
}
try {
this.pathRegExp = new RegExp(this.path);
} catch(e) {
Log.debug( { route : route }, 'Invalid regular expression for route path' );
}
}
isValid() {
return (
this.pathRegExp instanceof RegExp &&
( -1 !== [ 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', ].indexOf(this.method) ) ||
!_.isFunction(this.handler)
);
}
matchesRequest(req) { return req.method === this.method && this.pathRegExp.test(req.url); }
getRouteKey() { return `${this.method}:${this.path}`; }
}
exports.getModule = class WebServerModule extends ServerModule {
constructor() {
super();
this.enableHttp = Config.contentServers.web.http.enabled || true;
this.enableHttps = Config.contentServers.web.https.enabled || false;
this.routes = {};
if(Config.contentServers.web.staticRoot) {
this.addRoute({
method : 'GET',
path : '/static/.*$',
handler : this.routeStaticFile,
});
}
}
createServer() {
if(this.enableHttp) {
this.httpServer = http.createServer( (req, resp) => this.routeRequest(req, resp) );
}
if(this.enableHttps) {
const options = {
cert : fs.readFileSync(Config.contentServers.web.https.certPem),
key : fs.readFileSync(Config.contentServers.web.https.keyPem),
};
// additional options
Object.assign(options, Config.contentServers.web.https.options || {} );
this.httpsServer = https.createServer(options, this.routeRequest);
}
}
listen() {
let ok = true;
[ 'http', 'https' ].forEach(service => {
const name = `${service}Server`;
if(this[name]) {
const port = parseInt(Config.contentServers.web[service].port);
if(isNaN(port)) {
ok = false;
return Log.warn( { port : Config.contentServers.web[service].port, server : ModuleInfo.name }, `Invalid port (${service})` );
}
return this[name].listen(port);
}
});
return ok;
}
addRoute(route) {
route = new Route(route);
if(!route.isValid()) {
Log.warn( { route : route }, 'Cannot add route: missing or invalid required members' );
return false;
}
const routeKey = route.getRouteKey();
if(routeKey in this.routes) {
Log.warn( { route : route }, 'Cannot add route: duplicate method/path combination exists' );
return false;
}
this.routes[routeKey] = route;
return true;
}
routeRequest(req, resp) {
const route = _.find(this.routes, r => r.matchesRequest(req) );
return route ? route.handler(req, resp) : this.accessDenied(resp);
}
respondWithError(resp, code, bodyText, title) {
const customErrorPage = paths.join(Config.contentServers.web.staticRoot, `${code}.html`);
fs.readFile(customErrorPage, 'utf8', (err, data) => {
resp.writeHead(code, { 'Content-Type' : 'text/html' } );
if(err) {
return resp.end(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<article>
<h2>${bodyText}</h2>
</article>
</body>
</html>`
);
}
return resp.end(data);
});
}
accessDenied(resp) {
return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied');
}
routeStaticFile(req, resp) {
const fileName = req.url.substr(req.url.indexOf('/', 1));
const filePath = paths.join(Config.contentServers.web.staticRoot, fileName);
fs.stat(filePath, (err, stats) => {
if(err) {
return this.respondWithError(resp, 404, 'File not found.', 'File Not Found');
}
const headers = {
'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'),
'Content-Length' : stats.size,
};
const readStream = fs.createReadStream(filePath);
resp.writeHead(200, headers);
return readStream.pipe(resp);
});
}
};

View File

@ -2,14 +2,14 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const Config = require('../../config.js').config; const Config = require('../../config.js').config;
const baseClient = require('../../client.js'); const baseClient = require('../../client.js');
const Log = require('../../logger.js').log; const Log = require('../../logger.js').log;
const ServerModule = require('../../server_module.js').ServerModule; const LoginServerModule = require('../../login_server_module.js');
const userLogin = require('../../user_login.js').userLogin; const userLogin = require('../../user_login.js').userLogin;
const enigVersion = require('../../../package.json').version; const enigVersion = require('../../../package.json').version;
const theme = require('../../theme.js'); const theme = require('../../theme.js');
const stringFormat = require('../../string_format.js'); const stringFormat = require('../../string_format.js');
// deps // deps
const ssh2 = require('ssh2'); const ssh2 = require('ssh2');
@ -18,15 +18,14 @@ const util = require('util');
const _ = require('lodash'); const _ = require('lodash');
const assert = require('assert'); const assert = require('assert');
exports.moduleInfo = { const ModuleInfo = exports.moduleInfo = {
name : 'SSH', name : 'SSH',
desc : 'SSH Server', desc : 'SSH Server',
author : 'NuSkooler', author : 'NuSkooler',
isSecure : true, isSecure : true,
packageName : 'codes.l33t.enigma.ssh.server',
}; };
exports.getModule = SSHServerModule;
function SSHClient(clientConn) { function SSHClient(clientConn) {
baseClient.Client.apply(this, arguments); baseClient.Client.apply(this, arguments);
@ -226,40 +225,45 @@ util.inherits(SSHClient, baseClient.Client);
SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ]; SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ];
function SSHServerModule() { exports.getModule = class SSHServerModule extends LoginServerModule {
ServerModule.call(this); constructor() {
} super();
}
util.inherits(SSHServerModule, ServerModule); createServer() {
const serverConf = {
hostKeys : [
{
key : fs.readFileSync(Config.loginServers.ssh.privateKeyPem),
passphrase : Config.loginServers.ssh.privateKeyPass,
}
],
ident : 'enigma-bbs-' + enigVersion + '-srv',
// Note that sending 'banner' breaks at least EtherTerm!
debug : (sshDebugLine) => {
if(true === Config.loginServers.ssh.traceConnections) {
Log.trace(`SSH: ${sshDebugLine}`);
}
},
};
SSHServerModule.prototype.createServer = function() { this.server = ssh2.Server(serverConf);
SSHServerModule.super_.prototype.createServer.call(this); this.server.on('connection', (conn, info) => {
Log.info(info, 'New SSH connection');
this.handleNewClient(new SSHClient(conn), conn._sock, ModuleInfo);
});
}
const serverConf = { listen() {
hostKeys : [ const port = parseInt(Config.loginServers.ssh.port);
{ if(isNaN(port)) {
key : fs.readFileSync(Config.loginServers.ssh.privateKeyPem), Log.error( { server : ModuleInfo.name, port : Config.loginServers.ssh.port }, 'Cannot load server (invalid port)' );
passphrase : Config.loginServers.ssh.privateKeyPass, return false;
} }
],
ident : 'enigma-bbs-' + enigVersion + '-srv',
// Note that sending 'banner' breaks at least EtherTerm!
debug : (sshDebugLine) => {
if(true === Config.loginServers.ssh.traceConnections) {
Log.trace(`SSH: ${sshDebugLine}`);
}
},
};
const server = ssh2.Server(serverConf); this.server.listen(port);
server.on('connection', function onConnection(conn, info) { Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' );
Log.info(info, 'New SSH connection'); return true;
}
const client = new SSHClient(conn);
this.emit('client', client, conn._sock);
});
return server;
}; };

View File

@ -2,10 +2,10 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const baseClient = require('../../client.js'); const baseClient = require('../../client.js');
const Log = require('../../logger.js').log; const Log = require('../../logger.js').log;
const ServerModule = require('../../server_module.js').ServerModule; const LoginServerModule = require('../../login_server_module.js');
const Config = require('../../config.js').config; const Config = require('../../config.js').config;
// deps // deps
const net = require('net'); const net = require('net');
@ -16,16 +16,14 @@ const util = require('util');
//var debug = require('debug')('telnet'); //var debug = require('debug')('telnet');
exports.moduleInfo = { const ModuleInfo = exports.moduleInfo = {
name : 'Telnet', name : 'Telnet',
desc : 'Telnet Server', desc : 'Telnet Server',
author : 'NuSkooler', author : 'NuSkooler',
isSecure : false, isSecure : false,
packageName : 'codes.l33t.enigma.telnet.server',
}; };
exports.getModule = TelnetServerModule;
// //
// Telnet Protocol Resources // Telnet Protocol Resources
// * http://pcmicro.com/netfoss/telnet.html // * http://pcmicro.com/netfoss/telnet.html
@ -440,6 +438,65 @@ function TelnetClient(input, output) {
newEnvironRequested : false, newEnvironRequested : false,
}; };
this.setTemporaryDirectDataHandler = function(handler) {
this.input.removeAllListeners('data');
this.input.on('data', handler);
};
this.restoreDataHandler = function() {
this.input.removeAllListeners('data');
this.input.on('data', this.dataHandler);
};
this.dataHandler = function(b) {
bufs.push(b);
let i;
while((i = bufs.indexOf(IAC_BUF)) >= 0) {
//
// Some clients will send even IAC separate from data
//
if(bufs.length <= (i + 1)) {
i = MORE_DATA_REQUIRED;
break;
}
assert(bufs.length > (i + 1));
if(i > 0) {
self.emit('data', bufs.splice(0, i).toBuffer());
}
i = parseBufs(bufs);
if(MORE_DATA_REQUIRED === i) {
break;
} else {
if(i.option) {
self.emit(i.option, i); // "transmit binary", "echo", ...
}
self.handleTelnetEvent(i);
if(i.data) {
self.emit('data', i.data);
}
}
}
if(MORE_DATA_REQUIRED !== i && bufs.length > 0) {
//
// Standard data payload. This can still be "non-user" data
// such as ANSI control, but we don't handle that here.
//
self.emit('data', bufs.splice(0).toBuffer());
}
};
this.input.on('data', this.dataHandler);
/*
this.input.on('data', b => { this.input.on('data', b => {
bufs.push(b); bufs.push(b);
@ -484,8 +541,8 @@ function TelnetClient(input, output) {
// //
self.emit('data', bufs.splice(0).toBuffer()); self.emit('data', bufs.splice(0).toBuffer());
} }
}); });
*/
this.input.on('end', () => { this.input.on('end', () => {
self.emit('end'); self.emit('end');
@ -767,22 +824,34 @@ Object.keys(OPTIONS).forEach(function(name) {
}); });
}); });
function TelnetServerModule() { exports.getModule = class TelnetServerModule extends LoginServerModule {
ServerModule.call(this); constructor() {
} super();
}
util.inherits(TelnetServerModule, ServerModule); createServer() {
this.server = net.createServer( sock => {
const client = new TelnetClient(sock, sock);
TelnetServerModule.prototype.createServer = function() { client.banner();
TelnetServerModule.super_.prototype.createServer.call(this);
const server = net.createServer( (sock) => { this.handleNewClient(client, sock, ModuleInfo);
const client = new TelnetClient(sock, sock); });
client.banner();
server.emit('client', client, sock); this.server.on('error', err => {
}); Log.info( { error : err.message }, 'Telnet server error');
});
}
return server; listen() {
const port = parseInt(Config.loginServers.telnet.port);
if(isNaN(port)) {
Log.error( { server : ModuleInfo.name, port : Config.loginServers.telnet.port }, 'Cannot load server (invalid port)' );
return false;
}
this.server.listen(port);
Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' );
return true;
}
}; };

View File

@ -1,9 +1,7 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
exports.getModule = StandardMenuModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'Standard Menu Module', name : 'Standard Menu Module',
@ -11,30 +9,19 @@ exports.moduleInfo = {
author : 'NuSkooler', author : 'NuSkooler',
}; };
function StandardMenuModule(menuConfig) { exports.getModule = class StandardMenuModule extends MenuModule {
MenuModule.call(this, menuConfig); constructor(options) {
} super(options);
}
require('util').inherits(StandardMenuModule, MenuModule); mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
StandardMenuModule.prototype.enter = function() {
StandardMenuModule.super_.prototype.enter.call(this);
};
StandardMenuModule.prototype.beforeArt = function(cb) {
StandardMenuModule.super_.prototype.beforeArt.call(this, cb);
};
StandardMenuModule.prototype.mciReady = function(mciData, cb) {
var self = this;
StandardMenuModule.super_.prototype.mciReady.call(this, mciData, function mciReadyComplete(err) {
if(err) {
cb(err);
} else {
// we do this so other modules can be both customized and still perform standard tasks // we do this so other modules can be both customized and still perform standard tasks
StandardMenuModule.super_.prototype.standardMCIReadyHandler.call(self, mciData, cb); return this.standardMCIReadyHandler(mciData, cb);
} });
}); }
}; };

View File

@ -118,6 +118,14 @@ class StatLog {
return user.persistProperty(statName, statValue, cb); return user.persistProperty(statName, statValue, cb);
} }
getUserStat(user, statName) {
return user.properties[statName];
}
getUserStatNum(user, statName) {
return parseInt(this.getUserStat(user, statName)) || 0;
}
incrementUserStat(user, statName, incrementBy, cb) { incrementUserStat(user, statName, incrementBy, cb) {
incrementBy = incrementBy || 1; incrementBy = incrementBy || 1;

View File

@ -6,6 +6,8 @@ const pad = require('./string_util.js').pad;
const stylizeString = require('./string_util.js').stylizeString; const stylizeString = require('./string_util.js').stylizeString;
const renderStringLength = require('./string_util.js').renderStringLength; const renderStringLength = require('./string_util.js').renderStringLength;
const renderSubstr = require('./string_util.js').renderSubstr; const renderSubstr = require('./string_util.js').renderSubstr;
const formatByteSize = require('./string_util.js').formatByteSize;
const formatByteSizeAbbr = require('./string_util.js').formatByteSizeAbbr;
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
@ -265,6 +267,12 @@ const transformers = {
styleSmallI : (s) => stylizeString(s, 'small i'), styleSmallI : (s) => stylizeString(s, 'small i'),
styleMixed : (s) => stylizeString(s, 'mixed'), styleMixed : (s) => stylizeString(s, 'mixed'),
styleL33t : (s) => stylizeString(s, 'l33t'), styleL33t : (s) => stylizeString(s, 'l33t'),
// toMegs(), toKilobytes(), ...
// toList(), toCommaList(),
sizeWithAbbr : (n) => formatByteSize(n, true, 2),
sizeWithoutAbbr : (n) => formatByteSize(n, false, 2),
sizeAbbr : (n) => formatByteSizeAbbr(n),
}; };
function transformValue(transformerName, value) { function transformValue(transformerName, value) {

View File

@ -2,18 +2,26 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const miscUtil = require('./misc_util.js'); const miscUtil = require('./misc_util.js');
const iconv = require('iconv-lite'); const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser;
const ANSI = require('./ansi_term.js');
// deps
const iconv = require('iconv-lite');
exports.stylizeString = stylizeString; exports.stylizeString = stylizeString;
exports.pad = pad; exports.pad = pad;
exports.replaceAt = replaceAt; exports.replaceAt = replaceAt;
exports.isPrintable = isPrintable; exports.isPrintable = isPrintable;
exports.stripAllLineFeeds = stripAllLineFeeds;
exports.debugEscapedString = debugEscapedString; exports.debugEscapedString = debugEscapedString;
exports.stringFromNullTermBuffer = stringFromNullTermBuffer; exports.stringFromNullTermBuffer = stringFromNullTermBuffer;
exports.renderSubstr = renderSubstr; exports.renderSubstr = renderSubstr;
exports.renderStringLength = renderStringLength; exports.renderStringLength = renderStringLength;
exports.formatByteSizeAbbr = formatByteSizeAbbr;
exports.formatByteSize = formatByteSize;
exports.cleanControlCodes = cleanControlCodes; exports.cleanControlCodes = cleanControlCodes;
exports.createCleanAnsi = createCleanAnsi;
// :TODO: create Unicode verison of this // :TODO: create Unicode verison of this
const VOWELS = [ 'a', 'e', 'i', 'o', 'u' ]; const VOWELS = [ 'a', 'e', 'i', 'o', 'u' ];
@ -182,6 +190,10 @@ function stringLength(s) {
return s.length; return s.length;
} }
function stripAllLineFeeds(s) {
return s.replace(/\r?\n|[\r\u2028\u2029]/g, '');
}
function debugEscapedString(s) { function debugEscapedString(s) {
return JSON.stringify(s).slice(1, -1); return JSON.stringify(s).slice(1, -1);
} }
@ -286,20 +298,49 @@ function renderStringLength(s) {
return len; return len;
} }
const SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :)
function formatByteSizeAbbr(byteSize) {
if(0 === byteSize) {
return SIZE_ABBRS[0]; // B
}
return SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))];
}
function formatByteSize(byteSize, withAbbr, decimals) {
withAbbr = withAbbr || false;
decimals = decimals || 3;
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]}`;
}
return result;
}
// :TODO: See notes in word_wrap.js about need to consolidate the various ANSI related RegExp's // :TODO: See notes in word_wrap.js about need to consolidate the various ANSI related RegExp's
//const REGEXP_ANSI_CONTROL_CODES = /(\x1b\x5b)([\?=;0-9]*?)([0-9A-ORZcf-npsu=><])/g; //const REGEXP_ANSI_CONTROL_CODES = /(\x1b\x5b)([\?=;0-9]*?)([0-9A-ORZcf-npsu=><])/g;
const REGEXP_ANSI_CONTROL_CODES = /(?:\x1b\x5b)([\?=;0-9]*?)([A-ORZcf-npsu=><])/g; const REGEXP_ANSI_CONTROL_CODES = /(?:\x1b\x5b)([\?=;0-9]*?)([A-ORZcf-npsu=><])/g;
const ANSI_OPCODES_ALLOWED_CLEAN = [ const ANSI_OPCODES_ALLOWED_CLEAN = [
'C', 'm' , 'A', 'B', // up, down
'A', 'B', 'D' 'C', 'D', // right, left
'm', // color
]; ];
function cleanControlCodes(input) { const AnsiSpecialOpCodes = {
positioning : [ 'A', 'B', 'C', 'D' ], // up, down, right, left
style : [ 'm' ] // color
};
function cleanControlCodes(input, options) {
let m; let m;
let pos; let pos;
let cleaned = ''; let cleaned = '';
options = options || {};
// //
// Loop through |input| adding only allowed ESC // Loop through |input| adding only allowed ESC
// sequences and literals to |cleaned| // sequences and literals to |cleaned|
@ -313,6 +354,10 @@ function cleanControlCodes(input) {
cleaned += input.slice(pos, m.index); cleaned += input.slice(pos, m.index);
} }
if(options.all) {
continue;
}
if(ANSI_OPCODES_ALLOWED_CLEAN.indexOf(m[2].charAt(0)) > -1) { if(ANSI_OPCODES_ALLOWED_CLEAN.indexOf(m[2].charAt(0)) > -1) {
cleaned += m[0]; cleaned += m[0];
} }
@ -328,61 +373,147 @@ function cleanControlCodes(input) {
return cleaned; return cleaned;
} }
function getCleanAnsi(input) { function createCleanAnsi(input, options, cb) {
//
// Process |input| and produce |cleaned|, an array
// of lines with "clean" ANSI.
//
// Clean ANSI:
// * Contains only color/SGR sequences
// * All movement (up/down/left/right) removed but positioning
// left intact via spaces/etc.
//
// Temporary processing will occur in a grid. Each cell
// containing a character (defaulting to space) possibly a SGR
//
let m;
let pos;
let grid = [];
let gridPos = { row : 0, col : 0 };
function updateGrid(data, dataType) {
//
// Start at to grid[row][col] and populate val[0]...val[N]
// creating cells as necessary
//
if(!grid[gridPos.row]) {
grid[gridPos.row] = [];
}
if('literal' === dataType) {
data.forEach(c => {
grid[gridPos.row][gridPos.col] = (grid[gridPos.row][gridPos.col] || '') + c; // append to existing SGR
gridPos.col++;
});
} else if('sgr' === dataType) {
grid[gridPos.row][gridPos.col] = (grid[gridPos.row][gridPos.col] || '') + data;
}
}
function literal(s) {
let charCode;
const len = s.length;
for(let i = 0; i < len; ++i) {
charCode = s.charCodeAt(i) & 0xff;
if(!input) {
return cb('');
}
options.width = options.width || 80;
options.height = options.height || 25;
const canvas = new Array(options.height);
for(let i = 0; i < options.height; ++i) {
canvas[i] = new Array(options.width);
for(let j = 0; j < options.width; ++j) {
canvas[i][j] = {};
} }
} }
do { const parserOpts = {
pos = REGEXP_ANSI_CONTROL_CODES.lastIndex; termHeight : options.height,
m = REGEXP_ANSI_CONTROL_CODES.exec(input); termWidth : options.width,
};
if(null !== m) {
if(m.index > pos) { const parser = new ANSIEscapeParser(parserOpts);
updateGrid(input.slice(pos, m.index), 'literal');
const canvasPos = {
col : 0,
row : 0,
};
let sgr;
function ensureCell() {
// we've pre-allocated a matrix, but allow for > supplied dimens up front. They will be trimmed @ finalize
if(!canvas[canvasPos.row]) {
canvas[canvasPos.row] = new Array(options.width);
for(let j = 0; j < options.width; ++j) {
canvas[canvasPos.row][j] = {};
} }
} }
} while(0 !== REGEXP_ANSI_CONTROL_CODES.lastIndex); canvas[canvasPos.row][canvasPos.col] = canvas[canvasPos.row][canvasPos.col] || {};
//canvas[canvasPos.row][0].width = Math.max(canvas[canvasPos.row][0].width || 0, canvasPos.col);
}
parser.on('literal', literal => {
//
// CR/LF are handled for 'position update'; we don't need the chars themselves
//
literal = literal.replace(/\r?\n|[\r\u2028\u2029]/g, '');
for(let i = 0; i < literal.length; ++i) {
const c = literal.charAt(i);
ensureCell();
canvas[canvasPos.row][canvasPos.col].char = c;
if(sgr) {
canvas[canvasPos.row][canvasPos.col].sgr = sgr;
sgr = null;
}
canvasPos.col += 1;
}
});
parser.on('control', (match, opCode) => {
if('m' !== opCode) {
return; // don't care'
}
sgr = match;
});
parser.on('position update', (row, col) => {
canvasPos.row = row - 1;
canvasPos.col = Math.min(col - 1, options.width);
});
parser.on('complete', () => {
for(let row = 0; row < options.height; ++row) {
let col = 0;
//while(col <= canvas[row][0].width) {
while(col < options.width) {
if(!canvas[row][col].char) {
canvas[row][col].char = ' ';
if(!canvas[row][col].sgr) {
// :TODO: fix duplicate SGR's in a row here - we just need one per sequence
canvas[row][col].sgr = ANSI.reset();
}
}
col += 1;
}
// :TODO: end *all* with CRLF - general usage will be width : 79 - prob update defaults
if(col <= options.width) {
canvas[row][col] = canvas[row][col] || {};
canvas[row][col].char = '\r\n';
canvas[row][col].sgr = ANSI.reset();
// :TODO: don't splice, just reset + fill with ' ' till end
for(let fillCol = col; fillCol <= options.width; ++fillCol) {
canvas[row][fillCol].char = ' ';
}
//canvas[row] = canvas[row].splice(0, col + 1);
//canvas[row][options.width - 1].char = '\r\n';
} else {
canvas[row] = canvas[row].splice(0, options.width + 1);
}
}
let out = '';
for(let row = 0; row < options.height; ++row) {
out += canvas[row].map( col => {
let c = col.sgr || '';
c += col.char;
return c;
}).join('');
}
// :TODO: finalize: @ any non-char cell, reset sgr & set to ' '
// :TODO: finalize: after sgr established, omit anything > supplied dimens
return cb(out);
});
parser.parse(input);
} }
/*
const fs = require('fs');
let data = fs.readFileSync('/home/nuskooler/Downloads/art3.ans');
data = iconv.decode(data, 'cp437');
createCleanAnsi(data, { width : 79, height : 25 }, (out) => {
out = iconv.encode(out, 'cp437');
fs.writeFileSync('/home/nuskooler/Downloads/art4.ans', out);
});
*/

View File

@ -63,9 +63,15 @@ function logoff(callingMenu, formData, extraArgs, cb) {
} }
function prevMenu(callingMenu, formData, extraArgs, cb) { function prevMenu(callingMenu, formData, extraArgs, cb) {
// :TODO: this is a pretty big hack -- need the whole key map concep there like other places
if(formData.key && 'return' === formData.key.name) {
callingMenu.submitFormData = formData;
}
callingMenu.prevMenu( err => { callingMenu.prevMenu( err => {
if(err) { if(err) {
callingMenu.client.log.error( { error : err.toString() }, 'Error attempting to fallback!'); callingMenu.client.log.error( { error : err.message }, 'Error attempting to fallback!');
} }
return cb(err); return cb(err);
}); });
@ -74,7 +80,7 @@ function prevMenu(callingMenu, formData, extraArgs, cb) {
function nextMenu(callingMenu, formData, extraArgs, cb) { function nextMenu(callingMenu, formData, extraArgs, cb) {
callingMenu.nextMenu( err => { callingMenu.nextMenu( err => {
if(err) { if(err) {
callingMenu.client.log.error( { error : err.toString() }, 'Error attempting to go to next menu!'); callingMenu.client.log.error( { error : err.message}, 'Error attempting to go to next menu!');
} }
return cb(err); return cb(err);
}); });

View File

@ -10,6 +10,7 @@ const stylizeString = require('./string_util.js').stylizeString;
const renderSubstr = require('./string_util.js').renderSubstr; const renderSubstr = require('./string_util.js').renderSubstr;
const renderStringLength = require('./string_util.js').renderStringLength; const renderStringLength = require('./string_util.js').renderStringLength;
const pipeToAnsi = require('./color_codes.js').pipeToAnsi; const pipeToAnsi = require('./color_codes.js').pipeToAnsi;
const stripAllLineFeeds = require('./string_util.js').stripAllLineFeeds;
// deps // deps
const util = require('util'); const util = require('util');
@ -102,7 +103,7 @@ function TextView(options) {
renderLength = renderStringLength(textToDraw); renderLength = renderStringLength(textToDraw);
if(renderLength > this.dimens.width) { if(renderLength >= this.dimens.width) {
if(this.hasFocus) { if(this.hasFocus) {
if(this.horizScroll) { if(this.horizScroll) {
textToDraw = renderSubstr(textToDraw, renderLength - this.dimens.width, renderLength); textToDraw = renderSubstr(textToDraw, renderLength - this.dimens.width, renderLength);
@ -150,7 +151,7 @@ TextView.prototype.redraw = function() {
// and there is no actual text (e.g. save SGR's and processing) // and there is no actual text (e.g. save SGR's and processing)
// //
if(!this.hasDrawnOnce) { if(!this.hasDrawnOnce) {
if(!this.text) { if(_.isUndefined(this.text)) {
return; return;
} }
} }
@ -183,7 +184,7 @@ TextView.prototype.setText = function(text, redraw) {
text = text.toString(); text = text.toString();
} }
text = pipeToAnsi(text, this.client); // expand MCI/etc. text = pipeToAnsi(stripAllLineFeeds(text), this.client); // expand MCI/etc.
var widthDelta = 0; var widthDelta = 0;
if(this.text && this.text !== text) { if(this.text && this.text !== text) {

View File

@ -1,21 +1,21 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var Config = require('./config.js').config; const Config = require('./config.js').config;
var art = require('./art.js'); const art = require('./art.js');
var ansi = require('./ansi_term.js'); const ansi = require('./ansi_term.js');
var miscUtil = require('./misc_util.js'); const Log = require('./logger.js').log;
var Log = require('./logger.js').log; const configCache = require('./config_cache.js');
var configCache = require('./config_cache.js'); const getFullConfig = require('./config_util.js').getFullConfig;
var getFullConfig = require('./config_util.js').getFullConfig; const asset = require('./asset.js');
var asset = require('./asset.js'); const ViewController = require('./view_controller.js').ViewController;
var ViewController = require('./view_controller.js').ViewController; const Errors = require('./enig_error.js').Errors;
var fs = require('fs'); const fs = require('fs');
var paths = require('path'); const paths = require('path');
var async = require('async'); const async = require('async');
var _ = require('lodash'); const _ = require('lodash');
var assert = require('assert'); const assert = require('assert');
exports.getThemeArt = getThemeArt; exports.getThemeArt = getThemeArt;
exports.getAvailableThemes = getAvailableThemes; exports.getAvailableThemes = getAvailableThemes;
@ -24,6 +24,7 @@ exports.setClientTheme = setClientTheme;
exports.initAvailableThemes = initAvailableThemes; exports.initAvailableThemes = initAvailableThemes;
exports.displayThemeArt = displayThemeArt; exports.displayThemeArt = displayThemeArt;
exports.displayThemedPause = displayThemedPause; exports.displayThemedPause = displayThemedPause;
exports.displayThemedPrompt = displayThemedPrompt;
exports.displayThemedAsset = displayThemedAsset; exports.displayThemedAsset = displayThemedAsset;
function refreshThemeHelpers(theme) { function refreshThemeHelpers(theme) {
@ -100,10 +101,10 @@ function loadTheme(themeID, cb) {
}); });
} }
var availableThemes = {}; const availableThemes = {};
var IMMUTABLE_MCI_PROPERTIES = [ const IMMUTABLE_MCI_PROPERTIES = [
'maxLength', 'argName', 'submit', 'validate' 'maxLength', 'argName', 'submit', 'validate'
]; ];
function getMergedTheme(menuConfig, promptConfig, theme) { function getMergedTheme(menuConfig, promptConfig, theme) {
@ -119,44 +120,44 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
// //
var mergedTheme = _.cloneDeep(menuConfig); var mergedTheme = _.cloneDeep(menuConfig);
if(_.isObject(promptConfig.prompts)) { if(_.isObject(promptConfig.prompts)) {
mergedTheme.prompts = _.cloneDeep(promptConfig.prompts); mergedTheme.prompts = _.cloneDeep(promptConfig.prompts);
} }
// //
// Add in data we won't be altering directly from the theme // Add in data we won't be altering directly from the theme
// //
mergedTheme.info = theme.info; mergedTheme.info = theme.info;
mergedTheme.helpers = theme.helpers; mergedTheme.helpers = theme.helpers;
// //
// merge customizer to disallow immutable MCI properties // merge customizer to disallow immutable MCI properties
// //
var mciCustomizer = function(objVal, srcVal, key) { var mciCustomizer = function(objVal, srcVal, key) {
return IMMUTABLE_MCI_PROPERTIES.indexOf(key) > -1 ? objVal : srcVal; return IMMUTABLE_MCI_PROPERTIES.indexOf(key) > -1 ? objVal : srcVal;
}; };
function getFormKeys(fromObj) { function getFormKeys(fromObj) {
return _.remove(_.keys(fromObj), function pred(k) { return _.remove(_.keys(fromObj), function pred(k) {
return !isNaN(k); // remove all non-numbers return !isNaN(k); // remove all non-numbers
}); });
} }
function mergeMciProperties(dest, src) { function mergeMciProperties(dest, src) {
Object.keys(src).forEach(function mciEntry(mci) { Object.keys(src).forEach(function mciEntry(mci) {
_.merge(dest[mci], src[mci], mciCustomizer); _.mergeWith(dest[mci], src[mci], mciCustomizer);
}); });
} }
function applyThemeMciBlock(dest, src, formKey) { function applyThemeMciBlock(dest, src, formKey) {
if(_.isObject(src.mci)) { if(_.isObject(src.mci)) {
mergeMciProperties(dest, src.mci); mergeMciProperties(dest, src.mci);
} else { } else {
if(_.has(src, [ formKey, 'mci' ])) { if(_.has(src, [ formKey, 'mci' ])) {
mergeMciProperties(dest, src[formKey].mci); mergeMciProperties(dest, src[formKey].mci);
} }
} }
} }
// //
// menu.hjson can have a couple different structures: // menu.hjson can have a couple different structures:
@ -180,103 +181,103 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
// * If theme.hjson provides form ID's, use them. Otherwise, we'll apply directly assuming // * If theme.hjson provides form ID's, use them. Otherwise, we'll apply directly assuming
// there is a generic 'mci' block. // there is a generic 'mci' block.
// //
function applyToForm(form, menuTheme, formKey) { function applyToForm(form, menuTheme, formKey) {
if(_.isObject(form.mci)) { if(_.isObject(form.mci)) {
// non-explicit: no MCI code(s) key assumed since we found 'mci' directly under form ID // non-explicit: no MCI code(s) key assumed since we found 'mci' directly under form ID
applyThemeMciBlock(form.mci, menuTheme, formKey); applyThemeMciBlock(form.mci, menuTheme, formKey);
} else { } else {
var menuMciCodeKeys = _.remove(_.keys(form), function pred(k) { var menuMciCodeKeys = _.remove(_.keys(form), function pred(k) {
return k === k.toUpperCase(); // remove anything not uppercase return k === k.toUpperCase(); // remove anything not uppercase
}); });
menuMciCodeKeys.forEach(function mciKeyEntry(mciKey) { menuMciCodeKeys.forEach(function mciKeyEntry(mciKey) {
var applyFrom; var applyFrom;
if(_.has(menuTheme, [ mciKey, 'mci' ])) { if(_.has(menuTheme, [ mciKey, 'mci' ])) {
applyFrom = menuTheme[mciKey]; applyFrom = menuTheme[mciKey];
} else { } else {
applyFrom = menuTheme; applyFrom = menuTheme;
} }
applyThemeMciBlock(form[mciKey].mci, applyFrom); applyThemeMciBlock(form[mciKey].mci, applyFrom);
}); });
} }
} }
[ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) { [ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) {
_.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) { _.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) {
var createdFormSection = false; var createdFormSection = false;
var mergedThemeMenu = mergedTheme[sectionName][menuName]; var mergedThemeMenu = mergedTheme[sectionName][menuName];
if(_.has(theme, [ 'customization', sectionName, menuName ])) { if(_.has(theme, [ 'customization', sectionName, menuName ])) {
var menuTheme = theme.customization[sectionName][menuName]; var menuTheme = theme.customization[sectionName][menuName];
// config block is direct assign/overwrite // config block is direct assign/overwrite
// :TODO: should probably be _.merge() // :TODO: should probably be _.merge()
if(menuTheme.config) { if(menuTheme.config) {
mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config); mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config);
} }
if('menus' === sectionName) { if('menus' === sectionName) {
if(_.isObject(mergedThemeMenu.form)) { if(_.isObject(mergedThemeMenu.form)) {
getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) { getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) {
applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey); applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey);
}); });
} else { } else {
if(_.isObject(menuTheme.mci)) { if(_.isObject(menuTheme.mci)) {
// //
// Not specified at menu level means we apply anything from the // Not specified at menu level means we apply anything from the
// theme to form.0.mci{} // theme to form.0.mci{}
// //
mergedThemeMenu.form = { 0 : { mci : { } } }; mergedThemeMenu.form = { 0 : { mci : { } } };
mergeMciProperties(mergedThemeMenu.form[0], menuTheme); mergeMciProperties(mergedThemeMenu.form[0], menuTheme);
createdFormSection = true; createdFormSection = true;
} }
} }
} else if('prompts' === sectionName) { } else if('prompts' === sectionName) {
// no 'form' or form keys for prompts -- direct to mci // no 'form' or form keys for prompts -- direct to mci
applyToForm(mergedThemeMenu, menuTheme); applyToForm(mergedThemeMenu, menuTheme);
} }
} }
// //
// Finished merging for this menu/prompt // Finished merging for this menu/prompt
// //
// If the following conditions are true, set runtime.autoNext to true: // If the following conditions are true, set runtime.autoNext to true:
// * This is a menu // * This is a menu
// * There is/was no explicit 'form' section // * There is/was no explicit 'form' section
// * There is no 'prompt' specified // * There is no 'prompt' specified
// //
if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) && if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) &&
(createdFormSection || !_.isObject(mergedThemeMenu.form))) (createdFormSection || !_.isObject(mergedThemeMenu.form)))
{ {
mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } ); mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } );
} }
}); });
}); });
return mergedTheme; return mergedTheme;
} }
function initAvailableThemes(cb) { function initAvailableThemes(cb) {
var menuConfig; var menuConfig;
var promptConfig; var promptConfig;
async.waterfall( async.waterfall(
[ [
function loadMenuConfig(callback) { function loadMenuConfig(callback) {
getFullConfig(Config.general.menuFile, function gotConfig(err, mc) { getFullConfig(Config.general.menuFile, function gotConfig(err, mc) {
menuConfig = mc; menuConfig = mc;
callback(err); callback(err);
}); });
}, },
function loadPromptConfig(callback) { function loadPromptConfig(callback) {
getFullConfig(Config.general.promptFile, function gotConfig(err, pc) { getFullConfig(Config.general.promptFile, function gotConfig(err, pc) {
promptConfig = pc; promptConfig = pc;
callback(err); callback(err);
}); });
}, },
function getDir(callback) { function getDir(callback) {
fs.readdir(Config.paths.themes, function dirRead(err, files) { fs.readdir(Config.paths.themes, function dirRead(err, files) {
callback(err, files); callback(err, files);
@ -294,7 +295,7 @@ function initAvailableThemes(cb) {
filtered.forEach(function themeEntry(themeId) { filtered.forEach(function themeEntry(themeId) {
loadTheme(themeId, function themeLoaded(err, theme, themePath) { loadTheme(themeId, function themeLoaded(err, theme, themePath) {
if(!err) { if(!err) {
availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme); availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme);
configCache.on('recached', function recached(path) { configCache.on('recached', function recached(path) {
if(themePath === path) { if(themePath === path) {
@ -339,32 +340,32 @@ function getRandomTheme() {
} }
function setClientTheme(client, themeId) { function setClientTheme(client, themeId) {
var desc; var desc;
try { try {
client.currentTheme = getAvailableThemes()[themeId]; client.currentTheme = getAvailableThemes()[themeId];
desc = 'Set client theme'; desc = 'Set client theme';
} catch(e) { } catch(e) {
client.currentTheme = getAvailableThemes()[Config.defaults.theme]; client.currentTheme = getAvailableThemes()[Config.defaults.theme];
desc = 'Failed setting theme by supplied ID; Using default'; desc = 'Failed setting theme by supplied ID; Using default';
} }
client.log.debug( { themeId : themeId, info : client.currentTheme.info }, desc); client.log.debug( { themeId : themeId, info : client.currentTheme.info }, desc);
} }
function getThemeArt(options, cb) { function getThemeArt(options, cb) {
// //
// options - required: // options - required:
// name // name
// client
// //
// options - optional // options - optional
// themeId // client - needed for user's theme/etc.
// asAnsi // themeId
// readSauce // asAnsi
// random // readSauce
// random
// //
if(!options.themeId && _.has(options.client, 'user.properties.theme_id')) { if(!options.themeId && _.has(options, 'client.user.properties.theme_id')) {
options.themeId = options.client.user.properties.theme_id; options.themeId = options.client.user.properties.theme_id;
} else { } else {
options.themeId = Config.defaults.theme; options.themeId = Config.defaults.theme;
@ -438,9 +439,13 @@ function getThemeArt(options, cb) {
], ],
function complete(err, artInfo) { function complete(err, artInfo) {
if(err) { if(err) {
options.client.log.debug( { error : err }, 'Cannot find art'); if(options.client) {
options.client.log.debug( { error : err.message }, 'Cannot find theme art' );
} else {
Log.debug( { error : err.message }, 'Cannot find theme art' );
}
} }
cb(err, artInfo); return cb(err, artInfo);
} }
); );
} }
@ -481,110 +486,187 @@ function displayThemeArt(options, cb) {
}); });
} }
/*
function displayThemedPrompt(name, client, options, cb) {
async.waterfall(
[
function loadConfig(callback) {
configCache.getModConfig('prompt.hjson', (err, promptJson) => {
if(err) {
return callback(err);
}
if(_.has(promptJson, [ 'prompts', name ] )) {
return callback(Errors.DoesNotExist(`Prompt "${name}" does not exist`));
}
const promptConfig = promptJson.prompts[name];
if(!_.isObject(promptConfig)) {
return callback(Errors.Invalid(`Prompt "${name} is invalid`));
}
return callback(null, promptConfig);
});
},
function display(promptConfig, callback) {
if(options.clearScreen) {
client.term.rawWrite(ansi.clearScreen());
}
//
// If we did not clear the screen, don't let the font change
//
const dispOptions = Object.assign( {}, promptConfig.options );
if(!options.clearScreen) {
dispOptions.font = 'not_really_a_font!';
}
displayThemedAsset(
promptConfig.art,
client,
dispOptions,
(err, artData) => {
if(err) {
return callback(err);
}
return callback(null, promptConfig, artData.mciMap);
}
);
},
function prepViews(promptConfig, mciMap, callback) {
vc = new ViewController( { client : client } );
const loadOpts = {
promptName : name,
mciMap : mciMap,
config : promptConfig,
};
vc.loadFromPromptConfig(loadOpts, err => {
callback(null);
});
}
]
);
}
*/
function displayThemedPrompt(name, client, options, cb) {
const useTempViewController = _.isUndefined(options.viewController);
async.waterfall(
[
function display(callback) {
const promptConfig = client.currentTheme.prompts[name];
if(!promptConfig) {
return callback(Errors.DoesNotExist(`Missing "${name}" prompt configuration!`));
}
if(options.clearScreen) {
client.term.rawWrite(ansi.resetScreen());
}
//
// If we did *not* clear the screen, don't let the font change
// as it will mess with the output of the existing art displayed in a terminal
//
const dispOptions = Object.assign( {}, promptConfig.options );
if(!options.clearScreen) {
dispOptions.font = 'not_really_a_font!'; // kludge :)
}
displayThemedAsset(
promptConfig.art,
client,
dispOptions,
(err, artInfo) => {
return callback(err, promptConfig, artInfo);
}
);
},
function discoverCursorPosition(promptConfig, artInfo, callback) {
if(!options.clearPrompt) {
// no need to query cursor - we're not gonna use it
return callback(null, promptConfig, artInfo);
}
client.once('cursor position report', pos => {
artInfo.startRow = pos[0] - artInfo.height;
return callback(null, promptConfig, artInfo);
});
client.term.rawWrite(ansi.queryPos());
},
function createMCIViews(promptConfig, artInfo, callback) {
const tempViewController = useTempViewController ? new ViewController( { client : client } ) : options.viewController;
const loadOpts = {
promptName : name,
mciMap : artInfo.mciMap,
config : promptConfig,
};
tempViewController.loadFromPromptConfig(loadOpts, () => {
return callback(null, artInfo, tempViewController);
});
},
function pauseForUserInput(artInfo, tempViewController, callback) {
if(!options.pause) {
return callback(null, artInfo, tempViewController);
}
client.waitForKeyPress( () => {
return callback(null, artInfo, tempViewController);
});
},
function clearPauseArt(artInfo, tempViewController, callback) {
if(options.clearPrompt) {
if(artInfo.startRow && artInfo.height) {
client.term.rawWrite(ansi.goto(artInfo.startRow, 1));
// Note: Does not work properly in NetRunner < 2.0b17:
client.term.rawWrite(ansi.deleteLine(artInfo.height));
} else {
client.term.rawWrite(ansi.eraseLine(1));
}
}
return callback(null, tempViewController);
}
],
(err, tempViewController) => {
if(err) {
client.log.warn( { error : err.message }, `Failed displaying "${name}" prompt` );
}
if(tempViewController && useTempViewController) {
tempViewController.detachClientEvents();
}
return cb(null);
}
);
}
// //
// Pause prompts are a special prompt by the name 'pause'. // Pause prompts are a special prompt by the name 'pause'.
// //
function displayThemedPause(options, cb) { function displayThemedPause(client, options, cb) {
//
// options.client if(!cb && _.isFunction(options)) {
// options clearPrompt cb = options;
// options = {};
assert(_.isObject(options.client)); }
if(!_.isBoolean(options.clearPrompt)) { if(!_.isBoolean(options.clearPrompt)) {
options.clearPrompt = true; options.clearPrompt = true;
} }
// :TODO: Support animated pause prompts. Probably via MCI with AnimatedView const promptOptions = Object.assign( {}, options, { pause : true } );
return displayThemedPrompt('pause', client, promptOptions, cb);
var artInfo;
var vc;
var promptConfig;
async.series(
[
function loadPromptJSON(callback) {
configCache.getModConfig('prompt.hjson', function loaded(err, promptJson) {
if(err) {
callback(err);
} else {
if(_.has(promptJson, [ 'prompts', 'pause' ] )) {
promptConfig = promptJson.prompts.pause;
callback(_.isObject(promptConfig) ? null : new Error('Invalid prompt config block!'));
} else {
callback(new Error('Missing standard \'pause\' prompt'));
}
}
});
},
function displayPausePrompt(callback) {
//
// Override .font so it doesn't change from current setting
//
var dispOptions = promptConfig.options;
dispOptions.font = 'not_really_a_font!';
displayThemedAsset(
promptConfig.art,
options.client,
dispOptions,
function displayed(err, artData) {
artInfo = artData;
callback(err);
}
);
},
function discoverCursorPosition(callback) {
options.client.once('cursor position report', function cpr(pos) {
artInfo.startRow = pos[0] - artInfo.height;
callback(null);
});
options.client.term.rawWrite(ansi.queryPos());
},
function createMCIViews(callback) {
vc = new ViewController( { client : options.client, noInput : true } );
vc.loadFromPromptConfig( { promptName : 'pause', mciMap : artInfo.mciMap, config : promptConfig }, function loaded(err) {
callback(null);
});
},
function pauseForUserInput(callback) {
options.client.waitForKeyPress(function keyPressed() {
callback(null);
});
},
function clearPauseArt(callback) {
if(options.clearPrompt) {
if(artInfo.startRow && artInfo.height) {
options.client.term.rawWrite(ansi.goto(artInfo.startRow, 1));
// Note: Does not work properly in NetRunner < 2.0b17:
options.client.term.rawWrite(ansi.deleteLine(artInfo.height));
} else {
options.client.term.rawWrite(ansi.eraseLine(1))
}
}
callback(null);
}
/*
, function debugPause(callback) {
setTimeout(function to() {
callback(null);
}, 4000);
}
*/
],
function complete(err) {
if(err) {
Log.error(err);
}
if(vc) {
vc.detachClientEvents();
}
cb();
}
);
} }
function displayThemedAsset(assetSpec, client, options, cb) { function displayThemedAsset(assetSpec, client, options, cb) {

View File

@ -25,9 +25,7 @@ function ToggleMenuView (options) {
*/ */
this.updateSelection = function() { this.updateSelection = function() {
//assert(!self.positionCacheExpired);
assert(this.focusedItemIndex >= 0 && this.focusedItemIndex <= self.items.length); assert(this.focusedItemIndex >= 0 && this.focusedItemIndex <= self.items.length);
self.redraw(); self.redraw();
}; };
} }
@ -74,28 +72,38 @@ ToggleMenuView.prototype.setFocus = function(focused) {
this.redraw(); this.redraw();
}; };
ToggleMenuView.prototype.onKeyPress = function(ch, key) { ToggleMenuView.prototype.focusNext = function() {
if(key) { if(this.items.length - 1 === this.focusedItemIndex) {
var needsUpdate; this.focusedItemIndex = 0;
if(this.isKeyMapped('right', key.name) || this.isKeyMapped('down', key.name)) { } else {
if(this.items.length - 1 === this.focusedItemIndex) { this.focusedItemIndex++;
this.focusedItemIndex = 0; }
} else {
this.focusedItemIndex++;
}
needsUpdate = true;
} else if(this.isKeyMapped('left', key.name) || this.isKeyMapped('up', key.name)) {
if(0 === this.focusedItemIndex) {
this.focusedItemIndex = this.items.length - 1;
} else {
this.focusedItemIndex--;
}
needsUpdate = true;
}
if(needsUpdate) { this.updateSelection();
this.updateSelection();
return; ToggleMenuView.super_.prototype.focusNext.call(this);
};
ToggleMenuView.prototype.focusPrevious = function() {
if(0 === this.focusedItemIndex) {
this.focusedItemIndex = this.items.length - 1;
} else {
this.focusedItemIndex--;
}
this.updateSelection();
ToggleMenuView.super_.prototype.focusPrevious.call(this);
};
ToggleMenuView.prototype.onKeyPress = function(ch, key) {
if(key) {
if(this.isKeyMapped('right', key.name) || this.isKeyMapped('down', key.name)) {
this.focusNext();
} else if(this.isKeyMapped('left', key.name) || this.isKeyMapped('up', key.nam4e)) {
this.focusPrevious();
} }
} }

View File

@ -325,6 +325,22 @@ User.prototype.persistProperty = function(propName, propValue, cb) {
); );
}; };
User.prototype.removeProperty = function(propName, cb) {
// update live
delete this.properties[propName];
userDb.run(
`DELETE FROM user_property
WHERE user_id = ? AND prop_name = ?;`,
[ this.userId, propName ],
err => {
if(cb) {
return cb(err);
}
}
);
};
User.prototype.persistProperties = function(properties, cb) { User.prototype.persistProperties = function(properties, cb) {
var self = this; var self = this;
@ -458,7 +474,7 @@ function generatePasswordDerivedKeySalt(cb) {
function generatePasswordDerivedKey(password, salt, cb) { function generatePasswordDerivedKey(password, salt, cb) {
password = new Buffer(password).toString('hex'); password = new Buffer(password).toString('hex');
crypto.pbkdf2(password, salt, User.PBKDF2.iterations, User.PBKDF2.keyLen, function onDerivedKey(err, dk) { crypto.pbkdf2(password, salt, User.PBKDF2.iterations, User.PBKDF2.keyLen, 'sha1', function onDerivedKey(err, dk) {
if(err) { if(err) {
cb(err); cb(err);
} else { } else {

View File

@ -1,17 +1,15 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var MenuModule = require('./menu_module.js').MenuModule; const MenuModule = require('./menu_module.js').MenuModule;
var ViewController = require('./view_controller.js').ViewController; const ViewController = require('./view_controller.js').ViewController;
var theme = require('./theme.js'); const theme = require('./theme.js');
var sysValidate = require('./system_view_validate.js'); const sysValidate = require('./system_view_validate.js');
var async = require('async'); const async = require('async');
var assert = require('assert'); const assert = require('assert');
var _ = require('lodash'); const _ = require('lodash');
var moment = require('moment'); const moment = require('moment');
exports.getModule = UserConfigModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'User Configuration', name : 'User Configuration',
@ -19,7 +17,7 @@ exports.moduleInfo = {
author : 'NuSkooler', author : 'NuSkooler',
}; };
var MciCodeIds = { const MciCodeIds = {
RealName : 1, RealName : 1,
BirthDate : 2, BirthDate : 2,
Sex : 3, Sex : 3,
@ -37,192 +35,187 @@ var MciCodeIds = {
SaveCancel : 25, SaveCancel : 25,
}; };
function UserConfigModule(options) { exports.getModule = class UserConfigModule extends MenuModule {
MenuModule.call(this, options); constructor(options) {
super(options);
var self = this; const self = this;
self.getView = function(viewId) {
return self.viewControllers.menu.getView(viewId);
};
self.setViewText = function(viewId, text) { this.menuMethods = {
var v = self.getView(viewId);
if(v) {
v.setText(text);
}
};
this.menuMethods = {
//
// Validation support
//
validateEmailAvail : function(data, cb) {
// //
// If nothing changed, we know it's OK // Validation support
// //
if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) { validateEmailAvail : function(data, cb) {
return cb(null);
}
// Otherwise we can use the standard system method
return sysValidate.validateEmailAvail(data, cb);
},
validatePassword : function(data, cb) {
//
// Blank is OK - this means we won't be changing it
//
if(!data || 0 === data.length) {
return cb(null);
}
// Otherwise we can use the standard system method
return sysValidate.validatePasswordSpec(data, cb);
},
validatePassConfirmMatch : function(data, cb) {
var passwordView = self.getView(MciCodeIds.Password);
cb(passwordView.getData() === data ? null : new Error('Passwords do not match'));
},
viewValidationListener : function(err, cb) {
var errMsgView = self.getView(MciCodeIds.ErrorMsg);
var newFocusId;
if(errMsgView) {
if(err) {
errMsgView.setText(err.message);
if(err.view.getId() === MciCodeIds.PassConfirm) {
newFocusId = MciCodeIds.Password;
var passwordView = self.getView(MciCodeIds.Password);
passwordView.clearText();
err.view.clearText();
}
} else {
errMsgView.clearText();
}
}
cb(newFocusId);
},
//
// Handlers
//
saveChanges : function(formData, extraArgs, cb) {
assert(formData.value.password === formData.value.passwordConfirm);
const newProperties = {
real_name : formData.value.realName,
birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(),
sex : formData.value.sex,
location : formData.value.location,
affiliation : formData.value.affils,
email_address : formData.value.email,
web_address : formData.value.web,
term_height : formData.value.termHeight.toString(),
theme_id : self.availThemeInfo[formData.value.theme].themeId,
};
// runtime set theme
theme.setClientTheme(self.client, newProperties.theme_id);
// persist all changes
self.client.user.persistProperties(newProperties, err => {
if(err) {
self.client.log.warn( { error : err.toString() }, 'Failed persisting updated properties');
// :TODO: warn end user!
return self.prevMenu(cb);
}
// //
// New password if it's not empty // If nothing changed, we know it's OK
// //
self.client.log.info('User updated properties'); if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) {
return cb(null);
if(formData.value.password.length > 0) {
self.client.user.setNewAuthCredentials(formData.value.password, err => {
if(err) {
self.client.log.error( { err : err }, 'Failed storing new authentication credentials');
} else {
self.client.log.info('User changed authentication credentials');
}
return self.prevMenu(cb);
});
} else {
return self.prevMenu(cb);
} }
});
},
};
}
require('util').inherits(UserConfigModule, MenuModule);
UserConfigModule.prototype.mciReady = function(mciData, cb) {
var self = this;
var vc = self.viewControllers.menu = new ViewController( { client : self.client} );
var currentThemeIdIndex = 0;
async.series(
[
function callParentMciReady(callback) {
UserConfigModule.super_.prototype.mciReady.call(self, mciData, callback);
},
function loadFromConfig(callback) {
vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback);
},
function prepareAvailableThemes(callback) {
self.availThemeInfo = _.sortBy(_.map(theme.getAvailableThemes(), function makeThemeInfo(t, themeId) {
return {
themeId : themeId,
name : t.info.name,
author : t.info.author,
desc : _.isString(t.info.desc) ? t.info.desc : '',
group : _.isString(t.info.group) ? t.info.group : '',
};
}), 'name');
currentThemeIdIndex = _.findIndex(self.availThemeInfo, function cmp(ti) { // Otherwise we can use the standard system method
return ti.themeId === self.client.user.properties.theme_id; return sysValidate.validateEmailAvail(data, cb);
});
callback(null);
}, },
function populateViews(callback) {
var user = self.client.user; validatePassword : function(data, cb) {
//
self.setViewText(MciCodeIds.RealName, user.properties.real_name); // Blank is OK - this means we won't be changing it
self.setViewText(MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD')); //
self.setViewText(MciCodeIds.Sex, user.properties.sex); if(!data || 0 === data.length) {
self.setViewText(MciCodeIds.Loc, user.properties.location); return cb(null);
self.setViewText(MciCodeIds.Affils, user.properties.affiliation); }
self.setViewText(MciCodeIds.Email, user.properties.email_address);
self.setViewText(MciCodeIds.Web, user.properties.web_address); // Otherwise we can use the standard system method
self.setViewText(MciCodeIds.TermHeight, user.properties.term_height.toString()); return sysValidate.validatePasswordSpec(data, cb);
},
validatePassConfirmMatch : function(data, cb) {
var passwordView = self.getView(MciCodeIds.Password);
cb(passwordView.getData() === data ? null : new Error('Passwords do not match'));
},
viewValidationListener : function(err, cb) {
var errMsgView = self.getView(MciCodeIds.ErrorMsg);
var newFocusId;
if(errMsgView) {
if(err) {
errMsgView.setText(err.message);
if(err.view.getId() === MciCodeIds.PassConfirm) {
var themeView = self.getView(MciCodeIds.Theme); newFocusId = MciCodeIds.Password;
if(themeView) { var passwordView = self.getView(MciCodeIds.Password);
themeView.setItems(_.map(self.availThemeInfo, 'name')); passwordView.clearText();
themeView.setFocusItemIndex(currentThemeIdIndex); err.view.clearText();
}
} else {
errMsgView.clearText();
}
} }
cb(newFocusId);
},
//
// Handlers
//
saveChanges : function(formData, extraArgs, cb) {
assert(formData.value.password === formData.value.passwordConfirm);
var realNameView = self.getView(MciCodeIds.RealName); const newProperties = {
if(realNameView) { real_name : formData.value.realName,
realNameView.setFocus(true); // :TODO: HACK! menu.hjson sets focus, but manual population above breaks this. Needs a real fix! birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(),
} sex : formData.value.sex,
location : formData.value.location,
affiliation : formData.value.affils,
email_address : formData.value.email,
web_address : formData.value.web,
term_height : formData.value.termHeight.toString(),
theme_id : self.availThemeInfo[formData.value.theme].themeId,
};
callback(null); // runtime set theme
} theme.setClientTheme(self.client, newProperties.theme_id);
],
function complete(err) { // persist all changes
self.client.user.persistProperties(newProperties, err => {
if(err) {
self.client.log.warn( { error : err.toString() }, 'Failed persisting updated properties');
// :TODO: warn end user!
return self.prevMenu(cb);
}
//
// New password if it's not empty
//
self.client.log.info('User updated properties');
if(formData.value.password.length > 0) {
self.client.user.setNewAuthCredentials(formData.value.password, err => {
if(err) {
self.client.log.error( { err : err }, 'Failed storing new authentication credentials');
} else {
self.client.log.info('User changed authentication credentials');
}
return self.prevMenu(cb);
});
} else {
return self.prevMenu(cb);
}
});
},
};
}
getView(viewId) {
return this.viewControllers.menu.getView(viewId);
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) { if(err) {
self.client.log.warn( { error : err.toString() }, 'User configuration failed to init'); return cb(err);
self.prevMenu();
} else {
cb(null);
} }
}
); const self = this;
const vc = self.viewControllers.menu = new ViewController( { client : self.client} );
let currentThemeIdIndex = 0;
async.series(
[
function loadFromConfig(callback) {
vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback);
},
function prepareAvailableThemes(callback) {
self.availThemeInfo = _.sortBy(_.map(theme.getAvailableThemes(), function makeThemeInfo(t, themeId) {
return {
themeId : themeId,
name : t.info.name,
author : t.info.author,
desc : _.isString(t.info.desc) ? t.info.desc : '',
group : _.isString(t.info.group) ? t.info.group : '',
};
}), 'name');
currentThemeIdIndex = _.findIndex(self.availThemeInfo, function cmp(ti) {
return ti.themeId === self.client.user.properties.theme_id;
});
callback(null);
},
function populateViews(callback) {
var user = self.client.user;
self.setViewText('menu', MciCodeIds.RealName, user.properties.real_name);
self.setViewText('menu', MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD'));
self.setViewText('menu', MciCodeIds.Sex, user.properties.sex);
self.setViewText('menu', MciCodeIds.Loc, user.properties.location);
self.setViewText('menu', MciCodeIds.Affils, user.properties.affiliation);
self.setViewText('menu', MciCodeIds.Email, user.properties.email_address);
self.setViewText('menu', MciCodeIds.Web, user.properties.web_address);
self.setViewText('menu', MciCodeIds.TermHeight, user.properties.term_height.toString());
var themeView = self.getView(MciCodeIds.Theme);
if(themeView) {
themeView.setItems(_.map(self.availThemeInfo, 'name'));
themeView.setFocusItemIndex(currentThemeIdIndex);
}
var realNameView = self.getView(MciCodeIds.RealName);
if(realNameView) {
realNameView.setFocus(true); // :TODO: HACK! menu.hjson sets focus, but manual population above breaks this. Needs a real fix!
}
callback(null);
}
],
function complete(err) {
if(err) {
self.client.log.warn( { error : err.toString() }, 'User configuration failed to init');
self.prevMenu();
} else {
cb(null);
}
}
);
});
}
}; };

View File

@ -4,7 +4,6 @@
// ENiGMA½ // ENiGMA½
const setClientTheme = require('./theme.js').setClientTheme; const setClientTheme = require('./theme.js').setClientTheme;
const clientConnections = require('./client_connections.js').clientConnections; const clientConnections = require('./client_connections.js').clientConnections;
const userDb = require('./database.js').dbs.user;
const StatLog = require('./stat_log.js'); const StatLog = require('./stat_log.js');
const logger = require('./logger.js'); const logger = require('./logger.js');
@ -21,66 +20,66 @@ function userLogin(client, username, password, cb) {
// :TODO: if username exists, record failed login attempt to properties // :TODO: if username exists, record failed login attempt to properties
// :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true // :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true
cb(err); return cb(err);
} else {
const now = new Date();
const user = client.user;
//
// Ensure this user is not already logged in.
// Loop through active connections -- which includes the current --
// and check for matching user ID. If the count is > 1, disallow.
//
var existingClientConnection;
clientConnections.forEach(function connEntry(cc) {
if(cc.user !== user && cc.user.userId === user.userId) {
existingClientConnection = cc;
}
});
if(existingClientConnection) {
client.log.info( {
existingClientId : existingClientConnection.session.id,
username : user.username,
userId : user.userId },
'Already logged in'
);
var existingConnError = new Error('Already logged in as supplied user');
existingConnError.existingConn = true;
return cb(existingClientConnection);
}
// update client logger with addition of username
client.log = logger.log.child( { clientId : client.log.fields.clientId, username : user.username });
client.log.info('Successful login');
async.parallel(
[
function setTheme(callback) {
setClientTheme(client, user.properties.theme_id);
callback(null);
},
function updateSystemLoginCount(callback) {
StatLog.incrementSystemStat('login_count', 1, callback);
},
function recordLastLogin(callback) {
StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback);
},
function updateUserLoginCount(callback) {
StatLog.incrementUserStat(user, 'login_count', 1, callback);
},
function recordLoginHistory(callback) {
const LOGIN_HISTORY_MAX = 200; // history of up to last 200 callers
StatLog.appendSystemLogEntry('user_login_history', user.userId, LOGIN_HISTORY_MAX, StatLog.KeepType.Max, callback);
}
],
function complete(err) {
cb(err);
}
);
} }
const user = client.user;
//
// Ensure this user is not already logged in.
// Loop through active connections -- which includes the current --
// and check for matching user ID. If the count is > 1, disallow.
//
let existingClientConnection;
clientConnections.forEach(function connEntry(cc) {
if(cc.user !== user && cc.user.userId === user.userId) {
existingClientConnection = cc;
}
});
if(existingClientConnection) {
client.log.info( {
existingClientId : existingClientConnection.session.id,
username : user.username,
userId : user.userId },
'Already logged in'
);
var existingConnError = new Error('Already logged in as supplied user');
existingConnError.existingConn = true;
// :TODO: We should use EnigError & pass existing connection as second param
return cb(existingConnError);
}
// update client logger with addition of username
client.log = logger.log.child( { clientId : client.log.fields.clientId, username : user.username });
client.log.info('Successful login');
async.parallel(
[
function setTheme(callback) {
setClientTheme(client, user.properties.theme_id);
callback(null);
},
function updateSystemLoginCount(callback) {
StatLog.incrementSystemStat('login_count', 1, callback);
},
function recordLastLogin(callback) {
StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback);
},
function updateUserLoginCount(callback) {
StatLog.incrementUserStat(user, 'login_count', 1, callback);
},
function recordLoginHistory(callback) {
const LOGIN_HISTORY_MAX = 200; // history of up to last 200 callers
StatLog.appendSystemLogEntry('user_login_history', user.userId, LOGIN_HISTORY_MAX, StatLog.KeepType.Max, callback);
}
],
function complete(err) {
cb(err);
}
);
}); });
} }

View File

@ -1,10 +1,7 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
let uuid = require('node-uuid'); const createHash = require('crypto').createHash;
let assert = require('assert');
let _ = require('lodash');
let createHash = require('crypto').createHash;
exports.createNamedUUID = createNamedUUID; exports.createNamedUUID = createNamedUUID;
@ -13,9 +10,9 @@ function createNamedUUID(namespaceUuid, key) {
// v5 UUID generation code based on the work here: // v5 UUID generation code based on the work here:
// https://github.com/download13/uuidv5/blob/master/uuid.js // https://github.com/download13/uuidv5/blob/master/uuid.js
// //
if(!Buffer.isBuffer(namespaceUuid)) { if(!Buffer.isBuffer(namespaceUuid)) {
namespaceUuid = new Buffer(namespaceUuid); namespaceUuid = new Buffer(namespaceUuid);
} }
if(!Buffer.isBuffer(key)) { if(!Buffer.isBuffer(key)) {
key = new Buffer(key); key = new Buffer(key);

View File

@ -44,7 +44,7 @@ function VerticalMenuView(options) {
self.viewWindow = { self.viewWindow = {
top : self.focusedItemIndex, top : self.focusedItemIndex,
bottom : Math.min(self.focusedItemIndex + self.maxVisibleItems, self.items.length) - 1 bottom : Math.min(self.focusedItemIndex + self.maxVisibleItems, self.items.length) - 1,
}; };
}; };
@ -107,12 +107,14 @@ VerticalMenuView.prototype.redraw = function() {
delete this.oldDimens; delete this.oldDimens;
} }
let row = this.position.row; if(this.items.length) {
for(let i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) { let row = this.position.row;
this.items[i].row = row; for(let i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) {
row += this.itemSpacing + 1; this.items[i].row = row;
this.items[i].focused = this.focusedItemIndex === i; row += this.itemSpacing + 1;
this.drawItem(i); this.items[i].focused = this.focusedItemIndex === i;
this.drawItem(i);
}
} }
}; };
@ -171,7 +173,7 @@ VerticalMenuView.prototype.getData = function() {
VerticalMenuView.prototype.setItems = function(items) { VerticalMenuView.prototype.setItems = function(items) {
// if we have items already, save off their drawing area so we don't leave fragments at redraw // if we have items already, save off their drawing area so we don't leave fragments at redraw
if(this.items && this.items.length) { if(this.items && this.items.length) {
this.oldDimens = this.dimens; this.oldDimens = Object.assign({}, this.dimens);
} }
VerticalMenuView.super_.prototype.setItems.call(this, items); VerticalMenuView.super_.prototype.setItems.call(this, items);
@ -179,6 +181,14 @@ VerticalMenuView.prototype.setItems = function(items) {
this.positionCacheExpired = true; this.positionCacheExpired = true;
}; };
VerticalMenuView.prototype.removeItem = function(index) {
if(this.items && this.items.length) {
this.oldDimens = Object.assign({}, this.dimens);
}
VerticalMenuView.super_.prototype.removeItem.call(this, index);
};
// :TODO: Apply draw optimizaitons when only two items need drawn vs entire view! // :TODO: Apply draw optimizaitons when only two items need drawn vs entire view!
VerticalMenuView.prototype.focusNext = function() { VerticalMenuView.prototype.focusNext = function() {

View File

@ -6,7 +6,6 @@ var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory;
var menuUtil = require('./menu_util.js'); var menuUtil = require('./menu_util.js');
var asset = require('./asset.js'); var asset = require('./asset.js');
var ansi = require('./ansi_term.js'); var ansi = require('./ansi_term.js');
const Log = require('./logger.js');
// deps // deps
var events = require('events'); var events = require('events');
@ -74,8 +73,9 @@ function ViewController(options) {
self.switchFocus(actionForKey.viewId); self.switchFocus(actionForKey.viewId);
self.submitForm(key); self.submitForm(key);
} else if(_.isString(actionForKey.action)) { } else if(_.isString(actionForKey.action)) {
const formData = self.getFocusedView() ? self.getFormData() : { };
self.handleActionWrapper( self.handleActionWrapper(
{ ch : ch, key : key }, // formData Object.assign( { ch : ch, key : key }, formData ), // formData + key info
actionForKey); // actionBlock actionForKey); // actionBlock
} }
} else { } else {
@ -116,6 +116,7 @@ function ViewController(options) {
self.emit('submit', this.getFormData(key)); self.emit('submit', this.getFormData(key));
}; };
// :TODO: replace this in favor of overriding toJSON() for various things such that logging will *never* output them
this.getLogFriendlyFormData = function(formData) { this.getLogFriendlyFormData = function(formData) {
// :TODO: these fields should be part of menu.json sensitiveMembers[] // :TODO: these fields should be part of menu.json sensitiveMembers[]
var safeFormData = _.cloneDeep(formData); var safeFormData = _.cloneDeep(formData);
@ -143,8 +144,10 @@ function ViewController(options) {
var mci = mciMap[name]; var mci = mciMap[name];
var view = self.mciViewFactory.createFromMCI(mci); var view = self.mciViewFactory.createFromMCI(mci);
if(view && false === self.noInput) { if(view) {
view.on('action', self.viewActionListener); if(false === self.noInput) {
view.on('action', self.viewActionListener);
}
self.addView(view); self.addView(view);
} }
@ -181,52 +184,52 @@ function ViewController(options) {
propAsset = asset.getViewPropertyAsset(conf[propName]); propAsset = asset.getViewPropertyAsset(conf[propName]);
if(propAsset) { if(propAsset) {
switch(propAsset.type) { switch(propAsset.type) {
case 'config' : case 'config' :
propValue = asset.resolveConfigAsset(conf[propName]); propValue = asset.resolveConfigAsset(conf[propName]);
break; break;
case 'sysStat' : case 'sysStat' :
propValue = asset.resolveSystemStatAsset(conf[propName]); propValue = asset.resolveSystemStatAsset(conf[propName]);
break; break;
// :TODO: handle @art (e.g. text : @art ...) // :TODO: handle @art (e.g. text : @art ...)
case 'method' : case 'method' :
case 'systemMethod' : case 'systemMethod' :
if('validate' === propName) { if('validate' === propName) {
// :TODO: handle propAsset.location for @method script specification // :TODO: handle propAsset.location for @method script specification
if('systemMethod' === propAsset.type) {
// :TODO: implementation validation @systemMethod handling!
var methodModule = require(paths.join(__dirname, 'system_view_validate.js'));
if(_.isFunction(methodModule[propAsset.asset])) {
propValue = methodModule[propAsset.asset];
}
} else {
if(_.isFunction(self.client.currentMenuModule.menuMethods[propAsset.asset])) {
propValue = self.client.currentMenuModule.menuMethods[propAsset.asset];
}
}
} else {
if(_.isString(propAsset.location)) {
} else {
if('systemMethod' === propAsset.type) { if('systemMethod' === propAsset.type) {
// :TODO: // :TODO: implementation validation @systemMethod handling!
var methodModule = require(paths.join(__dirname, 'system_view_validate.js'));
if(_.isFunction(methodModule[propAsset.asset])) {
propValue = methodModule[propAsset.asset];
}
} else { } else {
// local to current module if(_.isFunction(self.client.currentMenuModule.menuMethods[propAsset.asset])) {
var currentModule = self.client.currentMenuModule; propValue = self.client.currentMenuModule.menuMethods[propAsset.asset];
if(_.isFunction(currentModule.menuMethods[propAsset.asset])) { }
// :TODO: Fix formData & extraArgs... this all needs general processing }
propValue = currentModule.menuMethods[propAsset.asset]({}, {});//formData, conf.extraArgs); } else {
if(_.isString(propAsset.location)) {
} else {
if('systemMethod' === propAsset.type) {
// :TODO:
} else {
// local to current module
var currentModule = self.client.currentMenuModule;
if(_.isFunction(currentModule.menuMethods[propAsset.asset])) {
// :TODO: Fix formData & extraArgs... this all needs general processing
propValue = currentModule.menuMethods[propAsset.asset]({}, {});//formData, conf.extraArgs);
}
} }
} }
} }
} break;
break;
default : default :
propValue = propValue = conf[propName]; propValue = propValue = conf[propName];
break; break;
} }
} else { } else {
propValue = conf[propName]; propValue = conf[propName];
@ -447,6 +450,12 @@ ViewController.prototype.setFocus = function(focused) {
this.setViewFocusWithEvents(this.focusedView, focused); this.setViewFocusWithEvents(this.focusedView, focused);
}; };
ViewController.prototype.resetInitialFocus = function() {
if(this.formInitialFocusId) {
return this.switchFocus(this.formInitialFocusId);
}
};
ViewController.prototype.switchFocus = function(id) { ViewController.prototype.switchFocus = function(id) {
// //
// Perform focus switching validation now // Perform focus switching validation now
@ -471,15 +480,19 @@ ViewController.prototype.switchFocus = function(id) {
}; };
ViewController.prototype.nextFocus = function() { ViewController.prototype.nextFocus = function() {
var nextId; let nextFocusView = this.focusedView ? this.focusedView : this.views[this.firstId];
if(!this.focusedView) { // find the next view that accepts focus
nextId = this.views[this.firstId].id; while(nextFocusView && nextFocusView.nextId) {
} else { nextFocusView = this.getView(nextFocusView.nextId);
nextId = this.views[this.focusedView.id].nextId; if(!nextFocusView || nextFocusView.acceptsFocus) {
break;
}
} }
this.switchFocus(nextId); if(nextFocusView && this.focusedView !== nextFocusView) {
this.switchFocus(nextFocusView.id);
}
}; };
ViewController.prototype.setViewOrder = function(order) { ViewController.prototype.setViewOrder = function(order) {
@ -498,7 +511,6 @@ ViewController.prototype.setViewOrder = function(order) {
} }
if(viewIdOrder.length > 0) { if(viewIdOrder.length > 0) {
var view;
var count = viewIdOrder.length - 1; var count = viewIdOrder.length - 1;
for(var i = 0; i < count; ++i) { for(var i = 0; i < count; ++i) {
this.views[viewIdOrder[i]].nextId = viewIdOrder[i + 1]; this.views[viewIdOrder[i]].nextId = viewIdOrder[i + 1];
@ -578,7 +590,7 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) {
for(var c = 0; c < menuSubmit.length; ++c) { for(var c = 0; c < menuSubmit.length; ++c) {
var actionBlock = menuSubmit[c]; var actionBlock = menuSubmit[c];
if(_.isEqual(formData.value, actionBlock.value, self.actionBlockValueComparator)) { if(_.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)) {
self.handleActionWrapper(formData, actionBlock); self.handleActionWrapper(formData, actionBlock);
break; // there an only be one... break; // there an only be one...
} }
@ -589,6 +601,33 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) {
callback(null); callback(null);
}, },
function loadActionKeys(callback) {
if(!_.isObject(promptConfig) || !_.isArray(promptConfig.actionKeys)) {
return callback(null);
}
promptConfig.actionKeys.forEach(ak => {
//
// * 'keys' must be present and be an array of key names
// * If 'viewId' is present, key(s) will focus & submit on behalf
// of the specified view.
// * If 'action' is present, that action will be procesed when
// triggered by key(s)
//
// Ultimately, create a map of key -> { action block }
//
if(!_.isArray(ak.keys)) {
return;
}
ak.keys.forEach(kn => {
self.actionKeyMap[kn] = ak;
});
});
return callback(null);
},
function drawAllViews(callback) { function drawAllViews(callback) {
self.redrawAll(initialFocusId); self.redrawAll(initialFocusId);
callback(null); callback(null);
@ -616,7 +655,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
var self = this; var self = this;
var formIdKey = options.formId ? options.formId.toString() : '0'; var formIdKey = options.formId ? options.formId.toString() : '0';
var initialFocusId = 1; // default to first this.formInitialFocusId = 1; // default to first
var formConfig; var formConfig;
// :TODO: honor options.withoutForm // :TODO: honor options.withoutForm
@ -669,7 +708,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
function applyViewConfiguration(callback) { function applyViewConfiguration(callback) {
if(_.isObject(formConfig)) { if(_.isObject(formConfig)) {
self.applyViewConfig(formConfig, function configApplied(err, info) { self.applyViewConfig(formConfig, function configApplied(err, info) {
initialFocusId = info.initialFocusId; self.formInitialFocusId = info.initialFocusId;
callback(err); callback(err);
}); });
} else { } else {
@ -706,7 +745,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
for(var c = 0; c < confForFormId.length; ++c) { for(var c = 0; c < confForFormId.length; ++c) {
var actionBlock = confForFormId[c]; var actionBlock = confForFormId[c];
if(_.isEqual(formData.value, actionBlock.value, self.actionBlockValueComparator)) { if(_.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)) {
self.handleActionWrapper(formData, actionBlock); self.handleActionWrapper(formData, actionBlock);
break; // there an only be one... break; // there an only be one...
} }
@ -744,12 +783,12 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
callback(null); callback(null);
}, },
function drawAllViews(callback) { function drawAllViews(callback) {
self.redrawAll(initialFocusId); self.redrawAll(self.formInitialFocusId);
callback(null); callback(null);
}, },
function setInitialViewFocus(callback) { function setInitialViewFocus(callback) {
if(initialFocusId) { if(self.formInitialFocusId) {
self.switchFocus(initialFocusId); self.switchFocus(self.formInitialFocusId);
} }
callback(null); callback(null);
} }
@ -792,7 +831,7 @@ ViewController.prototype.getFormData = function(key) {
} }
*/ */
var formData = { const formData = {
id : this.formId, id : this.formId,
submitId : this.focusedView.id, submitId : this.focusedView.id,
value : {}, value : {},
@ -802,36 +841,24 @@ ViewController.prototype.getFormData = function(key) {
formData.key = key; formData.key = key;
} }
var viewData; let viewData;
var view; _.each(this.views, view => {
for(var id in this.views) {
try { try {
view = this.views[id]; // don't fill forms with static, non user-editable data data
viewData = view.getData(); if(!view.acceptsInput) {
if(!_.isUndefined(viewData)) { return;
if(_.isString(view.submitArgName)) {
formData.value[view.submitArgName] = viewData;
} else {
formData.value[id] = viewData;
}
} }
viewData = view.getData();
if(_.isUndefined(viewData)) {
return;
}
formData.value[ view.submitArgName ? view.submitArgName : view.id ] = viewData;
} catch(e) { } catch(e) {
this.client.log.error(e); // :TODO: Log better ;) this.client.log.error( { error : e.message }, 'Exception caught gathering form data' );
} }
} });
return formData; return formData;
}
/*
ViewController.prototype.formatMenuArgs = function(args) {
var self = this;
return _.mapValues(args, function val(value) {
if('string' === typeof value) {
return self.formatMCIString(value);
}
return value;
});
}; };
*/

View File

@ -1,16 +1,19 @@
# About ENiGMA½ # About ENiGMA½
## High Level Feature Overview ## High Level Feature Overview
* Multi platform: Anywhere Node.js runs likely works (tested under Linux and OS X) * Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows)
* Multi node support * Unlimited multi node support (for all those BBS "callers"!)
* **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JS based mods * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods
* MCI support for lightbars, toggles, input areas, and so on plus many other other bells and whistles * MCI support for lightbars, toggles, input areas, and so on plus many other other bells and whistles
* Telnet & SSH access built in. Additional servers are easy to implement & plug in * Telnet & **SSH** access built in. Additional servers are easy to implement
* [CP437](http://www.ascii-codes.com/) and UTF-8 output * [CP437](http://www.ascii-codes.com/) and UTF-8 output
* [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior. * [SyncTerm](http://syncterm.bbsdev.net/) style font and baud emulation support. Display PC/DOS and Amiga style artwork as it's intended! In general, ANSI-BBS / [cterm.txt](http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt?content-type=text%2Fplain&revision=HEAD) / [bansi.txt](http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/bansi.txt) are followed for expected BBS behavior
* [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support * [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support
* Renegade style pipe codes * Renegade style pipe color codes
* [SQLite](http://sqlite.org/) storage of users and message areas * [SQLite](http://sqlite.org/) storage of users, message areas, and so on
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password storage * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption
* Door support including common dropfile formats and [DOSEMU](http://www.dosemu.org/) * [Door support](doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), and [DoorParty](http://forums.throwbackbbs.com/) support!
* [Bunyan](https://github.com/trentm/node-bunyan) logging * [Bunyan](https://github.com/trentm/node-bunyan) logging
* [Message networks](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](web_server.md). Legacy X/Y/Z modem also supported!
* Upload processor supporting [FILE_ID.DIZ](https://en.wikipedia.org/wiki/FILE_ID.DIZ) and [NFO](https://en.wikipedia.org/wiki/.nfo) extraction, year estimation, and more!

69
docs/archive.md Normal file
View File

@ -0,0 +1,69 @@
# File Archives & Archivers
ENiGMA½ can detect and process various archive formats such as zip and arj for a variety of tasks from file upload processing to EchoMail bundle compress/decompression. The `archives` section of `config.hjson` is used to override defaults, add new handlers, and so on.
## Archivers
Archivers are manged via the `archives:archivers` configuration block of `config.hjson`. Each entry in this section defines an **external archiver** that can be referenced in other sections of `config.hjson` as and in code. Entries define how to `compress`, `decompress` (a full archive), `list`, and `extract` (specific files from an archive).
### Predefined Archivers
The following archivers are pre-configured in ENiGMA½ as of this writing. Remember that you can override settings or add new handlers!
#### ZZip
* Formats: .7z, .bzip2, .zip, .gzip/.gz, and more
* Key: `7Zip`
* Homepage/package: [7-zip.org](http://www.7-zip.org/). Generally obtained from a `p7zip` package in *nix environments. See http://p7zip.sourceforge.net/ for details.
#### Lha
* Formats: <a href="https://en.wikipedia.org/wiki/LHA_(file_format)">LHA</a> files such as .lzh.
* Key: `Lha`
* Homepage/package: `lhasa` on most *nix environments. See also https://fragglet.github.io/lhasa/ and http://www2m.biglobe.ne.jp/~dolphin/lha/lha-unix.htm
#### Arj
* Formats: .arj
* Key: `Arj`
* Homepage/package: `arj` on most *nix environments.
### Archiver Configuration
Archiver entries in `config.hjson` are mostly self explanatory with the exception of `list` commands that require some additional information. The `args` member for an entry is an array of arguments to pass to `cmd`. Some variables are available to `args` that will be expanded by the system:
* `{archivePath}` (all): Path to the archive
* `{fileList}` (compress, extract): List of file(s) to compress or extract
* `{extractPath}` (decompress, extract): Path to extract *to*
For `list` commands, the `entryMatch` key must be provided. This key should provide a regular expression that matches two sub groups: One for uncompressed file byte sizes (sub group 1) and the other for file names (sub group 2). An optional `entryGroupOrder` can be supplied to change the default sub group order.
#### Example Archiver Configuration
```
7Zip: {
compress: {
cmd: 7za,
args: [ "a", "-tzip", "{archivePath}", "{fileList}" ]
}
decompress: {
cmd: 7za,
args: [ "e", "-o{extractPath}", "{archivePath}" ]
}
list: {
cmd: 7za,
args: [ "l", "{archivePath}" ]
entryMatch: "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\s[0-9]{2}:[0-9]{2}:[0-9]{2}\\s[A-Za-z\\.]{5}\\s+([0-9]+)\\s+[0-9]+\\s+([^\\r\\n]+)$",
}
extract: {
cmd: 7za,
args [ "e", "-o{extractPath}", "{archivePath}", "{fileList}" ]
}
}
```
## Archive Formats
Archive formats can be defined such that ENiGMA½ can detect them by signature or extension, then utilize the correct *archiver* to process them. Formats are defined in the `archives:formats` key in `config.hjson`. Many differnet types come pre-configured (see `core/config.js`).
### Example Archive Format Configuration
```
zip: {
sig: "504b0304" /* byte signature in HEX */
offset: 0
exts: [ "zip" ]
handler: 7Zip /* points to a defined archiver */
desc: "ZIP Archive"
}
```

View File

@ -1,10 +1,10 @@
# Configuration # Configuration
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. 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 Configuraiton ## 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 `~/.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.
**Windows note**: **~** resolves to *C:\Users\YOURLOGINNAME\* on modern installations, e.g. *C:\Users\NuSkooler\\.config\enigma-bbs\config.hjson* **Windows note**: **~** resolves to *C:\Users\YOURLOGINNAME\* on modern Windows installations, e.g. `C:\Users\NuSkooler\.config\enigma-bbs\config.hjson`
### oputil.js ### oputil.js
Please see `oputil.js config` for configuration generation options. Please see `oputil.js config` for configuration generation options.
@ -24,29 +24,19 @@ general: {
} }
``` ```
(Note the very slightly different syntax. **You can use standard JSON if you wish**)
### Specific Areas of Interest ### 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](./)
#### Archivers
External archivers can be configured for various tasks such as EchoMail bundle handling.
TODO: Document further inc. Members & defaults
**Example**:
```hjson
archivers: {'
zip: {
// byte signature in HEX of ZIP archives
sig: "504b0304"
// offset of sig
offset: 0
compressCmd: "7za"
compressArgs: [ "a", "-tzip", "{archivePath}", "{fileList}" ]
decompressCmd: "7za"
decompressArgs: [ "e", "-o{extractPath}", "{archivePath}" ]
}
}
```
### A Sample Configuration ### A Sample Configuration
Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked. Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked.
@ -136,4 +126,4 @@ Below is a **sample** `config.hjson` illustrating various (but certainly not all
``` ```
## Menus ## Menus
TODO: Documentation on menu.hjson, etc. See [the menu system docs](menu_system.md)

89
docs/file_base.md Normal file
View File

@ -0,0 +1,89 @@
# 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
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!)
* File Areas are still around but should generally be used less. Instead, files can have one or more tags. Think things like `dos.retro`, `pc.warez`, `games`, etc.
* Temporary web (http:// or https://) download links in additional to standard X/Y/Z protocol support
* Users can star rate files & search/filter by ratings
* Concept of user defined filters
## Other bells and whistles
* A given area can span one to many physical storage locations
* Upload processor can extract and use `FILE_ID.DIZ`/`DESC.SDI`, for standard descriptions as well as `README.TXT`, `*.NFO`, and so on for longer descriptions
* Upload processor also attempts release year estimation by scanning prementioned description file(s)
* Fast indexed Full Text Search (FTS)
* Duplicates validated by SHA-256
## Configuration
Like many things in ENiGMA½, configuration of file base(s) is handled via `config.hjson` -- specifically in the `fileBase` section.
```hjson
fileBase: {
areaStoragePrefix: /path/to/somewhere/
storageTags: {
/* ... */
}
areas: {
/* ... */
}
}
```
(Take a look at `core/config.js` for additional keys that may be overridden)
### Storage tags
**Storage Tags** define paths to a physical (file) storage location that can later be referenced in a file *Area* entry. Each entry may be either a fully qualified path or a relative path. Relative paths are relative to the value set by the `areaStoragePrefix` key. Below is an example defining a both a relative and fully qualified path each attached to a storage tag:
```hjson
storageTags: {
retro_pc: "retro_pc" // relative
retro_pc_bbs: "retro_pc/bbs" // still relative!
bbs_stuff: "/path/to/bbs_stuff_storage" // fully qualified
}
```
### Areas
File base **Areas** are configured using the `fileBase::areas` configuration block in `config.hjson`. Each entry within `areas` must contain a `name`, `desc`, and `storageTags`. Remember that in ENiGMA½ while areas are important, they should generally be used less than in tradditional BBS software. It is recommended to favor the use of more **tags** over more areas.
Example areas section:
```hjson
areas: {
retro_pc: {
name: Retro PC
desc: Oldschool PC/DOS
storageTags: [ "retro_pc", "retro_pc_bbs" ]
acs: {
write: GM[users] /* optional, see ACS below */
}
}
}
```
#### ACS
If no `acs` block is supplied, the following defaults apply to an area:
* `read` (list, download, etc.): `GM[users]`
* `write` (upload): `GM[sysops]`
To override read and/or write ACS, supply a valid `acs` member.
#### Uploads
Note that `storageTags` may contain *1:n* storage tag references. **Uploads in a particular area are stored in the first storage tag path**.
## Web Access
Temporary web HTTP(S) URLs can be used to download files using the built in web server. Temporary links expire after `fileBase::web::expireMinutes`. The full URL given to users is built using `contentServers::web::domain` and will default to HTTPS (http://) if enabled with a fallback to HTTP. The end result is users are given a temporary web link that may look something like this: `https://xibalba.l33t.codes:44512/f/h7JK`
See [Web Server](web_server.md) for more information.
## oputil
The `oputil.js` +op utilty `file-base` command has tools for managing file bases. For example, to import existing files found within **all** storage locations tied to an area:
```bash
oputil.js file-base --scan some_area
```
See `oputil.js file-base --help` for additional information.

View File

@ -11,12 +11,14 @@ Under most Linux/UNIX like environments (Linux, BSD, OS X, ...) new users can s
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/install.sh | bash curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/install.sh | bash
``` ```
For other environments such as Windows, see **The Manual Way** below.
## The Manual Way ## The Manual Way
For Windows environments or if you simply like to do things manually, read on... For Windows environments or if you simply like to do things manually, read on...
### Prerequisites ### Prerequisites
* [Node.js](https://nodejs.org/) version **v4.2.x or higher** * [Node.js](https://nodejs.org/) version **v6.x or higher**
* :information_source: It is suggested to use [nvm](https://github.com/creationix/nvm) to manage your Node/io.js installs * :information_source: It is **highly** suggested to use [nvm](https://github.com/creationix/nvm) to manage your Node/io.js installs
* [Python](https://www.python.org/downloads/) 2.7.x * [Python](https://www.python.org/downloads/) 2.7.x
* A compiler such as Clang or GCC for Linux/UNIX systems or a recent copy of Visual Studio ([Visual Studio Express](https://www.visualstudio.com/en-us/products/visual-studio-express-vs.aspx) editions OK) for Windows users. Note that you **should only need the Visual C++ component**. * A compiler such as Clang or GCC for Linux/UNIX systems or a recent copy of Visual Studio ([Visual Studio Express](https://www.visualstudio.com/en-us/products/visual-studio-express-vs.aspx) editions OK) for Windows users. Note that you **should only need the Visual C++ component**.
@ -25,13 +27,15 @@ For Windows environments or if you simply like to do things manually, read on...
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 enviornments (Please consider the `install.sh` approach unless you really want to manually install!):
```bash ```bash
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.0/install.sh | bash curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.0/install.sh | bash
nvm install 4.4.0 nvm install 6
nvm use 4.4.0 nvm use 6
``` ```
If the above completed without errors, you should now have `nvm`, `node`, and `npm` installed and in your environment. If the above completed without errors, you should now have `nvm`, `node`, and `npm` installed and in your environment.
For Windows nvm-like systems exist ([nvm-windows](https://github.com/coreybutler/nvm-windows), ...) or [just download the installer](https://nodejs.org/en/download/).
### Clone ### Clone
```bash ```bash
@ -56,9 +60,11 @@ The main system configuration is handled via `~/.config/enigma-bbs/config.hjson`
#### Via oputil.js #### Via oputil.js
`oputil.js` can be utilized to generate your **initial** configuration. **This is the recommended way for all new users**: `oputil.js` can be utilized to generate your **initial** configuration. **This is the recommended way for all new users**:
./oputil.js config --new ```bash
./oputil.js config --new
```
You wil be asked a series of basic questions. (You wil be asked a series of basic questions)
#### Example Starting Configuration #### 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**. Below is an _example_ configuration. It is recommended that you at least **start with a generated configuration using oputil.js described above**.
@ -69,7 +75,7 @@ Below is an _example_ configuration. It is recommended that you at least **start
boardName: Super Awesome BBS boardName: Super Awesome BBS
} }
servers: { loginServers: {
ssh: { ssh: {
privateKeyPass: YOUR_PK_PASS privateKeyPass: YOUR_PK_PASS
enabled: true /* set to false to disable the SSH server */ enabled: true /* set to false to disable the SSH server */

108
docs/mci.md Normal file
View File

@ -0,0 +1,108 @@
# MCI Codes
## Introduction
ENiGMA½ supports a variety of MCI codes. Some **predefined** codes produce information about the current user, system, or other statistics while others are used to instanciate a **View**. MCI codes are two characters in length and are prefixed with a percent (%) symbol. Some MCI codes have additional options that may be set directly from the code itself while others -- and more advanced options -- are controlled via the current theme. Standard (non-focus) and focus colors are set by placing duplicate codes back to back in art files.
## Views
A **View** is a control placed on a **form** that can display variable data or collect input. One example of a View is a Vertical Menu (`%VM`): Oldschool BBSers may recognize this as a lightbar menu.
### Available Views
* Text Label (`TL`): Displays text
* Edit Text (`ET`): Collect user input
* Masked Edit Text (`ME`): Collect user input using a *mask*
* Multi Line Text Edit (`MT`): Multi line edit control
* Button (`BT`): A button
* Vertical Menu (`VM`): A vertical menu aka a vertical lightbar
* Horizontal Menu (`HM`): A horizontal menu aka a horizontal lightbar
* Spinner Menu (`SM`): A spinner input control
* Toggle Menu (`TM`): A toggle menu commonly used for Yes/No style input
* Key Entry (`KE`): A *single* key input control
(Peek at `core/mci_view_factory.js` to see additional information on these)
## Predefined
There are many predefined MCI codes that can be used anywhere on the system (placed in any art file). More are added all the time so also check out `core/predefined_mci.js` for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, etc.
* `BN`: Board Name
* `VL`: Version *label*, e.g. "ENiGMA½ v0.0.3-alpha"
* `VN`: Version *number*, eg.. "0.0.3-alpha"
* `SN`: SysOp username
* `SR`: SysOp real name
* `SL`: SysOp location
* `SA`: SysOp affiliations
* `SS`: SysOp sex
* `SE`: SysOp email address
* `UN`: Current user's username
* `UI`: Current user's user ID
* `UG`: Current user's group membership(s)
* `UR`: Current user's real name
* `LO`: Current user's location
* `UA`: Current user's age
* `BD`: Current user's birthdate (using theme date format)
* `US`: Current user's sex
* `UE`: Current user's email address
* `UW`: Current user's web address
* `UF`: Current user's affiliations
* `UT`: Current user's *theme ID* (e.g. "luciano_blocktronics")
* `UC`: Current user's login/call count
* `ND`: Current user's connected node number
* `IP`: Current user's IP address
* `ST`: Current user's connected server name (e.g. "Telnet" or "SSH")
* `FN`: Current user's active file base filter name
* `DN`: Current user's number of downloads
* `DK`: Current user's download amount (formatted to appropriate bytes/megs/etc.)
* `UP`: Current user's number of uploads
* `UK`: Current user's upload amount (formatted to appropriate bytes/megs/etc.)
* `NR`: Current user's upload/download ratio
* `KR`: Current user's upload/download *bytes* ratio
* `MS`: Current user's account creation date (using theme date format)
* `PS`: Current user's post count
* `PC`: Current user's post/call ratio
* `MD`: Current user's status/viewing menu/activity
* `MA`: Current user's active message area name
* `MC`: Current user's active message conference name
* `ML`: Current user's active message area description
* `CM`: Current user's active message conference description
* `SH`: Current user's term height
* `SW`: Current user's term width
* `DT`: Current date (using theme date format)
* `CT`: Current time (using theme time format)
* `OS`: System OS (Linux, Windows, etc.)
* `OA`: System architecture (x86, x86_64, arm, etc.)
* `SC`: System CPU model
* `NV`: System underlying Node.js version
* `AN`: Current active node count
* `TC`: Total login/calls to system
* `RR`: Displays a random rumor
A special `XY` MCI code may also be utilized for placement identification when creating menus.
## Properties & Theming
Predefined MCI codes and other Views can have properties set via `menu.hjson` and further *themed* via `theme.hjson`.
### Common Properties
* `textStyle`: Sets the standard (non-focus) text style. See **Text Styles** below
* `focusTextStyle`: Sets focus text style. See **Text Styles** below.
* `itemSpacing`: Used to separate items in menus such as Vertical Menu and Horizontal Menu Views.
* `height`: Sets the height of views such as menus that may be > 1 character in height
* `width`: Sets the width of a view
* `focus`: If set to `true`, establishes initial focus
* `text`: (initial) text of a view
* `submit`: If set to `true` any `accept` action upon this view will submit the encompassing **form**
These are just a few of the properties set on various views. *Use the source Luke*, as well as taking a look at the default `menu.hjson` and `theme.hjson` files!
#### Text Styles
Standard style types available for `textStyle` and `focusTextStyle`:
* `normal`: Leaves text as-is. This is the default.
* `upper`: ENIGMA BULLETIN BOARD SOFTWARE
* `lower`: enigma bulletin board software
* `title`: Enigma Bulletin Board Software
* `first lower`: eNIGMA bULLETIN bOARD sOFTWARE
* `small vowels`: eNiGMa BuLLeTiN BoaRD SoFTWaRe
* `big vowels`: EniGMa bUllEtIn bOArd sOftwArE
* `small i`: ENiGMA BULLETiN BOARD SOFTWARE
* `mixed`: EnIGma BUlLEtIn BoaRd SOfTWarE (randomly assigned)
* `l33t`: 3n1gm4 bull371n b04rd 50f7w4r3

View File

@ -1,13 +1,14 @@
# Menu System # 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! By modifying your `menu.hjson` you will be able to create a custom look and feel unique to your board.
The default `menu.hjson` file lives within the `mods` directory. To specify another file, set the `menuFile` property in your `config.hjson` file: 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:
```hjson ```hjson
general: { general: {
/* Can also specify a full path */ /* Can also specify a full path */
menuFile: mybbs.hjson menuFile: mybbs.hjson
} }
``` ```
(You can start by copying the default `menu.hjson` to `mybbs.hjson`)
## The Basics ## 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 via a HJSON file. This file is located in the `mods` directory: `mods/menu.hjson`.
@ -37,6 +38,7 @@ Now let's look at `matrix`, the `next` entry from `telnetConnected`:
```hjson ```hjson
matrix: { matrix: {
art: matrix art: matrix
desc: Login Matrix
form: { form: {
0: { 0: {
VM: { VM: {

39
docs/web_server.md Normal file
View File

@ -0,0 +1,39 @@
# 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!
## 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`:
```hjson
contentServers: {
web: {
domain: bbs.yourdomain.com
http: {
enabled: true
}
}
}
```
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:
```hjson
contentServers: {
web: {
domain: bbs.yourdomain.com
https: {
enabled: true
port: 8443
certPem: /path/to/your/cert.pem
keyPem: /path/to/your/cert_private_key.pem
}
}
}
```
### Static Routes
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 `<error_code>.html` file in the *static routes* area. For example: `404.html`.

View File

@ -2,7 +2,7 @@
{ # this ensures the entire script is downloaded before execution { # this ensures the entire script is downloaded before execution
ENIGMA_NODE_VERSION=${ENIGMA_NODE_VERSION:=4.4} ENIGMA_NODE_VERSION=${ENIGMA_NODE_VERSION:=6}
ENIGMA_INSTALL_DIR=${ENIGMA_INSTALL_DIR:=$HOME/enigma-bbs} ENIGMA_INSTALL_DIR=${ENIGMA_INSTALL_DIR:=$HOME/enigma-bbs}
ENIGMA_SOURCE=${ENIGMA_SOURCE:=https://github.com/NuSkooler/enigma-bbs.git} ENIGMA_SOURCE=${ENIGMA_SOURCE:=https://github.com/NuSkooler/enigma-bbs.git}
TIME_FORMAT=`date "+%Y-%m-%d %H:%M:%S"` TIME_FORMAT=`date "+%Y-%m-%d %H:%M:%S"`
@ -22,7 +22,7 @@ _____________________ _____ ____________________ __________\\_ /
ENiGMA½ will be installed to ${ENIGMA_INSTALL_DIR}, from source ${ENIGMA_SOURCE}. ENiGMA½ will be installed to ${ENIGMA_INSTALL_DIR}, from source ${ENIGMA_SOURCE}.
ENiGMA½ requires Node, v${ENIGMA_NODE_VERSION} will be installed via nvm. If you already have nvm installed, this install script will update it to the latest version. ENiGMA½ requires Node.js. Version ${ENIGMA_NODE_VERSION}.x current will be installed via nvm. If you already have nvm installed, this install script will update it to the latest version.
If this isn't what you were expecting, hit ctrl-c now. Installation will continue in ${WAIT_BEFORE_INSTALL} seconds... If this isn't what you were expecting, hit ctrl-c now. Installation will continue in ${WAIT_BEFORE_INSTALL} seconds...
@ -103,8 +103,23 @@ enigma_footer() {
cat << EndOfMessage cat << EndOfMessage
If this is the first time you've installed ENiGMA½, you now need to generate a minimal configuration. To do so, run the following commands: If this is the first time you've installed ENiGMA½, you now need to generate a minimal configuration. To do so, run the following commands:
cd ${ENIGMA_INSTALL_DIR} cd ${ENIGMA_INSTALL_DIR}
./oputil.js config --new ./oputil.js config --new
Additionally, the following support binaires are recommended:
7zip: Archive support
Debian/Ubuntu : apt-get install p7zip
CentOS : yum install p7zip
Lha: Archive support
Debian/Ubuntu : apt-get install lhasa
Arj: Archive support
Debian/Ubuntu : apt-get install arj
sz/rz: Various X/Y/Z modem support
Debian/Ubuntu : apt-get install lrzsz
CentOS : yum install lrzsz
EndOfMessage EndOfMessage
echo -e "\e[39m" echo -e "\e[39m"

9
misc/startup_banner.asc Normal file
View File

@ -0,0 +1,9 @@
_____________________ _____ ____________________ __________\_ /
\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp!
// __|___// | \// |// | \// | | \// \ /___ /_____
/____ _____| __________ ___|__| ____| \ / _____ \
---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/
/__ _\
<*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/
-------------------------------------------------------------------------------

View File

@ -1,23 +1,21 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
let MenuModule = require('../core/menu_module.js').MenuModule; const MenuModule = require('../core/menu_module.js').MenuModule;
let DropFile = require('../core/dropfile.js').DropFile; const DropFile = require('../core/dropfile.js').DropFile;
let door = require('../core/door.js'); const door = require('../core/door.js');
let theme = require('../core/theme.js'); const theme = require('../core/theme.js');
let ansi = require('../core/ansi_term.js'); const ansi = require('../core/ansi_term.js');
let async = require('async'); const async = require('async');
let assert = require('assert'); const assert = require('assert');
let paths = require('path'); const paths = require('path');
let _ = require('lodash'); const _ = require('lodash');
let mkdirs = require('fs-extra').mkdirs; const mkdirs = require('fs-extra').mkdirs;
// :TODO: This should really be a system module... needs a little work to allow for such // :TODO: This should really be a system module... needs a little work to allow for such
exports.getModule = AbracadabraModule; const activeDoorNodeInstances = {};
let activeDoorNodeInstances = {};
exports.moduleInfo = { exports.moduleInfo = {
name : 'Abracadabra', name : 'Abracadabra',
@ -60,20 +58,20 @@ exports.moduleInfo = {
:TODO: See Mystic & others for other arg options that we may need to support :TODO: See Mystic & others for other arg options that we may need to support
*/ */
function AbracadabraModule(options) {
MenuModule.call(this, options);
let self = this; exports.getModule = class AbracadabraModule extends MenuModule {
constructor(options) {
super(options);
this.config = options.menuConfig.config; this.config = options.menuConfig.config;
// :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! -- { key : type, key2 : type2, ... }
assert(_.isString(this.config.name, 'Config \'name\' is required'));
assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required'));
assert(_.isString(this.config.cmd, 'Config \'cmd\' is required'));
// :TODO: MenuModule.validateConfig(cb) -- validate config section gracefully instead of asserts! this.config.nodeMax = this.config.nodeMax || 0;
assert(_.isString(this.config.name, 'Config \'name\' is required')); this.config.args = this.config.args || [];
assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required')); }
assert(_.isString(this.config.cmd, 'Config \'cmd\' is required'));
this.config.nodeMax = this.config.nodeMax || 0;
this.config.args = this.config.args || [];
/* /*
:TODO: :TODO:
@ -82,7 +80,9 @@ function AbracadabraModule(options) {
* Font support ala all other menus... or does this just work? * Font support ala all other menus... or does this just work?
*/ */
this.initSequence = function() { initSequence() {
const self = this;
async.series( async.series(
[ [
function validateNodeCount(callback) { function validateNodeCount(callback) {
@ -99,14 +99,15 @@ function AbracadabraModule(options) {
if(_.isString(self.config.tooManyArt)) { if(_.isString(self.config.tooManyArt)) {
theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() { theme.displayThemeArt( { client : self.client, name : self.config.tooManyArt }, function displayed() {
theme.displayThemedPause( { client : self.client }, function keyPressed() { self.pausePrompt( () => {
callback(new Error('Too many active instances')); callback(new Error('Too many active instances'));
}); });
}); });
} else { } else {
self.client.term.write('\nToo many active instances. Try again later.\n'); self.client.term.write('\nToo many active instances. Try again later.\n');
theme.displayThemedPause( { client : self.client }, function keyPressed() { // :TODO: Use MenuModule.pausePrompt()
self.pausePrompt( () => {
callback(new Error('Too many active instances')); callback(new Error('Too many active instances'));
}); });
} }
@ -146,54 +147,51 @@ function AbracadabraModule(options) {
} }
} }
); );
}; }
this.runDoor = function() { runDoor() {
const exeInfo = { const exeInfo = {
cmd : self.config.cmd, cmd : this.config.cmd,
args : self.config.args, args : this.config.args,
io : self.config.io || 'stdio', io : this.config.io || 'stdio',
encoding : self.config.encoding || self.client.term.outputEncoding, encoding : this.config.encoding || this.client.term.outputEncoding,
dropFile : self.dropFile.fileName, dropFile : this.dropFile.fileName,
node : self.client.node, node : this.client.node,
//inhSocket : self.client.output._handle.fd, //inhSocket : this.client.output._handle.fd,
}; };
const doorInstance = new door.Door(self.client, exeInfo); const doorInstance = new door.Door(this.client, exeInfo);
doorInstance.once('finished', () => { doorInstance.once('finished', () => {
// //
// Try to clean up various settings such as scroll regions that may // Try to clean up various settings such as scroll regions that may
// have been set within the door // have been set within the door
// //
self.client.term.rawWrite( this.client.term.rawWrite(
ansi.normal() + ansi.normal() +
ansi.goto(self.client.term.termHeight, self.client.term.termWidth) + ansi.goto(this.client.term.termHeight, this.client.term.termWidth) +
ansi.setScrollRegion() + ansi.setScrollRegion() +
ansi.goto(self.client.term.termHeight, 0) + ansi.goto(this.client.term.termHeight, 0) +
'\r\n\r\n' '\r\n\r\n'
); );
self.prevMenu(); this.prevMenu();
}); });
self.client.term.write(ansi.resetScreen()); this.client.term.write(ansi.resetScreen());
doorInstance.run(); doorInstance.run();
}; }
}
require('util').inherits(AbracadabraModule, MenuModule); leave() {
super.leave();
if(!this.lastError) {
activeDoorNodeInstances[this.config.name] -= 1;
}
}
AbracadabraModule.prototype.leave = function() { finishedLoading() {
AbracadabraModule.super_.prototype.leave.call(this); this.runDoor();
if(!this.lastError) {
activeDoorNodeInstances[this.config.name] -= 1;
} }
}; };
AbracadabraModule.prototype.finishedLoading = function() {
this.runDoor();
};

BIN
mods/art/NEWUSER1.ANS Normal file

Binary file not shown.

View File

@ -1,33 +0,0 @@
/* jslint node: true */
'use strict';
var MenuModule = require('../core/menu_module.js').MenuModule;
exports.getModule = ArtPoolModule;
exports.moduleInfo = {
name : 'Art Pool',
desc : 'Display art from a pool of options',
author : 'NuSkooler',
};
function ArtPoolModule(options) {
MenuModule.call(this, options);
var config = this.menuConfig.config;
//
// :TODO: General idea
// * Break up some of MenuModule initSequence's calls into methods
// * initSequence here basically has general "clear", "next", etc. as per normal
// * Display art -> ooptinal pause -> display more if requested, etc.
// * Finally exit & move on as per normal
}
require('util').inherits(ArtPoolModule, MenuModule);
MessageAreaModule.prototype.mciReady = function(mciData, cb) {
this.standardMCIReadyHandler(mciData, cb);
};

View File

@ -36,28 +36,26 @@ const packageJson = require('../package.json');
// :TODO: BUG: When a client disconnects, it's not handled very well -- the log is spammed with tons of errors // :TODO: BUG: When a client disconnects, it's not handled very well -- the log is spammed with tons of errors
// :TODO: ENH: Support nodeMax and tooManyArt // :TODO: ENH: Support nodeMax and tooManyArt
exports.getModule = BBSLinkModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'BBSLink', name : 'BBSLink',
desc : 'BBSLink Access Module', desc : 'BBSLink Access Module',
author : 'NuSkooler', author : 'NuSkooler',
}; };
exports.getModule = class BBSLinkModule extends MenuModule {
constructor(options) {
super(options);
function BBSLinkModule(options) { this.config = options.menuConfig.config;
MenuModule.call(this, options); this.config.host = this.config.host || 'games.bbslink.net';
this.config.port = this.config.port || 23;
}
var self = this; initSequence() {
this.config = options.menuConfig.config; let token;
let randomKey;
this.config.host = this.config.host || 'games.bbslink.net'; let clientTerminated;
this.config.port = this.config.port || 23; const self = this;
this.initSequence = function() {
var token;
var randomKey;
var clientTerminated;
async.series( async.series(
[ [
@ -180,17 +178,17 @@ function BBSLinkModule(options) {
} }
} }
); );
}; }
this.simpleHttpRequest = function(path, headers, cb) { simpleHttpRequest(path, headers, cb) {
var getOpts = { const getOpts = {
host : this.config.host, host : this.config.host,
path : path, path : path,
headers : headers, headers : headers,
}; };
var req = http.get(getOpts, function response(resp) { const req = http.get(getOpts, function response(resp) {
var data = ''; let data = '';
resp.on('data', function chunk(c) { resp.on('data', function chunk(c) {
data += c; data += c;
@ -205,7 +203,5 @@ function BBSLinkModule(options) {
req.on('error', function reqErr(err) { req.on('error', function reqErr(err) {
cb(err); cb(err);
}); });
}; }
} };
require('util').inherits(BBSLinkModule, MenuModule);

View File

@ -17,17 +17,13 @@ const _ = require('lodash');
// :TODO: add notes field // :TODO: add notes field
exports.getModule = BBSListModule; const moduleInfo = exports.moduleInfo = {
const moduleInfo = {
name : 'BBS List', name : 'BBS List',
desc : 'List of other BBSes', desc : 'List of other BBSes',
author : 'Andrew Pamment', author : 'Andrew Pamment',
packageName : 'com.magickabbs.enigma.bbslist' packageName : 'com.magickabbs.enigma.bbslist'
}; };
exports.moduleInfo = moduleInfo;
const MciViewIds = { const MciViewIds = {
view : { view : {
BBSList : 1, BBSList : 1,
@ -69,13 +65,106 @@ const SELECTED_MCI_NAME_TO_ENTRY = {
SelectedBBSNotes : 'notes', SelectedBBSNotes : 'notes',
}; };
function BBSListModule(options) { exports.getModule = class BBSListModule extends MenuModule {
MenuModule.call(this, options); constructor(options) {
super(options);
const self = this; const self = this;
const config = this.menuConfig.config; this.menuMethods = {
//
// Validators
//
viewValidationListener : function(err, cb) {
const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error);
if(errMsgView) {
if(err) {
errMsgView.setText(err.message);
} else {
errMsgView.clearText();
}
}
this.initSequence = function() { return cb(null);
},
//
// Key & submit handlers
//
addBBS : function(formData, extraArgs, cb) {
self.displayAddScreen(cb);
},
deleteBBS : function(formData, extraArgs, cb) {
const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList);
if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) {
// must be owner or +op
return cb(null);
}
const entry = self.entries[self.selectedBBS];
if(!entry) {
return cb(null);
}
self.database.run(
`DELETE FROM bbs_list
WHERE id=?;`,
[ entry.id ],
err => {
if (err) {
self.client.log.error( { err : err }, 'Error deleting from BBS list');
} else {
self.entries.splice(self.selectedBBS, 1);
self.setEntries(entriesView);
if(self.entries.length > 0) {
entriesView.focusPrevious();
}
self.viewControllers.view.redrawAll();
}
return cb(null);
}
);
},
submitBBS : function(formData, extraArgs, cb) {
let ok = true;
[ 'BBSName', 'Sysop', 'Telnet' ].forEach( mciName => {
if('' === self.viewControllers.add.getView(MciViewIds.add[mciName]).getData()) {
ok = false;
}
});
if(!ok) {
// validators should prevent this!
return cb(null);
}
self.database.run(
`INSERT INTO bbs_list (bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes)
VALUES(?, ?, ?, ?, ?, ?, ?, ?);`,
[ formData.value.name, formData.value.sysop, formData.value.telnet, formData.value.www, formData.value.location, formData.value.software, self.client.user.userId, formData.value.notes ],
err => {
if(err) {
self.client.log.error( { err : err }, 'Error adding to BBS list');
}
self.clearAddForm();
self.displayBBSList(true, cb);
}
);
},
cancelSubmit : function(formData, extraArgs, cb) {
self.clearAddForm();
self.displayBBSList(true, cb);
}
};
}
initSequence() {
const self = this;
async.series( async.series(
[ [
function beforeDisplayArt(callback) { function beforeDisplayArt(callback) {
@ -92,39 +181,42 @@ function BBSListModule(options) {
self.finishedLoading(); self.finishedLoading();
} }
); );
}; }
this.drawSelectedEntry = function(entry) { drawSelectedEntry(entry) {
if(!entry) { if(!entry) {
Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => {
self.setViewText(MciViewIds.view[mciName], ''); this.setViewText('view', MciViewIds.view[mciName], '');
}); });
} else { } else {
const youSubmittedFormat = config.youSubmittedFormat || '{submitter} (You!)'; const youSubmittedFormat = this.menuConfig.youSubmittedFormat || '{submitter} (You!)';
Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => { Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => {
const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]]; const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]];
if(MciViewIds.view[mciName]) { if(MciViewIds.view[mciName]) {
if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == self.client.user.userId) { if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == this.client.user.userId) {
self.setViewText(MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry)); this.setViewText('view',MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry));
} else { } else {
self.setViewText(MciViewIds.view[mciName], t); this.setViewText('view',MciViewIds.view[mciName], t);
} }
} }
}); });
} }
}; }
this.setEntries = function(entriesView) { setEntries(entriesView) {
const config = this.menuConfig.config;
const listFormat = config.listFormat || '{bbsName}'; const listFormat = config.listFormat || '{bbsName}';
const focusListFormat = config.focusListFormat || '{bbsName}'; const focusListFormat = config.focusListFormat || '{bbsName}';
entriesView.setItems(self.entries.map( e => stringFormat(listFormat, e) ) ); entriesView.setItems(this.entries.map( e => stringFormat(listFormat, e) ) );
entriesView.setFocusItems(self.entries.map( e => stringFormat(focusListFormat, e) ) ); entriesView.setFocusItems(this.entries.map( e => stringFormat(focusListFormat, e) ) );
}; }
displayBBSList(clearScreen, cb) {
const self = this;
this.displayBBSList = function(clearScreen, cb) {
async.waterfall( async.waterfall(
[ [
function clearAndDisplayArt(callback) { function clearAndDisplayArt(callback) {
@ -135,7 +227,7 @@ function BBSListModule(options) {
self.client.term.rawWrite(ansi.resetScreen()); self.client.term.rawWrite(ansi.resetScreen());
} }
theme.displayThemedAsset( theme.displayThemedAsset(
config.art.entries, self.menuConfig.config.art.entries,
self.client, self.client,
{ font : self.menuConfig.font, trailingLF : false }, { font : self.menuConfig.font, trailingLF : false },
(err, artData) => { (err, artData) => {
@ -238,9 +330,11 @@ function BBSListModule(options) {
} }
} }
); );
}; }
displayAddScreen(cb) {
const self = this;
this.displayAddScreen = function(cb) {
async.waterfall( async.waterfall(
[ [
function clearAndDisplayArt(callback) { function clearAndDisplayArt(callback) {
@ -248,7 +342,7 @@ function BBSListModule(options) {
self.client.term.rawWrite(ansi.resetScreen()); self.client.term.rawWrite(ansi.resetScreen());
theme.displayThemedAsset( theme.displayThemedAsset(
config.art.add, self.menuConfig.config.art.add,
self.client, self.client,
{ font : self.menuConfig.font }, { font : self.menuConfig.font },
(err, artData) => { (err, artData) => {
@ -284,117 +378,17 @@ function BBSListModule(options) {
} }
} }
); );
}; }
this.clearAddForm = function() { clearAddForm() {
[ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => { [ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => {
const v = self.viewControllers.add.getView(MciViewIds.add[mciName]); this.setViewText('add', MciViewIds.add[mciName], '');
if(v) {
v.setText('');
}
}); });
}; }
this.menuMethods = { initDatabase(cb) {
// const self = this;
// Validators
//
viewValidationListener : function(err, cb) {
const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error);
if(errMsgView) {
if(err) {
errMsgView.setText(err.message);
} else {
errMsgView.clearText();
}
}
return cb(null);
},
//
// Key & submit handlers
//
addBBS : function(formData, extraArgs, cb) {
self.displayAddScreen(cb);
},
deleteBBS : function(formData, extraArgs, cb) {
const entriesView = self.viewControllers.view.getView(MciViewIds.view.BBSList);
if(self.entries[self.selectedBBS].submitterUserId !== self.client.user.userId && !self.client.user.isSysOp()) {
// must be owner or +op
return cb(null);
}
const entry = self.entries[self.selectedBBS];
if(!entry) {
return cb(null);
}
self.database.run(
`DELETE FROM bbs_list
WHERE id=?;`,
[ entry.id ],
err => {
if (err) {
self.client.log.error( { err : err }, 'Error deleting from BBS list');
} else {
self.entries.splice(self.selectedBBS, 1);
self.setEntries(entriesView);
if(self.entries.length > 0) {
entriesView.focusPrevious();
}
self.viewControllers.view.redrawAll();
}
return cb(null);
}
);
},
submitBBS : function(formData, extraArgs, cb) {
let ok = true;
[ 'BBSName', 'Sysop', 'Telnet' ].forEach( mciName => {
if('' === self.viewControllers.add.getView(MciViewIds.add[mciName]).getData()) {
ok = false;
}
});
if(!ok) {
// validators should prevent this!
return cb(null);
}
self.database.run(
`INSERT INTO bbs_list (bbs_name, sysop, telnet, www, location, software, submitter_user_id, notes)
VALUES(?, ?, ?, ?, ?, ?, ?, ?);`,
[ formData.value.name, formData.value.sysop, formData.value.telnet, formData.value.www, formData.value.location, formData.value.software, self.client.user.userId, formData.value.notes ],
err => {
if(err) {
self.client.log.error( { err : err }, 'Error adding to BBS list');
}
self.clearAddForm();
self.displayBBSList(true, cb);
}
);
},
cancelSubmit : function(formData, extraArgs, cb) {
self.clearAddForm();
self.displayBBSList(true, cb);
}
};
this.setViewText = function(id, text) {
var v = self.viewControllers.view.getView(id);
if(v) {
v.setText(text);
}
};
this.initDatabase = function(cb) {
async.series( async.series(
[ [
function openDatabase(callback) { function openDatabase(callback) {
@ -422,15 +416,15 @@ function BBSListModule(options) {
callback(null); callback(null);
} }
], ],
cb err => {
return cb(err);
}
); );
}; }
}
require('util').inherits(BBSListModule, MenuModule); beforeArt(cb) {
super.beforeArt(err => {
BBSListModule.prototype.beforeArt = function(cb) { return err ? cb(err) : this.initDatabase(cb);
BBSListModule.super_.prototype.beforeArt.call(this, err => { });
return err ? cb(err) : this.initDatabase(cb); }
});
}; };

View File

@ -1,7 +1,7 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
var MenuModule = require('../core/menu_module.js').MenuModule; const MenuModule = require('../core/menu_module.js').MenuModule;
const stringFormat = require('../core/string_format.js'); const stringFormat = require('../core/string_format.js');
// deps // deps
@ -33,8 +33,9 @@ var MciViewIds = {
InputArea : 3, InputArea : 3,
}; };
// :TODO: needs converted to ES6 MenuModule subclass
function ErcClientModule(options) { function ErcClientModule(options) {
MenuModule.call(this, options); MenuModule.prototype.ctorShim.call(this, options);
const self = this; const self = this;
this.config = options.menuConfig.config; this.config = options.menuConfig.config;

View File

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

648
mods/file_area_list.js Normal file
View File

@ -0,0 +1,648 @@
/* jslint node: true */
'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 createCleanAnsi = require('../core/string_util.js').createCleanAnsi;
const FileArea = require('../core/file_base_area.js');
const Errors = require('../core/enig_error.js').Errors;
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 cleanControlCodes = require('../core/string_util.js').cleanControlCodes;
// deps
const async = require('async');
const _ = require('lodash');
const moment = require('moment');
exports.moduleInfo = {
name : 'File Area List',
desc : 'Lists contents of file an file area',
author : 'NuSkooler',
};
const FormIds = {
browse : 0,
details : 1,
detailsGeneral : 2,
detailsNfo : 3,
detailsFileList : 4,
};
const MciViewIds = {
browse : {
desc : 1,
navMenu : 2,
customRangeStart : 10, // 10+ = customs
},
details : {
navMenu : 1,
infoXyTop : 2, // %XY starting position for info area
infoXyBottom : 3,
customRangeStart : 10, // 10+ = customs
},
detailsGeneral : {
customRangeStart : 10, // 10+ = customs
},
detailsNfo : {
nfo : 1,
customRangeStart : 10, // 10+ = customs
},
detailsFileList : {
fileList : 1,
customRangeStart : 10, // 10+ = customs
},
};
exports.getModule = class FileAreaList extends MenuModule {
constructor(options) {
super(options);
if(options.extraArgs) {
this.filterCriteria = options.extraArgs.filterCriteria;
}
this.dlQueue = new DownloadQueue(this.client);
if(!this.filterCriteria) {
this.filterCriteria = FileBaseFilters.getActiveFilter(this.client);
}
if(_.isString(this.filterCriteria)) {
this.filterCriteria = JSON.parse(this.filterCriteria);
}
if(_.has(options, 'lastMenuResult.value')) {
this.lastMenuResultValue = options.lastMenuResult.value;
}
this.menuMethods = {
nextFile : (formData, extraArgs, cb) => {
if(this.fileListPosition + 1 < this.fileList.length) {
this.fileListPosition += 1;
return this.displayBrowsePage(true, cb); // true=clerarScreen
}
return cb(null);
},
prevFile : (formData, extraArgs, cb) => {
if(this.fileListPosition > 0) {
--this.fileListPosition;
return this.displayBrowsePage(true, cb); // true=clearScreen
}
return cb(null);
},
viewDetails : (formData, extraArgs, cb) => {
this.viewControllers.browse.setFocus(false);
return this.displayDetailsPage(cb);
},
detailsQuit : (formData, extraArgs, cb) => {
this.viewControllers.details.setFocus(false);
return this.displayBrowsePage(true, cb); // true=clearScreen
},
toggleQueue : (formData, extraArgs, cb) => {
this.dlQueue.toggle(this.currentFileEntry);
this.updateQueueIndicator();
return cb(null);
},
showWebDownloadLink : (formData, extraArgs, cb) => {
return this.fetchAndDisplayWebDownloadLink(cb);
},
displayHelp : (formData, extraArgs, cb) => {
return this.displayHelpPage(cb);
}
};
}
enter() {
super.enter();
}
leave() {
super.leave();
}
getSaveState() {
return {
fileList : this.fileList,
fileListPosition : this.fileListPosition,
};
}
restoreSavedState(savedState) {
if(savedState) {
this.fileList = savedState.fileList;
this.fileListPosition = savedState.fileListPosition;
}
}
updateFileEntryWithMenuResult(cb) {
if(!this.lastMenuResultValue) {
return cb(null);
}
if(_.isNumber(this.lastMenuResultValue.rating)) {
const fileId = this.fileList[this.fileListPosition];
FileEntry.persistUserRating(fileId, this.client.user.userId, this.lastMenuResultValue.rating, err => {
if(err) {
this.client.log.warn( { error : err.message, fileId : fileId }, 'Failed to persist file rating' );
}
return cb(null);
});
} else {
return cb(null);
}
}
initSequence() {
const self = this;
async.series(
[
function preInit(callback) {
return self.updateFileEntryWithMenuResult(callback);
},
function beforeArt(callback) {
return self.beforeArt(callback);
},
function display(callback) {
return self.displayBrowsePage(false, err => {
if(err && 'NORESULTS' === err.reasonCode) {
self.gotoMenu(self.menuConfig.config.noResultsMenu || 'fileBaseListEntriesNoResults');
}
return callback(err);
});
}
],
() => {
self.finishedLoading();
}
);
}
populateCurrentEntryInfo(cb) {
const config = this.menuConfig.config;
const currEntry = this.currentFileEntry;
const uploadTimestampFormat = config.browseUploadTimestampFormat || config.uploadTimestampFormat || 'YYYY-MMM-DD';
const area = FileArea.getFileAreaByTag(currEntry.areaTag);
const hashTagsSep = config.hashTagsSep || ', ';
const isQueuedIndicator = config.isQueuedIndicator || 'Y';
const isNotQueuedIndicator = config.isNotQueuedIndicator || 'N';
const entryInfo = currEntry.entryInfo = {
fileId : currEntry.fileId,
areaTag : currEntry.areaTag,
areaName : area.name || 'N/A',
areaDesc : area.desc || 'N/A',
fileSha256 : currEntry.fileSha256,
fileName : currEntry.fileName,
desc : currEntry.desc || '',
descLong : currEntry.descLong || '',
userRating : currEntry.userRating,
uploadTimestamp : moment(currEntry.uploadTimestamp).format(uploadTimestampFormat),
hashTags : Array.from(currEntry.hashTags).join(hashTagsSep),
isQueued : this.dlQueue.isQueued(currEntry) ? isQueuedIndicator : isNotQueuedIndicator,
webDlLink : '', // :TODO: fetch web any existing web d/l link
webDlExpire : '', // :TODO: fetch web d/l link expire time
};
//
// We need the entry object to contain meta keys even if they are empty as
// consumers may very likely attempt to use them
//
const metaValues = FileEntry.getWellKnownMetaValues();
metaValues.forEach(name => {
const value = !_.isUndefined(currEntry.meta[name]) ? currEntry.meta[name] : 'N/A';
entryInfo[_.camelCase(name)] = value;
});
if(entryInfo.archiveType) {
entryInfo.archiveTypeDesc = _.has(Config, [ 'archives', 'formats', entryInfo.archiveType, 'desc' ]) ?
Config.archives.formats[entryInfo.archiveType].desc :
entryInfo.archiveType;
} else {
entryInfo.archiveTypeDesc = 'N/A';
}
entryInfo.uploadByUsername = entryInfo.uploadByUsername || 'N/A'; // may be imported
entryInfo.hashTags = entryInfo.hashTags || '(none)';
// create a rating string, e.g. "**---"
const userRatingTicked = config.userRatingTicked || '*';
const userRatingUnticked = config.userRatingUnticked || '';
entryInfo.userRating = ~~Math.round(entryInfo.userRating) || 0; // be safe!
entryInfo.userRatingString = new Array(entryInfo.userRating + 1).join(userRatingTicked);
if(entryInfo.userRating < 5) {
entryInfo.userRatingString += new Array( (5 - entryInfo.userRating) + 1).join(userRatingUnticked);
}
FileAreaWeb.getExistingTempDownloadServeItem(this.client, this.currentFileEntry, (err, serveItem) => {
if(err) {
entryInfo.webDlLink = config.webDlLinkNeedsGenerated || 'Not yet generated';
entryInfo.webDlExpire = '';
} else {
const webDlExpireTimeFormat = config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
entryInfo.webDlLink = serveItem.url;
entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat);
}
return cb(null);
});
}
populateCustomLabels(category, startId) {
return this.updateCustomViewTextsWithFilter(category, startId, this.currentFileEntry.entryInfo);
}
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));
if('details' === name) {
try {
self.detailsInfoArea = {
top : artData.mciMap.XY2.position,
bottom : artData.mciMap.XY3.position,
};
} catch(e) {
return callback(Errors.DoesNotExist('Missing XY2 and XY3 position indicators!'));
}
}
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);
}
);
}
displayBrowsePage(clearScreen, cb) {
const self = this;
async.series(
[
function fetchEntryData(callback) {
if(self.fileList) {
return callback(null);
}
return self.loadFileIds(false, callback); // false=do not force
},
function checkEmptyResults(callback) {
if(0 === self.fileList.length) {
return callback(Errors.General('No results for criteria', 'NORESULTS'));
}
return callback(null);
},
function prepArtAndViewController(callback) {
return self.displayArtAndPrepViewController('browse', { clearScreen : clearScreen }, callback);
},
function loadCurrentFileInfo(callback) {
self.currentFileEntry = new FileEntry();
self.currentFileEntry.load( self.fileList[ self.fileListPosition ], err => {
if(err) {
return callback(err);
}
return self.populateCurrentEntryInfo(callback);
});
},
function populateViews(callback) {
if(_.isString(self.currentFileEntry.desc)) {
const descView = self.viewControllers.browse.getView(MciViewIds.browse.desc);
if(descView) {
createCleanAnsi(
self.currentFileEntry.desc,
{ height : self.client.termHeight, width : descView.dimens.width },
cleanDesc => {
// :TODO: use cleanDesc -- need to finish createCleanAnsi() !!
//descView.setText(cleanDesc);
descView.setText( self.currentFileEntry.desc );
self.updateQueueIndicator();
self.populateCustomLabels('browse', MciViewIds.browse.customRangeStart);
return callback(null);
}
);
}
} else {
self.updateQueueIndicator();
self.populateCustomLabels('browse', MciViewIds.browse.customRangeStart);
return callback(null);
}
}
],
err => {
if(cb) {
return cb(err);
}
}
);
}
displayDetailsPage(cb) {
const self = this;
async.series(
[
function prepArtAndViewController(callback) {
return self.displayArtAndPrepViewController('details', { clearScreen : true }, callback);
},
function populateViews(callback) {
self.populateCustomLabels('details', MciViewIds.details.customRangeStart);
return callback(null);
},
function prepSection(callback) {
return self.displayDetailsSection('general', false, callback);
},
function listenNavChanges(callback) {
const navMenu = self.viewControllers.details.getView(MciViewIds.details.navMenu);
navMenu.setFocusItemIndex(0);
navMenu.on('index update', index => {
const sectionName = {
0 : 'general',
1 : 'nfo',
2 : 'fileList',
}[index];
if(sectionName) {
self.displayDetailsSection(sectionName, true);
}
});
return callback(null);
}
],
err => {
return cb(err);
}
);
}
displayHelpPage(cb) {
this.displayAsset(
this.menuConfig.config.art.help,
{ clearScreen : true },
() => {
this.client.waitForKeyPress( () => {
return this.displayBrowsePage(true, cb);
});
}
);
}
fetchAndDisplayWebDownloadLink(cb) {
const self = this;
async.series(
[
function generateLinkIfNeeded(callback) {
if(self.currentFileEntry.webDlExpireTime < moment()) {
return callback(null);
}
const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes');
FileAreaWeb.createAndServeTempDownload(
self.client,
self.currentFileEntry,
{ expireTime : expireTime },
(err, url) => {
if(err) {
return callback(err);
}
self.currentFileEntry.webDlExpireTime = expireTime;
const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm';
self.currentFileEntry.entryInfo.webDlLink = url;
self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat);
return callback(null);
}
);
},
function updateActiveViews(callback) {
self.updateCustomViewTextsWithFilter(
'browse',
MciViewIds.browse.customRangeStart, self.currentFileEntry.entryInfo,
{ filter : [ '{webDlLink}', '{webDlExpire}' ] }
);
return callback(null);
}
],
err => {
return cb(err);
}
);
}
updateQueueIndicator() {
const isQueuedIndicator = this.menuConfig.config.isQueuedIndicator || 'Y';
const isNotQueuedIndicator = this.menuConfig.config.isNotQueuedIndicator || 'N';
this.currentFileEntry.entryInfo.isQueued = stringFormat(
this.dlQueue.isQueued(this.currentFileEntry) ?
isQueuedIndicator :
isNotQueuedIndicator
);
this.updateCustomViewTextsWithFilter(
'browse',
MciViewIds.browse.customRangeStart,
this.currentFileEntry.entryInfo,
{ filter : [ '{isQueued}' ] }
);
}
cacheArchiveEntries(cb) {
// check cache
if(this.currentFileEntry.archiveEntries) {
return cb(null, 'cache');
}
const areaInfo = FileArea.getFileAreaByTag(this.currentFileEntry.areaTag);
if(!areaInfo) {
return cb(Errors.Invalid('Invalid area tag'));
}
const filePath = this.currentFileEntry.filePath;
const archiveUtil = ArchiveUtil.getInstance();
archiveUtil.listEntries(filePath, this.currentFileEntry.entryInfo.archiveType, (err, entries) => {
if(err) {
return cb(err);
}
this.currentFileEntry.archiveEntries = entries;
return cb(null, 're-cached');
});
}
populateFileListing() {
const fileListView = this.viewControllers.detailsFileList.getView(MciViewIds.detailsFileList.fileList);
if(this.currentFileEntry.entryInfo.archiveType) {
this.cacheArchiveEntries( (err, cacheStatus) => {
if(err) {
// :TODO: Handle me!!!
fileListView.setItems( [ 'Failed getting file listing' ] ); // :TODO: make this not suck
return;
}
if('re-cached' === cacheStatus) {
const fileListEntryFormat = this.menuConfig.config.fileListEntryFormat || '{fileName} {fileSize}'; // :TODO: use byteSize here?
const focusFileListEntryFormat = this.menuConfig.config.focusFileListEntryFormat || fileListEntryFormat;
fileListView.setItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(fileListEntryFormat, entry) ) );
fileListView.setFocusItems( this.currentFileEntry.archiveEntries.map( entry => stringFormat(focusFileListEntryFormat, entry) ) );
fileListView.redraw();
}
});
} else {
fileListView.setItems( [ stringFormat(this.menuConfig.config.notAnArchiveFormat || 'Not an archive', { fileName : this.currentFileEntry.fileName } ) ] );
}
}
displayDetailsSection(sectionName, clearArea, cb) {
const self = this;
const name = `details${_.upperFirst(sectionName)}`;
async.series(
[
function detachPrevious(callback) {
if(self.lastDetailsViewController) {
self.lastDetailsViewController.detachClientEvents();
}
return callback(null);
},
function prepArtAndViewController(callback) {
function gotoTopPos() {
self.client.term.rawWrite(ansi.goto(self.detailsInfoArea.top[0], 1));
}
gotoTopPos();
if(clearArea) {
self.client.term.rawWrite(ansi.reset());
let pos = self.detailsInfoArea.top[0];
const bottom = self.detailsInfoArea.bottom[0];
while(pos++ <= bottom) {
self.client.term.rawWrite(ansi.eraseLine() + ansi.down());
}
gotoTopPos();
}
return self.displayArtAndPrepViewController(name, { clearScreen : false, noInput : true }, callback);
},
function populateViews(callback) {
self.lastDetailsViewController = self.viewControllers[name];
switch(sectionName) {
case 'nfo' :
{
const nfoView = self.viewControllers.detailsNfo.getView(MciViewIds.detailsNfo.nfo);
if(nfoView) {
nfoView.setText(self.currentFileEntry.entryInfo.descLong);
}
}
break;
case 'fileList' :
self.populateFileListing();
break;
}
self.populateCustomLabels(name, MciViewIds[name].customRangeStart);
return callback(null);
}
],
err => {
if(cb) {
return cb(err);
}
}
);
}
loadFileIds(force, cb) {
if(force || (_.isUndefined(this.fileList) || _.isUndefined(this.fileListPosition))) {
this.fileListPosition = 0;
FileEntry.findFiles(this.filterCriteria, (err, fileIds) => {
this.fileList = fileIds;
return cb(err);
});
}
}
};

View File

@ -0,0 +1,218 @@
/* jslint node: true */
'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');
// deps
const async = require('async');
const _ = require('lodash');
exports.moduleInfo = {
name : 'File Base Download Queue Manager',
desc : 'Module for interacting with download queue/batch',
author : 'NuSkooler',
};
const FormIds = {
queueManager : 0,
details : 1,
};
const MciViewIds = {
queueManager : {
queue : 1,
navMenu : 2,
},
details : {
}
};
exports.getModule = class FileBaseDownloadQueueManager extends MenuModule {
constructor(options) {
super(options);
this.dlQueue = new DownloadQueue(this.client);
if(_.has(options, 'lastMenuResult.sentFileIds')) {
this.sentFileIds = options.lastMenuResult.sentFileIds;
}
this.fallbackOnly = options.lastMenuResult ? true : false;
this.menuMethods = {
downloadAll : (formData, extraArgs, cb) => {
const modOpts = {
extraArgs : {
sendQueue : this.dlQueue.items,
direction : 'send',
}
};
return this.gotoMenu(this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', modOpts, cb);
},
viewItemInfo : (formData, extraArgs, cb) => {
},
removeItem : (formData, extraArgs, cb) => {
const selectedItem = this.dlQueue.items[formData.value.queueItem];
if(!selectedItem) {
return cb(null);
}
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);
}
};
}
initSequence() {
if(0 === this.dlQueue.items.length) {
if(this.sendFileIds) {
// we've finished everything up - just fall back
return this.prevMenu();
}
// Simply an empty D/L queue: Present a specialized "empty queue" page
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);
}
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 || '{fileName} {byteSize}';
const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) );
queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) );
queueView.redraw();
return cb(null);
}
displayQueueManagerPage(clearScreen, cb) {
const self = this;
async.series(
[
function prepArtAndViewController(callback) {
return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback);
},
function populateViews(callback) {
return self.updateDownloadQueueView(callback);
}
],
err => {
if(cb) {
return cb(err);
}
}
);
}
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);
}
);
}
};

120
mods/file_base_search.js Normal file
View File

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

View File

@ -0,0 +1,158 @@
/* 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;
// deps
const async = require('async');
const _ = require('lodash');
exports.moduleInfo = {
name : 'File transfer protocol selection',
desc : 'Select protocol / method for file transfer',
author : 'NuSkooler',
};
const MciViewIds = {
protList : 1,
};
exports.getModule = class FileTransferProtocolSelectModule extends MenuModule {
constructor(options) {
super(options);
this.config = this.menuConfig.config || {};
if(options.extraArgs) {
if(options.extraArgs.direction) {
this.config.direction = options.extraArgs.direction;
}
}
this.config.direction = this.config.direction || 'send';
this.extraArgs = options.extraArgs;
if(_.has(options, 'lastMenuResult.sentFileIds')) {
this.sentFileIds = options.lastMenuResult.sentFileIds;
}
if(_.has(options, 'lastMenuResult.recvFilePaths')) {
this.recvFilePaths = options.lastMenuResult.recvFilePaths;
}
this.fallbackOnly = options.lastMenuResult ? true : false;
this.loadAvailProtocols();
this.menuMethods = {
selectProtocol : (formData, extraArgs, cb) => {
const protocol = this.protocols[formData.value.protocol];
const finalExtraArgs = this.extraArgs || {};
Object.assign(finalExtraArgs, { protocol : protocol.protocol, direction : this.config.direction }, extraArgs );
const modOpts = {
extraArgs : finalExtraArgs,
};
if('send' === this.config.direction) {
return this.gotoMenu(this.config.downloadFilesMenu || 'sendFilesToUser', modOpts, cb);
} else {
return this.gotoMenu(this.config.uploadFilesMenu || 'recvFilesFromUser', modOpts, cb);
}
},
};
}
getMenuResult() {
if(this.sentFileIds) {
return { sentFileIds : this.sentFileIds };
}
if(this.recvFilePaths) {
return { recvFilePaths : this.recvFilePaths };
}
}
initSequence() {
if(this.sentFileIds || this.recvFilePaths) {
// nothing to do here; move along (we're just falling through)
this.prevMenu();
} else {
super.initSequence();
}
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
const self = this;
const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
async.series(
[
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu
};
return vc.loadFromMenuConfig(loadOpts, callback);
},
function populateList(callback) {
const protListView = vc.getView(MciViewIds.protList);
const protListFormat = self.config.protListFormat || '{name}';
const protListFocusFormat = self.config.protListFocusFormat || protListFormat;
protListView.setItems(self.protocols.map(p => stringFormat(protListFormat, p) ) );
protListView.setFocusItems(self.protocols.map(p => stringFormat(protListFocusFormat, p) ) );
protListView.redraw();
return callback(null);
}
],
err => {
return cb(err);
}
);
});
}
loadAvailProtocols() {
this.protocols = _.map(Config.fileTransferProtocols, (protInfo, protocol) => {
return {
protocol : protocol,
name : protInfo.name,
hasBatch : _.has(protInfo, 'external.recvArgs'),
hasNonBatch : _.has(protInfo, 'external.recvArgsNonBatch'),
sort : protInfo.sort,
};
});
// Filter out batch vs non-batch only protocols
if(this.extraArgs.recvFileName) { // non-batch aka non-blind
this.protocols = this.protocols.filter( prot => prot.hasNonBatch );
} else {
this.protocols = this.protocols.filter( prot => prot.hasBatch );
}
// natural sort taking explicit orders into consideration
this.protocols.sort( (a, b) => {
if(_.isNumber(a.sort) && _.isNumber(b.sort)) {
return a.sort - b.sort;
} else {
return a.name.localeCompare(b.name, { sensitivity : false, numeric : true } );
}
});
}
};

View File

@ -32,111 +32,112 @@ exports.moduleInfo = {
packageName : 'codes.l33t.enigma.lastcallers' // :TODO: concept idea for mods packageName : 'codes.l33t.enigma.lastcallers' // :TODO: concept idea for mods
}; };
exports.getModule = LastCallersModule; const MciCodeIds = {
var MciCodeIds = {
CallerList : 1, CallerList : 1,
}; };
function LastCallersModule(options) { exports.getModule = class LastCallersModule extends MenuModule {
MenuModule.call(this, options); constructor(options) {
} super(options);
}
require('util').inherits(LastCallersModule, MenuModule); mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
LastCallersModule.prototype.mciReady = function(mciData, cb) { const self = this;
const self = this; const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
let loginHistory; let loginHistory;
let callersView; let callersView;
async.series( async.series(
[ [
function callParentMciReady(callback) { function loadFromConfig(callback) {
LastCallersModule.super_.prototype.mciReady.call(self, mciData, callback); const loadOpts = {
}, callingMenu : self,
function loadFromConfig(callback) { mciMap : mciData.menu,
const loadOpts = { noInput : true,
callingMenu : self, };
mciMap : mciData.menu,
noInput : true,
};
vc.loadFromMenuConfig(loadOpts, callback); vc.loadFromMenuConfig(loadOpts, callback);
}, },
function fetchHistory(callback) { function fetchHistory(callback) {
callersView = vc.getView(MciCodeIds.CallerList); callersView = vc.getView(MciCodeIds.CallerList);
// fetch up // fetch up
StatLog.getSystemLogEntries('user_login_history', StatLog.Order.TimestampDesc, 200, (err, lh) => { StatLog.getSystemLogEntries('user_login_history', StatLog.Order.TimestampDesc, 200, (err, lh) => {
loginHistory = lh; loginHistory = lh;
if(self.menuConfig.config.hideSysOpLogin) { if(self.menuConfig.config.hideSysOpLogin) {
const noOpLoginHistory = loginHistory.filter(lh => { const noOpLoginHistory = loginHistory.filter(lh => {
return false === isRootUserId(parseInt(lh.log_value)); // log_value=userId return false === isRootUserId(parseInt(lh.log_value)); // log_value=userId
}); });
// //
// If we have enough items to display, or hideSysOpLogin is set to 'always', // If we have enough items to display, or hideSysOpLogin is set to 'always',
// then set loginHistory to our filtered list. Else, we'll leave it be. // then set loginHistory to our filtered list. Else, we'll leave it be.
// //
if(noOpLoginHistory.length >= callersView.dimens.height || 'always' === self.menuConfig.config.hideSysOpLogin) { if(noOpLoginHistory.length >= callersView.dimens.height || 'always' === self.menuConfig.config.hideSysOpLogin) {
loginHistory = noOpLoginHistory; loginHistory = noOpLoginHistory;
} }
} }
// //
// Finally, we need to trim up the list to the needed size // Finally, we need to trim up the list to the needed size
// //
loginHistory = loginHistory.slice(0, callersView.dimens.height); loginHistory = loginHistory.slice(0, callersView.dimens.height);
return callback(err); return callback(err);
});
},
function getUserNamesAndProperties(callback) {
const getPropOpts = {
names : [ 'location', 'affiliation' ]
};
const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD';
async.each(
loginHistory,
(item, next) => {
item.userId = parseInt(item.log_value);
item.ts = moment(item.timestamp).format(dateTimeFormat);
getUserName(item.userId, (err, userName) => {
item.userName = userName;
getPropOpts.userId = item.userId;
loadProperties(getPropOpts, (err, props) => {
if(!err) {
item.location = props.location;
item.affiliation = item.affils = props.affiliation;
}
return next();
});
}); });
}, },
callback function getUserNamesAndProperties(callback) {
); const getPropOpts = {
}, names : [ 'location', 'affiliation' ]
function populateList(callback) { };
const listFormat = self.menuConfig.config.listFormat || '{userName} - {location} - {affiliation} - {ts}';
callersView.setItems(_.map(loginHistory, ce => stringFormat(listFormat, ce) ) ); const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD';
callersView.redraw(); async.each(
return callback(null); loginHistory,
} (item, next) => {
], item.userId = parseInt(item.log_value);
(err) => { item.ts = moment(item.timestamp).format(dateTimeFormat);
if(err) {
self.client.log.error( { error : err.toString() }, 'Error loading last callers'); getUserName(item.userId, (err, userName) => {
} item.userName = userName;
cb(err); getPropOpts.userId = item.userId;
}
); loadProperties(getPropOpts, (err, props) => {
if(!err) {
item.location = props.location;
item.affiliation = item.affils = props.affiliation;
}
return next();
});
});
},
callback
);
},
function populateList(callback) {
const listFormat = self.menuConfig.config.listFormat || '{userName} - {location} - {affiliation} - {ts}';
callersView.setItems(_.map(loginHistory, ce => stringFormat(listFormat, ce) ) );
callersView.redraw();
return callback(null);
}
],
(err) => {
if(err) {
self.client.log.error( { error : err.toString() }, 'Error loading last callers');
}
cb(err);
}
);
});
}
}; };

View File

@ -1,11 +1,29 @@
{ {
/* /*
ENiGMA½ Menu Configuration ./\/\.' ENiGMA½ Menu Configuration -/--/-------- - -- -
This configuration is in HJSON format. Strict to-spec JSON is also _____________________ _____ ____________________ __________\_ /
perfectly valid. The hjson npm can be used to convert to/from JSON. \__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp!
// __|___// | \// |// | \// | | \// \ /___ /_____
/____ _____| __________ ___|__| ____| \ / _____ \
---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/
/__ _\
<*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/
-------------------------------------------------------------------------------
This configuration is in HJSON (http://hjson.org/) format. Strict to-spec
JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON.
See http://hjson.org/ for more information and syntax. See http://hjson.org/ for more information and syntax.
If you haven't yet, copy the conents of this file to something like
sick_board.hjson. Point to it via config.hjson using the
'general.menuFile' key:
general: { menuFile: "sick_board.hjson" }
*/ */
menus: { menus: {
// //
@ -34,7 +52,7 @@
// //
sshConnectedNewUser: { sshConnectedNewUser: {
art: CONNECT art: CONNECT
next: newUserApplicationSsh next: newUserApplicationPreSsh
options: { nextTimeout: 1500 } options: { nextTimeout: 1500 }
} }
@ -60,7 +78,7 @@
} }
{ {
value: { 1: 1 }, value: { 1: 1 },
action: @menu:newUserApplication action: @menu:newUserApplicationPre
} }
{ {
value: { 1: 2 }, value: { 1: 2 },
@ -162,12 +180,24 @@
desc: Logging Off desc: Logging Off
next: @systemMethod:logoff next: @systemMethod:logoff
} }
/*
TODO: display PRINT before this (Obv/2) or NEWUSER1 (Mystic) // A quick preamble - defaults to warning about broken terminals
*/ newUserApplicationPre: {
art: NEWUSER1
next: newUserApplication
desc: Applying
options: {
pause: true
cls: true
}
}
newUserApplication: { newUserApplication: {
module: nua module: nua
art: NUA art: NUA
options: {
menuFlags: [ "noHistory" ]
}
next: [ next: [
{ {
// Initial SysOp does not send feedback to themselves // Initial SysOp does not send feedback to themselves
@ -268,6 +298,17 @@
} }
} }
// A quick preamble - defaults to warning about broken terminals (SSH version)
newUserApplicationPreSsh: {
art: NEWUSER1
next: newUserApplicationSsh
desc: Applying
options: {
pause: true
cls: true
}
}
// //
// SSH specialization of NUA // SSH specialization of NUA
// Canceling this form logs off vs falling back to matrix // Canceling this form logs off vs falling back to matrix
@ -275,6 +316,9 @@
newUserApplicationSsh: { newUserApplicationSsh: {
art: NUA art: NUA
fallback: logoff fallback: logoff
options: {
menuFlags: [ "noHistory" ]
}
next: newUserFeedbackToSysOpPreamble next: newUserFeedbackToSysOpPreamble
form: { form: {
0: { 0: {
@ -350,7 +394,7 @@
} }
{ {
value: { "submission" : 1 } value: { "submission" : 1 }
action: @systemMethod:prevMenu action: @systemMethod:logoff
} }
] ]
} }
@ -358,7 +402,7 @@
actionKeys: [ actionKeys: [
{ {
keys: [ "escape" ] keys: [ "escape" ]
action: @systemMethod:prevMenu action: @systemMethod:logoff
} }
] ]
} }
@ -708,6 +752,10 @@
value: { command: "D" } value: { command: "D" }
action: @menu:doorMenu action: @menu:doorMenu
} }
{
value: { command: "F" }
action: @menu:fileBase
}
{ {
value: { command: "U" } value: { command: "U" }
action: @menu:mainMenuUserList action: @menu:mainMenuUserList
@ -1696,6 +1744,7 @@
HM1: { HM1: {
// :TODO: (#)Jump/(L)Index (msg list)/Last // :TODO: (#)Jump/(L)Index (msg list)/Last
items: [ "prev", "next", "reply", "quit", "help" ] items: [ "prev", "next", "reply", "quit", "help" ]
focusItemIndex: 1
} }
} }
submit: { submit: {
@ -2226,6 +2275,639 @@
} }
} }
////////////////////////////////////////////////////////////////////////
// File Area
////////////////////////////////////////////////////////////////////////
fileBase: {
desc: File Base
art: FMENU
prompt: fileMenuCommand
submit: [
{
value: { menuOption: "B" }
action: @menu:fileBaseListEntries
}
{
value: { menuOption: "F" }
action: @menu:fileAreaFilterEditor
}
{
value: { menuOption: "Q" }
action: @systemMethod:prevMenu
}
{
value: { menuOption: "G" }
action: @menu:fullLogoffSequence
}
{
value: { menuOption: "D" }
action: @menu:fileBaseDownloadManager
}
{
value: { menuOption: "U" }
action: @menu:fileBaseUploadFiles
}
{
value: { menuOption: "S" }
action: @menu:fileBaseSearch
}
]
}
fileBaseListEntries: {
module: file_area_list
desc: Browsing Files
config: {
art: {
browse: FBRWSE
details: FDETAIL
detailsGeneral: FDETGEN
detailsNfo: FDETNFO
detailsFileList: FDETLST
help: FBHELP
}
}
form: {
0: {
mci: {
MT1: {
mode: preview
}
HM2: {
focus: true
submit: true
argName: navSelect
items: [
"prev", "next", "details", "toggle queue", "rate", "change filter", "help", "quit"
]
focusItemIndex: 1
}
}
submit: {
*: [
{
value: { navSelect: 0 }
action: @method:prevFile
}
{
value: { navSelect: 1 }
action: @method:nextFile
}
{
value: { navSelect: 2 }
action: @method:viewDetails
}
{
value: { navSelect: 3 }
action: @method:toggleQueue
}
{
value: { navSelect: 4 }
action: @menu:fileBaseGetRatingForSelectedEntry
}
{
value: { navSelect: 5 }
action: @menu:fileAreaFilterEditor
}
{
value: { navSelect: 6 }
action: @method:displayHelp
}
{
value: { navSelect: 7 }
action: @systemMethod:prevMenu
}
]
}
actionKeys: [
{
keys: [ "w", "shift + w" ]
action: @method:showWebDownloadLink
}
{
keys: [ "escape", "q", "shift + q" ]
action: @systemMethod:prevMenu
}
{
keys: [ "t", "shift + t" ]
action: @method:toggleQueue
}
{
keys: [ "f", "shift + f" ]
action: @menu:fileAreaFilterEditor
}
{
keys: [ "v", "shift + v" ]
action: @method:viewDetails
}
{
keys: [ "r", "shift + r" ]
action: @menu:fileBaseGetRatingForSelectedEntry
}
{
keys: [ "?" ]
action: @method:displayHelp
}
]
}
1: {
mci: {
HM1: {
focus: true
submit: true
argName: navSelect
items: [
"general", "nfo/readme", "file listing"
]
}
}
actionKeys: [
{
keys: [ "escape", "q", "shift + q" ]
action: @method:detailsQuit
}
]
}
2: {
// details - general
mci: {}
}
3: {
// details - nfo/readme
mci: {
MT1: {
mode: preview
}
}
}
4: {
// details - file listing
mci: {
VM1: {
}
}
}
}
}
fileBaseGetRatingForSelectedEntry: {
desc: Rating a File
prompt: fileBaseRateEntryPrompt
options: {
cls: true
}
submit: [
// :TODO: handle esc/q
{
// pass data back to caller
value: { rating: null }
action: @systemMethod:prevMenu
}
]
}
fileBaseListEntriesNoResults: {
desc: Browsing Files
art: FBNORES
options: {
pause: true
menuFlags: [ "noHistory" ]
}
}
fileBaseSearch: {
module: file_base_search
desc: Searching Files
art: FSEARCH
form: {
0: {
mci: {
ET1: {
focus: true
argName: searchTerms
}
BT2: {
argName: search
text: search
submit: true
}
ET3: {
maxLength: 64
argName: tags
}
SM4: {
maxLength: 64
argName: areaIndex
}
SM5: {
items: [
"upload date",
"uploaded by",
"downloads",
"rating",
"estimated year",
"size",
]
argName: sortByIndex
}
SM6: {
items: [
"decending",
"ascending"
]
argName: orderByIndex
}
BT7: {
argName: advancedSearch
text: advanced search
submit: true
}
}
submit: {
*: [
{
value: { search: null }
action: @method:search
}
{
value: { advancedSearch: null }
action: @method:search
}
]
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
]
}
}
}
fileAreaFilterEditor: {
desc: File Filter Editor
module: file_area_filter_edit
art: FFILEDT
form: {
0: {
mci: {
ET1: {
argName: searchTerms
}
ET2: {
maxLength: 64
argName: tags
}
SM3: {
maxLength: 64
argName: areaIndex
}
SM4: {
items: [
"upload date",
"uploaded by",
"downloads",
"rating",
"estimated year",
"size",
]
argName: sortByIndex
}
SM5: {
items: [
"decending",
"ascending"
]
argName: orderByIndex
}
ET6: {
maxLength: 64
argName: name
validate: @systemMethod:validateNonEmpty
}
HM7: {
focus: true
items: [
"prev", "next", "make active", "save", "new", "delete"
]
argName: navSelect
focusItemIndex: 1
}
}
submit: {
*: [
{
value: { navSelect: 0 }
action: @method:prevFilter
}
{
value: { navSelect: 1 }
action: @method:nextFilter
}
{
value: { navSelect: 2 }
action: @method:makeFilterActive
}
{
value: { navSelect: 3 }
action: @method:saveFilter
}
{
value: { navSelect: 4 }
action: @method:newFilter
}
{
value: { navSelect: 5 }
action: @method:deleteFilter
}
]
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
]
}
}
}
fileBaseDownloadManager: {
desc: Download Manager
module: file_base_download_manager
config: {
art: {
queueManager: FDLMGR
/*
NYI
details: FDLDET
*/
}
emptyQueueMenu: fileBaseDownloadManagerEmptyQueue
}
form: {
0: {
mci: {
VM1: {
argName: queueItem
}
HM2: {
focus: true
items: [ "download all", "quit" ]
argName: navSelect
}
}
submit: {
*: [
{
value: { navSelect: 0 }
action: @method:downloadAll
}
{
value: { navSelect: 1 }
action: @systemMethod:prevMenu
}
]
}
actionKeys: [
{
keys: [ "a", "shift + a" ]
action: @method:downloadAll
}
{
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" ]
}
}
fileTransferProtocolSelection: {
desc: Protocol selection
module: file_transfer_protocol_select
art: FPROSEL
form: {
0: {
mci: {
VM1: {
focus: true
argName: protocol
}
}
submit: {
*: [
{
value: { protocol: null }
action: @method:selectProtocol
}
]
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
]
}
}
}
fileBaseUploadFiles: {
desc: Uploading
module: upload
config: {
art: {
options: ULOPTS
fileDetails: ULDETAIL
processing: ULCHECK
dupes: ULDUPES
}
}
form: {
// options
0: {
mci: {
SM1: {
argName: areaSelect
focus: true
}
TM2: {
argName: uploadType
items: [ "blind", "supply filename" ]
}
ET3: {
argName: fileName
maxLength: 255
validate: @method:validateNonBlindFileName
}
HM4: {
argName: navSelect
items: [ "continue", "cancel" ]
submit: true
}
}
submit: {
*: [
{
value: { navSelect: 0 }
action: @method:optionsNavContinue
}
{
value: { navSelect: 1 }
action: @systemMethod:prevMenu
}
]
}
"actionKeys" : [
{
"keys" : [ "escape" ],
action: @systemMethod:prevMenu
}
]
}
1: {
mci: {
TL1: {}
TL2: {}
TL3: {}
MT4: {}
TL10: {}
}
}
// file details entry
2: {
mci: {
MT1: {
argName: shortDesc
tabSwitchesView: true
focus: true
}
ET2: {
argName: tags
}
ME3: {
argName: estYear
maskPattern: "####"
}
BT4: {
argName: continue
text: continue
submit: true
}
}
submit: {
*: [
{
value: { continue: null }
action: @method:fileDetailsContinue
}
]
}
}
// dupes
3: {
mci: {
VM1: {
/*
Use 'dupeInfoFormat' to custom format:
areaDesc
areaName
areaTag
desc
descLong
fileId
fileName
fileSha256
storageTag
uploadTimestamp
*/
mode: preview
}
}
}
}
}
fileBaseNoUploadAreasAvail: {
desc: File Base
art: ULNOAREA
options: {
pause: true
menuFlags: [ "noHistory" ]
}
}
sendFilesToUser: {
desc: Downloading
module: @systemModule:file_transfer
config: {
// defaults - generally use extraArgs
protocol: zmodem8kSexyz
direction: send
}
}
recvFilesFromUser: {
desc: Uploading
module: @systemModule:file_transfer
config: {
// defaults - generally use extraArgs
protocol: zmodem8kSexyz
direction: recv
}
}
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
// Required entries // Required entries
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////

View File

@ -6,7 +6,6 @@ const MenuModule = require('../core/menu_module.js').MenuModule;
const ViewController = require('../core/view_controller.js').ViewController; const ViewController = require('../core/view_controller.js').ViewController;
const messageArea = require('../core/message_area.js'); const messageArea = require('../core/message_area.js');
const displayThemeArt = require('../core/theme.js').displayThemeArt; const displayThemeArt = require('../core/theme.js').displayThemeArt;
const displayThemedPause = require('../core/theme.js').displayThemedPause;
const resetScreen = require('../core/ansi_term.js').resetScreen; const resetScreen = require('../core/ansi_term.js').resetScreen;
const stringFormat = require('../core/string_format.js'); const stringFormat = require('../core/string_format.js');
@ -14,8 +13,6 @@ const stringFormat = require('../core/string_format.js');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
exports.getModule = MessageAreaListModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'Message Area List', name : 'Message Area List',
desc : 'Module for listing / choosing message areas', desc : 'Module for listing / choosing message areas',
@ -36,152 +33,145 @@ exports.moduleInfo = {
|TI Current time |TI Current time
*/ */
const MCICodesIDs = { const MciViewIds = {
AreaList : 1, AreaList : 1,
SelAreaInfo1 : 2, SelAreaInfo1 : 2,
SelAreaInfo2 : 3, SelAreaInfo2 : 3,
}; };
function MessageAreaListModule(options) { exports.getModule = class MessageAreaListModule extends MenuModule {
MenuModule.call(this, options); constructor(options) {
super(options);
var self = this; this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag(
this.client.user.properties.message_conf_tag,
{ client : this.client }
);
this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag( const self = this;
self.client.user.properties.message_conf_tag, this.menuMethods = {
{ client : self.client } changeArea : function(formData, extraArgs, cb) {
); if(1 === formData.submitId) {
let area = self.messageAreas[formData.value.area];
const areaTag = area.areaTag;
area = area.area; // what we want is actually embedded
this.prevMenuOnTimeout = function(timeout, cb) { messageArea.changeMessageArea(self.client, areaTag, err => {
setTimeout( () => { if(err) {
self.prevMenu(cb); self.client.term.pipeWrite(`\n|00Cannot change area: ${err.message}\n`);
}, timeout);
};
this.menuMethods = { self.prevMenuOnTimeout(1000, cb);
changeArea : function(formData, extraArgs, cb) { } else {
if(1 === formData.submitId) { if(_.isString(area.art)) {
let area = self.messageAreas[formData.value.area]; const dispOptions = {
const areaTag = area.areaTag; client : self.client,
area = area.area; // what we want is actually embedded name : area.art,
};
messageArea.changeMessageArea(self.client, areaTag, err => { self.client.term.rawWrite(resetScreen());
if(err) {
self.client.term.pipeWrite(`\n|00Cannot change area: ${err.message}\n`);
self.prevMenuOnTimeout(1000, cb); displayThemeArt(dispOptions, () => {
} else { // pause by default, unless explicitly told not to
if(_.isString(area.art)) { if(_.has(area, 'options.pause') && false === area.options.pause) {
const dispOptions = { return self.prevMenuOnTimeout(1000, cb);
client : self.client, } else {
name : area.art, self.pausePrompt( () => {
}; return self.prevMenu(cb);
});
self.client.term.rawWrite(resetScreen()); }
});
displayThemeArt(dispOptions, () => { } else {
// pause by default, unless explicitly told not to return self.prevMenu(cb);
if(_.has(area, 'options.pause') && false === area.options.pause) { }
return self.prevMenuOnTimeout(1000, cb);
} else {
displayThemedPause( { client : self.client }, () => {
return self.prevMenu(cb);
});
}
});
} else {
return self.prevMenu(cb);
} }
} });
}); } else {
} else { return cb(null);
return cb(null); }
} }
} };
}; }
this.setViewText = function(id, text) { prevMenuOnTimeout(timeout, cb) {
const v = self.viewControllers.areaList.getView(id); setTimeout( () => {
if(v) { return this.prevMenu(cb);
v.setText(text); }, timeout);
} }
};
this.updateGeneralAreaInfoViews = function(areaIndex) { updateGeneralAreaInfoViews(areaIndex) {
// :TODO: these concepts have been replaced with the {someKey} style formatting - update me!
/* experimental: not yet avail /* experimental: not yet avail
const areaInfo = self.messageAreas[areaIndex]; const areaInfo = self.messageAreas[areaIndex];
[ MCICodesIDs.SelAreaInfo1, MCICodesIDs.SelAreaInfo2 ].forEach(mciId => { [ MciViewIds.SelAreaInfo1, MciViewIds.SelAreaInfo2 ].forEach(mciId => {
const v = self.viewControllers.areaList.getView(mciId); const v = self.viewControllers.areaList.getView(mciId);
if(v) { if(v) {
v.setFormatObject(areaInfo.area); v.setFormatObject(areaInfo.area);
} }
}); });
*/ */
}; }
} mciReady(mciData, cb) {
super.mciReady(mciData, err => {
require('util').inherits(MessageAreaListModule, MenuModule); if(err) {
return cb(err);
MessageAreaListModule.prototype.mciReady = function(mciData, cb) {
const self = this;
const vc = self.viewControllers.areaList = new ViewController( { client : self.client } );
async.series(
[
function callParentMciReady(callback) {
MessageAreaListModule.super_.prototype.mciReady.call(this, mciData, function parentMciReady(err) {
callback(err);
});
},
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
formId : 0,
};
vc.loadFromMenuConfig(loadOpts, function startingViewReady(err) {
callback(err);
});
},
function populateAreaListView(callback) {
const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}';
const focusListFormat = self.menuConfig.config.focusListFormat || listFormat;
const areaListView = vc.getView(MCICodesIDs.AreaList);
let i = 1;
areaListView.setItems(_.map(self.messageAreas, v => {
return stringFormat(listFormat, {
index : i++,
areaTag : v.area.areaTag,
name : v.area.name,
desc : v.area.desc,
});
}));
i = 1;
areaListView.setFocusItems(_.map(self.messageAreas, v => {
return stringFormat(focusListFormat, {
index : i++,
areaTag : v.area.areaTag,
name : v.area.name,
desc : v.area.desc,
});
}));
areaListView.on('index update', areaIndex => {
self.updateGeneralAreaInfoViews(areaIndex);
});
areaListView.redraw();
callback(null);
} }
],
function complete(err) { const self = this;
return cb(err); const vc = self.viewControllers.areaList = new ViewController( { client : self.client } );
}
); async.series(
}; [
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
formId : 0,
};
vc.loadFromMenuConfig(loadOpts, function startingViewReady(err) {
callback(err);
});
},
function populateAreaListView(callback) {
const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}';
const focusListFormat = self.menuConfig.config.focusListFormat || listFormat;
const areaListView = vc.getView(MciViewIds.AreaList);
let i = 1;
areaListView.setItems(_.map(self.messageAreas, v => {
return stringFormat(listFormat, {
index : i++,
areaTag : v.area.areaTag,
name : v.area.name,
desc : v.area.desc,
});
}));
i = 1;
areaListView.setFocusItems(_.map(self.messageAreas, v => {
return stringFormat(focusListFormat, {
index : i++,
areaTag : v.area.areaTag,
name : v.area.name,
desc : v.area.desc,
});
}));
areaListView.on('index update', areaIndex => {
self.updateGeneralAreaInfoViews(areaIndex);
});
areaListView.redraw();
callback(null);
}
],
function complete(err) {
return cb(err);
}
);
});
}
};

View File

@ -1,15 +1,11 @@
/* jslint node: true */ /* jslint node: true */
'use strict'; 'use strict';
let FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule;
//var Message = require('../core/message.js').Message; const persistMessage = require('../core/message_area.js').persistMessage;
let persistMessage = require('../core/message_area.js').persistMessage;
let user = require('../core/user.js');
let _ = require('lodash'); const _ = require('lodash');
let async = require('async'); const async = require('async');
exports.getModule = AreaPostFSEModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'Message Area Post', name : 'Message Area Post',
@ -17,56 +13,55 @@ exports.moduleInfo = {
author : 'NuSkooler', author : 'NuSkooler',
}; };
function AreaPostFSEModule(options) { exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule {
FullScreenEditorModule.call(this, options); constructor(options) {
super(options);
var self = this; const self = this;
// we're posting, so always start with 'edit' mode // we're posting, so always start with 'edit' mode
this.editorMode = 'edit'; this.editorMode = 'edit';
this.menuMethods.editModeMenuSave = function(formData, extraArgs, cb) { this.menuMethods.editModeMenuSave = function(formData, extraArgs, cb) {
var msg; var msg;
async.series( async.series(
[ [
function getMessageObject(callback) { function getMessageObject(callback) {
self.getMessage(function gotMsg(err, msgObj) { self.getMessage(function gotMsg(err, msgObj) {
msg = msgObj; msg = msgObj;
return callback(err); return callback(err);
}); });
}, },
function saveMessage(callback) { function saveMessage(callback) {
return persistMessage(msg, callback); return persistMessage(msg, callback);
}, },
function updateStats(callback) { function updateStats(callback) {
self.updateUserStats(callback); self.updateUserStats(callback);
}
],
function complete(err) {
if(err) {
// :TODO:... sooooo now what?
} else {
// note: not logging 'from' here as it's part of client.log.xxxx()
self.client.log.info(
{ to : msg.toUserName, subject : msg.subject, uuid : msg.uuid },
'Message persisted'
);
}
return self.nextMenu(cb);
} }
], );
function complete(err) { };
if(err) {
// :TODO:... sooooo now what?
} else {
// note: not logging 'from' here as it's part of client.log.xxxx()
self.client.log.info(
{ to : msg.toUserName, subject : msg.subject, uuid : msg.uuid },
'Message persisted'
);
}
return self.nextMenu(cb);
}
);
};
}
require('util').inherits(AreaPostFSEModule, FullScreenEditorModule);
AreaPostFSEModule.prototype.enter = function() {
if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) {
this.messageAreaTag = this.client.user.properties.message_area_tag;
} }
AreaPostFSEModule.super_.prototype.enter.call(this); enter() {
}; if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) {
this.messageAreaTag = this.client.user.properties.message_area_tag;
}
super.enter();
}
};

View File

@ -8,122 +8,118 @@ const Message = require('../core/message.js');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
exports.getModule = AreaViewFSEModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'Message Area View', name : 'Message Area View',
desc : 'Module for viewing an area message', desc : 'Module for viewing an area message',
author : 'NuSkooler', author : 'NuSkooler',
}; };
function AreaViewFSEModule(options) { exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule {
FullScreenEditorModule.call(this, options); constructor(options) {
super(options);
const self = this; this.editorType = 'area';
this.editorMode = 'view';
this.editorType = 'area'; if(_.isObject(options.extraArgs)) {
this.editorMode = 'view'; this.messageList = options.extraArgs.messageList;
this.messageIndex = options.extraArgs.messageIndex;
}
if(_.isObject(options.extraArgs)) { this.messageList = this.messageList || [];
this.messageList = options.extraArgs.messageList; this.messageIndex = this.messageIndex || 0;
this.messageIndex = options.extraArgs.messageIndex; this.messageTotal = this.messageList.length;
const self = this;
// assign *additional* menuMethods
Object.assign(this.menuMethods, {
nextMessage : (formData, extraArgs, cb) => {
if(self.messageIndex + 1 < self.messageList.length) {
self.messageIndex++;
return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
}
return cb(null);
},
prevMessage : (formData, extraArgs, cb) => {
if(self.messageIndex > 0) {
self.messageIndex--;
return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
}
return cb(null);
},
movementKeyPressed : (formData, extraArgs, cb) => {
const bodyView = self.viewControllers.body.getView(1); // :TODO: use const here vs magic #
// :TODO: Create methods for up/down vs using keyPressXXXXX
switch(formData.key.name) {
case 'down arrow' : bodyView.scrollDocumentUp(); break;
case 'up arrow' : bodyView.scrollDocumentDown(); break;
case 'page up' : bodyView.keyPressPageUp(); break;
case 'page down' : bodyView.keyPressPageDown(); break;
}
// :TODO: need to stop down/page down if doing so would push the last
// visible page off the screen at all .... this should be handled by MLTEV though...
return cb(null);
},
replyMessage : (formData, extraArgs, cb) => {
if(_.isString(extraArgs.menu)) {
const modOpts = {
extraArgs : {
messageAreaTag : self.messageAreaTag,
replyToMessage : self.message,
}
};
return self.gotoMenu(extraArgs.menu, modOpts, cb);
}
self.client.log(extraArgs, 'Missing extraArgs.menu');
return cb(null);
}
});
} }
this.messageList = this.messageList || [];
this.messageIndex = this.messageIndex || 0;
this.messageTotal = this.messageList.length;
this.menuMethods.nextMessage = function(formData, extraArgs, cb) { loadMessageByUuid(uuid, cb) {
if(self.messageIndex + 1 < self.messageList.length) {
self.messageIndex++;
return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
}
return cb(null);
};
this.menuMethods.prevMessage = function(formData, extraArgs, cb) {
if(self.messageIndex > 0) {
self.messageIndex--;
return self.loadMessageByUuid(self.messageList[self.messageIndex].messageUuid, cb);
}
return cb(null);
};
this.menuMethods.movementKeyPressed = function(formData, extraArgs, cb) {
const bodyView = self.viewControllers.body.getView(1); // :TODO: use const here vs magic #
// :TODO: Create methods for up/down vs using keyPressXXXXX
switch(formData.key.name) {
case 'down arrow' : bodyView.scrollDocumentUp(); break;
case 'up arrow' : bodyView.scrollDocumentDown(); break;
case 'page up' : bodyView.keyPressPageUp(); break;
case 'page down' : bodyView.keyPressPageDown(); break;
}
// :TODO: need to stop down/page down if doing so would push the last
// visible page off the screen at all .... this should be handled by MLTEV though...
return cb(null);
};
this.menuMethods.replyMessage = function(formData, extraArgs, cb) {
if(_.isString(extraArgs.menu)) {
const modOpts = {
extraArgs : {
messageAreaTag : self.messageAreaTag,
replyToMessage : self.message,
}
};
return self.gotoMenu(extraArgs.menu, modOpts, cb);
}
self.client.log(extraArgs, 'Missing extraArgs.menu');
return cb(null);
};
this.loadMessageByUuid = function(uuid, cb) {
const msg = new Message(); const msg = new Message();
msg.load( { uuid : uuid, user : self.client.user }, () => { msg.load( { uuid : uuid, user : this.client.user }, () => {
self.setMessage(msg); this.setMessage(msg);
if(cb) { if(cb) {
return cb(null); return cb(null);
} }
}); });
}; }
}
require('util').inherits(AreaViewFSEModule, FullScreenEditorModule); finishedLoading() {
AreaViewFSEModule.prototype.finishedLoading = function() {
if(this.messageList.length) {
this.loadMessageByUuid(this.messageList[this.messageIndex].messageUuid); this.loadMessageByUuid(this.messageList[this.messageIndex].messageUuid);
} }
};
getSaveState() {
AreaViewFSEModule.prototype.getSaveState = function() { return {
AreaViewFSEModule.super_.prototype.getSaveState.call(this); messageList : this.messageList,
messageIndex : this.messageIndex,
return { messageTotal : this.messageList.length,
messageList : this.messageList, };
messageIndex : this.messageIndex, }
messageTotal : this.messageList.length,
}; restoreSavedState(savedState) {
}; this.messageList = savedState.messageList;
this.messageIndex = savedState.messageIndex;
AreaViewFSEModule.prototype.restoreSavedState = function(savedState) { this.messageTotal = savedState.messageTotal;
AreaViewFSEModule.super_.prototype.restoreSavedState.call(this, savedState); }
this.messageList = savedState.messageList; getMenuResult() {
this.messageIndex = savedState.messageIndex; return this.messageIndex;
this.messageTotal = savedState.messageTotal; }
};
AreaViewFSEModule.prototype.getMenuResult = function() {
return this.messageIndex;
}; };

View File

@ -6,7 +6,6 @@ const MenuModule = require('../core/menu_module.js').MenuModule;
const ViewController = require('../core/view_controller.js').ViewController; const ViewController = require('../core/view_controller.js').ViewController;
const messageArea = require('../core/message_area.js'); const messageArea = require('../core/message_area.js');
const displayThemeArt = require('../core/theme.js').displayThemeArt; const displayThemeArt = require('../core/theme.js').displayThemeArt;
const displayThemedPause = require('../core/theme.js').displayThemedPause;
const resetScreen = require('../core/ansi_term.js').resetScreen; const resetScreen = require('../core/ansi_term.js').resetScreen;
const stringFormat = require('../core/string_format.js'); const stringFormat = require('../core/string_format.js');
@ -14,15 +13,13 @@ const stringFormat = require('../core/string_format.js');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
exports.getModule = MessageConfListModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'Message Conference List', name : 'Message Conference List',
desc : 'Module for listing / choosing message conferences', desc : 'Module for listing / choosing message conferences',
author : 'NuSkooler', author : 'NuSkooler',
}; };
const MCICodeIDs = { const MciViewIds = {
ConfList : 1, ConfList : 1,
// :TODO: // :TODO:
@ -30,127 +27,122 @@ const MCICodeIDs = {
// //
}; };
function MessageConfListModule(options) { exports.getModule = class MessageConfListModule extends MenuModule {
MenuModule.call(this, options); constructor(options) {
super(options);
var self = this; this.messageConfs = messageArea.getSortedAvailMessageConferences(this.client);
const self = this;
this.menuMethods = {
changeConference : function(formData, extraArgs, cb) {
if(1 === formData.submitId) {
let conf = self.messageConfs[formData.value.conf];
const confTag = conf.confTag;
conf = conf.conf; // what we want is embedded
this.messageConfs = messageArea.getSortedAvailMessageConferences(self.client); messageArea.changeMessageConference(self.client, confTag, err => {
if(err) {
self.client.term.pipeWrite(`\n|00Cannot change conference: ${err.message}\n`);
this.prevMenuOnTimeout = function(timeout, cb) { setTimeout( () => {
setTimeout( () => { return self.prevMenu(cb);
self.prevMenu(cb); }, 1000);
}, timeout);
};
this.menuMethods = {
changeConference : function(formData, extraArgs, cb) {
if(1 === formData.submitId) {
let conf = self.messageConfs[formData.value.conf];
const confTag = conf.confTag;
conf = conf.conf; // what we want is embedded
messageArea.changeMessageConference(self.client, confTag, err => {
if(err) {
self.client.term.pipeWrite(`\n|00Cannot change conference: ${err.message}\n`);
setTimeout( () => {
return self.prevMenu(cb);
}, 1000);
} else {
if(_.isString(conf.art)) {
const dispOptions = {
client : self.client,
name : conf.art,
};
self.client.term.rawWrite(resetScreen());
displayThemeArt(dispOptions, () => {
// pause by default, unless explicitly told not to
if(_.has(conf, 'options.pause') && false === conf.options.pause) {
return self.prevMenuOnTimeout(1000, cb);
} else {
displayThemedPause( { client : self.client }, () => {
return self.prevMenu(cb);
});
}
});
} else { } else {
return self.prevMenu(cb); if(_.isString(conf.art)) {
const dispOptions = {
client : self.client,
name : conf.art,
};
self.client.term.rawWrite(resetScreen());
displayThemeArt(dispOptions, () => {
// pause by default, unless explicitly told not to
if(_.has(conf, 'options.pause') && false === conf.options.pause) {
return self.prevMenuOnTimeout(1000, cb);
} else {
self.pausePrompt( () => {
return self.prevMenu(cb);
});
}
});
} else {
return self.prevMenu(cb);
}
} }
});
} else {
return cb(null);
}
}
};
}
prevMenuOnTimeout(timeout, cb) {
setTimeout( () => {
return this.prevMenu(cb);
}, timeout);
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
const self = this;
const vc = self.viewControllers.areaList = new ViewController( { client : self.client } );
async.series(
[
function loadFromConfig(callback) {
let loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
formId : 0,
};
vc.loadFromMenuConfig(loadOpts, callback);
},
function populateConfListView(callback) {
const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}';
const focusListFormat = self.menuConfig.config.focusListFormat || listFormat;
const confListView = vc.getView(MciViewIds.ConfList);
let i = 1;
confListView.setItems(_.map(self.messageConfs, v => {
return stringFormat(listFormat, {
index : i++,
confTag : v.conf.confTag,
name : v.conf.name,
desc : v.conf.desc,
});
}));
i = 1;
confListView.setFocusItems(_.map(self.messageConfs, v => {
return stringFormat(focusListFormat, {
index : i++,
confTag : v.conf.confTag,
name : v.conf.name,
desc : v.conf.desc,
});
}));
confListView.redraw();
callback(null);
},
function populateTextViews(callback) {
// :TODO: populate other avail MCI, e.g. current conf name
callback(null);
} }
}); ],
} else { function complete(err) {
return cb(null); cb(err);
} }
} );
}; });
}
this.setViewText = function(id, text) { };
const v = self.viewControllers.areaList.getView(id);
if(v) {
v.setText(text);
}
};
}
require('util').inherits(MessageConfListModule, MenuModule);
MessageConfListModule.prototype.mciReady = function(mciData, cb) {
var self = this;
const vc = self.viewControllers.areaList = new ViewController( { client : self.client } );
async.series(
[
function callParentMciReady(callback) {
MessageConfListModule.super_.prototype.mciReady.call(this, mciData, callback);
},
function loadFromConfig(callback) {
let loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
formId : 0,
};
vc.loadFromMenuConfig(loadOpts, callback);
},
function populateConfListView(callback) {
const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}';
const focusListFormat = self.menuConfig.config.focusListFormat || listFormat;
const confListView = vc.getView(MCICodeIDs.ConfList);
let i = 1;
confListView.setItems(_.map(self.messageConfs, v => {
return stringFormat(listFormat, {
index : i++,
confTag : v.conf.confTag,
name : v.conf.name,
desc : v.conf.desc,
});
}));
i = 1;
confListView.setFocusItems(_.map(self.messageConfs, v => {
return stringFormat(focusListFormat, {
index : i++,
confTag : v.conf.confTag,
name : v.conf.name,
desc : v.conf.desc,
});
}));
confListView.redraw();
callback(null);
},
function populateTextViews(callback) {
// :TODO: populate other avail MCI, e.g. current conf name
callback(null);
}
],
function complete(err) {
cb(err);
}
);
};

View File

@ -2,10 +2,11 @@
'use strict'; 'use strict';
// ENiGMA½ // ENiGMA½
const MenuModule = require('../core/menu_module.js').MenuModule; const MenuModule = require('../core/menu_module.js').MenuModule;
const ViewController = require('../core/view_controller.js').ViewController; const ViewController = require('../core/view_controller.js').ViewController;
const messageArea = require('../core/message_area.js'); const messageArea = require('../core/message_area.js');
const stringFormat = require('../core/string_format.js'); const stringFormat = require('../core/string_format.js');
const MessageAreaConfTempSwitcher = require('../core/mod_mixins.js').MessageAreaConfTempSwitcher;
// deps // deps
const async = require('async'); const async = require('async');
@ -28,8 +29,6 @@ const moment = require('moment');
TL2 : Message info 1: { msgNumSelected, msgNumTotal } TL2 : Message info 1: { msgNumSelected, msgNumTotal }
*/ */
exports.getModule = MessageListModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'Message List', name : 'Message List',
desc : 'Module for listing/browsing available messages', desc : 'Module for listing/browsing available messages',
@ -41,218 +40,213 @@ const MCICodesIDs = {
MsgInfo1 : 2, // TL2 MsgInfo1 : 2, // TL2
}; };
function MessageListModule(options) { exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(MenuModule) {
MenuModule.call(this, options); constructor(options) {
super(options);
const self = this; const self = this;
const config = this.menuConfig.config; const config = this.menuConfig.config;
this.messageAreaTag = config.messageAreaTag; this.messageAreaTag = config.messageAreaTag;
if(options.extraArgs) { if(options.extraArgs) {
// //
// |extraArgs| can override |messageAreaTag| provided by config // |extraArgs| can override |messageAreaTag| provided by config
// as well as supply a pre-defined message list // as well as supply a pre-defined message list
// //
if(options.extraArgs.messageAreaTag) { if(options.extraArgs.messageAreaTag) {
this.messageAreaTag = options.extraArgs.messageAreaTag; this.messageAreaTag = options.extraArgs.messageAreaTag;
}
if(options.extraArgs.messageList) {
this.messageList = options.extraArgs.messageList;
}
} }
if(options.extraArgs.messageList) { this.menuMethods = {
this.messageList = options.extraArgs.messageList; selectMessage : function(formData, extraArgs, cb) {
} if(1 === formData.submitId) {
} self.initialFocusIndex = formData.value.message;
this.menuMethods = { const modOpts = {
selectMessage : function(formData, extraArgs, cb) { extraArgs : {
if(1 === formData.submitId) { messageAreaTag : self.messageAreaTag,
self.initialFocusIndex = formData.value.message; messageList : self.messageList,
messageIndex : formData.value.message,
const modOpts = { }
extraArgs : {
messageAreaTag : self.messageAreaTag,
messageList : self.messageList,
messageIndex : formData.value.message,
}
};
//
// Provide a serializer so we don't dump *huge* bits of information to the log
// due to the size of |messageList|. See https://github.com/trentm/node-bunyan/issues/189
//
modOpts.extraArgs.toJSON = function() {
const logMsgList = (this.messageList.length <= 4) ?
this.messageList :
this.messageList.slice(0, 2).concat(this.messageList.slice(-2));
return {
messageAreaTag : this.messageAreaTag,
apprevMessageList : logMsgList,
messageCount : this.messageList.length,
messageIndex : formData.value.message,
}; };
};
return self.gotoMenu(config.menuViewPost || 'messageAreaViewPost', modOpts, cb); //
} else { // Provide a serializer so we don't dump *huge* bits of information to the log
return cb(null); // due to the size of |messageList|. See https://github.com/trentm/node-bunyan/issues/189
} //
}, modOpts.extraArgs.toJSON = function() {
const logMsgList = (this.messageList.length <= 4) ?
this.messageList :
this.messageList.slice(0, 2).concat(this.messageList.slice(-2));
fullExit : function(formData, extraArgs, cb) { return {
self.menuResult = { fullExit : true }; messageAreaTag : this.messageAreaTag,
return self.prevMenu(cb); apprevMessageList : logMsgList,
} messageCount : this.messageList.length,
}; messageIndex : formData.value.message,
};
};
this.setViewText = function(id, text) { return self.gotoMenu(config.menuViewPost || 'messageAreaViewPost', modOpts, cb);
const v = self.viewControllers.allViews.getView(id);
if(v) {
v.setText(text);
}
};
}
require('util').inherits(MessageListModule, MenuModule);
require('../core/mod_mixins.js').MessageAreaConfTempSwitcher.call(MessageListModule.prototype);
MessageListModule.prototype.enter = function() {
MessageListModule.super_.prototype.enter.call(this);
//
// Config can specify |messageAreaTag| else it comes from
// the user's current area
//
if(this.messageAreaTag) {
this.tempMessageConfAndAreaSwitch(this.messageAreaTag);
} else {
this.messageAreaTag = this.messageAreaTag = this.client.user.properties.message_area_tag;
}
};
MessageListModule.prototype.leave = function() {
this.tempMessageConfAndAreaRestore();
MessageListModule.super_.prototype.leave.call(this);
};
MessageListModule.prototype.mciReady = function(mciData, cb) {
const self = this;
const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
async.series(
[
function callParentMciReady(callback) {
MessageListModule.super_.prototype.mciReady.call(self, mciData, callback);
},
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu
};
return vc.loadFromMenuConfig(loadOpts, callback);
},
function fetchMessagesInArea(callback) {
//
// Config can supply messages else we'll need to populate the list now
//
if(_.isArray(self.messageList)) {
return callback(0 === self.messageList.length ? new Error('No messages in area') : null);
}
messageArea.getMessageListForArea( { client : self.client }, self.messageAreaTag, function msgs(err, msgList) {
if(!msgList || 0 === msgList.length) {
return callback(new Error('No messages in area'));
}
self.messageList = msgList;
return callback(err);
});
},
function getLastReadMesageId(callback) {
messageArea.getMessageAreaLastReadId(self.client.user.userId, self.messageAreaTag, function lastRead(err, lastReadId) {
self.lastReadId = lastReadId || 0;
return callback(null); // ignore any errors, e.g. missing value
});
},
function updateMessageListObjects(callback) {
const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM Do';
const newIndicator = self.menuConfig.config.newIndicator || '*';
const regIndicator = new Array(newIndicator.length + 1).join(' '); // fill with space to avoid draw issues
let msgNum = 1;
self.messageList.forEach( (listItem, index) => {
listItem.msgNum = msgNum++;
listItem.ts = moment(listItem.modTimestamp).format(dateTimeFormat);
listItem.newIndicator = listItem.messageId > self.lastReadId ? newIndicator : regIndicator;
if(_.isUndefined(self.initialFocusIndex) && listItem.messageId > self.lastReadId) {
self.initialFocusIndex = index;
}
});
return callback(null);
},
function populateList(callback) {
const msgListView = vc.getView(MCICodesIDs.MsgList);
const listFormat = self.menuConfig.config.listFormat || '{msgNum} - {subject} - {toUserName}';
const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; // :TODO: default change color here
const messageInfo1Format = self.menuConfig.config.messageInfo1Format || '{msgNumSelected} / {msgNumTotal}';
// :TODO: This can take a very long time to load large lists. What we need is to implement the "owner draw" concept in
// which items are requested (e.g. their format at least) *as-needed* vs trying to get the format for all of them at once
msgListView.setItems(_.map(self.messageList, listEntry => {
return stringFormat(listFormat, listEntry);
}));
msgListView.setFocusItems(_.map(self.messageList, listEntry => {
return stringFormat(focusListFormat, listEntry);
}));
msgListView.on('index update', idx => {
self.setViewText(
MCICodesIDs.MsgInfo1,
stringFormat(messageInfo1Format, { msgNumSelected : (idx + 1), msgNumTotal : self.messageList.length } ));
});
if(self.initialFocusIndex > 0) {
// note: causes redraw()
msgListView.setFocusItemIndex(self.initialFocusIndex);
} else { } else {
msgListView.redraw(); return cb(null);
} }
},
return callback(null); fullExit : function(formData, extraArgs, cb) {
}, self.menuResult = { fullExit : true };
function drawOtherViews(callback) { return self.prevMenu(cb);
const messageInfo1Format = self.menuConfig.config.messageInfo1Format || '{msgNumSelected} / {msgNumTotal}';
self.setViewText(
MCICodesIDs.MsgInfo1,
stringFormat(messageInfo1Format, { msgNumSelected : self.initialFocusIndex + 1, msgNumTotal : self.messageList.length } ));
return callback(null);
},
],
err => {
if(err) {
self.client.log.error( { error : err.message }, 'Error loading message list');
} }
return cb(err); };
}
enter() {
super.enter();
//
// Config can specify |messageAreaTag| else it comes from
// the user's current area
//
if(this.messageAreaTag) {
this.tempMessageConfAndAreaSwitch(this.messageAreaTag);
} else {
this.messageAreaTag = this.client.user.properties.message_area_tag;
} }
); }
};
MessageListModule.prototype.getSaveState = function() { leave() {
return { initialFocusIndex : this.initialFocusIndex }; this.tempMessageConfAndAreaRestore();
}; super.leave();
}
MessageListModule.prototype.restoreSavedState = function(savedState) { mciReady(mciData, cb) {
if(savedState) { super.mciReady(mciData, err => {
this.initialFocusIndex = savedState.initialFocusIndex; if(err) {
return cb(err);
}
const self = this;
const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
async.series(
[
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu
};
return vc.loadFromMenuConfig(loadOpts, callback);
},
function fetchMessagesInArea(callback) {
//
// Config can supply messages else we'll need to populate the list now
//
if(_.isArray(self.messageList)) {
return callback(0 === self.messageList.length ? new Error('No messages in area') : null);
}
messageArea.getMessageListForArea( { client : self.client }, self.messageAreaTag, function msgs(err, msgList) {
if(!msgList || 0 === msgList.length) {
return callback(new Error('No messages in area'));
}
self.messageList = msgList;
return callback(err);
});
},
function getLastReadMesageId(callback) {
messageArea.getMessageAreaLastReadId(self.client.user.userId, self.messageAreaTag, function lastRead(err, lastReadId) {
self.lastReadId = lastReadId || 0;
return callback(null); // ignore any errors, e.g. missing value
});
},
function updateMessageListObjects(callback) {
const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM Do';
const newIndicator = self.menuConfig.config.newIndicator || '*';
const regIndicator = new Array(newIndicator.length + 1).join(' '); // fill with space to avoid draw issues
let msgNum = 1;
self.messageList.forEach( (listItem, index) => {
listItem.msgNum = msgNum++;
listItem.ts = moment(listItem.modTimestamp).format(dateTimeFormat);
listItem.newIndicator = listItem.messageId > self.lastReadId ? newIndicator : regIndicator;
if(_.isUndefined(self.initialFocusIndex) && listItem.messageId > self.lastReadId) {
self.initialFocusIndex = index;
}
});
return callback(null);
},
function populateList(callback) {
const msgListView = vc.getView(MCICodesIDs.MsgList);
const listFormat = self.menuConfig.config.listFormat || '{msgNum} - {subject} - {toUserName}';
const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; // :TODO: default change color here
const messageInfo1Format = self.menuConfig.config.messageInfo1Format || '{msgNumSelected} / {msgNumTotal}';
// :TODO: This can take a very long time to load large lists. What we need is to implement the "owner draw" concept in
// which items are requested (e.g. their format at least) *as-needed* vs trying to get the format for all of them at once
msgListView.setItems(_.map(self.messageList, listEntry => {
return stringFormat(listFormat, listEntry);
}));
msgListView.setFocusItems(_.map(self.messageList, listEntry => {
return stringFormat(focusListFormat, listEntry);
}));
msgListView.on('index update', idx => {
self.setViewText(
'allViews',
MCICodesIDs.MsgInfo1,
stringFormat(messageInfo1Format, { msgNumSelected : (idx + 1), msgNumTotal : self.messageList.length } ));
});
if(self.initialFocusIndex > 0) {
// note: causes redraw()
msgListView.setFocusItemIndex(self.initialFocusIndex);
} else {
msgListView.redraw();
}
return callback(null);
},
function drawOtherViews(callback) {
const messageInfo1Format = self.menuConfig.config.messageInfo1Format || '{msgNumSelected} / {msgNumTotal}';
self.setViewText(
'allViews',
MCICodesIDs.MsgInfo1,
stringFormat(messageInfo1Format, { msgNumSelected : self.initialFocusIndex + 1, msgNumTotal : self.messageList.length } ));
return callback(null);
},
],
err => {
if(err) {
self.client.log.error( { error : err.message }, 'Error loading message list');
}
return cb(err);
}
);
});
}
getSaveState() {
return { initialFocusIndex : this.initialFocusIndex };
}
restoreSavedState(savedState) {
if(savedState) {
this.initialFocusIndex = savedState.initialFocusIndex;
}
}
getMenuResult() {
return this.menuResult;
} }
}; };
MessageListModule.prototype.getMenuResult = function() {
return this.menuResult;
};

View File

@ -9,8 +9,6 @@ const login = require('../core/system_menu_method.js').login;
const Config = require('../core/config.js').config; const Config = require('../core/config.js').config;
const messageArea = require('../core/message_area.js'); const messageArea = require('../core/message_area.js');
exports.getModule = NewUserAppModule;
exports.moduleInfo = { exports.moduleInfo = {
name : 'NUA', name : 'NUA',
desc : 'New User Application', desc : 'New User Application',
@ -23,123 +21,124 @@ const MciViewIds = {
errMsg : 11, errMsg : 11,
}; };
function NewUserAppModule(options) { exports.getModule = class NewUserAppModule extends MenuModule {
MenuModule.call(this, options);
constructor(options) {
super(options);
const self = this;
const self = this; this.menuMethods = {
//
// Validation stuff
//
validatePassConfirmMatch : function(data, cb) {
const passwordView = self.viewControllers.menu.getView(MciViewIds.password);
return cb(passwordView.getData() === data ? null : new Error('Passwords do not match'));
},
this.menuMethods = { viewValidationListener : function(err, cb) {
// const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg);
// Validation stuff let newFocusId;
//
validatePassConfirmMatch : function(data, cb) { if(err) {
const passwordView = self.viewControllers.menu.getView(MciViewIds.password); errMsgView.setText(err.message);
return cb(passwordView.getData() === data ? null : new Error('Passwords do not match')); err.view.clearText();
},
viewValidationListener : function(err, cb) { if(err.view.getId() === MciViewIds.confirm) {
const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg); newFocusId = MciViewIds.password;
let newFocusId; self.viewControllers.menu.getView(MciViewIds.password).clearText();
}
if(err) { } else {
errMsgView.setText(err.message); errMsgView.clearText();
err.view.clearText();
if(err.view.getId() === MciViewIds.confirm) {
newFocusId = MciViewIds.password;
self.viewControllers.menu.getView(MciViewIds.password).clearText();
} }
} else {
errMsgView.clearText();
}
return cb(newFocusId); return cb(newFocusId);
}, },
//
// Submit handlers
//
submitApplication : function(formData, extraArgs, cb) {
const newUser = new user.User();
newUser.username = formData.value.username;
// //
// We have to disable ACS checks for initial default areas as the user is not yet ready // Submit handlers
// //
let confTag = messageArea.getDefaultMessageConferenceTag(self.client, true); // true=disableAcsCheck submitApplication : function(formData, extraArgs, cb) {
let areaTag = messageArea.getDefaultMessageAreaTagByConfTag(self.client, confTag, true); // true=disableAcsCheck const newUser = new user.User();
// can't store undefined! newUser.username = formData.value.username;
confTag = confTag || '';
areaTag = areaTag || '';
newUser.properties = {
real_name : formData.value.realName,
birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), // :TODO: Use moment & explicit ISO string format
sex : formData.value.sex,
location : formData.value.location,
affiliation : formData.value.affils,
email_address : formData.value.email,
web_address : formData.value.web,
account_created : new Date().toISOString(), // :TODO: Use moment & explicit ISO string format
message_conf_tag : confTag,
message_area_tag : areaTag,
term_height : self.client.term.termHeight, //
term_width : self.client.term.termWidth, // We have to disable ACS checks for initial default areas as the user is not yet ready
//
let confTag = messageArea.getDefaultMessageConferenceTag(self.client, true); // true=disableAcsCheck
let areaTag = messageArea.getDefaultMessageAreaTagByConfTag(self.client, confTag, true); // true=disableAcsCheck
// :TODO: Other defaults // can't store undefined!
// :TODO: should probably have a place to create defaults/etc. confTag = confTag || '';
}; areaTag = areaTag || '';
newUser.properties = {
real_name : formData.value.realName,
birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), // :TODO: Use moment & explicit ISO string format
sex : formData.value.sex,
location : formData.value.location,
affiliation : formData.value.affils,
email_address : formData.value.email,
web_address : formData.value.web,
account_created : new Date().toISOString(), // :TODO: Use moment & explicit ISO string format
message_conf_tag : confTag,
message_area_tag : areaTag,
if('*' === Config.defaults.theme) { term_height : self.client.term.termHeight,
newUser.properties.theme_id = theme.getRandomTheme(); term_width : self.client.term.termWidth,
} else {
newUser.properties.theme_id = Config.defaults.theme;
}
// :TODO: User.create() should validate email uniqueness!
newUser.create( { password : formData.value.password }, err => {
if(err) {
self.client.log.info( { error : err, username : formData.value.username }, 'New user creation failed');
self.gotoMenu(extraArgs.error, err => { // :TODO: Other defaults
if(err) { // :TODO: should probably have a place to create defaults/etc.
return self.prevMenu(cb); };
}
return cb(null); if('*' === Config.defaults.theme) {
}); newUser.properties.theme_id = theme.getRandomTheme();
} else { } else {
self.client.log.info( { username : formData.value.username, userId : newUser.userId }, 'New user created'); newUser.properties.theme_id = Config.defaults.theme;
// Cache SysOp information now
// :TODO: Similar to bbs.js. DRY
if(newUser.isSysOp()) {
Config.general.sysOp = {
username : formData.value.username,
properties : newUser.properties,
};
}
if(user.User.AccountStatus.inactive === self.client.user.properties.account_status) {
return self.gotoMenu(extraArgs.inactive, cb);
} else {
//
// If active now, we need to call login() to authenticate
//
return login(self, formData, extraArgs, cb);
}
} }
});
}, // :TODO: User.create() should validate email uniqueness!
}; newUser.create( { password : formData.value.password }, err => {
} if(err) {
self.client.log.info( { error : err, username : formData.value.username }, 'New user creation failed');
require('util').inherits(NewUserAppModule, MenuModule); self.gotoMenu(extraArgs.error, err => {
if(err) {
return self.prevMenu(cb);
}
return cb(null);
});
} else {
self.client.log.info( { username : formData.value.username, userId : newUser.userId }, 'New user created');
NewUserAppModule.prototype.mciReady = function(mciData, cb) { // Cache SysOp information now
this.standardMCIReadyHandler(mciData, cb); // :TODO: Similar to bbs.js. DRY
}; if(newUser.isSysOp()) {
Config.general.sysOp = {
username : formData.value.username,
properties : newUser.properties,
};
}
if(user.User.AccountStatus.inactive === self.client.user.properties.account_status) {
return self.gotoMenu(extraArgs.inactive, cb);
} else {
//
// If active now, we need to call login() to authenticate
//
return login(self, formData, extraArgs, cb);
}
}
});
},
};
}
mciReady(mciData, cb) {
return this.standardMCIReadyHandler(mciData, cb);
}
};

View File

@ -31,9 +31,7 @@ exports.moduleInfo = {
packageName : 'codes.l33t.enigma.onelinerz', packageName : 'codes.l33t.enigma.onelinerz',
}; };
exports.getModule = OnelinerzModule; const MciViewIds = {
const MciCodeIds = {
ViewForm : { ViewForm : {
Entries : 1, Entries : 1,
AddPrompt : 2, AddPrompt : 2,
@ -50,20 +48,52 @@ const FormIds = {
Add : 1, Add : 1,
}; };
function OnelinerzModule(options) { exports.getModule = class OnelinerzModule extends MenuModule {
MenuModule.call(this, options); constructor(options) {
super(options);
const self = this; const self = this;
const config = this.menuConfig.config;
this.initSequence = function() { this.menuMethods = {
viewAddScreen : function(formData, extraArgs, cb) {
return self.displayAddScreen(cb);
},
addEntry : function(formData, extraArgs, cb) {
if(_.isString(formData.value.oneliner) && formData.value.oneliner.length > 0) {
const oneliner = formData.value.oneliner.trim(); // remove any trailing ws
self.storeNewOneliner(oneliner, err => {
if(err) {
self.client.log.warn( { error : err.message }, 'Failed saving oneliner');
}
self.clearAddForm();
return self.displayViewScreen(true, cb); // true=cls
});
} else {
// empty message - treat as if cancel was hit
return self.displayViewScreen(true, cb); // true=cls
}
},
cancelAdd : function(formData, extraArgs, cb) {
self.clearAddForm();
return self.displayViewScreen(true, cb); // true=cls
}
};
}
initSequence() {
const self = this;
async.series( async.series(
[ [
function beforeDisplayArt(callback) { function beforeDisplayArt(callback) {
self.beforeArt(callback); return self.beforeArt(callback);
}, },
function display(callback) { function display(callback) {
self.displayViewScreen(false, callback); return self.displayViewScreen(false, callback);
} }
], ],
err => { err => {
@ -73,9 +103,11 @@ function OnelinerzModule(options) {
self.finishedLoading(); self.finishedLoading();
} }
); );
}; }
displayViewScreen(clearScreen, cb) {
const self = this;
this.displayViewScreen = function(clearScreen, cb) {
async.waterfall( async.waterfall(
[ [
function clearAndDisplayArt(callback) { function clearAndDisplayArt(callback) {
@ -88,7 +120,7 @@ function OnelinerzModule(options) {
} }
theme.displayThemedAsset( theme.displayThemedAsset(
config.art.entries, self.menuConfig.config.art.entries,
self.client, self.client,
{ font : self.menuConfig.font, trailingLF : false }, { font : self.menuConfig.font, trailingLF : false },
(err, artData) => { (err, artData) => {
@ -112,12 +144,12 @@ function OnelinerzModule(options) {
return vc.loadFromMenuConfig(loadOpts, callback); return vc.loadFromMenuConfig(loadOpts, callback);
} else { } else {
self.viewControllers.view.setFocus(true); self.viewControllers.view.setFocus(true);
self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt).redraw(); self.viewControllers.view.getView(MciViewIds.ViewForm.AddPrompt).redraw();
return callback(null); return callback(null);
} }
}, },
function fetchEntries(callback) { function fetchEntries(callback) {
const entriesView = self.viewControllers.view.getView(MciCodeIds.ViewForm.Entries); const entriesView = self.viewControllers.view.getView(MciViewIds.ViewForm.Entries);
const limit = entriesView.dimens.height; const limit = entriesView.dimens.height;
let entries = []; let entries = [];
@ -142,8 +174,8 @@ function OnelinerzModule(options) {
); );
}, },
function populateEntries(entriesView, entries, callback) { function populateEntries(entriesView, entries, callback) {
const listFormat = config.listFormat || '{username}@{ts}: {oneliner}';// :TODO: should be userName to be consistent const listFormat = self.menuConfig.config.listFormat || '{username}@{ts}: {oneliner}';// :TODO: should be userName to be consistent
const tsFormat = config.timestampFormat || 'ddd h:mma'; const tsFormat = self.menuConfig.config.timestampFormat || 'ddd h:mma';
entriesView.setItems(entries.map( e => { entriesView.setItems(entries.map( e => {
return stringFormat(listFormat, { return stringFormat(listFormat, {
@ -159,7 +191,7 @@ function OnelinerzModule(options) {
return callback(null); return callback(null);
}, },
function finalPrep(callback) { function finalPrep(callback) {
const promptView = self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt); const promptView = self.viewControllers.view.getView(MciViewIds.ViewForm.AddPrompt);
promptView.setFocusItemIndex(1); // default to NO promptView.setFocusItemIndex(1); // default to NO
return callback(null); return callback(null);
} }
@ -170,9 +202,11 @@ function OnelinerzModule(options) {
} }
} }
); );
}; }
displayAddScreen(cb) {
const self = this;
this.displayAddScreen = function(cb) {
async.waterfall( async.waterfall(
[ [
function clearAndDisplayArt(callback) { function clearAndDisplayArt(callback) {
@ -180,7 +214,7 @@ function OnelinerzModule(options) {
self.client.term.rawWrite(ansi.resetScreen()); self.client.term.rawWrite(ansi.resetScreen());
theme.displayThemedAsset( theme.displayThemedAsset(
config.art.add, self.menuConfig.config.art.add,
self.client, self.client,
{ font : self.menuConfig.font }, { font : self.menuConfig.font },
(err, artData) => { (err, artData) => {
@ -205,7 +239,7 @@ function OnelinerzModule(options) {
} else { } else {
self.viewControllers.add.setFocus(true); self.viewControllers.add.setFocus(true);
self.viewControllers.add.redrawAll(); self.viewControllers.add.redrawAll();
self.viewControllers.add.switchFocus(MciCodeIds.AddForm.NewEntry); self.viewControllers.add.switchFocus(MciViewIds.AddForm.NewEntry);
return callback(null); return callback(null);
} }
} }
@ -216,80 +250,50 @@ function OnelinerzModule(options) {
} }
} }
); );
}; }
this.clearAddForm = function() { clearAddForm() {
const newEntryView = self.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry); this.setViewText('add', MciViewIds.AddForm.NewEntry, '');
const previewView = self.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview); this.setViewText('add', MciViewIds.AddForm.EntryPreview, '');
}
newEntryView.setText(''); initDatabase(cb) {
const self = this;
// preview is optional
if(previewView) {
previewView.setText('');
}
};
this.menuMethods = {
viewAddScreen : function(formData, extraArgs, cb) {
return self.displayAddScreen(cb);
},
addEntry : function(formData, extraArgs, cb) {
if(_.isString(formData.value.oneliner) && formData.value.oneliner.length > 0) {
const oneliner = formData.value.oneliner.trim(); // remove any trailing ws
self.storeNewOneliner(oneliner, err => {
if(err) {
self.client.log.warn( { error : err.message }, 'Failed saving oneliner');
}
self.clearAddForm();
return self.displayViewScreen(true, cb); // true=cls
});
} else {
// empty message - treat as if cancel was hit
return self.displayViewScreen(true, cb); // true=cls
}
},
cancelAdd : function(formData, extraArgs, cb) {
self.clearAddForm();
return self.displayViewScreen(true, cb); // true=cls
}
};
this.initDatabase = function(cb) {
async.series( async.series(
[ [
function openDatabase(callback) { function openDatabase(callback) {
self.db = new sqlite3.Database( self.db = new sqlite3.Database(
getModDatabasePath(exports.moduleInfo), getModDatabasePath(exports.moduleInfo),
callback err => {
return callback(err);
}
); );
}, },
function createTables(callback) { function createTables(callback) {
self.db.serialize( () => { self.db.run(
self.db.run( `CREATE TABLE IF NOT EXISTS onelinerz (
`CREATE TABLE IF NOT EXISTS onelinerz ( id INTEGER PRIMARY KEY,
id INTEGER PRIMARY KEY, user_id INTEGER_NOT NULL,
user_id INTEGER_NOT NULL, user_name VARCHAR NOT NULL,
user_name VARCHAR NOT NULL, oneliner VARCHAR NOT NULL,
oneliner VARCHAR NOT NULL, timestamp DATETIME NOT NULL
timestamp DATETIME NOT NULL );`
)` ,
); err => {
return callback(err);
}); });
callback(null);
} }
], ],
cb err => {
return cb(err);
}
); );
}; }
this.storeNewOneliner = function(oneliner, cb) { storeNewOneliner(oneliner, cb) {
const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ'); const self = this;
const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ');
async.series( async.series(
[ [
@ -315,15 +319,15 @@ function OnelinerzModule(options) {
); );
} }
], ],
cb err => {
return cb(err);
}
); );
}; }
}
require('util').inherits(OnelinerzModule, MenuModule); beforeArt(cb) {
super.beforeArt(err => {
OnelinerzModule.prototype.beforeArt = function(cb) { return err ? cb(err) : this.initDatabase(cb);
OnelinerzModule.super_.prototype.beforeArt.call(this, err => { });
return err ? cb(err) : this.initDatabase(cb); }
});
}; };

View File

@ -1,8 +1,34 @@
{ {
/*
./\/\.' ENiGMA½ Prompt Configuration -/--/-------- - -- -
_____________________ _____ ____________________ __________\_ /
\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp!
// __|___// | \// |// | \// | | \// \ /___ /_____
/____ _____| __________ ___|__| ____| \ / _____ \
---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/
/__ _\
<*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/
-------------------------------------------------------------------------------
This configuration is in HJSON (http://hjson.org/) format. Strict to-spec
JSON is also perfectly valid. Use 'hjson' from npm to convert to/from JSON.
See http://hjson.org/ for more information and syntax.
If you haven't yet, copy the conents of this file to something like
sick_board_prompt.hjson. Point to it via config.hjson using the
'general.promptFile' key:
general: { promptFile: "sick_board_prompt.hjson" }
*/
// :TODO: this entire file needs cleaned up a LOT // :TODO: this entire file needs cleaned up a LOT
// :TODO: Convert all of this to HJSON // :TODO: Convert all of this to HJSON
"prompts" : { prompts: {
"userCredentials" : { userCredentials: {
"art" : "usercred", "art" : "usercred",
"mci" : { "mci" : {
"ET1" : { "ET1" : {
@ -106,8 +132,47 @@
} }
} }
}, },
///////////////////////////////////////////////////////////////////////
// File Base Related
///////////////////////////////////////////////////////////////////////
fileMenuCommand: {
art: FILPMPT
mci: {
TL1: {}
ET2: {
argName: menuOption
width: 20
maxLength: 20
textStyle: upper
focus: true
}
}
}
fileBaseRateEntryPrompt: {
art: RATEFILE
mci: {
SM1: {
argName: rating
items: [ "-----", "*----", "**---", "***--", "****-", "*****" ]
}
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
]
}
/////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////
// Standard / Required // Standard / Required
//
// Prompts in this section are considered "standard" and are required
// to be present
//
/////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////
pause: { pause: {
// //

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