diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index a34168f3..5d3f54be 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -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): diff --git a/LICENSE.TXT b/LICENSE.TXT index 5bc50d13..8d256374 100644 --- a/LICENSE.TXT +++ b/LICENSE.TXT @@ -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 diff --git a/README.md b/README.md index 353c6a54..2dd16223 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..802a8092 --- /dev/null +++ b/UPGRADE.md @@ -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). diff --git a/core/acs.js b/core/acs.js index df89be9b..f2e04b9f 100644 --- a/core/acs.js +++ b/core/acs.js @@ -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; \ No newline at end of file diff --git a/core/ansi_escape_parser.js b/core/ansi_escape_parser.js index c3df3568..fb7ea732 100644 --- a/core/ansi_escape_parser.js +++ b/core/ansi_escape_parser.js @@ -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(); } diff --git a/core/archive_util.js b/core/archive_util.js index 1c543a12..f1bf70df 100644 --- a/core/archive_util.js +++ b/core/archive_util.js @@ -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() { diff --git a/core/art.js b/core/art.js index 0e283218..3d3febe1 100644 --- a/core/art.js +++ b/core/art.js @@ -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 = {}; diff --git a/core/bbs.js b/core/bbs.js index 36243227..2995dabc 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -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 + +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... -} \ No newline at end of file diff --git a/core/button_view.js b/core/button_view.js index dec1cd10..007ca29f 100644 --- a/core/button_view.js +++ b/core/button_view.js @@ -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'); } diff --git a/core/client_connections.js b/core/client_connections.js index e4a5c5ff..2b3c1714 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -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 ); +} \ No newline at end of file diff --git a/core/conf_area_util.js b/core/conf_area_util.js new file mode 100644 index 00000000..5dabfb73 --- /dev/null +++ b/core/conf_area_util.js @@ -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 + } + }); +} \ No newline at end of file diff --git a/core/config.js b/core/config.js index c53f37f0..755cc56c 100644 --- a/core/config.js +++ b/core/config.js @@ -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 diff --git a/core/config_cache.js b/core/config_cache.js index 41bdef16..fa80c19d 100644 --- a/core/config_cache.js +++ b/core/config_cache.js @@ -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); } diff --git a/core/connect.js b/core/connect.js index bd2e40a2..ffd28a0c 100644 --- a/core/connect.js +++ b/core/connect.js @@ -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` ); diff --git a/core/crc.js b/core/crc.js new file mode 100644 index 00000000..886dad1d --- /dev/null +++ b/core/crc.js @@ -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; + } +}; diff --git a/core/database.js b/core/database.js index e5e74571..2c327f4c 100644 --- a/core/database.js +++ b/core/database.js @@ -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); } }; \ No newline at end of file diff --git a/core/door.js b/core/door.js index 302de522..5670db1e 100644 --- a/core/door.js +++ b/core/door.js @@ -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) { diff --git a/core/door_party.js b/core/door_party.js index 9eb733f4..1766f5ff 100644 --- a/core/door_party.js +++ b/core/door_party.js @@ -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); \ No newline at end of file + } +}; diff --git a/core/download_queue.js b/core/download_queue.js new file mode 100644 index 00000000..6bfbd47f --- /dev/null +++ b/core/download_queue.js @@ -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'); + } + } +}; diff --git a/core/enig_error.js b/core/enig_error.js index adf5eef6..27453227 100644 --- a/core/enig_error.js +++ b/core/enig_error.js @@ -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', +}; \ No newline at end of file diff --git a/core/file_area_web.js b/core/file_area_web.js new file mode 100644 index 00000000..fc2ae61b --- /dev/null +++ b/core/file_area_web.js @@ -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(); \ No newline at end of file diff --git a/core/file_base_area.js b/core/file_base_area.js new file mode 100644 index 00000000..bce81fac --- /dev/null +++ b/core/file_base_area.js @@ -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); + }); +} diff --git a/core/file_base_filter.js b/core/file_base_filter.js new file mode 100644 index 00000000..801ae47e --- /dev/null +++ b/core/file_base_filter.js @@ -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); + } +}; diff --git a/core/file_entry.js b/core/file_entry.js new file mode 100644 index 00000000..f7836555 --- /dev/null +++ b/core/file_entry.js @@ -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); + }); + } +}; diff --git a/core/file_transfer.js b/core/file_transfer.js new file mode 100644 index 00000000..9735abe7 --- /dev/null +++ b/core/file_transfer.js @@ -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(); + } + ); + } +}; diff --git a/core/file_util.js b/core/file_util.js new file mode 100644 index 00000000..e2ea6e90 --- /dev/null +++ b/core/file_util.js @@ -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; +} diff --git a/core/fse.js b/core/fse.js index 0afc31c1..8a7e57f4 100644 --- a/core/fse.js +++ b/core/fse.js @@ -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); + } }; diff --git a/core/ftn_util.js b/core/ftn_util.js index 0860aeb4..c48cc222 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -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 diff --git a/core/horizontal_menu_view.js b/core/horizontal_menu_view.js index 87c194ee..28f4c29d 100644 --- a/core/horizontal_menu_view.js +++ b/core/horizontal_menu_view.js @@ -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(); } } diff --git a/core/key_entry_view.js b/core/key_entry_view.js index fa48e877..cf1ba008 100644 --- a/core/key_entry_view.js +++ b/core/key_entry_view.js @@ -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); diff --git a/core/listening_server.js b/core/listening_server.js new file mode 100644 index 00000000..94efd475 --- /dev/null +++ b/core/listening_server.js @@ -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); + }); +} diff --git a/core/login_server_module.js b/core/login_server_module.js new file mode 100644 index 00000000..4f003982 --- /dev/null +++ b/core/login_server_module.js @@ -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(); + } + }); + }); + } +}; diff --git a/core/menu_module.js b/core/menu_module.js index 5477e6d3..1f350564 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -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; + } + } }; diff --git a/core/menu_stack.js b/core/menu_stack.js index 84e6a6e0..f4b29460 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -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); diff --git a/core/menu_util.js b/core/menu_util.js index bc439a9b..7e15d6da 100644 --- a/core/menu_util.js +++ b/core/menu_util.js @@ -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 + '\'')); diff --git a/core/menu_view.js b/core/menu_view.js index 0c11c51c..07d19e8a 100644 --- a/core/menu_view.js +++ b/core/menu_view.js @@ -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); diff --git a/core/message.js b/core/message.js index e1d3e4fd..19d13e61 100644 --- a/core/message.js +++ b/core/message.js @@ -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; diff --git a/core/message_area.js b/core/message_area.js index 2f74357b..8fe250a1 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -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); } ); } diff --git a/core/mod_mixins.js b/core/mod_mixins.js index 00ec452b..291e0cc9 100644 --- a/core/mod_mixins.js +++ b/core/mod_mixins.js @@ -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; } - }; - + } }; diff --git a/core/module_util.js b/core/module_util.js index 651a2a8a..3e51207c 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -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); } diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js index 0b05747a..532c8acf 100644 --- a/core/multi_line_edit_text_view.js +++ b/core/multi_line_edit_text_view.js @@ -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; + } } }); } diff --git a/core/new_scan.js b/core/new_scan.js index f38196e4..0483e8d1 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -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); + } + ); + }); + } }; diff --git a/core/oputil/oputil_common.js b/core/oputil/oputil_common.js new file mode 100644 index 00000000..3b99bf11 --- /dev/null +++ b/core/oputil/oputil_common.js @@ -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; + }); +} \ No newline at end of file diff --git a/core/oputil/oputil_config.js b/core/oputil/oputil_config.js new file mode 100644 index 00000000..7071f459 --- /dev/null +++ b/core/oputil/oputil_config.js @@ -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); + } +} diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js new file mode 100644 index 00000000..fe9ebb54 --- /dev/null +++ b/core/oputil/oputil_file_base.js @@ -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(); + } +} \ No newline at end of file diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js new file mode 100644 index 00000000..25237680 --- /dev/null +++ b/core/oputil/oputil_help.js @@ -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] + [] + +global args: + --config PATH : specify config path (${getDefaultConfigPath()}) + +where is one of: + user : user utilities + config : config file management + file-base + fb : file base management + +`, + User : +`usage: optutil.js user --user USERNAME + +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 + +valid args: + --new : generate a new/initial configuration +`, + FileBase : +`usage: oputil.js file-base [] [] + +where is one of: + scan 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 : + --tags TAG1,TAG2,... : specify tag(s) to assign to discovered entries +` +}; + +function getHelpFor(command) { + return usageHelp[command]; +} \ No newline at end of file diff --git a/core/oputil/oputil_main.js b/core/oputil/oputil_main.js new file mode 100644 index 00000000..78dae8d2 --- /dev/null +++ b/core/oputil/oputil_main.js @@ -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); + } +}; diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js new file mode 100644 index 00000000..feb712f6 --- /dev/null +++ b/core/oputil/oputil_user.js @@ -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')); + } + } + ); +} \ No newline at end of file diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 985225bb..58f56cf4 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -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 // diff --git a/core/sauce.js b/core/sauce.js index 0dad1bc9..295a6069 100644 --- a/core/sauce.js +++ b/core/sauce.js @@ -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 = { diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 1257fc34..874e17d4 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -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) { diff --git a/core/servers/content/web.js b/core/servers/content/web.js new file mode 100644 index 00000000..11394659 --- /dev/null +++ b/core/servers/content/web.js @@ -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(` + + + + ${title} + + + +
+

${bodyText}

+
+ + ` + ); + } + + 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); + }); + } +}; diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js index 1abd1e84..51a2da2f 100644 --- a/core/servers/login/ssh.js +++ b/core/servers/login/ssh.js @@ -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; + } }; diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index 7573e62e..2e27a6f8 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -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; + } }; diff --git a/core/standard_menu.js b/core/standard_menu.js index 4d4b8819..ddaebff3 100644 --- a/core/standard_menu.js +++ b/core/standard_menu.js @@ -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); + }); + } }; diff --git a/core/stat_log.js b/core/stat_log.js index 60e9e77f..41c4ceb5 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -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; diff --git a/core/string_format.js b/core/string_format.js index 9abac186..dd1ece78 100644 --- a/core/string_format.js +++ b/core/string_format.js @@ -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) { diff --git a/core/string_util.js b/core/string_util.js index ab543a4e..0c296da8 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -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); +}); +*/ \ No newline at end of file diff --git a/core/system_menu_method.js b/core/system_menu_method.js index 95a47a96..2439d7df 100644 --- a/core/system_menu_method.js +++ b/core/system_menu_method.js @@ -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); }); diff --git a/core/text_view.js b/core/text_view.js index 8d8439a0..f1b3ee7e 100644 --- a/core/text_view.js +++ b/core/text_view.js @@ -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) { diff --git a/core/theme.js b/core/theme.js index 90ff0cd4..d9a51ca0 100644 --- a/core/theme.js +++ b/core/theme.js @@ -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) { diff --git a/core/toggle_menu_view.js b/core/toggle_menu_view.js index a740782c..35676193 100644 --- a/core/toggle_menu_view.js +++ b/core/toggle_menu_view.js @@ -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(); } } diff --git a/core/user.js b/core/user.js index 0288d8dd..53272fff 100644 --- a/core/user.js +++ b/core/user.js @@ -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 { diff --git a/core/user_config.js b/core/user_config.js index 901ec80f..432cdade 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -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); + } + } + ); + }); + } }; diff --git a/core/user_login.js b/core/user_login.js index b122b372..76b01598 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -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); + } + ); }); } \ No newline at end of file diff --git a/core/uuid_util.js b/core/uuid_util.js index 00e8840c..d8023f95 100644 --- a/core/uuid_util.js +++ b/core/uuid_util.js @@ -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); diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index f72095e1..445f5f4a 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -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() { diff --git a/core/view_controller.js b/core/view_controller.js index f2fbb366..ecf36be5 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -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; - }); }; -*/ \ No newline at end of file diff --git a/docs/about.md b/docs/about.md index 55d0a45f..50656011 100644 --- a/docs/about.md +++ b/docs/about.md @@ -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 \ No newline at end of file + * 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! \ No newline at end of file diff --git a/docs/archive.md b/docs/archive.md new file mode 100644 index 00000000..8303858c --- /dev/null +++ b/docs/archive.md @@ -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: LHA 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" +} +``` \ No newline at end of file diff --git a/docs/config.md b/docs/config.md index 00ea751e..ca760676 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. \ No newline at end of file +See [the menu system docs](menu_system.md) \ No newline at end of file diff --git a/docs/file_base.md b/docs/file_base.md new file mode 100644 index 00000000..bfd802b9 --- /dev/null +++ b/docs/file_base.md @@ -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. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 52b2d089..4ceb6449 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 */ diff --git a/docs/mci.md b/docs/mci.md new file mode 100644 index 00000000..28d424fd --- /dev/null +++ b/docs/mci.md @@ -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 \ No newline at end of file diff --git a/docs/menu_system.md b/docs/menu_system.md index b8b02fc8..1dea637c 100644 --- a/docs/menu_system.md +++ b/docs/menu_system.md @@ -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: { diff --git a/docs/web_server.md b/docs/web_server.md new file mode 100644 index 00000000..740515e9 --- /dev/null +++ b/docs/web_server.md @@ -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 `.html` file in the *static routes* area. For example: `404.html`. diff --git a/misc/install.sh b/misc/install.sh index 628cc51a..915d3355 100755 --- a/misc/install.sh +++ b/misc/install.sh @@ -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" diff --git a/misc/startup_banner.asc b/misc/startup_banner.asc new file mode 100644 index 00000000..b758e066 --- /dev/null +++ b/misc/startup_banner.asc @@ -0,0 +1,9 @@ +_____________________ _____ ____________________ __________\_ / +\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! +// __|___// | \// |// | \// | | \// \ /___ /_____ +/____ _____| __________ ___|__| ____| \ / _____ \ +---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + +------------------------------------------------------------------------------- diff --git a/mods/abracadabra.js b/mods/abracadabra.js index 4596fb4a..a84d2c63 100644 --- a/mods/abracadabra.js +++ b/mods/abracadabra.js @@ -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(); -}; \ No newline at end of file diff --git a/mods/art/NEWUSER1.ANS b/mods/art/NEWUSER1.ANS new file mode 100644 index 00000000..70edd11e Binary files /dev/null and b/mods/art/NEWUSER1.ANS differ diff --git a/mods/art_pool.js b/mods/art_pool.js deleted file mode 100644 index 8b0020fd..00000000 --- a/mods/art_pool.js +++ /dev/null @@ -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); -}; diff --git a/mods/bbs_link.js b/mods/bbs_link.js index 92bab4e8..0cf0a5db 100644 --- a/mods/bbs_link.js +++ b/mods/bbs_link.js @@ -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); \ No newline at end of file + } +}; diff --git a/mods/bbs_list.js b/mods/bbs_list.js index 9b37b24e..43ff3135 100644 --- a/mods/bbs_list.js +++ b/mods/bbs_list.js @@ -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); + }); + } }; diff --git a/mods/erc_client.js b/mods/erc_client.js index dd0f9494..02b42ad5 100644 --- a/mods/erc_client.js +++ b/mods/erc_client.js @@ -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; diff --git a/mods/file_area_filter_edit.js b/mods/file_area_filter_edit.js new file mode 100644 index 00000000..00d13a2e --- /dev/null +++ b/mods/file_area_filter_edit.js @@ -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(); + } + } +}; diff --git a/mods/file_area_list.js b/mods/file_area_list.js new file mode 100644 index 00000000..aa661a5e --- /dev/null +++ b/mods/file_area_list.js @@ -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); + }); + } + } +}; diff --git a/mods/file_base_download_manager.js b/mods/file_base_download_manager.js new file mode 100644 index 00000000..812a2422 --- /dev/null +++ b/mods/file_base_download_manager.js @@ -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); + } + ); + } +}; diff --git a/mods/file_base_search.js b/mods/file_base_search.js new file mode 100644 index 00000000..e984e1a4 --- /dev/null +++ b/mods/file_base_search.js @@ -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); + } +}; diff --git a/mods/file_transfer_protocol_select.js b/mods/file_transfer_protocol_select.js new file mode 100644 index 00000000..6efa5a93 --- /dev/null +++ b/mods/file_transfer_protocol_select.js @@ -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 } ); + } + }); + } +}; diff --git a/mods/last_callers.js b/mods/last_callers.js index 36a682a2..bf05fc35 100644 --- a/mods/last_callers.js +++ b/mods/last_callers.js @@ -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); + } + ); + }); + } }; diff --git a/mods/menu.hjson b/mods/menu.hjson index 372c11ab..c98b0122 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -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 //////////////////////////////////////////////////////////////////////// diff --git a/mods/msg_area_list.js b/mods/msg_area_list.js index 85603ef4..a6a0df4c 100644 --- a/mods/msg_area_list.js +++ b/mods/msg_area_list.js @@ -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); - } - ); -}; \ No newline at end of file + + 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); + } + ); + }); + } +}; diff --git a/mods/msg_area_post_fse.js b/mods/msg_area_post_fse.js index 8a515895..21b5d068 100644 --- a/mods/msg_area_post_fse.js +++ b/mods/msg_area_post_fse.js @@ -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(); + } +}; \ No newline at end of file diff --git a/mods/msg_area_view_fse.js b/mods/msg_area_view_fse.js index a82433b2..129476ae 100644 --- a/mods/msg_area_view_fse.js +++ b/mods/msg_area_view_fse.js @@ -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; + } }; diff --git a/mods/msg_conf_list.js b/mods/msg_conf_list.js index 1a328430..91c24de4 100644 --- a/mods/msg_conf_list.js +++ b/mods/msg_conf_list.js @@ -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); - } - ); -}; \ No newline at end of file + ], + function complete(err) { + cb(err); + } + ); + }); + } +}; diff --git a/mods/msg_list.js b/mods/msg_list.js index 83b4fe90..498d8976 100644 --- a/mods/msg_list.js +++ b/mods/msg_list.js @@ -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; -}; diff --git a/mods/nua.js b/mods/nua.js index 14ef4803..31658eb3 100644 --- a/mods/nua.js +++ b/mods/nua.js @@ -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); -}; \ No newline at end of file + // 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); + } +}; diff --git a/mods/onelinerz.js b/mods/onelinerz.js index a00685f2..335c25ce 100644 --- a/mods/onelinerz.js +++ b/mods/onelinerz.js @@ -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); + }); + } }; diff --git a/mods/prompt.hjson b/mods/prompt.hjson index 3dac7326..cfeb8fbc 100644 --- a/mods/prompt.hjson +++ b/mods/prompt.hjson @@ -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: { // diff --git a/mods/telnet_bridge.js b/mods/telnet_bridge.js index a8a2d9d3..1dbb1ae9 100644 --- a/mods/telnet_bridge.js +++ b/mods/telnet_bridge.js @@ -27,9 +27,6 @@ const buffers = require('buffers'); */ // :TODO: ENH: Support nodeMax and tooManyArt - -exports.getModule = TelnetBridgeModule; - exports.moduleInfo = { name : 'Telnet Bridge', desc : 'Connect to other Telnet Systems', @@ -52,7 +49,9 @@ class TelnetClientConnection extends EventEmitter { // client may have bailed if(_.has(this, 'client.term.output')) { - this.client.term.output.unpipe(this.bridgeConnection); + if(this.bridgeConnection) { + this.client.term.output.unpipe(this.bridgeConnection); + } this.client.term.output.resume(); } } @@ -123,18 +122,18 @@ class TelnetClientConnection extends EventEmitter { } +exports.getModule = class TelnetBridgeModule extends MenuModule { + constructor(options) { + super(options); -function TelnetBridgeModule(options) { - MenuModule.call(this, options); - - const self = this; - this.config = options.menuConfig.config; + this.config = options.menuConfig.config; + // defaults + this.config.port = this.config.port || 23; + } - // defaults - this.config.port = this.config.port || 23; - - this.initSequence = function() { + initSequence() { let clientTerminated; + const self = this; async.series( [ @@ -195,7 +194,5 @@ function TelnetBridgeModule(options) { } } ); - }; -} - -require('util').inherits(TelnetBridgeModule, MenuModule); + } +}; diff --git a/mods/themes/luciano_blocktronics/FBHELP.ANS b/mods/themes/luciano_blocktronics/FBHELP.ANS new file mode 100644 index 00000000..5dc95322 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FBHELP.ANS differ diff --git a/mods/themes/luciano_blocktronics/FBNORES.ANS b/mods/themes/luciano_blocktronics/FBNORES.ANS new file mode 100644 index 00000000..81464593 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FBNORES.ANS differ diff --git a/mods/themes/luciano_blocktronics/FBRWSE.ANS b/mods/themes/luciano_blocktronics/FBRWSE.ANS new file mode 100644 index 00000000..52db95e0 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FBRWSE.ANS differ diff --git a/mods/themes/luciano_blocktronics/FDETAIL.ANS b/mods/themes/luciano_blocktronics/FDETAIL.ANS new file mode 100644 index 00000000..fd637f58 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FDETAIL.ANS differ diff --git a/mods/themes/luciano_blocktronics/FDETGEN.ANS b/mods/themes/luciano_blocktronics/FDETGEN.ANS new file mode 100644 index 00000000..ffd94744 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FDETGEN.ANS differ diff --git a/mods/themes/luciano_blocktronics/FDETLST.ANS b/mods/themes/luciano_blocktronics/FDETLST.ANS new file mode 100644 index 00000000..397a52cc Binary files /dev/null and b/mods/themes/luciano_blocktronics/FDETLST.ANS differ diff --git a/mods/themes/luciano_blocktronics/FDETNFO.ANS b/mods/themes/luciano_blocktronics/FDETNFO.ANS new file mode 100644 index 00000000..def86e18 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FDETNFO.ANS differ diff --git a/mods/themes/luciano_blocktronics/FDLMGR.ANS b/mods/themes/luciano_blocktronics/FDLMGR.ANS new file mode 100644 index 00000000..a85ffcbe Binary files /dev/null and b/mods/themes/luciano_blocktronics/FDLMGR.ANS differ diff --git a/mods/themes/luciano_blocktronics/FEMPTYQ.ANS b/mods/themes/luciano_blocktronics/FEMPTYQ.ANS new file mode 100644 index 00000000..1943ff00 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FEMPTYQ.ANS differ diff --git a/mods/themes/luciano_blocktronics/FFILEDT.ANS b/mods/themes/luciano_blocktronics/FFILEDT.ANS new file mode 100644 index 00000000..19245e6f Binary files /dev/null and b/mods/themes/luciano_blocktronics/FFILEDT.ANS differ diff --git a/mods/themes/luciano_blocktronics/FILPMPT.ANS b/mods/themes/luciano_blocktronics/FILPMPT.ANS new file mode 100644 index 00000000..40415248 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FILPMPT.ANS differ diff --git a/mods/themes/luciano_blocktronics/FMENU.ANS b/mods/themes/luciano_blocktronics/FMENU.ANS new file mode 100644 index 00000000..5869f815 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FMENU.ANS differ diff --git a/mods/themes/luciano_blocktronics/FPROSEL.ANS b/mods/themes/luciano_blocktronics/FPROSEL.ANS new file mode 100644 index 00000000..bf41f1bb Binary files /dev/null and b/mods/themes/luciano_blocktronics/FPROSEL.ANS differ diff --git a/mods/themes/luciano_blocktronics/FSEARCH.ANS b/mods/themes/luciano_blocktronics/FSEARCH.ANS new file mode 100644 index 00000000..efb19617 Binary files /dev/null and b/mods/themes/luciano_blocktronics/FSEARCH.ANS differ diff --git a/mods/themes/luciano_blocktronics/MMENU.ANS b/mods/themes/luciano_blocktronics/MMENU.ANS index b2305013..995a5db5 100644 Binary files a/mods/themes/luciano_blocktronics/MMENU.ANS and b/mods/themes/luciano_blocktronics/MMENU.ANS differ diff --git a/mods/themes/luciano_blocktronics/RATEFILE.ANS b/mods/themes/luciano_blocktronics/RATEFILE.ANS new file mode 100644 index 00000000..bbea2bd0 Binary files /dev/null and b/mods/themes/luciano_blocktronics/RATEFILE.ANS differ diff --git a/mods/themes/luciano_blocktronics/STATUS.ANS b/mods/themes/luciano_blocktronics/STATUS.ANS index f119dfb9..b90ed2d9 100644 Binary files a/mods/themes/luciano_blocktronics/STATUS.ANS and b/mods/themes/luciano_blocktronics/STATUS.ANS differ diff --git a/mods/themes/luciano_blocktronics/ULCHECK.ANS b/mods/themes/luciano_blocktronics/ULCHECK.ANS new file mode 100644 index 00000000..83dabb08 Binary files /dev/null and b/mods/themes/luciano_blocktronics/ULCHECK.ANS differ diff --git a/mods/themes/luciano_blocktronics/ULDETAIL.ANS b/mods/themes/luciano_blocktronics/ULDETAIL.ANS new file mode 100644 index 00000000..ab3ea881 Binary files /dev/null and b/mods/themes/luciano_blocktronics/ULDETAIL.ANS differ diff --git a/mods/themes/luciano_blocktronics/ULDUPES.ANS b/mods/themes/luciano_blocktronics/ULDUPES.ANS new file mode 100644 index 00000000..7a4cb20b Binary files /dev/null and b/mods/themes/luciano_blocktronics/ULDUPES.ANS differ diff --git a/mods/themes/luciano_blocktronics/ULNOAREA.ANS b/mods/themes/luciano_blocktronics/ULNOAREA.ANS new file mode 100644 index 00000000..2e47643a Binary files /dev/null and b/mods/themes/luciano_blocktronics/ULNOAREA.ANS differ diff --git a/mods/themes/luciano_blocktronics/ULOPTS.ANS b/mods/themes/luciano_blocktronics/ULOPTS.ANS new file mode 100644 index 00000000..f49d712b Binary files /dev/null and b/mods/themes/luciano_blocktronics/ULOPTS.ANS differ diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/mods/themes/luciano_blocktronics/theme.hjson index 1397060c..3dfe67e6 100644 --- a/mods/themes/luciano_blocktronics/theme.hjson +++ b/mods/themes/luciano_blocktronics/theme.hjson @@ -478,6 +478,302 @@ } } + //////////////////// file base //////////////////////////////// + + fileBase: { + mci: { + FN4: { + width: 18 + textOverflow: ... + } + } + } + + fileBaseListEntries: { + config: { + hashTagsSep: "|08, |07" + browseInfoFormat10: "|00|10{fileName} |08- |03{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08- |03uploaded |11{uploadTimestamp}" + browseInfoFormat11: "|00|15{areaName}" + browseInfoFormat12: "|00|07{hashTags}" + browseInfoFormat13: "|00|07{estReleaseYear}" + browseInfoFormat14: "|00|07{dlCount}" + browseInfoFormat15: "{userRatingString}" + browseInfoFormat16: "{isQueued}" + browseInfoFormat17: "{webDlLink}{webDlExpire}" + + webDlExpireTimeFormat: " [|08- |07exp] ddd, MMM Do @ h:mm a" + webDlLinkNeedsGenerated: "|08(|07press |10W |07to generate link|08)" + + isQueuedIndicator: "|00|10YES" + isNotQueuedIndicator: "|00|07no" + + userRatingTicked: "|00|15*" + userRatingUnticked: "|00|07-" + + detailsGeneralInfoFormat10: "{fileName}" + detailsGeneralInfoFormat11: "|00|07{byteSize!sizeWithoutAbbr} |11{byteSize!sizeAbbr} |08(|03{byteSize:,} |11B|08)" + detailsGeneralInfoFormat12: "|00|07{hashTags}" + detailsGeneralInfoFormat13: "{estReleaseYear}" + detailsGeneralInfoFormat14: "{dlCount}" + detailsGeneralInfoFormat15: "{userRatingString}" + detailsGeneralInfoFormat16: "{fileCrc32}" + detailsGeneralInfoFormat17: "{fileMd5}" + detailsGeneralInfoFormat18: "{fileSha1}" + detailsGeneralInfoFormat19: "{fileSha256}" + detailsGeneralInfoFormat20: "{uploadByUsername}" + detailsGeneralInfoFormat21: "{uploadTimestamp}" + detailsGeneralInfoFormat22: "{archiveTypeDesc}" + + fileListEntryFormat: "|00|03{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusFileListEntryFormat: "|00|19|15{fileName:<67.66} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" + + notAnArchiveFormat: "|00|08( |07{fileName} is not an archive |08)" + } + + 0: { + mci: { + MT1: { + height: 16 + width: 45 + } + HM2: { + focusTextStyle: first lower + } + + TL11: { + width: 21 + textOverflow: ... + } + + TL12: { + width: 21 + textOverflow: ... + } + TL13: { width: 21 } + TL14: { width: 21 } + TL15: { width: 21 } + TL16: { width: 21 } + TL17: { width: 73 } + + } + } + + 1: { + mci: { + HM1: { + focusTextStyle: first lower + } + } + } + + 2: { + + } + + 3: { + // details - nfo/readme + mci: { + MT1: { + height: 19 + width: 79 + } + } + } + + 4: { + mci: { + VM1: { + height: 17 + width: 79 + } + } + } + } + + fileBaseSearch: { + mci: { + ET1: { + width: 42 + } + BT2: { + focusTextStyle: first lower + } + ET3: { + width: 42 + } + SM4: { + width: 14 + justify: right + } + SM5: { + width: 14 + justify: right + } + SM6: { + width: 14 + justify: right + } + BT7: { + focusTextStyle: first lower + } + } + } + + fileAreaFilterEditor: { + mci: { + ET1: { + width: 26 + } + ET2: { + width: 26 + } + SM3: { + width: 14 + justify: right + } + SM4: { + width: 14 + justify: right + } + SM5: { + width: 14 + justify: right + } + ET6: { + width: 26 + } + HM7: { + focusTextStyle: first lower + } + } + } + + fileBaseDownloadManager: { + config: { + queueListFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusQueueListFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" + } + + 0: { + mci: { + VM1: { + height: 11 + width: 69 + } + HM2: { + width: 50 + focusTextStyle: first lower + } + } + } + } + + fileBaseUploadFiles: { + config: { + // processing + processingInfoFormat10: "{stepIndicatorText}" + processingInfoFormat11: "|00|15{fileName} |08- |11{currentFileNum} |08/ |11{totalFileNum}" + + // details entry + fileDetailsInfoFormat10: "{fileName} |02■" + + // dupes + dupeInfoFormat: "|00|11{fileName:<53.52}|03{areaName}" + } + + // options + 0: { + mci: { + SM1: { + width: 14 + justify: right + focusTextStyle: first lower + } + + TM2: { + focusTextStyle: first lower + styleSGR1: |00|08 + } + + ET3: { + width: 40 + } + + HM4: { + focusTextStyle: first lower + } + } + } + + // processing/scanning + 1: { + mci: { + TL1: { width: 48 } + TL2: { width: 48 } + TL3: { width: 48 } + MT4: { + height: 6 + width: 68 + mode: preview + } + TL10: { width: 48 } + TL11: { width: 48 } + } + } + + // file details + 2: { + mci: { + MT1: { + height: 14 + width: 45 + } + + ET2: { + width: 25 + } + + ME3: { + width: 4 + } + + BT4: { + focusTextStyle: first lower + } + } + } + + // dupes + 3: { + mci: { + VM1: { + height: 17 + width: 75 + } + } + } + } + + fileTransferProtocolSelection: { + config: { + protListFormat: "|00|03{name}" + protListFocusFormat: "|00|19|15{name}" + } + + 0: { + mci: { + VM1: { + height: 15 + width: 30 + focusTextStyle: first lower + } + } + } + } + + + //////////////////////////////// ERC /////////////////////////////// + ercClient: { config: { //chatEntryFormat: "|00|08[|03{bbsTag}|08] |10{userName}|08: |02{message}" @@ -493,6 +789,14 @@ } } } + + fileMenuCommand: { + mci: { + TL1: { + text: "|00|15|MD|08 >> |03active filter|08: |10|FN" + } + } + } } } } \ No newline at end of file diff --git a/mods/upload.js b/mods/upload.js new file mode 100644 index 00000000..5faf0d76 --- /dev/null +++ b/mods/upload.js @@ -0,0 +1,706 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const MenuModule = require('../core/menu_module.js').MenuModule; +const stringFormat = require('../core/string_format.js'); +const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; +const getAreaDefaultStorageDirectory = require('../core/file_base_area.js').getAreaDefaultStorageDirectory; +const scanFile = require('../core/file_base_area.js').scanFile; +const getFileAreaByTag = require('../core/file_base_area.js').getFileAreaByTag; +const ansiGoto = require('../core/ansi_term.js').goto; +const moveFileWithCollisionHandling = require('../core/file_util.js').moveFileWithCollisionHandling; +const pathWithTerminatingSeparator = require('../core/file_util.js').pathWithTerminatingSeparator; +const Log = require('../core/logger.js').log; +const Errors = require('../core/enig_error.js').Errors; +const FileEntry = require('../core/file_entry.js'); +const enigmaToAnsi = require('../core/color_codes.js').enigmaToAnsi; + +// deps +const async = require('async'); +const _ = require('lodash'); +const temptmp = require('temptmp').createTrackedSession('upload'); +const paths = require('path'); +const sanatizeFilename = require('sanitize-filename'); + +exports.moduleInfo = { + name : 'Upload', + desc : 'Module for classic file uploads', + author : 'NuSkooler', +}; + +const FormIds = { + options : 0, + processing : 1, + fileDetails : 2, + dupes : 3, +}; + +const MciViewIds = { + options : { + area : 1, // area selection + uploadType : 2, // blind vs specify filename + fileName : 3, // for non-blind; not editable for blind + navMenu : 4, // next/cancel/etc. + errMsg : 5, // errors (e.g. filename cannot be blank) + }, + + processing : { + calcHashIndicator : 1, + archiveListIndicator : 2, + descFileIndicator : 3, + logStep : 4, + customRangeStart : 10, // 10+ = customs + }, + + fileDetails : { + desc : 1, // defaults to 'desc' (e.g. from FILE_ID.DIZ) + tags : 2, // tag(s) for item + estYear : 3, + accept : 4, // accept fields & continue + customRangeStart : 10, // 10+ = customs + }, + + dupes : { + dupeList : 1, + } +}; + +exports.getModule = class UploadModule extends MenuModule { + + constructor(options) { + super(options); + + if(_.has(options, 'lastMenuResult.recvFilePaths')) { + this.recvFilePaths = options.lastMenuResult.recvFilePaths; + } + + this.availAreas = getSortedAvailableFileAreas(this.client, { writeAcs : true } ); + + this.menuMethods = { + optionsNavContinue : (formData, extraArgs, cb) => { + return this.performUpload(cb); + }, + + fileDetailsContinue : (formData, extraArgs, cb) => { + // see displayFileDetailsPageForUploadEntry() for this hackery: + cb(null); + return this.fileDetailsCurrentEntrySubmitCallback(null, formData.value); // move on to the next entry, if any + }, + + // validation + validateNonBlindFileName : (fileName, cb) => { + fileName = sanatizeFilename(fileName); // remove unsafe chars, path info, etc. + if(0 === fileName.length) { + return cb(new Error('Invalid filename')); + } + + if(0 === fileName.length) { + return cb(new Error('Filename cannot be empty')); + } + + // At least SEXYZ doesn't like non-blind names that start with a number - it becomes confused + if(/^[0-9].*$/.test(fileName)) { + return cb(new Error('Invalid filename')); + } + + return cb(null); + }, + viewValidationListener : (err, cb) => { + const errView = this.viewControllers.options.getView(MciViewIds.options.errMsg); + if(errView) { + if(err) { + errView.setText(err.message); + } else { + errView.clearText(); + } + } + + return cb(null); + } + }; + } + + getSaveState() { + // if no areas, we're falling back due to lack of access/areas avail to upload to + if(this.availAreas.length > 0) { + return { + uploadType : this.uploadType, + tempRecvDirectory : this.tempRecvDirectory, + areaInfo : this.availAreas[ this.viewControllers.options.getView(MciViewIds.options.area).getData() ], + }; + } + } + + restoreSavedState(savedState) { + if(savedState.areaInfo) { + this.uploadType = savedState.uploadType; + this.areaInfo = savedState.areaInfo; + this.tempRecvDirectory = savedState.tempRecvDirectory; + } + } + + isBlindUpload() { return 'blind' === this.uploadType; } + isFileTransferComplete() { return !_.isUndefined(this.recvFilePaths); } + + initSequence() { + const self = this; + + if(0 === this.availAreas.length) { + // + return this.gotoMenu(this.menuConfig.config.noUploadAreasAvailMenu || 'fileBaseNoUploadAreasAvail'); + } + + async.series( + [ + function before(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + if(self.isFileTransferComplete()) { + return self.displayProcessingPage(callback); + } else { + return self.displayOptionsPage(callback); + } + } + ], + () => { + return self.finishedLoading(); + } + ); + } + + finishedLoading() { + if(this.isFileTransferComplete()) { + return this.processUploadedFiles(); + } + } + + performUpload(cb) { + temptmp.mkdir( { prefix : 'enigul-' }, (err, tempRecvDirectory) => { + if(err) { + return cb(err); + } + + // need a terminator for various external protocols + this.tempRecvDirectory = pathWithTerminatingSeparator(tempRecvDirectory); + + const modOpts = { + extraArgs : { + recvDirectory : this.tempRecvDirectory, // we'll move files from here to their area container once processed/confirmed + direction : 'recv', + } + }; + + if(!this.isBlindUpload()) { + // data has been sanatized at this point + modOpts.extraArgs.recvFileName = this.viewControllers.options.getView(MciViewIds.options.fileName).getData(); + } + + // + // Move along to protocol selection -> file transfer + // Upon completion, we'll re-enter the module with some file paths handed to us + // + return this.gotoMenu( + this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', + modOpts, + cb + ); + }); + } + + continueNonBlindUpload(cb) { + return cb(null); + } + + updateScanStepInfoViews(stepInfo) { + // :TODO: add some blinking (e.g. toggle items) indicators - see OBV.DOC + + const fmtObj = Object.assign( {}, stepInfo); + let stepIndicatorFmt = ''; + let logStepFmt; + + const fmtConfig = this.menuConfig.config; + + const indicatorStates = fmtConfig.indicatorStates || [ '|', '/', '-', '\\' ]; + const indicatorFinished = fmtConfig.indicatorFinished || '√'; + + const indicator = { }; + const self = this; + + function updateIndicator(mci, isFinished) { + indicator.mci = mci; + + if(isFinished) { + indicator.text = indicatorFinished; + } else { + self.scanStatus.indicatorPos += 1; + if(self.scanStatus.indicatorPos >= indicatorStates.length) { + self.scanStatus.indicatorPos = 0; + } + indicator.text = indicatorStates[self.scanStatus.indicatorPos]; + } + } + + switch(stepInfo.step) { + case 'start' : + logStepFmt = stepIndicatorFmt = fmtConfig.scanningStartFormat || 'Scanning {fileName}'; + break; + + case 'hash_update' : + stepIndicatorFmt = fmtConfig.calcHashFormat || 'Calculating hash/checksums: {calcHashPercent}%'; + updateIndicator(MciViewIds.processing.calcHashIndicator); + break; + + case 'hash_finish' : + stepIndicatorFmt = fmtConfig.calcHashCompleteFormat || 'Finished calculating hash/checksums'; + updateIndicator(MciViewIds.processing.calcHashIndicator, true); + break; + + case 'archive_list_start' : + stepIndicatorFmt = fmtConfig.extractArchiveListFormat || 'Extracting archive list'; + updateIndicator(MciViewIds.processing.archiveListIndicator); + break; + + case 'archive_list_finish' : + fmtObj.archivedFileCount = stepInfo.archiveEntries.length; + stepIndicatorFmt = fmtConfig.extractArchiveListFinishFormat || 'Archive list extracted ({archivedFileCount} files)'; + updateIndicator(MciViewIds.processing.archiveListIndicator, true); + break; + + case 'archive_list_failed' : + stepIndicatorFmt = fmtConfig.extractArchiveListFailedFormat || 'Archive list extraction failed'; + break; + + case 'desc_files_start' : + stepIndicatorFmt = fmtConfig.processingDescFilesFormat || 'Processing description files'; + updateIndicator(MciViewIds.processing.descFileIndicator); + break; + + case 'desc_files_finish' : + stepIndicatorFmt = fmtConfig.processingDescFilesFinishFormat || 'Finished processing description files'; + updateIndicator(MciViewIds.processing.descFileIndicator, true); + break; + + case 'finished' : + logStepFmt = stepIndicatorFmt = fmtConfig.scanningStartFormat || 'Finished'; + break; + } + + fmtObj.stepIndicatorText = stringFormat(stepIndicatorFmt, fmtObj); + + if(this.hasProcessingArt) { + this.updateCustomViewTextsWithFilter('processing', MciViewIds.processing.customRangeStart, fmtObj, { appendMultiLine : true } ); + + if(indicator.mci && indicator.text) { + this.setViewText('processing', indicator.mci, indicator.text); + } + + if(logStepFmt) { + this.setViewText('processing', MciViewIds.processing.logStep, stringFormat(logStepFmt, fmtObj), { appendMultiLine : true } ); + } + } else { + this.client.term.pipeWrite(fmtObj.stepIndicatorText); + } + } + + scanFiles(cb) { + const self = this; + + const results = { + newEntries : [], + dupes : [], + }; + + self.client.log.debug('Scanning upload(s)', { paths : this.recvFilePaths } ); + + let currentFileNum = 0; + + async.eachSeries(this.recvFilePaths, (filePath, nextFilePath) => { + // :TODO: virus scanning/etc. should occur around here + + currentFileNum += 1; + + self.scanStatus = { + indicatorPos : 0, + }; + + const scanOpts = { + areaTag : self.areaInfo.areaTag, + storageTag : self.areaInfo.storageTags[0], + }; + + function handleScanStep(stepInfo, nextScanStep) { + stepInfo.totalFileNum = self.recvFilePaths.length; + stepInfo.currentFileNum = currentFileNum; + + self.updateScanStepInfoViews(stepInfo); + return nextScanStep(null); + } + + self.client.log.debug('Scanning file', { filePath : filePath } ); + + scanFile(filePath, scanOpts, handleScanStep, (err, fileEntry, dupeEntries) => { + if(err) { + return nextFilePath(err); + } + + // new or dupe? + if(dupeEntries.length > 0) { + // 1:n dupes found + self.client.log.debug('Duplicate file(s) found', { dupeEntries : dupeEntries } ); + + results.dupes = results.dupes.concat(dupeEntries); + } else { + // new one + results.newEntries.push(fileEntry); + } + + return nextFilePath(null); + }); + }, err => { + return cb(err, results); + }); + } + + cleanupTempFiles() { + temptmp.cleanup( paths => { + Log.debug( { paths : paths, sessionId : temptmp.sessionId }, 'Temporary files cleaned up' ); + }); + } + + moveAndPersistUploadsToDatabase(newEntries) { + + const areaStorageDir = getAreaDefaultStorageDirectory(this.areaInfo); + const self = this; + + async.eachSeries(newEntries, (newEntry, nextEntry) => { + const src = paths.join(self.tempRecvDirectory, newEntry.fileName); + const dst = paths.join(areaStorageDir, newEntry.fileName); + + moveFileWithCollisionHandling(src, dst, (err, finalPath) => { + if(err) { + self.client.log.error( + 'Failed moving physical upload file', { error : err.message, fileName : newEntry.fileName, source : src, dest : dst } + ); + + return nextEntry(null); // still try next file + } + + self.client.log.debug('Moved upload to area', { path : finalPath } ); + + // persist to DB + newEntry.persist(err => { + if(err) { + self.client.log.error('Failed persisting upload to database', { path : finalPath, error : err.message } ); + } + + return nextEntry(null); // still try next file + }); + }); + }, () => { + // + // Finally, we can remove any temp files that we may have created + // + self.cleanupTempFiles(); + }); + } + + prepDetailsForUpload(scanResults, cb) { + async.eachSeries(scanResults.newEntries, (newEntry, nextEntry) => { + newEntry.meta.upload_by_username = this.client.user.username; + newEntry.meta.upload_by_user_id = this.client.user.userId; + + this.displayFileDetailsPageForUploadEntry(newEntry, (err, newValues) => { + if(err) { + return nextEntry(err); + } + + // if the file entry did *not* have a desc, take the user desc + if(!this.fileEntryHasDetectedDesc(newEntry)) { + newEntry.desc = newValues.shortDesc.trim(); + } + + if(newValues.estYear.length > 0) { + newEntry.meta.est_release_year = newValues.estYear; + } + + if(newValues.tags.length > 0) { + newEntry.setHashTags(newValues.tags); + } + + return nextEntry(err); + }); + }, err => { + delete this.fileDetailsCurrentEntrySubmitCallback; + return cb(err, scanResults); + }); + } + + displayDupesPage(dupes, cb) { + // + // If we have custom art to show, use it - else just dump basic info. + // Pause at the end in either case. + // + const self = this; + + async.waterfall( + [ + function prepArtAndViewController(callback) { + self.prepViewControllerWithArt( + 'dupes', + FormIds.dupes, + { clearScreen : true, trailingLF : false }, + err => { + if(err) { + self.client.term.pipeWrite('|00|07Duplicate upload(s) found:\n'); + return callback(null, null); + } + + const dupeListView = self.viewControllers.dupes.getView(MciViewIds.dupes.dupeList); + return callback(null, dupeListView); + } + ); + }, + function prepDupeObjects(dupeListView, callback) { + // update dupe objects with additional info that can be used for formatString() and the like + async.each(dupes, (dupe, nextDupe) => { + FileEntry.loadBasicEntry(dupe.fileId, dupe, err => { + if(err) { + return nextDupe(err); + } + + const areaInfo = getFileAreaByTag(dupe.areaTag); + if(areaInfo) { + dupe.areaName = areaInfo.name; + dupe.areaDesc = areaInfo.desc; + } + return nextDupe(null); + }); + }, err => { + return callback(err, dupeListView); + }); + }, + function populateDupeInfo(dupeListView, callback) { + const dupeInfoFormat = self.menuConfig.config.dupeInfoFormat || '{fileName} @ {areaName}'; + + if(dupeListView) { + dupeListView.setItems(dupes.map(dupe => stringFormat(dupeInfoFormat, dupe) ) ); + dupeListView.redraw(); + } else { + dupes.forEach(dupe => { + self.client.term.pipeWrite(`${stringFormat(dupeInfoFormat, dupe)}\n`); + }); + } + + return callback(null); + }, + function pause(callback) { + return self.pausePrompt( { row : self.client.term.termHeight }, callback); + } + ], + err => { + return cb(err); + } + ); + } + + processUploadedFiles() { + // + // For each file uploaded, we need to process & gather information + // + const self = this; + + async.waterfall( + [ + function prepNonBlind(callback) { + if(self.isBlindUpload()) { + return callback(null); + } + + // + // For non-blind uploads, batch is not supported, we expect a single file + // in |recvFilePaths|. If not, it's an error (we don't want to process the wrong thing) + // + if(self.recvFilePaths.length > 1) { + self.client.log.warn( { recvFilePaths : self.recvFilePaths }, 'Non-blind upload received 2:n files' ); + return callback(Errors.UnexpectedState(`Non-blind upload expected single file but got received ${self.recvFilePaths.length}`)); + } + + return callback(null); + }, + function scan(callback) { + return self.scanFiles(callback); + }, + function pause(scanResults, callback) { + if(self.hasProcessingArt) { + self.client.term.rawWrite(ansiGoto(self.client.term.termHeight, 1)); + } else { + self.client.term.write('\n'); + } + + self.pausePrompt( () => { + return callback(null, scanResults); + }); + }, + function displayDupes(scanResults, callback) { + if(0 === scanResults.dupes.length) { + return callback(null, scanResults); + } + + return self.displayDupesPage(scanResults.dupes, () => { + return callback(null, scanResults); + }); + }, + function prepDetails(scanResults, callback) { + return self.prepDetailsForUpload(scanResults, callback); + }, + function startMovingAndPersistingToDatabase(scanResults, callback) { + // + // *Start* the process of moving files from their current |tempRecvDirectory| + // locations -> their final area destinations. Don't make the user wait + // here as I/O can take quite a bit of time. Log any failures. + // + self.moveAndPersistUploadsToDatabase(scanResults.newEntries); + return callback(null); + }, + ], + err => { + if(err) { + self.client.log.warn('File upload error encountered', { error : err.message } ); + self.cleanupTempFiles(); // normally called after moveAndPersistUploadsToDatabase() is completed. + } + + return self.prevMenu(); + } + ); + } + + displayOptionsPage(cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.prepViewControllerWithArt( + 'options', + FormIds.options, + { clearScreen : true, trailingLF : false }, + callback + ); + }, + function populateViews(callback) { + const areaSelectView = self.viewControllers.options.getView(MciViewIds.options.area); + areaSelectView.setItems( self.availAreas.map(areaInfo => areaInfo.name ) ); + + const uploadTypeView = self.viewControllers.options.getView(MciViewIds.options.uploadType); + const fileNameView = self.viewControllers.options.getView(MciViewIds.options.fileName); + + const blindFileNameText = self.menuConfig.config.blindFileNameText || '(blind - filename ignored)'; + + uploadTypeView.on('index update', idx => { + self.uploadType = (0 === idx) ? 'blind' : 'non-blind'; + + if(self.isBlindUpload()) { + fileNameView.setText(blindFileNameText); + fileNameView.acceptsFocus = false; + } else { + fileNameView.clearText(); + fileNameView.acceptsFocus = true; + } + }); + + // sanatize filename for display when leaving the view + self.viewControllers.options.on('leave', prevView => { + if(prevView.id === MciViewIds.options.fileName) { + fileNameView.setText(sanatizeFilename(fileNameView.getData())); + } + }); + + self.uploadType = 'blind'; + uploadTypeView.setFocusItemIndex(0); // default to blind + fileNameView.setText(blindFileNameText); + areaSelectView.redraw(); + + return callback(null); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayProcessingPage(cb) { + return this.prepViewControllerWithArt( + 'processing', + FormIds.processing, + { clearScreen : true, trailingLF : false }, + err => { + // note: this art is not required + this.hasProcessingArt = !err; + + return cb(null); + } + ); + } + + fileEntryHasDetectedDesc(fileEntry) { + return (fileEntry.desc && fileEntry.desc.length > 0); + } + + displayFileDetailsPageForUploadEntry(fileEntry, cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.prepViewControllerWithArt( + 'fileDetails', + FormIds.fileDetails, + { clearScreen : true, trailingLF : false }, + callback + ); + }, + function populateViews(callback) { + const descView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.desc); + const tagsView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.tags); + const yearView = self.viewControllers.fileDetails.getView(MciViewIds.fileDetails.estYear); + + self.updateCustomViewTextsWithFilter('fileDetails', MciViewIds.fileDetails.customRangeStart, fileEntry ); + + tagsView.setText( Array.from(fileEntry.hashTags).join(',') ); // :TODO: optional 'hashTagsSep' like file list/browse + yearView.setText(fileEntry.meta.est_release_year || ''); + + if(self.fileEntryHasDetectedDesc(fileEntry)) { + descView.setPropertyValue('mode', 'preview'); + descView.setText(fileEntry.desc); + descView.acceptsFocus = false; + self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.tags); + } else { + descView.setPropertyValue('mode', 'edit'); + descView.setText(''); + descView.acceptsFocus = true; + self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.desc); + } + + return callback(null); + } + ], + err => { + // + // we only call |cb| here if there is an error + // else, wait for the current from to be submit - then call - + // this way we'll move on to the next file entry when ready + // + if(err) { + return cb(err); + } + + self.fileDetailsCurrentEntrySubmitCallback = cb; // stash for moduleMethods.fileDetailsContinue + } + ); + } +}; diff --git a/mods/user_list.js b/mods/user_list.js index 8401a182..07c27965 100644 --- a/mods/user_list.js +++ b/mods/user_list.js @@ -1,15 +1,14 @@ /* jslint node: true */ 'use strict'; -var MenuModule = require('../core/menu_module.js').MenuModule; -//var userDb = require('../core/database.js').dbs.user; -var getUserList = require('../core/user.js').getUserList; -var ViewController = require('../core/view_controller.js').ViewController; +const MenuModule = require('../core/menu_module.js').MenuModule; +const getUserList = require('../core/user.js').getUserList; +const ViewController = require('../core/view_controller.js').ViewController; const stringFormat = require('../core/string_format.js'); -var moment = require('moment'); -var async = require('async'); -var _ = require('lodash'); +const moment = require('moment'); +const async = require('async'); +const _ = require('lodash'); /* Available listFormat/focusListFormat object members: @@ -29,85 +28,85 @@ exports.moduleInfo = { author : 'NuSkooler', }; -exports.getModule = UserListModule; - -var MciCodeIds = { +const MciViewIds = { UserList : 1, }; -function UserListModule(options) { - MenuModule.call(this, options); -} +exports.getModule = class UserListModule extends MenuModule { + constructor(options) { + super(options); + } -require('util').inherits(UserListModule, MenuModule); - -UserListModule.prototype.mciReady = function(mciData, cb) { - var self = this; - var vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); - - var userList = []; - - var USER_LIST_OPTS = { - properties : [ 'location', 'affiliation', 'last_login_timestamp' ], - }; - - async.series( - [ - // :TODO: These two functions repeated all over -- need DRY - function callParentMciReady(callback) { - UserListModule.super_.prototype.mciReady.call(self, mciData, callback); - }, - function loadFromConfig(callback) { - var loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - }; - - vc.loadFromMenuConfig(loadOpts, callback); - }, - function fetchUserList(callback) { - // :TODO: Currently fetching all users - probably always OK, but this could be paged - getUserList(USER_LIST_OPTS, function got(err, ul) { - userList = ul; - callback(err); - }); - }, - function populateList(callback) { - var userListView = vc.getView(MciCodeIds.UserList); - - var listFormat = self.menuConfig.config.listFormat || '{userName} - {affils}'; - var focusListFormat = self.menuConfig.config.focusListFormat || listFormat; // :TODO: default changed color! - var dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD'; - - function getUserFmtObj(ue) { - return { - userId : ue.userId, - userName : ue.userName, - affils : ue.affiliation, - location : ue.location, - // :TODO: the rest! - note : ue.note || '', - lastLoginTs : moment(ue.last_login_timestamp).format(dateTimeFormat), - }; - } - - userListView.setItems(_.map(userList, function formatUserEntry(ue) { - return stringFormat(listFormat, getUserFmtObj(ue)); - })); - - userListView.setFocusItems(_.map(userList, function formatUserEntry(ue) { - return stringFormat(focusListFormat, getUserFmtObj(ue)); - })); - - userListView.redraw(); - callback(null); - } - ], - function complete(err) { + mciReady(mciData, cb) { + super.mciReady(mciData, err => { if(err) { - self.client.log.error( { error : err.toString() }, 'Error loading user list'); + return cb(err); } - cb(err); - } - ); -}; \ No newline at end of file + + const self = this; + const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); + + let userList = []; + + const USER_LIST_OPTS = { + properties : [ 'location', 'affiliation', 'last_login_timestamp' ], + }; + + async.series( + [ + function loadFromConfig(callback) { + var loadOpts = { + callingMenu : self, + mciMap : mciData.menu, + }; + + vc.loadFromMenuConfig(loadOpts, callback); + }, + function fetchUserList(callback) { + // :TODO: Currently fetching all users - probably always OK, but this could be paged + getUserList(USER_LIST_OPTS, function got(err, ul) { + userList = ul; + callback(err); + }); + }, + function populateList(callback) { + var userListView = vc.getView(MciViewIds.UserList); + + var listFormat = self.menuConfig.config.listFormat || '{userName} - {affils}'; + var focusListFormat = self.menuConfig.config.focusListFormat || listFormat; // :TODO: default changed color! + var dateTimeFormat = self.menuConfig.config.dateTimeFormat || 'ddd MMM DD'; + + function getUserFmtObj(ue) { + return { + userId : ue.userId, + userName : ue.userName, + affils : ue.affiliation, + location : ue.location, + // :TODO: the rest! + note : ue.note || '', + lastLoginTs : moment(ue.last_login_timestamp).format(dateTimeFormat), + }; + } + + userListView.setItems(_.map(userList, function formatUserEntry(ue) { + return stringFormat(listFormat, getUserFmtObj(ue)); + })); + + userListView.setFocusItems(_.map(userList, function formatUserEntry(ue) { + return stringFormat(focusListFormat, getUserFmtObj(ue)); + })); + + userListView.redraw(); + callback(null); + } + ], + function complete(err) { + if(err) { + self.client.log.error( { error : err.toString() }, 'Error loading user list'); + } + cb(err); + } + ); + }); + } +}; diff --git a/mods/whos_online.js b/mods/whos_online.js index 770528f0..a0a87829 100644 --- a/mods/whos_online.js +++ b/mods/whos_online.js @@ -18,66 +18,67 @@ exports.moduleInfo = { packageName : 'codes.l33t.enigma.whosonline' }; -exports.getModule = WhosOnlineModule; - -const MciCodeIds = { +const MciViewIds = { OnlineList : 1, }; -function WhosOnlineModule(options) { - MenuModule.call(this, options); -} +exports.getModule = class WhosOnlineModule extends MenuModule { + constructor(options) { + super(options); + } -require('util').inherits(WhosOnlineModule, MenuModule); - -WhosOnlineModule.prototype.mciReady = function(mciData, cb) { - const self = this; - const vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); - - async.series( - [ - function callParentMciReady(callback) { - return WhosOnlineModule.super_.prototype.mciReady.call(self, mciData, callback); - }, - function loadFromConfig(callback) { - const loadOpts = { - callingMenu : self, - mciMap : mciData.menu, - noInput : true, - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - }, - function populateList(callback) { - const onlineListView = vc.getView(MciCodeIds.OnlineList); - const listFormat = self.menuConfig.config.listFormat || '{node} - {userName} - {action} - {timeOn}'; - const nonAuthUser = self.menuConfig.config.nonAuthUser || 'Logging In'; - const otherUnknown = self.menuConfig.config.otherUnknown || 'N/A'; - const onlineList = getActiveNodeList(self.menuConfig.config.authUsersOnly).slice(0, onlineListView.height); - - onlineListView.setItems(_.map(onlineList, oe => { - if(oe.authenticated) { - oe.timeOn = _.capitalize(oe.timeOn.humanize()); - } else { - [ 'realName', 'location', 'affils', 'timeOn' ].forEach(m => { - oe[m] = otherUnknown; - }); - oe.userName = nonAuthUser; - } - return stringFormat(listFormat, oe); - })); - - onlineListView.focusItems = onlineListView.items; - onlineListView.redraw(); - - return callback(null); - } - ], - function complete(err) { + mciReady(mciData, cb) { + super.mciReady(mciData, err => { if(err) { - self.client.log.error( { error : err.message }, 'Error loading who\'s online'); + return cb(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, + noInput : true, + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + }, + function populateList(callback) { + const onlineListView = vc.getView(MciViewIds.OnlineList); + const listFormat = self.menuConfig.config.listFormat || '{node} - {userName} - {action} - {timeOn}'; + const nonAuthUser = self.menuConfig.config.nonAuthUser || 'Logging In'; + const otherUnknown = self.menuConfig.config.otherUnknown || 'N/A'; + const onlineList = getActiveNodeList(self.menuConfig.config.authUsersOnly).slice(0, onlineListView.height); + + onlineListView.setItems(_.map(onlineList, oe => { + if(oe.authenticated) { + oe.timeOn = _.upperFirst(oe.timeOn.humanize()); + } else { + [ 'realName', 'location', 'affils', 'timeOn' ].forEach(m => { + oe[m] = otherUnknown; + }); + oe.userName = nonAuthUser; + } + return stringFormat(listFormat, oe); + })); + + onlineListView.focusItems = onlineListView.items; + onlineListView.redraw(); + + return callback(null); + } + ], + function complete(err) { + if(err) { + self.client.log.error( { error : err.message }, 'Error loading who\'s online'); + } + return cb(err); + } + ); + }); + } }; diff --git a/oputil.js b/oputil.js index 11f4fd8f..365b0d06 100755 --- a/oputil.js +++ b/oputil.js @@ -4,454 +4,4 @@ /* eslint-disable no-console */ 'use strict'; -// ENiGMA½ -const config = require('./core/config.js'); -const db = require('./core/database.js'); -const resolvePath = require('./core/misc_util.js').resolvePath; - -// deps -const _ = require('lodash'); -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'); - -const argv = require('minimist')(process.argv.slice(2)); - -const ExitCodes = { - SUCCESS : 0, - ERROR : -1, - BAD_COMMAND : -2, - BAD_ARGS : -3, -}; - -const USAGE_HELP = { - General : -`usage: optutil.js [--version] [--help] - [] - -global args: - --config PATH : specify config path (${getDefaultConfigPath()}) - -commands: - user : user utilities - config : config file management - -`, - User : -`usage: optutil.js user --user USERNAME - -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 - -valid args: - --new : generate a new/initial configuration -` -}; - -function printUsage(command) { - console.error(USAGE_HELP[command]); -} - -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 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')); - } - } - ); -} - -function handleUserCommand() { - if(true === argv.help || !_.isString(argv.user) || 0 === argv.user.length) { - process.exitCode = ExitCodes.ERROR; - return printUsage('User'); - } - - 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 getAnswers(questions, cb) { - inq.prompt(questions).then( answers => { - return cb(answers); - }); -} - -function getDefaultConfigPath() { - return resolvePath('~/.config/enigma-bbs/config.hjson'); -} - -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 = { - 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) { - process.exitCode = ExitCodes.ERROR; - return printUsage('Config'); - } - - 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 { - process.exitCode = ExitCodes.ERROR; - return printUsage('Config'); - } -} - -function main() { - - process.exitCode = ExitCodes.SUCCESS; - - if(true === argv.version) { - return console.info(require('./package.json').version); - } - - if(0 === argv._.length || - 'help' === argv._[0]) - { - printUsage('General'); - process.exit(ExitCodes.SUCCESS); - } - - switch(argv._[0]) { - case 'user' : - handleUserCommand(); - break; - - case 'config' : - handleConfigCommand(); - break; - - default: - printUsage(''); - process.exitCode = ExitCodes.BAD_COMMAND; - } -} - -main(); \ No newline at end of file +require('./core/oputil/oputil_main.js')(); diff --git a/package.json b/package.json index d35884eb..11a3cbc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "enigma-bbs", - "version": "0.0.1-alpha", + "version": "0.0.4-alpha", "description": "ENiGMA½ Bulletin Board System", "author": "Bryan Ashby ", "license": "BSD-2-Clause", @@ -14,30 +14,37 @@ }, "keywords": [ "bbs", - "telnet" + "telnet", + "retro" ], "dependencies": { - "async": "^1.5.1", + "async": "^2.1.4", "binary": "0.3.x", - "buffers": "0.1.x", + "buffers": "NuSkooler/node-buffers", "bunyan": "^1.7.1", "farmhash": "^1.2.1", - "fs-extra": "0.26.x", - "gaze": "^0.5.2", - "hjson": "1.7.x", + "fs-extra": "^2.0.0", + "gaze": "^1.1.2", + "hashids": "^1.1.1", + "hjson": "^2.4.1", "iconv-lite": "^0.4.13", - "inquirer": "^1.1.0", + "inquirer": "^3.0.1", "later": "1.2.0", - "lodash": "^3.10.1", + "lodash": "^4.17.4", + "mime-types": "^2.1.12", "minimist": "1.2.x", "moment": "^2.11.0", - "node-uuid": "^1.4.7", + "uuid": "^3.0.1", + "uuid-parse" : "^1.0.0", "ptyw.js": "NuSkooler/ptyw.js", "sqlite3": "^3.1.1", "ssh2": "^0.5.1", - "temp": "^0.8.3" + "temptmp" : "^1.0.0", + "sanitize-filename" : "^1.6.1" + }, + "devDependencies": { }, "engines": { - "node": ">=4.2.0" + "node": ">=6.9.2" } } diff --git a/www/.gitkeep b/www/.gitkeep new file mode 100644 index 00000000..e69de29b