enigma-bbs/core/transfer_file.js

499 lines
13 KiB
JavaScript

/* 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');
// 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
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.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();
}
}
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);
});
}
}
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);
});
});
}
});
}
pathWithTerminatingSeparator(path) {
if(path && paths.sep !== path.charAt(path.length - 1)) {
path = path + paths.sep;
}
return path;
}
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,
cwd : tempWorkingDir,
});
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) {
const tmp = data.toString('binary').replace(/\xff/g, '\xff\xff');
this.client.term.rawWrite(new Buffer(tmp, 'binary'));
} else {
this.client.term.rawWrite(data);
}
});
externalProc.once('close', () => {
return this.restorePipeAfterExternalProc(externalProc);
});
externalProc.once('exit', (exitCode) => {
this.client.log.debug( { cmd : cmd, args : args, exitCode : exitCode }, 'Process exited' );
this.restorePipeAfterExternalProc(externalProc);
externalProc.removeAllListeners();
return cb(null);
});
}
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) {
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) {
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();
});
}
);
}
};