enigma-bbs/core/oputil/oputil_config.js

603 lines
20 KiB
JavaScript

/* jslint node: true */
/* eslint-disable no-console */
'use strict';
// ENiGMA½
const resolvePath = require('../../core/misc_util.js').resolvePath;
const {
printUsageAndSetExitCode,
getConfigPath,
argv,
ExitCodes,
initConfigAndDatabases
} = require('./oputil_common.js');
const getHelpFor = require('./oputil_help.js').getHelpFor;
const Errors = require('../../core/enig_error.js').Errors;
// deps
const async = require('async');
const inq = require('inquirer');
const mkdirsSync = require('fs-extra').mkdirsSync;
const fs = require('graceful-fs');
const hjson = require('hjson');
const paths = require('path');
const _ = require('lodash');
const sanatizeFilename = require('sanitize-filename');
const packageJson = require('../../package.json');
exports.handleConfigCommand = handleConfigCommand;
function getAnswers(questions, cb) {
inq.prompt(questions).then( answers => {
return cb(answers);
});
}
const ConfigIncludeKeys = [
'theme',
'users.preAuthIdleLogoutSeconds', 'users.idleLogoutSeconds',
'users.newUserNames',
'paths.logs',
'loginServers',
'contentServers',
'fileBase.areaStoragePrefix',
'logging.rotatingFile',
];
const HJSONStringifyComonOpts = {
emitRootBraces : true,
bracesSameLine : true,
space : 4,
keepWsc : true,
quotes : 'min',
eol : '\n',
};
const QUESTIONS = {
Intro : [
{
name : 'createNewConfig',
message : 'Create a new configuration?',
type : 'confirm',
default : false,
},
{
name : 'configPath',
message : 'Configuration path:',
default : getConfigPath(),
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(),
},
],
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 => {
return callback(answers.overwriteConfig ? null : 'exit');
});
} else {
return callback(null);
}
},
function basic(callback) {
getAnswers(QUESTIONS.Basic, answers => {
const defaultConfig = require('../../core/config.js').getDefaultConfig();
// start by plopping in values we want directly from config.js
const template = hjson.rt.parse(fs.readFileSync(paths.join(__dirname, '../../misc/config_template.in.hjson'), 'utf8'));
const direct = {};
_.each(ConfigIncludeKeys, keyPath => {
_.set(direct, keyPath, _.get(defaultConfig, keyPath));
});
config = _.mergeWith(template, direct);
// we can override/add to it based on user input from this point on...
config.general.boardName = answers.boardName;
return callback(null);
});
},
function msgConfAndArea(callback) {
getAnswers(QUESTIONS.MessageConfAndArea, answers => {
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[confName].areas = {};
config.messageConferences[confName].areas[areaName] = {
name : answers.msgAreaName,
desc : answers.msgAreaDesc,
sort : 1,
default : true,
};
return callback(null);
});
},
function misc(callback) {
getAnswers(QUESTIONS.Misc, answers => {
config.logging.rotatingFile.level = answers.loggingLevel;
return callback(null);
});
}
],
err => {
return cb(err, configPath, config);
}
);
}
function writeConfig(config, path) {
config = hjson.stringify(config, HJSONStringifyComonOpts)
.replace(/%ENIG_VERSION%/g, packageJson.version)
.replace(/%HJSON_VERSION%/g, hjson.version)
;
try {
fs.writeFileSync(path, config, 'utf8');
return true;
} catch(e) {
return false;
}
}
const copyFileSyncSilent = (to, from, flags) => {
try {
fs.copyFileSync(to, from, flags);
} catch(e) {}
};
function buildNewConfig() {
askNewConfigQuestions( (err, configPath, config) => {
if(err) {
return;
}
const bn = sanatizeFilename(config.general.boardName).replace(/ /g, '_').toLowerCase();
const menuFile = `${bn}.hjson`;
copyFileSyncSilent(
paths.join(__dirname, '../../config/menu.hjson'),
paths.join(__dirname, '../../config/', menuFile),
fs.constants.COPYFILE_EXCL
);
const promptFile = `${bn}_prompt.hjson`;
copyFileSyncSilent(
paths.join(__dirname, '../../config/prompt.hjson'),
paths.join(__dirname, '../../config/', promptFile),
fs.constants.COPYFILE_EXCL
)
config.general.menuFile = menuFile;
config.general.promptFile = promptFile;
if(writeConfig(config, configPath)) {
console.info('Configuration generated');
} else {
console.error('Failed writing configuration');
}
});
}
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();
}
const ext = paths.extname(path).toLowerCase().substr(1);
return ext; // .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`));
}
let existingNetworkNames = [];
if(_.has(sysConfig, 'messageNetworks.ftn.networks')) {
existingNetworkNames = Object.keys(sysConfig.messageNetworks.ftn.networks);
}
if(0 === existingNetworkNames.length) {
return callback(Errors.DoesNotExist('No FTN style networks exist in your configuration'));
}
if(networkName && !existingNetworkNames.find(net => networkName === net)) {
return callback(Errors.DoesNotExist(`FTN style Network "${networkName}" does not exist`));
}
getAnswers([
{
name : 'confTag',
message : 'Message conference:',
type : 'list',
choices : msgConfs,
pageSize : 10,
when : !confTag,
},
{
name : 'networkName',
message : 'Network name:',
type : 'list',
choices : existingNetworkNames,
when : !networkName,
},
{
name : 'uplinks',
message : 'Uplink(s) (comma separated):',
type : 'input',
validate : (input) => {
const inputUplinks = input.split(/[\s,]+/);
return validateUplinks(inputUplinks) ? true : 'Invalid uplink(s)';
},
when : !uplinks && 'bbs' !== importType,
}
],
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 confirmWithUser(callback) {
const sysConfig = require('../../core/config.js').get();
console.info(`Importing the following for "${confTag}" - (${sysConfig.messageConferences[confTag].name} - ${sysConfig.messageConferences[confTag].desc})`);
importEntries.forEach(ie => {
console.info(` ${ie.ftnTag} - ${ie.name}`);
});
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 docs/msg_networks.md 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,
};
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('Configuration generated.');
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],
name : m[2],
});
}
} 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];
importEntries.push({
ftnTag : tag,
name : `Area: ${tag}`,
uplinks : m[2].split(/[\s,]+/),
});
}
}
return importEntries;
}
function catCurrentConfig() {
try {
const config = hjson.rt.parse(fs.readFileSync(getConfigPath(), 'utf8'));
const hjsonOpts = Object.assign({}, HJSONStringifyComonOpts, {
colors : false === argv.colors ? false : true,
keepWsc : false === argv.comments ? false : true,
});
console.log(hjson.stringify(config, hjsonOpts));
} catch(e) {
if('ENOENT' == e.code) {
console.error(`File not found: ${getConfigPath()}`);
} else {
console.error(e);
}
}
}
function handleConfigCommand() {
if(true === argv.help) {
return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR);
}
const action = argv._[1];
switch(action) {
case 'new' : return buildNewConfig();
case 'import-areas' : return importAreas();
case 'cat' : return catCurrentConfig();
default : return printUsageAndSetExitCode(getHelpFor('Config'), ExitCodes.ERROR);
}
}