enigma-bbs/core/file_transfer.js

732 lines
24 KiB
JavaScript

/* jslint node: true */
'use strict';
// enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule;
const Config = require('./config.js').get;
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;
const Events = require('./events.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
// deps
const async = require('async');
const _ = require('lodash');
const pty = require('node-pty');
const temptmp = require('temptmp').createTrackedSession('transfer_file');
const paths = require('path');
const fs = require('graceful-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
//
const config = Config();
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 },
`User "${this.client.user.username}" downloaded ${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(
callback => callback(null, 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), err => {
if (err) {
return callback(err);
}
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`];
// support for handlers that need IACs taken care of over Telnet/etc.
const processIACs = external.processIACs || external.escapeTelnet; // deprecated name
// :TODO: we should only do this when over Telnet (or derived, such as WebSockets)?
const IAC = Buffer.from([255]);
const EscapedIAC = Buffer.from([255, 255]);
this.client.log.debug(
{
cmd: cmd,
args: args,
tempDir: this.recvDirectory,
direction: this.direction,
},
'Executing external protocol'
);
const spawnOpts = {
cols: this.client.term.termWidth,
rows: this.client.term.termHeight,
cwd: this.recvDirectory,
encoding: null, // don't bork our data!
};
const externalProc = pty.spawn(cmd, args, spawnOpts);
let dataHits = 0;
const updateActivity = () => {
if (0 === dataHits++ % 4) {
this.client.explicitActivityTimeUpdate();
}
};
this.client.setTemporaryDirectDataHandler(data => {
updateActivity();
// needed for things like sz/rz
if (processIACs) {
let iacPos = data.indexOf(EscapedIAC);
if (-1 === iacPos) {
return externalProc.write(data);
}
// at least one double (escaped) IAC
let lastPos = 0;
while (iacPos > -1) {
let rem = iacPos - lastPos;
if (rem >= 0) {
externalProc.write(data.slice(lastPos, iacPos + 1));
}
lastPos = iacPos + 2;
iacPos = data.indexOf(EscapedIAC, lastPos);
}
if (lastPos < data.length) {
externalProc.write(data.slice(lastPos));
}
// const tmp = data.toString('binary').replace(/\xff{2}/g, '\xff'); // de-escape
// externalProc.write(Buffer.from(tmp, 'binary'));
} else {
externalProc.write(data);
}
});
externalProc.onData(data => {
updateActivity();
// needed for things like sz/rz
if (processIACs) {
let iacPos = data.indexOf(IAC);
if (-1 === iacPos) {
return this.client.term.rawWrite(data);
}
// Has at least a single IAC
let lastPos = 0;
while (iacPos !== -1) {
if (iacPos - lastPos > 0) {
this.client.term.rawWrite(data.slice(lastPos, iacPos));
}
this.client.term.rawWrite(EscapedIAC);
lastPos = iacPos + 1;
iacPos = data.indexOf(IAC, lastPos);
}
if (lastPos < data.length) {
this.client.term.rawWrite(data.slice(lastPos));
}
} 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,
UserProps.FileDlTotalCount,
downloadCount
);
StatLog.incrementUserStat(
this.client.user,
UserProps.FileDlTotalBytes,
downloadBytes
);
StatLog.incrementSystemStat(SysProps.FileDlTotalCount, downloadCount);
StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, 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,
UserProps.FileUlTotalCount,
uploadCount
);
StatLog.incrementUserStat(
this.client.user,
UserProps.FileUlTotalBytes,
uploadBytes
);
StatLog.incrementSystemStat(SysProps.FileUlTotalCount, uploadCount);
StatLog.incrementSystemStat(SysProps.FileUlTotalBytes, 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);
const dlFileEntries = dlQueue.removeItems(sentFileIds);
// fire event for downloaded entries
Events.emit(Events.getSystemEvents().UserDownload, {
user: self.client.user,
files: dlFileEntries,
});
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();
}
);
}
};