Merge branch 'master' of ssh://numinibsd/git/base/enigma-bbs

This commit is contained in:
Bryan Ashby 2017-02-16 21:16:46 -07:00
commit 2bf22e722f
130 changed files with 11257 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**
**Environment**
- [ ] I am using Node.js v4.x or higher
- [ ] I am using Node.js v6.x or higher
- [ ] `npm install` reports success
- Actual Node.js version (`node --version`):
- 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.
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
* Multi platform: Anywhere Node.js runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows)
* Multi node support
* Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows)
* Unlimited multi node support (for all those BBS "callers"!)
* **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods
* 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
* [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
* Pipe codes (ala Renegade)
* [SQLite](http://sqlite.org/) storage of users and message areas
* Renegade style pipe color codes
* [SQLite](http://sqlite.org/) storage of users, message areas, and so on
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption
* Door support 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
* 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
* More ES6+ usage, and **documentation**!
* File areas
* More ACS support coverage
* 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
* 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
* 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**
* Email: bryan -at- l33t.codes
* Facebook ENiGMA½ group
* ENiGMA discussion on [HappyNet](http://andrew.homeunix.org/doku.php?id=happynet)
* [Facebook ENiGMA½ group](https://www.facebook.com/groups/enigmabbs/)
* ENiGMA discussion on [fsxNet](http://bbs.geek.nz/#fsxNet)
## Terminal Clients
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. 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
* 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!
* 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/)!
* [Apam](https://github.com/apamment) of HappyLand BBS and [HappyNet](http://andrew.homeunix.org/doku.php?id=happynet)!
## 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.
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) {
return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead);
}
@ -33,6 +36,21 @@ class ACS {
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) {
assert(_.isArray(condArray));
assert(_.isString(memberName));
@ -59,6 +77,10 @@ class ACS {
ACS.Defaults = {
MessageAreaRead : 'GM[users]',
MessageConfRead : 'GM[users]',
FileAreaRead : 'GM[users]',
FileAreaWrite : 'GM[sysops]',
FileAreaDownload : 'GM[users]',
};
module.exports = ACS;

View File

@ -48,8 +48,10 @@ function ANSIEscapeParser(options) {
self.row = Math.max(self.row, 1);
self.row = Math.min(self.row, self.termHeight);
self.emit('move cursor', self.column, self.row);
self.rowUpdated();
// self.emit('move cursor', self.column, self.row);
self.positionUpdated();
//self.rowUpdated();
};
self.saveCursorPosition = function() {
@ -63,7 +65,9 @@ function ANSIEscapeParser(options) {
self.row = self.savedPosition.row;
self.column = self.savedPosition.column;
delete self.savedPosition;
self.rowUpdated();
self.positionUpdated();
// self.rowUpdated();
};
self.clearScreen = function() {
@ -71,11 +75,76 @@ function ANSIEscapeParser(options) {
self.emit('clear screen');
};
/*
self.rowUpdated = function() {
self.emit('row update', self.row + self.scrollBack);
};*/
self.positionUpdated = function() {
self.emit('position update', self.row, self.column);
};
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 len = text.length;
@ -88,29 +157,31 @@ function ANSIEscapeParser(options) {
case LF :
self.row++;
self.rowUpdated();
self.positionUpdated();
//self.rowUpdated();
break;
default :
// wrap
if(self.column === self.termWidth) {
if(self.column > self.termWidth) {
self.column = 1;
self.row++;
self.rowUpdated();
//self.rowUpdated();
self.positionUpdated();
} else {
self.column++;
self.column += 1;
}
break;
}
if(self.row === 26) { // :TODO: should be termHeight + 1 ?
self.scrollBack++;
self.row--;
self.rowUpdated();
if(self.row === self.termHeight) {
self.scrollBack += 1;
self.row -= 1;
self.positionUpdated();
}
}
//self.emit('chunk', text);
self.emit('literal', text);
}
@ -155,7 +226,7 @@ function ANSIEscapeParser(options) {
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 = {
// 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,
stop : false,
};
@ -201,7 +272,11 @@ function ANSIEscapeParser(options) {
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.
var pos;
var match;
@ -308,40 +383,45 @@ function ANSIEscapeParser(options) {
*/
function escape(opCode, args) {
var arg;
var i;
var len;
let arg;
switch(opCode) {
// cursor up
case 'A' :
arg = args[0] || 1;
//arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(0, -arg);
break;
// cursor down
case 'B' :
arg = args[0] || 1;
//arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(0, arg);
break;
// cursor forward/right
case 'C' :
arg = args[0] || 1;
//arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(arg, 0);
break;
// cursor back/left
case 'D' :
arg = args[0] || 1;
//arg = args[0] || 1;
arg = isNaN(args[0]) ? 1 : args[0];
self.moveCursor(-arg, 0);
break;
case 'f' : // horiz & vertical
case 'H' : // cursor position
self.row = args[0] || 1;
self.column = args[1] || 1;
self.rowUpdated();
//self.row = args[0] || 1;
//self.column = args[1] || 1;
self.row = isNaN(args[0]) ? 1 : args[0];
self.column = isNaN(args[1]) ? 1 : args[1];
//self.rowUpdated();
self.positionUpdated();
break;
// save position
@ -356,7 +436,7 @@ function ANSIEscapeParser(options) {
// set graphic rendition
case 'm' :
for(i = 0, len = args.length; i < len; ++i) {
for(let i = 0, len = args.length; i < len; ++i) {
arg = args[i];
if(ANSIEscapeParser.foregroundColors[arg]) {
@ -410,12 +490,13 @@ function ANSIEscapeParser(options) {
}
}
}
break; // m
break;
// :TODO: s, u, K
// erase display/screen
case 'J' :
// :TODO: Handle others
// :TODO: Handle other 'J' types!
if(2 === args[0]) {
self.clearScreen();
}

View File

@ -4,12 +4,44 @@
// ENiGMA½
const Config = require('./config.js').config;
const stringFormat = require('./string_format.js');
const Errors = require('./enig_error.js').Errors;
// base/modules
const fs = require('fs');
const _ = require('lodash');
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 {
constructor() {
@ -17,95 +49,120 @@ module.exports = class ArchiveUtil {
this.longestSignature = 0;
}
// singleton access
static getInstance() {
if(!archiveUtil) {
archiveUtil = new ArchiveUtil();
archiveUtil.init();
}
return archiveUtil;
}
init() {
//
// Load configuration
//
if(_.has(Config, 'archivers')) {
Object.keys(Config.archivers).forEach(archKey => {
const arch = Config.archivers[archKey];
if(!_.isString(arch.sig) ||
!_.isString(arch.compressCmd) ||
!_.isString(arch.decompressCmd) ||
!_.isArray(arch.compressArgs) ||
!_.isArray(arch.decompressArgs))
{
// :TODO: log warning
return;
if(_.has(Config, 'archives.archivers')) {
Object.keys(Config.archives.archivers).forEach(archKey => {
const archConfig = Config.archives.archivers[archKey];
const archiver = new Archiver(archConfig);
if(!archiver.ok()) {
// :TODO: Log warning - bad archiver/config
}
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;
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) {
if(!archType) {
if(!archType || 0 === archType.length) {
return;
}
archType = archType.toLowerCase();
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) {
return this.getArchiver(archType) ? true : false;
}
detectTypeWithBuf(buf, cb) {
// :TODO: implement me!
}
detectType(path, cb) {
if(!_.has(Config, 'archives.formats')) {
return cb(Errors.DoesNotExist('No formats configured'));
}
fs.open(path, 'r', (err, fd) => {
if(err) {
cb(err);
return;
return cb(err);
}
let buf = new Buffer(this.longestSignature);
const buf = new Buffer(this.longestSignature);
fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => {
if(err) {
return cb(err);
}
// return first match
const detected = _.findKey(this.archivers, arch => {
const lenNeeded = arch.offset + arch.sig.length;
const archFormat = _.findKey(Config.archives.formats, archFormat => {
const lenNeeded = archFormat.offset + archFormat.sig.length;
if(bytesRead < lenNeeded) {
return false;
}
const comp = buf.slice(arch.offset, arch.offset + arch.sig.length);
return (arch.sig.equals(comp));
const comp = buf.slice(archFormat.offset, archFormat.offset + archFormat.sig.length);
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,
// so we have this horrible, horrible hack:
let err;
comp.once('data', d => {
proc.once('data', d => {
if(_.isString(d) && d.startsWith('execvp(3) failed.: No such file or directory')) {
err = new Error(`${action} failed: ${d.trim()}`);
}
});
comp.once('exit', exitCode => {
proc.once('exit', exitCode => {
if(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}`));
}
let args = _.clone(archiver.compressArgs); // don't muck with orig
for(let i = 0; i < args.length; ++i) {
args[i] = stringFormat(args[i], {
archivePath : archivePath,
fileList : files.join(' '),
});
}
const fmtObj = {
archivePath : archivePath,
fileList : files.join(' '), // :TODO: probably need same hack as extractTo here!
};
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);
if(!archiver) {
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() {

View File

@ -7,7 +7,6 @@ const miscUtil = require('./misc_util.js');
const ansi = require('./ansi_term.js');
const aep = require('./ansi_escape_parser.js');
const sauce = require('./sauce.js');
const farmhash = require('farmhash');
// deps
const fs = require('fs');
@ -15,6 +14,7 @@ const paths = require('path');
const assert = require('assert');
const iconv = require('iconv-lite');
const _ = require('lodash');
const farmhash = require('farmhash');
exports.getArt = getArt;
exports.getArtFromPath = getArtFromPath;
@ -78,7 +78,7 @@ function getArtFromPath(path, options, cb) {
return iconv.decode(data, encoding);
} else {
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) {
return SUPPORTED_ART_TYPES[ext.toLowerCase()].defaultEncoding;
const artType = SUPPORTED_ART_TYPES[ext.toLowerCase()];
return artType ? artType.defaultEncoding : 'utf8';
}
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
@ -266,7 +270,7 @@ function display(client, art, options, cb) {
if(!options.disableMciCache && !mciMapFromCache) {
// cache our MCI findings...
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???
@ -290,7 +294,7 @@ function display(client, art, options, cb) {
if(mciMap) {
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 {
// no cached MCI info
mciMap = {};

View File

@ -10,12 +10,15 @@ const conf = require('./config.js');
const logger = require('./logger.js');
const database = require('./database.js');
const clientConns = require('./client_connections.js');
const resolvePath = require('./misc_util.js').resolvePath;
// deps
const async = require('async');
const util = require('util');
const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs;
const fs = require('fs');
const paths = require('path');
// our main entry point
exports.bbsMain = bbsMain;
@ -23,30 +26,38 @@ exports.bbsMain = bbsMain;
// object with various services we want to de-init/shutdown cleanly if possible
const initServices = {};
const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby';
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() {
async.waterfall(
[
function processArgs(callback) {
const args = process.argv.slice(2);
const argv = require('minimist')(process.argv.slice(2));
var configPath;
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];
}
}
if(argv.help) {
printHelpAndExit();
}
callback(null, configPath || conf.getDefaultPath(), _.isString(configPath));
const configOverridePath = argv.config;
return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath));
},
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
@ -71,14 +82,20 @@ function bbsMain() {
if(err) {
console.error('Error initializing: ' + util.inspect(err));
}
callback(err);
return callback(err);
});
},
function listenConnections(callback) {
startListening(callback);
}
],
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) {
console.error('Error initializing: ' + util.inspect(err));
}
@ -87,7 +104,9 @@ function bbsMain() {
}
function shutdownSystem() {
logger.log.info('Process interrupted, shutting down...');
const msg = 'Process interrupted. Shutting down...';
console.info(msg);
logger.log.info(msg);
async.series(
[
@ -100,21 +119,32 @@ function shutdownSystem() {
}
callback(null);
},
function stopListeningServers(callback) {
return require('./listening_server.js').shutdown( () => {
return callback(null); // ignore err
});
},
function stopEventScheduler(callback) {
if(initServices.eventScheduler) {
return initServices.eventScheduler.shutdown( () => {
callback(null); // ignore err
return callback(null); // ignore err
});
} else {
return callback(null);
}
},
function stopFileAreaWeb(callback) {
require('./file_area_web.js').startup( () => {
return callback(null); // ignore err
});
},
function stopMsgNetwork(callback) {
require('./msg_network.js').shutdown(callback);
}
],
() => {
process.exit();
console.info('Goodbye!');
return process.exit();
}
);
}
@ -208,6 +238,12 @@ function initialize(cb) {
function readyMessageNetworkSupport(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) {
const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule;
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 */
'use strict';
var TextView = require('./text_view.js').TextView;
var miscUtil = require('./misc_util.js');
var util = require('util');
var assert = require('assert');
const TextView = require('./text_view.js').TextView;
const miscUtil = require('./misc_util.js');
const util = require('util');
exports.ButtonView = ButtonView;
@ -20,7 +19,8 @@ function ButtonView(options) {
util.inherits(ButtonView, TextView);
ButtonView.prototype.onKeyPress = function(ch, key) {
if(' ' === ch) {
// allow space = submit
if(' ' === ch) {
this.emit('action', 'accept');
}

View File

@ -12,6 +12,7 @@ exports.getActiveConnections = getActiveConnections;
exports.getActiveNodeList = getActiveNodeList;
exports.addNewClient = addNewClient;
exports.removeClient = removeClient;
exports.getConnectionByUserId = getConnectionByUserId;
const 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 */
'use strict';
var miscUtil = require('./misc_util.js');
// ENiGMA½
const miscUtil = require('./misc_util.js');
var fs = require('fs');
var paths = require('path');
var async = require('async');
var _ = require('lodash');
var hjson = require('hjson');
var assert = require('assert');
// deps
const fs = require('fs');
const paths = require('path');
const async = require('async');
const _ = require('lodash');
const hjson = require('hjson');
const assert = require('assert');
exports.init = init;
exports.getDefaultPath = getDefaultPath;
exports.init = init;
exports.getDefaultPath = getDefaultPath;
function hasMessageConferenceAndArea(config) {
assert(_.isObject(config.messageConferences)); // we create one ourself!
@ -43,38 +45,45 @@ function init(configPath, cb) {
async.waterfall(
[
function loadUserConfig(callback) {
if(_.isString(configPath)) {
fs.readFile(configPath, { encoding : 'utf8' }, function configData(err, data) {
if(err) {
callback(err);
} else {
try {
var configJson = hjson.parse(data);
callback(null, configJson);
} catch(e) {
callback(e);
}
}
});
} else {
callback(null, { } );
if(!_.isString(configPath)) {
return callback(null, { } );
}
fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => {
if(err) {
return callback(err);
}
let configJson;
try {
configJson = hjson.parse(configData);
} catch(e) {
return callback(e);
}
return callback(null, configJson);
});
},
function mergeWithDefaultConfig(configJson, callback) {
var mergedConfig = _.merge(getDefaultConfig(), configJson, function mergeCustomizer(conf1, conf2) {
// Arrays should always concat
if(_.isArray(conf1)) {
// :TODO: look for collisions & override dupes
return conf1.concat(conf2);
const mergedConfig = _.mergeWith(
getDefaultConfig(),
configJson, (conf1, 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) {
//
// Various sections must now exist in config
//
// :TODO: Logic is broken here:
if(hasMessageConferenceAndArea(mergedConfig)) {
var msgAreasErr = new Error('Please create at least one message conference and area!');
msgAreasErr.code = 'EBADCONFIG';
@ -92,7 +101,7 @@ function init(configPath, cb) {
}
function getDefaultPath() {
var base = miscUtil.resolvePath('~/');
const base = miscUtil.resolvePath('~/');
if(base) {
// e.g. /home/users/joeuser/.config/enigma-bbs/config.hjson
return paths.join(base, '.config', 'enigma-bbs', 'config.hjson');
@ -211,17 +220,212 @@ function getDefaultConfig() {
}
},
archivers : {
zip : {
sig : '504b0304',
offset : 0,
compressCmd : '7z',
compressArgs : [ 'a', '-tzip', '{archivePath}', '{fileList}' ],
decompressCmd : '7z',
decompressArgs : [ 'e', '-o{extractPath}', '{archivePath}' ]
contentServers : {
web : {
domain : 'another-fine-enigma-bbs.org',
staticRoot : paths.join(__dirname, './../www'),
http : {
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 : {
//
@ -272,22 +476,60 @@ function getDefaultConfig() {
// areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|:
areaStoragePrefix : paths.join(__dirname, './../file_base/'),
maxDescFileByteSize : 471859, // ~1/4 MB
maxDescLongFileByteSize : 524288, // 1/2 MB
fileNamePatterns: {
shortDesc : [ '^FILE_ID\.DIZ$', '^DESC\.SDI$' ],
longDesc : [ '^.*\.NFO$', '^README\.1ST$', '^README\.TXT$' ],
// These are NOT case sensitive
// FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ
desc : [
'^FILE_ID\.DIZ$', '^DESC\.SDI$', '^DESCRIPT\.ION$', '^FILE\.DES$', '$FILE\.SDI$', '^DISK\.ID$'
],
// 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: {
message_attachment : {
name : 'Message attachments',
desc : 'File attachments to messages',
system_message_attachment : {
name : 'Message attachments',
desc : 'File attachments to messages',
storageTags : 'sys_msg_attach', // may be string or array of strings
}
}
},
eventScheduler : {
events : {
trimMessageAreas : {
// may optionally use [or ]@watch:/path/to/file

View File

@ -40,11 +40,11 @@ function ConfigCache() {
this.gaze.on('changed', function fileChanged(filePath) {
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) {
if(err) {
Log.error( { error : err, filePath : filePath } , 'Error recaching HJSON config');
Log.error( { error : err.message, path : filePath } , 'Failed re-caching configuration');
} else {
self.emit('recached', filePath);
}

View File

@ -123,7 +123,7 @@ function displayBanner(term) {
// note: intentional formatting:
term.pipeWrite(`
|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/
|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 _ = require('lodash');
const assert = require('assert');
const moment = require('moment');
// database handles
let dbs = {};
exports.getModDatabasePath = getModDatabasePath;
exports.getISOTimestampString = getISOTimestampString;
exports.initializeDatabases = initializeDatabases;
exports.dbs = dbs;
@ -46,17 +48,22 @@ function getModDatabasePath(moduleInfo, suffix) {
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) {
async.each( [ 'system', 'user', 'message', 'file' ], (dbName, next) => {
async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => {
dbs[dbName] = new sqlite3.Database(getDatabasePath(dbName), err => {
if(err) {
return cb(err);
}
dbs[dbName].serialize( () => {
DB_INIT_TABLE[dbName]();
return next(null);
DB_INIT_TABLE[dbName]( () => {
return next(null);
});
});
});
}, err => {
@ -65,7 +72,7 @@ function initializeDatabases(cb) {
}
const DB_INIT_TABLE = {
system : () => {
system : (cb) => {
dbs.system.run('PRAGMA foreign_keys = ON;');
// Various stat/event logging - see stat_log.js
@ -98,9 +105,11 @@ const DB_INIT_TABLE = {
UNIQUE(timestamp, user_id, log_name)
);`
);
return cb(null);
},
user : () => {
user : (cb) => {
dbs.user.run('PRAGMA foreign_keys = ON;');
dbs.user.run(
@ -138,9 +147,11 @@ const DB_INIT_TABLE = {
timestamp DATETIME NOT NULL
);`
);
return cb(null);
},
message : () => {
message : (cb) => {
dbs.message.run('PRAGMA foreign_keys = ON;');
dbs.message.run(
@ -175,17 +186,23 @@ const DB_INIT_TABLE = {
dbs.message.run(
`CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN
DELETE FROM message_fts WHERE docid=old.rowid;
END;
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;
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);
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);
END;`
);
@ -201,6 +218,7 @@ const DB_INIT_TABLE = {
);`
);
// :TODO: need SQL to ensure cleaned up if delete from message?
/*
dbs.message.run(
@ -237,9 +255,11 @@ const DB_INIT_TABLE = {
UNIQUE(scan_toss, area_tag)
);`
);
return cb(null);
},
file : () => {
file : (cb) => {
dbs.file.run('PRAGMA foreign_keys = ON;');
dbs.file.run(
@ -247,11 +267,11 @@ const DB_INIT_TABLE = {
`CREATE TABLE IF NOT EXISTS file (
file_id INTEGER PRIMARY KEY,
area_tag VARCHAR NOT NULL,
file_sha1 VARCHAR NOT NULL,
file_sha256 VARCHAR NOT NULL,
file_name, /* FTS @ file_fts */
storage_tag VARCHAR NOT NULL,
desc, /* FTS @ file_fts */
desc_long, /* FTS @ file_fts */
upload_by_username VARCHAR NOT NULL,
desc_long, /* FTS @ file_fts */
upload_timestamp DATETIME NOT NULL
);`
);
@ -273,18 +293,24 @@ const DB_INIT_TABLE = {
dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_before_update BEFORE UPDATE ON file BEGIN
DELETE FROM file_fts WHERE docid=old.rowid;
END;
CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN
END;`
);
dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_before_delete BEFORE DELETE ON file BEGIN
DELETE FROM file_fts WHERE docid=old.rowid;
END;
END;`
);
CREATE TRIGGER IF NOT EXISTS file_after_update AFTER UPDATE ON file BEGIN
INSERT INTO file_fts(docid, file_name, desc, long_desc) VALUES(new.rowid, new.file_name, new.desc, new.long_desc);
END;
dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_after_update AFTER UPDATE ON file BEGIN
INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long);
END;`
);
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.long_desc);
dbs.file.run(
`CREATE TRIGGER IF NOT EXISTS file_after_insert AFTER INSERT ON file BEGIN
INSERT INTO file_fts(docid, file_name, desc, desc_long) VALUES(new.rowid, new.file_name, new.desc, new.desc_long);
END;`
);
@ -315,5 +341,24 @@ const DB_INIT_TABLE = {
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
// :TODO: Use .map() here
let args = _.clone(self.exeInfo.args); // we need a copy so the original is not modified
for(let i = 0; i < args.length; ++i) {

View File

@ -10,28 +10,26 @@ const async = require('async');
const _ = require('lodash');
const SSHClient = require('ssh2').Client;
exports.getModule = DoorPartyModule;
exports.moduleInfo = {
name : 'DoorParty',
desc : 'DoorParty Access Module',
author : 'NuSkooler',
};
exports.getModule = class DoorPartyModule extends MenuModule {
constructor(options) {
super(options);
function DoorPartyModule(options) {
MenuModule.call(this, options);
// establish defaults
this.config = options.menuConfig.config;
this.config.host = this.config.host || 'dp.throwbackbbs.com';
this.config.sshPort = this.config.sshPort || 2022;
this.config.rloginPort = this.config.rloginPort || 513;
}
const self = this;
// 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() {
initSequence() {
let clientTerminated;
const self = this;
async.series(
[
@ -116,7 +114,7 @@ function DoorPartyModule(options) {
],
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
@ -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.EnigMenuError = EnigMenuError;
exports.Errors = {
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 StatLog = require('./stat_log.js');
const stringFormat = require('./string_format.js');
const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher;
// deps
const async = require('async');
@ -20,12 +21,6 @@ const assert = require('assert');
const _ = require('lodash');
const moment = require('moment');
exports.FullScreenEditorModule = FullScreenEditorModule;
// :TODO: clean this up:
exports.getModule = FullScreenEditorModule;
exports.moduleInfo = {
name : 'Full Screen Editor (FSE)',
desc : 'A full screen editor/viewer',
@ -66,7 +61,7 @@ exports.moduleInfo = {
*/
var MCICodeIds = {
const MciCodeIds = {
ViewModeHeader : {
From : 1,
To : 2,
@ -98,72 +93,192 @@ var MCICodeIds = {
},
};
function FullScreenEditorModule(options) {
MenuModule.call(this, 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
var self = this;
var config = this.menuConfig.config;
exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModule extends MessageAreaConfTempSwitcher(MenuModule) {
//
// 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;
}
this.messageIndex = config.messageIndex || 0;
this.messageTotal = config.messageTotal || 0;
this.toUserId = config.toUserId || 0;
constructor(options) {
super(options);
// extraArgs can override some config
if(_.isObject(options.extraArgs)) {
if(options.extraArgs.messageAreaTag) {
this.messageAreaTag = options.extraArgs.messageAreaTag;
const self = this;
const config = this.menuConfig.config;
//
// 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;
}
if(options.extraArgs.toUserId) {
this.toUserId = options.extraArgs.toUserId;
this.isReady = false;
if(_.has(options, 'extraArgs.message')) {
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() {
return 'edit' === self.editorMode;
};
this.isViewMode = function() {
return 'view' === self.editorMode;
};
isViewMode() {
return 'view' === this.editorMode;
}
this.isLocalEmail = function() {
return Message.WellKnownAreaTags.Private === self.messageAreaTag;
};
isLocalEmail() {
return Message.WellKnownAreaTags.Private === this.messageAreaTag;
}
this.isReply = function() {
return !_.isUndefined(self.replyToMessage);
};
isReply() {
return !_.isUndefined(this.replyToMessage);
}
this.getFooterName = function() {
return 'footer' + _.capitalize(self.footerMode); // e.g. 'footerEditor', 'footerEditorMenu', ...
};
getFooterName() {
return 'footer' + _.upperFirst(this.footerMode); // e.g. 'footerEditor', 'footerEditorMenu', ...
}
this.getFormId = function(name) {
getFormId(name) {
return {
header : 0,
body : 1,
@ -174,27 +289,13 @@ function FullScreenEditorModule(options) {
help : 50,
}[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:
this.getHeaderViews = function() {
var vc = self.viewControllers.header;
getHeaderViews() {
var vc = this.viewControllers.header;
if(self.isViewMode()) {
if(this.isViewMode()) {
return {
from : vc.getView(1),
to : vc.getView(2),
@ -206,61 +307,55 @@ function FullScreenEditorModule(options) {
};
}
};
}
this.setInitialFooterMode = function() {
switch(self.editorMode) {
case 'edit' : self.footerMode = 'editor'; break;
case 'view' : self.footerMode = 'view'; break;
setInitialFooterMode() {
switch(this.editorMode) {
case 'edit' : this.footerMode = 'editor'; break;
case 'view' : this.footerMode = 'view'; break;
}
};
}
this.buildMessage = function() {
var headerValues = self.viewControllers.header.getFormData().value;
buildMessage() {
const headerValues = this.viewControllers.header.getFormData().value;
var msgOpts = {
areaTag : self.messageAreaTag,
areaTag : this.messageAreaTag,
toUserName : headerValues.to,
fromUserName : headerValues.from,
fromUserName : this.client.user.username,
subject : headerValues.subject,
message : self.viewControllers.body.getFormData().value.message,
message : this.viewControllers.body.getFormData().value.message,
};
if(self.isReply()) {
msgOpts.replyToMsgId = self.replyToMessage.messageId;
if(this.isReply()) {
msgOpts.replyToMsgId = this.replyToMessage.messageId;
}
self.message = new Message(msgOpts);
};
this.message = new Message(msgOpts);
}
/*
this.setBodyMessageViewText = function() {
self.bodyMessageView.setText(cleanControlCodes(self.message.message));
};
*/
this.setMessage = function(message) {
self.message = message;
setMessage(message) {
this.message = message;
updateMessageAreaLastReadId(
self.client.user.userId, self.messageAreaTag, self.message.messageId, () => {
this.client.user.userId, this.messageAreaTag, this.message.messageId, () => {
if(self.isReady) {
self.initHeaderViewMode();
self.initFooterViewMode();
if(this.isReady) {
this.initHeaderViewMode();
this.initFooterViewMode();
var bodyMessageView = self.viewControllers.body.getView(1);
if(bodyMessageView && _.has(self, 'message.message')) {
//self.setBodyMessageViewText();
bodyMessageView.setText(cleanControlCodes(self.message.message));
//bodyMessageView.redraw();
var bodyMessageView = this.viewControllers.body.getView(1);
if(bodyMessageView && _.has(this, 'message.message')) {
bodyMessageView.setText(cleanControlCodes(this.message.message));
}
}
}
);
};
}
getMessage(cb) {
const self = this;
this.getMessage = function(cb) {
async.series(
[
function buildIfNecessary(callback) {
@ -296,24 +391,22 @@ function FullScreenEditorModule(options) {
cb(err, self.message);
}
);
};
}
this.updateUserStats = function(cb) {
updateUserStats(cb) {
if(Message.isPrivateAreaTag(this.message.areaTag)) {
if(cb) {
return cb(null);
cb(null);
}
return; // don't inc stats for private messages
}
StatLog.incrementUserStat(
self.client.user,
'post_count',
1,
cb
);
};
return StatLog.incrementUserStat(this.client.user, 'post_count', 1, cb);
}
redrawFooter(options, cb) {
const self = this;
this.redrawFooter = function(options, cb) {
async.waterfall(
[
function moveToFooterPosition(callback) {
@ -339,7 +432,7 @@ function FullScreenEditorModule(options) {
callback(null);
},
function displayFooterArt(callback) {
var footerArt = self.menuConfig.config.art[options.footerName];
const footerArt = self.menuConfig.config.art[options.footerName];
theme.displayThemedAsset(
footerArt,
@ -355,10 +448,11 @@ function FullScreenEditorModule(options) {
cb(err, artData);
}
);
};
}
this.redrawScreen = function(cb) {
redrawScreen(cb) {
var comps = [ 'header', 'body' ];
const self = this;
var art = self.menuConfig.config.art;
self.client.term.rawWrite(ansi.resetScreen());
@ -399,43 +493,44 @@ function FullScreenEditorModule(options) {
cb(err);
}
);
};
}
switchFooter(cb) {
var footerName = this.getFooterName();
this.switchFooter = function(cb) {
var footerName = self.getFooterName();
self.redrawFooter( { footerName : footerName, clear : true }, function artDisplayed(err, artData) {
this.redrawFooter( { footerName : footerName, clear : true }, (err, artData) => {
if(err) {
cb(err);
return;
}
var formId = self.getFormId(footerName);
var formId = this.getFormId(footerName);
if(_.isUndefined(self.viewControllers[footerName])) {
if(_.isUndefined(this.viewControllers[footerName])) {
var menuLoadOpts = {
callingMenu : self,
callingMenu : this,
formId : formId,
mciMap : artData.mciMap
};
self.addViewController(
this.addViewController(
footerName,
new ViewController( { client : self.client, formId : formId } )
).loadFromMenuConfig(menuLoadOpts, function footerReady(err) {
new ViewController( { client : this.client, formId : formId } )
).loadFromMenuConfig(menuLoadOpts, err => {
cb(err);
});
} else {
self.viewControllers[footerName].redrawAll();
this.viewControllers[footerName].redrawAll();
cb(null);
}
});
};
}
this.initSequence = function() {
initSequence() {
var mciData = { };
const self = this;
var art = self.menuConfig.config.art;
assert(_.isObject(art));
async.series(
@ -489,10 +584,10 @@ function FullScreenEditorModule(options) {
}
}
);
};
}
this.createInitialViews = function(mciData, cb) {
createInitialViews(mciData, cb) {
const self = this;
var menuLoadOpts = { callingMenu : self };
async.series(
@ -603,11 +698,11 @@ function FullScreenEditorModule(options) {
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
// place - if this is for existing usernames else validate spec
@ -627,103 +722,94 @@ function FullScreenEditorModule(options) {
cb(err);
});
};
}
this.updateEditModePosition = function(pos) {
if(self.isEditMode()) {
var posView = self.viewControllers.footerEditor.getView(1);
updateEditModePosition(pos) {
if(this.isEditMode()) {
var posView = this.viewControllers.footerEditor.getView(1);
if(posView) {
self.client.term.rawWrite(ansi.savePos());
posView.setText(_.padLeft(String(pos.row + 1), 2, '0') + ',' + _.padLeft(String(pos.col + 1), 2, '0'));
self.client.term.rawWrite(ansi.restorePos());
this.client.term.rawWrite(ansi.savePos());
// :TODO: Use new formatting techniques here, e.g. state.cursorPositionRow, cursorPositionCol and cursorPositionFormat
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) {
if(self.isEditMode()) {
var modeView = self.viewControllers.footerEditor.getView(2);
updateTextEditMode(mode) {
if(this.isEditMode()) {
var modeView = this.viewControllers.footerEditor.getView(2);
if(modeView) {
self.client.term.rawWrite(ansi.savePos());
this.client.term.rawWrite(ansi.savePos());
modeView.setText('insert' === mode ? 'INS' : 'OVR');
self.client.term.rawWrite(ansi.restorePos());
this.client.term.rawWrite(ansi.restorePos());
}
}
};
}
this.setHeaderText = function(id, text) {
var v = self.viewControllers.header.getView(id);
if(v) {
v.setText(text);
}
};
setHeaderText(id, text) {
this.setViewText('header', id, text);
}
this.initHeaderViewMode = function() {
assert(_.isObject(self.message));
initHeaderViewMode() {
assert(_.isObject(this.message));
self.setHeaderText(MCICodeIds.ViewModeHeader.From, self.message.fromUserName);
self.setHeaderText(MCICodeIds.ViewModeHeader.To, self.message.toUserName);
self.setHeaderText(MCICodeIds.ViewModeHeader.Subject, self.message.subject);
self.setHeaderText(MCICodeIds.ViewModeHeader.DateTime, moment(self.message.modTimestamp).format(self.client.currentTheme.helpers.getDateTimeFormat()));
self.setHeaderText(MCICodeIds.ViewModeHeader.MsgNum, (self.messageIndex + 1).toString());
self.setHeaderText(MCICodeIds.ViewModeHeader.MsgTotal, self.messageTotal.toString());
self.setHeaderText(MCICodeIds.ViewModeHeader.ViewCount, self.message.viewCount);
self.setHeaderText(MCICodeIds.ViewModeHeader.HashTags, 'TODO hash tags');
self.setHeaderText(MCICodeIds.ViewModeHeader.MessageID, self.message.messageId);
self.setHeaderText(MCICodeIds.ViewModeHeader.ReplyToMsgID, self.message.replyToMessageId);
};
this.setHeaderText(MciCodeIds.ViewModeHeader.From, this.message.fromUserName);
this.setHeaderText(MciCodeIds.ViewModeHeader.To, this.message.toUserName);
this.setHeaderText(MciCodeIds.ViewModeHeader.Subject, this.message.subject);
this.setHeaderText(MciCodeIds.ViewModeHeader.DateTime, moment(this.message.modTimestamp).format(this.client.currentTheme.helpers.getDateTimeFormat()));
this.setHeaderText(MciCodeIds.ViewModeHeader.MsgNum, (this.messageIndex + 1).toString());
this.setHeaderText(MciCodeIds.ViewModeHeader.MsgTotal, this.messageTotal.toString());
this.setHeaderText(MciCodeIds.ViewModeHeader.ViewCount, this.message.viewCount);
this.setHeaderText(MciCodeIds.ViewModeHeader.HashTags, 'TODO hash tags');
this.setHeaderText(MciCodeIds.ViewModeHeader.MessageID, this.message.messageId);
this.setHeaderText(MciCodeIds.ViewModeHeader.ReplyToMsgID, this.message.replyToMessageId);
}
this.initHeaderReplyEditMode = function() {
assert(_.isObject(self.replyToMessage));
initHeaderReplyEditMode() {
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
// that way -- avoid RE: RE: RE: RE: ...
//
let newSubj = self.replyToMessage.subject;
let newSubj = this.replyToMessage.subject;
if(false === /^RE:\s+/i.test(newSubj)) {
newSubj = `RE: ${newSubj}`;
}
self.setHeaderText(MCICodeIds.ReplyEditModeHeader.Subject, newSubj);
};
this.setHeaderText(MciCodeIds.ReplyEditModeHeader.Subject, newSubj);
}
this.initFooterViewMode = function() {
function setFooterText(id, text) {
var v = self.viewControllers.footerView.getView(id);
if(v) {
v.setText(text);
}
}
initFooterViewMode() {
this.setViewText('footerView', MciCodeIds.ViewModeFooter.MsgNum, (this.messageIndex + 1).toString() );
this.setViewText('footerView', MciCodeIds.ViewModeFooter.MsgTotal, this.messageTotal.toString() );
}
setFooterText(MCICodeIds.ViewModeFooter.MsgNum, (self.messageIndex + 1).toString());
setFooterText(MCICodeIds.ViewModeFooter.MsgTotal, self.messageTotal.toString());
};
this.displayHelp = function(cb) {
self.client.term.rawWrite(ansi.resetScreen());
displayHelp(cb) {
this.client.term.rawWrite(ansi.resetScreen());
theme.displayThemeArt(
{ name : self.menuConfig.config.art.help, client : self.client },
{ name : this.menuConfig.config.art.help, client : this.client },
() => {
self.client.waitForKeyPress( () => {
self.redrawScreen( () => {
self.viewControllers[self.getFooterName()].setFocus(true);
this.client.waitForKeyPress( () => {
this.redrawScreen( () => {
this.viewControllers[this.getFooterName()].setFocus(true);
return cb(null);
});
});
}
);
};
}
this.displayQuoteBuilder = function() {
displayQuoteBuilder() {
//
// Clear body area
//
self.newQuoteBlock = true;
this.newQuoteBlock = true;
const self = this;
async.waterfall(
[
@ -779,19 +865,19 @@ function FullScreenEditorModule(options) {
}
}
);
};
}
this.observeEditorEvents = function() {
var bodyView = self.viewControllers.body.getView(1);
observeEditorEvents() {
const bodyView = this.viewControllers.body.getView(1);
bodyView.on('edit position', function cursorPosUpdate(pos) {
self.updateEditModePosition(pos);
bodyView.on('edit position', pos => {
this.updateEditModePosition(pos);
});
bodyView.on('text edit mode', function textEditMode(mode) {
self.updateTextEditMode(mode);
bodyView.on('text edit mode', mode => {
this.updateTextEditMode(mode);
});
};
}
/*
this.observeViewPosition = function() {
@ -801,43 +887,43 @@ function FullScreenEditorModule(options) {
};
*/
this.switchToHeader = function() {
self.viewControllers.body.setFocus(false);
self.viewControllers.header.switchFocus(2); // to
switchToHeader() {
this.viewControllers.body.setFocus(false);
this.viewControllers.header.switchFocus(2); // to
}
switchToBody() {
this.viewControllers.header.setFocus(false);
this.viewControllers.body.switchFocus(1);
this.observeEditorEvents();
};
this.switchToBody = function() {
self.viewControllers.header.setFocus(false);
self.viewControllers.body.switchFocus(1);
switchToFooter() {
this.viewControllers.header.setFocus(false);
this.viewControllers.body.setFocus(false);
self.observeEditorEvents();
};
this.viewControllers[this.getFooterName()].switchFocus(1); // HM1
}
this.switchToFooter = function() {
self.viewControllers.header.setFocus(false);
self.viewControllers.body.setFocus(false);
self.viewControllers[self.getFooterName()].switchFocus(1); // HM1
};
this.switchFromQuoteBuilderToBody = function() {
self.viewControllers.quoteBuilder.setFocus(false);
var body = self.viewControllers.body.getView(1);
switchFromQuoteBuilderToBody() {
this.viewControllers.quoteBuilder.setFocus(false);
var body = this.viewControllers.body.getView(1);
body.redraw();
self.viewControllers.body.switchFocus(1);
this.viewControllers.body.switchFocus(1);
// :TODO: create method (DRY)
self.updateTextEditMode(body.getTextEditMode());
self.updateEditModePosition(body.getEditPosition());
this.updateTextEditMode(body.getTextEditMode());
this.updateEditModePosition(body.getEditPosition());
self.observeEditorEvents();
};
this.observeEditorEvents();
}
this.quoteBuilderFinalize = function() {
quoteBuilderFinalize() {
// :TODO: fix magic #'s
var quoteMsgView = self.viewControllers.quoteBuilder.getView(1);
var msgView = self.viewControllers.body.getView(1);
var quoteMsgView = this.viewControllers.quoteBuilder.getView(1);
var msgView = this.viewControllers.body.getView(1);
var quoteLines = quoteMsgView.getData();
@ -848,164 +934,43 @@ function FullScreenEditorModule(options) {
quoteMsgView.setText('');
var footerName = self.getFooterName();
this.footerMode = 'editor';
self.footerMode = 'editor';
self.switchFooter(function switched(err) {
self.switchFromQuoteBuilderToBody();
this.switchFooter( () => {
this.switchFromQuoteBuilderToBody();
});
};
}
this.getQuoteByHeader = function() {
getQuoteByHeader() {
let quoteFormat = this.menuConfig.config.quoteFormats;
if(Array.isArray(quoteFormat)) {
quoteFormat = quoteFormat[ Math.floor(Math.random() * quoteFormat.length) ];
} else if(!_.isString(quoteFormat)) {
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, {
dateTime : moment(self.replyToMessage.modTimestamp).format(dtFormat),
userName : self.replyToMessage.fromUserName,
dateTime : moment(this.replyToMessage.modTimestamp).format(dtFormat),
userName : this.replyToMessage.fromUserName,
});
};
}
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);
enter() {
if(this.messageAreaTag) {
this.tempMessageConfAndAreaSwitch(this.messageAreaTag);
}
};
if(_.has(options, 'extraArgs.message')) {
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);
super.enter();
}
FullScreenEditorModule.super_.prototype.enter.call(this);
};
leave() {
this.tempMessageConfAndAreaRestore();
super.leave();
}
FullScreenEditorModule.prototype.leave = function() {
this.tempMessageConfAndAreaRestore();
FullScreenEditorModule.super_.prototype.leave.call(this);
};
FullScreenEditorModule.prototype.mciReady = function(mciData, cb) {
this.mciReadyHandler(mciData, cb);
mciReady(mciData, cb) {
return this.mciReadyHandler(mciData, cb);
}
};

View File

@ -8,7 +8,7 @@ let FNV1a = require('./fnv1a.js');
let _ = require('lodash');
let iconv = require('iconv-lite');
let moment = require('moment');
let uuid = require('node-uuid');
//let uuid = require('node-uuid');
let os = require('os');
let packageJson = require('../package.json');
@ -39,7 +39,7 @@ exports.getQuotePrefix = getQuotePrefix;
// Namespace for RFC-4122 name based UUIDs generated from
// 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

View File

@ -113,30 +113,39 @@ HorizontalMenuView.prototype.setItems = function(items) {
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) {
if(key) {
var prevFocusedItemIndex = this.focusedItemIndex;
if(this.isKeyMapped('left', key.name)) {
if(0 === this.focusedItemIndex) {
this.focusedItemIndex = this.items.length - 1;
} else {
this.focusedItemIndex--;
}
this.focusPrevious();
} else if(this.isKeyMapped('right', key.name)) {
if(this.items.length - 1 === this.focusedItemIndex) {
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;
this.focusNext();
}
}

View File

@ -15,22 +15,30 @@ module.exports = class KeyEntryView extends View {
super(options);
this.eatTabKey = options.eatTabKey || true;
this.caseInsensitive = options.caseInsensitive || true;
this.eatTabKey = options.eatTabKey || 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) {
if(ch && isPrintable(ch)) {
this.redraw(); // sets position
this.client.term.write(stylizeString(ch, this.textStyle));
}
const drawKey = ch;
if(ch && this.caseInsensitive) {
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;
if(key && 'tab' === key.name && !this.eatTabKey) {
@ -54,6 +62,12 @@ module.exports = class KeyEntryView extends View {
this.caseInsensitive = propValue;
}
break;
case 'keys' :
if(Array.isArray(propValue)) {
this.keys = propValue;
}
break;
}
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 */
'use strict';
var PluginModule = require('./plugin_module.js').PluginModule;
var theme = require('./theme.js');
var art = require('./art.js');
var Log = require('./logger.js').log;
var ansi = require('./ansi_term.js');
var asset = require('./asset.js');
var ViewController = require('./view_controller.js').ViewController;
var menuUtil = require('./menu_util.js');
var Config = require('./config.js').config;
const PluginModule = require('./plugin_module.js').PluginModule;
const theme = require('./theme.js');
const ansi = require('./ansi_term.js');
const ViewController = require('./view_controller.js').ViewController;
const menuUtil = require('./menu_util.js');
const Config = require('./config.js').config;
const stringFormat = require('../core/string_format.js');
const MultiLineEditTextView = require('../core/multi_line_edit_text_view.js').MultiLineEditTextView;
const Errors = require('../core/enig_error.js').Errors;
var async = require('async');
var assert = require('assert');
var _ = require('lodash');
// deps
const async = require('async');
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) {
PluginModule.call(this, options);
this.viewControllers = {};
}
var self = this;
this.menuName = options.menuName;
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
enter() {
this.initSequence();
}
this.cls = _.isBoolean(this.menuConfig.options.cls) ?
this.menuConfig.options.cls :
Config.menus.cls;
leave() {
this.detachViewControllers();
}
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() {
return 'end' === self.menuConfig.options.pause || true === self.menuConfig.options.pause;
};
self.displayAsset(
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 _.isNumber(self.menuConfig.options.nextTimeout);
};
return callback(null); // any errors are non-fatal
}
);
},
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) {
function goNext() {
if(_.isString(self.menuConfig.next) || _.isArray(self.menuConfig.next)) {
return callback(null);
},
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);
} else {
return self.prevMenu(cb);
}
}
if(_.has(self.menuConfig, 'runtime.autoNext') && true === self.menuConfig.runtime.autoNext) {
/*
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()) {
if(_.has(this.menuConfig, 'runtime.autoNext') && true === this.menuConfig.runtime.autoNext) {
if(this.hasNextTimeout()) {
setTimeout( () => {
return goNext();
return gotoNextMenu();
}, this.menuConfig.options.nextTimeout);
} 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() {
var mciData = { };
const self = this;
async.series(
[
function addViewControllers(callback) {
_.forEach(mciData, (mciMap, name) => {
assert('menu' === name || 'prompt' === name);
self.addViewController(name, new ViewController( { client : self.client } ) );
});
async.series(
[
function beforeDisplayArt(callback) {
self.beforeArt(callback);
},
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;
return callback(null);
},
function createMenu(callback) {
if(!self.viewControllers.menu) {
return callback(null);
}
// Prompts *must* have art. If it's missing it's an error
// :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 = {
const menuLoadOpts = {
mciMap : mciData.menu,
callingMenu : self,
withoutForm : _.isObject(mciData.prompt),
};
self.viewControllers.menu.loadFromMenuConfig(menuLoadOpts, function menuLoaded(err) {
callback(err);
self.viewControllers.menu.loadFromMenuConfig(menuLoadOpts, err => {
return callback(err);
});
} else {
callback(null);
}
},
function createPrompt(callback) {
if(self.viewControllers.prompt) {
var promptLoadOpts = {
},
function createPrompt(callback) {
if(!self.viewControllers.prompt) {
return callback(null);
}
const promptLoadOpts = {
callingMenu : self,
mciMap : mciData.prompt,
};
self.viewControllers.prompt.loadFromPromptConfig(promptLoadOpts, function promptLoaded(err) {
callback(err);
self.viewControllers.prompt.loadFromPromptConfig(promptLoadOpts, 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() {
// nothing in base
prepViewControllerWithArt(name, formId, options, cb) {
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)) {
cb = options;
options = {};
}
const self = this;
@ -133,6 +134,12 @@ module.exports = class MenuStack {
currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState();
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({
@ -146,11 +153,17 @@ module.exports = class MenuStack {
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(
{ stack : _.map(self.stack, stackEntry => stackEntry.name) },
'Updated menu stack');
self.client.log.trace( { stack : stackEntries }, 'Updated menu stack' );
modInst.enter();
if(cb) {
cb(null);

View File

@ -64,6 +64,12 @@ function loadMenu(options, cb) {
},
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 modSupplied = null !== modAsset;
@ -88,18 +94,20 @@ function loadMenu(options, cb) {
{ moduleName : modData.name, extraArgs : options.extraArgs, config : modData.config, info : modData.mod.modInfo },
'Creating menu module instance');
let moduleInstance;
try {
const moduleInstance = new modData.mod.getModule({
moduleInstance = new modData.mod.getModule({
menuName : options.name,
menuConfig : modData.config,
extraArgs : options.extraArgs,
client : options.client,
lastMenuResult : options.lastMenuResult,
});
return callback(null, moduleInstance);
});
} catch(e) {
return callback(e);
}
return callback(null, moduleInstance);
}
],
(err, modInst) => {
@ -121,8 +129,8 @@ function getFormConfigByIDAndMap(menuConfig, formId, mciMap, cb) {
return;
}
var formForId = menuConfig.form[formId];
var mciReqKey = _.filter(_.pluck(_.sortBy(mciMap, 'code'), 'code'), function(mci) {
const formForId = menuConfig.form[formId];
const mciReqKey = _.filter(_.map(_.sortBy(mciMap, 'code'), 'code'), (mci) => {
return MCIViewFactory.UserViewCodes.indexOf(mci) > -1;
}).join('');
@ -142,8 +150,7 @@ function getFormConfigByIDAndMap(menuConfig, formId, mciMap, cb) {
//
if(_.has(formForId, 'mci') || _.has(formForId, 'submit')) {
Log.trace('Using generic configuration');
cb(null, formForId);
return;
return cb(null, formForId);
}
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() {
return this.items.length;
};
@ -92,12 +106,10 @@ MenuView.prototype.getItem = function(index) {
};
MenuView.prototype.focusNext = function() {
// nothing @ base currently
this.emit('index update', this.focusedItemIndex);
};
MenuView.prototype.focusPrevious = function() {
// nothign @ base currently
this.emit('index update', this.focusedItemIndex);
};
@ -143,12 +155,13 @@ MenuView.prototype.setItemSpacing = function(itemSpacing) {
MenuView.prototype.setPropertyValue = function(propName, value) {
switch(propName) {
case 'itemSpacing' : this.setItemSpacing(value); break;
case 'items' : this.setItems(value); break;
case 'focusItems' : this.setFocusItems(value); break;
case 'hotKeys' : this.setHotKeys(value); break;
case 'hotKeySubmit' : this.hotKeySubmit = value; break;
case 'justify' : this.justify = value; break;
case 'itemSpacing' : this.setItemSpacing(value); break;
case 'items' : this.setItems(value); break;
case 'focusItems' : this.setFocusItems(value); break;
case 'hotKeys' : this.setHotKeys(value); break;
case 'hotKeySubmit' : this.hotKeySubmit = value; break;
case 'justify' : this.justify = value; break;
case 'focusItemIndex' : this.focusedItemIndex = value; break;
}
MenuView.super_.prototype.setPropertyValue.call(this, propName, value);

View File

@ -1,21 +1,23 @@
/* jslint node: true */
'use strict';
let msgDb = require('./database.js').dbs.message;
let wordWrapText = require('./word_wrap.js').wordWrapText;
let ftnUtil = require('./ftn_util.js');
let createNamedUUID = require('./uuid_util.js').createNamedUUID;
const msgDb = require('./database.js').dbs.message;
const wordWrapText = require('./word_wrap.js').wordWrapText;
const ftnUtil = require('./ftn_util.js');
const createNamedUUID = require('./uuid_util.js').createNamedUUID;
const getISOTimestampString = require('./database.js').getISOTimestampString;
let uuid = require('node-uuid');
let async = require('async');
let _ = require('lodash');
let assert = require('assert');
let moment = require('moment');
const iconvEncode = require('iconv-lite').encode;
// deps
const uuidParse = require('uuid-parse');
const async = require('async');
const _ = require('lodash');
const assert = require('assert');
const moment = require('moment');
const iconvEncode = require('iconv-lite').encode;
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) {
options = options || {};
@ -64,11 +66,6 @@ function Message(options) {
this.isPrivate = function() {
return Message.isPrivateAreaTag(this.areaTag);
};
this.getMessageTimestampString = function(ts) {
ts = ts || moment();
return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ');
};
}
Message.WellKnownAreaTags = {
@ -138,7 +135,7 @@ Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) {
subject = iconvEncode(subject.toUpperCase().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) {
@ -374,7 +371,7 @@ Message.prototype.persist = function(cb) {
msgDb.run(
`INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);`,
[ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, 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
if(!err) {
self.messageId = this.lastID;

View File

@ -2,11 +2,12 @@
'use strict';
// ENiGMA½
const msgDb = require('./database.js').dbs.message;
const Config = require('./config.js').config;
const Message = require('./message.js');
const Log = require('./logger.js').log;
const msgNetRecord = require('./msg_network.js').recordMessage;
const msgDb = require('./database.js').dbs.message;
const Config = require('./config.js').config;
const Message = require('./message.js');
const Log = require('./logger.js').log;
const msgNetRecord = require('./msg_network.js').recordMessage;
const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs;
// deps
const async = require('async');
@ -32,34 +33,11 @@ exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId;
exports.persistMessage = persistMessage;
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) {
options = options || { includeSystemInternal : false };
// 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) {
return true;
}
@ -95,7 +73,7 @@ function getAvailableMessageAreasByConfTag(confTag, options) {
return areas;
} else {
// perform ACS check per area
return _.omit(areas, area => {
return _.omitBy(areas, area => {
return !options.client.acs.hasMessageAreaRead(area);
});
}
@ -269,7 +247,7 @@ function changeMessageConference(client, confTag, cb) {
}
function changeMessageAreaWithOptions(client, areaTag, options, cb) {
options = options || {};
options = options || {}; // :TODO: this is currently pointless... cb is required...
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');
}
cb(err);
return cb(err);
}
);
}

View File

@ -1,56 +1,31 @@
/* jslint node: true */
'use strict';
const messageArea = require('../core/message_area.js');
const messageArea = require('../core/message_area.js');
// deps
const assert = require('assert');
//
// 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() {
exports.MessageAreaConfTempSwitcher = Sup => class extends Sup {
this.tempMessageConfAndAreaSwitch = function(messageAreaTag) {
tempMessageConfAndAreaSwitch(messageAreaTag) {
messageAreaTag = messageAreaTag || this.messageAreaTag;
if(!messageAreaTag) {
return; // nothing to do!
}
this.prevMessageConfAndArea = {
confTag : this.client.user.properties.message_conf_tag,
areaTag : this.client.user.properties.message_area_tag,
};
if(!messageArea.tempChangeMessageConfAndArea(this.client, this.messageAreaTag)) {
this.client.log.warn( { messageAreaTag : messageArea }, 'Failed to perform temporary message area/conf switch');
}
};
}
this.tempMessageConfAndAreaRestore = function() {
tempMessageConfAndAreaRestore() {
if(this.prevMessageConfAndArea) {
this.client.user.properties.message_conf_tag = this.prevMessageConfAndArea.confTag;
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!'));
}
// Ref configuration, if any, for convience to the module
mod.runtime = { config : modConfig };
return cb(null, mod);
}

View File

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

View File

@ -17,8 +17,6 @@ exports.moduleInfo = {
author : 'NuSkooler',
};
exports.getModule = NewScanModule;
/*
* :TODO:
* * 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
ScanStatusList : 2, // VM2 (appends)
};
function NewScanModule(options) {
MenuModule.call(this, options);
exports.getModule = class NewScanModule extends MenuModule {
constructor(options) {
super(options);
var self = this;
var config = this.menuConfig.config;
this.newScanFullExit = _.has(options, 'lastMenuResult.fullExit') ? options.lastMenuResult.fullExit : false;
this.newScanFullExit = _.has(options, 'lastMenuResult.fullExit') ? options.lastMenuResult.fullExit : false;
this.currentStep = 'messageConferences';
this.currentScanAux = {};
this.currentStep = 'messageConferences';
this.currentScanAux = {};
// :TODO: Make this conf/area specific:
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:
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';
this.updateScanStatus = function(statusText) {
var vc = self.viewControllers.allViews;
var view = vc.getView(MciCodeIds.ScanStatusLabel);
if(view) {
view.setText(statusText);
}
updateScanStatus(statusText) {
this.setViewText('allViews', MciCodeIds.ScanStatusLabel, statusText);
/*
view = vc.getView(MciCodeIds.ScanStatusList);
// :TODO: MenuView needs appendItem()
if(view) {
}
};
*/
}
this.newScanMessageConference = function(cb) {
newScanMessageConference(cb) {
// lazy init
if(!self.sortedMessageConfs) {
if(!this.sortedMessageConfs) {
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 {
confTag : k,
conf : v,
@ -80,19 +75,20 @@ function NewScanModule(options) {
// always come first such that we display private mails/etc. before
// other conferences & areas
//
self.sortedMessageConfs.sort((a, b) => {
this.sortedMessageConfs.sort((a, b) => {
if('system_internal' === a.confTag) {
return -1;
} 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;
self.currentScanAux.area = self.currentScanAux.area || 0;
this.currentScanAux.conf = this.currentScanAux.conf || 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(
[
@ -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!
const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : self.client } );
const currentArea = sortedAreas[self.currentScanAux.area];
const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : this.client } );
const currentArea = sortedAreas[this.currentScanAux.area];
//
// Scan and update index until we find something. If results are found,
// we'll goto the list module & show them.
//
const self = this;
async.waterfall(
[
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;
var vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
restoreSavedState(savedState) {
this.currentStep = savedState.currentStep;
this.currentScanAux = savedState.currentScanAux;
}
// :TODO: display scan step/etc.
async.series(
[
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);
mciReady(mciData, cb) {
if(this.newScanFullExit) {
// user has canceled the entire scan @ message list view
return cb(null);
}
);
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 clientConnections = require('./client_connections.js');
const StatLog = require('./stat_log.js');
const FileBaseFilters = require('./file_base_filter.js');
const formatByteSize = require('./string_util.js').formatByteSize;
// deps
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) {
if(!client || !code) {
@ -67,32 +80,43 @@ function getPredefinedMCIValue(client, code) {
UN : function userName() { return client.user.username; },
UI : function userId() { return client.user.userId.toString(); },
UG : function groups() { return _.values(client.user.groups).join(', '); },
UR : function realName() { return client.user.properties.real_name; },
LO : function location() { return client.user.properties.location; },
UR : function realName() { return userStatAsString(client, 'real_name', ''); },
LO : function location() { return userStatAsString(client, 'location', ''); },
UA : function age() { return client.user.getAge().toString(); },
UB : function birthdate() { return moment(client.user.properties.birthdate).format(client.currentTheme.helpers.getDateFormat()); },
US : function sex() { return client.user.properties.sex; },
UE : function emailAddres() { return client.user.properties.email_address; },
UW : function webAddress() { return client.user.properties.web_address; },
UF : function affils() { return client.user.properties.affiliation; },
UT : function themeId() { return client.user.properties.theme_id; },
UC : function loginCount() { return client.user.properties.login_count.toString(); },
BD : function birthdate() { return moment(client.user.properties.birthdate).format(client.currentTheme.helpers.getDateFormat()); }, // iNiQUiTY
US : function sex() { return userStatAsString(client, 'sex', ''); },
UE : function emailAddres() { return userStatAsString(client, 'email_address', ''); },
UW : function webAddress() { return userStatAsString(client, 'web_address', ''); },
UF : function affils() { return userStatAsString(client, 'affiliation', ''); },
UT : function themeId() { return userStatAsString(client, 'theme_id', ''); },
UC : function loginCount() { return userStatAsString(client, 'login_count', 0); },
ND : function connectedNode() { return client.node.toString(); },
IP : function clientIpAddress() { return client.address().address; },
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()); },
CS : function currentStatus() { return client.currentStatus; },
PS : function userPostCount() {
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}%`;
},
PS : function userPostCount() { return userStatAsString(client, 'post_count', 0); },
PC : function userPostCallRatio() { return getRatio(client, 'post_count', 'login_count'); },
MD : function currentMenuDescription() {
return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : '';
@ -163,6 +187,26 @@ function getPredefinedMCIValue(client, code) {
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
//

View File

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

View File

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

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

@ -0,0 +1,180 @@
/* 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.bind(this),
});
}
}
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);
const self = this;
fs.stat(filePath, (err, stats) => {
if(err) {
return self.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';
// ENiGMA½
const Config = require('../../config.js').config;
const baseClient = require('../../client.js');
const Log = require('../../logger.js').log;
const ServerModule = require('../../server_module.js').ServerModule;
const userLogin = require('../../user_login.js').userLogin;
const enigVersion = require('../../../package.json').version;
const theme = require('../../theme.js');
const stringFormat = require('../../string_format.js');
const Config = require('../../config.js').config;
const baseClient = require('../../client.js');
const Log = require('../../logger.js').log;
const LoginServerModule = require('../../login_server_module.js');
const userLogin = require('../../user_login.js').userLogin;
const enigVersion = require('../../../package.json').version;
const theme = require('../../theme.js');
const stringFormat = require('../../string_format.js');
// deps
const ssh2 = require('ssh2');
@ -18,15 +18,14 @@ const util = require('util');
const _ = require('lodash');
const assert = require('assert');
exports.moduleInfo = {
const ModuleInfo = exports.moduleInfo = {
name : 'SSH',
desc : 'SSH Server',
author : 'NuSkooler',
isSecure : true,
packageName : 'codes.l33t.enigma.ssh.server',
};
exports.getModule = SSHServerModule;
function SSHClient(clientConn) {
baseClient.Client.apply(this, arguments);
@ -226,40 +225,45 @@ util.inherits(SSHClient, baseClient.Client);
SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ];
function SSHServerModule() {
ServerModule.call(this);
}
exports.getModule = class SSHServerModule extends LoginServerModule {
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() {
SSHServerModule.super_.prototype.createServer.call(this);
this.server = ssh2.Server(serverConf);
this.server.on('connection', (conn, info) => {
Log.info(info, 'New SSH connection');
this.handleNewClient(new SSHClient(conn), conn._sock, ModuleInfo);
});
}
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}`);
}
},
};
listen() {
const port = parseInt(Config.loginServers.ssh.port);
if(isNaN(port)) {
Log.error( { server : ModuleInfo.name, port : Config.loginServers.ssh.port }, 'Cannot load server (invalid port)' );
return false;
}
const server = ssh2.Server(serverConf);
server.on('connection', function onConnection(conn, info) {
Log.info(info, 'New SSH connection');
const client = new SSHClient(conn);
this.emit('client', client, conn._sock);
});
return server;
this.server.listen(port);
Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' );
return true;
}
};

View File

@ -2,10 +2,10 @@
'use strict';
// ENiGMA½
const baseClient = require('../../client.js');
const Log = require('../../logger.js').log;
const ServerModule = require('../../server_module.js').ServerModule;
const Config = require('../../config.js').config;
const baseClient = require('../../client.js');
const Log = require('../../logger.js').log;
const LoginServerModule = require('../../login_server_module.js');
const Config = require('../../config.js').config;
// deps
const net = require('net');
@ -16,16 +16,14 @@ const util = require('util');
//var debug = require('debug')('telnet');
exports.moduleInfo = {
const ModuleInfo = exports.moduleInfo = {
name : 'Telnet',
desc : 'Telnet Server',
author : 'NuSkooler',
isSecure : false,
packageName : 'codes.l33t.enigma.telnet.server',
};
exports.getModule = TelnetServerModule;
//
// Telnet Protocol Resources
// * http://pcmicro.com/netfoss/telnet.html
@ -440,6 +438,65 @@ function TelnetClient(input, output) {
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 => {
bufs.push(b);
@ -484,8 +541,8 @@ function TelnetClient(input, output) {
//
self.emit('data', bufs.splice(0).toBuffer());
}
});
*/
this.input.on('end', () => {
self.emit('end');
@ -767,22 +824,34 @@ Object.keys(OPTIONS).forEach(function(name) {
});
});
function TelnetServerModule() {
ServerModule.call(this);
}
exports.getModule = class TelnetServerModule extends LoginServerModule {
constructor() {
super();
}
util.inherits(TelnetServerModule, ServerModule);
createServer() {
this.server = net.createServer( sock => {
const client = new TelnetClient(sock, sock);
TelnetServerModule.prototype.createServer = function() {
TelnetServerModule.super_.prototype.createServer.call(this);
client.banner();
const server = net.createServer( (sock) => {
const client = new TelnetClient(sock, sock);
client.banner();
this.handleNewClient(client, sock, ModuleInfo);
});
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 */
'use strict';
var MenuModule = require('./menu_module.js').MenuModule;
exports.getModule = StandardMenuModule;
const MenuModule = require('./menu_module.js').MenuModule;
exports.moduleInfo = {
name : 'Standard Menu Module',
@ -11,30 +9,19 @@ exports.moduleInfo = {
author : 'NuSkooler',
};
function StandardMenuModule(menuConfig) {
MenuModule.call(this, menuConfig);
}
exports.getModule = class StandardMenuModule extends MenuModule {
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
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);
}
getUserStat(user, statName) {
return user.properties[statName];
}
getUserStatNum(user, statName) {
return parseInt(this.getUserStat(user, statName)) || 0;
}
incrementUserStat(user, statName, incrementBy, cb) {
incrementBy = incrementBy || 1;

View File

@ -6,6 +6,8 @@ const pad = require('./string_util.js').pad;
const stylizeString = require('./string_util.js').stylizeString;
const renderStringLength = require('./string_util.js').renderStringLength;
const renderSubstr = require('./string_util.js').renderSubstr;
const formatByteSize = require('./string_util.js').formatByteSize;
const formatByteSizeAbbr = require('./string_util.js').formatByteSizeAbbr;
// deps
const _ = require('lodash');
@ -265,6 +267,12 @@ const transformers = {
styleSmallI : (s) => stylizeString(s, 'small i'),
styleMixed : (s) => stylizeString(s, 'mixed'),
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) {

View File

@ -2,18 +2,26 @@
'use strict';
// ENiGMA½
const miscUtil = require('./misc_util.js');
const iconv = require('iconv-lite');
const miscUtil = require('./misc_util.js');
const ANSIEscapeParser = require('./ansi_escape_parser.js').ANSIEscapeParser;
const ANSI = require('./ansi_term.js');
// deps
const iconv = require('iconv-lite');
exports.stylizeString = stylizeString;
exports.pad = pad;
exports.replaceAt = replaceAt;
exports.isPrintable = isPrintable;
exports.stripAllLineFeeds = stripAllLineFeeds;
exports.debugEscapedString = debugEscapedString;
exports.stringFromNullTermBuffer = stringFromNullTermBuffer;
exports.renderSubstr = renderSubstr;
exports.renderStringLength = renderStringLength;
exports.formatByteSizeAbbr = formatByteSizeAbbr;
exports.formatByteSize = formatByteSize;
exports.cleanControlCodes = cleanControlCodes;
exports.createCleanAnsi = createCleanAnsi;
// :TODO: create Unicode verison of this
const VOWELS = [ 'a', 'e', 'i', 'o', 'u' ];
@ -182,6 +190,10 @@ function stringLength(s) {
return s.length;
}
function stripAllLineFeeds(s) {
return s.replace(/\r?\n|[\r\u2028\u2029]/g, '');
}
function debugEscapedString(s) {
return JSON.stringify(s).slice(1, -1);
}
@ -286,20 +298,49 @@ function renderStringLength(s) {
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
//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 ANSI_OPCODES_ALLOWED_CLEAN = [
'C', 'm' ,
'A', 'B', 'D'
'A', 'B', // up, down
'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 pos;
let cleaned = '';
options = options || {};
//
// Loop through |input| adding only allowed ESC
// sequences and literals to |cleaned|
@ -313,6 +354,10 @@ function cleanControlCodes(input) {
cleaned += input.slice(pos, m.index);
}
if(options.all) {
continue;
}
if(ANSI_OPCODES_ALLOWED_CLEAN.indexOf(m[2].charAt(0)) > -1) {
cleaned += m[0];
}
@ -328,61 +373,147 @@ function cleanControlCodes(input) {
return cleaned;
}
function getCleanAnsi(input) {
//
// 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;
function createCleanAnsi(input, options, cb) {
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 {
pos = REGEXP_ANSI_CONTROL_CODES.lastIndex;
m = REGEXP_ANSI_CONTROL_CODES.exec(input);
if(null !== m) {
if(m.index > pos) {
updateGrid(input.slice(pos, m.index), 'literal');
const parserOpts = {
termHeight : options.height,
termWidth : options.width,
};
const parser = new ANSIEscapeParser(parserOpts);
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) {
// :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 => {
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);
});
@ -74,7 +80,7 @@ function prevMenu(callingMenu, formData, extraArgs, cb) {
function nextMenu(callingMenu, formData, extraArgs, cb) {
callingMenu.nextMenu( 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);
});

View File

@ -10,6 +10,7 @@ const stylizeString = require('./string_util.js').stylizeString;
const renderSubstr = require('./string_util.js').renderSubstr;
const renderStringLength = require('./string_util.js').renderStringLength;
const pipeToAnsi = require('./color_codes.js').pipeToAnsi;
const stripAllLineFeeds = require('./string_util.js').stripAllLineFeeds;
// deps
const util = require('util');
@ -102,7 +103,7 @@ function TextView(options) {
renderLength = renderStringLength(textToDraw);
if(renderLength > this.dimens.width) {
if(renderLength >= this.dimens.width) {
if(this.hasFocus) {
if(this.horizScroll) {
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)
//
if(!this.hasDrawnOnce) {
if(!this.text) {
if(_.isUndefined(this.text)) {
return;
}
}
@ -183,7 +184,7 @@ TextView.prototype.setText = function(text, redraw) {
text = text.toString();
}
text = pipeToAnsi(text, this.client); // expand MCI/etc.
text = pipeToAnsi(stripAllLineFeeds(text), this.client); // expand MCI/etc.
var widthDelta = 0;
if(this.text && this.text !== text) {

View File

@ -1,21 +1,21 @@
/* jslint node: true */
'use strict';
var Config = require('./config.js').config;
var art = require('./art.js');
var ansi = require('./ansi_term.js');
var miscUtil = require('./misc_util.js');
var Log = require('./logger.js').log;
var configCache = require('./config_cache.js');
var getFullConfig = require('./config_util.js').getFullConfig;
var asset = require('./asset.js');
var ViewController = require('./view_controller.js').ViewController;
const Config = require('./config.js').config;
const art = require('./art.js');
const ansi = require('./ansi_term.js');
const Log = require('./logger.js').log;
const configCache = require('./config_cache.js');
const getFullConfig = require('./config_util.js').getFullConfig;
const asset = require('./asset.js');
const ViewController = require('./view_controller.js').ViewController;
const Errors = require('./enig_error.js').Errors;
var fs = require('fs');
var paths = require('path');
var async = require('async');
var _ = require('lodash');
var assert = require('assert');
const fs = require('fs');
const paths = require('path');
const async = require('async');
const _ = require('lodash');
const assert = require('assert');
exports.getThemeArt = getThemeArt;
exports.getAvailableThemes = getAvailableThemes;
@ -24,6 +24,7 @@ exports.setClientTheme = setClientTheme;
exports.initAvailableThemes = initAvailableThemes;
exports.displayThemeArt = displayThemeArt;
exports.displayThemedPause = displayThemedPause;
exports.displayThemedPrompt = displayThemedPrompt;
exports.displayThemedAsset = displayThemedAsset;
function refreshThemeHelpers(theme) {
@ -100,10 +101,10 @@ function loadTheme(themeID, cb) {
});
}
var availableThemes = {};
const availableThemes = {};
var IMMUTABLE_MCI_PROPERTIES = [
'maxLength', 'argName', 'submit', 'validate'
const IMMUTABLE_MCI_PROPERTIES = [
'maxLength', 'argName', 'submit', 'validate'
];
function getMergedTheme(menuConfig, promptConfig, theme) {
@ -119,44 +120,44 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
//
var mergedTheme = _.cloneDeep(menuConfig);
if(_.isObject(promptConfig.prompts)) {
mergedTheme.prompts = _.cloneDeep(promptConfig.prompts);
}
//
// Add in data we won't be altering directly from the theme
//
mergedTheme.info = theme.info;
mergedTheme.helpers = theme.helpers;
//
// merge customizer to disallow immutable MCI properties
//
var mciCustomizer = function(objVal, srcVal, key) {
if(_.isObject(promptConfig.prompts)) {
mergedTheme.prompts = _.cloneDeep(promptConfig.prompts);
}
//
// Add in data we won't be altering directly from the theme
//
mergedTheme.info = theme.info;
mergedTheme.helpers = theme.helpers;
//
// merge customizer to disallow immutable MCI properties
//
var mciCustomizer = function(objVal, srcVal, key) {
return IMMUTABLE_MCI_PROPERTIES.indexOf(key) > -1 ? objVal : srcVal;
};
function getFormKeys(fromObj) {
return _.remove(_.keys(fromObj), function pred(k) {
return !isNaN(k); // remove all non-numbers
});
}
function mergeMciProperties(dest, src) {
Object.keys(src).forEach(function mciEntry(mci) {
_.merge(dest[mci], src[mci], mciCustomizer);
});
}
function applyThemeMciBlock(dest, src, formKey) {
if(_.isObject(src.mci)) {
mergeMciProperties(dest, src.mci);
} else {
if(_.has(src, [ formKey, 'mci' ])) {
mergeMciProperties(dest, src[formKey].mci);
}
}
}
function getFormKeys(fromObj) {
return _.remove(_.keys(fromObj), function pred(k) {
return !isNaN(k); // remove all non-numbers
});
}
function mergeMciProperties(dest, src) {
Object.keys(src).forEach(function mciEntry(mci) {
_.mergeWith(dest[mci], src[mci], mciCustomizer);
});
}
function applyThemeMciBlock(dest, src, formKey) {
if(_.isObject(src.mci)) {
mergeMciProperties(dest, src.mci);
} else {
if(_.has(src, [ formKey, 'mci' ])) {
mergeMciProperties(dest, src[formKey].mci);
}
}
}
//
// 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
// there is a generic 'mci' block.
//
function applyToForm(form, menuTheme, formKey) {
if(_.isObject(form.mci)) {
// non-explicit: no MCI code(s) key assumed since we found 'mci' directly under form ID
applyThemeMciBlock(form.mci, menuTheme, formKey);
} else {
var menuMciCodeKeys = _.remove(_.keys(form), function pred(k) {
return k === k.toUpperCase(); // remove anything not uppercase
});
menuMciCodeKeys.forEach(function mciKeyEntry(mciKey) {
var applyFrom;
if(_.has(menuTheme, [ mciKey, 'mci' ])) {
applyFrom = menuTheme[mciKey];
} else {
applyFrom = menuTheme;
}
applyThemeMciBlock(form[mciKey].mci, applyFrom);
});
}
}
function applyToForm(form, menuTheme, formKey) {
if(_.isObject(form.mci)) {
// non-explicit: no MCI code(s) key assumed since we found 'mci' directly under form ID
applyThemeMciBlock(form.mci, menuTheme, formKey);
} else {
var menuMciCodeKeys = _.remove(_.keys(form), function pred(k) {
return k === k.toUpperCase(); // remove anything not uppercase
});
menuMciCodeKeys.forEach(function mciKeyEntry(mciKey) {
var applyFrom;
if(_.has(menuTheme, [ mciKey, 'mci' ])) {
applyFrom = menuTheme[mciKey];
} else {
applyFrom = menuTheme;
}
applyThemeMciBlock(form[mciKey].mci, applyFrom);
});
}
}
[ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) {
_.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) {
var createdFormSection = false;
var mergedThemeMenu = mergedTheme[sectionName][menuName];
if(_.has(theme, [ 'customization', sectionName, menuName ])) {
var menuTheme = theme.customization[sectionName][menuName];
// config block is direct assign/overwrite
// :TODO: should probably be _.merge()
if(menuTheme.config) {
mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config);
}
if('menus' === sectionName) {
if(_.isObject(mergedThemeMenu.form)) {
getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) {
applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey);
});
} else {
if(_.isObject(menuTheme.mci)) {
//
// Not specified at menu level means we apply anything from the
// theme to form.0.mci{}
//
mergedThemeMenu.form = { 0 : { mci : { } } };
mergeMciProperties(mergedThemeMenu.form[0], menuTheme);
createdFormSection = true;
}
}
} else if('prompts' === sectionName) {
// no 'form' or form keys for prompts -- direct to mci
applyToForm(mergedThemeMenu, menuTheme);
}
}
//
// Finished merging for this menu/prompt
//
// If the following conditions are true, set runtime.autoNext to true:
// * This is a menu
// * There is/was no explicit 'form' section
// * There is no 'prompt' specified
//
if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) &&
(createdFormSection || !_.isObject(mergedThemeMenu.form)))
{
mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } );
}
});
});
[ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) {
_.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) {
var createdFormSection = false;
var mergedThemeMenu = mergedTheme[sectionName][menuName];
if(_.has(theme, [ 'customization', sectionName, menuName ])) {
var menuTheme = theme.customization[sectionName][menuName];
// config block is direct assign/overwrite
// :TODO: should probably be _.merge()
if(menuTheme.config) {
mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config);
}
if('menus' === sectionName) {
if(_.isObject(mergedThemeMenu.form)) {
getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) {
applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey);
});
} else {
if(_.isObject(menuTheme.mci)) {
//
// Not specified at menu level means we apply anything from the
// theme to form.0.mci{}
//
mergedThemeMenu.form = { 0 : { mci : { } } };
mergeMciProperties(mergedThemeMenu.form[0], menuTheme);
createdFormSection = true;
}
}
} else if('prompts' === sectionName) {
// no 'form' or form keys for prompts -- direct to mci
applyToForm(mergedThemeMenu, menuTheme);
}
}
//
// Finished merging for this menu/prompt
//
// If the following conditions are true, set runtime.autoNext to true:
// * This is a menu
// * There is/was no explicit 'form' section
// * There is no 'prompt' specified
//
if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) &&
(createdFormSection || !_.isObject(mergedThemeMenu.form)))
{
mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } );
}
});
});
return mergedTheme;
}
function initAvailableThemes(cb) {
var menuConfig;
var promptConfig;
var menuConfig;
var promptConfig;
async.waterfall(
[
function loadMenuConfig(callback) {
getFullConfig(Config.general.menuFile, function gotConfig(err, mc) {
menuConfig = mc;
callback(err);
});
},
function loadPromptConfig(callback) {
getFullConfig(Config.general.promptFile, function gotConfig(err, pc) {
promptConfig = pc;
callback(err);
});
},
function loadMenuConfig(callback) {
getFullConfig(Config.general.menuFile, function gotConfig(err, mc) {
menuConfig = mc;
callback(err);
});
},
function loadPromptConfig(callback) {
getFullConfig(Config.general.promptFile, function gotConfig(err, pc) {
promptConfig = pc;
callback(err);
});
},
function getDir(callback) {
fs.readdir(Config.paths.themes, function dirRead(err, files) {
callback(err, files);
@ -294,7 +295,7 @@ function initAvailableThemes(cb) {
filtered.forEach(function themeEntry(themeId) {
loadTheme(themeId, function themeLoaded(err, theme, themePath) {
if(!err) {
availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme);
availableThemes[themeId] = getMergedTheme(menuConfig, promptConfig, theme);
configCache.on('recached', function recached(path) {
if(themePath === path) {
@ -339,32 +340,32 @@ function getRandomTheme() {
}
function setClientTheme(client, themeId) {
var desc;
try {
client.currentTheme = getAvailableThemes()[themeId];
desc = 'Set client theme';
} catch(e) {
client.currentTheme = getAvailableThemes()[Config.defaults.theme];
desc = 'Failed setting theme by supplied ID; Using default';
}
client.log.debug( { themeId : themeId, info : client.currentTheme.info }, desc);
var desc;
try {
client.currentTheme = getAvailableThemes()[themeId];
desc = 'Set client theme';
} catch(e) {
client.currentTheme = getAvailableThemes()[Config.defaults.theme];
desc = 'Failed setting theme by supplied ID; Using default';
}
client.log.debug( { themeId : themeId, info : client.currentTheme.info }, desc);
}
function getThemeArt(options, cb) {
//
// options - required:
// name
// client
// name
//
// options - optional
// themeId
// asAnsi
// readSauce
// random
// client - needed for user's theme/etc.
// themeId
// asAnsi
// 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;
} else {
options.themeId = Config.defaults.theme;
@ -438,9 +439,13 @@ function getThemeArt(options, cb) {
],
function complete(err, artInfo) {
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'.
//
function displayThemedPause(options, cb) {
//
// options.client
// options clearPrompt
//
assert(_.isObject(options.client));
function displayThemedPause(client, options, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
}
if(!_.isBoolean(options.clearPrompt)) {
options.clearPrompt = true;
}
// :TODO: Support animated pause prompts. Probably via MCI with AnimatedView
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();
}
);
const promptOptions = Object.assign( {}, options, { pause : true } );
return displayThemedPrompt('pause', client, promptOptions, cb);
}
function displayThemedAsset(assetSpec, client, options, cb) {

View File

@ -25,9 +25,7 @@ function ToggleMenuView (options) {
*/
this.updateSelection = function() {
//assert(!self.positionCacheExpired);
assert(this.focusedItemIndex >= 0 && this.focusedItemIndex <= self.items.length);
self.redraw();
};
}
@ -74,28 +72,38 @@ ToggleMenuView.prototype.setFocus = function(focused) {
this.redraw();
};
ToggleMenuView.prototype.onKeyPress = function(ch, key) {
if(key) {
var needsUpdate;
if(this.isKeyMapped('right', key.name) || this.isKeyMapped('down', key.name)) {
if(this.items.length - 1 === 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;
}
ToggleMenuView.prototype.focusNext = function() {
if(this.items.length - 1 === this.focusedItemIndex) {
this.focusedItemIndex = 0;
} else {
this.focusedItemIndex++;
}
if(needsUpdate) {
this.updateSelection();
return;
this.updateSelection();
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) {
var self = this;
@ -458,7 +474,7 @@ function generatePasswordDerivedKeySalt(cb) {
function generatePasswordDerivedKey(password, salt, cb) {
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) {
cb(err);
} else {

View File

@ -1,17 +1,15 @@
/* jslint node: true */
'use strict';
var MenuModule = require('./menu_module.js').MenuModule;
var ViewController = require('./view_controller.js').ViewController;
var theme = require('./theme.js');
var sysValidate = require('./system_view_validate.js');
const MenuModule = require('./menu_module.js').MenuModule;
const ViewController = require('./view_controller.js').ViewController;
const theme = require('./theme.js');
const sysValidate = require('./system_view_validate.js');
var async = require('async');
var assert = require('assert');
var _ = require('lodash');
var moment = require('moment');
exports.getModule = UserConfigModule;
const async = require('async');
const assert = require('assert');
const _ = require('lodash');
const moment = require('moment');
exports.moduleInfo = {
name : 'User Configuration',
@ -19,7 +17,7 @@ exports.moduleInfo = {
author : 'NuSkooler',
};
var MciCodeIds = {
const MciCodeIds = {
RealName : 1,
BirthDate : 2,
Sex : 3,
@ -37,192 +35,187 @@ var MciCodeIds = {
SaveCancel : 25,
};
function UserConfigModule(options) {
MenuModule.call(this, options);
exports.getModule = class UserConfigModule extends MenuModule {
constructor(options) {
super(options);
var self = this;
self.getView = function(viewId) {
return self.viewControllers.menu.getView(viewId);
};
const self = this;
self.setViewText = function(viewId, text) {
var v = self.getView(viewId);
if(v) {
v.setText(text);
}
};
this.menuMethods = {
//
// Validation support
//
validateEmailAvail : function(data, cb) {
this.menuMethods = {
//
// If nothing changed, we know it's OK
// Validation support
//
if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) {
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);
}
validateEmailAvail : function(data, cb) {
//
// New password if it's not empty
// If nothing changed, we know it's OK
//
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);
if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) {
return cb(null);
}
});
},
};
}
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) {
return ti.themeId === self.client.user.properties.theme_id;
});
callback(null);
// Otherwise we can use the standard system method
return sysValidate.validateEmailAvail(data, cb);
},
function populateViews(callback) {
var user = self.client.user;
self.setViewText(MciCodeIds.RealName, user.properties.real_name);
self.setViewText(MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD'));
self.setViewText(MciCodeIds.Sex, user.properties.sex);
self.setViewText(MciCodeIds.Loc, user.properties.location);
self.setViewText(MciCodeIds.Affils, user.properties.affiliation);
self.setViewText(MciCodeIds.Email, user.properties.email_address);
self.setViewText(MciCodeIds.Web, user.properties.web_address);
self.setViewText(MciCodeIds.TermHeight, user.properties.term_height.toString());
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);
var themeView = self.getView(MciCodeIds.Theme);
if(themeView) {
themeView.setItems(_.map(self.availThemeInfo, 'name'));
themeView.setFocusItemIndex(currentThemeIdIndex);
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);
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!
}
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,
};
callback(null);
}
],
function complete(err) {
// 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
//
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) {
self.client.log.warn( { error : err.toString() }, 'User configuration failed to init');
self.prevMenu();
} else {
cb(null);
return cb(err);
}
}
);
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½
const setClientTheme = require('./theme.js').setClientTheme;
const clientConnections = require('./client_connections.js').clientConnections;
const userDb = require('./database.js').dbs.user;
const StatLog = require('./stat_log.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: check Config max failed logon attempts/etc. - set err.maxAttempts = true
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);
}
);
return 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 */
'use strict';
let uuid = require('node-uuid');
let assert = require('assert');
let _ = require('lodash');
let createHash = require('crypto').createHash;
const createHash = require('crypto').createHash;
exports.createNamedUUID = createNamedUUID;
@ -13,9 +10,9 @@ function createNamedUUID(namespaceUuid, key) {
// v5 UUID generation code based on the work here:
// https://github.com/download13/uuidv5/blob/master/uuid.js
//
if(!Buffer.isBuffer(namespaceUuid)) {
namespaceUuid = new Buffer(namespaceUuid);
}
if(!Buffer.isBuffer(namespaceUuid)) {
namespaceUuid = new Buffer(namespaceUuid);
}
if(!Buffer.isBuffer(key)) {
key = new Buffer(key);

View File

@ -44,7 +44,7 @@ function VerticalMenuView(options) {
self.viewWindow = {
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;
}
let row = this.position.row;
for(let i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) {
this.items[i].row = row;
row += this.itemSpacing + 1;
this.items[i].focused = this.focusedItemIndex === i;
this.drawItem(i);
if(this.items.length) {
let row = this.position.row;
for(let i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) {
this.items[i].row = row;
row += this.itemSpacing + 1;
this.items[i].focused = this.focusedItemIndex === i;
this.drawItem(i);
}
}
};
@ -171,7 +173,7 @@ VerticalMenuView.prototype.getData = function() {
VerticalMenuView.prototype.setItems = function(items) {
// if we have items already, save off their drawing area so we don't leave fragments at redraw
if(this.items && this.items.length) {
this.oldDimens = this.dimens;
this.oldDimens = Object.assign({}, this.dimens);
}
VerticalMenuView.super_.prototype.setItems.call(this, items);
@ -179,6 +181,14 @@ VerticalMenuView.prototype.setItems = function(items) {
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!
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 asset = require('./asset.js');
var ansi = require('./ansi_term.js');
const Log = require('./logger.js');
// deps
var events = require('events');
@ -74,8 +73,9 @@ function ViewController(options) {
self.switchFocus(actionForKey.viewId);
self.submitForm(key);
} else if(_.isString(actionForKey.action)) {
const formData = self.getFocusedView() ? self.getFormData() : { };
self.handleActionWrapper(
{ ch : ch, key : key }, // formData
Object.assign( { ch : ch, key : key }, formData ), // formData + key info
actionForKey); // actionBlock
}
} else {
@ -116,6 +116,7 @@ function ViewController(options) {
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) {
// :TODO: these fields should be part of menu.json sensitiveMembers[]
var safeFormData = _.cloneDeep(formData);
@ -143,8 +144,10 @@ function ViewController(options) {
var mci = mciMap[name];
var view = self.mciViewFactory.createFromMCI(mci);
if(view && false === self.noInput) {
view.on('action', self.viewActionListener);
if(view) {
if(false === self.noInput) {
view.on('action', self.viewActionListener);
}
self.addView(view);
}
@ -181,52 +184,52 @@ function ViewController(options) {
propAsset = asset.getViewPropertyAsset(conf[propName]);
if(propAsset) {
switch(propAsset.type) {
case 'config' :
propValue = asset.resolveConfigAsset(conf[propName]);
break;
case 'sysStat' :
propValue = asset.resolveSystemStatAsset(conf[propName]);
break;
case 'config' :
propValue = asset.resolveConfigAsset(conf[propName]);
break;
case 'sysStat' :
propValue = asset.resolveSystemStatAsset(conf[propName]);
break;
// :TODO: handle @art (e.g. text : @art ...)
// :TODO: handle @art (e.g. text : @art ...)
case 'method' :
case 'systemMethod' :
if('validate' === propName) {
// :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 {
case 'method' :
case 'systemMethod' :
if('validate' === propName) {
// :TODO: handle propAsset.location for @method script specification
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 {
// 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);
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) {
// :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 :
propValue = propValue = conf[propName];
break;
default :
propValue = propValue = conf[propName];
break;
}
} else {
propValue = conf[propName];
@ -447,6 +450,12 @@ ViewController.prototype.setFocus = function(focused) {
this.setViewFocusWithEvents(this.focusedView, focused);
};
ViewController.prototype.resetInitialFocus = function() {
if(this.formInitialFocusId) {
return this.switchFocus(this.formInitialFocusId);
}
};
ViewController.prototype.switchFocus = function(id) {
//
// Perform focus switching validation now
@ -471,15 +480,19 @@ ViewController.prototype.switchFocus = function(id) {
};
ViewController.prototype.nextFocus = function() {
var nextId;
let nextFocusView = this.focusedView ? this.focusedView : this.views[this.firstId];
if(!this.focusedView) {
nextId = this.views[this.firstId].id;
} else {
nextId = this.views[this.focusedView.id].nextId;
// find the next view that accepts focus
while(nextFocusView && nextFocusView.nextId) {
nextFocusView = this.getView(nextFocusView.nextId);
if(!nextFocusView || nextFocusView.acceptsFocus) {
break;
}
}
this.switchFocus(nextId);
if(nextFocusView && this.focusedView !== nextFocusView) {
this.switchFocus(nextFocusView.id);
}
};
ViewController.prototype.setViewOrder = function(order) {
@ -498,7 +511,6 @@ ViewController.prototype.setViewOrder = function(order) {
}
if(viewIdOrder.length > 0) {
var view;
var count = viewIdOrder.length - 1;
for(var i = 0; i < count; ++i) {
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) {
var actionBlock = menuSubmit[c];
if(_.isEqual(formData.value, actionBlock.value, self.actionBlockValueComparator)) {
if(_.isEqualWith(formData.value, actionBlock.value, self.actionBlockValueComparator)) {
self.handleActionWrapper(formData, actionBlock);
break; // there an only be one...
}
@ -589,6 +601,33 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) {
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) {
self.redrawAll(initialFocusId);
callback(null);
@ -616,7 +655,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
var self = this;
var formIdKey = options.formId ? options.formId.toString() : '0';
var initialFocusId = 1; // default to first
this.formInitialFocusId = 1; // default to first
var formConfig;
// :TODO: honor options.withoutForm
@ -669,7 +708,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
function applyViewConfiguration(callback) {
if(_.isObject(formConfig)) {
self.applyViewConfig(formConfig, function configApplied(err, info) {
initialFocusId = info.initialFocusId;
self.formInitialFocusId = info.initialFocusId;
callback(err);
});
} else {
@ -706,7 +745,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
for(var c = 0; c < confForFormId.length; ++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);
break; // there an only be one...
}
@ -744,12 +783,12 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
callback(null);
},
function drawAllViews(callback) {
self.redrawAll(initialFocusId);
self.redrawAll(self.formInitialFocusId);
callback(null);
},
function setInitialViewFocus(callback) {
if(initialFocusId) {
self.switchFocus(initialFocusId);
if(self.formInitialFocusId) {
self.switchFocus(self.formInitialFocusId);
}
callback(null);
}
@ -792,7 +831,7 @@ ViewController.prototype.getFormData = function(key) {
}
*/
var formData = {
const formData = {
id : this.formId,
submitId : this.focusedView.id,
value : {},
@ -802,36 +841,24 @@ ViewController.prototype.getFormData = function(key) {
formData.key = key;
}
var viewData;
var view;
for(var id in this.views) {
let viewData;
_.each(this.views, view => {
try {
view = this.views[id];
viewData = view.getData();
if(!_.isUndefined(viewData)) {
if(_.isString(view.submitArgName)) {
formData.value[view.submitArgName] = viewData;
} else {
formData.value[id] = viewData;
}
// don't fill forms with static, non user-editable data data
if(!view.acceptsInput) {
return;
}
viewData = view.getData();
if(_.isUndefined(viewData)) {
return;
}
formData.value[ view.submitArgName ? view.submitArgName : view.id ] = viewData;
} catch(e) {
this.client.log.error(e); // :TODO: Log better ;)
this.client.log.error( { error : e.message }, 'Exception caught gathering form data' );
}
}
});
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½
## High Level Feature Overview
* Multi platform: Anywhere Node.js runs likely works (tested under Linux and OS X)
* Multi node support
* **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JS based mods
* 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
* [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.
* [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support
* Renegade style pipe codes
* [SQLite](http://sqlite.org/) storage of users and message areas
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password storage
* Door support including common dropfile formats and [DOSEMU](http://www.dosemu.org/)
* [Bunyan](https://github.com/trentm/node-bunyan) logging
* Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows)
* Unlimited multi node support (for all those BBS "callers"!)
* **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods
* 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
* [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
* [SAUCE](http://www.acid.org/info/sauce/sauce.htm) support
* Renegade style pipe color codes
* [SQLite](http://sqlite.org/) storage of users, message areas, and so on
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption
* [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
* [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 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.
**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
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
* [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
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
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 `fb` command has tools for managing file bases. For example, to import existing files found within **all** storage locations tied to an area and set tags `tag1` and `tag2` to each import:
```bash
oputil.js fb scan some_area --tags tag1,tag2
```
See `oputil.js fb --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
```
For other environments such as Windows, see **The Manual Way** below.
## The Manual Way
For Windows environments or if you simply like to do things manually, read on...
### Prerequisites
* [Node.js](https://nodejs.org/) version **v4.2.x or higher**
* :information_source: It is suggested to use [nvm](https://github.com/creationix/nvm) to manage your Node/io.js installs
* [Node.js](https://nodejs.org/) version **v6.x or higher**
* :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
* 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!):
```bash
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.0/install.sh | bash
nvm install 4.4.0
nvm use 4.4.0
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.0/install.sh | bash
nvm install 6
nvm use 6
```
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
```bash
@ -56,9 +60,11 @@ The main system configuration is handled via `~/.config/enigma-bbs/config.hjson`
#### Via oputil.js
`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
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
}
servers: {
loginServers: {
ssh: {
privateKeyPass: YOUR_PK_PASS
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
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
general: {
/* Can also specify a full path */
menuFile: mybbs.hjson
}
```
(You can start by copying the default `menu.hjson` to `mybbs.hjson`)
## The Basics
Like all configuration within ENiGMA½, menu configuration is done via a HJSON file. This file is located in the `mods` directory: `mods/menu.hjson`.
@ -37,6 +38,7 @@ Now let's look at `matrix`, the `next` entry from `telnetConnected`:
```hjson
matrix: {
art: matrix
desc: Login Matrix
form: {
0: {
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
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_SOURCE=${ENIGMA_SOURCE:=https://github.com/NuSkooler/enigma-bbs.git}
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½ 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...
@ -103,8 +103,23 @@ enigma_footer() {
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:
cd ${ENIGMA_INSTALL_DIR}
./oputil.js config --new
cd ${ENIGMA_INSTALL_DIR}
./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
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 */
'use strict';
let MenuModule = require('../core/menu_module.js').MenuModule;
let DropFile = require('../core/dropfile.js').DropFile;
let door = require('../core/door.js');
let theme = require('../core/theme.js');
let ansi = require('../core/ansi_term.js');
const MenuModule = require('../core/menu_module.js').MenuModule;
const DropFile = require('../core/dropfile.js').DropFile;
const door = require('../core/door.js');
const theme = require('../core/theme.js');
const ansi = require('../core/ansi_term.js');
let async = require('async');
let assert = require('assert');
let paths = require('path');
let _ = require('lodash');
let mkdirs = require('fs-extra').mkdirs;
const async = require('async');
const assert = require('assert');
const paths = require('path');
const _ = require('lodash');
const mkdirs = require('fs-extra').mkdirs;
// :TODO: This should really be a system module... needs a little work to allow for such
exports.getModule = AbracadabraModule;
let activeDoorNodeInstances = {};
const activeDoorNodeInstances = {};
exports.moduleInfo = {
name : 'Abracadabra',
@ -60,20 +58,20 @@ exports.moduleInfo = {
: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!
assert(_.isString(this.config.name, 'Config \'name\' is required'));
assert(_.isString(this.config.dropFileType, 'Config \'dropFileType\' is required'));
assert(_.isString(this.config.cmd, 'Config \'cmd\' is required'));
this.config.nodeMax = this.config.nodeMax || 0;
this.config.args = this.config.args || [];
this.config.nodeMax = this.config.nodeMax || 0;
this.config.args = this.config.args || [];
}
/*
:TODO:
@ -82,7 +80,9 @@ function AbracadabraModule(options) {
* Font support ala all other menus... or does this just work?
*/
this.initSequence = function() {
initSequence() {
const self = this;
async.series(
[
function validateNodeCount(callback) {
@ -99,14 +99,15 @@ function AbracadabraModule(options) {
if(_.isString(self.config.tooManyArt)) {
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'));
});
});
} else {
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'));
});
}
@ -146,54 +147,51 @@ function AbracadabraModule(options) {
}
}
);
};
}
this.runDoor = function() {
runDoor() {
const exeInfo = {
cmd : self.config.cmd,
args : self.config.args,
io : self.config.io || 'stdio',
encoding : self.config.encoding || self.client.term.outputEncoding,
dropFile : self.dropFile.fileName,
node : self.client.node,
//inhSocket : self.client.output._handle.fd,
cmd : this.config.cmd,
args : this.config.args,
io : this.config.io || 'stdio',
encoding : this.config.encoding || this.client.term.outputEncoding,
dropFile : this.dropFile.fileName,
node : this.client.node,
//inhSocket : this.client.output._handle.fd,
};
const doorInstance = new door.Door(self.client, exeInfo);
const doorInstance = new door.Door(this.client, exeInfo);
doorInstance.once('finished', () => {
//
// Try to clean up various settings such as scroll regions that may
// have been set within the door
//
self.client.term.rawWrite(
this.client.term.rawWrite(
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.goto(self.client.term.termHeight, 0) +
ansi.goto(this.client.term.termHeight, 0) +
'\r\n\r\n'
);
self.prevMenu();
this.prevMenu();
});
self.client.term.write(ansi.resetScreen());
this.client.term.write(ansi.resetScreen());
doorInstance.run();
};
}
}
require('util').inherits(AbracadabraModule, MenuModule);
leave() {
super.leave();
if(!this.lastError) {
activeDoorNodeInstances[this.config.name] -= 1;
}
}
AbracadabraModule.prototype.leave = function() {
AbracadabraModule.super_.prototype.leave.call(this);
if(!this.lastError) {
activeDoorNodeInstances[this.config.name] -= 1;
finishedLoading() {
this.runDoor();
}
};
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: ENH: Support nodeMax and tooManyArt
exports.getModule = BBSLinkModule;
exports.moduleInfo = {
name : 'BBSLink',
desc : 'BBSLink Access Module',
author : 'NuSkooler',
};
exports.getModule = class BBSLinkModule extends MenuModule {
constructor(options) {
super(options);
function BBSLinkModule(options) {
MenuModule.call(this, options);
this.config = options.menuConfig.config;
this.config.host = this.config.host || 'games.bbslink.net';
this.config.port = this.config.port || 23;
}
var self = this;
this.config = options.menuConfig.config;
this.config.host = this.config.host || 'games.bbslink.net';
this.config.port = this.config.port || 23;
this.initSequence = function() {
var token;
var randomKey;
var clientTerminated;
initSequence() {
let token;
let randomKey;
let clientTerminated;
const self = this;
async.series(
[
@ -180,17 +178,17 @@ function BBSLinkModule(options) {
}
}
);
};
}
this.simpleHttpRequest = function(path, headers, cb) {
var getOpts = {
simpleHttpRequest(path, headers, cb) {
const getOpts = {
host : this.config.host,
path : path,
headers : headers,
};
var req = http.get(getOpts, function response(resp) {
var data = '';
const req = http.get(getOpts, function response(resp) {
let data = '';
resp.on('data', function chunk(c) {
data += c;
@ -205,7 +203,5 @@ function BBSLinkModule(options) {
req.on('error', function reqErr(err) {
cb(err);
});
};
}
require('util').inherits(BBSLinkModule, MenuModule);
}
};

View File

@ -17,17 +17,13 @@ const _ = require('lodash');
// :TODO: add notes field
exports.getModule = BBSListModule;
const moduleInfo = {
const moduleInfo = exports.moduleInfo = {
name : 'BBS List',
desc : 'List of other BBSes',
author : 'Andrew Pamment',
packageName : 'com.magickabbs.enigma.bbslist'
};
exports.moduleInfo = moduleInfo;
const MciViewIds = {
view : {
BBSList : 1,
@ -69,13 +65,106 @@ const SELECTED_MCI_NAME_TO_ENTRY = {
SelectedBBSNotes : 'notes',
};
function BBSListModule(options) {
MenuModule.call(this, options);
exports.getModule = class BBSListModule extends MenuModule {
constructor(options) {
super(options);
const self = this;
const config = this.menuConfig.config;
const self = this;
this.menuMethods = {
//
// Validators
//
viewValidationListener : function(err, cb) {
const errMsgView = self.viewControllers.add.getView(MciViewIds.add.Error);
if(errMsgView) {
if(err) {
errMsgView.setText(err.message);
} else {
errMsgView.clearText();
}
}
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(
[
function beforeDisplayArt(callback) {
@ -92,39 +181,42 @@ function BBSListModule(options) {
self.finishedLoading();
}
);
};
}
this.drawSelectedEntry = function(entry) {
drawSelectedEntry(entry) {
if(!entry) {
Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => {
self.setViewText(MciViewIds.view[mciName], '');
this.setViewText('view', MciViewIds.view[mciName], '');
});
} else {
const youSubmittedFormat = config.youSubmittedFormat || '{submitter} (You!)';
const youSubmittedFormat = this.menuConfig.youSubmittedFormat || '{submitter} (You!)';
Object.keys(SELECTED_MCI_NAME_TO_ENTRY).forEach(mciName => {
const t = entry[SELECTED_MCI_NAME_TO_ENTRY[mciName]];
if(MciViewIds.view[mciName]) {
if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == self.client.user.userId) {
self.setViewText(MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry));
if('SelectedBBSSubmitter' == mciName && entry.submitterUserId == this.client.user.userId) {
this.setViewText('view',MciViewIds.view.SelectedBBSSubmitter, stringFormat(youSubmittedFormat, entry));
} 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 focusListFormat = config.focusListFormat || '{bbsName}';
entriesView.setItems(self.entries.map( e => stringFormat(listFormat, e) ) );
entriesView.setFocusItems(self.entries.map( e => stringFormat(focusListFormat, e) ) );
};
entriesView.setItems(this.entries.map( e => stringFormat(listFormat, e) ) );
entriesView.setFocusItems(this.entries.map( e => stringFormat(focusListFormat, e) ) );
}
displayBBSList(clearScreen, cb) {
const self = this;
this.displayBBSList = function(clearScreen, cb) {
async.waterfall(
[
function clearAndDisplayArt(callback) {
@ -135,7 +227,7 @@ function BBSListModule(options) {
self.client.term.rawWrite(ansi.resetScreen());
}
theme.displayThemedAsset(
config.art.entries,
self.menuConfig.config.art.entries,
self.client,
{ font : self.menuConfig.font, trailingLF : false },
(err, artData) => {
@ -238,9 +330,11 @@ function BBSListModule(options) {
}
}
);
};
}
displayAddScreen(cb) {
const self = this;
this.displayAddScreen = function(cb) {
async.waterfall(
[
function clearAndDisplayArt(callback) {
@ -248,7 +342,7 @@ function BBSListModule(options) {
self.client.term.rawWrite(ansi.resetScreen());
theme.displayThemedAsset(
config.art.add,
self.menuConfig.config.art.add,
self.client,
{ font : self.menuConfig.font },
(err, artData) => {
@ -284,117 +378,17 @@ function BBSListModule(options) {
}
}
);
};
}
this.clearAddForm = function() {
clearAddForm() {
[ 'BBSName', 'Sysop', 'Telnet', 'Www', 'Location', 'Software', 'Error', 'Notes' ].forEach( mciName => {
const v = self.viewControllers.add.getView(MciViewIds.add[mciName]);
if(v) {
v.setText('');
}
this.setViewText('add', MciViewIds.add[mciName], '');
});
};
}
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();
}
}
initDatabase(cb) {
const self = this;
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(
[
function openDatabase(callback) {
@ -422,15 +416,15 @@ function BBSListModule(options) {
callback(null);
}
],
cb
err => {
return cb(err);
}
);
};
}
}
require('util').inherits(BBSListModule, MenuModule);
BBSListModule.prototype.beforeArt = function(cb) {
BBSListModule.super_.prototype.beforeArt.call(this, err => {
return err ? cb(err) : this.initDatabase(cb);
});
beforeArt(cb) {
super.beforeArt(err => {
return err ? cb(err) : this.initDatabase(cb);
});
}
};

View File

@ -1,7 +1,7 @@
/* jslint node: true */
'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');
// deps
@ -33,8 +33,9 @@ var MciViewIds = {
InputArea : 3,
};
// :TODO: needs converted to ES6 MenuModule subclass
function ErcClientModule(options) {
MenuModule.call(this, options);
MenuModule.prototype.ctorShim.call(this, options);
const self = this;
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
};
exports.getModule = LastCallersModule;
var MciCodeIds = {
const MciCodeIds = {
CallerList : 1,
};
function LastCallersModule(options) {
MenuModule.call(this, options);
}
exports.getModule = class LastCallersModule extends MenuModule {
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 vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
const self = this;
const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );
let loginHistory;
let callersView;
let loginHistory;
let callersView;
async.series(
[
function callParentMciReady(callback) {
LastCallersModule.super_.prototype.mciReady.call(self, mciData, callback);
},
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
noInput : true,
};
async.series(
[
function loadFromConfig(callback) {
const loadOpts = {
callingMenu : self,
mciMap : mciData.menu,
noInput : true,
};
vc.loadFromMenuConfig(loadOpts, callback);
},
function fetchHistory(callback) {
callersView = vc.getView(MciCodeIds.CallerList);
vc.loadFromMenuConfig(loadOpts, callback);
},
function fetchHistory(callback) {
callersView = vc.getView(MciCodeIds.CallerList);
// fetch up
StatLog.getSystemLogEntries('user_login_history', StatLog.Order.TimestampDesc, 200, (err, lh) => {
loginHistory = lh;
// fetch up
StatLog.getSystemLogEntries('user_login_history', StatLog.Order.TimestampDesc, 200, (err, lh) => {
loginHistory = lh;
if(self.menuConfig.config.hideSysOpLogin) {
const noOpLoginHistory = loginHistory.filter(lh => {
return false === isRootUserId(parseInt(lh.log_value)); // log_value=userId
});
if(self.menuConfig.config.hideSysOpLogin) {
const noOpLoginHistory = loginHistory.filter(lh => {
return false === isRootUserId(parseInt(lh.log_value)); // log_value=userId
});
//
// 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.
//
if(noOpLoginHistory.length >= callersView.dimens.height || 'always' === self.menuConfig.config.hideSysOpLogin) {
loginHistory = noOpLoginHistory;
}
}
//
// Finally, we need to trim up the list to the needed size
//
loginHistory = loginHistory.slice(0, callersView.dimens.height);
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();
});
//
// 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.
//
if(noOpLoginHistory.length >= callersView.dimens.height || 'always' === self.menuConfig.config.hideSysOpLogin) {
loginHistory = noOpLoginHistory;
}
}
//
// Finally, we need to trim up the list to the needed size
//
loginHistory = loginHistory.slice(0, callersView.dimens.height);
return callback(err);
});
},
callback
);
},
function populateList(callback) {
const listFormat = self.menuConfig.config.listFormat || '{userName} - {location} - {affiliation} - {ts}';
function getUserNamesAndProperties(callback) {
const getPropOpts = {
names : [ 'location', 'affiliation' ]
};
callersView.setItems(_.map(loginHistory, ce => stringFormat(listFormat, ce) ) );
const dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD';
callersView.redraw();
return callback(null);
}
],
(err) => {
if(err) {
self.client.log.error( { error : err.toString() }, 'Error loading last callers');
}
cb(err);
}
);
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 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.
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: {
//
@ -34,7 +52,7 @@
//
sshConnectedNewUser: {
art: CONNECT
next: newUserApplicationSsh
next: newUserApplicationPreSsh
options: { nextTimeout: 1500 }
}
@ -60,7 +78,7 @@
}
{
value: { 1: 1 },
action: @menu:newUserApplication
action: @menu:newUserApplicationPre
}
{
value: { 1: 2 },
@ -162,12 +180,24 @@
desc: Logging Off
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: {
module: nua
art: NUA
options: {
menuFlags: [ "noHistory" ]
}
next: [
{
// 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
// Canceling this form logs off vs falling back to matrix
@ -275,6 +316,9 @@
newUserApplicationSsh: {
art: NUA
fallback: logoff
options: {
menuFlags: [ "noHistory" ]
}
next: newUserFeedbackToSysOpPreamble
form: {
0: {
@ -350,7 +394,7 @@
}
{
value: { "submission" : 1 }
action: @systemMethod:prevMenu
action: @systemMethod:logoff
}
]
}
@ -358,7 +402,7 @@
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
action: @systemMethod:logoff
}
]
}
@ -708,6 +752,10 @@
value: { command: "D" }
action: @menu:doorMenu
}
{
value: { command: "F" }
action: @menu:fileBase
}
{
value: { command: "U" }
action: @menu:mainMenuUserList
@ -1696,6 +1744,7 @@
HM1: {
// :TODO: (#)Jump/(L)Index (msg list)/Last
items: [ "prev", "next", "reply", "quit", "help" ]
focusItemIndex: 1
}
}
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
////////////////////////////////////////////////////////////////////////

View File

@ -6,7 +6,6 @@ const MenuModule = require('../core/menu_module.js').MenuModule;
const ViewController = require('../core/view_controller.js').ViewController;
const messageArea = require('../core/message_area.js');
const displayThemeArt = require('../core/theme.js').displayThemeArt;
const displayThemedPause = require('../core/theme.js').displayThemedPause;
const resetScreen = require('../core/ansi_term.js').resetScreen;
const stringFormat = require('../core/string_format.js');
@ -14,8 +13,6 @@ const stringFormat = require('../core/string_format.js');
const async = require('async');
const _ = require('lodash');
exports.getModule = MessageAreaListModule;
exports.moduleInfo = {
name : 'Message Area List',
desc : 'Module for listing / choosing message areas',
@ -36,152 +33,145 @@ exports.moduleInfo = {
|TI Current time
*/
const MCICodesIDs = {
const MciViewIds = {
AreaList : 1,
SelAreaInfo1 : 2,
SelAreaInfo2 : 3,
};
function MessageAreaListModule(options) {
MenuModule.call(this, options);
exports.getModule = class MessageAreaListModule extends MenuModule {
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(
self.client.user.properties.message_conf_tag,
{ client : self.client }
);
const self = this;
this.menuMethods = {
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) {
setTimeout( () => {
self.prevMenu(cb);
}, timeout);
};
messageArea.changeMessageArea(self.client, areaTag, err => {
if(err) {
self.client.term.pipeWrite(`\n|00Cannot change area: ${err.message}\n`);
this.menuMethods = {
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
self.prevMenuOnTimeout(1000, cb);
} else {
if(_.isString(area.art)) {
const dispOptions = {
client : self.client,
name : area.art,
};
messageArea.changeMessageArea(self.client, areaTag, err => {
if(err) {
self.client.term.pipeWrite(`\n|00Cannot change area: ${err.message}\n`);
self.client.term.rawWrite(resetScreen());
self.prevMenuOnTimeout(1000, cb);
} else {
if(_.isString(area.art)) {
const dispOptions = {
client : self.client,
name : area.art,
};
self.client.term.rawWrite(resetScreen());
displayThemeArt(dispOptions, () => {
// pause by default, unless explicitly told not to
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);
displayThemeArt(dispOptions, () => {
// pause by default, unless explicitly told not to
if(_.has(area, 'options.pause') && false === area.options.pause) {
return self.prevMenuOnTimeout(1000, cb);
} else {
self.pausePrompt( () => {
return self.prevMenu(cb);
});
}
});
} else {
return self.prevMenu(cb);
}
}
}
});
} else {
return cb(null);
});
} else {
return cb(null);
}
}
}
};
};
}
this.setViewText = function(id, text) {
const v = self.viewControllers.areaList.getView(id);
if(v) {
v.setText(text);
}
};
prevMenuOnTimeout(timeout, cb) {
setTimeout( () => {
return this.prevMenu(cb);
}, timeout);
}
this.updateGeneralAreaInfoViews = function(areaIndex) {
updateGeneralAreaInfoViews(areaIndex) {
// :TODO: these concepts have been replaced with the {someKey} style formatting - update me!
/* experimental: not yet avail
const areaInfo = self.messageAreas[areaIndex];
[ MCICodesIDs.SelAreaInfo1, MCICodesIDs.SelAreaInfo2 ].forEach(mciId => {
[ MciViewIds.SelAreaInfo1, MciViewIds.SelAreaInfo2 ].forEach(mciId => {
const v = self.viewControllers.areaList.getView(mciId);
if(v) {
v.setFormatObject(areaInfo.area);
}
});
*/
};
}
}
require('util').inherits(MessageAreaListModule, MenuModule);
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);
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
],
function complete(err) {
return cb(err);
}
);
};
const self = this;
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 */
'use strict';
let FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule;
//var Message = require('../core/message.js').Message;
let persistMessage = require('../core/message_area.js').persistMessage;
let user = require('../core/user.js');
const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule;
const persistMessage = require('../core/message_area.js').persistMessage;
let _ = require('lodash');
let async = require('async');
exports.getModule = AreaPostFSEModule;
const _ = require('lodash');
const async = require('async');
exports.moduleInfo = {
name : 'Message Area Post',
@ -17,56 +13,55 @@ exports.moduleInfo = {
author : 'NuSkooler',
};
function AreaPostFSEModule(options) {
FullScreenEditorModule.call(this, options);
exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule {
constructor(options) {
super(options);
var self = this;
const self = this;
// we're posting, so always start with 'edit' mode
this.editorMode = 'edit';
// we're posting, so always start with 'edit' mode
this.editorMode = 'edit';
this.menuMethods.editModeMenuSave = function(formData, extraArgs, cb) {
this.menuMethods.editModeMenuSave = function(formData, extraArgs, cb) {
var msg;
async.series(
[
function getMessageObject(callback) {
self.getMessage(function gotMsg(err, msgObj) {
msg = msgObj;
return callback(err);
});
},
function saveMessage(callback) {
return persistMessage(msg, callback);
},
function updateStats(callback) {
self.updateUserStats(callback);
var msg;
async.series(
[
function getMessageObject(callback) {
self.getMessage(function gotMsg(err, msgObj) {
msg = msgObj;
return callback(err);
});
},
function saveMessage(callback) {
return persistMessage(msg, callback);
},
function updateStats(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
const _ = require('lodash');
exports.getModule = AreaViewFSEModule;
exports.moduleInfo = {
name : 'Message Area View',
desc : 'Module for viewing an area message',
author : 'NuSkooler',
};
function AreaViewFSEModule(options) {
FullScreenEditorModule.call(this, options);
exports.getModule = class AreaViewFSEModule extends FullScreenEditorModule {
constructor(options) {
super(options);
const self = this;
this.editorType = 'area';
this.editorMode = 'view';
this.editorType = 'area';
this.editorMode = 'view';
if(_.isObject(options.extraArgs)) {
this.messageList = options.extraArgs.messageList;
this.messageIndex = options.extraArgs.messageIndex;
}
if(_.isObject(options.extraArgs)) {
this.messageList = options.extraArgs.messageList;
this.messageIndex = options.extraArgs.messageIndex;
this.messageList = this.messageList || [];
this.messageIndex = this.messageIndex || 0;
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) {
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) {
loadMessageByUuid(uuid, cb) {
const msg = new Message();
msg.load( { uuid : uuid, user : self.client.user }, () => {
self.setMessage(msg);
msg.load( { uuid : uuid, user : this.client.user }, () => {
this.setMessage(msg);
if(cb) {
return cb(null);
}
});
};
}
}
require('util').inherits(AreaViewFSEModule, FullScreenEditorModule);
AreaViewFSEModule.prototype.finishedLoading = function() {
if(this.messageList.length) {
finishedLoading() {
this.loadMessageByUuid(this.messageList[this.messageIndex].messageUuid);
}
};
AreaViewFSEModule.prototype.getSaveState = function() {
AreaViewFSEModule.super_.prototype.getSaveState.call(this);
return {
messageList : this.messageList,
messageIndex : this.messageIndex,
messageTotal : this.messageList.length,
};
};
AreaViewFSEModule.prototype.restoreSavedState = function(savedState) {
AreaViewFSEModule.super_.prototype.restoreSavedState.call(this, savedState);
this.messageList = savedState.messageList;
this.messageIndex = savedState.messageIndex;
this.messageTotal = savedState.messageTotal;
};
AreaViewFSEModule.prototype.getMenuResult = function() {
return this.messageIndex;
getSaveState() {
return {
messageList : this.messageList,
messageIndex : this.messageIndex,
messageTotal : this.messageList.length,
};
}
restoreSavedState(savedState) {
this.messageList = savedState.messageList;
this.messageIndex = savedState.messageIndex;
this.messageTotal = savedState.messageTotal;
}
getMenuResult() {
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 messageArea = require('../core/message_area.js');
const displayThemeArt = require('../core/theme.js').displayThemeArt;
const displayThemedPause = require('../core/theme.js').displayThemedPause;
const resetScreen = require('../core/ansi_term.js').resetScreen;
const stringFormat = require('../core/string_format.js');
@ -14,15 +13,13 @@ const stringFormat = require('../core/string_format.js');
const async = require('async');
const _ = require('lodash');
exports.getModule = MessageConfListModule;
exports.moduleInfo = {
name : 'Message Conference List',
desc : 'Module for listing / choosing message conferences',
author : 'NuSkooler',
};
const MCICodeIDs = {
const MciViewIds = {
ConfList : 1,
// :TODO:
@ -30,127 +27,122 @@ const MCICodeIDs = {
//
};
function MessageConfListModule(options) {
MenuModule.call(this, options);
exports.getModule = class MessageConfListModule extends MenuModule {
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( () => {
self.prevMenu(cb);
}, 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);
});
}
});
setTimeout( () => {
return self.prevMenu(cb);
}, 1000);
} 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 {
return cb(null);
}
}
};
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);
}
);
};
],
function complete(err) {
cb(err);
}
);
});
}
};

View File

@ -2,10 +2,11 @@
'use strict';
// ENiGMA½
const MenuModule = require('../core/menu_module.js').MenuModule;
const ViewController = require('../core/view_controller.js').ViewController;
const messageArea = require('../core/message_area.js');
const stringFormat = require('../core/string_format.js');
const MenuModule = require('../core/menu_module.js').MenuModule;
const ViewController = require('../core/view_controller.js').ViewController;
const messageArea = require('../core/message_area.js');
const stringFormat = require('../core/string_format.js');
const MessageAreaConfTempSwitcher = require('../core/mod_mixins.js').MessageAreaConfTempSwitcher;
// deps
const async = require('async');
@ -28,8 +29,6 @@ const moment = require('moment');
TL2 : Message info 1: { msgNumSelected, msgNumTotal }
*/
exports.getModule = MessageListModule;
exports.moduleInfo = {
name : 'Message List',
desc : 'Module for listing/browsing available messages',
@ -41,218 +40,213 @@ const MCICodesIDs = {
MsgInfo1 : 2, // TL2
};
function MessageListModule(options) {
MenuModule.call(this, options);
exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(MenuModule) {
constructor(options) {
super(options);
const self = this;
const config = this.menuConfig.config;
const self = this;
const config = this.menuConfig.config;
this.messageAreaTag = config.messageAreaTag;
this.messageAreaTag = config.messageAreaTag;
if(options.extraArgs) {
//
// |extraArgs| can override |messageAreaTag| provided by config
// as well as supply a pre-defined message list
//
if(options.extraArgs.messageAreaTag) {
this.messageAreaTag = options.extraArgs.messageAreaTag;
if(options.extraArgs) {
//
// |extraArgs| can override |messageAreaTag| provided by config
// as well as supply a pre-defined message list
//
if(options.extraArgs.messageAreaTag) {
this.messageAreaTag = options.extraArgs.messageAreaTag;
}
if(options.extraArgs.messageList) {
this.messageList = options.extraArgs.messageList;
}
}
if(options.extraArgs.messageList) {
this.messageList = options.extraArgs.messageList;
}
}
this.menuMethods = {
selectMessage : function(formData, extraArgs, cb) {
if(1 === formData.submitId) {
self.initialFocusIndex = formData.value.message;
this.menuMethods = {
selectMessage : function(formData, extraArgs, cb) {
if(1 === formData.submitId) {
self.initialFocusIndex = 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,
const modOpts = {
extraArgs : {
messageAreaTag : self.messageAreaTag,
messageList : self.messageList,
messageIndex : formData.value.message,
}
};
};
return self.gotoMenu(config.menuViewPost || 'messageAreaViewPost', modOpts, cb);
} else {
return cb(null);
}
},
//
// 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));
fullExit : function(formData, extraArgs, cb) {
self.menuResult = { fullExit : true };
return self.prevMenu(cb);
}
};
return {
messageAreaTag : this.messageAreaTag,
apprevMessageList : logMsgList,
messageCount : this.messageList.length,
messageIndex : formData.value.message,
};
};
this.setViewText = function(id, text) {
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);
return self.gotoMenu(config.menuViewPost || 'messageAreaViewPost', modOpts, cb);
} else {
msgListView.redraw();
return cb(null);
}
},
return callback(null);
},
function drawOtherViews(callback) {
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');
fullExit : function(formData, extraArgs, cb) {
self.menuResult = { fullExit : true };
return self.prevMenu(cb);
}
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() {
return { initialFocusIndex : this.initialFocusIndex };
};
leave() {
this.tempMessageConfAndAreaRestore();
super.leave();
}
MessageListModule.prototype.restoreSavedState = function(savedState) {
if(savedState) {
this.initialFocusIndex = savedState.initialFocusIndex;
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 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 messageArea = require('../core/message_area.js');
exports.getModule = NewUserAppModule;
exports.moduleInfo = {
name : 'NUA',
desc : 'New User Application',
@ -23,123 +21,124 @@ const MciViewIds = {
errMsg : 11,
};
function NewUserAppModule(options) {
MenuModule.call(this, options);
exports.getModule = class NewUserAppModule extends MenuModule {
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 = {
//
// 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'));
},
viewValidationListener : function(err, cb) {
const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg);
let newFocusId;
if(err) {
errMsgView.setText(err.message);
err.view.clearText();
viewValidationListener : function(err, cb) {
const errMsgView = self.viewControllers.menu.getView(MciViewIds.errMsg);
let newFocusId;
if(err) {
errMsgView.setText(err.message);
err.view.clearText();
if(err.view.getId() === MciViewIds.confirm) {
newFocusId = MciViewIds.password;
self.viewControllers.menu.getView(MciViewIds.password).clearText();
if(err.view.getId() === MciViewIds.confirm) {
newFocusId = MciViewIds.password;
self.viewControllers.menu.getView(MciViewIds.password).clearText();
}
} else {
errMsgView.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
//
let confTag = messageArea.getDefaultMessageConferenceTag(self.client, true); // true=disableAcsCheck
let areaTag = messageArea.getDefaultMessageAreaTagByConfTag(self.client, confTag, true); // true=disableAcsCheck
// Submit handlers
//
submitApplication : function(formData, extraArgs, cb) {
const newUser = new user.User();
// can't store undefined!
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,
newUser.username = formData.value.username;
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
// :TODO: should probably have a place to create defaults/etc.
};
// can't store undefined!
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) {
newUser.properties.theme_id = theme.getRandomTheme();
} 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');
term_height : self.client.term.termHeight,
term_width : self.client.term.termWidth,
self.gotoMenu(extraArgs.error, err => {
if(err) {
return self.prevMenu(cb);
}
return cb(null);
});
// :TODO: Other defaults
// :TODO: should probably have a place to create defaults/etc.
};
if('*' === Config.defaults.theme) {
newUser.properties.theme_id = theme.getRandomTheme();
} else {
self.client.log.info( { username : formData.value.username, userId : newUser.userId }, 'New user created');
// 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);
}
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');
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) {
this.standardMCIReadyHandler(mciData, cb);
};
// 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);
}
}
});
},
};
}
mciReady(mciData, cb) {
return this.standardMCIReadyHandler(mciData, cb);
}
};

View File

@ -31,9 +31,7 @@ exports.moduleInfo = {
packageName : 'codes.l33t.enigma.onelinerz',
};
exports.getModule = OnelinerzModule;
const MciCodeIds = {
const MciViewIds = {
ViewForm : {
Entries : 1,
AddPrompt : 2,
@ -50,20 +48,52 @@ const FormIds = {
Add : 1,
};
function OnelinerzModule(options) {
MenuModule.call(this, options);
exports.getModule = class OnelinerzModule extends MenuModule {
constructor(options) {
super(options);
const self = this;
const config = this.menuConfig.config;
const self = this;
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(
[
function beforeDisplayArt(callback) {
self.beforeArt(callback);
return self.beforeArt(callback);
},
function display(callback) {
self.displayViewScreen(false, callback);
return self.displayViewScreen(false, callback);
}
],
err => {
@ -73,9 +103,11 @@ function OnelinerzModule(options) {
self.finishedLoading();
}
);
};
}
displayViewScreen(clearScreen, cb) {
const self = this;
this.displayViewScreen = function(clearScreen, cb) {
async.waterfall(
[
function clearAndDisplayArt(callback) {
@ -88,7 +120,7 @@ function OnelinerzModule(options) {
}
theme.displayThemedAsset(
config.art.entries,
self.menuConfig.config.art.entries,
self.client,
{ font : self.menuConfig.font, trailingLF : false },
(err, artData) => {
@ -112,12 +144,12 @@ function OnelinerzModule(options) {
return vc.loadFromMenuConfig(loadOpts, callback);
} else {
self.viewControllers.view.setFocus(true);
self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt).redraw();
self.viewControllers.view.getView(MciViewIds.ViewForm.AddPrompt).redraw();
return callback(null);
}
},
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;
let entries = [];
@ -142,8 +174,8 @@ function OnelinerzModule(options) {
);
},
function populateEntries(entriesView, entries, callback) {
const listFormat = config.listFormat || '{username}@{ts}: {oneliner}';// :TODO: should be userName to be consistent
const tsFormat = config.timestampFormat || 'ddd h:mma';
const listFormat = self.menuConfig.config.listFormat || '{username}@{ts}: {oneliner}';// :TODO: should be userName to be consistent
const tsFormat = self.menuConfig.config.timestampFormat || 'ddd h:mma';
entriesView.setItems(entries.map( e => {
return stringFormat(listFormat, {
@ -159,7 +191,7 @@ function OnelinerzModule(options) {
return callback(null);
},
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
return callback(null);
}
@ -170,9 +202,11 @@ function OnelinerzModule(options) {
}
}
);
};
}
displayAddScreen(cb) {
const self = this;
this.displayAddScreen = function(cb) {
async.waterfall(
[
function clearAndDisplayArt(callback) {
@ -180,7 +214,7 @@ function OnelinerzModule(options) {
self.client.term.rawWrite(ansi.resetScreen());
theme.displayThemedAsset(
config.art.add,
self.menuConfig.config.art.add,
self.client,
{ font : self.menuConfig.font },
(err, artData) => {
@ -205,7 +239,7 @@ function OnelinerzModule(options) {
} else {
self.viewControllers.add.setFocus(true);
self.viewControllers.add.redrawAll();
self.viewControllers.add.switchFocus(MciCodeIds.AddForm.NewEntry);
self.viewControllers.add.switchFocus(MciViewIds.AddForm.NewEntry);
return callback(null);
}
}
@ -216,80 +250,50 @@ function OnelinerzModule(options) {
}
}
);
};
}
this.clearAddForm = function() {
const newEntryView = self.viewControllers.add.getView(MciCodeIds.AddForm.NewEntry);
const previewView = self.viewControllers.add.getView(MciCodeIds.AddForm.EntryPreview);
clearAddForm() {
this.setViewText('add', MciViewIds.AddForm.NewEntry, '');
this.setViewText('add', MciViewIds.AddForm.EntryPreview, '');
}
newEntryView.setText('');
// preview is optional
if(previewView) {
previewView.setText('');
}
};
initDatabase(cb) {
const self = this;
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(
[
function openDatabase(callback) {
self.db = new sqlite3.Database(
getModDatabasePath(exports.moduleInfo),
callback
err => {
return callback(err);
}
);
},
function createTables(callback) {
self.db.serialize( () => {
self.db.run(
`CREATE TABLE IF NOT EXISTS onelinerz (
id INTEGER PRIMARY KEY,
user_id INTEGER_NOT NULL,
user_name VARCHAR NOT NULL,
oneliner VARCHAR NOT NULL,
timestamp DATETIME NOT NULL
)`
);
self.db.run(
`CREATE TABLE IF NOT EXISTS onelinerz (
id INTEGER PRIMARY KEY,
user_id INTEGER_NOT NULL,
user_name VARCHAR NOT NULL,
oneliner VARCHAR NOT NULL,
timestamp DATETIME NOT NULL
);`
,
err => {
return callback(err);
});
callback(null);
}
],
cb
err => {
return cb(err);
}
);
};
}
this.storeNewOneliner = function(oneliner, cb) {
const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ');
storeNewOneliner(oneliner, cb) {
const self = this;
const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ');
async.series(
[
@ -315,15 +319,15 @@ function OnelinerzModule(options) {
);
}
],
cb
err => {
return cb(err);
}
);
};
}
}
require('util').inherits(OnelinerzModule, MenuModule);
OnelinerzModule.prototype.beforeArt = function(cb) {
OnelinerzModule.super_.prototype.beforeArt.call(this, err => {
return err ? cb(err) : this.initDatabase(cb);
});
beforeArt(cb) {
super.beforeArt(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: Convert all of this to HJSON
"prompts" : {
"userCredentials" : {
prompts: {
userCredentials: {
"art" : "usercred",
"mci" : {
"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
//
// Prompts in this section are considered "standard" and are required
// to be present
//
///////////////////////////////////////////////////////////////////////
pause: {
//

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