diff --git a/core/acs.js b/core/acs.js index 484407da..f2e04b9f 100644 --- a/core/acs.js +++ b/core/acs.js @@ -43,6 +43,10 @@ class ACS { 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); } @@ -75,6 +79,7 @@ ACS.Defaults = { MessageConfRead : 'GM[users]', FileAreaRead : 'GM[users]', + FileAreaWrite : 'GM[sysops]', FileAreaDownload : 'GM[users]', }; diff --git a/core/art.js b/core/art.js index 4b870cde..d33c81da 100644 --- a/core/art.js +++ b/core/art.js @@ -266,7 +266,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 +290,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/config.js b/core/config.js index 975e1d55..0a7a2217 100644 --- a/core/config.js +++ b/core/config.js @@ -289,22 +289,28 @@ function getDefaultConfig() { external : { sendCmd : 'sz', // Avail on Debian/Ubuntu based systems as the package "lrzsz" sendArgs : [ - '--zmodem', '-y', '--try-8k', '--binary', '--restricted', '{filePath}' + '--zmodem', '-y', '--try-8k', '--binary', '--restricted', '{filePaths}' ], escapeTelnet : true, // set to true to escape Telnet codes such as IAC + supportsBatch : true, } }, zmodem8kSexyz : { - name : 'ZModem (SEXYZ)', + name : 'ZModem 8k (SEXYZ)', type : 'external', external : { // :TODO: Look into shipping sexyz binaries or at least hosting them somewhere for common systems sendCmd : 'sexyz', sendArgs : [ - '-telnet', 'sz', '{filePath}' + '-telnet', '-8', 'sz', '@{fileListPath}' + ], + recvCmd : 'sexyz', + recvArgs : [ + '-telnet', '-8', 'rz', '{uploadDir}' ], escapeTelnet : false, // -telnet option does this for us + supportsBatch : true, } } diff --git a/core/door_party.js b/core/door_party.js index 9eb733f4..d782c07e 100644 --- a/core/door_party.js +++ b/core/door_party.js @@ -116,7 +116,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 diff --git a/core/download_queue.js b/core/download_queue.js index e1ecc6f8..7254b9c8 100644 --- a/core/download_queue.js +++ b/core/download_queue.js @@ -12,6 +12,14 @@ module.exports = class 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); @@ -25,10 +33,19 @@ module.exports = class DownloadQueue { fileId : fileEntry.fileId, areaTag : fileEntry.areaTag, fileName : fileEntry.fileName, - byteSize : fileEntry.meta.byteSize || 0, + 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; diff --git a/core/file_area.js b/core/file_area.js index ac0ae0d1..4903f571 100644 --- a/core/file_area.js +++ b/core/file_area.js @@ -34,7 +34,7 @@ const WellKnownAreaTags = exports.WellKnownAreaTags = { }; function getAvailableFileAreas(client, options) { - options = options || { includeSystemInternal : false }; + options = options || { }; // perform ACS check per conf & omit system_internal if desired const areasWithTags = _.map(Config.fileBase.areas, (area, areaTag) => Object.assign(area, { areaTag : areaTag } ) ); @@ -43,6 +43,10 @@ function getAvailableFileAreas(client, options) { return true; } + if(options.writeAcs && !client.acs.FileAreaWrite(area)) { + return true; // omit + } + return !client.acs.hasFileAreaRead(area); }); } diff --git a/core/file_area_web.js b/core/file_area_web.js index cc5227a2..841ce904 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -93,7 +93,7 @@ class FileAreaWebAccess { [ hashId ] ); - delete this.expireTime[hashId]; + delete this.expireTimers[hashId]; } scheduleExpire(hashId, expireTime) { diff --git a/core/file_entry.js b/core/file_entry.js index f4e65b51..f816a62b 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -159,6 +159,21 @@ module.exports = class FileEntry { ); } + 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 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/menu_module.js b/core/menu_module.js index 5477e6d3..d740c478 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -3,14 +3,12 @@ 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; +// deps var async = require('async'); var assert = require('assert'); var _ = require('lodash'); @@ -236,6 +234,11 @@ MenuModule.prototype.gotoMenu = function(name, options, cb) { this.client.menuStack.goto(name, options, cb); }; +MenuModule.prototype.popAndGotoMenu = function(name, options, cb) { + this.client.menuStack.pop(); + this.client.menuStack.goto(name, options, cb); +}; + MenuModule.prototype.leave = function() { this.detachViewControllers(); }; @@ -322,3 +325,65 @@ MenuModule.prototype.finishedLoading = function() { MenuModule.prototype.getMenuResult = function() { // nothing in base }; + +MenuModule.prototype.displayAsset = function(name, options, cb) { + + if(_.isFunction(options)) { + cb = options; + options = {}; + } + + if(options.clearScreen) { + this.client.term.rawWrite(ansi.clearScreen()); + } + + return theme.displayThemedAsset( + name, + this.client, + Object.merge( { font : this.menuConfig.config }, options ), + (err, artData) => { + if(cb) { + return cb(err, artData); + } + } + ); + +}; + +MenuModule.prototype.prepViewController = function(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); + } + + this.viewControllers[name].setFocus(true); + return cb(null); +}; + + +MenuModule.prototype.prepViewControllerWithArt = function(name, formId, options, cb) { + this.displayAsset( + name, + options, + (err, artData) => { + if(err) { + return cb(err); + } + + return this.prepViewController(name, formId, artData, cb); + } + ); +}; \ No newline at end of file diff --git a/core/menu_stack.js b/core/menu_stack.js index 84e6a6e0..d1fc15bb 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -135,11 +135,21 @@ module.exports = class MenuStack { currentModuleInfo.instance.leave(); } - self.push({ - name : name, - instance : modInst, - extraArgs : loadOpts.extraArgs, - }); + const noHistory = modInst.menuConfig.options.menuFlags.indexOf('noHistory') > -1; + + const stackToLog = _.map(self.stack, stackEntry => stackEntry.name); + + if(!noHistory) { + self.push({ + name : name, + instance : modInst, + extraArgs : loadOpts.extraArgs, + }); + + stackToLog.push(name); + } else { + stackToLog.push(`${name} (noHistory)`); + } // restore previous state if requested if(options && options.savedState) { @@ -149,8 +159,11 @@ module.exports = class MenuStack { modInst.enter(); self.client.log.trace( - { stack : _.map(self.stack, stackEntry => stackEntry.name) }, - 'Updated menu stack'); + { + stack : stackToLog + }, + 'Updated menu stack' + ); if(cb) { cb(null); diff --git a/core/menu_util.js b/core/menu_util.js index bc439a9b..f62dbd6c 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; diff --git a/core/predefined_mci.js b/core/predefined_mci.js index aadd824f..9ad300e7 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -9,6 +9,7 @@ const getMessageConferenceByTag = require('./message_area.js').getMessageConfe 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'); @@ -85,6 +86,13 @@ function getPredefinedMCIValue(client, code) { const activeFilter = FileBaseFilters.getActiveFilter(client); return activeFilter ? activeFilter.name : ''; }, + DN : function userNumDownloads() { return StatLog.getUserStat(client.user, 'dl_total_count'); }, // Obv/2 + DK : function userByteDownload() { // Obv/2 + const byteSize = parseInt(StatLog.getUserStat(client.user, 'dl_total_bytes')) || 0; + return formatByteSize(byteSize, true); + }, + // :TODO: Up/down ratio (count) + // :TODO: Up/down ratio (bytes) MS : function accountCreated() { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); }, CS : function currentStatus() { return client.currentStatus; }, @@ -168,6 +176,12 @@ function getPredefinedMCIValue(client, code) { return StatLog.getSystemStat('random_rumor'); }, + // + // System File Base, Up/Download Info + // + // :TODO: DD - Today's # of downloads (iNiQUiTY) + // + // // Special handling for XY // diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index 406e28a5..f23a1dc7 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -438,6 +438,65 @@ function TelnetClient(input, output) { newEnvironRequested : false, }; + this.setTemporaryDataHandler = function(handler) { + this.input.removeAllListeners(); + this.input.on('data', handler); + }; + + this.restoreDataHandler = function() { + this.input.removeAllListeners(); + 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); @@ -482,8 +541,8 @@ function TelnetClient(input, output) { // self.emit('data', bufs.splice(0).toBuffer()); } - }); + */ this.input.on('end', () => { self.emit('end'); diff --git a/core/stat_log.js b/core/stat_log.js index 60e9e77f..4d53581a 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -118,6 +118,10 @@ class StatLog { return user.persistProperty(statName, statValue, cb); } + getUserStat(user, statName) { + return user.properties[statName]; + } + incrementUserStat(user, statName, incrementBy, cb) { incrementBy = incrementBy || 1; diff --git a/core/transfer_file.js b/core/transfer_file.js index 4faf5aa3..bed1a613 100644 --- a/core/transfer_file.js +++ b/core/transfer_file.js @@ -2,14 +2,22 @@ 'use strict'; // enigma-bbs -const MenuModule = require('./menu_module.js').MenuModule; -const Config = require('./config.js').config; -const stringFormat = require('./string_format.js'); +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'); // deps const async = require('async'); const _ = require('lodash'); const pty = require('ptyw.js'); +const temp = require('temp').track(); // track() cleans up temp dir/files for us +const paths = require('path'); +const fs = require('fs'); +const fse = require('fs-extra'); /* Resources @@ -30,67 +38,281 @@ exports.getModule = class TransferFileModule extends MenuModule { super(options); this.config = this.menuConfig.config || {}; - this.config.protocol = this.config.protocol || 'zmodem8kSz'; - this.config.direction = this.config.direction || 'send'; - this.protocolConfig = Config.fileTransferProtocols[this.config.protocol]; + // + // Most options can be set via extraArgs or config block + // + if(options.extraArgs) { + if(options.extraArgs.protocol) { + this.protocolConfig = Config.fileTransferProtocols[options.extraArgs.protocol]; + } - // :TODO: bring in extraArgs for path(s) to send when sending; Allow to hard code in config (e.g. for info pack/static downloads) + 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.recvFiles; + } + + 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 = []; + } + + get isSending() { + return 'send' === this.direction; } restorePipeAfterExternalProc(pipe) { if(!this.pipeRestored) { this.pipeRestored = true; + + this.client.restoreDataHandler(); - this.client.term.output.unpipe(pipe); - this.client.term.output.resume(); + //this.client.term.output.unpipe(pipe); + //this.client.term.output.resume(); } } sendFiles(cb) { - async.eachSeries(this.sendQueue, (filePath, next) => { - // :TODO: built in protocols - // :TODO: use protocol passed in - this.executeExternalProtocolHandler(filePath, err => { - return next(err); + // :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); }); - }, err => { - 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); + }); + } + } + + recvFiles(cb) { + this.executeExternalProtocolHandlerForRecv( (err, tempWorkingDir) => { + if(err) { + return cb(err); + } + + this.receivedFiles = []; + + if(this.recvFileName) { + // file name specified - we expect a single file in |tempWorkingDir| + return cb(null); + } else { + // + // blind recv (upload) - files in |tempWorkingDir| should be named appropriately already + // move files to |this.recvDirectory| + // + fs.readdir(tempWorkingDir, (err, files) => { + if(err) { + return cb(err); + } + + async.each(files, (file, nextFile) => { + fse.move( + paths.join(tempWorkingDir, file), + paths.join(this.recvDirectory, file), + err => { + if(err) { + // :TODO: IMPORTANT: Handle collisions - rename to FILE(1).EXT, etc. + this.client.log.warn( + { tempWorkingDir : tempWorkingDir, recvDirectory : this.recvDirectory, file : file, error : err.message }, + 'Failed to move upload file to destination directory' + ); + } else { + this.receivedFiles.push(file); + } + return nextFile(null); // don't pass along err; try next + } + ); + }, () => { + return cb(null); + }); + + }); + } }); } - executeExternalProtocolHandler(filePath, cb) { - const external = this.protocolConfig.external; - const cmd = external[`${this.config.direction}Cmd`]; - const args = external[`${this.config.direction}Args`].map(arg => { - return stringFormat(arg, { - filePath : filePath, - }); - }); + pathWithTerminatingSeparator(path) { + if(path && paths.sep !== path.charAt(path.length - 1)) { + path = path + paths.sep; + } + return path; + } - /*this.client.term.rawWrite(new Buffer( - [ - 255, 253, 0, // IAC DO TRANSMIT_BINARY - 255, 251, 0, // IAC WILL TRANSMIT_BINARY - ] - ));*/ + prepAndBuildSendArgs(filePaths, cb) { + const external = this.protocolConfig.external; + const externalArgs = external[`${this.direction}Args`]; + const self = this; + let tempWorkingDir; + + async.waterfall( + [ + function getTempFileListPath(callback) { + const hasFileList = externalArgs.find(ea => (ea.indexOf('{fileListPath}') > -1) ); + if(!hasFileList) { + temp.mkdir('enigdl-', (err, tempDir) => { + if(err) { + return callback(err); + } + + tempWorkingDir = self.pathWithTerminatingSeparator(tempDir); + return callback(null, null); + }); + } else { + temp.open( { prefix : 'enigdl-', suffix : '.txt' }, (err, tempFileInfo) => { + if(err) { + return callback(err); // failed to create it + } + + tempWorkingDir = self.pathWithTerminatingSeparator(paths.dirname(tempFileInfo.path)); + + fs.write(tempFileInfo.fd, filePaths.join('\n')); + 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, tempWorkingDir); + } + ); + } + + prepAndBuildRecvArgs(cb) { + const self = this; + + async.waterfall( + [ + function getTempRecvPath(callback) { + temp.mkdir('enigrcv-', (err, tempWorkingDir) => { + tempWorkingDir = self.pathWithTerminatingSeparator(tempWorkingDir); + return callback(err, tempWorkingDir); + }); + }, + function createArgs(tempWorkingDir, callback) { + const externalArgs = self.protocolConfig.external[`${self.direction}Args`]; + const args = externalArgs.map(arg => stringFormat(arg, { + uploadDir : tempWorkingDir, + fileName : self.recvFileName || '', + })); + + return callback(null, args, tempWorkingDir); + } + ], + (err, args, tempWorkingDir) => { + return cb(err, args, tempWorkingDir); + } + ); + } + + executeExternalProtocolHandler(args, tempWorkingDir, cb) { + const external = this.protocolConfig.external; + const cmd = external[`${this.direction}Cmd`]; + + this.client.log.debug( + { cmd : cmd, args : args, tempDir : tempWorkingDir, direction : this.direction }, + 'Executing external protocol' + ); const externalProc = pty.spawn(cmd, args, { - cols : this.client.term.termWidth, - rows : this.client.term.termHeight, - // :TODO: cwd - // :TODO: anything else?? - //env : self.exeInfo.env, + cols : this.client.term.termWidth, + rows : this.client.term.termHeight, + cwd : tempWorkingDir, }); - this.client.term.output.pipe(externalProc); - - /*this.client.term.output.on('data', data => { - // let tmp = data.toString('binary').replace(/\xff\xff/g, '\xff'); - // proc.write(new Buffer(tmp, 'binary')); - proc.write(data); + this.client.setTemporaryDataHandler(data => { + externalProc.write(data); }); - */ + + //this.client.term.output.pipe(externalProc); + externalProc.on('data', data => { // needed for things like sz/rz if(external.escapeTelnet) { @@ -105,7 +327,9 @@ exports.getModule = class TransferFileModule extends MenuModule { return this.restorePipeAfterExternalProc(externalProc); }); - externalProc.once('exit', exitCode => { + externalProc.once('exit', (exitCode) => { + this.client.log.debug( { cmd : cmd, args : args, exitCode : exitCode }, 'Process exited' ); + this.restorePipeAfterExternalProc(externalProc); externalProc.removeAllListeners(); @@ -113,20 +337,162 @@ exports.getModule = class TransferFileModule extends MenuModule { }); } + executeExternalProtocolHandlerForSend(filePaths, cb) { + if(!Array.isArray(filePaths)) { + filePaths = [ filePaths ]; + } + + this.prepAndBuildSendArgs(filePaths, (err, args, tempWorkingDir) => { + if(err) { + return cb(err); + } + + this.executeExternalProtocolHandler(args, tempWorkingDir, err => { + return cb(err); + }); + }); + } + + executeExternalProtocolHandlerForRecv(cb) { + this.prepAndBuildRecvArgs( (err, args, tempWorkingDir) => { + if(err) { + return cb(err); + } + + this.executeExternalProtocolHandler(args, tempWorkingDir, err => { + return cb(err, tempWorkingDir); + }); + }); + } + + getMenuResult() { + return { sentFileIds : this.sentFileIds }; + } + + 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); + } + + downloadCount += 1; + + if(_.isNumber(queueItem.byteSize)) { + 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 { + 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) { + // :TODO: update user & system upload stats + return cb(null); + } + initSequence() { const self = this; + // :TODO: break this up to send|recv + async.series( [ function validateConfig(callback) { - // :TODO: + if(self.isSending) { + if(!Array.isArray(self.sendQueue)) { + self.sendQueue = [ self.sendQueue ]; + } + } + return callback(null); }, function transferFiles(callback) { - self.sendQueue = [ '/home/nuskooler/Downloads/fdoor100.zip' ]; // :TODO: testing of course - return self.sendFiles(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) { + temp.cleanup( err => { + if(err) { + self.client.log.warn( { error : err.message }, 'Failed to clean up temporary file/directory(s)' ); + } + return callback(null); // ignore err + }); + }, + 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'); + } + + // Wait for a key press - attempt to avoid issues with some terminals after xfer + self.client.term.write('|00\nTransfer(s) complete. Press a key\n'); + self.client.waitForKeyPress( () => { + self.prevMenu(); + }); + } ); } }; diff --git a/main.js b/main.js index ef624294..ab8d2652 100755 --- a/main.js +++ b/main.js @@ -1,6 +1,7 @@ #!/usr/bin/env node /* jslint node: true */ + 'use strict'; /* diff --git a/mods/file_area_filter_edit.js b/mods/file_area_filter_edit.js index 80d87fa3..e7935f9a 100644 --- a/mods/file_area_filter_edit.js +++ b/mods/file_area_filter_edit.js @@ -10,7 +10,6 @@ const stringFormat = require('../core/string_format.js'); // deps const async = require('async'); -const _ = require('lodash'); exports.moduleInfo = { name : 'File Area Filter Editor', diff --git a/mods/file_area_list.js b/mods/file_area_list.js index 219e4d20..58e72c35 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -496,7 +496,7 @@ exports.getModule = class FileAreaList extends MenuModule { } if('re-cached' === cacheStatus) { - const fileListEntryFormat = this.menuConfig.config.fileListEntryFormat || '{fileName} {fileSize}'; + 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) ) ); diff --git a/mods/file_base_download_manager.js b/mods/file_base_download_manager.js new file mode 100644 index 00000000..017a3141 --- /dev/null +++ b/mods/file_base_download_manager.js @@ -0,0 +1,195 @@ +/* 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, + } + }; + + return this.gotoMenu(this.menuConfig.config.fileTransferProtocolSelection || 'fileTransferProtocolSelection', modOpts, cb); + }, + viewItemInfo : (formData, extraArgs, cb) => { + }, + removeItem : (formData, extraArgs, cb) => { + const selectedItem = formData.value.queueItem; + this.dlQueue.removeItems(selectedItem); + return this.updateDownloadQueueView(cb); + }, + clearQueue : (formData, extraArgs, cb) => { + this.dlQueue.clear(); + return this.updateDownloadQueueView(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 + // :TODO: This technique can be applied in many areas of the code; probablly need a better name than 'popAndGotoMenu' though + // ...actually, the option to not append to the stack would be better here + return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); + //return this.popAndGotoMenu(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(); + } + ); + } + + 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.clearScreen()); + } + + 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_transfer_protocol_select.js b/mods/file_transfer_protocol_select.js new file mode 100644 index 00000000..b6f2a8ce --- /dev/null +++ b/mods/file_transfer_protocol_select.js @@ -0,0 +1,126 @@ +/* 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 || {}; + this.config.direction = this.config.direction || 'send'; + + this.loadAvailProtocols(); + + this.extraArgs = options.extraArgs; + + if(_.has(options, 'lastMenuResult.sentFileIds')) { + this.sentFileIds = options.lastMenuResult.sentFileIds; + } + + this.fallbackOnly = options.lastMenuResult ? true : false; + + 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 || 'downloadFiles', modOpts, cb); + } else { + return this.gotoMenu(this.config.uploadFilesMenu || 'uploadFiles', modOpts, cb); + } + }, + }; + } + + getMenuResult() { + if(this.sentFileIds) { + return { sentFileIds : this.sentFileIds }; + } + } + + initSequence() { + if(this.sentFileIds) { + // nothing to do here; move along + 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, + }; + }); + + this.protocols.sort( (a, b) => a.name.localeCompare(b.name) ); + } +};