diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..bc7309be --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "env": { + "es6": true, + "node": true + }, + "extends": "eslint:recommended", + "rules": { + "indent": [ + "error", + "tab" + ], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "always" + ], + "comma-dangle": 0 + } +} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..a34168f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,16 @@ +For :bug: bug reports, please fill out the information below plus any additional relevant information. If this is a feature request, feel free to clear the form. + +**Short problem description** + +**Environment** +- [ ] I am using Node.js v4.x or higher +- [ ] `npm install` reports success +- Actual Node.js version (`node --version`): +- Operating system (`uname -a` on *nix systems): +- Revision (`git rev-parse --short HEAD`): + +**Expected behavior** + +**Actual behavior** + +**Steps to reproduce** diff --git a/README.md b/README.md index 9ed90948..b1d12540 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption * Door support including common dropfile formats and legacy DOS doors (See [Doors](docs/doors.md)) * [Bunyan](https://github.com/trentm/node-bunyan) logging + * FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export ## In the Works -* Lots of code cleanup, ES6+ usage, and **documentation**! -* FTN import & export +* More ES6+ usage, and **documentation**! * File areas -* Full access checking framework (ACS) +* ACS support for more areas * SysOp dashboard (ye ol' WFC) * Missing functionality such as searching, pipe code support in message areas, etc. * String localization @@ -65,7 +65,7 @@ Please see the [Quickstart](docs/index.md#quickstart) ## License Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license: -Copyright (c) 2015, Bryan D. Ashby +Copyright (c) 2015-2016, Bryan D. Ashby All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/core/acs_parser.js b/core/acs_parser.js index 874033db..454e1ba5 100644 --- a/core/acs_parser.js +++ b/core/acs_parser.js @@ -804,16 +804,13 @@ module.exports = (function() { return !isNaN(value) && user.getAge() >= value; }, AS : function accountStatus() { - - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - return _.findIndex(value, function cmp(accStatus) { - return parseInt(accStatus, 10) === parseInt(user.properties.account_status, 10); - }) > -1; + const userAccountStatus = parseInt(user.properties.account_status, 10); + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(userAccountStatus) > -1; }, EC : function isEncoding() { switch(value) { @@ -842,7 +839,7 @@ module.exports = (function() { // :TODO: implement me!! return false; }, - SC : function isSecerConnection() { + SC : function isSecureConnection() { return client.session.isSecure; }, ML : function minutesLeft() { @@ -870,16 +867,20 @@ module.exports = (function() { return !isNaN(value) && client.term.termWidth >= value; }, ID : function isUserId(value) { - return user.userId === value; + if(!_.isArray(value)) { + value = [ value ]; + } + + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(user.userId) > -1; }, WD : function isOneOfDayOfWeek() { - // :TODO: return true if DoW - if(_.isNumber(value)) { - - } else if(_.isArray(value)) { - + if(!_.isArray(value)) { + value = [ value ]; } - return false; + + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(new Date().getDay()) > -1; }, MM : function isMinutesPastMidnight() { // :TODO: return true if value is >= minutes past midnight sys time diff --git a/core/acs_util.js b/core/acs_util.js index fe111fe1..0f91927d 100644 --- a/core/acs_util.js +++ b/core/acs_util.js @@ -7,8 +7,13 @@ var acsParser = require('./acs_parser.js'); var _ = require('lodash'); var assert = require('assert'); +exports.checkAcs = checkAcs; exports.getConditionalValue = getConditionalValue; +function checkAcs(client, acsString) { + return acsParser.parse(acsString, { client : client } ); +} + function getConditionalValue(client, condArray, memberName) { assert(_.isObject(client)); assert(_.isArray(condArray)); diff --git a/core/archive_util.js b/core/archive_util.js new file mode 100644 index 00000000..914e5e47 --- /dev/null +++ b/core/archive_util.js @@ -0,0 +1,151 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +let Config = require('./config.js').config; + +// base/modules +let fs = require('fs'); +let _ = require('lodash'); +let pty = require('ptyw.js'); + +module.exports = class ArchiveUtil { + + constructor() { + this.archivers = {}; + this.longestSignature = 0; + } + + init() { + // + // Load configuration + // + if(_.has(Config, 'archivers')) { + Object.keys(Config.archivers).forEach(archKey => { + const arch = Config.archivers[archKey]; + if(!_.isString(arch.sig) || + !_.isString(arch.compressCmd) || + !_.isString(arch.decompressCmd) || + !_.isArray(arch.compressArgs) || + !_.isArray(arch.decompressArgs)) + { + // :TODO: log warning + return; + } + + const archiver = { + compressCmd : arch.compressCmd, + compressArgs : arch.compressArgs, + decompressCmd : arch.decompressCmd, + decompressArgs : arch.decompressArgs, + sig : new Buffer(arch.sig, 'hex'), + offset : arch.offset || 0, + }; + + this.archivers[archKey] = archiver; + + if(archiver.offset + archiver.sig.length > this.longestSignature) { + this.longestSignature = archiver.offset + archiver.sig.length; + } + }); + } + } + + getArchiver(archType) { + if(!archType) { + return; + } + + archType = archType.toLowerCase(); + return this.archivers[archType]; + } + + haveArchiver(archType) { + return this.getArchiver(archType) ? true : false; + } + + detectType(path, cb) { + fs.open(path, 'r', (err, fd) => { + if(err) { + cb(err); + return; + } + + let buf = new Buffer(this.longestSignature); + fs.read(fd, buf, 0, buf.length, 0, (err, bytesRead) => { + if(err) { + cb(err); + return; + } + + // return first match + const detected = _.findKey(this.archivers, arch => { + const lenNeeded = arch.offset + arch.sig.length; + + if(buf.length < lenNeeded) { + return false; + } + + const comp = buf.slice(arch.offset, arch.offset + arch.sig.length); + return (arch.sig.equals(comp)); + }); + + cb(detected ? null : new Error('Unknown type'), detected); + }); + }); + } + + compressTo(archType, archivePath, files, cb) { + const archiver = this.getArchiver(archType); + + if(!archiver) { + return cb(new Error(`Unknown archive type: ${archType}`)); + } + + let args = _.clone(archiver.compressArgs); // don't muck with orig + for(let i = 0; i < args.length; ++i) { + args[i] = args[i].format({ + archivePath : archivePath, + fileList : files.join(' '), + }); + } + + let comp = pty.spawn(archiver.compressCmd, args, this.getPtyOpts()); + + comp.once('exit', exitCode => { + cb(exitCode ? new Error(`Compression failed with exit code: ${exitCode}`) : null); + }); + } + + extractTo(archivePath, extractPath, archType, cb) { + const archiver = this.getArchiver(archType); + + if(!archiver) { + return cb(new Error(`Unknown archive type: ${archType}`)); + } + + let args = _.clone(archiver.decompressArgs); // don't muck with orig + for(let i = 0; i < args.length; ++i) { + args[i] = args[i].format({ + archivePath : archivePath, + extractPath : extractPath, + }); + } + + let comp = pty.spawn(archiver.decompressCmd, args, this.getPtyOpts()); + + comp.once('exit', exitCode => { + cb(exitCode ? new Error(`Decompression failed with exit code: ${exitCode}`) : null); + }); + } + + getPtyOpts() { + return { + // :TODO: cwd + name : 'enigma-archiver', + cols : 80, + rows : 24, + env : process.env, + }; + } +} diff --git a/core/art.js b/core/art.js index 9cf0dd9b..5efdbdee 100644 --- a/core/art.js +++ b/core/art.js @@ -12,6 +12,7 @@ var events = require('events'); var util = require('util'); var ansi = require('./ansi_term.js'); var aep = require('./ansi_escape_parser.js'); +var sauce = require('./sauce.js'); var _ = require('lodash'); @@ -20,10 +21,6 @@ exports.getArtFromPath = getArtFromPath; exports.display = display; exports.defaultEncodingFromExtension = defaultEncodingFromExtension; -var SAUCE_SIZE = 128; -var SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE' -var COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT' - // :TODO: Return MCI code information // :TODO: process SAUCE comments // :TODO: return font + font mapped information from SAUCE @@ -43,156 +40,6 @@ var SUPPORTED_ART_TYPES = { // :TODO: extension for topaz ansi/ascii. }; -// -// See -// http://www.acid.org/info/sauce/sauce.htm -// -// :TODO: Move all SAUCE stuff to sauce.js -function readSAUCE(data, cb) { - if(data.length < SAUCE_SIZE) { - cb(new Error('No SAUCE record present')); - return; - } - - var offset = data.length - SAUCE_SIZE; - var sauceRec = data.slice(offset); - - binary.parse(sauceRec) - .buffer('id', 5) - .buffer('version', 2) - .buffer('title', 35) - .buffer('author', 20) - .buffer('group', 20) - .buffer('date', 8) - .word32lu('fileSize') - .word8('dataType') - .word8('fileType') - .word16lu('tinfo1') - .word16lu('tinfo2') - .word16lu('tinfo3') - .word16lu('tinfo4') - .word8('numComments') - .word8('flags') - .buffer('tinfos', 22) // SAUCE 00.5 - .tap(function onVars(vars) { - - if(!SAUCE_ID.equals(vars.id)) { - cb(new Error('No SAUCE record present')); - return; - } - - var ver = iconv.decode(vars.version, 'cp437'); - - if('00' !== ver) { - cb(new Error('Unsupported SAUCE version: ' + ver)); - return; - } - - var sauce = { - id : iconv.decode(vars.id, 'cp437'), - version : iconv.decode(vars.version, 'cp437').trim(), - title : iconv.decode(vars.title, 'cp437').trim(), - author : iconv.decode(vars.author, 'cp437').trim(), - group : iconv.decode(vars.group, 'cp437').trim(), - date : iconv.decode(vars.date, 'cp437').trim(), - fileSize : vars.fileSize, - dataType : vars.dataType, - fileType : vars.fileType, - tinfo1 : vars.tinfo1, - tinfo2 : vars.tinfo2, - tinfo3 : vars.tinfo3, - tinfo4 : vars.tinfo4, - numComments : vars.numComments, - flags : vars.flags, - tinfos : vars.tinfos, - }; - - var dt = SAUCE_DATA_TYPES[sauce.dataType]; - if(dt && dt.parser) { - sauce[dt.name] = dt.parser(sauce); - } - - cb(null, sauce); - }); -} - -// :TODO: These need completed: -var SAUCE_DATA_TYPES = {}; -SAUCE_DATA_TYPES[0] = { name : 'None' }; -SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE }; -SAUCE_DATA_TYPES[2] = 'Bitmap'; -SAUCE_DATA_TYPES[3] = 'Vector'; -SAUCE_DATA_TYPES[4] = 'Audio'; -SAUCE_DATA_TYPES[5] = 'BinaryText'; -SAUCE_DATA_TYPES[6] = 'XBin'; -SAUCE_DATA_TYPES[7] = 'Archive'; -SAUCE_DATA_TYPES[8] = 'Executable'; - -var SAUCE_CHARACTER_FILE_TYPES = {}; -SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII'; -SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi'; -SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation'; -SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script'; -SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard'; -SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar'; -SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML'; -SAUCE_CHARACTER_FILE_TYPES[7] = 'Source'; -SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw'; - -// -// Map of SAUCE font -> encoding hint -// -// Note that this is the same mapping that x84 uses. Be compatible! -// -var SAUCE_FONT_TO_ENCODING_HINT = { - 'Amiga MicroKnight' : 'amiga', - 'Amiga MicroKnight+' : 'amiga', - 'Amiga mOsOul' : 'amiga', - 'Amiga P0T-NOoDLE' : 'amiga', - 'Amiga Topaz 1' : 'amiga', - 'Amiga Topaz 1+' : 'amiga', - 'Amiga Topaz 2' : 'amiga', - 'Amiga Topaz 2+' : 'amiga', - 'Atari ATASCII' : 'atari', - 'IBM EGA43' : 'cp437', - 'IBM EGA' : 'cp437', - 'IBM VGA25G' : 'cp437', - 'IBM VGA50' : 'cp437', - 'IBM VGA' : 'cp437', -}; - -['437', '720', '737', '775', '819', '850', '852', '855', '857', '858', -'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) { - var codec = 'cp' + page; - SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec; - SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec; -}); - -function parseCharacterSAUCE(sauce) { - var result = {}; - - result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; - - if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) { - // convience: create ansiFlags - sauce.ansiFlags = sauce.flags; - - var i = 0; - while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { - ++i; - } - var fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); - if(fontName.length > 0) { - result.fontName = fontName; - } - } - - return result; -} - function getFontNameFromSAUCE(sauce) { if(sauce.Character) { return sauce.Character.fontName; @@ -249,7 +96,7 @@ function getArtFromPath(path, options, cb) { } if(options.readSauce === true) { - readSAUCE(data, function onSauce(err, sauce) { + sauce.readSAUCE(data, function onSauce(err, sauce) { if(err) { cb(null, getResult()); } else { diff --git a/core/asset.js b/core/asset.js index 7f25a683..566add7c 100644 --- a/core/asset.js +++ b/core/asset.js @@ -2,7 +2,6 @@ 'use strict'; var Config = require('./config.js').config; -var theme = require('./theme.js'); var _ = require('lodash'); var assert = require('assert'); diff --git a/core/bbs.js b/core/bbs.js index 92b0e094..56b24efc 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -1,44 +1,47 @@ /* jslint node: true */ 'use strict'; +//var SegfaultHandler = require('segfault-handler'); +//SegfaultHandler.registerHandler('enigma-bbs-segfault.log'); + // ENiGMA½ -var conf = require('./config.js'); -var logger = require('./logger.js'); -var miscUtil = require('./misc_util.js'); -var database = require('./database.js'); -var clientConns = require('./client_connections.js'); +let conf = require('./config.js'); +let logger = require('./logger.js'); +let miscUtil = require('./misc_util.js'); +let database = require('./database.js'); +let clientConns = require('./client_connections.js'); -var paths = require('path'); -var async = require('async'); -var util = require('util'); -var _ = require('lodash'); -var assert = require('assert'); -var mkdirp = require('mkdirp'); +let paths = require('path'); +let async = require('async'); +let util = require('util'); +let _ = require('lodash'); +let assert = require('assert'); +let mkdirp = require('mkdirp'); -exports.bbsMain = bbsMain; +// our main entry point +exports.bbsMain = bbsMain; function bbsMain() { async.waterfall( [ function processArgs(callback) { - var args = parseArgs(); + const args = process.argv.slice(2); var configPath; if(args.indexOf('--help') > 0) { // :TODO: display help } else { - var argCount = args.length; - for(var i = 0; i < argCount; ++i) { - var arg = args[i]; - if('--config' == arg) { + let argCount = args.length; + for(let i = 0; i < argCount; ++i) { + const arg = args[i]; + if('--config' === arg) { configPath = args[i + 1]; } } } - var configPathSupplied = _.isString(configPath); - callback(null, configPath || conf.getDefaultPath(), configPathSupplied); + callback(null, configPath || conf.getDefaultPath(), _.isString(configPath)); }, function initConfig(configPath, configPathSupplied, callback) { conf.init(configPath, function configInit(err) { @@ -68,25 +71,19 @@ function bbsMain() { } callback(err); }); + }, + function listenConnections(callback) { + startListening(callback); } ], function complete(err) { - if(!err) { - startListening(); + if(err) { + logger.log.error(err); } } ); } -function parseArgs() { - var args = []; - process.argv.slice(2).forEach(function(val, index, array) { - args.push(val); - }); - - return args; -} - function initialize(cb) { async.series( [ @@ -117,7 +114,7 @@ function initialize(cb) { process.exit(); }); - + // Init some extensions require('string-format').extend(String.prototype, require('./string_util.js').stringFormatExtensions); @@ -169,6 +166,9 @@ function initialize(cb) { }); } }); + }, + function readyMessageNetworkSupport(callback) { + require('./msg_network.js').startup(callback); } ], function onComplete(err) { @@ -177,29 +177,36 @@ function initialize(cb) { ); } -function startListening() { +function startListening(cb) { if(!conf.config.servers) { // :TODO: Log error ... output to stderr as well. We can do it all with the logger - logger.log.error('No servers configured'); - return []; + //logger.log.error('No servers configured'); + cb(new Error('No servers configured')); + return; } - var moduleUtil = require('./module_util.js'); // late load so we get Config + let moduleUtil = require('./module_util.js'); // late load so we get Config - moduleUtil.loadModulesForCategory('servers', function onServerModule(err, module) { + moduleUtil.loadModulesForCategory('servers', (err, module) => { if(err) { logger.log.info(err); return; } - var port = parseInt(module.runtime.config.port); + const port = parseInt(module.runtime.config.port); if(isNaN(port)) { logger.log.error( { port : module.runtime.config.port, server : module.moduleInfo.name }, 'Cannot load server (Invalid port)'); return; } - var moduleInst = new module.getModule(); - var server = moduleInst.createServer(); + const moduleInst = new module.getModule(); + let server; + try { + server = moduleInst.createServer(); + } catch(e) { + logger.log.warn(e, 'Exception caught creating server!'); + return; + } // :TODO: handle maxConnections, e.g. conf.maxConnections @@ -260,7 +267,11 @@ function startListening() { }); server.listen(port); - logger.log.info({ server : module.moduleInfo.name, port : port }, 'Listening for connections'); + + logger.log.info( + { server : module.moduleInfo.name, port : port }, 'Listening for connections'); + }, err => { + cb(err); }); } diff --git a/core/config.js b/core/config.js index 6bc9ac61..2fccab82 100644 --- a/core/config.js +++ b/core/config.js @@ -8,10 +8,37 @@ var paths = require('path'); var async = require('async'); var _ = require('lodash'); var hjson = require('hjson'); +var assert = require('assert'); exports.init = init; exports.getDefaultPath = getDefaultPath; +function hasMessageConferenceAndArea(config) { + assert(_.isObject(config.messageConferences)); // we create one ourself! + + const nonInternalConfs = Object.keys(config.messageConferences).filter(confTag => { + return 'system_internal' !== confTag; + }); + + if(0 === nonInternalConfs.length) { + return false; + } + + // :TODO: there is likely a better/cleaner way of doing this + + var result = false; + _.forEach(nonInternalConfs, confTag => { + if(_.has(config.messageConferences[confTag], 'areas') && + Object.keys(config.messageConferences[confTag].areas) > 0) + { + result = true; + return false; // stop iteration + } + }); + + return result; +} + function init(configPath, cb) { async.waterfall( [ @@ -48,18 +75,13 @@ function init(configPath, cb) { // // Various sections must now exist in config // - if(!_.has(mergedConfig, 'messages.areas.') || - !_.isArray(mergedConfig.messages.areas) || - 0 === mergedConfig.messages.areas.length || - !_.isString(mergedConfig.messages.areas[0].name)) - { - var msgAreasErr = new Error('Please create at least one message area'); + if(hasMessageConferenceAndArea(mergedConfig)) { + var msgAreasErr = new Error('Please create at least one message conference and area!'); msgAreasErr.code = 'EBADCONFIG'; callback(msgAreasErr); - return; - } - - callback(null, mergedConfig); + } else { + callback(null, mergedConfig); + } } ], function complete(err, mergedConfig) { @@ -150,6 +172,10 @@ function getDefaultConfig() { paths : { mods : paths.join(__dirname, './../mods/'), servers : paths.join(__dirname, './servers/'), + + scannerTossers : paths.join(__dirname, './scanner_tossers/'), + mailers : paths.join(__dirname, './mailers/') , + art : paths.join(__dirname, './../mods/art/'), themes : paths.join(__dirname, './../mods/themes/'), logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such @@ -166,7 +192,7 @@ function getDefaultConfig() { }, ssh : { port : 8889, - enabled : true, + enabled : false, // defualt to false as PK/pass in config.hjson are required // // Private key in PEM format @@ -183,24 +209,52 @@ function getDefaultConfig() { } }, - messages : { - areas : [ - { name : 'private_mail', desc : 'Private Email', groups : [ 'users' ] } - ] + archivers : { + zip : { + sig : "504b0304", + offset : 0, + compressCmd : "7z", + compressArgs : [ "a", "-tzip", "{archivePath}", "{fileList}" ], + decompressCmd : "7z", + decompressArgs : [ "e", "-o{extractPath}", "{archivePath}" ] + } }, - networks : { - /* - networkName : { // e.g. fidoNet - address : { - zone : 0, - net : 0, - node : 0, - point : 0, - domain : 'l33t.codes' + messageConferences : { + system_internal : { + name : 'System Internal', + desc : 'Built in conference for private messages, bulletins, etc.', + + areas : { + private_mail : { + name : 'Private Mail', + desc : 'Private user to user mail/email', + }, + + local_bulletin : { + name : 'System Bulletins', + desc : 'Bulletin messages for all users', + } } } - */ + }, + + scannerTossers : { + ftn_bso : { + paths : { + outbound : paths.join(__dirname, './../mail/ftn_out/'), + inbound : paths.join(__dirname, './../mail/ftn_in/'), + secInbound : paths.join(__dirname, './../mail/ftn_secin/'), + }, + + // + // Packet and (ArcMail) bundle target sizes are just that: targets. + // Actual sizes may be slightly larger when we must place a full + // PKT contents *somewhere* + // + packetTargetByteSize : 512000, // 512k, before placing messages in a new pkt + bundleTargetByteSize : 2048000, // 2M, before creating another archive + } }, misc : { diff --git a/core/database.js b/core/database.js index 1b8f9afa..3baf682a 100644 --- a/core/database.js +++ b/core/database.js @@ -132,7 +132,7 @@ function createMessageBaseTables() { dbs.message.run( 'CREATE TABLE IF NOT EXISTS message (' + ' message_id INTEGER PRIMARY KEY,' + - ' area_name VARCHAR NOT NULL,' + + ' area_tag VARCHAR NOT NULL,' + ' message_uuid VARCHAR(36) NOT NULL,' + ' reply_to_message_id INTEGER,' + ' to_user_name VARCHAR NOT NULL,' + @@ -175,7 +175,7 @@ function createMessageBaseTables() { ' meta_category INTEGER NOT NULL,' + ' meta_name VARCHAR NOT NULL,' + ' meta_value VARCHAR NOT NULL,' + - ' UNIQUE(message_id, meta_category, meta_name, meta_value),' + + ' UNIQUE(message_id, meta_category, meta_name, meta_value),' + // why unique here? ' FOREIGN KEY(message_id) REFERENCES message(message_id)' + ');' ); @@ -198,20 +198,19 @@ function createMessageBaseTables() { dbs.message.run( 'CREATE TABLE IF NOT EXISTS user_message_area_last_read (' + ' user_id INTEGER NOT NULL,' + - ' area_name VARCHAR NOT NULL,' + + ' area_tag VARCHAR NOT NULL,' + ' message_id INTEGER NOT NULL,' + - ' UNIQUE(user_id, area_name)' + + ' UNIQUE(user_id, area_tag)' + ');' ); - + dbs.message.run( - 'CREATE TABLE IF NOT EXISTS user_message_status (' + - ' user_id INTEGER NOT NULL,' + - ' message_id INTEGER NOT NULL,' + - ' status INTEGER NOT NULL,' + - ' UNIQUE(user_id, message_id, status),' + - ' FOREIGN KEY(user_id) REFERENCES user(id)' + - ');' + `CREATE TABLE IF NOT EXISTS message_area_last_scan ( + scan_toss VARCHAR NOT NULL, + area_tag VARCHAR NOT NULL, + message_id INTEGER NOT NULL, + UNIQUE(scan_toss, area_tag) + );` ); } diff --git a/core/fnv1a.js b/core/fnv1a.js new file mode 100644 index 00000000..f7714936 --- /dev/null +++ b/core/fnv1a.js @@ -0,0 +1,50 @@ +/* jslint node: true */ +'use strict'; + +let _ = require('lodash'); + +// FNV-1a based on work here: https://github.com/wiedi/node-fnv +module.exports = class FNV1a { + constructor(data) { + this.hash = 0x811c9dc5; + + if(!_.isUndefined(data)) { + this.update(data); + } + } + + update(data) { + if(_.isNumber(data)) { + data = data.toString(); + } + + if(_.isString(data)) { + data = new Buffer(data); + } + + if(!Buffer.isBuffer(data)) { + throw new Error('data must be String or Buffer!'); + } + + for(let b of data) { + this.hash = this.hash ^ b; + this.hash += + (this.hash << 24) + (this.hash << 8) + (this.hash << 7) + + (this.hash << 4) + (this.hash << 1); + } + + return this; + } + + digest(encoding) { + encoding = encoding || 'binary'; + let buf = new Buffer(4); + buf.writeInt32BE(this.hash & 0xffffffff, 0); + return buf.toString(encoding); + } + + get value() { + return this.hash & 0xffffffff; + } +} + diff --git a/core/fse.js b/core/fse.js index a8cc6843..f6aa7e7c 100644 --- a/core/fse.js +++ b/core/fse.js @@ -7,7 +7,7 @@ var ansi = require('../core/ansi_term.js'); var theme = require('../core/theme.js'); var MultiLineEditTextView = require('../core/multi_line_edit_text_view.js').MultiLineEditTextView; var Message = require('../core/message.js'); -var getMessageAreaByName = require('../core/message_area.js').getMessageAreaByName; +var getMessageAreaByTag = require('../core/message_area.js').getMessageAreaByTag; var updateMessageAreaLastReadId = require('../core/message_area.js').updateMessageAreaLastReadId; var getUserIdAndName = require('../core/user.js').getUserIdAndName; @@ -75,6 +75,8 @@ var MCICodeIds = { HashTags : 9, MessageID : 10, ReplyToMsgID : 11, + + // :TODO: ConfName }, @@ -104,15 +106,15 @@ function FullScreenEditorModule(options) { // editorMode : view | edit | quote // // menuConfig.config or extraArgs - // messageAreaName + // messageAreaTag // messageIndex / messageTotal // toUserId // this.editorType = config.editorType; this.editorMode = config.editorMode; - if(config.messageAreaName) { - this.messageAreaName = config.messageAreaName; + if(config.messageAreaTag) { + this.messageAreaTag = config.messageAreaTag; } this.messageIndex = config.messageIndex || 0; @@ -121,8 +123,8 @@ function FullScreenEditorModule(options) { // extraArgs can override some config if(_.isObject(options.extraArgs)) { - if(options.extraArgs.messageAreaName) { - this.messageAreaName = options.extraArgs.messageAreaName; + if(options.extraArgs.messageAreaTag) { + this.messageAreaTag = options.extraArgs.messageAreaTag; } if(options.extraArgs.messageIndex) { this.messageIndex = options.extraArgs.messageIndex; @@ -134,9 +136,6 @@ function FullScreenEditorModule(options) { this.toUserId = options.extraArgs.toUserId; } } - - console.log(this.toUserId) - console.log(this.messageAreaName) this.isReady = false; @@ -149,7 +148,7 @@ function FullScreenEditorModule(options) { }; this.isLocalEmail = function() { - return Message.WellKnownAreaNames.Private === self.messageAreaName; + return Message.WellKnownAreaTags.Private === self.messageAreaTag; }; this.isReply = function() { @@ -217,7 +216,7 @@ function FullScreenEditorModule(options) { var headerValues = self.viewControllers.header.getFormData().value; var msgOpts = { - areaName : self.messageAreaName, + areaTag : self.messageAreaTag, toUserName : headerValues.to, fromUserName : headerValues.from, subject : headerValues.subject, @@ -235,7 +234,7 @@ function FullScreenEditorModule(options) { self.message = message; updateMessageAreaLastReadId( - self.client.user.userId, self.messageAreaName, self.message.messageId, + self.client.user.userId, self.messageAreaTag, self.message.messageId, function lastReadUpdated() { if(self.isReady) { @@ -308,7 +307,7 @@ function FullScreenEditorModule(options) { // :TODO: We'd like to delete up to N rows, but this does not work // in NetRunner: - //self.client.term.rawWrite(ansi.reset() + ansi.deleteLine(3)); + self.client.term.rawWrite(ansi.reset() + ansi.deleteLine(3)); self.client.term.rawWrite(ansi.reset() + ansi.eraseLine(2)) } @@ -631,7 +630,7 @@ function FullScreenEditorModule(options) { }; this.initHeaderGeneric = function() { - self.setHeaderText(MCICodeIds.ViewModeHeader.AreaName, getMessageAreaByName(self.messageAreaName).desc); + self.setHeaderText(MCICodeIds.ViewModeHeader.AreaName, getMessageAreaByTag(self.messageAreaTag).name); }; this.initHeaderViewMode = function() { @@ -965,13 +964,10 @@ function FullScreenEditorModule(options) { require('util').inherits(FullScreenEditorModule, MenuModule); -FullScreenEditorModule.prototype.enter = function(client) { - FullScreenEditorModule.super_.prototype.enter.call(this, client); - - +FullScreenEditorModule.prototype.enter = function() { + FullScreenEditorModule.super_.prototype.enter.call(this); }; FullScreenEditorModule.prototype.mciReady = function(mciData, cb) { this.mciReadyHandler(mciData, cb); - //this['mciReadyHandler' + _.capitalize(this.editorType)](mciData); }; diff --git a/core/ftn_address.js b/core/ftn_address.js new file mode 100644 index 00000000..3e849b55 --- /dev/null +++ b/core/ftn_address.js @@ -0,0 +1,198 @@ +/* jslint node: true */ +'use strict'; + +let _ = require('lodash'); + +const FTN_ADDRESS_REGEXP = /^([0-9]+:)?([0-9]+)(\/[0-9]+)?(\.[0-9]+)?(@[a-z0-9\-\.]+)?$/i; +const FTN_PATTERN_REGEXP = /^([0-9\*]+:)?([0-9\*]+)(\/[0-9\*]+)?(\.[0-9\*]+)?(@[a-z0-9\-\.\*]+)?$/i; + +module.exports = class Address { + constructor(addr) { + if(addr) { + if(_.isObject(addr)) { + Object.assign(this, addr); + } else if(_.isString(addr)) { + const temp = Address.fromString(addr); + if(temp) { + Object.assign(this, temp); + } + } + } + } + + isEqual(other) { + if(_.isString(other)) { + other = Address.fromString(other); + } + + return ( + this.net === other.net && + this.node === other.node && + this.zone === other.zone && + this.point === other.point && + this.domain === other.domain + ); + } + + getMatchAddr(pattern) { + const m = FTN_PATTERN_REGEXP.exec(pattern); + if(m) { + let addr = { }; + + if(m[1]) { + addr.zone = m[1].slice(0, -1) + if('*' !== addr.zone) { + addr.zone = parseInt(addr.zone); + } + } else { + addr.zone = '*'; + } + + if(m[2]) { + addr.net = m[2]; + if('*' !== addr.net) { + addr.net = parseInt(addr.net); + } + } else { + addr.net = '*'; + } + + if(m[3]) { + addr.node = m[3].substr(1); + if('*' !== addr.node) { + addr.node = parseInt(addr.node); + } + } else { + addr.node = '*'; + } + + if(m[4]) { + addr.point = m[4].substr(1); + if('*' !== addr.point) { + addr.point = parseInt(addr.point); + } + } else { + addr.point = '*'; + } + + if(m[5]) { + addr.domain = m[5].substr(1); + } else { + addr.domain = '*'; + } + + return addr; + } + } + + /* + getMatchScore(pattern) { + let score = 0; + const addr = this.getMatchAddr(pattern); + if(addr) { + const PARTS = [ 'net', 'node', 'zone', 'point', 'domain' ]; + for(let i = 0; i < PARTS.length; ++i) { + const member = PARTS[i]; + if(this[member] === addr[member]) { + score += 2; + } else if('*' === addr[member]) { + score += 1; + } else { + break; + } + } + } + + return score; + } + */ + + isPatternMatch(pattern) { + const addr = this.getMatchAddr(pattern); + if(addr) { + return ( + ('*' === addr.net || this.net === addr.net) && + ('*' === addr.node || this.node === addr.node) && + ('*' === addr.zone || this.zone === addr.zone) && + ('*' === addr.point || this.point === addr.point) && + ('*' === addr.domain || this.domain === addr.domain) + ); + } + + return false; + } + + static fromString(addrStr) { + const m = FTN_ADDRESS_REGEXP.exec(addrStr); + + if(m) { + // start with a 2D + let addr = { + net : parseInt(m[2]), + node : parseInt(m[3].substr(1)), + }; + + // 3D: Addition of zone if present + if(m[1]) { + addr.zone = parseInt(m[1].slice(0, -1)); + } + + // 4D if optional point is present + if(m[4]) { + addr.point = parseInt(m[4].substr(1)); + } + + // 5D with @domain + if(m[5]) { + addr.domain = m[5].substr(1); + } + + return new Address(addr); + } + } + + toString(dimensions) { + dimensions = dimensions || '5D'; + + let addrStr = `${this.zone}:${this.net}`; + + // allow for e.g. '4D' or 5 + const dim = parseInt(dimensions.toString()[0]); + + if(dim >= 3) { + addrStr += `/${this.node}`; + } + + // missing & .0 are equiv for point + if(dim >= 4 && this.point) { + addrStr += `.${this.point}`; + } + + if(5 === dim && this.domain) { + addrStr += `@${this.domain.toLowerCase()}`; + } + + return addrStr; + } + + static getComparator() { + return function(left, right) { + let c = (left.zone || 0) - (right.zone || 0); + if(0 !== c) { + return c; + } + + c = (left.net || 0) - (right.net || 0); + if(0 !== c) { + return c; + } + + c = (left.node || 0) - (right.node || 0); + if(0 !== c) { + return c; + } + + return (left.domain || '').localeCompare(right.domain || ''); + } + } +} diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index a63ea79f..25d2d6be 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -1,71 +1,190 @@ /* jslint node: true */ 'use strict'; -var MailPacket = require('./mail_packet.js'); -var ftn = require('./ftn_util.js'); -var Message = require('./message.js'); +let ftn = require('./ftn_util.js'); +let Message = require('./message.js'); +let sauce = require('./sauce.js'); +let Address = require('./ftn_address.js'); +let strUtil = require('./string_util.js'); -var _ = require('lodash'); -var assert = require('assert'); -var binary = require('binary'); -var fs = require('fs'); -var util = require('util'); -var async = require('async'); -var iconv = require('iconv-lite'); +let _ = require('lodash'); +let assert = require('assert'); +let binary = require('binary'); +let fs = require('fs'); +let async = require('async'); +let iconv = require('iconv-lite'); +let moment = require('moment'); + +exports.Packet = Packet; /* - :TODO: should probably be broken up - FTNPacket - FTNPacketImport: packet -> message(s) - FTNPacketExport: message(s) -> packet + :TODO: things + * Test SAUCE ignore/extraction + * FSP-1010 for netmail (see SBBS) + * Syncronet apparently uses odd origin lines + * Origin lines starting with "#" instead of "*" ? + */ -// -// References -// * http://ftsc.org/docs/fts-0001.016 -// * http://ftsc.org/docs/fsc-0048.002 -// -// Other implementations: -// * https://github.com/M-griffin/PyPacketMail/blob/master/PyPacketMail.py -// -function FTNMailPacket(options) { +const FTN_PACKET_HEADER_SIZE = 58; // fixed header size +const FTN_PACKET_HEADER_TYPE = 2; +const FTN_PACKET_MESSAGE_TYPE = 2; +const FTN_PACKET_BAUD_TYPE_2_2 = 2; +const NULL_TERM_BUFFER = new Buffer( [ 0x00 ] ); - MailPacket.call(this, options); - - var self = this; - self.KLUDGE_PREFIX = '\x01'; +// SAUCE magic header + version ("00") +const FTN_MESSAGE_SAUCE_HEADER = new Buffer('SAUCE00'); - this.getPacketHeaderAddress = function() { - return { - zone : self.packetHeader.destZone, - net : self.packetHeader.destNet, - node : self.packetHeader.destNode, - point : self.packetHeader.destPoint, +const FTN_MESSAGE_KLUDGE_PREFIX = '\x01'; + +class PacketHeader { + constructor(origAddr, destAddr, version, createdMoment) { + const EMPTY_ADDRESS = { + node : 0, + net : 0, + zone : 0, + point : 0, }; - }; - this.getNetworkNameForAddress = function(addr) { - var nodeAddr; - for(var network in self.nodeAddresses) { - nodeAddr = self.nodeAddresses[network]; - if(nodeAddr.zone === addr.zone && - nodeAddr.net === addr.net && - nodeAddr.node === addr.node && - nodeAddr.point === addr.point) - { - return network; - } + this.packetVersion = version || '2+'; + + this.origAddress = origAddr || EMPTY_ADDRESS; + this.destAddress = destAddr || EMPTY_ADDRESS; + this.created = createdMoment || moment(); + + // uncommon to set the following explicitly + this.prodCodeLo = 0xfe; // http://ftsc.org/docs/fta-1005.003 + this.prodRevLo = 0; + this.baud = 0; + this.packetType = FTN_PACKET_HEADER_TYPE; + this.password = ''; + this.prodData = 0x47694e45; // "ENiG" + + this.capWord = 0x0001; + this.capWordValidate = ((this.capWord & 0xff) << 8) | ((this.capWord >> 8) & 0xff); + + this.prodCodeHi = 0xfe; // see above + this.prodRevHi = 0; + } + + get origAddress() { + let addr = new Address({ + node : this.origNode, + zone : this.origZone, + }); + + if(this.origPoint) { + addr.point = this.origPoint; + addr.net = this.auxNet; + } else { + addr.net = this.origNet; } - }; - this.parseFtnPacketHeader = function(packetBuffer, cb) { + return addr; + } + + set origAddress(address) { + if(_.isString(address)) { + address = Address.fromString(address); + } + + this.origNode = address.node; + + // See FSC-48 + if(address.point) { + this.origNet = -1; + this.auxNet = address.net; + } else { + this.origNet = address.net; + this.auxNet = 0; + } + + this.origZone = address.zone; + this.origZone2 = address.zone; + this.origPoint = address.point || 0; + } + + get destAddress() { + let addr = new Address({ + node : this.destNode, + net : this.destNet, + zone : this.destZone, + }); + + if(this.destPoint) { + addr.point = this.destPoint; + } + + return addr; + } + + set destAddress(address) { + if(_.isString(address)) { + address = Address.fromString(address); + } + + this.destNode = address.node; + this.destNet = address.net; + this.destZone = address.zone; + this.destZone2 = address.zone; + this.destPoint = address.point || 0; + } + + get created() { + return moment({ + year : this.year, + month : this.month - 1, // moment uses 0 indexed months + date : this.day, + hour : this.hour, + minute : this.minute, + second : this.second + }); + } + + set created(momentCreated) { + if(!moment.isMoment(momentCreated)) { + momentCreated = moment(momentCreated); + } + + this.year = momentCreated.year(); + this.month = momentCreated.month() + 1; // moment uses 0 indexed months + this.day = momentCreated.date(); // day of month + this.hour = momentCreated.hour(); + this.minute = momentCreated.minute(); + this.second = momentCreated.second(); + } +} + +exports.PacketHeader = PacketHeader; + +// +// Read/Write FTN packets with support for the following formats: +// +// * Type 2 FTS-0001 @ http://ftsc.org/docs/fts-0001.016 (Obsolete) +// * Type 2.2 FSC-0045 @ http://ftsc.org/docs/fsc-0045.001 +// * Type 2+ FSC-0039 and FSC-0048 @ http://ftsc.org/docs/fsc-0039.004 +// and http://ftsc.org/docs/fsc-0048.002 +// +// Additional resources: +// * Writeup on differences between type 2, 2.2, and 2+: +// http://walon.org/pub/fidonet/FTSC-nodelists-etc./pkt-types.txt +// +function Packet(options) { + var self = this; + + this.options = options || {}; + + this.parsePacketHeader = function(packetBuffer, cb) { assert(Buffer.isBuffer(packetBuffer)); - if(packetBuffer.length < 58) { + if(packetBuffer.length < FTN_PACKET_HEADER_SIZE) { cb(new Error('Buffer too small')); - return; + return; } + // + // Start out reading as if this is a FSC-0048 2+ packet + // binary.parse(packetBuffer) .word16lu('origNode') .word16lu('destNode') @@ -77,371 +196,747 @@ function FTNMailPacket(options) { .word16lu('second') .word16lu('baud') .word16lu('packetType') - .word16lu('originNet') + .word16lu('origNet') .word16lu('destNet') .word8('prodCodeLo') - .word8('revisionMajor') // aka serialNo - .buffer('password', 8) // null terminated C style string + .word8('prodRevLo') // aka serialNo + .buffer('password', 8) // null padded C style string .word16lu('origZone') .word16lu('destZone') - // Additions in FSC-0048.002 follow... + // + // The following is "filler" in FTS-0001, specifics in + // FSC-0045 and FSC-0048 + // .word16lu('auxNet') - .word16lu('capWordA') + .word16lu('capWordValidate') .word8('prodCodeHi') - .word8('revisionMinor') - .word16lu('capWordB') - .word16lu('originZone2') + .word8('prodRevHi') + .word16lu('capWord') + .word16lu('origZone2') .word16lu('destZone2') - .word16lu('originPoint') + .word16lu('origPoint') .word16lu('destPoint') .word32lu('prodData') - .tap(function tapped(packetHeader) { - packetHeader.password = ftn.stringFromFTN(packetHeader.password); + .tap(packetHeader => { + // Convert password from NULL padded array to string + //packetHeader.password = ftn.stringFromFTN(packetHeader.password); + packetHeader.password = strUtil.stringFromNullTermBuffer(packetHeader.password, 'CP437'); - // :TODO: Don't hard code magic # here - if(2 !== packetHeader.packetType) { - cb(new Error('Packet is not Type-2')); + if(FTN_PACKET_HEADER_TYPE !== packetHeader.packetType) { + cb(new Error('Unsupported header type: ' + packetHeader.packetType)); return; } - // :TODO: validate & pass error if failure - cb(null, packetHeader); + // + // What kind of packet do we really have here? + // + // :TODO: adjust values based on version discovered + if(FTN_PACKET_BAUD_TYPE_2_2 === packetHeader.baud) { + packetHeader.packetVersion = '2.2'; + + // See FSC-0045 + packetHeader.origPoint = packetHeader.year; + packetHeader.destPoint = packetHeader.month; + + packetHeader.destDomain = packetHeader.origZone2; + packetHeader.origDomain = packetHeader.auxNet; + } else { + // + // See heuristics described in FSC-0048, "Receiving Type-2+ bundles" + // + const capWordValidateSwapped = + ((packetHeader.capWordValidate & 0xff) << 8) | + ((packetHeader.capWordValidate >> 8) & 0xff); + + if(capWordValidateSwapped === packetHeader.capWord && + 0 != packetHeader.capWord && + packetHeader.capWord & 0x0001) + { + packetHeader.packetVersion = '2+'; + + // See FSC-0048 + if(-1 === packetHeader.origNet) { + packetHeader.origNet = packetHeader.auxNet; + } + } else { + packetHeader.packetVersion = '2'; + + // :TODO: should fill bytes be 0? + } + } + + packetHeader.created = moment({ + year : packetHeader.year, + month : packetHeader.month - 1, // moment uses 0 indexed months + date : packetHeader.day, + hour : packetHeader.hour, + minute : packetHeader.minute, + second : packetHeader.second + }); + + let ph = new PacketHeader(); + _.assign(ph, packetHeader); + + cb(null, ph); }); }; + + this.getPacketHeaderBuffer = function(packetHeader) { + let buffer = new Buffer(FTN_PACKET_HEADER_SIZE); - - self.getMessageMeta = function(msgBody) { - var meta = { - FtnKludge : msgBody.kludgeLines, - FtnProperty : {}, - }; - - if(msgBody.tearLine) { - meta.FtnProperty.ftn_tear_line = [ msgBody.tearLine ]; - } - if(msgBody.seenBy.length > 0) { - meta.FtnProperty.ftn_seen_by = msgBody.seenBy; - } - if(msgBody.area) { - meta.FtnProperty.ftn_area = [ msgBody.area ]; - } - if(msgBody.originLine) { - meta.FtnProperty.ftn_origin = [ msgBody.originLine ]; - } + buffer.writeUInt16LE(packetHeader.origNode, 0); + buffer.writeUInt16LE(packetHeader.destNode, 2); + buffer.writeUInt16LE(packetHeader.year, 4); + buffer.writeUInt16LE(packetHeader.month, 6); + buffer.writeUInt16LE(packetHeader.day, 8); + buffer.writeUInt16LE(packetHeader.hour, 10); + buffer.writeUInt16LE(packetHeader.minute, 12); + buffer.writeUInt16LE(packetHeader.second, 14); - return meta; + buffer.writeUInt16LE(packetHeader.baud, 16); + buffer.writeUInt16LE(FTN_PACKET_HEADER_TYPE, 18); + buffer.writeUInt16LE(packetHeader.origNet, 20); + buffer.writeUInt16LE(packetHeader.destNet, 22); + buffer.writeUInt8(packetHeader.prodCodeLo, 24); + buffer.writeUInt8(packetHeader.prodRevHi, 25); + + const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8); + pass.copy(buffer, 26); + + buffer.writeUInt16LE(packetHeader.origZone, 34); + buffer.writeUInt16LE(packetHeader.destZone, 36); + buffer.writeUInt16LE(packetHeader.auxNet, 38); + buffer.writeUInt16LE(packetHeader.capWordValidate, 40); + buffer.writeUInt8(packetHeader.prodCodeHi, 42); + buffer.writeUInt8(packetHeader.prodRevLo, 43); + buffer.writeUInt16LE(packetHeader.capWord, 44); + buffer.writeUInt16LE(packetHeader.origZone2, 46); + buffer.writeUInt16LE(packetHeader.destZone2, 48); + buffer.writeUInt16LE(packetHeader.origPoint, 50); + buffer.writeUInt16LE(packetHeader.destPoint, 52); + buffer.writeUInt32LE(packetHeader.prodData, 54); + + return buffer; + } + + this.writePacketHeader = function(packetHeader, ws) { + let buffer = new Buffer(FTN_PACKET_HEADER_SIZE); + + buffer.writeUInt16LE(packetHeader.origNode, 0); + buffer.writeUInt16LE(packetHeader.destNode, 2); + buffer.writeUInt16LE(packetHeader.year, 4); + buffer.writeUInt16LE(packetHeader.month, 6); + buffer.writeUInt16LE(packetHeader.day, 8); + buffer.writeUInt16LE(packetHeader.hour, 10); + buffer.writeUInt16LE(packetHeader.minute, 12); + buffer.writeUInt16LE(packetHeader.second, 14); + + buffer.writeUInt16LE(packetHeader.baud, 16); + buffer.writeUInt16LE(FTN_PACKET_HEADER_TYPE, 18); + buffer.writeUInt16LE(packetHeader.origNet, 20); + buffer.writeUInt16LE(packetHeader.destNet, 22); + buffer.writeUInt8(packetHeader.prodCodeLo, 24); + buffer.writeUInt8(packetHeader.prodRevHi, 25); + + const pass = ftn.stringToNullPaddedBuffer(packetHeader.password, 8); + pass.copy(buffer, 26); + + buffer.writeUInt16LE(packetHeader.origZone, 34); + buffer.writeUInt16LE(packetHeader.destZone, 36); + buffer.writeUInt16LE(packetHeader.auxNet, 38); + buffer.writeUInt16LE(packetHeader.capWordValidate, 40); + buffer.writeUInt8(packetHeader.prodCodeHi, 42); + buffer.writeUInt8(packetHeader.prodRevLo, 43); + buffer.writeUInt16LE(packetHeader.capWord, 44); + buffer.writeUInt16LE(packetHeader.origZone2, 46); + buffer.writeUInt16LE(packetHeader.destZone2, 48); + buffer.writeUInt16LE(packetHeader.origPoint, 50); + buffer.writeUInt16LE(packetHeader.destPoint, 52); + buffer.writeUInt32LE(packetHeader.prodData, 54); + + ws.write(buffer); + + return buffer.length; }; - this.parseFtnMessageBody = function(msgBodyBuffer, cb) { + this.processMessageBody = function(messageBodyBuffer, cb) { // // From FTS-0001.16: - // "Message text is unbounded and null terminated (note exception below). + // "Message text is unbounded and null terminated (note exception below). // - // A 'hard' carriage return, 0DH, marks the end of a paragraph, and must - // be preserved. + // A 'hard' carriage return, 0DH, marks the end of a paragraph, and must + // be preserved. // - // So called 'soft' carriage returns, 8DH, may mark a previous - // processor's automatic line wrap, and should be ignored. Beware that - // they may be followed by linefeeds, or may not. + // So called 'soft' carriage returns, 8DH, may mark a previous + // processor's automatic line wrap, and should be ignored. Beware that + // they may be followed by linefeeds, or may not. // - // All linefeeds, 0AH, should be ignored. Systems which display message - // text should wrap long lines to suit their application." + // All linefeeds, 0AH, should be ignored. Systems which display message + // text should wrap long lines to suit their application." // - // This is a bit tricky. Decoding the buffer to CP437 converts all 0x8d -> 0xec, so we'll - // have to replace those characters if the buffer is left as CP437. - // After decoding, we'll need to peek at the buffer for the various kludge lines - // for charsets & possibly re-decode. Uggh! - // - - // :TODO: Use the proper encoding here. There appear to be multiple specs and/or - // stuff people do with this... some specs kludge lines, which is kinda durpy since - // to get to that point, one must read the file (and decode) to find said kludge... - - - //var msgLines = msgBodyBuffer.toString().split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g); - - //var msgLines = iconv.decode(msgBodyBuffer, 'CP437').replace(/\xec/g, '').split(/\r\n|[\r\n]/g); - var msgLines = iconv.decode(msgBodyBuffer, 'CP437').replace(/[\xec\n]/g, '').split(/\r/g); - - var msgBody = { - message : [], - kludgeLines : {}, // -> [ value1, value2, ... ] + // This can be a bit tricky: + // * Decoding as CP437 converts 0x8d -> 0xec, so we'll need to correct for that + // * Many kludge lines specify an encoding. If we find one of such lines, we'll + // likely need to re-decode as the specified encoding + // * SAUCE is binary-ish data, so we need to inspect for it before any + // decoding occurs + // + let messageBodyData = { + message : [], + kludgeLines : {}, // KLUDGE:[value1, value2, ...] map seenBy : [], }; - var preOrigin = true; + function addKludgeLine(line) { + const sepIndex = line.indexOf(':'); + const key = line.substr(0, sepIndex).toUpperCase(); + const value = line.substr(sepIndex + 1).trim(); - function addKludgeLine(kl) { - var kludgeParts = kl.split(':'); - kludgeParts[0] = kludgeParts[0].toUpperCase(); - kludgeParts[1] = kludgeParts[1].trim(); + // + // Allow mapped value to be either a key:value if there is only + // one entry, or key:[value1, value2,...] if there are more + // + if(messageBodyData.kludgeLines[key]) { + if(!_.isArray(messageBodyData.kludgeLines[key])) { + messageBodyData.kludgeLines[key] = [ messageBodyData.kludgeLines[key] ]; + } + messageBodyData.kludgeLines[key].push(value); + } else { + messageBodyData.kludgeLines[key] = value; + } + } + + let encoding = 'cp437'; - (msgBody.kludgeLines[kludgeParts[0]] = msgBody.kludgeLines[kludgeParts[0]] || []).push(kludgeParts[1]); + async.series( + [ + function extractSauce(callback) { + // :TODO: This is wrong: SAUCE may not have EOF marker for one, also if it's + // present, we need to extract it but keep the rest of hte message intact as it likely + // has SEEN-BY, PATH, and other kludge information *appended* + const sauceHeaderPosition = messageBodyBuffer.indexOf(FTN_MESSAGE_SAUCE_HEADER); + if(sauceHeaderPosition > -1) { + sauce.readSAUCE(messageBodyBuffer.slice(sauceHeaderPosition, sauceHeaderPosition + sauce.SAUCE_SIZE), (err, theSauce) => { + if(!err) { + // we read some SAUCE - don't re-process that portion into the body + messageBodyBuffer = messageBodyBuffer.slice(0, sauceHeaderPosition) + messageBodyBuffer.slice(sauceHeaderPosition + sauce.SAUCE_SIZE); +// messageBodyBuffer = messageBodyBuffer.slice(0, sauceHeaderPosition); + messageBodyData.sauce = theSauce; + } else { + console.log(err) + } + callback(null); // failure to read SAUCE is OK + }); + } else { + callback(null); + } + }, + function extractChrsAndDetermineEncoding(callback) { + // + // From FTS-5003.001: + // "The CHRS control line is formatted as follows: + // + // ^ACHRS: + // + // Where is a character string of no more than eight (8) + // ASCII characters identifying the character set or character encoding + // scheme used, and level is a positive integer value describing what + // level of CHRS the message is written in." + // + // Also according to the spec, the deprecated "CHARSET" value may be used + // :TODO: Look into CHARSET more - should we bother supporting it? + // :TODO: See encodingFromHeader() for CHRS/CHARSET support @ https://github.com/Mithgol/node-fidonet-jam + const FTN_CHRS_PREFIX = new Buffer( [ 0x01, 0x43, 0x48, 0x52, 0x53, 0x3a, 0x20 ] ); // "\x01CHRS:" + const FTN_CHRS_SUFFIX = new Buffer( [ 0x0d ] ); + binary.parse(messageBodyBuffer) + .scan('prefix', FTN_CHRS_PREFIX) + .scan('content', FTN_CHRS_SUFFIX) + .tap(chrsData => { + if(chrsData.prefix && chrsData.content && chrsData.content.length > 0) { + const chrs = iconv.decode(chrsData.content, 'CP437'); + const chrsEncoding = ftn.getEncodingFromCharacterSetIdentifier(chrs); + if(chrsEncoding) { + encoding = chrsEncoding; + } + callback(null); + } else { + callback(null); + } + }); + }, + function extractMessageData(callback) { + // + // Decode |messageBodyBuffer| using |encoding| defaulted or detected above + // + // :TODO: Look into \xec thing more - document + const messageLines = iconv.decode(messageBodyBuffer, encoding).replace(/\xec/g, '').split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g); + let endOfMessage = true; + + messageLines.forEach(line => { + if(0 === line.length) { + messageBodyData.message.push(''); + return; + } + + if(line.startsWith('AREA:')) { + messageBodyData.area = line.substring(line.indexOf(':') + 1).trim(); + } else if(line.startsWith('--- ')) { + // Tear Lines are tracked allowing for specialized display/etc. + messageBodyData.tearLine = line; + } else if(/[ ]{1,2}(\* )?Origin\: /.test(line)) { // To spec is " * Origin: ..." + messageBodyData.originLine = line; + endOfMessage = false; // Anything past origin is not part of the message body + } else if(line.startsWith('SEEN-BY:')) { + endOfMessage = true; // Anything past the first SEEN-BY is not part of the message body + messageBodyData.seenBy.push(line.substring(line.indexOf(':') + 1).trim()); + } else if(FTN_MESSAGE_KLUDGE_PREFIX === line.charAt(0)) { + if('PATH:' === line.slice(1, 6)) { + endOfMessage = true; // Anything pats the first PATH is not part of the message body + } + addKludgeLine(line.slice(1)); + } else if(endOfMessage) { + // regular ol' message line + messageBodyData.message.push(line); + } + }); + + callback(null); + } + ], + function complete(err) { + messageBodyData.message = messageBodyData.message.join('\n'); + cb(messageBodyData); + } + ); + }; + + this.parsePacketMessages = function(packetBuffer, iterator, cb) { + binary.parse(packetBuffer) + .word16lu('messageType') + .word16lu('ftn_orig_node') + .word16lu('ftn_dest_node') + .word16lu('ftn_orig_network') + .word16lu('ftn_dest_network') + .word16lu('ftn_attr_flags') + .word16lu('ftn_cost') + .scan('modDateTime', NULL_TERM_BUFFER) // :TODO: 20 bytes max + .scan('toUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max + .scan('fromUserName', NULL_TERM_BUFFER) // :TODO: 36 bytes max + .scan('subject', NULL_TERM_BUFFER) // :TODO: 72 bytes max6 + .scan('message', NULL_TERM_BUFFER) + .tap(function tapped(msgData) { // no arrow function; want classic this + if(!msgData.messageType) { + // end marker -- no more messages + return cb(null); + } + + if(FTN_PACKET_MESSAGE_TYPE != msgData.messageType) { + return cb(new Error('Unsupported message type: ' + msgData.messageType)); + } + + const read = + 14 + // fixed header size + msgData.modDateTime.length + 1 + + msgData.toUserName.length + 1 + + msgData.fromUserName.length + 1 + + msgData.subject.length + 1 + + msgData.message.length + 1; + + // + // Convert null terminated arrays to strings + // + let convMsgData = {}; + [ 'modDateTime', 'toUserName', 'fromUserName', 'subject' ].forEach(k => { + convMsgData[k] = iconv.decode(msgData[k], 'CP437'); + }); + + // + // The message body itself is a special beast as it may + // contain an origin line, kludges, SAUCE in the case + // of ANSI files, etc. + // + let msg = new Message( { + toUserName : convMsgData.toUserName, + fromUserName : convMsgData.fromUserName, + subject : convMsgData.subject, + modTimestamp : ftn.getDateFromFtnDateTime(convMsgData.modDateTime), + }); + + msg.meta.FtnProperty = {}; + msg.meta.FtnProperty.ftn_orig_node = msgData.ftn_orig_node; + msg.meta.FtnProperty.ftn_dest_node = msgData.ftn_dest_node; + msg.meta.FtnProperty.ftn_orig_network = msgData.ftn_orig_network; + msg.meta.FtnProperty.ftn_dest_network = msgData.ftn_dest_network; + msg.meta.FtnProperty.ftn_attr_flags = msgData.ftn_attr_flags; + msg.meta.FtnProperty.ftn_cost = msgData.ftn_cost; + + self.processMessageBody(msgData.message, messageBodyData => { + msg.message = messageBodyData.message; + msg.meta.FtnKludge = messageBodyData.kludgeLines; + + if(messageBodyData.tearLine) { + msg.meta.FtnProperty.ftn_tear_line = messageBodyData.tearLine; + + if(self.options.keepTearAndOrigin) { + msg.message += `\r\n${messageBodyData.tearLine}\r\n`; + } + } + + if(messageBodyData.seenBy.length > 0) { + msg.meta.FtnProperty.ftn_seen_by = messageBodyData.seenBy; + } + + if(messageBodyData.area) { + msg.meta.FtnProperty.ftn_area = messageBodyData.area; + } + + if(messageBodyData.originLine) { + msg.meta.FtnProperty.ftn_origin = messageBodyData.originLine; + + if(self.options.keepTearAndOrigin) { + msg.message += `${messageBodyData.originLine}\r\n`; + } + } + + const nextBuf = packetBuffer.slice(read); + if(nextBuf.length > 0) { + let next = function(e) { + if(e) { + cb(e); + } else { + self.parsePacketMessages(nextBuf, iterator, cb); + } + }; + + iterator('message', msg, next); + } else { + cb(null); + } + }); + }); + }; + + this.getMessageEntryBuffer = function(message, options) { + let basicHeader = new Buffer(34); + + basicHeader.writeUInt16LE(FTN_PACKET_MESSAGE_TYPE, 0); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_node, 2); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_node, 4); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_network, 6); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_network, 8); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_attr_flags, 10); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12); + + const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0'); + dateTimeBuffer.copy(basicHeader, 14); + + // toUserName & fromUserName: up to 36 bytes in length, NULL term'd + // :TODO: DRY... + let toUserNameBuf = iconv.encode(message.toUserName + '\0', 'CP437').slice(0, 36); + toUserNameBuf[toUserNameBuf.length - 1] = '\0'; // ensure it's null term'd + + let fromUserNameBuf = iconv.encode(message.fromUserName + '\0', 'CP437').slice(0, 36); + fromUserNameBuf[fromUserNameBuf.length - 1] = '\0'; // ensure it's null term'd + + // subject: up to 72 bytes in length, NULL term'd + let subjectBuf = iconv.encode(message.subject + '\0', 'CP437').slice(0, 72); + subjectBuf[subjectBuf.length - 1] = '\0'; // ensure it's null term'd + + // + // message: unbound length, NULL term'd + // + // We need to build in various special lines - kludges, area, + // seen-by, etc. + // + // :TODO: Put this in it's own method + let msgBody = ''; + + function appendMeta(k, m) { + if(m) { + let a = m; + if(!_.isArray(a)) { + a = [ a ]; + } + a.forEach(v => { + msgBody += `${k}: ${v}\r`; + }); + } + } + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // AREA:CONFERENCE + // Should be first line in a message + // + if(message.meta.FtnProperty.ftn_area) { + msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) + } + + Object.keys(message.meta.FtnKludge).forEach(k => { + // we want PATH to be last + if('PATH' !== k) { + appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + } + }); + + msgBody += message.message + '\r'; + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // Tear line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_tear_line) { + msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`; + } + + // + // Origin line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_origin) { + msgBody += `${message.meta.FtnProperty.ftn_origin}\r`; } - msgLines.forEach(function nextLine(line) { - if(0 === line.length) { - msgBody.message.push(''); - return; - } + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // SEEN-BY and PATH should be the last lines of a message + // + appendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01) - if(preOrigin) { - if(_.startsWith(line, 'AREA:')) { - msgBody.area = line.substring(line.indexOf(':') + 1).trim(); - } else if(_.startsWith(line, '--- ')) { - // Tag lines are tracked allowing for specialized display/etc. - msgBody.tearLine = line; - } else if(/[ ]{1,2}(\* )?Origin\: /.test(line)) { // To spec is " * Origin: ..." - msgBody.originLine = line; - preOrigin = false; - } else if(self.KLUDGE_PREFIX === line.charAt(0)) { - addKludgeLine(line.slice(1)); - } else { - msgBody.message.push(line); + appendMeta('\x01PATH', message.meta.FtnKludge['PATH']); + + return Buffer.concat( [ + basicHeader, + toUserNameBuf, + fromUserNameBuf, + subjectBuf, + iconv.encode(msgBody + '\0', options.encoding) + ]); + }; + + this.writeMessage = function(message, ws, options) { + let basicHeader = new Buffer(34); + + basicHeader.writeUInt16LE(FTN_PACKET_MESSAGE_TYPE, 0); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_node, 2); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_node, 4); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_orig_network, 6); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_dest_network, 8); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_attr_flags, 10); + basicHeader.writeUInt16LE(message.meta.FtnProperty.ftn_cost, 12); + + const dateTimeBuffer = new Buffer(ftn.getDateTimeString(message.modTimestamp) + '\0'); + dateTimeBuffer.copy(basicHeader, 14); + + ws.write(basicHeader); + + // toUserName & fromUserName: up to 36 bytes in length, NULL term'd + // :TODO: DRY... + let encBuf = iconv.encode(message.toUserName + '\0', 'CP437').slice(0, 36); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + encBuf = iconv.encode(message.fromUserName + '\0', 'CP437').slice(0, 36); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + // subject: up to 72 bytes in length, NULL term'd + encBuf = iconv.encode(message.subject + '\0', 'CP437').slice(0, 72); + encBuf[encBuf.length - 1] = '\0'; // ensure it's null term'd + ws.write(encBuf); + + // + // message: unbound length, NULL term'd + // + // We need to build in various special lines - kludges, area, + // seen-by, etc. + // + // :TODO: Put this in it's own method + let msgBody = ''; + + function appendMeta(k, m) { + if(m) { + let a = m; + if(!_.isArray(a)) { + a = [ a ]; } - // :TODO: SAUCE/etc. can be present? - } else { - if(_.startsWith(line, 'SEEN-BY:')) { - msgBody.seenBy.push(line.substring(line.indexOf(':') + 1).trim()); - } else if(self.KLUDGE_PREFIX === line.charAt(0)) { - addKludgeLine(line.slice(1)); - } + a.forEach(v => { + msgBody += `${k}: ${v}\r`; + }); + } + } + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // AREA:CONFERENCE + // Should be first line in a message + // + if(message.meta.FtnProperty.ftn_area) { + msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) + } + + Object.keys(message.meta.FtnKludge).forEach(k => { + // we want PATH to be last + if('PATH' !== k) { + appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); } }); - cb(null, msgBody); + msgBody += message.message + '\r'; + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // Tear line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_tear_line) { + msgBody += `${message.meta.FtnProperty.ftn_tear_line}\r`; + } + + // + // Origin line should be near the bottom of a message + // + if(message.meta.FtnProperty.ftn_origin) { + msgBody += `${message.meta.FtnProperty.ftn_origin}\r`; + } + + // + // FTN-0004.001 @ http://ftsc.org/docs/fts-0004.001 + // SEEN-BY and PATH should be the last lines of a message + // + appendMeta('SEEN-BY', message.meta.FtnProperty.ftn_seen_by); // note: no ^A (0x01) + + appendMeta('\x01PATH', message.meta.FtnKludge['PATH']); + + // + // :TODO: We should encode based on config and add the proper kludge here! + ws.write(iconv.encode(msgBody + '\0', options.encoding)); }; - this.extractMessages = function(buffer, cb) { - var nullTermBuf = new Buffer( [ 0 ] ); - - binary.stream(buffer).loop(function looper(end, vars) { - this - .word16lu('messageType') - .word16lu('originNode') - .word16lu('destNode') - .word16lu('originNet') - .word16lu('destNet') - .word8('attrFlags1') - .word8('attrFlags2') - .word16lu('cost') - .scan('modDateTime', nullTermBuf) - .scan('toUserName', nullTermBuf) - .scan('fromUserName', nullTermBuf) - .scan('subject', nullTermBuf) - .scan('message', nullTermBuf) - .tap(function tapped(msgData) { - if(!msgData.originNode) { - end(); - cb(null); - return; - } - - // buffer to string conversion - [ 'modDateTime', 'toUserName', 'fromUserName', 'subject', ].forEach(function field(f) { - msgData[f] = iconv.decode(msgData[f], 'CP437'); - }); - - self.parseFtnMessageBody(msgData.message, function msgBodyParsed(err, msgBody) { - // - // Now, create a Message object - // - var msg = new Message( { - // :TODO: areaId needs to be looked up via AREA line - may need a 1:n alias -> area ID lookup - toUserName : msgData.toUserName, - fromUserName : msgData.fromUserName, - subject : msgData.subject, - message : msgBody.message.join('\n'), // :TODO: \r\n is better? - modTimestamp : ftn.getDateFromFtnDateTime(msgData.modDateTime), - meta : self.getMessageMeta(msgBody), - }); - - self.emit('message', msg); // :TODO: Placeholder - }); - }); - }); - }; - - this.parseFtnMessages = function(buffer, cb) { - var nullTermBuf = new Buffer( [ 0 ] ); - var fidoMessages = []; - - binary.stream(buffer).loop(function looper(end, vars) { - this - .word16lu('messageType') - .word16lu('originNode') - .word16lu('destNode') - .word16lu('originNet') - .word16lu('destNet') - .word8('attrFlags1') - .word8('attrFlags2') - .word16lu('cost') - .scan('modDateTime', nullTermBuf) - .scan('toUserName', nullTermBuf) - .scan('fromUserName', nullTermBuf) - .scan('subject', nullTermBuf) - .scan('message', nullTermBuf) - .tap(function tapped(msgData) { - if(!msgData.originNode) { - end(); - cb(null, fidoMessages); - return; - } - - // buffer to string conversion - // :TODO: What is the real encoding here? - [ 'modDateTime', 'toUserName', 'fromUserName', 'subject', ].forEach(function field(f) { - msgData[f] = msgData[f].toString(); - }); - - self.parseFtnMessageBody(msgData.message, function msgBodyParsed(err, msgBody) { - msgData.message = msgBody; - fidoMessages.push(_.clone(msgData)); - }); - }); - }); - }; - - this.extractMesssagesFromPacketBuffer = function(packetBuffer, cb) { - async.waterfall( + this.parsePacketBuffer = function(packetBuffer, iterator, cb) { + async.series( [ - function parseHeader(callback) { - self.parseFtnPacketHeader(packetBuffer, function headerParsed(err, packetHeader) { - self.packetHeader = packetHeader; - callback(err); + function processHeader(callback) { + self.parsePacketHeader(packetBuffer, (err, header) => { + if(err) { + return callback(err); + } + + let next = function(e) { + callback(e); + }; + + iterator('header', header, next); }); }, - function validateDesinationAddress(callback) { - self.localNetworkName = self.getNetworkNameForAddress(self.getPacketHeaderAddress()); - self.localNetworkName = 'AllowAnyNetworkForDebugging'; - callback(self.localNetworkName ? null : new Error('Packet not addressed do this system')); - }, - function extractEmbeddedMessages(callback) { - // note: packet header is 58 bytes in length - self.extractMessages(packetBuffer.slice(58), function extracted(err) { - callback(err); - }); + function processMessages(callback) { + self.parsePacketMessages( + packetBuffer.slice(FTN_PACKET_HEADER_SIZE), + iterator, + callback); } ], - function complete(err) { - cb(err); - } - ); - }; - - this.loadMessagesFromPacketBuffer = function(packetBuffer, cb) { - async.waterfall( - [ - function parseHeader(callback) { - self.parseFtnPacketHeader(packetBuffer, function headerParsed(err, packetHeader) { - self.packetHeader = packetHeader; - callback(err); - }); - }, - function validateDesinationAddress(callback) { - self.localNetworkName = self.getNetworkNameForAddress(self.getPacketHeaderAddress()); - self.localNetworkName = 'AllowAnyNetworkForDebugging'; - callback(self.localNetworkName ? null : new Error('Packet not addressed do this system')); - }, - function parseMessages(callback) { - self.parseFtnMessages(packetBuffer.slice(58), function messagesParsed(err, fidoMessages) { - callback(err, fidoMessages); - }); - }, - function createMessageObjects(fidoMessages, callback) { - fidoMessages.forEach(function msg(fmsg) { - console.log(fmsg); - }); - } - ], - function complete(err) { - cb(err); - } - ); + cb // complete + ); }; } -require('util').inherits(FTNMailPacket, MailPacket); +// +// Message attributes defined in FTS-0001.016 +// http://ftsc.org/docs/fts-0001.016 +// +// See also: +// * http://www.skepticfiles.org/aj/basics03.htm +// +Packet.Attribute = { + Private : 0x0001, // Private message / NetMail + Crash : 0x0002, + Received : 0x0004, + Sent : 0x0008, + FileAttached : 0x0010, + InTransit : 0x0020, + Orphan : 0x0040, + KillSent : 0x0080, + Local : 0x0100, // Message is from *this* system + Hold : 0x0200, + Reserved0 : 0x0400, + FileRequest : 0x0800, + ReturnReceiptRequest : 0x1000, + ReturnReceipt : 0x2000, + AuditRequest : 0x4000, + FileUpdateRequest : 0x8000, +}; +Object.freeze(Packet.Attribute); -FTNMailPacket.prototype.parse = function(path, cb) { +Packet.prototype.read = function(pathOrBuffer, iterator, cb) { var self = this; - async.waterfall( + async.series( [ - function readFromFile(callback) { - fs.readFile(path, function packetData(err, data) { - callback(err, data); - }); + function getBufferIfPath(callback) { + if(_.isString(pathOrBuffer)) { + fs.readFile(pathOrBuffer, (err, data) => { + pathOrBuffer = data; + callback(err); + }); + } else { + callback(null); + } }, - function extractMessages(data, callback) { - self.loadMessagesFromPacketBuffer(data, function extracted(err, messages) { - callback(err, messages); + function parseBuffer(callback) { + self.parsePacketBuffer(pathOrBuffer, iterator, err => { + callback(err); }); } ], - function complete(err, messages) { - cb(err, messages); + err => { + cb(err); } ); }; -FTNMailPacket.prototype.read = function(options) { - FTNMailPacket.super_.prototype.read.call(this, options); +Packet.prototype.writeHeader = function(ws, packetHeader) { + return this.writePacketHeader(packetHeader, ws); +}; - var self = this; +Packet.prototype.writeMessageEntry = function(ws, msgEntry) { + ws.write(msgEntry); + return msgEntry.length; +}; - if(_.isString(options.packetPath)) { - async.waterfall( - [ - function readPacketFile(callback) { - fs.readFile(options.packetPath, function packetData(err, data) { - callback(err, data); - }); - }, - function extractMessages(data, callback) { - self.extractMesssagesFromPacketBuffer(data, function extracted(err) { - callback(err); - }); - } - ], - function complete(err) { - if(err) { - self.emit('error', err); - } - } - ); - } else if(Buffer.isBuffer(options.packetBuffer)) { +Packet.prototype.writeTerminator = function(ws) { + ws.write(new Buffer( [ 0 ] )); // final extra null term + return 1; +}; +Packet.prototype.writeStream = function(ws, messages, options) { + if(!_.isBoolean(options.terminatePacket)) { + options.terminatePacket = true; + } + + if(_.isObject(options.packetHeader)) { + this.writePacketHeader(options.packetHeader, ws); + } + + options.encoding = options.encoding || 'utf8'; + + messages.forEach(msg => { + this.writeMessage(msg, ws, options); + }); + + if(true === options.terminatePacket) { + ws.write(new Buffer( [ 0 ] )); // final extra null term } }; -FTNMailPacket.prototype.write = function(options) { - FTNMailPacket.super_.prototype.write.call(this, options); -}; - - -var mailPacket = new FTNMailPacket( - { - nodeAddresses : { - fidoNet : { - zone : 46, - net : 1, - node : 140, - point : 0, - domain : '' - } - } +Packet.prototype.write = function(path, packetHeader, messages, options) { + if(!_.isArray(messages)) { + messages = [ messages ]; } -); + + options = options || { encoding : 'utf8' }; // utf-8 = 'CHRS UTF-8 4' -mailPacket.on('message', function msgParsed(msg) { - console.log(msg); -}); - -mailPacket.read( { packetPath : '/home/nuskooler/ownCloud/Projects/ENiGMA½ BBS/FTNPackets/BAD_BNDL.007' } ); - -/* -mailPacket.parse('/home/nuskooler/ownCloud/Projects/ENiGMA½ BBS/FTNPackets/BAD_BNDL.007', function parsed(err, messages) { - console.log(err) -}); -*/ \ No newline at end of file + this.writeStream( + fs.createWriteStream(path), // :TODO: specify mode/etc. + messages, + { packetHeader : packetHeader, terminatePacket : true } + ); +}; diff --git a/core/ftn_util.js b/core/ftn_util.js index 6ff0430b..e46ee12c 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -1,42 +1,65 @@ /* jslint node: true */ 'use strict'; -var Config = require('./config.js').config; +let Config = require('./config.js').config; +let Address = require('./ftn_address.js'); +let FNV1a = require('./fnv1a.js'); +let createNamedUUID = require('./uuid_util.js').createNamedUUID; +let _ = require('lodash'); +let assert = require('assert'); +let iconv = require('iconv-lite'); +let moment = require('moment'); +let uuid = require('node-uuid'); +let os = require('os'); -var _ = require('lodash'); -var assert = require('assert'); -var binary = require('binary'); -var fs = require('fs'); -var util = require('util'); -var iconv = require('iconv-lite'); +let packageJson = require('../package.json'); // :TODO: Remove "Ftn" from most of these -- it's implied in the module -exports.stringFromFTN = stringFromFTN; -exports.getFormattedFTNAddress = getFormattedFTNAddress; -exports.getDateFromFtnDateTime = getDateFromFtnDateTime; +exports.stringToNullPaddedBuffer = stringToNullPaddedBuffer; +exports.getMessageSerialNumber = getMessageSerialNumber; +exports.createMessageUuid = createMessageUuid; +exports.createMessageUuidAlternate = createMessageUuidAlternate; +exports.getDateFromFtnDateTime = getDateFromFtnDateTime; +exports.getDateTimeString = getDateTimeString; -exports.getQuotePrefix = getQuotePrefix; +exports.getMessageIdentifier = getMessageIdentifier; +exports.getProductIdentifier = getProductIdentifier; +exports.getUTCTimeZoneOffset = getUTCTimeZoneOffset; +exports.getOrigin = getOrigin; +exports.getTearLine = getTearLine; +exports.getVia = getVia; +exports.getAbbreviatedNetNodeList = getAbbreviatedNetNodeList; +exports.parseAbbreviatedNetNodeList = parseAbbreviatedNetNodeList; +exports.getUpdatedSeenByEntries = getUpdatedSeenByEntries; +exports.getUpdatedPathEntries = getUpdatedPathEntries; + +exports.getCharacterSetIdentifierByEncoding = getCharacterSetIdentifierByEncoding; +exports.getEncodingFromCharacterSetIdentifier = getEncodingFromCharacterSetIdentifier; + +exports.getQuotePrefix = getQuotePrefix; + +// +// Namespace for RFC-4122 name based UUIDs generated from +// FTN kludges MSGID + AREA +// +const ENIGMA_FTN_MSGID_NAMESPACE = uuid.parse('a5c7ae11-420c-4469-a116-0e9a6d8d2654'); // See list here: https://github.com/Mithgol/node-fidonet-jam -// :TODO: proably move this elsewhere as a general method -function stringFromFTN(buf, encoding) { - var nullPos = buf.length; - for(var i = 0; i < buf.length; ++i) { - if(0x00 === buf[i]) { - nullPos = i; - break; - } +function stringToNullPaddedBuffer(s, bufLen) { + let buffer = new Buffer(bufLen).fill(0x00); + let enc = iconv.encode(s, 'CP437').slice(0, bufLen); + for(let i = 0; i < enc.length; ++i) { + buffer[i] = enc[i]; } - - return iconv.decode(buf.slice(0, nullPos), encoding || 'utf-8'); + return buffer; } - // // Convert a FTN style DateTime string to a Date object // +// :TODO: Name the next couple methods better - for FTN *packets* function getDateFromFtnDateTime(dateTime) { // // Examples seen in the wild (Working): @@ -44,45 +67,146 @@ function getDateFromFtnDateTime(dateTime) { // "Tue 01 Jan 80 00:00" // "27 Feb 15 00:00:03" // - return (new Date(Date.parse(dateTime))).toISOString(); + // :TODO: Use moment.js here + return moment(Date.parse(dateTime)); // Date.parse() allows funky formats +// return (new Date(Date.parse(dateTime))).toISOString(); } -function getFormattedFTNAddress(address, dimensions) { - //var addr = util.format('%d:%d', address.zone, address.net); - var addr = '{0}:{1}'.format(address.zone, address.net); - switch(dimensions) { - case 2 : - case '2D' : - // above - break; - - case 3 : - case '3D' : - addr += '/{0}'.format(address.node); - break; - - case 4 : - case '4D': - addr += '.{0}'.format(address.point || 0); // missing and 0 are equiv for point - break; - - case 5 : - case '5D' : - if(address.domain) { - addr += '@{0}'.format(address.domain); - } - break; +function getDateTimeString(m) { + // + // From http://ftsc.org/docs/fts-0001.016: + // DateTime = (* a character string 20 characters long *) + // (* 01 Jan 86 02:34:56 *) + // DayOfMonth " " Month " " Year " " + // " " HH ":" MM ":" SS + // Null + // + // DayOfMonth = "01" | "02" | "03" | ... | "31" (* Fido 0 fills *) + // Month = "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | + // "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" + // Year = "01" | "02" | .. | "85" | "86" | ... | "99" | "00" + // HH = "00" | .. | "23" + // MM = "00" | .. | "59" + // SS = "00" | .. | "59" + // + if(!moment.isMoment(m)) { + m = moment(m); } - return addr; + return m.format('DD MMM YY HH:mm:ss'); } -function getFtnMessageSerialNumber(messageId) { - return ((Math.floor((Date.now() - Date.UTC(2015, 1, 1)) / 1000) + messageId)).toString(16); +// +// Create a v5 named UUID given a message ID ("MSGID") and +// FTN area tag ("AREA"). +// +// This is similar to CrashMail +// See https://github.com/larsks/crashmail/blob/master/crashmail/dupe.c +// +function createMessageUuid(ftnMsgId, ftnArea) { + assert(_.isString(ftnMsgId)); + assert(_.isString(ftnArea)); + + ftnMsgId = iconv.encode(ftnMsgId, 'CP437'); + ftnArea = iconv.encode(ftnArea.toUpperCase(), 'CP437'); + + return uuid.unparse(createNamedUUID(ENIGMA_FTN_MSGID_NAMESPACE, Buffer.concat( [ ftnMsgId, ftnArea ] ))); +}; + +// +// Create a v5 named UUID given a FTN area tag ("AREA"), +// create/modified date, subject, and message body +// +// This method should be used as a backup for when a MSGID is +// not available in which createMessageUuid() above should be +// used instead. +// +function createMessageUuidAlternate(ftnArea, modTimestamp, subject, msgBody) { + assert(_.isString(ftnArea)); + assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp)); + assert(_.isString(subject)); + assert(_.isString(msgBody)); + + ftnArea = iconv.encode(ftnArea.toUpperCase(), 'CP437'); + modTimestamp = iconv.encode(getDateTimeString(modTimestamp), 'CP437'); + subject = iconv.encode(subject.toUpperCase().trim(), 'CP437'); + msgBody = iconv.encode(msgBody.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437'); + + return uuid.unparse(createNamedUUID(ENIGMA_FTN_MSGID_NAMESPACE, Buffer.concat( [ ftnArea, modTimestamp, subject, msgBody ] ))); } -function getFTNMessageID(messageId, areaId) { - return messageId + '.' + areaId + '@' + getFTNAddress() + ' ' + getFTNMessageSerialNumber(messageId) +function getMessageSerialNumber(messageId) { + const msSinceEnigmaEpoc = (Date.now() - Date.UTC(2016, 1, 1)); + const hash = Math.abs(new FNV1a(msSinceEnigmaEpoc + messageId).value).toString(16); + return `00000000${hash}`.substr(-8); +} + +// +// Return a FTS-0009.001 compliant MSGID value given a message +// See http://ftsc.org/docs/fts-0009.001 +// +// "A MSGID line consists of the string "^AMSGID:" (where ^A is a +// control-A (hex 01) and the double-quotes are not part of the +// string), followed by a space, the address of the originating +// system, and a serial number unique to that message on the +// originating system, i.e.: +// +// ^AMSGID: origaddr serialno +// +// The originating address should be specified in a form that +// constitutes a valid return address for the originating network. +// If the originating address is enclosed in double-quotes, the +// entire string between the beginning and ending double-quotes is +// considered to be the orginating address. A double-quote character +// within a quoted address is represented by by two consecutive +// double-quote characters. The serial number may be any eight +// character hexadecimal number, as long as it is unique - no two +// messages from a given system may have the same serial number +// within a three years. The manner in which this serial number is +// generated is left to the implementor." +// +// +// Examples & Implementations +// +// Synchronet: .@ +// 2606.agora-agn_tst@46:1/142 19609217 +// +// Mystic: +// 46:3/102 46686263 +// +// ENiGMA½: .@<5dFtnAddress> +// +function getMessageIdentifier(message, address) { + const addrStr = new Address(address).toString('5D'); + return `${message.messageId}.${message.areaTag.toLowerCase()}@${addrStr} ${getMessageSerialNumber(message.messageId)}`; +} + +// +// Return a FSC-0046.005 Product Identifier or "PID" +// http://ftsc.org/docs/fsc-0046.005 +// +// Note that we use a variant on the spec for +// in which (; ; ) is used instead +// +function getProductIdentifier() { + const version = packageJson.version + .replace(/\-/g, '.') + .replace(/alpha/,'a') + .replace(/beta/,'b'); + + const nodeVer = process.version.substr(1); // remove 'v' prefix + + return `ENiGMA1/2 ${version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; +} + +// +// Return a FRL-1004 style time zone offset for a +// 'TZUTC' kludge line +// +// http://ftsc.org/docs/frl-1004.002 +// +function getUTCTimeZoneOffset() { + return moment().format('ZZ').replace(/\+/, ''); } // Get a FSC-0032 style quote prefixes @@ -91,25 +215,221 @@ function getQuotePrefix(name) { return ' ' + name[0].toUpperCase() + name[1].toLowerCase() + '> '; } +// +// Return a FTS-0004 Origin line +// http://ftsc.org/docs/fts-0004.001 +// +function getOrigin(address) { + const origin = _.has(Config.messageNetworks.originName) ? + Config.messageNetworks.originName : + Config.general.boardName; + + const addrStr = new Address(address).toString('5D'); + return ` * Origin: ${origin} (${addrStr})`; +} + +function getTearLine() { + const nodeVer = process.version.substr(1); // remove 'v' prefix + return `--- ENiGMA 1/2 v${packageJson.version} (${os.platform()}; ${os.arch()}; ${nodeVer})`; +} // -// Specs: -// * http://ftsc.org/docs/fts-0009.001 -// * -// -function getFtnMsgIdKludgeLine(origAddress, messageId) { - if(_.isObject(origAddress)) { - origAddress = getFormattedFTNAddress(origAddress, '5D'); +// Return a FRL-1005.001 "Via" line +// http://ftsc.org/docs/frl-1005.001 +// +function getVia(address) { + /* + FRL-1005.001 states teh following format: + + ^AVia: @YYYYMMDD.HHMMSS[.Precise][.Time Zone] + [Serial Number] + */ + const addrStr = new Address(address).toString('5D'); + const dateTime = moment().utc().format('YYYYMMDD.HHmmSS.SSSS.UTC'); + + const version = packageJson.version + .replace(/\-/g, '.') + .replace(/alpha/,'a') + .replace(/beta/,'b'); + + return `${addrStr} @${dateTime} ENiGMA1/2 ${version}`; +} + +function getAbbreviatedNetNodeList(netNodes) { + let abbrList = ''; + let currNet; + netNodes.forEach(netNode => { + if(_.isString(netNode)) { + netNode = Address.fromString(netNode); + } + if(currNet !== netNode.net) { + abbrList += `${netNode.net}/`; + currNet = netNode.net; + } + abbrList += `${netNode.node} `; + }); + + return abbrList.trim(); // remove trailing space +} + +// +// Parse an abbreviated net/node list commonly used for SEEN-BY and PATH +// +function parseAbbreviatedNetNodeList(netNodes) { + const re = /([0-9]+)\/([0-9]+)\s?|([0-9]+)\s?/g; + let net; + let m; + let results = []; + while(null !== (m = re.exec(netNodes))) { + if(m[1] && m[2]) { + net = parseInt(m[1]); + results.push(new Address( { net : net, node : parseInt(m[2]) } )); + } else if(net) { + results.push(new Address( { net : net, node : parseInt(m[3]) } )); + } } - return '\x01MSGID: ' + origAddress + ' ' + getFtnMessageSerialNumber(messageId); + return results; } +// +// Return a FTS-0004.001 SEEN-BY entry(s) that include +// all pre-existing SEEN-BY entries with the addition +// of |additions|. +// +// See http://ftsc.org/docs/fts-0004.001 +// and notes at http://ftsc.org/docs/fsc-0043.002. +// +// For a great write up, see http://www.skepticfiles.org/aj/basics03.htm +// +// This method returns an sorted array of values, but +// not the "SEEN-BY" prefix itself +// +function getUpdatedSeenByEntries(existingEntries, additions) { + /* + From FTS-0004: + + "There can be many seen-by lines at the end of Conference + Mail messages, and they are the real "meat" of the control + information. They are used to determine the systems to + receive the exported messages. The format of the line is: + + SEEN-BY: 132/101 113 136/601 1014/1 + + The net/node numbers correspond to the net/node numbers of + the systems having already received the message. In this way + a message is never sent to a system twice. In a conference + with many participants the number of seen-by lines can be + very large. This line is added if it is not already a part + of the message, or added to if it already exists, each time + a message is exported to other systems. This is a REQUIRED + field, and Conference Mail will not function correctly if + this field is not put in place by other Echomail compatible + programs." + */ + existingEntries = existingEntries || []; + if(!_.isArray(existingEntries)) { + existingEntries = [ existingEntries ]; + } + + if(!_.isString(additions)) { + additions = parseAbbreviatedNetNodeList(getAbbreviatedNetNodeList(additions)); + } + + additions = additions.sort(Address.getComparator()); -function getFTNOriginLine() { // - // Specs: - // http://ftsc.org/docs/fts-0004.001 + // For now, we'll just append a new SEEN-BY entry // - return ' * Origin: ' + Config.general.boardName + '(' + getFidoNetAddress() + ')'; + // :TODO: we should at least try and update what is already there in a smart way + existingEntries.push(getAbbreviatedNetNodeList(additions)); + return existingEntries; } + +function getUpdatedPathEntries(existingEntries, localAddress) { + // :TODO: append to PATH in a smart way! We shoudl try to fit at least the last existing line + + existingEntries = existingEntries || []; + if(!_.isArray(existingEntries)) { + existingEntries = [ existingEntries ]; + } + + existingEntries.push(getAbbreviatedNetNodeList( + parseAbbreviatedNetNodeList(localAddress))); + + return existingEntries; +} + +// +// Return FTS-5000.001 "CHRS" value +// http://ftsc.org/docs/fts-5003.001 +// +const ENCODING_TO_FTS_5003_001_CHARS = { + // level 1 - generally should not be used + ascii : [ 'ASCII', 1 ], + 'us-ascii' : [ 'ASCII', 1 ], + + // level 2 - 8 bit, ASCII based + cp437 : [ 'CP437', 2 ], + cp850 : [ 'CP850', 2 ], + + // level 3 - reserved + + // level 4 + utf8 : [ 'UTF-8', 4 ], + 'utf-8' : [ 'UTF-8', 4 ], +}; + + +function getCharacterSetIdentifierByEncoding(encodingName) { + const value = ENCODING_TO_FTS_5003_001_CHARS[encodingName.toLowerCase()]; + return value ? `${value[0]} ${value[1]}` : encodingName.toUpperCase(); +} + +function getEncodingFromCharacterSetIdentifier(chrs) { + const ident = chrs.split(' ')[0].toUpperCase(); + + // :TODO: fill in the rest!!! + return { + // level 1 + 'ASCII' : 'iso-646-1', + 'DUTCH' : 'iso-646', + 'FINNISH' : 'iso-646-10', + 'FRENCH' : 'iso-646', + 'CANADIAN' : 'iso-646', + 'GERMAN' : 'iso-646', + 'ITALIAN' : 'iso-646', + 'NORWEIG' : 'iso-646', + 'PORTU' : 'iso-646', + 'SPANISH' : 'iso-656', + 'SWEDISH' : 'iso-646-10', + 'SWISS' : 'iso-646', + 'UK' : 'iso-646', + 'ISO-10' : 'iso-646-10', + + // level 2 + 'CP437' : 'cp437', + 'CP850' : 'cp850', + 'CP852' : 'cp852', + 'CP866' : 'cp866', + 'CP848' : 'cp848', + 'CP1250' : 'cp1250', + 'CP1251' : 'cp1251', + 'CP1252' : 'cp1252', + 'CP10000' : 'macroman', + 'LATIN-1' : 'iso-8859-1', + 'LATIN-2' : 'iso-8859-2', + 'LATIN-5' : 'iso-8859-9', + 'LATIN-9' : 'iso-8859-15', + + // level 4 + 'UTF-8' : 'utf8', + + // deprecated stuff + 'IBMPC' : 'cp1250', // :TODO: validate + '+7_FIDO' : 'cp866', + '+7' : 'cp866', + 'MAC' : 'macroman', // :TODO: validate + + }[ident]; +} \ No newline at end of file diff --git a/core/menu_module.js b/core/menu_module.js index e052b1db..503829c1 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -25,6 +25,10 @@ function MenuModule(options) { var self = this; this.menuName = options.menuName; this.menuConfig = options.menuConfig; + this.client = options.client; + + // :TODO: this and the line below with .config creates empty ({}) objects in the theme -- + // ...which we really should not do. If they aren't there already, don't use 'em. this.menuConfig.options = options.menuConfig.options || {}; this.menuMethods = {}; // methods called from @method's @@ -190,10 +194,7 @@ require('util').inherits(MenuModule, PluginModule); require('./mod_mixins.js').ViewControllerManagement.call(MenuModule.prototype); -MenuModule.prototype.enter = function(client) { - this.client = client; - assert(_.isObject(client)); - +MenuModule.prototype.enter = function() { if(_.isString(this.menuConfig.status)) { this.client.currentStatus = this.menuConfig.status; } else { diff --git a/core/menu_stack.js b/core/menu_stack.js index 5caf6531..64e8df40 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -131,7 +131,7 @@ MenuStack.prototype.goto = function(name, options, cb) { modInst.restoreSavedState(options.savedState); } - modInst.enter(self.client); + modInst.enter(); self.client.log.trace( { stack : _.map(self.stack, function(si) { return si.name; } ) }, diff --git a/core/menu_util.js b/core/menu_util.js index 601888a8..a00edb0b 100644 --- a/core/menu_util.js +++ b/core/menu_util.js @@ -4,10 +4,8 @@ // ENiGMA½ var moduleUtil = require('./module_util.js'); var Log = require('./logger.js').log; -var conf = require('./config.js'); // :TODO: remove me! var Config = require('./config.js').config; var asset = require('./asset.js'); -var theme = require('./theme.js'); var getFullConfig = require('./config_util.js').getFullConfig; var MCIViewFactory = require('./mci_view_factory.js').MCIViewFactory; var acsUtil = require('./acs_util.js'); @@ -68,17 +66,18 @@ function loadMenu(options, cb) { }); }, function loadMenuModule(menuConfig, callback) { - var modAsset = asset.getModuleAsset(menuConfig.module); - var modSupplied = null !== modAsset; - var modLoadOpts = { + const modAsset = asset.getModuleAsset(menuConfig.module); + const modSupplied = null !== modAsset; + + const modLoadOpts = { name : modSupplied ? modAsset.asset : 'standard_menu', path : (!modSupplied || 'systemModule' === modAsset.type) ? __dirname : Config.paths.mods, category : (!modSupplied || 'systemModule' === modAsset.type) ? null : 'mods', }; moduleUtil.loadModuleEx(modLoadOpts, function moduleLoaded(err, mod) { - var modData = { + const modData = { name : modLoadOpts.name, config : menuConfig, mod : mod, @@ -97,7 +96,8 @@ function loadMenu(options, cb) { { menuName : options.name, menuConfig : modData.config, - extraArgs : options.extraArgs + extraArgs : options.extraArgs, + client : options.client, }); callback(null, moduleInstance); } catch(e) { @@ -174,7 +174,7 @@ function handleAction(client, formData, conf) { assert(_.isObject(conf)); assert(_.isString(conf.action)); - var actionAsset = asset.parseAsset(conf.action); + const actionAsset = asset.parseAsset(conf.action); assert(_.isObject(actionAsset)); switch(actionAsset.type) { @@ -245,84 +245,3 @@ function handleNext(client, nextSpec, conf) { break; } } - - - -// :TODO: Seems better in theme.js, but that includes ViewController...which would then include theme.js -// ...theme.js only brings in VC to create themed pause prompt. Perhaps that should live elsewhere -/* -function applyGeneralThemeCustomization(options) { - // - // options.name - // options.client - // options.type - // options.config - // - assert(_.isString(options.name)); - assert(_.isObject(options.client)); - assert("menus" === options.type || "prompts" === options.type); - - if(_.has(options.client.currentTheme, [ 'customization', options.type, options.name ])) { - var themeConfig = options.client.currentTheme.customization[options.type][options.name]; - - if(themeConfig.config) { - Object.keys(themeConfig.config).forEach(function confEntry(conf) { - if(options.config[conf]) { - _.defaultsDeep(options.config[conf], themeConfig.config[conf]); - } else { - options.config[conf] = themeConfig.config[conf]; - } - }); - } - } -} -*/ - -/* -function applyMciThemeCustomization(options) { - // - // options.name : menu/prompt name - // options.mci : menu/prompt .mci section - // options.client : client - // options.type : menu|prompt - // options.formId : (optional) form ID in cases where multiple forms may exist wanting their own customization - // - // In the case of formId, the theme must include the ID as well, e.g.: - // { - // ... - // "2" : { - // "TL1" : { ... } - // } - // } - // - assert(_.isString(options.name)); - assert("menus" === options.type || "prompts" === options.type); - assert(_.isObject(options.client)); - - if(_.isUndefined(options.mci)) { - options.mci = {}; - } - - if(_.has(options.client.currentTheme, [ 'customization', options.type, options.name ])) { - var themeConfig = options.client.currentTheme.customization[options.type][options.name]; - - if(options.formId && _.has(themeConfig, options.formId.toString())) { - // form ID found - use exact match - themeConfig = themeConfig[options.formId]; - } - - if(themeConfig.mci) { - Object.keys(themeConfig.mci).forEach(function mciEntry(mci) { - // :TODO: a better way to do this? - if(options.mci[mci]) { - _.defaults(options.mci[mci], themeConfig.mci[mci]); - } else { - options.mci[mci] = themeConfig.mci[mci]; - } - }); - } - } - - // :TODO: apply generic stuff, e.g. "VM" (vs "VM1") -} -*/ \ No newline at end of file diff --git a/core/message.js b/core/message.js index 4b99fcfc..a9f74b0f 100644 --- a/core/message.js +++ b/core/message.js @@ -1,14 +1,15 @@ /* jslint node: true */ 'use strict'; -var msgDb = require('./database.js').dbs.message; -var wordWrapText = require('./word_wrap.js').wordWrapText; -var ftnUtil = require('./ftn_util.js'); +let msgDb = require('./database.js').dbs.message; +let wordWrapText = require('./word_wrap.js').wordWrapText; +let ftnUtil = require('./ftn_util.js'); -var uuid = require('node-uuid'); -var async = require('async'); -var _ = require('lodash'); -var assert = require('assert'); +let uuid = require('node-uuid'); +let async = require('async'); +let _ = require('lodash'); +let assert = require('assert'); +let moment = require('moment'); module.exports = Message; @@ -16,18 +17,18 @@ function Message(options) { options = options || {}; this.messageId = options.messageId || 0; // always generated @ persist - this.areaName = options.areaName || Message.WellKnownAreaNames.Invalid; - this.uuid = uuid.v1(); + this.areaTag = options.areaTag || Message.WellKnownAreaTags.Invalid; + this.uuid = options.uuid || uuid.v1(); this.replyToMsgId = options.replyToMsgId || 0; this.toUserName = options.toUserName || ''; this.fromUserName = options.fromUserName || ''; this.subject = options.subject || ''; this.message = options.message || ''; - if(_.isDate(options.modTimestamp)) { - this.modTimestamp = options.modTimestamp; + if(_.isDate(options.modTimestamp) || moment.isMoment(options.modTimestamp)) { + this.modTimestamp = moment(options.modTimestamp); } else if(_.isString(options.modTimestamp)) { - this.modTimestamp = new Date(options.modTimestamp); + this.modTimestamp = moment(options.modTimestamp); } this.viewCount = options.viewCount || 0; @@ -44,55 +45,30 @@ function Message(options) { this.meta = options.meta; } -// this.meta = options.meta || {}; this.hashTags = options.hashTags || []; - var self = this; - this.isValid = function() { // :TODO: validate as much as possible return true; }; this.isPrivate = function() { - return this.areaName === Message.WellKnownAreaNames.Private ? true : false; + return this.areaTag === Message.WellKnownAreaTags.Private ? true : false; }; this.getMessageTimestampString = function(ts) { - ts = ts || new Date(); - return ts.toISOString(); + ts = ts || moment(); + return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); }; - - /* - Object.defineProperty(this, 'messageId', { - get : function() { - return messageId; - } - }); - - Object.defineProperty(this, 'areaId', { - get : function() { return areaId; }, - set : function(i) { - areaId = i; - } - }); - - */ } -Message.WellKnownAreaNames = { +Message.WellKnownAreaTags = { Invalid : '', Private : 'private_mail', Bulletin : 'local_bulletin', }; -// :TODO: This doesn't seem like a good way to go -- perhaps only for local/user2user, or just use -// a system similar to the "last read" for general areas -Message.Status = { - New : 0, - Read : 1, -}; - +// :TODO: FTN stuff really doesn't belong here - move it elsewhere and/or just use the names directly when needed Message.MetaCategories = { System : 1, // ENiGMA1/2 stuff FtnProperty : 2, // Various FTN network properties, ftn_cost, ftn_origin, ... @@ -102,18 +78,27 @@ Message.MetaCategories = { Message.SystemMetaNames = { LocalToUserID : 'local_to_user_id', LocalFromUserID : 'local_from_user_id', + StateFlags0 : 'state_flags0', // See Message.StateFlags0 }; -Message.FtnPropertyNames = { - FtnCost : 'ftn_cost', +Message.StateFlags0 = { + None : 0x00000000, + Imported : 0x00000001, // imported from foreign system + Exported : 0x00000002, // exported to foreign system +}; + +Message.FtnPropertyNames = { FtnOrigNode : 'ftn_orig_node', FtnDestNode : 'ftn_dest_node', FtnOrigNetwork : 'ftn_orig_network', FtnDestNetwork : 'ftn_dest_network', + FtnAttrFlags : 'ftn_attr_flags', + FtnCost : 'ftn_cost', FtnOrigZone : 'ftn_orig_zone', FtnDestZone : 'ftn_dest_zone', FtnOrigPoint : 'ftn_orig_point', FtnDestPoint : 'ftn_dest_point', + FtnAttribute : 'ftn_attribute', FtnTearLine : 'ftn_tear_line', // http://ftsc.org/docs/fts-0004.001 @@ -132,6 +117,124 @@ Message.prototype.setLocalFromUserId = function(userId) { this.meta.System.local_from_user_id = userId; }; +Message.getMessageIdByUuid = function(uuid, cb) { + msgDb.get( + `SELECT message_id + FROM message + WHERE message_uuid = ? + LIMIT 1;`, + [ uuid ], + (err, row) => { + if(err) { + cb(err); + } else { + const success = (row && row.message_id); + cb(success ? null : new Error('No match'), success ? row.message_id : null); + } + } + ); +}; + +Message.getMessageIdsByMetaValue = function(category, name, value, cb) { + msgDb.all( + `SELECT message_id + FROM message_meta + WHERE meta_category = ? AND meta_name = ? AND meta_value = ?;`, + [ category, name, value ], + (err, rows) => { + if(err) { + cb(err); + } else { + cb(null, rows.map(r => parseInt(r.message_id))); // return array of ID(s) + } + } + ); +}; + +Message.getMetaValuesByMessageId = function(messageId, category, name, cb) { + const sql = + `SELECT meta_value + FROM message_meta + WHERE message_id = ? AND meta_category = ? AND meta_name = ?;`; + + msgDb.all(sql, [ messageId, category, name ], (err, rows) => { + if(err) { + return cb(err); + } + + if(0 === rows.length) { + return cb(new Error('No value for category/name')); + } + + // single values are returned without an array + if(1 === rows.length) { + return cb(null, rows[0].meta_value); + } + + cb(null, rows.map(r => r.meta_value)); // map to array of values only + }); +}; + +Message.getMetaValuesByMessageUuid = function(uuid, category, name, cb) { + async.waterfall( + [ + function getMessageId(callback) { + Message.getMessageIdByUuid(uuid, (err, messageId) => { + callback(err, messageId); + }); + }, + function getMetaValues(messageId, callback) { + Message.getMetaValuesByMessageId(messageId, category, name, (err, values) => { + callback(err, values); + }); + } + ], + (err, values) => { + cb(err, values); + } + ); +}; + +Message.prototype.loadMeta = function(cb) { + /* + Example of loaded this.meta: + + meta: { + System: { + local_to_user_id: 1234, + }, + FtnProperty: { + ftn_seen_by: [ "1/102 103", "2/42 52 65" ] + } + } + */ + + const sql = + `SELECT meta_category, meta_name, meta_value + FROM message_meta + WHERE message_id = ?;`; + + let self = this; + msgDb.each(sql, [ this.messageId ], (err, row) => { + if(!(row.meta_category in self.meta)) { + self.meta[row.meta_category] = { }; + self.meta[row.meta_category][row.meta_name] = row.meta_value; + } else { + if(!(row.meta_name in self.meta[row.meta_category])) { + self.meta[row.meta_category][row.meta_name] = row.meta_value; + } else { + if(_.isString(self.meta[row.meta_category][row.meta_name])) { + self.meta[row.meta_category][row.meta_name] = [ self.meta[row.meta_category][row.meta_name] ]; + } + + self.meta[row.meta_category][row.meta_name].push(row.meta_value); + } + } + }, err => { + cb(err); + }); +}; + Message.prototype.load = function(options, cb) { assert(_.isString(options.uuid)); @@ -141,7 +244,7 @@ Message.prototype.load = function(options, cb) { [ function loadMessage(callback) { msgDb.get( - 'SELECT message_id, area_name, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, ' + + 'SELECT message_id, area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, ' + 'message, modified_timestamp, view_count ' + 'FROM message ' + 'WHERE message_uuid=? ' + @@ -149,14 +252,14 @@ Message.prototype.load = function(options, cb) { [ options.uuid ], function row(err, msgRow) { self.messageId = msgRow.message_id; - self.areaName = msgRow.area_name; + self.areaTag = msgRow.area_tag; self.messageUuid = msgRow.message_uuid; self.replyToMsgId = msgRow.reply_to_message_id; self.toUserName = msgRow.to_user_name; self.fromUserName = msgRow.from_user_name; self.subject = msgRow.subject; self.message = msgRow.message; - self.modTimestamp = msgRow.modified_timestamp; + self.modTimestamp = moment(msgRow.modified_timestamp); self.viewCount = msgRow.view_count; callback(err); @@ -164,18 +267,13 @@ Message.prototype.load = function(options, cb) { ); }, function loadMessageMeta(callback) { - // :TODO: - callback(null); + self.loadMeta(err => { + callback(err); + }); }, function loadHashTags(callback) { // :TODO: callback(null); - }, - function loadMessageStatus(callback) { - if(options.user) { - // :TODO: Load from user_message_status - } - callback(null); } ], function complete(err) { @@ -184,27 +282,59 @@ Message.prototype.load = function(options, cb) { ); }; +Message.prototype.persistMetaValue = function(category, name, value, cb) { + const metaStmt = msgDb.prepare( + `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) + VALUES (?, ?, ?, ?);`); + + if(!_.isArray(value)) { + value = [ value ]; + } + + let self = this; + + async.each(value, (v, next) => { + metaStmt.run(self.messageId, category, name, v, err => { + next(err); + }); + }, err => { + cb(err); + }); +}; + +Message.startTransaction = function(cb) { + msgDb.run('BEGIN;', err => { + cb(err); + }); +}; + +Message.endTransaction = function(hadError, cb) { + msgDb.run(hadError ? 'ROLLBACK;' : 'COMMIT;', err => { + cb(err); + }); +}; + Message.prototype.persist = function(cb) { if(!this.isValid()) { - cb(new Error('Cannot persist invalid message!')); - return; + return cb(new Error('Cannot persist invalid message!')); } - var self = this; - + let self = this; + async.series( [ function beginTransaction(callback) { - msgDb.run('BEGIN;', function transBegin(err) { + Message.startTransaction(err => { callback(err); }); }, function storeMessage(callback) { msgDb.run( - 'INSERT INTO message (area_name, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) ' + - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?);', [ self.areaName, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, self.getMessageTimestampString(self.modTimestamp) ], - function msgInsert(err) { + `INSERT INTO message (area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, + [ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, self.getMessageTimestampString(self.modTimestamp) ], + function inserted(err) { // use for this scope if(!err) { self.messageId = this.lastID; } @@ -217,26 +347,30 @@ Message.prototype.persist = function(cb) { if(!self.meta) { callback(null); } else { - // :TODO: this should be it's own method such that meta can be updated - var metaStmt = msgDb.prepare( - 'INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) ' + - 'VALUES (?, ?, ?, ?);'); - - for(var metaCategroy in self.meta) { - async.each(Object.keys(self.meta[metaCategroy]), function meta(metaName, next) { - metaStmt.run(self.messageId, Message.MetaCategories[metaCategroy], metaName, self.meta[metaCategroy][metaName], function inserted(err) { - next(err); - }); - }, function complete(err) { - if(!err) { - metaStmt.finalize(function finalized() { - callback(null); - }); - } else { - callback(err); + /* + Example of self.meta: + + meta: { + System: { + local_to_user_id: 1234, + }, + FtnProperty: { + ftn_seen_by: [ "1/102 103", "2/42 52 65" ] } + } + */ + async.each(Object.keys(self.meta), (category, nextCat) => { + async.each(Object.keys(self.meta[category]), (name, nextName) => { + self.persistMetaValue(category, name, self.meta[category][name], err => { + nextName(err); + }); + }, err => { + nextCat(err); }); - } + + }, err => { + callback(err); + }); } }, function storeHashTags(callback) { @@ -244,9 +378,9 @@ Message.prototype.persist = function(cb) { callback(null); } ], - function complete(err) { - msgDb.run(err ? 'ROLLBACK;' : 'COMMIT;', function transEnd(err) { - cb(err, self.messageId); + err => { + Message.endTransaction(err, transErr => { + cb(err ? err : transErr, self.messageId); }); } ); diff --git a/core/message_area.js b/core/message_area.js index 53aec52b..e2e73f48 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -1,104 +1,290 @@ /* jslint node: true */ 'use strict'; -var msgDb = require('./database.js').dbs.message; -var Config = require('./config.js').config; -var Message = require('./message.js'); -var Log = require('./logger.js').log; +let msgDb = require('./database.js').dbs.message; +let Config = require('./config.js').config; +let Message = require('./message.js'); +let Log = require('./logger.js').log; +let checkAcs = require('./acs_util.js').checkAcs; +let msgNetRecord = require('./msg_network.js').recordMessage; -var async = require('async'); -var _ = require('lodash'); -var assert = require('assert'); +let async = require('async'); +let _ = require('lodash'); +let assert = require('assert'); -exports.getAvailableMessageAreas = getAvailableMessageAreas; -exports.getDefaultMessageArea = getDefaultMessageArea; -exports.getMessageAreaByName = getMessageAreaByName; +exports.getAvailableMessageConferences = getAvailableMessageConferences; +exports.getSortedAvailMessageConferences = getSortedAvailMessageConferences; +exports.getAvailableMessageAreasByConfTag = getAvailableMessageAreasByConfTag; +exports.getSortedAvailMessageAreasByConfTag = getSortedAvailMessageAreasByConfTag; +exports.getDefaultMessageConferenceTag = getDefaultMessageConferenceTag; +exports.getDefaultMessageAreaTagByConfTag = getDefaultMessageAreaTagByConfTag; +exports.getMessageConferenceByTag = getMessageConferenceByTag; +exports.getMessageAreaByTag = getMessageAreaByTag; +exports.changeMessageConference = changeMessageConference; exports.changeMessageArea = changeMessageArea; exports.getMessageListForArea = getMessageListForArea; exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; exports.getMessageAreaLastReadId = getMessageAreaLastReadId; exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId; +exports.persistMessage = persistMessage; -function getAvailableMessageAreas(options) { - // example: [ { "name" : "local_music", "desc" : "Music Discussion", "groups" : ["somegroup"] }, ... ] - options = options || {}; +const CONF_AREA_RW_ACS_DEFAULT = 'GM[users]'; +const AREA_MANAGE_ACS_DEFAULT = 'GM[sysops]'; - var areas = Config.messages.areas; - var avail = []; - for(var i = 0; i < areas.length; ++i) { - if(true !== options.includePrivate && - Message.WellKnownAreaNames.Private === areas[i].name) - { - continue; - } +const AREA_ACS_DEFAULT = { + read : CONF_AREA_RW_ACS_DEFAULT, + write : CONF_AREA_RW_ACS_DEFAULT, + manage : AREA_MANAGE_ACS_DEFAULT, +}; - avail.push(areas[i]); - } - - return avail; +function getAvailableMessageConferences(client, options) { + options = options || { includeSystemInternal : false }; + + // perform ACS check per conf & omit system_internal if desired + return _.omit(Config.messageConferences, (v, k) => { + if(!options.includeSystemInternal && 'system_internal' === k) { + return true; + } + + const readAcs = v.acs || CONF_AREA_RW_ACS_DEFAULT; + return !checkAcs(client, readAcs); + }); } -function getDefaultMessageArea() { - // - // Return first non-private/etc. area name. This will be from config.hjson - // - return getAvailableMessageAreas()[0]; - /* - var avail = getAvailableMessageAreas(); - for(var i = 0; i < avail.length; ++i) { - if(Message.WellKnownAreaNames.Private !== avail[i].name) { - return avail[i]; - } - } - */ -} - -function getMessageAreaByName(areaName) { - areaName = areaName.toLowerCase(); - - var availAreas = getAvailableMessageAreas( { includePrivate : true } ); - var index = _.findIndex(availAreas, function pred(an) { - return an.name == areaName; +function getSortedAvailMessageConferences(client, options) { + const sorted = _.map(getAvailableMessageConferences(client, options), (v, k) => { + return { + confTag : k, + conf : v, + }; + }); + + sorted.sort((a, b) => { + const keyA = a.conf.sort ? a.conf.sort.toString() : a.conf.name; + const keyB = b.conf.sort ? b.conf.sort.toString() : b.conf.name; + return keyA.localeCompare(keyB); }); - if(index > -1) { - return availAreas[index]; - } + return sorted; } -function changeMessageArea(client, areaName, cb) { +// Return an *object* of available areas within |confTag| +function getAvailableMessageAreasByConfTag(confTag, options) { + options = options || {}; + + // :TODO: confTag === "" then find default + + if(_.has(Config.messageConferences, [ confTag, 'areas' ])) { + const areas = Config.messageConferences[confTag].areas; + + if(!options.client || true === options.noAcsCheck) { + // everything - no ACS checks + return areas; + } else { + // perform ACS check per area + return _.omit(areas, (v, k) => { + const readAcs = _.has(v, 'acs.read') ? v.acs.read : CONF_AREA_RW_ACS_DEFAULT; + return !checkAcs(options.client, readAcs); + }); + } + } +} + +function getSortedAvailMessageAreasByConfTag(confTag, options) { + const areas = _.map(getAvailableMessageAreasByConfTag(confTag, options), (v, k) => { + return { + areaTag : k, + area : v, + } + }); + + areas.sort((a, b) => { + const keyA = a.area.sort ? a.area.sort.toString() : a.area.name; + const keyB = b.area.sort ? b.area.sort.toString() : b.area.name; + return keyA.localeCompare(keyB); + }); + + return areas; +} + +function getDefaultMessageConferenceTag(client, disableAcsCheck) { + // + // Find the first conference marked 'default'. If found, + // inspect |client| against *read* ACS using defaults if not + // specified. + // + // If the above fails, just go down the list until we get one + // that passes. + // + // It's possible that we end up with nothing here! + // + // Note that built in 'system_internal' is always ommited here + // + let defaultConf = _.findKey(Config.messageConferences, o => o.default); + if(defaultConf) { + const acs = Config.messageConferences[defaultConf].acs || CONF_AREA_RW_ACS_DEFAULT; + if(true === disableAcsCheck || checkAcs(client, acs)) { + return defaultConf; + } + } + + // just use anything we can + defaultConf = _.findKey(Config.messageConferences, (o, k) => { + const acs = o.acs || CONF_AREA_RW_ACS_DEFAULT; + return 'system_internal' !== k && (true === disableAcsCheck || checkAcs(client, acs)); + }); + + return defaultConf; +} + +function getDefaultMessageAreaTagByConfTag(client, confTag, disableAcsCheck) { + // + // Similar to finding the default conference: + // Find the first entry marked 'default', if any. If found, check | client| against + // *read* ACS. If this fails, just find the first one we can that passes checks. + // + // It's possible that we end up with nothing! + // + confTag = confTag || getDefaultMessageConferenceTag(client); + + if(confTag && _.has(Config.messageConferences, [ confTag, 'areas' ])) { + const areaPool = Config.messageConferences[confTag].areas; + let defaultArea = _.findKey(areaPool, o => o.default); + if(defaultArea) { + const readAcs = _.has(areaPool, [ defaultArea, 'acs', 'read' ]) ? areaPool[defaultArea].acs.read : AREA_ACS_DEFAULT.read; + if(true === disableAcsCheck || checkAcs(client, readAcs)) { + return defaultArea; + } + } + + defaultArea = _.findKey(areaPool, (o, k) => { + const readAcs = _.has(areaPool, [ defaultArea, 'acs', 'read' ]) ? areaPool[defaultArea].acs.read : AREA_ACS_DEFAULT.read; + return (true === disableAcsCheck || checkAcs(client, readAcs)); + }); + + return defaultArea; + } +} + +function getMessageConferenceByTag(confTag) { + return Config.messageConferences[confTag]; +} + +function getMessageAreaByTag(areaTag, optionalConfTag) { + const confs = Config.messageConferences; + + if(_.isString(optionalConfTag)) { + if(_.has(confs, [ optionalConfTag, 'areas', areaTag ])) { + return confs[optionalConfTag].areas[areaTag]; + } + } else { + // + // No confTag to work with - we'll have to search through them all + // + var area; + _.forEach(confs, (v, k) => { + if(_.has(v, [ 'areas', areaTag ])) { + area = v.areas[areaTag]; + return false; // stop iteration + } + }); + + return area; + } +} + +function changeMessageConference(client, confTag, cb) { + async.waterfall( + [ + function getConf(callback) { + const conf = getMessageConferenceByTag(confTag); + + if(conf) { + callback(null, conf); + } else { + callback(new Error('Invalid message conference tag')); + } + }, + function getDefaultAreaInConf(conf, callback) { + const areaTag = getDefaultMessageAreaTagByConfTag(client, confTag); + const area = getMessageAreaByTag(areaTag, confTag); + + if(area) { + callback(null, conf, { areaTag : areaTag, area : area } ); + } else { + callback(new Error('No available areas for this user in conference')); + } + }, + function validateAccess(conf, areaInfo, callback) { + const confAcs = conf.acs || CONF_AREA_RW_ACS_DEFAULT; + + if(!checkAcs(client, confAcs)) { + callback(new Error('User does not have access to this conference')); + } else { + const areaAcs = _.has(areaInfo, 'area.acs.read') ? areaInfo.area.acs.read : CONF_AREA_RW_ACS_DEFAULT; + if(!checkAcs(client, areaAcs)) { + callback(new Error('User does not have access to default area in this conference')); + } else { + callback(null, conf, areaInfo); + } + } + }, + function changeConferenceAndArea(conf, areaInfo, callback) { + const newProps = { + message_conf_tag : confTag, + message_area_tag : areaInfo.areaTag, + }; + client.user.persistProperties(newProps, err => { + callback(err, conf, areaInfo); + }); + }, + ], + function complete(err, conf, areaInfo) { + if(!err) { + client.log.info( { confTag : confTag, confName : conf.name, areaTag : areaInfo.areaTag }, 'Current message conference changed'); + } else { + client.log.warn( { confTag : confTag, error : err.message }, 'Could not change message conference'); + } + cb(err); + } + ); +} + +function changeMessageArea(client, areaTag, cb) { async.waterfall( [ function getArea(callback) { - var area = getMessageAreaByName(areaName); + const area = getMessageAreaByTag(areaTag); if(area) { callback(null, area); } else { - callback(new Error('Invalid message area')); + callback(new Error('Invalid message area tag')); } }, function validateAccess(area, callback) { - if(_.isArray(area.groups) && ! - client.user.isGroupMember(area.groups)) - { + // + // Need at least *read* to access the area + // + const readAcs = _.has(area, 'acs.read') ? area.acs.read : CONF_AREA_RW_ACS_DEFAULT; + if(!checkAcs(client, readAcs)) { callback(new Error('User does not have access to this area')); } else { callback(null, area); } }, function changeArea(area, callback) { - client.user.persistProperty('message_area_name', area.name, function persisted(err) { + client.user.persistProperty('message_area_tag', areaTag, function persisted(err) { callback(err, area); }); } ], function complete(err, area) { if(!err) { - client.log.info( area, 'Current message area changed'); + client.log.info( { areaTag : areaTag, area : area }, 'Current message area changed'); } else { - client.log.warn( { area : area, error : err.message }, 'Could not change message area'); + client.log.warn( { areaTag : areaTag, area : area, error : err.message }, 'Could not change message area'); } cb(err); @@ -119,9 +305,9 @@ function getMessageFromRow(row) { }; } -function getNewMessagesInAreaForUser(userId, areaName, cb) { +function getNewMessagesInAreaForUser(userId, areaTag, cb) { // - // If |areaName| is Message.WellKnownAreaNames.Private, + // If |areaTag| is Message.WellKnownAreaTags.Private, // only messages addressed to |userId| should be returned. // // Only messages > lastMessageId should be returned @@ -131,7 +317,7 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { async.waterfall( [ function getLastMessageId(callback) { - getMessageAreaLastReadId(userId, areaName, function fetched(err, lastMessageId) { + getMessageAreaLastReadId(userId, areaTag, function fetched(err, lastMessageId) { callback(null, lastMessageId || 0); // note: willingly ignoring any errors here! }); }, @@ -139,9 +325,9 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { var sql = 'SELECT message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count ' + 'FROM message ' + - 'WHERE area_name="' + areaName + '" AND message_id > ' + lastMessageId; + 'WHERE area_tag ="' + areaTag + '" AND message_id > ' + lastMessageId; - if(Message.WellKnownAreaNames.Private === areaName) { + if(Message.WellKnownAreaTags.Private === areaTag) { sql += ' AND message_id in (' + 'SELECT message_id from message_meta where meta_category=' + Message.MetaCategories.System + @@ -150,8 +336,6 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { sql += ' ORDER BY message_id;'; - console.log(sql) - msgDb.each(sql, function msgRow(err, row) { if(!err) { msgList.push(getMessageFromRow(row)); @@ -160,18 +344,17 @@ function getNewMessagesInAreaForUser(userId, areaName, cb) { } ], function complete(err) { - console.log(msgList) cb(err, msgList); } ); } -function getMessageListForArea(options, areaName, cb) { +function getMessageListForArea(options, areaTag, cb) { // // options.client (required) // - options.client.log.debug( { areaName : areaName }, 'Fetching available messages'); + options.client.log.debug( { areaTag : areaTag }, 'Fetching available messages'); assert(_.isObject(options.client)); @@ -193,9 +376,9 @@ function getMessageListForArea(options, areaName, cb) { msgDb.each( 'SELECT message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count ' + 'FROM message ' + - 'WHERE area_name=? ' + + 'WHERE area_tag = ? ' + 'ORDER BY message_id;', - [ areaName.toLowerCase() ], + [ areaTag.toLowerCase() ], function msgRow(err, row) { if(!err) { msgList.push(getMessageFromRow(row)); @@ -214,24 +397,24 @@ function getMessageListForArea(options, areaName, cb) { ); } -function getMessageAreaLastReadId(userId, areaName, cb) { +function getMessageAreaLastReadId(userId, areaTag, cb) { msgDb.get( 'SELECT message_id ' + 'FROM user_message_area_last_read ' + - 'WHERE user_id = ? AND area_name = ?;', - [ userId, areaName ], + 'WHERE user_id = ? AND area_tag = ?;', + [ userId, areaTag ], function complete(err, row) { cb(err, row ? row.message_id : 0); } ); } -function updateMessageAreaLastReadId(userId, areaName, messageId, cb) { +function updateMessageAreaLastReadId(userId, areaTag, messageId, cb) { // :TODO: likely a better way to do this... async.waterfall( [ function getCurrent(callback) { - getMessageAreaLastReadId(userId, areaName, function result(err, lastId) { + getMessageAreaLastReadId(userId, areaTag, function result(err, lastId) { lastId = lastId || 0; callback(null, lastId); // ignore errors as we default to 0 }); @@ -239,27 +422,45 @@ function updateMessageAreaLastReadId(userId, areaName, messageId, cb) { function update(lastId, callback) { if(messageId > lastId) { msgDb.run( - 'REPLACE INTO user_message_area_last_read (user_id, area_name, message_id) ' + + 'REPLACE INTO user_message_area_last_read (user_id, area_tag, message_id) ' + 'VALUES (?, ?, ?);', - [ userId, areaName, messageId ], - callback + [ userId, areaTag, messageId ], + function written(err) { + callback(err, true); // true=didUpdate + } ); } else { callback(null); } } ], - function complete(err) { + function complete(err, didUpdate) { if(err) { Log.debug( - { error : err.toString(), userId : userId, areaName : areaName, messageId : messageId }, + { error : err.toString(), userId : userId, areaTag : areaTag, messageId : messageId }, 'Failed updating area last read ID'); } else { - Log.trace( - { userId : userId, areaName : areaName, messageId : messageId }, - 'Area last read ID updated'); + if(true === didUpdate) { + Log.trace( + { userId : userId, areaTag : areaTag, messageId : messageId }, + 'Area last read ID updated'); + } } cb(err); } ); } + +function persistMessage(message, cb) { + async.series( + [ + function persistMessageToDisc(callback) { + message.persist(callback); + }, + function recordToMessageNetworks(callback) { + msgNetRecord(message, callback); + } + ], + cb + ); +} \ No newline at end of file diff --git a/core/module_util.js b/core/module_util.js index 55dd61ed..931f8194 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -1,13 +1,16 @@ /* jslint node: true */ 'use strict'; -var Config = require('./config.js').config; -var miscUtil = require('./misc_util.js'); +// ENiGMA½ +let Config = require('./config.js').config; +let miscUtil = require('./misc_util.js'); -var fs = require('fs'); -var paths = require('path'); -var _ = require('lodash'); -var assert = require('assert'); +// standard/deps +let fs = require('fs'); +let paths = require('path'); +let _ = require('lodash'); +let assert = require('assert'); +let async = require('async'); // exports exports.loadModuleEx = loadModuleEx; @@ -19,33 +22,34 @@ function loadModuleEx(options, cb) { assert(_.isString(options.name)); assert(_.isString(options.path)); - var modConfig = _.isObject(Config[options.category]) ? Config[options.category][options.name] : null; + const modConfig = _.isObject(Config[options.category]) ? Config[options.category][options.name] : null; if(_.isObject(modConfig) && false === modConfig.enabled) { cb(new Error('Module "' + options.name + '" is disabled')); return; } + var mod; try { - var mod = require(paths.join(options.path, options.name + '.js')); - - if(!_.isObject(mod.moduleInfo)) { - cb(new Error('Module is missing "moduleInfo" section')); - return; - } - - if(!_.isFunction(mod.getModule)) { - cb(new Error('Invalid or missing "getModule" method for module!')); - return; - } - - // Safe configuration, if any, for convience to the module - mod.runtime = { config : modConfig }; - - cb(null, mod); + mod = require(paths.join(options.path, options.name + '.js')); } catch(e) { cb(e); } + + if(!_.isObject(mod.moduleInfo)) { + cb(new Error('Module is missing "moduleInfo" section')); + return; + } + + if(!_.isFunction(mod.getModule)) { + cb(new Error('Invalid or missing "getModule" method for module!')); + return; + } + + // Ref configuration, if any, for convience to the module + mod.runtime = { config : modConfig }; + + cb(null, mod); } function loadModule(name, category, cb) { @@ -61,19 +65,26 @@ function loadModule(name, category, cb) { }); } -function loadModulesForCategory(category, cb) { - var path = Config.paths[category]; - - fs.readdir(path, function onFiles(err, files) { +function loadModulesForCategory(category, iterator, complete) { + + fs.readdir(Config.paths[category], (err, files) => { if(err) { - cb(err); - return; + return iterator(err); } - var filtered = files.filter(function onFilter(file) { return '.js' === paths.extname(file); }); - filtered.forEach(function onFile(file) { - var modName = paths.basename(file, '.js'); - loadModule(paths.basename(file, '.js'), category, cb); + const jsModules = files.filter(file => { + return '.js' === paths.extname(file); + }); + + async.each(jsModules, (file, next) => { + loadModule(paths.basename(file, '.js'), category, (err, mod) => { + iterator(err, mod); + next(); + }); + }, err => { + if(complete) { + complete(err); + } }); }); } diff --git a/core/msg_network.js b/core/msg_network.js new file mode 100644 index 00000000..030c544e --- /dev/null +++ b/core/msg_network.js @@ -0,0 +1,59 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +let loadModulesForCategory = require('./module_util.js').loadModulesForCategory; + +// standard/deps +let async = require('async'); + +exports.startup = startup +exports.shutdown = shutdown; +exports.recordMessage = recordMessage; + +let msgNetworkModules = []; + +function startup(cb) { + async.series( + [ + function loadModules(callback) { + loadModulesForCategory('scannerTossers', (err, module) => { + if(!err) { + const modInst = new module.getModule(); + + modInst.startup(err => { + if(!err) { + msgNetworkModules.push(modInst); + } + }); + } + }, err => { + callback(err); + }); + } + ], + cb + ); +} + +function shutdown() { + msgNetworkModules.forEach(mod => { + mod.shutdown(); + }); + + msgNetworkModules = []; +} + +function recordMessage(message, cb) { + // + // Give all message network modules (scanner/tossers) + // a chance to do something with |message|. Any or all can + // choose to ignore it. + // + async.each(msgNetworkModules, (modInst, next) => { + modInst.record(message); + next(); + }, err => { + cb(err); + }); +} \ No newline at end of file diff --git a/core/msg_scan_toss_module.js b/core/msg_scan_toss_module.js new file mode 100644 index 00000000..8172d77f --- /dev/null +++ b/core/msg_scan_toss_module.js @@ -0,0 +1,24 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +var PluginModule = require('./plugin_module.js').PluginModule; + +exports.MessageScanTossModule = MessageScanTossModule; + +function MessageScanTossModule() { + PluginModule.call(this); +} + +require('util').inherits(MessageScanTossModule, PluginModule); + +MessageScanTossModule.prototype.startup = function(cb) { + cb(null); +}; + +MessageScanTossModule.prototype.shutdown = function(cb) { + cb(null); +}; + +MessageScanTossModule.prototype.record = function(message) { +}; \ No newline at end of file diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js index cfc6341f..63c5e20b 100644 --- a/core/multi_line_edit_text_view.js +++ b/core/multi_line_edit_text_view.js @@ -266,24 +266,21 @@ function MultiLineEditTextView(options) { } return lines; }; - - this.getOutputText = function(startIndex, endIndex, includeEol) { - var lines = self.getTextLines(startIndex, endIndex); - - // - // Convert lines to contiguous string -- all expanded - // tabs put back to single '\t' characters. - // - var text = ''; - var re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g'); - for(var i = 0; i < lines.length; ++i) { - text += lines[i].text.replace(re, '\t'); - if(includeEol && lines[i].eol) { - text += '\n'; - } - } - return text; - }; + + this.getOutputText = function(startIndex, endIndex, eolMarker) { + let lines = self.getTextLines(startIndex, endIndex); + let text = ''; + var re = new RegExp('\\t{1,' + (self.tabWidth) + '}', 'g'); + + lines.forEach(line => { + text += line.text.replace(re, '\t'); + if(eolMarker && line.eol) { + text += eolMarker; + } + }); + + return text; + } this.getContiguousText = function(startIndex, endIndex, includeEol) { var lines = self.getTextLines(startIndex, endIndex); @@ -1018,7 +1015,7 @@ MultiLineEditTextView.prototype.addText = function(text) { }; MultiLineEditTextView.prototype.getData = function() { - return this.getOutputText(0, this.textLines.length, true); + return this.getOutputText(0, this.textLines.length, '\r\n'); }; MultiLineEditTextView.prototype.setPropertyValue = function(propName, value) { diff --git a/core/new_scan.js b/core/new_scan.js index 3201402a..2b1f8a1f 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -7,6 +7,7 @@ var Message = require('./message.js'); var MenuModule = require('./menu_module.js').MenuModule; var ViewController = require('../core/view_controller.js').ViewController; +var _ = require('lodash'); var async = require('async'); exports.moduleInfo = { @@ -36,10 +37,11 @@ function NewScanModule(options) { var self = this; var config = this.menuConfig.config; - this.currentStep = 'messageAreas'; - this.currentScanAux = 0; // e.g. Message.WellKnownAreaNames.Private when currentSteps = messageAreas + this.currentStep = 'messageConferences'; + this.currentScanAux = {}; - this.scanStartFmt = config.scanStartFmt || 'Scanning {desc}...'; + // :TODO: Make this conf/area specific: + this.scanStartFmt = config.scanStartFmt || 'Scanning {confName} - {areaName}...'; this.scanFinishNoneFmt = config.scanFinishNoneFmt || 'Nothing new'; this.scanFinishNewFmt = config.scanFinishNewFmt || '{count} entries found'; this.scanCompleteMsg = config.scanCompleteMsg || 'Finished newscan'; @@ -57,10 +59,65 @@ function NewScanModule(options) { if(view) { } }; + + this.newScanMessageConference = function(cb) { + // lazy init + if(!self.sortedMessageConfs) { + const getAvailOpts = { includeSystemInternal : true }; // find new private messages, bulletins, etc. - this.newScanMessageArea = function(cb) { - var availMsgAreas = msgArea.getAvailableMessageAreas( { includePrivate : true } ); - var currentArea = availMsgAreas[self.currentScanAux]; + self.sortedMessageConfs = _.map(msgArea.getAvailableMessageConferences(self.client, getAvailOpts), (v, k) => { + return { + confTag : k, + conf : v, + }; + }); + + // + // Sort conferences by name, other than 'system_internal' which should + // always come first such that we display private mails/etc. before + // other conferences & areas + // + self.sortedMessageConfs.sort((a, b) => { + if('system_internal' === a.confTag) { + return -1; + } else { + return a.conf.name.localeCompare(b.conf.name); + } + }); + + self.currentScanAux.conf = self.currentScanAux.conf || 0; + self.currentScanAux.area = self.currentScanAux.area || 0; + } + + const currentConf = self.sortedMessageConfs[self.currentScanAux.conf]; + + async.series( + [ + function scanArea(callback) { + //self.currentScanAux.area = self.currentScanAux.area || 0; + + self.newScanMessageArea(currentConf, function areaScanComplete(err) { + if(self.sortedMessageConfs.length > self.currentScanAux.conf + 1) { + self.currentScanAux.conf += 1; + self.currentScanAux.area = 0; + + self.newScanMessageConference(cb); // recursive to next conf + //callback(null); + } else { + self.updateScanStatus(self.scanCompleteMsg); + callback(new Error('No more conferences')); + } + }); + } + ], + cb + ); + }; + + this.newScanMessageArea = function(conf, cb) { + // :TODO: it would be nice to cache this - must be done by conf! + const sortedAreas = msgArea.getSortedAvailMessageAreasByConfTag(conf.confTag, { client : self.client } ); + const currentArea = sortedAreas[self.currentScanAux.area]; // // Scan and update index until we find something. If results are found, @@ -70,8 +127,8 @@ function NewScanModule(options) { [ function checkAndUpdateIndex(callback) { // Advance to next area if possible - if(availMsgAreas.length >= self.currentScanAux + 1) { - self.currentScanAux += 1; + if(sortedAreas.length >= self.currentScanAux.area + 1) { + self.currentScanAux.area += 1; callback(null); } else { self.updateScanStatus(self.scanCompleteMsg); @@ -80,22 +137,30 @@ function NewScanModule(options) { }, function updateStatusScanStarted(callback) { self.updateScanStatus(self.scanStartFmt.format({ - desc : currentArea.desc, + confName : conf.conf.name, + confDesc : conf.conf.desc, + areaName : currentArea.area.name, + areaDesc : currentArea.area.desc, })); callback(null); }, function newScanAreaAndGetMessages(callback) { msgArea.getNewMessagesInAreaForUser( - self.client.user.userId, currentArea.name, function msgs(err, msgList) { + self.client.user.userId, currentArea.areaTag, function msgs(err, msgList) { if(!err) { if(0 === msgList.length) { self.updateScanStatus(self.scanFinishNoneFmt.format({ - desc : currentArea.desc, + confName : conf.conf.name, + confDesc : conf.conf.desc, + areaName : currentArea.area.name, + areaDesc : currentArea.area.desc, })); } else { self.updateScanStatus(self.scanFinishNewFmt.format({ - desc : currentArea.desc, - count : msgList.length, + confName : conf.conf.name, + confDesc : conf.conf.desc, + areaName : currentArea.area.name, + count : msgList.length, })); } } @@ -107,14 +172,14 @@ function NewScanModule(options) { if(msgList && msgList.length > 0) { var nextModuleOpts = { extraArgs: { - messageAreaName : currentArea.name, + messageAreaTag : currentArea.areaTag, messageList : msgList, } }; self.gotoMenu(config.newScanMessageList || 'newScanMessageList', nextModuleOpts); } else { - self.newScanMessageArea(cb); + self.newScanMessageArea(conf, cb); } } ], @@ -161,10 +226,10 @@ NewScanModule.prototype.mciReady = function(mciData, cb) { }, function performCurrentStepScan(callback) { switch(self.currentStep) { - case 'messageAreas' : - self.newScanMessageArea(function scanComplete(err) { - callback(null); // finished - }); + case 'messageConferences' : + self.newScanMessageConference(function scanComplete(err) { + callback(null); // finished + }); break; default : @@ -180,9 +245,3 @@ NewScanModule.prototype.mciReady = function(mciData, cb) { } ); }; - -/* -NewScanModule.prototype.finishedLoading = function() { - NewScanModule.super_.prototype.finishedLoading.call(this); -}; -*/ \ No newline at end of file diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 2d9d4a7d..b8d7bbbc 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -3,7 +3,7 @@ var Config = require('./config.js').config; var Log = require('./logger.js').log; -var getMessageAreaByName = require('./message_area.js').getMessageAreaByName; +var getMessageAreaByTag = require('./message_area.js').getMessageAreaByTag; var clientConnections = require('./client_connections.js'); var sysProp = require('./system_property.js'); @@ -63,10 +63,15 @@ function getPredefinedMCIValue(client, code) { return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : ''; }, - MA : function messageAreaDescription() { - var area = getMessageAreaByName(client.user.properties.message_area_name); - return area ? area.desc : ''; + MA : function messageAreaName() { + const area = getMessageAreaByTag(client.user.properties.message_area_tag); + return area ? area.name : ''; }, + + ML : function messageAreaDescription() { + const area = getMessageAreaByTag(client.user.properties.message_area_tag); + return area ? area.desc : ''; + }, SH : function termHeight() { return client.term.termHeight.toString(); }, SW : function termWidth() { return client.term.termWidth.toString(); }, diff --git a/core/sauce.js b/core/sauce.js new file mode 100644 index 00000000..0dad1bc9 --- /dev/null +++ b/core/sauce.js @@ -0,0 +1,172 @@ +/* jslint node: true */ +'use strict'; + +var binary = require('binary'); +var iconv = require('iconv-lite'); + +exports.readSAUCE = readSAUCE; + +const SAUCE_SIZE = 128; +const SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE' +const COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT' + +exports.SAUCE_SIZE = SAUCE_SIZE; +// :TODO: SAUCE should be a class +// - with getFontName() +// - ...other methods + +// +// See +// http://www.acid.org/info/sauce/sauce.htm +// +const SAUCE_VALID_DATA_TYPES = [0, 1, 2, 3, 4, 5, 6, 7, 8 ]; + +function readSAUCE(data, cb) { + if(data.length < SAUCE_SIZE) { + cb(new Error('No SAUCE record present')); + return; + } + + var offset = data.length - SAUCE_SIZE; + var sauceRec = data.slice(offset); + + binary.parse(sauceRec) + .buffer('id', 5) + .buffer('version', 2) + .buffer('title', 35) + .buffer('author', 20) + .buffer('group', 20) + .buffer('date', 8) + .word32lu('fileSize') + .word8('dataType') + .word8('fileType') + .word16lu('tinfo1') + .word16lu('tinfo2') + .word16lu('tinfo3') + .word16lu('tinfo4') + .word8('numComments') + .word8('flags') + .buffer('tinfos', 22) // SAUCE 00.5 + .tap(function onVars(vars) { + + if(!SAUCE_ID.equals(vars.id)) { + cb(new Error('No SAUCE record present')); + return; + } + + var ver = iconv.decode(vars.version, 'cp437'); + + if('00' !== ver) { + cb(new Error('Unsupported SAUCE version: ' + ver)); + return; + } + + if(-1 === SAUCE_VALID_DATA_TYPES.indexOf(vars.dataType)) { + cb(new Error('Unsupported SAUCE DataType: ' + vars.dataType)); + return; + } + + var sauce = { + id : iconv.decode(vars.id, 'cp437'), + version : iconv.decode(vars.version, 'cp437').trim(), + title : iconv.decode(vars.title, 'cp437').trim(), + author : iconv.decode(vars.author, 'cp437').trim(), + group : iconv.decode(vars.group, 'cp437').trim(), + date : iconv.decode(vars.date, 'cp437').trim(), + fileSize : vars.fileSize, + dataType : vars.dataType, + fileType : vars.fileType, + tinfo1 : vars.tinfo1, + tinfo2 : vars.tinfo2, + tinfo3 : vars.tinfo3, + tinfo4 : vars.tinfo4, + numComments : vars.numComments, + flags : vars.flags, + tinfos : vars.tinfos, + }; + + var dt = SAUCE_DATA_TYPES[sauce.dataType]; + if(dt && dt.parser) { + sauce[dt.name] = dt.parser(sauce); + } + + cb(null, sauce); + }); +} + +// :TODO: These need completed: +var SAUCE_DATA_TYPES = {}; +SAUCE_DATA_TYPES[0] = { name : 'None' }; +SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE }; +SAUCE_DATA_TYPES[2] = 'Bitmap'; +SAUCE_DATA_TYPES[3] = 'Vector'; +SAUCE_DATA_TYPES[4] = 'Audio'; +SAUCE_DATA_TYPES[5] = 'BinaryText'; +SAUCE_DATA_TYPES[6] = 'XBin'; +SAUCE_DATA_TYPES[7] = 'Archive'; +SAUCE_DATA_TYPES[8] = 'Executable'; + +var SAUCE_CHARACTER_FILE_TYPES = {}; +SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII'; +SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi'; +SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation'; +SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script'; +SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard'; +SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar'; +SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML'; +SAUCE_CHARACTER_FILE_TYPES[7] = 'Source'; +SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw'; + +// +// Map of SAUCE font -> encoding hint +// +// Note that this is the same mapping that x84 uses. Be compatible! +// +var SAUCE_FONT_TO_ENCODING_HINT = { + 'Amiga MicroKnight' : 'amiga', + 'Amiga MicroKnight+' : 'amiga', + 'Amiga mOsOul' : 'amiga', + 'Amiga P0T-NOoDLE' : 'amiga', + 'Amiga Topaz 1' : 'amiga', + 'Amiga Topaz 1+' : 'amiga', + 'Amiga Topaz 2' : 'amiga', + 'Amiga Topaz 2+' : 'amiga', + 'Atari ATASCII' : 'atari', + 'IBM EGA43' : 'cp437', + 'IBM EGA' : 'cp437', + 'IBM VGA25G' : 'cp437', + 'IBM VGA50' : 'cp437', + 'IBM VGA' : 'cp437', +}; + +['437', '720', '737', '775', '819', '850', '852', '855', '857', '858', +'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) { + var codec = 'cp' + page; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec; +}); + +function parseCharacterSAUCE(sauce) { + var result = {}; + + result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; + + if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) { + // convience: create ansiFlags + sauce.ansiFlags = sauce.flags; + + var i = 0; + while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { + ++i; + } + var fontName = iconv.decode(sauce.tinfos.slice(0, i), 'cp437'); + if(fontName.length > 0) { + result.fontName = fontName; + } + } + + return result; +} \ No newline at end of file diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js new file mode 100644 index 00000000..2582c279 --- /dev/null +++ b/core/scanner_tossers/ftn_bso.js @@ -0,0 +1,1272 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +let MessageScanTossModule = require('../msg_scan_toss_module.js').MessageScanTossModule; +let Config = require('../config.js').config; +let ftnMailPacket = require('../ftn_mail_packet.js'); +let ftnUtil = require('../ftn_util.js'); +let Address = require('../ftn_address.js'); +let Log = require('../logger.js').log; +let ArchiveUtil = require('../archive_util.js'); +let msgDb = require('../database.js').dbs.message; +let Message = require('../message.js'); + +let moment = require('moment'); +let _ = require('lodash'); +let paths = require('path'); +let mkdirp = require('mkdirp'); +let async = require('async'); +let fs = require('fs'); +let later = require('later'); +let temp = require('temp').track(); // track() cleans up temp dir/files for us +let assert = require('assert'); +let gaze = require('gaze'); + +exports.moduleInfo = { + name : 'FTN BSO', + desc : 'BSO style message scanner/tosser for FTN networks', + author : 'NuSkooler', +}; + +/* + :TODO: + * Support (approx) max bundle size + * Support NetMail + * NetMail needs explicit isNetMail() check + * NetMail filename / location / etc. is still unknown - need to post on groups & get real answers + +*/ + +exports.getModule = FTNMessageScanTossModule; + +const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:|@immediate)([^\0]+)?$/; + +function FTNMessageScanTossModule() { + MessageScanTossModule.call(this); + + let self = this; + + this.archUtil = new ArchiveUtil(); + this.archUtil.init(); + + + if(_.has(Config, 'scannerTossers.ftn_bso')) { + this.moduleConfig = Config.scannerTossers.ftn_bso; + + + } + + this.getDefaultNetworkName = function() { + if(this.moduleConfig.defaultNetwork) { + return this.moduleConfig.defaultNetwork; + } + + const networkNames = Object.keys(Config.messageNetworks.ftn.networks); + if(1 === networkNames.length) { + return networkNames[0]; + } + }; + + this.isDefaultDomainZone = function(networkName, address) { + const defaultNetworkName = this.getDefaultNetworkName(); + return(networkName === defaultNetworkName && address.zone === this.moduleConfig.defaultZone); + }; + + this.getNetworkNameByAddress = function(remoteAddress) { + return _.findKey(Config.messageNetworks.ftn.networks, network => { + const localAddress = Address.fromString(network.localAddress); + return !_.isUndefined(localAddress) && localAddress.isEqual(remoteAddress); + }); + }; + + this.getNetworkNameByAddressPattern = function(remoteAddressPattern) { + return _.findKey(Config.messageNetworks.ftn.networks, network => { + const localAddress = Address.fromString(network.localAddress); + return !_.isUndefined(localAddress) && localAddress.isPatternMatch(remoteAddressPattern); + }); + }; + + this.getLocalAreaTagByFtnAreaTag = function(ftnAreaTag) { + return _.findKey(Config.messageNetworks.ftn.areas, areaConf => { + return areaConf.tag === ftnAreaTag; + }); + }; + + this.getExportType = function(nodeConfig) { + return _.isString(nodeConfig.exportType) ? nodeConfig.exportType.toLowerCase() : 'crash'; + }; + + /* + this.getSeenByAddresses = function(messageSeenBy) { + if(!_.isArray(messageSeenBy)) { + messageSeenBy = [ messageSeenBy ]; + } + + let seenByAddrs = []; + messageSeenBy.forEach(sb => { + seenByAddrs = seenByAddrs.concat(ftnUtil.parseAbbreviatedNetNodeList(sb)); + }); + return seenByAddrs; + }; + */ + + this.messageHasValidMSGID = function(msg) { + return _.isString(msg.meta.FtnKludge.MSGID) && msg.meta.FtnKludge.MSGID.length > 0; + }; + + this.getOutgoingPacketDir = function(networkName, destAddress) { + let dir = this.moduleConfig.paths.outbound; + if(!this.isDefaultDomainZone(networkName, destAddress)) { + const hexZone = `000${destAddress.zone.toString(16)}`.substr(-3); + dir = paths.join(dir, `${networkName.toLowerCase()}.${hexZone}`); + } + return dir; + }; + + this.getOutgoingPacketFileName = function(basePath, messageId, isTemp) { + // + // Generating an outgoing packet file name comes with a few issues: + // * We must use DOS 8.3 filenames due to legacy systems that receive + // the packet not understanding LFNs + // * We need uniqueness; This is especially important with packets that + // end up in bundles and on the receiving/remote system where conflicts + // with other systems could also occur + // + // There are a lot of systems in use here for the name: + // * HEX CRC16/32 of data + // * HEX UNIX timestamp + // * Mystic at least at one point, used Hex8(day of month + seconds past midnight + hundredths of second) + // See https://groups.google.com/forum/#!searchin/alt.bbs.mystic/netmail$20filename/alt.bbs.mystic/m1xLnY8i1pU/YnG2excdl6MJ + // * SBBSEcho uses DDHHMMSS - see https://github.com/ftnapps/pkg-sbbs/blob/master/docs/fidonet.txt + // * We already have a system for 8-character serial number gernation that is + // used for e.g. in FTS-0009.001 MSGIDs... let's use that! + // + const name = ftnUtil.getMessageSerialNumber(messageId); + const ext = (true === isTemp) ? 'pk_' : 'pkt'; + return paths.join(basePath, `${name}.${ext}`); + }; + + this.getOutgoingFlowFileExtension = function(destAddress, flowType, exportType) { + let ext; + + switch(flowType) { + case 'mail' : ext = `${exportType.toLowerCase()[0]}ut`; break; + case 'ref' : ext = `${exportType.toLowerCase()[0]}lo`; break; + case 'busy' : ext = 'bsy'; break; + case 'request' : ext = 'req'; break; + case 'requests' : ext = 'hrq'; break; + } + + return ext; + }; + + this.getOutgoingFlowFileName = function(basePath, destAddress, flowType, exportType) { + let basename; + const ext = self.getOutgoingFlowFileExtension(destAddress, flowType, exportType); + + if(destAddress.point) { + + } else { + // + // Use |destAddress| nnnnNNNN.??? where nnnn is dest net and NNNN is dest + // node. This seems to match what Mystic does + // + basename = + `0000${destAddress.net.toString(16)}`.substr(-4) + + `0000${destAddress.node.toString(16)}`.substr(-4); + } + + return paths.join(basePath, `${basename}.${ext}`); + }; + + this.flowFileAppendRefs = function(filePath, fileRefs, directive, cb) { + const appendLines = fileRefs.reduce( (content, ref) => { + return content + `${directive}${ref}\n`; + }, ''); + + fs.appendFile(filePath, appendLines, err => { + cb(err); + }); + }; + + this.getOutgoingBundleFileName = function(basePath, sourceAddress, destAddress, cb) { + // + // Base filename is constructed as such: + // * If this |destAddress| is *not* a point address, we use NNNNnnnn where + // NNNN is 0 padded hex of dest net - source net and and nnnn is 0 padded + // hex of dest node - source node. + // * If |destAddress| is a point, NNNN becomes 0000 and nnnn becomes 'p' + + // 3 digit 0 padded hex point + // + // Extension is dd? where dd is Su...Mo and ? is 0...Z as collisions arise + // + let basename; + if(destAddress.point) { + const pointHex = `000${destAddress.point}`.substr(-3); + basename = `0000p${pointHex}`; + } else { + basename = + `0000${Math.abs(sourceAddress.net - destAddress.net).toString(16)}`.substr(-4) + + `0000${Math.abs(sourceAddress.node - destAddress.node).toString(16)}`.substr(-4); + } + + // + // We need to now find the first entry that does not exist starting + // with dd0 to ddz + // + const EXT_SUFFIXES = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); + let fileName = `${basename}.${moment().format('dd').toLowerCase()}`; + async.detectSeries(EXT_SUFFIXES, (suffix, callback) => { + const checkFileName = fileName + suffix; + fs.stat(paths.join(basePath, checkFileName), err => { + callback((err && 'ENOENT' === err.code) ? true : false); + }); + }, finalSuffix => { + if(finalSuffix) { + cb(null, paths.join(basePath, fileName + finalSuffix)); + } else { + cb(new Error('Could not acquire a bundle filename!')); + } + }); + }; + + this.prepareMessage = function(message, options) { + // + // Set various FTN kludges/etc. + // + message.meta.FtnProperty = message.meta.FtnProperty || {}; + message.meta.FtnKludge = message.meta.FtnKludge || {}; + + message.meta.FtnProperty.ftn_orig_node = options.network.localAddress.node; + message.meta.FtnProperty.ftn_dest_node = options.destAddress.node; + message.meta.FtnProperty.ftn_orig_network = options.network.localAddress.net; + message.meta.FtnProperty.ftn_dest_network = options.destAddress.net; + message.meta.FtnProperty.ftn_cost = 0; + message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); + + // :TODO: Need an explicit isNetMail() check + let ftnAttribute = + ftnMailPacket.Packet.Attribute.Local; // message from our system + + if(message.isPrivate()) { + ftnAttribute |= ftnMailPacket.Packet.Attribute.Private; + + // + // NetMail messages need a FRL-1005.001 "Via" line + // http://ftsc.org/docs/frl-1005.001 + // + if(_.isString(message.meta.FtnKludge.Via)) { + message.meta.FtnKludge.Via = [ message.meta.FtnKludge.Via ]; + } + message.meta.FtnKludge.Via = message.meta.FtnKludge.Via || []; + message.meta.FtnKludge.Via.push(ftnUtil.getVia(options.network.localAddress)); + } else { + // + // Set appropriate attribute flag for export type + // + switch(this.getExportType(options.nodeConfig)) { + case 'crash' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Crash; break; + case 'hold' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Hold; break; + // :TODO: Others? + } + + // + // EchoMail requires some additional properties & kludges + // + message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(options.network.localAddress); + message.meta.FtnProperty.ftn_area = Config.messageNetworks.ftn.areas[message.areaTag].tag; + + // + // When exporting messages, we should create/update SEEN-BY + // with remote address(s) we are exporting to. + // + const seenByAdditions = + [ `${options.network.localAddress.net}/${options.network.localAddress.node}` ].concat(Config.messageNetworks.ftn.areas[message.areaTag].uplinks); + message.meta.FtnProperty.ftn_seen_by = + ftnUtil.getUpdatedSeenByEntries(message.meta.FtnProperty.ftn_seen_by, seenByAdditions); + + // + // And create/update PATH for ourself + // + message.meta.FtnKludge.PATH = + ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, options.network.localAddress); + } + + message.meta.FtnProperty.ftn_attr_flags = ftnAttribute; + + // + // Additional kludges + // + // Check for existence of MSGID as we may already have stored it from a previous + // export that failed to finish + // + if(!message.meta.FtnKludge.MSGID) { + message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier(message, options.network.localAddress); + } + + message.meta.FtnKludge.TZUTC = ftnUtil.getUTCTimeZoneOffset(); + + // + // According to FSC-0046: + // + // "When a Conference Mail processor adds a TID to a message, it may not + // add a PID. An existing TID should, however, be replaced. TIDs follow + // the same format used for PIDs, as explained above." + // + message.meta.FtnKludge.TID = ftnUtil.getProductIdentifier(); + + // + // Determine CHRS and actual internal encoding name + // Try to preserve anything already here + let encoding = options.nodeConfig.encoding || 'utf8'; + if(message.meta.FtnKludge.CHRS) { + const encFromChars = ftnUtil.getEncodingFromCharacterSetIdentifier(message.meta.FtnKludge.CHRS); + if(encFromChars) { + encoding = encFromChars; + } + } + + options.encoding = encoding; // save for later + message.meta.FtnKludge.CHRS = ftnUtil.getCharacterSetIdentifierByEncoding(encoding); + // :TODO: FLAGS kludge? + }; + + this.setReplyKludgeFromReplyToMsgId = function(message, cb) { + // + // Look up MSGID kludge for |message.replyToMsgId|, if any. + // If found, we can create a REPLY kludge with the previously + // discovered MSGID. + // + + if(0 === message.replyToMsgId) { + return cb(null); // nothing to do + } + + Message.getMetaValuesByMessageId(message.replyToMsgId, 'FtnKludge', 'MSGID', (err, msgIdVal) => { + assert(_.isString(msgIdVal)); + + if(!err) { + // got a MSGID - create a REPLY + message.meta.FtnKludge.REPLY = msgIdVal; + } + + cb(null); // this method always passes + }); + }; + + // check paths, Addresses, etc. + this.isAreaConfigValid = function(areaConfig) { + if(!areaConfig || !_.isString(areaConfig.tag) || !_.isString(areaConfig.network)) { + return false; + } + + if(_.isString(areaConfig.uplinks)) { + areaConfig.uplinks = areaConfig.uplinks.split(' '); + } + + return (_.isArray(areaConfig.uplinks)); + }; + + + this.hasValidConfiguration = function() { + if(!_.has(this, 'moduleConfig.nodes') || !_.has(Config, 'messageNetworks.ftn.areas')) { + return false; + } + + // :TODO: need to check more! + + return true; + }; + + this.parseScheduleString = function(schedStr) { + if(!schedStr) { + return; // nothing to parse! + } + + let schedule = {}; + + const m = SCHEDULE_REGEXP.exec(schedStr); + if(m) { + schedStr = schedStr.substr(0, m.index).trim(); + + if('@watch:' === m[1]) { + schedule.watchFile = m[2]; + } else if('@immediate' === m[1]) { + schedule.immediate = true; + } + } + + if(schedStr.length > 0) { + const sched = later.parse.text(schedStr); + if(-1 === sched.error) { + schedule.sched = sched; + } + } + + // return undefined if we couldn't parse out anything useful + if(!_.isEmpty(schedule)) { + return schedule; + } + }; + + this.getAreaLastScanId = function(areaTag, cb) { + const sql = + `SELECT area_tag, message_id + FROM message_area_last_scan + WHERE scan_toss = "ftn_bso" AND area_tag = ? + LIMIT 1;`; + + msgDb.get(sql, [ areaTag ], (err, row) => { + cb(err, row ? row.message_id : 0); + }); + }; + + this.setAreaLastScanId = function(areaTag, lastScanId, cb) { + const sql = + `REPLACE INTO message_area_last_scan (scan_toss, area_tag, message_id) + VALUES ("ftn_bso", ?, ?);`; + + msgDb.run(sql, [ areaTag, lastScanId ], err => { + cb(err); + }); + }; + + this.getNodeConfigKeyByAddress = function(uplink) { + // :TODO: sort by least # of '*' & take top? + const nodeKey = _.filter(Object.keys(this.moduleConfig.nodes), addr => { + return Address.fromString(addr).isPatternMatch(uplink); + })[0]; + + return nodeKey; + }; + + this.exportMessagesByUuid = function(messageUuids, exportOpts, cb) { + // + // This method has a lot of madness going on: + // - Try to stuff messages into packets until we've hit the target size + // - We need to wait for write streams to finish before proceeding in many cases + // or data will be cut off when closing and creating a new stream + // + let exportedFiles = []; + let currPacketSize = self.moduleConfig.packetTargetByteSize; + let packet; + let ws; + let remainMessageBuf; + let remainMessageId; + const createTempPacket = !_.isString(exportOpts.nodeConfig.archiveType) || 0 === exportOpts.nodeConfig.archiveType.length; + + async.each(messageUuids, (msgUuid, nextUuid) => { + let message = new Message(); + + async.series( + [ + function finalizePrevious(callback) { + if(packet && currPacketSize >= self.moduleConfig.packetTargetByteSize) { + packet.writeTerminator(ws); + ws.end(); + ws.once('finish', () => { + callback(null); + }); + } else { + callback(null); + } + }, + function loadMessage(callback) { + message.load( { uuid : msgUuid }, err => { + if(err) { + return callback(err); + } + + // General preperation + self.prepareMessage(message, exportOpts); + + self.setReplyKludgeFromReplyToMsgId(message, err => { + callback(err); + }); + }); + }, + function createNewPacket(callback) { + if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { + packet = new ftnMailPacket.Packet(); + + const packetHeader = new ftnMailPacket.PacketHeader( + exportOpts.network.localAddress, + exportOpts.destAddress, + exportOpts.nodeConfig.packetType); + + packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; + + // use current message ID for filename seed + const pktFileName = self.getOutgoingPacketFileName(self.exportTempDir, message.messageId, createTempPacket); + exportedFiles.push(pktFileName); + + ws = fs.createWriteStream(pktFileName); + + currPacketSize = packet.writeHeader(ws, packetHeader); + + if(remainMessageBuf) { + currPacketSize += packet.writeMessageEntry(ws, remainMessageBuf); + remainMessageBuf = null; + } + } + + callback(null); + }, + function appendMessage(callback) { + const msgBuf = packet.getMessageEntryBuffer(message, exportOpts); + currPacketSize += msgBuf.length; + + if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { + remainMessageBuf = msgBuf; // save for next packet + remainMessageId = message.messageId; + } else { + ws.write(msgBuf); + } + callback(null); + }, + function storeStateFlags0Meta(callback) { + message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), err => { + callback(err); + }); + }, + function storeMsgIdMeta(callback) { + // + // We want to store some meta as if we had imported + // this message for later reference + // + if(message.meta.FtnKludge.MSGID) { + message.persistMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, err => { + callback(err); + }); + } else { + callback(null); + } + } + ], + err => { + nextUuid(err); + } + ); + }, err => { + if(err) { + cb(err); + } else { + async.series( + [ + function terminateLast(callback) { + if(packet) { + packet.writeTerminator(ws); + ws.end(); + ws.once('finish', () => { + callback(null); + }); + } else { + callback(null); + } + }, + function writeRemainPacket(callback) { + if(remainMessageBuf) { + // :TODO: DRY this with the code above -- they are basically identical + packet = new ftnMailPacket.Packet(); + + const packetHeader = new ftnMailPacket.PacketHeader( + exportOpts.network.localAddress, + exportOpts.destAddress, + exportOpts.nodeConfig.packetType); + + packetHeader.password = exportOpts.nodeConfig.packetPassword || ''; + + // use current message ID for filename seed + const pktFileName = self.getOutgoingPacketFileName(self.exportTempDir, remainMessageId, createTempPacket); + exportedFiles.push(pktFileName); + + ws = fs.createWriteStream(pktFileName); + + packet.writeHeader(ws, packetHeader); + ws.write(remainMessageBuf); + packet.writeTerminator(ws); + ws.end(); + ws.once('finish', () => { + callback(null); + }); + } else { + callback(null); + } + } + ], + err => { + cb(err, exportedFiles); + } + ); + } + }); + }; + + this.exportMessagesToUplinks = function(messageUuids, areaConfig, cb) { + async.each(areaConfig.uplinks, (uplink, nextUplink) => { + const nodeConfigKey = self.getNodeConfigKeyByAddress(uplink); + if(!nodeConfigKey) { + return nextUplink(); + } + + const exportOpts = { + nodeConfig : self.moduleConfig.nodes[nodeConfigKey], + network : Config.messageNetworks.ftn.networks[areaConfig.network], + destAddress : Address.fromString(uplink), + networkName : areaConfig.network, + }; + + if(_.isString(exportOpts.network.localAddress)) { + exportOpts.network.localAddress = Address.fromString(exportOpts.network.localAddress); + } + + const outgoingDir = self.getOutgoingPacketDir(exportOpts.networkName, exportOpts.destAddress); + const exportType = self.getExportType(exportOpts.nodeConfig); + + async.waterfall( + [ + function createOutgoingDir(callback) { + mkdirp(outgoingDir, err => { + callback(err); + }); + }, + function exportToTempArea(callback) { + self.exportMessagesByUuid(messageUuids, exportOpts, callback); + }, + function createArcMailBundle(exportedFileNames, callback) { + if(self.archUtil.haveArchiver(exportOpts.nodeConfig.archiveType)) { + // :TODO: support bundleTargetByteSize: + // + // Compress to a temp location then we'll move it in the next step + // + // Note that we must use the *final* output dir for getOutgoingBundleFileName() + // as it checks for collisions in bundle names! + // + self.getOutgoingBundleFileName(outgoingDir, exportOpts.network.localAddress, exportOpts.destAddress, (err, bundlePath) => { + if(err) { + return callback(err); + } + + // adjust back to temp path + const tempBundlePath = paths.join(self.exportTempDir, paths.basename(bundlePath)); + + self.archUtil.compressTo( + exportOpts.nodeConfig.archiveType, + tempBundlePath, + exportedFileNames, err => { + callback(err, [ tempBundlePath ] ); + } + ); + }); + } else { + callback(null, exportedFileNames); + } + }, + function moveFilesToOutgoing(exportedFileNames, callback) { + async.each(exportedFileNames, (oldPath, nextFile) => { + const ext = paths.extname(oldPath); + if('.pk_' === ext) { + // + // For a given temporary .pk_ file, we need to move it to the outoing + // directory with the appropriate BSO style filename. + // + const ext = self.getOutgoingFlowFileExtension( + exportOpts.destAddress, + 'mail', + exportType); + + const newPath = paths.join( + outgoingDir, + `${paths.basename(oldPath, 'pk_')}${ext}`); + + fs.rename(oldPath, newPath, nextFile); + } else { + const newPath = paths.join(outgoingDir, paths.basename(oldPath)); + fs.rename(oldPath, newPath, err => { + if(err) { + Log.warn( + { oldPath : oldPath, newPath : newPath }, + 'Failed moving temporary bundle file!'); + + return nextFile(); + } + + // + // For bundles, we need to append to the appropriate flow file + // + const flowFilePath = self.getOutgoingFlowFileName( + outgoingDir, + exportOpts.destAddress, + 'ref', + exportType); + + // directive of '^' = delete file after transfer + self.flowFileAppendRefs(flowFilePath, [ newPath ], '^', err => { + if(err) { + Log.warn( { path : flowFilePath }, 'Failed appending flow reference record!'); + } + nextFile(); + }); + }); + } + }, callback); + } + ], + err => { + // :TODO: do something with |err| ? + nextUplink(); + } + ); + }, cb); // complete + }; + + this.setReplyToMsgIdFtnReplyKludge = function(message, cb) { + // + // Given a FTN REPLY kludge, set |message.replyToMsgId|, if possible, + // by looking up an associated MSGID kludge meta. + // + // See also: http://ftsc.org/docs/fts-0009.001 + // + if(!_.isString(message.meta.FtnKludge.REPLY)) { + // nothing to do + return cb(); + } + + Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.REPLY, (err, msgIds) => { + if(!err) { + assert(1 === msgIds.length); + message.replyToMsgId = msgIds[0]; + } + cb(); + }); + }; + + this.importNetMailToArea = function(localAreaTag, header, message, cb) { + async.series( + [ + function validateDestinationAddress(callback) { + const localNetworkPattern = `${message.meta.FtnProperty.ftn_dest_network}/${message.meta.FtnProperty.ftn_dest_node}`; + const localNetworkName = self.getNetworkNameByAddressPattern(localNetworkPattern); + + callback(_.isString(localNetworkName) ? null : new Error('Packet destination is not us')); + }, + function basicSetup(callback) { + message.areaTag = localAreaTag; + + // + // If duplicates are NOT allowed in the area (the default), we need to update + // the message UUID using data available to us. Duplicate UUIDs are internally + // not allowed in our local database. + // + if(!Config.messageNetworks.ftn.areas[localAreaTag].allowDupes) { + if(self.messageHasValidMSGID(message)) { + // Update UUID with our preferred generation method + message.uuid = ftnUtil.createMessageUuid( + message.meta.FtnKludge.MSGID, + message.meta.FtnProperty.ftn_area); + } else { + // Update UUID with alternate/backup generation method + message.uuid = ftnUtil.createMessageUuidAlternate( + message.meta.FtnProperty.ftn_area, + message.modTimestamp, + message.subject, + message.message); + } + } + + callback(null); + }, + function setReplyToMessageId(callback) { + self.setReplyToMsgIdFtnReplyKludge(message, () => { + callback(null); + }); + }, + function persistImport(callback) { + // mark as imported + message.meta.System.StateFlags0 = Message.StateFlags0.Imported.toString(); + + // save to disc + message.persist(err => { + callback(err); + }); + } + ], + err => { + cb(err); + } + ); + }; + + // + // Ref. implementations on import: + // * https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/pkt.c + // https://github.com/larsks/crashmail/blob/26e5374710c7868dab3d834be14bf4041041aae5/crashmail/handle.c + // + this.importMessagesFromPacketFile = function(packetPath, password, cb) { + let packetHeader; + + const packetOpts = { keepTearAndOrigin : true }; + + new ftnMailPacket.Packet(packetOpts).read(packetPath, (entryType, entryData, next) => { + if('header' === entryType) { + packetHeader = entryData; + + const localNetworkName = self.getNetworkNameByAddress(packetHeader.destAddress); + if(!_.isString(localNetworkName)) { + next(new Error('No configuration for this packet')); + } else { + + // :TODO: password needs validated - need to determine if it will use the same node config (which can have wildcards) or something else?! + next(null); + } + + } else if('message' === entryType) { + const message = entryData; + const areaTag = message.meta.FtnProperty.ftn_area; + + if(areaTag) { + // + // EchoMail + // + const localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); + if(localAreaTag) { + self.importNetMailToArea(localAreaTag, packetHeader, message, err => { + if(err) { + if('SQLITE_CONSTRAINT' === err.code) { + Log.info( + { subject : message.subject, uuid : message.uuid }, + 'Not importing non-unique message'); + + return next(null); + } + } + + next(err); + }); + } else { + // + // No local area configured for this import + // + // :TODO: Handle the "catch all" case, if configured + } + } else { + // + // NetMail + // + } + } + }, err => { + cb(err); + }); + }; + + this.importPacketFilesFromDirectory = function(importDir, password, cb) { + async.waterfall( + [ + function getPacketFiles(callback) { + fs.readdir(importDir, (err, files) => { + if(err) { + return callback(err); + } + callback(null, files.filter(f => '.pkt' === paths.extname(f))); + }); + }, + function importPacketFiles(packetFiles, callback) { + let rejects = []; + async.each(packetFiles, (packetFile, nextFile) => { + self.importMessagesFromPacketFile(paths.join(importDir, packetFile), '', err => { + // :TODO: check err -- log / track rejects, etc. + if(err) { + rejects.push(packetFile); + } + nextFile(); + }); + }, err => { + // :TODO: Handle err! we should try to keep going though... + callback(err, packetFiles, rejects); + }); + }, + function handleProcessedFiles(packetFiles, rejects, callback) { + async.each(packetFiles, (packetFile, nextFile) => { + const fullPath = paths.join(importDir, packetFile); + if(rejects.indexOf(packetFile) > -1) { + // :TODO: rename to .bad, perhaps move to a rejects dir + log + nextFile(); + } else { + fs.unlink(fullPath, err => { + nextFile(); + }); + } + }, err => { + callback(err); + }); + } + ], + err => { + cb(err); + } + ); + }; + + this.importMessagesFromDirectory = function(inboundType, importDir, cb) { + async.waterfall( + [ + // start with .pkt files + function importPacketFiles(callback) { + self.importPacketFilesFromDirectory(importDir, '', err => { + callback(err); + }); + }, + function discoverBundles(callback) { + fs.readdir(importDir, (err, files) => { + files = files.filter(f => '.pkt' !== paths.extname(f)); + + async.map(files, (file, transform) => { + const fullPath = paths.join(importDir, file); + self.archUtil.detectType(fullPath, (err, archName) => { + transform(null, { path : fullPath, archName : archName } ); + }); + }, (err, bundleFiles) => { + callback(err, bundleFiles); + }); + }); + }, + function importBundles(bundleFiles, callback) { + let rejects = []; + + async.each(bundleFiles, (bundleFile, nextFile) => { + if(_.isUndefined(bundleFile.archName)) { + Log.warn( + { fileName : bundleFile.path }, + 'Unknown bundle archive type'); + + rejects.push(bundleFile.path); + + return nextFile(); // unknown archive type + } + + self.archUtil.extractTo( + bundleFile.path, + self.importTempDir, + bundleFile.archName, + err => { + if(err) { + Log.warn( + { fileName : bundleFile.path, error : err.toString() }, + 'Failed to extract bundle'); + + rejects.push(bundleFile.path); + } + + nextFile(); + } + ); + }, err => { + if(err) { + return callback(err); + } + + // + // All extracted - import .pkt's + // + self.importPacketFilesFromDirectory(self.importTempDir, '', err => { + // :TODO: handle |err| + callback(null, bundleFiles, rejects); + }); + }); + }, + function handleProcessedBundleFiles(bundleFiles, rejects, callback) { + async.each(bundleFiles, (bundleFile, nextFile) => { + if(rejects.indexOf(bundleFile.path) > -1) { + // :TODO: rename to .bad, perhaps move to a rejects dir + log + nextFile(); + } else { + fs.unlink(bundleFile.path, err => { + nextFile(); + }); + } + }, err => { + callback(err); + }); + } + ], + err => { + cb(err); + } + ); + }; + + this.createTempDirectories = function(cb) { + temp.mkdir('enigftnexport-', (err, tempDir) => { + if(err) { + return cb(err); + } + + self.exportTempDir = tempDir; + + temp.mkdir('enigftnimport-', (err, tempDir) => { + self.importTempDir = tempDir; + + cb(err); + }); + }); + }; + + // Starts an export block - returns true if we can proceed + this.exportingStart = function() { + if(!this.exportRunning) { + this.exportRunning = true; + return true; + } + + return false; + }; + + // ends an export block + this.exportingEnd = function() { + this.exportRunning = false; + }; +} + +require('util').inherits(FTNMessageScanTossModule, MessageScanTossModule); + +FTNMessageScanTossModule.prototype.startup = function(cb) { + Log.info(`${exports.moduleInfo.name} Scanner/Tosser starting up`); + + let importing = false; + + let self = this; + + function tryImportNow(reasonDesc) { + if(!importing) { + importing = true; + + Log.info( { module : exports.moduleInfo.name }, reasonDesc); + + self.performImport( () => { + importing = false; + }); + } + } + + this.createTempDirectories(err => { + if(err) { + Log.warn( { error : err.toStrong() }, 'Failed creating temporary directories!'); + return cb(err); + } + + if(_.isObject(this.moduleConfig.schedule)) { + const exportSchedule = this.parseScheduleString(this.moduleConfig.schedule.export); + if(exportSchedule) { + if(exportSchedule.sched && this.exportingStart()) { + this.exportTimer = later.setInterval( () => { + + Log.info( { module : exports.moduleInfo.name }, 'Performing scheduled message scan/export...'); + + this.performExport( () => { + this.exportingEnd(); + }); + }, exportSchedule.sched); + } + + if(_.isBoolean(exportSchedule.immediate)) { + this.exportImmediate = exportSchedule.immediate; + } + } + + const importSchedule = this.parseScheduleString(this.moduleConfig.schedule.import); + if(importSchedule) { + if(importSchedule.sched) { + this.importTimer = later.setInterval( () => { + tryImportNow('Performing scheduled message import/toss...'); + }, importSchedule.sched); + } + + if(_.isString(importSchedule.watchFile)) { + gaze(importSchedule.watchFile, (err, watcher) => { + watcher.on('all', (event, watchedPath) => { + if(importSchedule.watchFile === watchedPath) { + tryImportNow(`Performing import/toss due to @watch: ${watchedPath} (${event})`); + } + }); + }); + } + } + } + + FTNMessageScanTossModule.super_.prototype.startup.call(this, cb); + }); +}; + +FTNMessageScanTossModule.prototype.shutdown = function(cb) { + Log.info('FidoNet Scanner/Tosser shutting down'); + + if(this.exportTimer) { + this.exportTimer.clear(); + } + + if(this.importTimer) { + this.importTimer.clear(); + } + + // + // Clean up temp dir/files we created + // + temp.cleanup((err, stats) => { + const fullStats = Object.assign(stats, { exportTemp : this.exportTempDir, importTemp : this.importTempDir } ); + + if(err) { + Log.warn(fullStats, 'Failed cleaning up temporary directories!'); + } else { + Log.trace(fullStats, 'Temporary directories cleaned up'); + } + + FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); + }); +}; + +FTNMessageScanTossModule.prototype.performImport = function(cb) { + if(!this.hasValidConfiguration()) { + return cb(new Error('Missing or invalid configuration')); + } + + var self = this; + + async.each( [ 'inbound', 'secInbound' ], (inboundType, nextDir) => { + self.importMessagesFromDirectory(inboundType, self.moduleConfig.paths[inboundType], err => { + + nextDir(); + }); + }, cb); +}; + +FTNMessageScanTossModule.prototype.performExport = function(cb) { + // + // We're only concerned with areas related to FTN. For each area, loop though + // and let's find out what messages need exported. + // + if(!this.hasValidConfiguration()) { + return cb(new Error('Missing or invalid configuration')); + } + + // + // Select all messages that have a message_id > our last scan ID. + // Additionally exclude messages that have a ftn_attr_flags FtnProperty meta + // as those came via import! + // + /* + const getNewUuidsSql = + `SELECT message_id, message_uuid + FROM message m + WHERE area_tag = ? AND message_id > ? AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id AND meta_category = 'FtnProperty' AND meta_name = 'ftn_attr_flags') = 0 + ORDER BY message_id;`; + */ + + // + // Select all messages with a |message_id| > |lastScanId|. + // Additionally exclude messages with the System state_flags0 which will be present for + // imported or already exported messages + // + // NOTE: If StateFlags0 starts to use additional bits, we'll likely need to check them here! + // + const getNewUuidsSql = + `SELECT message_id, message_uuid + FROM message m + WHERE area_tag = ? AND message_id > ? AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id AND meta_category = 'System' AND meta_name = 'state_flags0') = 0 + ORDER BY message_id;`; + + var self = this; + + async.each(Object.keys(Config.messageNetworks.ftn.areas), (areaTag, nextArea) => { + const areaConfig = Config.messageNetworks.ftn.areas[areaTag]; + if(!this.isAreaConfigValid(areaConfig)) { + return nextArea(); + } + + // + // For each message that is newer than that of the last scan + // we need to export to each configured associated uplink(s) + // + async.waterfall( + [ + function getLastScanId(callback) { + self.getAreaLastScanId(areaTag, callback); + }, + function getNewUuids(lastScanId, callback) { + msgDb.all(getNewUuidsSql, [ areaTag, lastScanId ], (err, rows) => { + if(err) { + callback(err); + } else { + if(0 === rows.length) { + let nothingToDoErr = new Error('Nothing to do!'); + nothingToDoErr.noRows = true; + callback(nothingToDoErr); + } else { + callback(null, rows); + } + } + }); + }, + function exportToConfiguredUplinks(msgRows, callback) { + const uuidsOnly = msgRows.map(r => r.message_uuid); // convert to array of UUIDs only + self.exportMessagesToUplinks(uuidsOnly, areaConfig, err => { + const newLastScanId = msgRows[msgRows.length - 1].message_id; + + Log.info( + { areaTag : areaTag, messagesExported : msgRows.length, newLastScanId : newLastScanId }, + 'Export complete'); + + callback(err, newLastScanId); + }); + }, + function updateLastScanId(newLastScanId, callback) { + self.setAreaLastScanId(areaTag, newLastScanId, callback); + } + ], + function complete(err) { + nextArea(); + } + ); + }, err => { + cb(err); + }); +}; + +FTNMessageScanTossModule.prototype.record = function(message) { + // + // This module works off schedules, but we do support @immediate for export + // + if(true !== this.exportImmediate || !this.hasValidConfiguration()) { + return; + } + + if(message.isPrivate()) { + // :TODO: support NetMail + } else if(message.areaTag) { + const areaConfig = Config.messageNetworks.ftn.areas[message.areaTag]; + if(!this.isAreaConfigValid(areaConfig)) { + return; + } + + if(this.exportingStart()) { + this.exportMessagesToUplinks( [ message.uuid ], areaConfig, err => { + const info = { uuid : message.uuid, subject : message.subject }; + + if(err) { + Log.warn(info, 'Failed exporting message'); + } else { + Log.info(info, 'Message exported'); + } + + this.exportingEnd(); + }); + } + } +}; diff --git a/core/servers/ssh.js b/core/servers/ssh.js index 58fc4e17..974d4bcc 100644 --- a/core/servers/ssh.js +++ b/core/servers/ssh.js @@ -235,7 +235,8 @@ SSHServerModule.prototype.createServer = function() { privateKey : fs.readFileSync(Config.servers.ssh.privateKeyPem), passphrase : Config.servers.ssh.privateKeyPass, ident : 'enigma-bbs-' + enigVersion + '-srv', - // Note that sending 'banner' breaks at least EtherTerm! + + // Note that sending 'banner' breaks at least EtherTerm! debug : function debugSsh(dbgLine) { if(true === Config.servers.ssh.traceConnections) { Log.trace('SSH: ' + dbgLine); diff --git a/core/standard_menu.js b/core/standard_menu.js index d38a84f7..9848acb5 100644 --- a/core/standard_menu.js +++ b/core/standard_menu.js @@ -18,8 +18,8 @@ function StandardMenuModule(menuConfig) { require('util').inherits(StandardMenuModule, MenuModule); -StandardMenuModule.prototype.enter = function(client) { - StandardMenuModule.super_.prototype.enter.call(this, client); +StandardMenuModule.prototype.enter = function() { + StandardMenuModule.super_.prototype.enter.call(this); }; StandardMenuModule.prototype.beforeArt = function() { diff --git a/core/string_util.js b/core/string_util.js index f98d0d5a..02c28435 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -1,14 +1,16 @@ /* jslint node: true */ 'use strict'; -var miscUtil = require('./misc_util.js'); +let miscUtil = require('./misc_util.js'); +let iconv = require('iconv-lite'); -exports.stylizeString = stylizeString; -exports.pad = pad; -exports.replaceAt = replaceAt; -exports.isPrintable = isPrintable; -exports.debugEscapedString = debugEscapedString; +exports.stylizeString = stylizeString; +exports.pad = pad; +exports.replaceAt = replaceAt; +exports.isPrintable = isPrintable; +exports.debugEscapedString = debugEscapedString; +exports.stringFromNullTermBuffer = stringFromNullTermBuffer; // :TODO: create Unicode verison of this var VOWELS = [ 'a', 'e', 'i', 'o', 'u' ]; @@ -176,6 +178,23 @@ function debugEscapedString(s) { return JSON.stringify(s).slice(1, -1); } +function stringFromNullTermBuffer(buf, encoding) { + /*var nullPos = buf.length; + for(var i = 0; i < buf.length; ++i) { + if(0x00 === buf[i]) { + nullPos = i; + break; + } + } + */ + let nullPos = buf.indexOf(new Buffer( [ 0x00 ] )); + if(-1 === nullPos) { + nullPos = buf.length; + } + + return iconv.decode(buf.slice(0, nullPos), encoding || 'utf-8'); +} + // // Extend String.format's object syntax with some modifiers // e.g.: '{username!styleL33t}'.format( { username : 'Leet User' } ) -> "L33t U53r" diff --git a/core/theme.js b/core/theme.js index 29498434..5fd5db75 100644 --- a/core/theme.js +++ b/core/theme.js @@ -203,13 +203,13 @@ function getMergedTheme(menuConfig, promptConfig, theme) { } } - [ 'menus', 'prompts' ].forEach(function areaEntry(areaName) { - _.keys(mergedTheme[areaName]).forEach(function menuEntry(menuName) { + [ 'menus', 'prompts' ].forEach(function areaEntry(sectionName) { + _.keys(mergedTheme[sectionName]).forEach(function menuEntry(menuName) { var createdFormSection = false; - var mergedThemeMenu = mergedTheme[areaName][menuName]; + var mergedThemeMenu = mergedTheme[sectionName][menuName]; - if(_.has(theme, [ 'customization', areaName, menuName ])) { - var menuTheme = theme.customization[areaName][menuName]; + if(_.has(theme, [ 'customization', sectionName, menuName ])) { + var menuTheme = theme.customization[sectionName][menuName]; // config block is direct assign/overwrite // :TODO: should probably be _.merge() @@ -217,7 +217,7 @@ function getMergedTheme(menuConfig, promptConfig, theme) { mergedThemeMenu.config = _.assign(mergedThemeMenu.config || {}, menuTheme.config); } - if('menus' === areaName) { + if('menus' === sectionName) { if(_.isObject(mergedThemeMenu.form)) { getFormKeys(mergedThemeMenu.form).forEach(function formKeyEntry(formKey) { applyToForm(mergedThemeMenu.form[formKey], menuTheme, formKey); @@ -233,7 +233,7 @@ function getMergedTheme(menuConfig, promptConfig, theme) { createdFormSection = true; } } - } else if('prompts' === areaName) { + } else if('prompts' === sectionName) { // no 'form' or form keys for prompts -- direct to mci applyToForm(mergedThemeMenu, menuTheme); } @@ -247,7 +247,7 @@ function getMergedTheme(menuConfig, promptConfig, theme) { // * There is/was no explicit 'form' section // * There is no 'prompt' specified // - if('menus' === areaName && !_.isString(mergedThemeMenu.prompt) && + if('menus' === sectionName && !_.isString(mergedThemeMenu.prompt) && (createdFormSection || !_.isObject(mergedThemeMenu.form))) { mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, { autoNext : true } ); @@ -523,7 +523,8 @@ function displayThemedPause(options, cb) { if(options.clearPrompt) { if(artInfo.startRow && artInfo.height) { options.client.term.rawWrite(ansi.goto(artInfo.startRow, 1)); - // :TODO: This will not work with NetRunner: + + // Note: Does not work properly in NetRunner < 2.0b17: options.client.term.rawWrite(ansi.deleteLine(artInfo.height)); } else { options.client.term.rawWrite(ansi.eraseLine(1)) diff --git a/core/user_login.js b/core/user_login.js index 72ca22e2..0c22490c 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -56,7 +56,7 @@ function userLogin(client, username, password, cb) { // update client logger with addition of username client.log = logger.log.child( { clientId : client.log.fields.clientId, username : user.username }); - client.log.info('Successful login'); + client.log.info('Successful login'); async.parallel( [ diff --git a/core/uuid_util.js b/core/uuid_util.js new file mode 100644 index 00000000..00e8840c --- /dev/null +++ b/core/uuid_util.js @@ -0,0 +1,41 @@ +/* jslint node: true */ +'use strict'; + +let uuid = require('node-uuid'); +let assert = require('assert'); +let _ = require('lodash'); +let createHash = require('crypto').createHash; + +exports.createNamedUUID = createNamedUUID; + +function createNamedUUID(namespaceUuid, key) { + // + // v5 UUID generation code based on the work here: + // https://github.com/download13/uuidv5/blob/master/uuid.js + // + if(!Buffer.isBuffer(namespaceUuid)) { + namespaceUuid = new Buffer(namespaceUuid); + } + + if(!Buffer.isBuffer(key)) { + key = new Buffer(key); + } + + let digest = createHash('sha1').update( + Buffer.concat( [ namespaceUuid, key ] )).digest(); + + let u = new Buffer(16); + + // bbbb - bb - bb - bb - bbbbbb + digest.copy(u, 0, 0, 4); // time_low + digest.copy(u, 4, 4, 6); // time_mid + digest.copy(u, 6, 6, 8); // time_hi_and_version + + u[6] = (u[6] & 0x0f) | 0x50; // version, 4 most significant bits are set to version 5 (0101) + u[8] = (digest[8] & 0x3f) | 0x80; // clock_seq_hi_and_reserved, 2msb are set to 10 + u[9] = digest[9]; + + digest.copy(u, 10, 10, 16); + + return u; +} \ No newline at end of file diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index 6e290969..c220b557 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -27,7 +27,7 @@ function VerticalMenuView(options) { this.dimens.height = Math.min(self.dimens.height, self.client.term.termHeight - self.position.row); } - if(this.autoScale.width) { + if(self.autoScale.width) { var l = 0; self.items.forEach(function item(i) { if(i.text.length > l) { @@ -148,6 +148,17 @@ VerticalMenuView.prototype.setFocus = function(focused) { VerticalMenuView.prototype.setFocusItemIndex = function(index) { VerticalMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + + //this.updateViewVisibleItems(); + + // :TODO: |viewWindow| must be updated to reflect position change -- + // if > visibile then += by diff, if < visible + + if(this.focusedItemIndex > this.viewWindow.bottom) { + } else if (this.focusedItemIndex < this.viewWindow.top) { + // this.viewWindow.top--; +// this.viewWindow.bottom--; + } this.redraw(); }; diff --git a/core/view_controller.js b/core/view_controller.js index 7fbed5cd..8e4b36b9 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -488,7 +488,6 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) { assert(_.isObject(options.mciMap)); var self = this; - var promptName = _.isString(options.promptName) ? options.promptName : self.client.currentMenuModule.menuConfig.prompt; var promptConfig = _.isObject(options.config) ? options.config : self.client.currentMenuModule.menuConfig.promptConfig; var initialFocusId = 1; // default to first diff --git a/docs/config.md b/docs/config.md index 78601cc7..be2cad75 100644 --- a/docs/config.md +++ b/docs/config.md @@ -21,5 +21,90 @@ general: { } ``` +#### A Sample Configuration +Below is a **sample** `config.hjson` illustrating various (but not all!) elements that can be configured / tweaked. + + +```hjson +{ + general: { + boardName: A Sample BBS + } + + defaults: { + theme: super-fancy-theme + } + + preLoginTheme: luciano_blocktronics + + messageConferences: { + local_general: { + name: Local + desc: Local Discussions + default: true + + areas: { + local_enigma_dev: { + name: ENiGMA 1/2 Development + desc: Discussion related to development and features of ENiGMA 1/2! + default: true + } + } + } + + agoranet: { + name: Agoranet + desc: This network is for blatant exploitation of the greatest BBS scene art group ever.. ACiD. + + areas: { + agoranet_bbs: { + name: BBS Discussion + desc: Discussion related to BBSs + } + } + } + } + + messageNetworks: { + ftn: { + areas: { + agoranet_bbs: { /* hey kids, this matches above! */ + + // oh oh oh, and this one pairs up with a network below + network: agoranet + tag: AGN_BBS + uplinks: "46:1/100" + } + } + + networks: { + agoranet: { + localAddress: "46:3/102" + } + } + } + } + + scannerTossers: { + ftn_bso: { + schedule: { + import: every 1 hours or @watch:/home/enigma/bink/watchfile.txt + export: every 1 hours or @immediate + } + + defaultZone: 46 + defaultNetwork: agoranet + + nodes: { + "46:*": { + archiveType: ZIP + encoding: utf8 + } + } + } + } +} +``` + ### Menus TODO: Documentation on menu.hjson, etc. \ No newline at end of file diff --git a/docs/doors.md b/docs/doors.md index 0a1fbfdf..82ce677d 100644 --- a/docs/doors.md +++ b/docs/doors.md @@ -1,5 +1,5 @@ # Doors -ENiGMA½ supports a variety of methods for interacting with doors not limited to: +ENiGMA½ supports a variety of methods for interacting with doors — not limited to: * `abracadabra` module: Standard in/out (stdio) capture or temporary socket server that can be used with [DOSEMU](http://www.dosemu.org/), [DOSBox](http://www.dosbox.com/), [QEMU](http://wiki.qemu.org/Main_Page), etc. * `bbs_link` module for interaction with [BBSLink](http://www.bbslink.net/) @@ -28,7 +28,7 @@ Variables for use in `args`: ### DOSEMU with abracadabra -[DOSEMU](http://www.dosemu.org/) can provide a good solution for running legacy DOS doors when running on Linux systems. For this, we will a virtual serial port (COM1) that communicates with stdio. +[DOSEMU](http://www.dosemu.org/) can provide a good solution for running legacy DOS doors when running on Linux systems. For this, we will create a virtual serial port (COM1) that communicates via stdio. As an example, here are the steps for setting up Pimp Wars: diff --git a/docs/index.md b/docs/index.md index c88fd5ea..ed848949 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,7 @@ ENiGMA½ is a modern from scratch BBS package written in Node.js. TL;DR? This should get you started... ## Prerequisites -* [Node.js](https://nodejs.org/) version **v0.12.2 or higher** (v4.2+ is recommended) - * [io.js](https://iojs.org/) should also work, though I have not yet tested this. +* [Node.js](https://nodejs.org/) version **v4.2.x or higher** * :information_source: It is suggested to use [nvm](https://github.com/creationix/nvm) to manage your Node/io.js installs * **Windows users will need additional dependencies installed** for the `npm install` step in order to compile native binaries: * A recent copy of Visual Studio ([Visual Studio Express](https://www.visualstudio.com/en-us/products/visual-studio-express-vs.aspx) editions OK) @@ -16,9 +15,9 @@ TL;DR? This should get you started... If you're new to Node.js and/or do not care about Node itself and just want to get ENiGMA½ running these steps should get you going on most \*nix type enviornments: ```bash -curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.30.1/install.sh | bash -nvm install 4.2.4 -nvm use 4.2.4 +curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.0/install.sh | bash +nvm install 4.4.0 +nvm use 4.4.0 ``` @@ -48,16 +47,27 @@ The main system configuration is handled via `~/.config/enigma-bbs/config.hjson` general: { boardName: Super Awesome BBS } + servers: { ssh: { privateKeyPass: YOUR_PK_PASS enabled: true /* set to false to disable the SSH server */ } } -messages: { - areas: [ - { name: "local_enigma_discusssion", desc: "ENiGMA Discussion", groups: [ "users" ] } - ] + +messageConferences: { + local_general: { + name: Local + desc: Local Discussions + default: true + + areas: { + local_music: { + name: Music Discussion + desc: Music, bands, etc. + default: true + } + } } ``` diff --git a/docs/msg_conf_area.md b/docs/msg_conf_area.md new file mode 100644 index 00000000..05c11441 --- /dev/null +++ b/docs/msg_conf_area.md @@ -0,0 +1,57 @@ +# Message Conferences & Areas +**Message Conferences** and **Areas** allow for grouping of message base topics. + +## Message Conferences +Message Conferences are the top level container for 1:n Message Areas via the `messageConferences` section in `config.hjson`. Common message conferences may include a local conference and one or more conferences each dedicated to a particular message network such as FidoNet, AgoraNet, etc. + +Each conference is represented by a entry under `messageConferences`. **The areas key is the conferences tag**. + +**Members**: +* `name` (required): Friendly conference name +* `desc` (required): Friendly conference description +* `sort` (optional): If supplied, provides a key used for sorting +* `default` (optional): Specify `true` to make this the default conference (e.g. assigned to new users) +* `areas`: Container of 1:n areas described below + +**Example**: +```hjson +{ + messageConferences: { + local: { + name: Local + desc: Local discussion + sort: 1 + default: true + } + } +} +``` + +## Message Areas +Message Areas are topic specific containers for messages that live within a particular conference. **The areas key is it's areas tag**. For example, "General Discussion" may live under a Local conference while an AgoraNet conference may contain "BBS Discussion". + +**Members**: +* `name` (required): Friendly area name +* `desc` (required): Friendly area discription +* `sort` (optional): If supplied, provides a key used for sorting +* `default` (optional): Specify `true` to make this the default area (e.g. assigned to new users) + +**Example**: +```hjson +messageConferences: { + local: { + // ... see above ... + areas: { + local_enigma_dev: { + name: ENiGMA 1/2 Development + desc: Discussion related to features and development of ENiGMA 1/2! + sort: 1 + default: true + } + } + } +} +``` + +## Message Networks +ENiGMA½ has the ability to network with other systems via [Message Networks](msg_networks.md). Message **area tags** (described above) are utilized to map foreign areas with locally defined areas. \ No newline at end of file diff --git a/docs/msg_networks.md b/docs/msg_networks.md new file mode 100644 index 00000000..2fe4832a --- /dev/null +++ b/docs/msg_networks.md @@ -0,0 +1,117 @@ +# Message Networks +Message networks are configured in `messageNetworks` section of `config.hjson`. Each network type has it's own sub section such as `ftn` for FidoNet Technology Network (FTN) style networks. Message Networks tie directly with [Message Areas](msg_conf_area.md) that are also defined in `config.hjson`. + +## FidoNet Technology Network (FTN) +FTN networks are configured under the `messageNetworks::ftn` section of `config.hjson`. + +### Networks +The `networks` section contains a sub section for network(s) you wish you join your board with. Each entry's key name can be referenced elsewhere in `config.hjson` for FTN oriented configurations. + +**Members**: + * `localAddress` (required): FTN address of **your local system** + +**Example**: +```hjson +{ + messageNetworks: { + ftn: { + networks: { + agoranet: { + localAddress: "46:3/102" + } + } + } + } +} +``` + +### Areas +The `areas` section describes a mapping of local **area tags** found in your `messageConferences` to a message network (from `networks` described previously), a FTN specific area tag, and remote uplink address(s). This section can be thought of similar to the *AREAS.BBS* file used by other BBS packages. + +When importing, messages will be placed in the local area that matches key under `areas`. + +**Members**: + * `network` (required): Associated network from the `networks` section + * `tag` (required): FTN area tag + * `uplinks`: An array of FTN address uplink(s) for this network + +**Example**: +```hjson +{ + messageNetworks: { + ftn: { + areas: { + agoranet_bbs: { /* found within messageConferences */ + network: agoranet + tag: AGN_BBS + uplinks: "46:1/100" + } + } + } + } +} +``` + +### BSO Import / Export +The scanner/tosser module `ftn_bso` provides **B**inkley **S**tyle **O**utbound (BSO) import/toss & scan/export of messages EchoMail and NetMail messages. Configuration is supplied in `config.hjson` under `scannerTossers::ftn_bso`. + +**Members**: + * `defaultZone` (required): Sets the default BSO outbound zone + * `defaultNetwork` (optional): Sets the default network name from `messageNetworks::ftn::networks`. **Required if more than one network is defined**. + * `paths` (optional): Override default paths set by the system. This section may contain `outbound`, `inbound`, and `secInbound`. + * `packetTargetByteSize` (optional): Overrides the system *target* packet (.pkt) size of 512000 bytes (512k) + * `bundleTargetByteSize` (optional): Overrides the system *target* ArcMail bundle size of 2048000 bytes (2M) + * `schedule` (required): See Scheduling + * `nodes` (required): See Nodes + +#### Nodes +The `nodes` section defines how to export messages for one or more uplinks. + +A node entry starts with a FTN style address (up to 5D) **as a key** in `config.hjson`. This key may contain wildcard(s) for net/zone/node/point/domain. + +**Members**: + * `packetType` (optional): `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability + * `packetPassword` (optional): Password for the packet + * `encoding` (optional): Encoding to use for message bodies; Defaults to `utf-8` + * `archiveType` (optional): Specifies the archive type for ArcMail bundles. Must be a valid archiver name such as `zip` (See archiver configuration) + +**Example**: +```hjson +{ + scannerTossers: { + ftn_bso: { + nodes: { + "46:*: { + packetType: 2+ + packetPassword: mypass + encoding: cp437 + archiveType: zip + } + } + } + } +} +``` + +#### Scheduling +Schedules can be defined for importing and exporting via `import` and `export` under `schedule`. Each entry is allowed a "free form" text and/or special indicators for immediate export or watch file triggers. + + * `@immediate`: A message will be immediately exported if this trigger is defined in a schedule. Only used for `export`. + * `@watch:/path/to/file`: This trigger watches the path specified for changes and will trigger an import or export when such events occur. Only used for `import`. + * Free form text can be things like `at 5:00 pm` or `every 2 hours`. + +See [Later text parsing documentation](http://bunkat.github.io/later/parsers.html#text) for more information. + +**Example**: +```hjson +{ + scannerTossers: { + ftn_bso: { + schedule: { + import: every 1 hours or @watch:/path/to/watchfile.ext + export: every 1 hours or @immediate + } + } + } +} +``` \ No newline at end of file diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs index dc9e339e..14a03042 100644 --- a/misc/acs_parser.pegjs +++ b/misc/acs_parser.pegjs @@ -16,15 +16,13 @@ return !isNaN(value) && user.getAge() >= value; }, AS : function accountStatus() { - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - return _.findIndex(value, function cmp(accStatus) { - return parseInt(accStatus, 10) === parseInt(user.properties.account_status, 10); - }) > -1; + const userAccountStatus = parseInt(user.properties.account_status, 10); + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(userAccountStatus) > -1; }, EC : function isEncoding() { switch(value) { @@ -53,7 +51,7 @@ // :TODO: implement me!! return false; }, - SC : function isSecerConnection() { + SC : function isSecureConnection() { return client.session.isSecure; }, ML : function minutesLeft() { @@ -81,28 +79,20 @@ return !isNaN(value) && client.term.termWidth >= value; }, ID : function isUserId(value) { - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - return _.findIndex(value, function cmp(uid) { - return user.userId === parseInt(uid, 10); - }) > -1; + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(user.userId) > -1; }, WD : function isOneOfDayOfWeek() { - if(_.isNumber(value)) { + if(!_.isArray(value)) { value = [ value ]; } - assert(_.isArray(value)); - - var nowDayOfWeek = new Date().getDay(); - - return _.findIndex(value, function cmp(dow) { - return nowDayOfWeek === parseInt(dow, 10); - }) > -1; + value = value.map(n => parseInt(n, 10)); // ensure we have integers + return value.indexOf(new Date().getDay()) > -1; }, MM : function isMinutesPastMidnight() { // :TODO: return true if value is >= minutes past midnight sys time diff --git a/mods/abracadabra.js b/mods/abracadabra.js index 5e5ff34d..c5aaafa6 100644 --- a/mods/abracadabra.js +++ b/mods/abracadabra.js @@ -177,12 +177,6 @@ function AbracadabraModule(options) { require('util').inherits(AbracadabraModule, MenuModule); -/* -AbracadabraModule.prototype.enter = function(client) { - AbracadabraModule.super_.prototype.enter.call(this, client); -}; -*/ - AbracadabraModule.prototype.leave = function() { AbracadabraModule.super_.prototype.leave.call(this); diff --git a/mods/art/CONNECT1.ANS b/mods/art/CONNECT1.ANS index b9fd50bf..d1a870bc 100644 Binary files a/mods/art/CONNECT1.ANS and b/mods/art/CONNECT1.ANS differ diff --git a/mods/art/DOORMANY.ANS b/mods/art/DOORMANY.ANS index e3fa4a31..315004d6 100644 Binary files a/mods/art/DOORMANY.ANS and b/mods/art/DOORMANY.ANS differ diff --git a/mods/art/WELCOME1.ANS b/mods/art/WELCOME1.ANS index b09c5ad7..dc5ac3ee 100644 --- a/mods/art/WELCOME1.ANS +++ b/mods/art/WELCOME1.ANS @@ -1,19 +1,19 @@ - ܲ      -  ۲       ܲ -      -  ۲۲ܲ ۲ -     - ۲             -                            -     ܲ     ۲     -    ۲        -   ߲ ۲۲ ߲   ߲ ۲۲   ߲ -  ۲   - ۲ ޲ - ۲ ۲  -    enigmabbs soft ۲  -dangermouse ۲  -    ۲ - -    + ܲ      +  ۲       ܲ +      +  ۲۲ܲ ۲ +     + ۲             +                            +     ܲ     ۲     +    ۲        +   ߲ ۲۲ ߲   ߲ ۲۲   ߲ +  ۲   + ۲ ޲ + ۲ ۲  +    enigmabbs soft ۲  +dangermouse ۲  +    ۲ + +     \ No newline at end of file diff --git a/mods/art/WELCOME2.ANS b/mods/art/WELCOME2.ANS index cfc3ed42..df8b2732 100644 Binary files a/mods/art/WELCOME2.ANS and b/mods/art/WELCOME2.ANS differ diff --git a/mods/art/demo_edit_text_view.ans b/mods/art/demo_edit_text_view.ans index e6f7a240..e2b0f6a4 100644 Binary files a/mods/art/demo_edit_text_view.ans and b/mods/art/demo_edit_text_view.ans differ diff --git a/mods/art/demo_edit_text_view1.ans b/mods/art/demo_edit_text_view1.ans index 12f111e0..85b1d887 100644 Binary files a/mods/art/demo_edit_text_view1.ans and b/mods/art/demo_edit_text_view1.ans differ diff --git a/mods/art/demo_fse_local_user.ans b/mods/art/demo_fse_local_user.ans index be3e1477..9c27d224 100644 Binary files a/mods/art/demo_fse_local_user.ans and b/mods/art/demo_fse_local_user.ans differ diff --git a/mods/art/demo_fse_netmail_body.ans b/mods/art/demo_fse_netmail_body.ans index 1c545573..d38bc77b 100644 Binary files a/mods/art/demo_fse_netmail_body.ans and b/mods/art/demo_fse_netmail_body.ans differ diff --git a/mods/art/demo_fse_netmail_footer_edit.ans b/mods/art/demo_fse_netmail_footer_edit.ans index f5bad353..5d9aec62 100644 Binary files a/mods/art/demo_fse_netmail_footer_edit.ans and b/mods/art/demo_fse_netmail_footer_edit.ans differ diff --git a/mods/art/demo_fse_netmail_footer_edit_menu.ans b/mods/art/demo_fse_netmail_footer_edit_menu.ans index 6da50e74..bac8ffab 100644 Binary files a/mods/art/demo_fse_netmail_footer_edit_menu.ans and b/mods/art/demo_fse_netmail_footer_edit_menu.ans differ diff --git a/mods/art/demo_fse_netmail_header.ans b/mods/art/demo_fse_netmail_header.ans index 47ef04e6..298ddbf2 100644 Binary files a/mods/art/demo_fse_netmail_header.ans and b/mods/art/demo_fse_netmail_header.ans differ diff --git a/mods/art/demo_fse_netmail_help.ans b/mods/art/demo_fse_netmail_help.ans index f24025f8..701a3e74 100644 Binary files a/mods/art/demo_fse_netmail_help.ans and b/mods/art/demo_fse_netmail_help.ans differ diff --git a/mods/art/demo_horizontal_menu_view1.ans b/mods/art/demo_horizontal_menu_view1.ans index 0e486d39..9398469e 100644 Binary files a/mods/art/demo_horizontal_menu_view1.ans and b/mods/art/demo_horizontal_menu_view1.ans differ diff --git a/mods/art/demo_mask_edit_text_view1.ans b/mods/art/demo_mask_edit_text_view1.ans index f7c194c5..0c885630 100644 Binary files a/mods/art/demo_mask_edit_text_view1.ans and b/mods/art/demo_mask_edit_text_view1.ans differ diff --git a/mods/art/demo_multi_line_edit_text_view1.ans b/mods/art/demo_multi_line_edit_text_view1.ans index b38c0372..fce6aeab 100644 Binary files a/mods/art/demo_multi_line_edit_text_view1.ans and b/mods/art/demo_multi_line_edit_text_view1.ans differ diff --git a/mods/art/demo_selection_vm.ans b/mods/art/demo_selection_vm.ans index 52f50c1b..a3adfa71 100644 Binary files a/mods/art/demo_selection_vm.ans and b/mods/art/demo_selection_vm.ans differ diff --git a/mods/art/demo_spin_and_toggle.ans b/mods/art/demo_spin_and_toggle.ans index 470495b8..748de3f5 100644 Binary files a/mods/art/demo_spin_and_toggle.ans and b/mods/art/demo_spin_and_toggle.ans differ diff --git a/mods/art/demo_vertical_menu_view1.ans b/mods/art/demo_vertical_menu_view1.ans index 6ba4156b..62f29a13 100644 Binary files a/mods/art/demo_vertical_menu_view1.ans and b/mods/art/demo_vertical_menu_view1.ans differ diff --git a/mods/art/menu_prompt.ans b/mods/art/menu_prompt.ans index 47b49dab..bb7ebb4f 100644 Binary files a/mods/art/menu_prompt.ans and b/mods/art/menu_prompt.ans differ diff --git a/mods/art/msg_area_footer_view.ans b/mods/art/msg_area_footer_view.ans index b5552e27..f83f887b 100644 Binary files a/mods/art/msg_area_footer_view.ans and b/mods/art/msg_area_footer_view.ans differ diff --git a/mods/art/msg_area_list.ans b/mods/art/msg_area_list.ans index 7514ccd5..7512dfa6 100644 Binary files a/mods/art/msg_area_list.ans and b/mods/art/msg_area_list.ans differ diff --git a/mods/art/msg_area_post_header.ans b/mods/art/msg_area_post_header.ans index 3959d681..d9d4f18d 100644 Binary files a/mods/art/msg_area_post_header.ans and b/mods/art/msg_area_post_header.ans differ diff --git a/mods/art/msg_area_view_header.ans b/mods/art/msg_area_view_header.ans index ef755c9c..a05a8a0d 100644 Binary files a/mods/art/msg_area_view_header.ans and b/mods/art/msg_area_view_header.ans differ diff --git a/mods/art/test.ans b/mods/art/test.ans index 2ebac4ef..16ae8178 100644 --- a/mods/art/test.ans +++ b/mods/art/test.ans @@ -1,52 +1,52 @@ -You should never see this! - - -... nor this -[?33h - fONT tEST - ~~~~~~~~~ - - | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F - ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- - 0 |NUL|  |  |  |  |  |  |  |BS |HT |LF | | |CR |  |   - 1 |  |  |  |  |  |  |  |  |  |  |EOF|ESC|  |  |  |   - 2 | | ! | " | # | $ | % | & | ' | ( | ) | * | + | , | - | . | /  - 3 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | : | ; | < | = | > | ?  - ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- - 4 | @ | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O  - 5 | P | Q | R | S | T | U | V | W | X | Y | Z | [ | \ | ] | ^ | _  - 6 | ` | a | b | c | d | e | f | g | h | i | j | k | l | m | n | o  - 7 | p | q | r | s | t | u | v | w | x | y | z | { | | | } | ~ |   - ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- - 8 | | | | | | | | | | | | | | | |  - 9 | | | | | | | | | | | | | | | |  - A | | | | | | | | | | | | | | | |  - B | | | | | | | | | | | | | | | |  - ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- - C | | | | | | | | | | | | | | | |  - D | | | | | | | | | | | | | | | |  - E | | | | | | | | | | | | | | | |  - F | | | | | | | | | | | | | | | |  - - - cOLOR tEST - ~~~~~~~~~~ - -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - -  +You should never see this! + + +... nor this +[?33h + fONT tEST + ~~~~~~~~~ + + | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F + ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- + 0 |NUL|  |  |  |  |  |  |  |BS |HT |LF | | |CR |  |   + 1 |  |  |  |  |  |  |  |  |  |  |EOF|ESC|  |  |  |   + 2 | | ! | " | # | $ | % | & | ' | ( | ) | * | + | , | - | . | /  + 3 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | : | ; | < | = | > | ?  + ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- + 4 | @ | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O  + 5 | P | Q | R | S | T | U | V | W | X | Y | Z | [ | \ | ] | ^ | _  + 6 | ` | a | b | c | d | e | f | g | h | i | j | k | l | m | n | o  + 7 | p | q | r | s | t | u | v | w | x | y | z | { | | | } | ~ |   + ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- + 8 | | | | | | | | | | | | | | | |  + 9 | | | | | | | | | | | | | | | |  + A | | | | | | | | | | | | | | | |  + B | | | | | | | | | | | | | | | |  + ---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+--- + C | | | | | | | | | | | | | | | |  + D | | | | | | | | | | | | | | | |  + E | | | | | | | | | | | | | | | |  + F | | | | | | | | | | | | | | | |  + + + cOLOR tEST + ~~~~~~~~~~ + +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + +  diff --git a/mods/menu.hjson b/mods/menu.hjson index de4f40da..78ec3f4a 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -393,7 +393,7 @@ }, editorMode: edit editorType: email - messageAreaName: private_mail + messageAreaTag: private_mail toUserId: 1 /* always to +op */ } form: { @@ -806,7 +806,7 @@ }, editorMode: edit editorType: email - messageAreaName: private_mail + messageAreaTag: private_mail toUserId: 1 /* always to +op */ } form: { @@ -1019,6 +1019,10 @@ value: { command: "P" } action: @menu:messageAreaNewPost } + { + value: { command: "J" } + action: @menu:messageAreaChangeCurrentConference + } { value: { command: "C" } action: @menu:messageAreaChangeCurrentArea @@ -1041,7 +1045,39 @@ } ] } + + messageAreaChangeCurrentConference: { + art: CCHANGE + module: msg_conf_list + form: { + 0: { + mci: { + VM1: { + focus: true + submit: true + argName: conf + } + } + submit: { + *: [ + { + value: { conf: null } + action: @method:changeConference + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + messageAreaChangeCurrentArea: { + // :TODO: rename this art to ACHANGE art: CHANGE module: msg_area_list form: { @@ -1070,6 +1106,7 @@ } } } + messageAreaMessageList: { module: msg_list art: MSGLIST @@ -1163,7 +1200,7 @@ { value: { 1: 0 } action: @method:prevMessage - } + } { value: { 1: 1 } action: @method:nextMessage diff --git a/mods/msg_area_list.js b/mods/msg_area_list.js index 4935e271..a984e9ff 100644 --- a/mods/msg_area_list.js +++ b/mods/msg_area_list.js @@ -5,7 +5,6 @@ var MenuModule = require('../core/menu_module.js').MenuModule; var ViewController = require('../core/view_controller.js').ViewController; var messageArea = require('../core/message_area.js'); var strUtil = require('../core/string_util.js'); -//var msgDb = require('./database.js').dbs.message; var async = require('async'); var assert = require('assert'); @@ -43,30 +42,33 @@ function MessageAreaListModule(options) { var self = this; - this.messageAreas = messageArea.getAvailableMessageAreas(); + this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag( + self.client.user.properties.message_conf_tag, + { client : self.client } + ); this.menuMethods = { changeArea : function(formData, extraArgs) { if(1 === formData.submitId) { - var areaName = self.messageAreas[formData.value.area].name; + const areaTag = self.messageAreas[formData.value.area].areaTag; - messageArea.changeMessageArea(self.client, areaName, function areaChanged(err) { - if(err) { - self.client.term.pipeWrite('\n|00Cannot change area: ' + err.message + '\n'); + messageArea.changeMessageArea(self.client, areaTag, function areaChanged(err) { + if(err) { + self.client.term.pipeWrite('\n|00Cannot change area: ' + err.message + '\n'); - setTimeout(function timeout() { - self.prevMenu(); - }, 1000); - } else { - self.prevMenu(); - } - }); + setTimeout(function timeout() { + self.prevMenu(); + }, 1000); + } else { + self.prevMenu(); + } + }); } } }; this.setViewText = function(id, text) { - var v = self.viewControllers.areaList.getView(id); + const v = self.viewControllers.areaList.getView(id); if(v) { v.setText(text); } @@ -78,7 +80,7 @@ require('util').inherits(MessageAreaListModule, MenuModule); MessageAreaListModule.prototype.mciReady = function(mciData, cb) { var self = this; - var vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); + const vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); async.series( [ @@ -99,26 +101,29 @@ MessageAreaListModule.prototype.mciReady = function(mciData, cb) { }); }, function populateAreaListView(callback) { - var listFormat = self.menuConfig.config.listFormat || '{index} ) - {desc}'; - var focusListFormat = self.menuConfig.config.focusListFormat || listFormat; - - var areaListItems = []; - var focusListItems = []; - - // :TODO: use _.map() here - for(var i = 0; i < self.messageAreas.length; ++i) { - areaListItems.push(listFormat.format( - { index : i, name : self.messageAreas[i].name, desc : self.messageAreas[i].desc } ) - ); - focusListItems.push(focusListFormat.format( - { index : i, name : self.messageAreas[i].name, desc : self.messageAreas[i].desc } ) - ); - } - - var areaListView = vc.getView(1); - - areaListView.setItems(areaListItems); - areaListView.setFocusItems(focusListItems); + const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}'; + const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; + + const areaListView = vc.getView(1); + let i = 1; + areaListView.setItems(_.map(self.messageAreas, v => { + return listFormat.format({ + index : i++, + areaTag : v.area.areaTag, + name : v.area.name, + desc : v.area.desc, + }); + })); + + i = 1; + areaListView.setFocusItems(_.map(self.messageAreas, v => { + return focusListFormat.format({ + index : i++, + areaTag : v.area.areaTag, + name : v.area.name, + desc : v.area.desc, + }) + })); areaListView.redraw(); diff --git a/mods/msg_area_post_fse.js b/mods/msg_area_post_fse.js index 4723da68..292c0eec 100644 --- a/mods/msg_area_post_fse.js +++ b/mods/msg_area_post_fse.js @@ -1,12 +1,13 @@ /* jslint node: true */ 'use strict'; -var FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; -var Message = require('../core/message.js').Message; -var user = require('../core/user.js'); +let FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; +//var Message = require('../core/message.js').Message; +let persistMessage = require('../core/message_area.js').persistMessage; +let user = require('../core/user.js'); -var _ = require('lodash'); -var async = require('async'); +let _ = require('lodash'); +let async = require('async'); exports.getModule = AreaPostFSEModule; @@ -24,7 +25,7 @@ function AreaPostFSEModule(options) { // we're posting, so always start with 'edit' mode this.editorMode = 'edit'; - this.menuMethods.editModeMenuSave = function(formData, extraArgs) { + this.menuMethods.editModeMenuSave = function() { var msg; async.series( @@ -36,18 +37,25 @@ function AreaPostFSEModule(options) { }); }, function saveMessage(callback) { + persistMessage(msg, callback); + /* msg.persist(function persisted(err) { callback(err); }); + */ } ], function complete(err) { if(err) { // :TODO:... sooooo now what? } else { - console.log(msg); // :TODO: remove me -- probably log that one was saved, however. + // note: not logging 'from' here as it's part of client.log.xxxx() + self.client.log.info( + { to : msg.toUserName, subject : msg.subject, uuid : msg.uuid }, + 'Message persisted' + ); } - + self.nextMenu(); } ); @@ -56,11 +64,11 @@ function AreaPostFSEModule(options) { require('util').inherits(AreaPostFSEModule, FullScreenEditorModule); -AreaPostFSEModule.prototype.enter = function(client) { +AreaPostFSEModule.prototype.enter = function() { - if(_.isString(client.user.properties.message_area_name) && !_.isString(this.messageAreaName)) { - this.messageAreaName = client.user.properties.message_area_name; + if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) { + this.messageAreaTag = this.client.user.properties.message_area_tag; } - AreaPostFSEModule.super_.prototype.enter.call(this, client); + AreaPostFSEModule.super_.prototype.enter.call(this); }; diff --git a/mods/msg_area_view_fse.js b/mods/msg_area_view_fse.js index 7d1d1e44..09d823ed 100644 --- a/mods/msg_area_view_fse.js +++ b/mods/msg_area_view_fse.js @@ -72,7 +72,7 @@ function AreaViewFSEModule(options) { if(_.isString(extraArgs.menu)) { var modOpts = { extraArgs : { - messageAreaName : self.messageAreaName, + messageAreaTag : self.messageAreaTag, replyToMessage : self.message, } }; diff --git a/mods/msg_conf_list.js b/mods/msg_conf_list.js new file mode 100644 index 00000000..308785d5 --- /dev/null +++ b/mods/msg_conf_list.js @@ -0,0 +1,122 @@ +/* jslint node: true */ +'use strict'; + +var MenuModule = require('../core/menu_module.js').MenuModule; +var ViewController = require('../core/view_controller.js').ViewController; +var messageArea = require('../core/message_area.js'); + +var async = require('async'); +var assert = require('assert'); +var _ = require('lodash'); + +exports.getModule = MessageConfListModule; + +exports.moduleInfo = { + name : 'Message Conference List', + desc : 'Module for listing / choosing message conferences', + author : 'NuSkooler', +}; + +var MciCodesIds = { + ConfList : 1, + CurrentConf : 2, + + // :TODO: + // # areas in con + // +}; + +function MessageConfListModule(options) { + MenuModule.call(this, options); + + var self = this; + + this.messageConfs = messageArea.getSortedAvailMessageConferences(self.client); + + this.menuMethods = { + changeConference : function(formData, extraArgs) { + if(1 === formData.submitId) { + const confTag = self.messageConfs[formData.value.conf].confTag; + + messageArea.changeMessageConference(self.client, confTag, err => { + if(err) { + self.client.term.pipeWrite(`\n|00Cannot change conference: ${err.message}\n`); + + setTimeout(function timeout() { + self.prevMenu(); + }, 1000); + } else { + self.prevMenu(); + } + }); + } + } + }; + + this.setViewText = function(id, text) { + const v = self.viewControllers.areaList.getView(id); + if(v) { + v.setText(text); + } + }; +} + +require('util').inherits(MessageConfListModule, MenuModule); + +MessageConfListModule.prototype.mciReady = function(mciData, cb) { + var self = this; + const vc = self.viewControllers.areaList = new ViewController( { client : self.client } ); + + async.series( + [ + function callParentMciReady(callback) { + MessageConfListModule.super_.prototype.mciReady.call(this, mciData, callback); + }, + function loadFromConfig(callback) { + let loadOpts = { + callingMenu : self, + mciMap : mciData.menu, + formId : 0, + }; + + vc.loadFromMenuConfig(loadOpts, callback); + }, + function populateConfListView(callback) { + const listFormat = self.menuConfig.config.listFormat || '{index} ) - {name}'; + const focusListFormat = self.menuConfig.config.focusListFormat || listFormat; + + const confListView = vc.getView(1); + let i = 1; + confListView.setItems(_.map(self.messageConfs, v => { + return listFormat.format({ + index : i++, + confTag : v.conf.confTag, + name : v.conf.name, + desc : v.conf.desc, + }); + })); + + i = 1; + confListView.setFocusItems(_.map(self.messageConfs, v => { + return focusListFormat.format({ + index : i++, + confTag : v.conf.confTag, + name : v.conf.name, + desc : v.conf.desc, + }) + })); + + confListView.redraw(); + + callback(null); + }, + function populateTextViews(callback) { + // :TODO: populate other avail MCI, e.g. current conf name + callback(null); + } + ], + function complete(err) { + cb(err); + } + ); +}; \ No newline at end of file diff --git a/mods/msg_list.js b/mods/msg_list.js index c5fbe768..fbffa162 100644 --- a/mods/msg_list.js +++ b/mods/msg_list.js @@ -52,15 +52,15 @@ function MessageListModule(options) { var self = this; var config = this.menuConfig.config; - this.messageAreaName = config.messageAreaName; + this.messageAreaTag = config.messageAreaTag; if(options.extraArgs) { // - // |extraArgs| can override |messageAreaName| provided by config + // |extraArgs| can override |messageAreaTag| provided by config // as well as supply a pre-defined message list // - if(options.extraArgs.messageAreaName) { - this.messageAreaName = options.extraArgs.messageAreaName; + if(options.extraArgs.messageAreaTag) { + this.messageAreaTag = options.extraArgs.messageAreaTag; } if(options.extraArgs.messageList) { @@ -73,7 +73,7 @@ function MessageListModule(options) { if(1 === formData.submitId) { var modOpts = { extraArgs : { - messageAreaName : self.messageAreaName, + messageAreaTag : self.messageAreaTag, messageList : self.messageList, messageIndex : formData.value.message, } @@ -94,15 +94,15 @@ function MessageListModule(options) { require('util').inherits(MessageListModule, MenuModule); -MessageListModule.prototype.enter = function(client) { - MessageListModule.super_.prototype.enter.call(this, client); +MessageListModule.prototype.enter = function() { + MessageListModule.super_.prototype.enter.call(this); // - // Config can specify |messageAreaName| else it comes from + // Config can specify |messageAreaTag| else it comes from // the user's current area // - if(!this.messageAreaName) { - this.messageAreaName = client.user.properties.message_area_name; + if(!this.messageAreaTag) { + this.messageAreaTag = this.client.user.properties.message_area_tag; } }; @@ -110,6 +110,8 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { var self = this; var vc = self.viewControllers.allViews = new ViewController( { client : self.client } ); + var firstNewEntryIndex; + async.series( [ function callParentMciReady(callback) { @@ -130,7 +132,7 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { if(_.isArray(self.messageList)) { callback(0 === self.messageList.length ? new Error('No messages in area') : null); } else { - messageArea.getMessageListForArea( { client : self.client }, self.messageAreaName, function msgs(err, msgList) { + messageArea.getMessageListForArea( { client : self.client }, self.messageAreaTag, function msgs(err, msgList) { if(msgList && 0 === msgList.length) { callback(new Error('No messages in area')); } else { @@ -141,7 +143,7 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { } }, function getLastReadMesageId(callback) { - messageArea.getMessageAreaLastReadId(self.client.user.userId, self.messageAreaName, function lastRead(err, lastReadId) { + messageArea.getMessageAreaLastReadId(self.client.user.userId, self.messageAreaTag, function lastRead(err, lastReadId) { self.lastReadId = lastReadId || 0; callback(null); // ignore any errors, e.g. missing value }); @@ -158,6 +160,13 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { var msgNum = 1; function getMsgFmtObj(mle) { + + if(_.isUndefined(firstNewEntryIndex) && + mle.messageId > self.lastReadId) + { + firstNewEntryIndex = msgNum - 1; + } + return { msgNum : msgNum++, subj : mle.subject, @@ -180,14 +189,18 @@ MessageListModule.prototype.mciReady = function(mciData, cb) { msgListView.on('index update', function indexUpdated(idx) { self.setViewText(MciCodesIds.MsgSelNum, (idx + 1).toString()); }); - + msgListView.redraw(); + + if(firstNewEntryIndex > 0) { + msgListView.setFocusItemIndex(firstNewEntryIndex); + } callback(null); }, function populateOtherMciViews(callback) { - self.setViewText(MciCodesIds.MsgAreaDesc, messageArea.getMessageAreaByName(self.messageAreaName).desc); + self.setViewText(MciCodesIds.MsgAreaDesc, messageArea.getMessageAreaByTag(self.messageAreaTag).name); self.setViewText(MciCodesIds.MsgSelNum, (vc.getView(MciCodesIds.MsgList).getData() + 1).toString()); self.setViewText(MciCodesIds.MsgTotal, self.messageList.length.toString()); diff --git a/mods/nua.js b/mods/nua.js index c2a955f2..7d83e518 100644 --- a/mods/nua.js +++ b/mods/nua.js @@ -5,7 +5,7 @@ var user = require('../core/user.js'); var theme = require('../core/theme.js'); var login = require('../core/system_menu_method.js').login; var Config = require('../core/config.js').config; -var getDefaultMessageArea = require('../core/message_area.js').getDefaultMessageArea; +var messageArea = require('../core/message_area.js'); var async = require('async'); @@ -65,6 +65,16 @@ function NewUserAppModule(options) { newUser.username = formData.value.username; + // + // We have to disable ACS checks for initial default areas as the user is not yet ready + // + var confTag = messageArea.getDefaultMessageConferenceTag(self.client, true); // true=disableAcsCheck + var areaTag = messageArea.getDefaultMessageAreaTagByConfTag(self.client, confTag, true); // true=disableAcsCheck + + // can't store undefined! + confTag = confTag || ''; + areaTag = areaTag || ''; + newUser.properties = { real_name : formData.value.realName, birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), @@ -74,14 +84,12 @@ function NewUserAppModule(options) { email_address : formData.value.email, web_address : formData.value.web, account_created : new Date().toISOString(), - - message_area_name : getDefaultMessageArea().name, + + message_conf_tag : confTag, + message_area_tag : areaTag, term_height : self.client.term.termHeight, - term_width : self.client.term.termWidth, - - // :TODO: This is set in User.create() -- proabbly don't need it here: - //account_status : Config.users.requireActivation ? user.User.AccountStatus.inactive : user.User.AccountStatus.active, + term_width : self.client.term.termWidth, // :TODO: Other defaults // :TODO: should probably have a place to create defaults/etc. @@ -92,8 +100,8 @@ function NewUserAppModule(options) { } else { newUser.properties.theme_id = Config.defaults.theme; } - - // :TODO: .create() should also validate email uniqueness! + + // :TODO: User.create() should validate email uniqueness! newUser.create( { password : formData.value.password }, function created(err) { if(err) { self.client.log.info( { error : err, username : formData.value.username }, 'New user creation failed'); diff --git a/mods/themes/luciano_blocktronics/CHANGE.ANS b/mods/themes/luciano_blocktronics/CHANGE.ANS index 22cd22d8..885b3cc9 100644 Binary files a/mods/themes/luciano_blocktronics/CHANGE.ANS and b/mods/themes/luciano_blocktronics/CHANGE.ANS differ diff --git a/mods/themes/luciano_blocktronics/DONE.ANS b/mods/themes/luciano_blocktronics/DONE.ANS index 3c72d019..dba94043 100644 Binary files a/mods/themes/luciano_blocktronics/DONE.ANS and b/mods/themes/luciano_blocktronics/DONE.ANS differ diff --git a/mods/themes/luciano_blocktronics/IDLELOG.ANS b/mods/themes/luciano_blocktronics/IDLELOG.ANS index 8397ffbe..bcde1ff7 100644 Binary files a/mods/themes/luciano_blocktronics/IDLELOG.ANS and b/mods/themes/luciano_blocktronics/IDLELOG.ANS differ diff --git a/mods/themes/luciano_blocktronics/LASTCALL.ANS b/mods/themes/luciano_blocktronics/LASTCALL.ANS index 7d94b170..4d0a0308 100644 Binary files a/mods/themes/luciano_blocktronics/LASTCALL.ANS and b/mods/themes/luciano_blocktronics/LASTCALL.ANS differ diff --git a/mods/themes/luciano_blocktronics/LETTER.ANS b/mods/themes/luciano_blocktronics/LETTER.ANS index 4d239593..8160319e 100644 Binary files a/mods/themes/luciano_blocktronics/LETTER.ANS and b/mods/themes/luciano_blocktronics/LETTER.ANS differ diff --git a/mods/themes/luciano_blocktronics/MATRIX.ANS b/mods/themes/luciano_blocktronics/MATRIX.ANS index 3a196643..4e183723 100644 Binary files a/mods/themes/luciano_blocktronics/MATRIX.ANS and b/mods/themes/luciano_blocktronics/MATRIX.ANS differ diff --git a/mods/themes/luciano_blocktronics/MMENU.ANS b/mods/themes/luciano_blocktronics/MMENU.ANS index f0e97e2d..35950215 100644 Binary files a/mods/themes/luciano_blocktronics/MMENU.ANS and b/mods/themes/luciano_blocktronics/MMENU.ANS differ diff --git a/mods/themes/luciano_blocktronics/MNUPRMT.ANS b/mods/themes/luciano_blocktronics/MNUPRMT.ANS index 0e741116..d25116c0 100644 Binary files a/mods/themes/luciano_blocktronics/MNUPRMT.ANS and b/mods/themes/luciano_blocktronics/MNUPRMT.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGBODY.ANS b/mods/themes/luciano_blocktronics/MSGBODY.ANS index 78771ade..2c78dc88 100644 Binary files a/mods/themes/luciano_blocktronics/MSGBODY.ANS and b/mods/themes/luciano_blocktronics/MSGBODY.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGEFTR.ANS b/mods/themes/luciano_blocktronics/MSGEFTR.ANS index f4b4f1ac..6472298a 100644 Binary files a/mods/themes/luciano_blocktronics/MSGEFTR.ANS and b/mods/themes/luciano_blocktronics/MSGEFTR.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGEHDR.ANS b/mods/themes/luciano_blocktronics/MSGEHDR.ANS index 70c36bbe..2687f5ee 100644 Binary files a/mods/themes/luciano_blocktronics/MSGEHDR.ANS and b/mods/themes/luciano_blocktronics/MSGEHDR.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGEHLP.ANS b/mods/themes/luciano_blocktronics/MSGEHLP.ANS index 168d7b14..273d7e5d 100644 Binary files a/mods/themes/luciano_blocktronics/MSGEHLP.ANS and b/mods/themes/luciano_blocktronics/MSGEHLP.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGEMFT.ANS b/mods/themes/luciano_blocktronics/MSGEMFT.ANS index aaa1ce3c..f009f7c3 100644 Binary files a/mods/themes/luciano_blocktronics/MSGEMFT.ANS and b/mods/themes/luciano_blocktronics/MSGEMFT.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGLIST.ANS b/mods/themes/luciano_blocktronics/MSGLIST.ANS index 9a8e6ce2..911f1f20 100644 Binary files a/mods/themes/luciano_blocktronics/MSGLIST.ANS and b/mods/themes/luciano_blocktronics/MSGLIST.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGMNU.ANS b/mods/themes/luciano_blocktronics/MSGMNU.ANS index 7e0ab8c8..e27fed73 100644 Binary files a/mods/themes/luciano_blocktronics/MSGMNU.ANS and b/mods/themes/luciano_blocktronics/MSGMNU.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGQUOT.ANS b/mods/themes/luciano_blocktronics/MSGQUOT.ANS index d313b228..e7382b77 100644 Binary files a/mods/themes/luciano_blocktronics/MSGQUOT.ANS and b/mods/themes/luciano_blocktronics/MSGQUOT.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGVFTR.ANS b/mods/themes/luciano_blocktronics/MSGVFTR.ANS index 81b10fd1..d5133959 100644 Binary files a/mods/themes/luciano_blocktronics/MSGVFTR.ANS and b/mods/themes/luciano_blocktronics/MSGVFTR.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGVHDR.ANS b/mods/themes/luciano_blocktronics/MSGVHDR.ANS index 83186837..754ccee6 100644 Binary files a/mods/themes/luciano_blocktronics/MSGVHDR.ANS and b/mods/themes/luciano_blocktronics/MSGVHDR.ANS differ diff --git a/mods/themes/luciano_blocktronics/MSGVHLP.ANS b/mods/themes/luciano_blocktronics/MSGVHLP.ANS index f3fffd9d..0320614d 100644 Binary files a/mods/themes/luciano_blocktronics/MSGVHLP.ANS and b/mods/themes/luciano_blocktronics/MSGVHLP.ANS differ diff --git a/mods/themes/luciano_blocktronics/NUA.ANS b/mods/themes/luciano_blocktronics/NUA.ANS index 15d3d15a..b57cc056 100644 Binary files a/mods/themes/luciano_blocktronics/NUA.ANS and b/mods/themes/luciano_blocktronics/NUA.ANS differ diff --git a/mods/themes/luciano_blocktronics/PAUSE.ANS b/mods/themes/luciano_blocktronics/PAUSE.ANS index 53bae432..09e0051c 100644 Binary files a/mods/themes/luciano_blocktronics/PAUSE.ANS and b/mods/themes/luciano_blocktronics/PAUSE.ANS differ diff --git a/mods/themes/luciano_blocktronics/STATUS.ANS b/mods/themes/luciano_blocktronics/STATUS.ANS index 33bc53e5..f119dfb9 100644 Binary files a/mods/themes/luciano_blocktronics/STATUS.ANS and b/mods/themes/luciano_blocktronics/STATUS.ANS differ diff --git a/mods/themes/luciano_blocktronics/SYSSTAT.ANS b/mods/themes/luciano_blocktronics/SYSSTAT.ANS index 2f3b7044..97beb53d 100644 Binary files a/mods/themes/luciano_blocktronics/SYSSTAT.ANS and b/mods/themes/luciano_blocktronics/SYSSTAT.ANS differ diff --git a/mods/themes/luciano_blocktronics/TOONODE.ANS b/mods/themes/luciano_blocktronics/TOONODE.ANS index a7da6e05..3bec6eae 100644 Binary files a/mods/themes/luciano_blocktronics/TOONODE.ANS and b/mods/themes/luciano_blocktronics/TOONODE.ANS differ diff --git a/mods/themes/luciano_blocktronics/USERLOG.ANS b/mods/themes/luciano_blocktronics/USERLOG.ANS index 6522f679..587254b3 100644 Binary files a/mods/themes/luciano_blocktronics/USERLOG.ANS and b/mods/themes/luciano_blocktronics/USERLOG.ANS differ diff --git a/mods/themes/luciano_blocktronics/USERLST.ANS b/mods/themes/luciano_blocktronics/USERLST.ANS index 48ee5b69..fa4e3499 100644 Binary files a/mods/themes/luciano_blocktronics/USERLST.ANS and b/mods/themes/luciano_blocktronics/USERLST.ANS differ diff --git a/mods/themes/luciano_blocktronics/WHOSON.ANS b/mods/themes/luciano_blocktronics/WHOSON.ANS index 3b083edf..53575482 100644 Binary files a/mods/themes/luciano_blocktronics/WHOSON.ANS and b/mods/themes/luciano_blocktronics/WHOSON.ANS differ diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/mods/themes/luciano_blocktronics/theme.hjson index 60d56059..44bd8131 100644 --- a/mods/themes/luciano_blocktronics/theme.hjson +++ b/mods/themes/luciano_blocktronics/theme.hjson @@ -172,8 +172,8 @@ messageAreaChangeCurrentArea: { config: { - listFormat: "|00|15{index} |07- |03{desc}" - focusListFormat: "|00|19|15{index} - {desc}" + listFormat: "|00|15{index} |07- |03{name}" + focusListFormat: "|00|19|15{index} - {name}" } mci: { VM1: { @@ -310,6 +310,19 @@ } } } + + newScanMessageList: { + config: { + listFormat: "|00|15 {msgNum:<5.5}|03{subj:<29.29} |15{from:<20.20} {ts}" + focusListFormat: "|00|19> |15{msgNum:<5.5}{subj:<29.29} {from:<20.20} {ts}" + dateTimeFormat: ddd MMM Do + } + mci: { + VM1: { + height: 14 + } + } + } } } } \ No newline at end of file diff --git a/mods/user_list.js b/mods/user_list.js index 59826753..5303e420 100644 --- a/mods/user_list.js +++ b/mods/user_list.js @@ -2,7 +2,7 @@ 'use strict'; var MenuModule = require('../core/menu_module.js').MenuModule; -var userDb = require('../core/database.js').dbs.user; +//var userDb = require('../core/database.js').dbs.user; var getUserList = require('../core/user.js').getUserList; var ViewController = require('../core/view_controller.js').ViewController; diff --git a/mods/whos_online.js b/mods/whos_online.js index 6b0ef4c1..a607ae02 100644 --- a/mods/whos_online.js +++ b/mods/whos_online.js @@ -84,7 +84,6 @@ WhosOnlineModule.prototype.mciReady = function(mciData, cb) { return listFormat.format(oe); })); - // :TODO: This is a hack until pipe codes are better implemented onlineListView.focusItems = onlineListView.items; onlineListView.redraw(); diff --git a/oputil.js b/oputil.js index 5eef5339..5087b9b1 100755 --- a/oputil.js +++ b/oputil.js @@ -13,7 +13,7 @@ var assert = require('assert'); var argv = require('minimist')(process.argv.slice(2)); -var ExitCodes = { +const ExitCodes = { SUCCESS : 0, ERROR : -1, BAD_COMMAND : -2, @@ -28,9 +28,13 @@ function printUsage(command) { usage = 'usage: oputil.js [--version] [--help]\n' + ' []' + - '\n' + + '\n\n' + 'global args:\n' + - ' --config PATH : specify config path'; + ' --config PATH : specify config path' + + '\n\n' + + 'commands:\n' + + ' user : User utilities' + + '\n'; break; case 'user' : @@ -47,7 +51,7 @@ function printUsage(command) { } function initConfig(cb) { - var configPath = argv.config ? argv.config : config.getDefaultPath(); + const configPath = argv.config ? argv.config : config.getDefaultPath(); config.init(configPath, cb); } @@ -88,7 +92,7 @@ function handleUserCommand() { assert(_.isNumber(userId)); assert(userId > 0); - var u = new user.User(); + let u = new user.User(); u.userId = userId; u.setNewAuthCredentials(argv.password, function credsSet(err) { diff --git a/package.json b/package.json index aed5721b..0d1389ff 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "async": "^1.5.1", "binary": "0.3.x", "buffers": "0.1.x", - "bunyan": "1.5.x", + "bunyan": "^1.7.1", "gaze": "^0.5.2", "hjson": "1.7.x", "iconv-lite": "^0.4.13", + "later": "1.2.0", "lodash": "^3.10.1", "minimist": "1.2.x", "mkdirp": "0.5.x", @@ -28,7 +29,8 @@ "ptyw.js": "^0.3.7", "sqlite3": "^3.1.1", "ssh2": "^0.4.13", - "string-format": "davidchambers/string-format#mini-language" + "string-format": "davidchambers/string-format#mini-language", + "temp": "^0.8.3" }, "engines": { "node": ">=0.12.2"