enigma-bbs/core/oputil/oputil_message_base.js

873 lines
30 KiB
JavaScript

/* jslint node: true */
/* eslint-disable no-console */
'use strict';
const {
printUsageAndSetExitCode,
getConfigPath,
ExitCodes,
argv,
initConfigAndDatabases,
getAnswers,
writeConfig,
} = require('./oputil_common.js');
const getHelpFor = require('./oputil_help.js').getHelpFor;
const Address = require('../ftn_address.js');
const Errors = require('../enig_error.js').Errors;
// deps
const async = require('async');
const paths = require('path');
const fs = require('fs');
const hjson = require('hjson');
const _ = require('lodash');
const moment = require('moment');
exports.handleMessageBaseCommand = handleMessageBaseCommand;
function areaFix() {
//
// oputil mb areafix CMD1 CMD2 ... ADDR [--password PASS]
//
if (argv._.length < 3) {
return printUsageAndSetExitCode(getHelpFor('MessageBase'), ExitCodes.ERROR);
}
async.waterfall(
[
function init(callback) {
return initConfigAndDatabases(callback);
},
function validateAddress(callback) {
const addrArg = argv._.slice(-1)[0];
const ftnAddr = Address.fromString(addrArg);
if (!ftnAddr) {
return callback(
Errors.Invalid(`"${addrArg}" is not a valid FTN address`)
);
}
//
// We need to validate the address targets a system we know unless
// the --force option is used
//
// :TODO:
return callback(null, ftnAddr);
},
function fetchFromUser(ftnAddr, callback) {
//
// --from USER || +op from system
//
// If possible, we want the user ID of the supplied user as well
//
const User = require('../user.js');
if (argv.from) {
User.getUserIdAndNameByLookup(argv.from, (err, userId, fromName) => {
if (err) {
return callback(null, ftnAddr, argv.from, 0);
}
// fromName is the same as argv.from, but case may be differnet (yet correct)
return callback(null, ftnAddr, fromName, userId);
});
} else {
User.getUserName(User.RootUserID, (err, fromName) => {
return callback(
null,
ftnAddr,
fromName || 'SysOp',
err ? 0 : User.RootUserID
);
});
}
},
function createMessage(ftnAddr, fromName, fromUserId, callback) {
//
// Build message as commands separated by line feed
//
// We need to remove quotes from arguments. These are required
// in the case of e.g. removing an area: "-SOME_AREA" would end
// up confusing minimist, therefor they must be quoted: "'-SOME_AREA'"
//
const messageBody =
argv._.slice(2, -1)
.map(arg => {
return arg.replace(/["']/g, '');
})
.join('\r\n') + '\n';
const Message = require('../message.js');
const message = new Message({
toUserName: argv.to || 'AreaFix',
fromUserName: fromName,
subject: argv.password || '',
message: messageBody,
areaTag: Message.WellKnownAreaTags.Private, // mark private
meta: {
System: {
[Message.SystemMetaNames.RemoteToUser]: ftnAddr.toString(), // where to send it
[Message.SystemMetaNames.ExternalFlavor]:
Message.AddressFlavor.FTN, // on FTN-style network
},
},
});
if (0 !== fromUserId) {
message.setLocalFromUserId(fromUserId);
}
return callback(null, message);
},
function persistMessage(message, callback) {
message.persist(err => {
if (!err) {
console.log(
'AreaFix message persisted and will be exported at next scheduled scan'
);
}
return callback(err);
});
},
],
err => {
if (err) {
process.exitCode = ExitCodes.ERROR;
console.error(`${err.message}${err.reason ? ': ' + err.reason : ''}`);
}
}
);
}
function validateUplinks(uplinks) {
const ftnAddress = require('../../core/ftn_address.js');
const valid = uplinks.every(ul => {
const addr = ftnAddress.fromString(ul);
return addr;
});
return valid;
}
function getMsgAreaImportType(path) {
if (argv.type) {
return argv.type.toLowerCase();
}
return paths.extname(path).substr(1).toLowerCase(); // bbs|na|...
}
function importAreas() {
const importPath = argv._[argv._.length - 1];
if (argv._.length < 3 || !importPath || 0 === importPath.length) {
return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR);
}
const importType = getMsgAreaImportType(importPath);
if ('na' !== importType && 'bbs' !== importType) {
return console.error(`"${importType}" is not a recognized import file type`);
}
// optional data - we'll prompt if for anything not found
let confTag = argv.conf;
let networkName = argv.network;
let uplinks = argv.uplinks;
if (uplinks) {
uplinks = uplinks.split(/[\s,]+/);
}
let importEntries;
async.waterfall(
[
function readImportFile(callback) {
fs.readFile(importPath, 'utf8', (err, importData) => {
if (err) {
return callback(err);
}
importEntries = getImportEntries(importType, importData);
if (0 === importEntries.length) {
return callback(Errors.Invalid('Invalid or empty import file'));
}
// We should have enough to validate uplinks
if ('bbs' === importType) {
for (let i = 0; i < importEntries.length; ++i) {
if (!validateUplinks(importEntries[i].uplinks)) {
return callback(Errors.Invalid('Invalid uplink(s)'));
}
}
} else {
if (!validateUplinks(uplinks || [])) {
return callback(Errors.Invalid('Invalid uplink(s)'));
}
}
return callback(null);
});
},
function init(callback) {
return initConfigAndDatabases(callback);
},
function validateAndCollectInput(callback) {
const msgArea = require('../../core/message_area.js');
const sysConfig = require('../../core/config.js').get();
let msgConfs = msgArea.getSortedAvailMessageConferences(null, {
noClient: true,
});
if (!msgConfs) {
return callback(
Errors.DoesNotExist('No conferences exist in your configuration')
);
}
msgConfs = msgConfs.map(mc => {
return {
name: mc.conf.name,
value: mc.confTag,
};
});
if (
confTag &&
!msgConfs.find(mc => {
return confTag === mc.value;
})
) {
return callback(
Errors.DoesNotExist(`Conference "${confTag}" does not exist`)
);
}
const existingNetworkNames = Object.keys(
_.get(sysConfig, 'messageNetworks.ftn.networks', {})
);
if (
networkName &&
!existingNetworkNames.find(net => networkName === net)
) {
return callback(
Errors.DoesNotExist(
`FTN style Network "${networkName}" does not exist`
)
);
}
// can't use --uplinks without a network
if (!networkName && 0 === existingNetworkNames.length && uplinks) {
return callback(
Errors.Invalid(
'Cannot use --uplinks without an FTN network to import to'
)
);
}
getAnswers(
[
{
name: 'confTag',
message: 'Message conference:',
type: 'list',
choices: msgConfs,
pageSize: 10,
when: !confTag,
},
{
name: 'networkName',
message: 'FTN network name:',
type: 'list',
choices: ['-None-'].concat(existingNetworkNames),
pageSize: 10,
when: !networkName && existingNetworkNames.length > 0,
filter: choice => {
return '-None-' === choice ? undefined : choice;
},
},
],
answers => {
confTag = confTag || answers.confTag;
networkName = networkName || answers.networkName;
uplinks = uplinks || answers.uplinks;
importEntries.forEach(ie => {
ie.areaTag = ie.ftnTag.toLowerCase();
});
return callback(null);
}
);
},
function collectUplinks(callback) {
if (!networkName || uplinks || 'bbs' === importType) {
return callback(null);
}
getAnswers(
[
{
name: 'uplinks',
message: 'Uplink(s) (comma separated):',
type: 'input',
validate: input => {
const inputUplinks = input.split(/[\s,]+/);
return validateUplinks(inputUplinks)
? true
: 'Invalid uplink(s)';
},
},
],
answers => {
uplinks = answers.uplinks;
return callback(null);
}
);
},
function confirmWithUser(callback) {
const sysConfig = require('../../core/config.js').get();
console.info(`Importing the following for "${confTag}"`);
console.info(
`(${sysConfig.messageConferences[confTag].name} - ${sysConfig.messageConferences[confTag].desc})`
);
console.info('');
importEntries.forEach(ie => {
console.info(` ${ie.ftnTag} - ${ie.name}`);
});
if (networkName) {
console.info('');
console.info(`For FTN network: ${networkName}`);
console.info(`Uplinks: ${uplinks}`);
console.info('');
console.info(
'Importing will NOT create required FTN network configurations.'
);
console.info(
'If you have not yet done this, you will need to complete additional steps after importing.'
);
console.info('See Message Networks docs for details.');
console.info('');
}
getAnswers(
[
{
name: 'proceed',
message: 'Proceed?',
type: 'confirm',
},
],
answers => {
return callback(
answers.proceed ? null : Errors.General('User canceled')
);
}
);
},
function loadConfigHjson(callback) {
const configPath = getConfigPath();
fs.readFile(configPath, 'utf8', (err, confData) => {
if (err) {
return callback(err);
}
let config;
try {
config = hjson.parse(confData, { keepWsc: true });
} catch (e) {
return callback(e);
}
return callback(null, config);
});
},
function performImport(config, callback) {
const confAreas = { messageConferences: {} };
confAreas.messageConferences[confTag] = { areas: {} };
const msgNetworks = { messageNetworks: { ftn: { areas: {} } } };
importEntries.forEach(ie => {
const specificUplinks = ie.uplinks || uplinks; // AREAS.BBS has specific uplinks per area
confAreas.messageConferences[confTag].areas[ie.areaTag] = {
name: ie.name,
desc: ie.name,
};
if (networkName) {
msgNetworks.messageNetworks.ftn.areas[ie.areaTag] = {
network: networkName,
tag: ie.ftnTag,
uplinks: specificUplinks,
};
}
});
const newConfig = _.defaultsDeep(config, confAreas, msgNetworks);
const configPath = getConfigPath();
if (!writeConfig(newConfig, configPath)) {
return callback(
Errors.UnexpectedState('Failed writing configuration')
);
}
return callback(null);
},
],
err => {
if (err) {
console.error(err.reason ? err.reason : err.message);
} else {
const addFieldUpd = 'bbs' === importType ? '"name" and "desc"' : '"desc"';
console.info('Import complete.');
console.info(
`You may wish to validate changes made to ${getConfigPath()}`
);
console.info(`as well as update ${addFieldUpd} fields, sorting, etc.`);
console.info('');
}
}
);
}
function getImportEntries(importType, importData) {
let importEntries = [];
if ('na' === importType) {
//
// parse out
// TAG DESC
//
const re = /^([^\s]+)\s+([^\r\n]+)/gm;
let m;
while ((m = re.exec(importData))) {
importEntries.push({
ftnTag: m[1].trim(),
name: m[2].trim(),
});
}
} else if ('bbs' === importType) {
//
// Various formats for AREAS.BBS seem to exist. We want to support as much as possible.
//
// SBBS http://www.synchro.net/docs/sbbsecho.html#AREAS.BBS
// CODE TAG UPLINKS
//
// VADV https://www.vadvbbs.com/products/vadv/support/docs/docs_vfido.php#AREAS.BBS
// TAG UPLINKS
//
// Misc
// PATH|OTHER TAG UPLINKS
//
// Assume the second item is TAG and 1:n UPLINKS (space and/or comma sep) after (at the end)
//
const re = /^[^\s]+\s+([^\s]+)\s+([^\n]+)$/gm;
let m;
while ((m = re.exec(importData))) {
const tag = m[1].trim();
importEntries.push({
ftnTag: tag,
name: `Area: ${tag}`,
uplinks: m[2].trim().split(/[\s,]+/),
});
}
}
return importEntries;
}
function dumpQWKPacket() {
const packetPath = argv._[argv._.length - 1];
if (argv._.length < 3 || !packetPath || 0 === packetPath.length) {
return printUsageAndSetExitCode(getHelpFor('MessageBase'), ExitCodes.ERROR);
}
async.waterfall(
[
callback => {
return initConfigAndDatabases(callback);
},
callback => {
const { QWKPacketReader } = require('../qwk_mail_packet');
const reader = new QWKPacketReader(packetPath);
reader.on('error', err => {
console.error(`ERROR: ${err.message}`);
return callback(err);
});
reader.on('done', () => {
return callback(null);
});
reader.on('archive type', archiveType => {
console.info(`-> Archive type: ${archiveType}`);
});
reader.on('creator', creator => {
console.info(`-> Creator: ${creator}`);
});
reader.on('message', message => {
console.info('--- message ---');
console.info(`To: ${message.toUserName}`);
console.info(`From: ${message.fromUserName}`);
console.info(`Subject: ${message.subject}`);
console.info(`Message:\r\n${message.message}`);
});
reader.read();
},
],
err => {
if (err) {
console.error(`QWK dump failed: ${err.message}`);
}
}
);
}
function exportQWKPacket() {
let packetPath = argv._[argv._.length - 1];
if (argv._.length < 3 || !packetPath || 0 === packetPath.length) {
return printUsageAndSetExitCode(getHelpFor('MessageBase'), ExitCodes.ERROR);
}
// oputil mb qwk-export TAGS PATH [--user USER] [--after TIMESTAMP]
// [areaTag1,areaTag2,...] PATH --user USER --after TIMESTAMP
let bbsID = 'ENIGMA';
const filename = paths.basename(packetPath);
if (filename) {
const ext = paths.extname(filename);
bbsID = paths.basename(filename, ext);
}
packetPath = paths.dirname(packetPath);
const posArgLen = argv._.length;
let areaTags;
if (4 === posArgLen) {
areaTags = argv._[posArgLen - 2].split(',');
} else {
areaTags = [];
}
let newerThanTimestamp = null;
if (argv.after) {
const ts = moment(argv.after);
if (ts.isValid()) {
newerThanTimestamp = ts.format();
}
}
const userName = argv.user || '-';
const writerOptions = {
enableQWKE: !(false === argv.qwke),
enableHeadersExtension: !(false === argv.synchronet),
enableAtKludges: !(false === argv.synchronet),
archiveFormat: argv.format || 'application/zip',
};
let totalExported = 0;
async.waterfall(
[
callback => {
return initConfigAndDatabases(callback);
},
callback => {
const User = require('../../core/user.js');
User.getUserIdAndName(userName, (err, userId) => {
if (err) {
if ('-' === userName) {
userId = 1;
} else {
return callback(err);
}
}
return User.getUser(userId, callback);
});
},
(user, callback) => {
// populate area tags with all available to user
// if they were not explicitly supplied
if (!areaTags.length) {
const {
getAllAvailableMessageAreaTags,
} = require('../../core/message_area');
areaTags = getAllAvailableMessageAreaTags();
}
return callback(null, user);
},
(user, callback) => {
const Message = require('../message');
const filter = {
resultType: 'id',
areaTag: areaTags,
newerThanTimestamp,
};
// public
Message.findMessages(filter, (err, publicMessageIds) => {
if (err) {
return callback(err);
}
delete filter.areaTag;
filter.privateTagUserId = user.userId;
Message.findMessages(filter, (err, privateMessageIds) => {
return callback(
err,
user,
Message,
privateMessageIds.concat(publicMessageIds)
);
});
});
},
(user, Message, messageIds, callback) => {
const { QWKPacketWriter } = require('../qwk_mail_packet');
const writer = new QWKPacketWriter(
Object.assign(writerOptions, {
bbsID,
user,
})
);
writer.on('ready', () => {
async.eachSeries(
messageIds,
(messageId, nextMessageId) => {
const message = new Message();
message.load({ messageId }, err => {
if (!err) {
writer.appendMessage(message);
++totalExported;
}
return nextMessageId(err);
});
},
err => {
writer.finish(packetPath);
if (err) {
console.error(
`Failed to write one or more messages: ${err.message}`
);
}
}
);
});
writer.on('warning', err => {
console.warn(`!!! ${err.reason ? err.reason : err.message}`);
});
writer.on('finished', () => {
return callback(null);
});
writer.init();
},
],
err => {
if (err) {
return console.error(err.reason ? err.reason : err.message);
}
console.info(`-> Exported ${totalExported} messages`);
}
);
}
const listConferences = () => {
initConfigAndDatabases(err => {
if (err) {
return console.error(err.reason ? err.reason : err.message);
}
const { getSortedAvailMessageConferences } = require('../../core/message_area');
const conferences = getSortedAvailMessageConferences(null, { noClient: true });
for (let conf of conferences) {
console.info(`${conf.confTag} - ${conf.conf.name}`);
if (!argv.areas) {
continue;
}
for (let areaTag of Object.keys(conf.conf.areas)) {
console.info(` ${areaTag} - ${conf.conf.areas[areaTag].name}`);
}
}
});
};
const postMessage = () => {
const inputFile = argv._[argv._.length - 1];
if (argv._.length < 3 || !inputFile || 0 === inputFile.length) {
return printUsageAndSetExitCode(getHelpFor('MessageBase'), ExitCodes.ERROR);
}
async.waterfall(
[
callback => {
return initConfigAndDatabases(callback);
},
callback => {
fs.readFile(inputFile, { encoding: 'utf-8' }, (err, jsonData) => {
if (err) {
return callback(err);
}
let messageJson;
try {
messageJson = JSON.parse(jsonData);
} catch (e) {
return callback(e);
}
for (let f of ['to', 'from', 'subject', 'body', 'areaTag']) {
if (!_.isString(messageJson[f])) {
return callback(
Errors.MissingConfig(
`Missing "${f}" field in message JSON`
)
);
}
messageJson[f] = messageJson[f].trim();
if (messageJson[f].length === 0 && f !== 'subject') {
return callback(
Errors.Invalid(
`"${messageJson[f]}" is not a valid value for the "${f}" field`
)
);
}
}
const { getMessageAreaByTag } = require('../../core/message_area');
const area = getMessageAreaByTag(messageJson.areaTag);
if (!area) {
return callback(
Errors.DoesNotExist(
`Area "${messageJson.areaTag}" does not exist`
)
);
}
const { getAddressedToInfo } = require('../../core/mail_util');
const Message = require('../../core/message');
const toInfo = getAddressedToInfo(messageJson.to);
const fromInfo = getAddressedToInfo(messageJson.from);
if (fromInfo.flavor !== Message.AddressFlavor.Local) {
return callback(
Errors.Invalid(
'Only local "from" users are currently supported'
)
);
}
let modTimestamp;
if (_.isString(messageJson.timestamp)) {
modTimestamp = moment(messageJson.timestamp);
}
if (!modTimestamp || !modTimestamp.isValid()) {
modTimestamp = moment();
}
const message = new Message({
toUserName: messageJson.to,
fromUserName: messageJson.from,
subject: messageJson.subject,
message: messageJson.body,
areaTag: messageJson.areaTag,
modTimestamp,
});
if (toInfo.flavor !== Message.AddressFlavor.Local) {
message.setExternalFlavor(toInfo.flavor);
message.setRemoteToUser(toInfo.remote);
return callback(null, area, message);
}
const User = require('../../core/user');
User.getUserIdAndNameByLookup(
message.toUserName,
(err, toUserId, toUserName) => {
if (err) {
return callback(
Errors.DoesNotExist(
`User "${message.toUserName}" does not exist.`
)
);
}
message.to = toUserName; // adjust case/etc.
message.setLocalToUserId(toUserId);
return callback(null, area, message);
}
);
});
},
(area, message, callback) => {
message.persist(err => {
if (!err) {
console.info(
`Message from ${message.fromUserName} to ${message.toUserName}: "${message.subject}" in ${area.name}`
);
}
return callback(err);
});
},
],
err => {
if (err) {
return console.error(err.reason ? err.reason : err.message);
}
}
);
};
function handleMessageBaseCommand() {
function errUsage() {
return printUsageAndSetExitCode(getHelpFor('MessageBase'), ExitCodes.ERROR);
}
if (true === argv.help) {
return errUsage();
}
const action = argv._[1];
return (
{
areafix: areaFix,
'import-areas': importAreas,
'qwk-dump': dumpQWKPacket,
'qwk-export': exportQWKPacket,
'list-confs': listConferences,
post: postMessage,
}[action] || errUsage
)();
}