diff --git a/README.md b/README.md index 40c77f8a..89e9a434 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! -## Feature Available Now +## Features Available Now * Multi platform: Anywhere Node.js runs likely works (tested under Linux and OS X) * Multi node support * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods @@ -51,6 +51,7 @@ ENiGMA has been tested with many terminals. However, the following are suggested ## Boards * WQH: :skull: Xibalba :skull: (**telnet://xibalba.l33t.codes:44510**) * Support board: ☠ BLACK ƒlag ☠ (**telnet://blackflag.acid.org:2425**) +* HappyLand (**telnet://andrew.homeunix.org:2023**) ## Installation @@ -65,6 +66,7 @@ Please see the [Quickstart](docs/index.md#quickstart) * Jack Phlash for kick ass ENiGMA½ and Xibalba ASCII (Check out [IMPURE60](http://pc.textmod.es/pack/impure60/)!!) * Avon of [Agency BBS](http://bbs.geek.nz/) and fsxNet * Maskreet of [Throwback BBS](http://www.throwbackbbs.com/) hosting [DoorParty](http://forums.throwbackbbs.com/)! +* [Apam](https://github.com/apamment) of HappyLand BBS ## License Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license: diff --git a/core/database.js b/core/database.js index a3385509..924b60ed 100644 --- a/core/database.js +++ b/core/database.js @@ -1,17 +1,18 @@ /* jslint node: true */ 'use strict'; -var conf = require('./config.js'); - -var sqlite3 = require('sqlite3'); -var paths = require('path'); -var async = require('async'); +// ENiGMA½ +const conf = require('./config.js'); +// deps +const sqlite3 = require('sqlite3'); +const paths = require('path'); +const async = require('async'); const _ = require('lodash'); const assert = require('assert'); // database handles -var dbs = {}; +let dbs = {}; exports.getModDatabasePath = getModDatabasePath; exports.initializeDatabases = initializeDatabases; @@ -46,51 +47,52 @@ function getModDatabasePath(moduleInfo, suffix) { } function initializeDatabases(cb) { - // :TODO: this will need to change if more DB's are added ... why? async.series( [ function systemDb(callback) { - dbs.system = new sqlite3.Database(getDatabasePath('system'), function dbCreated(err) { + dbs.system = new sqlite3.Database(getDatabasePath('system'), err => { if(err) { - callback(err); - } else { - dbs.system.serialize(function serialized() { - createSystemTables(); - }); - callback(null); + return callback(err); } + + dbs.system.serialize( () => { + createSystemTables(); + }); + + return callback(null); }); }, function userDb(callback) { - dbs.user = new sqlite3.Database(getDatabasePath('user'), function dbCreated(err) { - if(err) { - callback(err); - } else { - dbs.user.serialize(function serialized() { - createUserTables(); - createInitialUserValues(); - }); - callback(null); + dbs.user = new sqlite3.Database(getDatabasePath('user'), err => { + if(err) { + return callback(err); } + + dbs.user.serialize( () => { + createUserTables(); + createInitialUserValues(); + }); + + return callback(null); }); }, function messageDb(callback) { - dbs.message = new sqlite3.Database(getDatabasePath('message'), function dbCreated(err) { + dbs.message = new sqlite3.Database(getDatabasePath('message'), err => { if(err) { - callback(err); - } else { - dbs.message.serialize(function serialized() { - createMessageBaseTables(); - createInitialMessageValues(); - }); - callback(null); + return callback(err); } + + + dbs.message.serialize(function serialized() { + createMessageBaseTables(); + createInitialMessageValues(); + }); + + return callback(null); }); } ], - function complete(err) { - cb(err); - } + cb ); } @@ -119,127 +121,135 @@ function createSystemTables() { function createUserTables() { dbs.user.run( - 'CREATE TABLE IF NOT EXISTS user (' + - ' id INTEGER PRIMARY KEY,' + - ' user_name VARCHAR NOT NULL,' + - ' UNIQUE(user_name)' + - ');' - ); + `CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY, + user_name VARCHAR NOT NULL, + UNIQUE(user_name) + );` + ); // :TODO: create FK on delete/etc. dbs.user.run( - 'CREATE TABLE IF NOT EXISTS user_property (' + - ' user_id INTEGER NOT NULL,' + - ' prop_name VARCHAR NOT NULL,' + - ' prop_value VARCHAR,' + - ' UNIQUE(user_id, prop_name),' + - ' FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE' + - ');' - ); + `CREATE TABLE IF NOT EXISTS user_property ( + user_id INTEGER NOT NULL, + prop_name VARCHAR NOT NULL, + prop_value VARCHAR, + UNIQUE(user_id, prop_name), + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE + );` + ); dbs.user.run( - 'CREATE TABLE IF NOT EXISTS user_group_member (' + - ' group_name VARCHAR NOT NULL,' + - ' user_id INTEGER NOT NULL,' + - ' UNIQUE(group_name, user_id)' + - ');' - ); + `CREATE TABLE IF NOT EXISTS user_group_member ( + group_name VARCHAR NOT NULL, + user_id INTEGER NOT NULL, + UNIQUE(group_name, user_id) + );` + ); dbs.user.run( - 'CREATE TABLE IF NOT EXISTS user_login_history (' + - ' user_id INTEGER NOT NULL,' + - ' user_name VARCHAR NOT NULL,' + - ' timestamp DATETIME NOT NULL' + - ');' + `CREATE TABLE IF NOT EXISTS user_login_history ( + user_id INTEGER NOT NULL, + user_name VARCHAR NOT NULL, + timestamp DATETIME NOT NULL + );` ); } function createMessageBaseTables() { dbs.message.run( - 'CREATE TABLE IF NOT EXISTS message (' + - ' message_id INTEGER PRIMARY KEY,' + - ' area_tag VARCHAR NOT NULL,' + - ' message_uuid VARCHAR(36) NOT NULL,' + - ' reply_to_message_id INTEGER,' + - ' to_user_name VARCHAR NOT NULL,' + - ' from_user_name VARCHAR NOT NULL,' + - ' subject,' + // FTS @ message_fts - ' message,' + // FTS @ message_fts - ' modified_timestamp DATETIME NOT NULL,' + - ' view_count INTEGER NOT NULL DEFAULT 0,' + - ' UNIQUE(message_uuid)' + - ');' + `CREATE TABLE IF NOT EXISTS message ( + message_id INTEGER PRIMARY KEY, + area_tag VARCHAR NOT NULL, + message_uuid VARCHAR(36) NOT NULL, + reply_to_message_id INTEGER, + to_user_name VARCHAR NOT NULL, + from_user_name VARCHAR NOT NULL, + subject, /* FTS @ message_fts */ + message, /* FTS @ message_fts */ + modified_timestamp DATETIME NOT NULL, + view_count INTEGER NOT NULL DEFAULT 0, + UNIQUE(message_uuid) + );` ); dbs.message.run( - 'CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4 (' + - ' content="message",' + - ' subject,' + - ' message' + - ');' + `CREATE INDEX IF NOT EXISTS message_by_area_tag_index + ON message (area_tag);` ); dbs.message.run( - 'CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN' + - ' DELETE FROM message_fts WHERE docid=old.rowid;' + - 'END;' + - 'CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN' + - ' DELETE FROM message_fts WHERE docid=old.rowid;' + - 'END;' + - '' + - 'CREATE TRIGGER IF NOT EXISTS message_after_update AFTER UPDATE ON message BEGIN' + - ' INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message);' + - 'END;' + - 'CREATE TRIGGER IF NOT EXISTS message_after_insert AFTER INSERT ON message BEGIN' + - ' INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message);' + - 'END;' + `CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4 ( + content="message", + subject, + message + );` + ); + + dbs.message.run( + `CREATE TRIGGER IF NOT EXISTS message_before_update BEFORE UPDATE ON message BEGIN + DELETE FROM message_fts WHERE docid=old.rowid; + END; + + CREATE TRIGGER IF NOT EXISTS message_before_delete BEFORE DELETE ON message BEGIN + DELETE FROM message_fts WHERE docid=old.rowid; + END; + + CREATE TRIGGER IF NOT EXISTS message_after_update AFTER UPDATE ON message BEGIN + INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); + END; + + CREATE TRIGGER IF NOT EXISTS message_after_insert AFTER INSERT ON message BEGIN + INSERT INTO message_fts(docid, subject, message) VALUES(new.rowid, new.subject, new.message); + END;` + ); + + dbs.message.run( + `CREATE TABLE IF NOT EXISTS message_meta ( + message_id INTEGER NOT NULL, + meta_category INTEGER NOT NULL, + meta_name VARCHAR NOT NULL, + meta_value VARCHAR NOT NULL, + UNIQUE(message_id, meta_category, meta_name, meta_value), + FOREIGN KEY(message_id) REFERENCES message(message_id) ON DELETE CASCADE + );` + ); + + // :TODO: need SQL to ensure cleaned up if delete from message? + /* + dbs.message.run( + `CREATE TABLE IF NOT EXISTS hash_tag ( + hash_tag_id INTEGER PRIMARY KEY, + hash_tag_name VARCHAR NOT NULL, + UNIQUE(hash_tag_name) + );` ); // :TODO: need SQL to ensure cleaned up if delete from message? dbs.message.run( - 'CREATE TABLE IF NOT EXISTS message_meta (' + - ' message_id INTEGER NOT NULL,' + - ' meta_category INTEGER NOT NULL,' + - ' meta_name VARCHAR NOT NULL,' + - ' meta_value VARCHAR NOT NULL,' + - ' UNIQUE(message_id, meta_category, meta_name, meta_value),' + // :TODO:why unique here? - ' FOREIGN KEY(message_id) REFERENCES message(message_id)' + - ');' - ); - - // :TODO: need SQL to ensure cleaned up if delete from message? - dbs.message.run( - 'CREATE TABLE IF NOT EXISTS hash_tag (' + - ' hash_tag_id INTEGER PRIMARY KEY,' + - ' hash_tag_name VARCHAR NOT NULL,' + - ' UNIQUE(hash_tag_name)' + - ');' - ); - - // :TODO: need SQL to ensure cleaned up if delete from message? - dbs.message.run( - 'CREATE TABLE IF NOT EXISTS message_hash_tag (' + - ' hash_tag_id INTEGER NOT NULL,' + - ' message_id INTEGER NOT NULL' + - ');' + `CREATE TABLE IF NOT EXISTS message_hash_tag ( + hash_tag_id INTEGER NOT NULL, + message_id INTEGER NOT NULL, + );` ); + */ dbs.message.run( - 'CREATE TABLE IF NOT EXISTS user_message_area_last_read (' + - ' user_id INTEGER NOT NULL,' + - ' area_tag VARCHAR NOT NULL,' + - ' message_id INTEGER NOT NULL,' + - ' UNIQUE(user_id, area_tag)' + - ');' + `CREATE TABLE IF NOT EXISTS user_message_area_last_read ( + user_id INTEGER NOT NULL, + area_tag VARCHAR NOT NULL, + message_id INTEGER NOT NULL, + UNIQUE(user_id, area_tag) + );` ); dbs.message.run( `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) + scan_toss VARCHAR NOT NULL, + area_tag VARCHAR NOT NULL, + message_id INTEGER NOT NULL, + UNIQUE(scan_toss, area_tag) );` ); } diff --git a/core/event_scheduler.js b/core/event_scheduler.js index b4243a7a..e6f94bd1 100644 --- a/core/event_scheduler.js +++ b/core/event_scheduler.js @@ -30,7 +30,7 @@ class ScheduledEvent { this.schedule = this.parseScheduleString(events[name].schedule); this.action = this.parseActionSpec(events[name].action); if(this.action) { - this.action.args = events[name].args; + this.action.args = events[name].args || []; } } diff --git a/core/ftn_util.js b/core/ftn_util.js index 11fb9a15..0860aeb4 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -4,10 +4,8 @@ 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'); @@ -18,8 +16,6 @@ let packageJson = require('../package.json'); // :TODO: Remove "Ftn" from most of these -- it's implied in the module exports.stringToNullPaddedBuffer = stringToNullPaddedBuffer; exports.getMessageSerialNumber = getMessageSerialNumber; -exports.createMessageUuid = createMessageUuid; -exports.createMessageUuidAlternate = createMessageUuidAlternate; exports.getDateFromFtnDateTime = getDateFromFtnDateTime; exports.getDateTimeString = getDateTimeString; @@ -96,45 +92,6 @@ function getDateTimeString(m) { return m.format('DD MMM YY HH:mm:ss'); } -// -// 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 getMessageSerialNumber(messageId) { const msSinceEnigmaEpoc = (Date.now() - Date.UTC(2016, 1, 1)); const hash = Math.abs(new FNV1a(msSinceEnigmaEpoc + messageId).value).toString(16); diff --git a/core/menu_module.js b/core/menu_module.js index 03f7e2d6..fc204001 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -246,15 +246,20 @@ MenuModule.prototype.leave = function() { this.detachViewControllers(); }; -MenuModule.prototype.beforeArt = function(cb) { - if(this.cls) { - this.client.term.write(ansi.resetScreen()); - } - +MenuModule.prototype.beforeArt = function(cb) { + // + // Set emulated baud rate - note that some terminals will display + // part of the ESC sequence here (generally a single 'r') if they + // do not support cterm style baud rates + // if(_.isNumber(this.menuConfig.options.baudRate)) { this.client.term.write(ansi.setEmulatedBaudRate(this.menuConfig.options.baudRate)); } + if(this.cls) { + this.client.term.write(ansi.resetScreen()); + } + return cb(null); }; diff --git a/core/menu_view.js b/core/menu_view.js index cf2aabd1..3c0524b9 100644 --- a/core/menu_view.js +++ b/core/menu_view.js @@ -1,13 +1,14 @@ /* jslint node: true */ 'use strict'; -var View = require('./view.js').View; -var ansi = require('./ansi_term.js'); -var miscUtil = require('./misc_util.js'); +// ENiGMA½ +const View = require('./view.js').View; +const miscUtil = require('./misc_util.js'); -var util = require('util'); -var assert = require('assert'); -var _ = require('lodash'); +// deps +const util = require('util'); +const assert = require('assert'); +const _ = require('lodash'); exports.MenuView = MenuView; @@ -17,7 +18,7 @@ function MenuView(options) { View.call(this, options); - var self = this; + const self = this; if(options.items) { this.setItems(options.items); @@ -47,7 +48,7 @@ function MenuView(options) { this.getHotKeyItemIndex = function(ch) { if(ch && self.hotKeys) { - var keyIndex = self.hotKeys[self.caseInsensitiveHotKeys ? ch.toLowerCase() : ch]; + const keyIndex = self.hotKeys[self.caseInsensitiveHotKeys ? ch.toLowerCase() : ch]; if(_.isNumber(keyIndex)) { return keyIndex; } @@ -59,11 +60,10 @@ function MenuView(options) { util.inherits(MenuView, View); MenuView.prototype.setItems = function(items) { - var self = this; if(items) { - this.items = []; // :TODO: better way? - items.forEach(function item(itemText) { - self.items.push( { text : itemText } ); + this.items = []; + items.forEach( itemText => { + this.items.push( { text : itemText } ); }); } }; @@ -72,9 +72,9 @@ MenuView.prototype.getCount = function() { return this.items.length; }; -MenuView.prototype.getItems = function() { - return _.map(this.items, function itemIter(i) { - return i.text; +MenuView.prototype.getItems = function() { + return this.items.map( item => { + return item.text; }); }; @@ -97,7 +97,7 @@ MenuView.prototype.setFocusItemIndex = function(index) { }; MenuView.prototype.onKeyPress = function(ch, key) { - var itemIndex = this.getHotKeyItemIndex(ch); + const itemIndex = this.getHotKeyItemIndex(ch); if(itemIndex >= 0) { this.setFocusItemIndex(itemIndex); @@ -110,12 +110,10 @@ MenuView.prototype.onKeyPress = function(ch, key) { }; MenuView.prototype.setFocusItems = function(items) { - var self = this; - if(items) { this.focusItems = []; - items.forEach(function item(itemText) { - self.focusItems.push( { text : itemText } ); + items.forEach( itemText => { + this.focusItems.push( { text : itemText } ); }); } }; @@ -130,11 +128,11 @@ MenuView.prototype.setItemSpacing = function(itemSpacing) { MenuView.prototype.setPropertyValue = function(propName, value) { switch(propName) { - case 'itemSpacing' : this.setItemSpacing(value); break; - case 'items' : this.setItems(value); break; - case 'focusItems' : this.setFocusItems(value); break; - case 'hotKeys' : this.setHotKeys(value); break; - case 'hotKeySubmit' : this.hotKeySubmit = value; break; + case 'itemSpacing' : this.setItemSpacing(value); break; + case 'items' : this.setItems(value); break; + case 'focusItems' : this.setFocusItems(value); break; + case 'hotKeys' : this.setHotKeys(value); break; + case 'hotKeySubmit' : this.hotKeySubmit = value; break; } MenuView.super_.prototype.setPropertyValue.call(this, propName, value); diff --git a/core/message.js b/core/message.js index 9bffe415..82536349 100644 --- a/core/message.js +++ b/core/message.js @@ -4,21 +4,30 @@ let msgDb = require('./database.js').dbs.message; let wordWrapText = require('./word_wrap.js').wordWrapText; let ftnUtil = require('./ftn_util.js'); +let createNamedUUID = require('./uuid_util.js').createNamedUUID; let uuid = require('node-uuid'); let async = require('async'); let _ = require('lodash'); let assert = require('assert'); let moment = require('moment'); +const iconvEncode = require('iconv-lite').encode; module.exports = Message; +const ENIGMA_MESSAGE_UUID_NAMESPACE = uuid.parse('154506df-1df8-46b9-98f8-ebb5815baaf8'); + function Message(options) { options = options || {}; this.messageId = options.messageId || 0; // always generated @ persist this.areaTag = options.areaTag || Message.WellKnownAreaTags.Invalid; - this.uuid = options.uuid || uuid.v1(); + + if(options.uuid) { + // note: new messages have UUID generated @ time of persist. See also Message.createMessageUUID() + this.uuid = options.uuid; + } + this.replyToMsgId = options.replyToMsgId || 0; this.toUserName = options.toUserName || ''; this.fromUserName = options.fromUserName || ''; @@ -110,6 +119,24 @@ Message.prototype.setLocalFromUserId = function(userId) { this.meta.System.local_from_user_id = userId; }; +Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) { + assert(_.isString(areaTag)); + assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp)); + assert(_.isString(subject)); + assert(_.isString(body)); + + if(!moment.isMoment(modTimestamp)) { + modTimestamp = moment(modTimestamp); + } + + areaTag = iconvEncode(areaTag.toUpperCase(), 'CP437'); + modTimestamp = iconvEncode(modTimestamp.format('DD MMM YY HH:mm:ss'), 'CP437'); + subject = iconvEncode(subject.toUpperCase().trim(), 'CP437'); + body = iconvEncode(body.replace(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g, '').trim(), 'CP437'); + + return uuid.unparse(createNamedUUID(ENIGMA_MESSAGE_UUID_NAMESPACE, Buffer.concat( [ areaTag, modTimestamp, subject, body ] ))); +} + Message.getMessageIdByUuid = function(uuid, cb) { msgDb.get( `SELECT message_id @@ -330,10 +357,20 @@ Message.prototype.persist = function(cb) { }); }, function storeMessage(callback) { + // generate a UUID for this message if required (general case) + const msgTimestamp = moment(); + if(!self.uuid) { + self.uuid = Message.createMessageUUID( + self.areaTag, + msgTimestamp, + self.subject, + self.message); + } + msgDb.run( `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) ], + [ self.areaTag, self.uuid, self.replyToMsgId, self.toUserName, self.fromUserName, self.subject, self.message, self.getMessageTimestampString(msgTimestamp) ], function inserted(err) { // use for this scope if(!err) { self.messageId = this.lastID; diff --git a/core/module_util.js b/core/module_util.js index 6fa23598..e60d129a 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -24,14 +24,29 @@ function loadModuleEx(options, cb) { const modConfig = _.isObject(Config[options.category]) ? Config[options.category][options.name] : null; if(_.isObject(modConfig) && false === modConfig.enabled) { - return cb(new Error('Module "' + options.name + '" is disabled')); + return cb(new Error(`Module "${options.name}" is disabled`)); } + // + // Modules are allowed to live in /path/to//.js or + // simply in /path/to/.js. This allows for more advanced modules + // to have their own containing folder, package.json & dependencies, etc. + // let mod; + let modPath = paths.join(options.path, `${options.name}.js`); // general case first try { - mod = require(paths.join(options.path, options.name + '.js')); + mod = require(modPath); } catch(e) { - return cb(e); + if('MODULE_NOT_FOUND' === e.code) { + modPath = paths.join(options.path, options.name, `${options.name}.js`); + try { + mod = require(modPath); + } catch(e) { + return cb(e); + } + } else { + return cb(e); + } } if(!_.isObject(mod.moduleInfo)) { @@ -45,7 +60,7 @@ function loadModuleEx(options, cb) { // Ref configuration, if any, for convience to the module mod.runtime = { config : modConfig }; - cb(null, mod); + return cb(null, mod); } function loadModule(name, category, cb) { @@ -56,7 +71,7 @@ function loadModule(name, category, cb) { } loadModuleEx( { name : name, path : path, category : category }, function loaded(err, mod) { - cb(err, mod); + return cb(err, mod); }); } @@ -74,11 +89,11 @@ function loadModulesForCategory(category, iterator, complete) { async.each(jsModules, (file, next) => { loadModule(paths.basename(file, '.js'), category, (err, mod) => { iterator(err, mod); - next(); + return next(); }); }, err => { if(complete) { - complete(err); + return complete(err); } }); }); diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 030ce380..0bc4621b 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -23,6 +23,7 @@ const assert = require('assert'); const gaze = require('gaze'); const fse = require('fs-extra'); const iconv = require('iconv-lite'); +const uuid = require('node-uuid'); exports.moduleInfo = { name : 'FTN BSO', @@ -192,11 +193,11 @@ function FTNMessageScanTossModule() { 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; + 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; @@ -307,8 +308,8 @@ function FTNMessageScanTossModule() { // 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; + case 'crash' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Crash; break; + case 'hold' : ftnAttribute |= ftnMailPacket.Packet.Attribute.Hold; break; // :TODO: Others? } @@ -783,9 +784,13 @@ function FTNMessageScanTossModule() { } Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.REPLY, (err, msgIds) => { - if(msgIds && msgIds.length > 0) { - assert(1 === msgIds.length); - message.replyToMsgId = msgIds[0]; + if(msgIds) { + // expect a single match, but dupe checking is not perfect - warn otherwise + if(1 === msgIds.length) { + message.replyToMsgId = msgIds[0]; + } else { + Log.warn( { msgIds : msgIds, replyKludge : message.meta.FtnKludge.REPLY }, 'Found 2:n MSGIDs matching REPLY kludge!'); + } } cb(); }); @@ -800,28 +805,35 @@ function FTNMessageScanTossModule() { callback(_.isString(localNetworkName) ? null : new Error('Packet destination is not us')); }, + function checkForDupeMSGID(callback) { + // + // If we have a MSGID, don't allow a dupe + // + if(!_.has(message.meta, 'FtnKludge.MSGID')) { + return callback(null); + } + + Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, (err, msgIds) => { + if(msgIds && msgIds.length > 0) { + const err = new Error('Duplicate MSGID'); + err.code = 'DUPE_MSGID'; + return callback(err); + } + + return callback(null); + }); + }, 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); - } + // If we *allow* dupes (disabled by default), then just generate + // a random UUID. Otherwise, don't assign the UUID just yet. It will be + // generated at persist() time and should be consistent across import/exports + // + if(Config.messageNetworks.ftn.areas[localAreaTag].allowDupes) { + // just generate a UUID & therefor always allow for dupes + message.uuid = uuid.v1(); } callback(null); @@ -846,6 +858,16 @@ function FTNMessageScanTossModule() { } ); }; + + this.appendTearAndOrigin = function(message) { + if(message.meta.FtnProperty.ftn_tear_line) { + message.message += `\r\n${message.meta.FtnProperty.ftn_tear_line}\r\n`; + } + + if(message.meta.FtnProperty.ftn_origin) { + message.message += `${message.meta.FtnProperty.ftn_origin}\r\n`; + } + }; // // Ref. implementations on import: @@ -855,7 +877,7 @@ function FTNMessageScanTossModule() { this.importMessagesFromPacketFile = function(packetPath, password, cb) { let packetHeader; - const packetOpts = { keepTearAndOrigin : true }; + const packetOpts = { keepTearAndOrigin : false }; // needed so we can calc message UUID without these; we'll add later let importStats = { areaSuccess : {}, // areaTag->count @@ -879,21 +901,30 @@ function FTNMessageScanTossModule() { } else if('message' === entryType) { const message = entryData; const areaTag = message.meta.FtnProperty.ftn_area; - + if(areaTag) { // // EchoMail // const localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); if(localAreaTag) { + message.uuid = Message.createMessageUUID( + localAreaTag, + message.modTimestamp, + message.subject, + message.message); + + self.appendTearAndOrigin(message); + self.importEchoMailToArea(localAreaTag, packetHeader, message, err => { if(err) { // bump area fail stats importStats.areaFail[localAreaTag] = (importStats.areaFail[localAreaTag] || 0) + 1; - if('SQLITE_CONSTRAINT' === err.code) { + if('SQLITE_CONSTRAINT' === err.code || 'DUPE_MSGID' === err.code) { + const msgId = _.has(message.meta, 'FtnKludge.MSGID') ? message.meta.FtnKludge.MSGID : 'N/A'; Log.info( - { area : localAreaTag, subject : message.subject, uuid : message.uuid }, + { area : localAreaTag, subject : message.subject, uuid : message.uuid, MSGID : msgId }, 'Not importing non-unique message'); return next(null); diff --git a/core/theme.js b/core/theme.js index 5fd5db75..6f4b56f9 100644 --- a/core/theme.js +++ b/core/theme.js @@ -384,7 +384,29 @@ function getThemeArt(options, cb) { // async.waterfall( [ - function fromSuppliedTheme(callback) { + function fromPath(callback) { + // + // We allow relative (to enigma-bbs) or full paths + // + if('/' === options.name[0]) { + // just take the path as-is + options.basePath = paths.dirname(options.name); + } else if(options.name.indexOf('/') > -1) { + // make relative to base BBS dir + options.basePath = paths.join(__dirname, '../', paths.dirname(options.name)); + } else { + return callback(null, null); + } + + art.getArt(options.name, options, (err, artInfo) => { + return callback(null, artInfo); + }); + }, + function fromSuppliedTheme(artInfo, callback) { + if(artInfo) { + return callback(null, artInfo); + } + options.basePath = paths.join(Config.paths.themes, options.themeId); art.getArt(options.name, options, function artLoaded(err, artInfo) { @@ -563,10 +585,9 @@ function displayThemedAsset(assetSpec, client, options, cb) { options = {}; } - var artAsset = asset.getArtAsset(assetSpec); + const artAsset = asset.getArtAsset(assetSpec); if(!artAsset) { - cb(new Error('Asset not found: ' + assetSpec)); - return; + return cb(new Error('Asset not found: ' + assetSpec)); } // :TODO: just use simple merge of options -> displayOptions @@ -578,24 +599,23 @@ function displayThemedAsset(assetSpec, client, options, cb) { }; switch(artAsset.type) { - case 'art' : - displayThemeArt(dispOpts, function displayed(err, artData) { - cb(err, err ? null : { mciMap : artData.mciMap, height : artData.extraInfo.height } ); - }); - break; + case 'art' : + displayThemeArt(dispOpts, function displayed(err, artData) { + return cb(err, err ? null : { mciMap : artData.mciMap, height : artData.extraInfo.height } ); + }); + break; - case 'method' : - // :TODO: fetch & render via method - break; + case 'method' : + // :TODO: fetch & render via method + break; - case 'inline ' : - // :TODO: think about this more in relation to themes, etc. How can this come - // from a theme (with override from menu.json) ??? - // look @ client.currentTheme.inlineArt[name] -> menu/prompt[name] - break; + case 'inline ' : + // :TODO: think about this more in relation to themes, etc. How can this come + // from a theme (with override from menu.json) ??? + // look @ client.currentTheme.inlineArt[name] -> menu/prompt[name] + break; - default : - cb(new Error('Unsupported art asset type: ' + artAsset.type)); - break; + default : + return cb(new Error('Unsupported art asset type: ' + artAsset.type)); } } \ No newline at end of file diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index c220b557..7deed5c3 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -1,15 +1,14 @@ /* jslint node: true */ 'use strict'; -var MenuView = require('./menu_view.js').MenuView; -var ansi = require('./ansi_term.js'); -var strUtil = require('./string_util.js'); -var miscUtil = require('./misc_util.js'); -var colorCodes = require('./color_codes.js'); +// ENiGMA½ +const MenuView = require('./menu_view.js').MenuView; +const ansi = require('./ansi_term.js'); +const strUtil = require('./string_util.js'); +const colorCodes = require('./color_codes.js'); -var util = require('util'); -var assert = require('assert'); -var _ = require('lodash'); +// deps +const util = require('util'); exports.VerticalMenuView = VerticalMenuView; @@ -19,7 +18,7 @@ function VerticalMenuView(options) { MenuView.call(this, options); - var self = this; + const self = this; this.performAutoScale = function() { if(this.autoScale.height) { @@ -28,13 +27,13 @@ function VerticalMenuView(options) { } if(self.autoScale.width) { - var l = 0; - self.items.forEach(function item(i) { - if(i.text.length > l) { - l = Math.min(i.text.length, self.client.term.termWidth - self.position.col); + let maxLen = 0; + self.items.forEach( item => { + if(item.text.length > maxLen) { + maxLen = Math.min(item.text.length, self.client.term.termWidth - self.position.col); } }); - self.dimens.width = l + 1; + self.dimens.width = maxLen + 1; } }; @@ -66,14 +65,14 @@ function VerticalMenuView(options) { */ this.drawItem = function(index) { - var item = self.items[index]; + const item = self.items[index]; if(!item) { return; } - var focusItem; - var text; + let focusItem; + let text; if(self.hasFocusItems()) { focusItem = self.focusItems[index]; @@ -109,7 +108,7 @@ function VerticalMenuView(options) { util.inherits(VerticalMenuView, MenuView); VerticalMenuView.prototype.redraw = function() { - VerticalMenuView.super_.prototype.redraw.call(this); + VerticalMenuView.super_.prototype.redraw.call(this); // :TODO: rename positionCacheExpired to something that makese sense; combine methods for such if(this.positionCacheExpired) { @@ -119,8 +118,24 @@ VerticalMenuView.prototype.redraw = function() { this.positionCacheExpired = false; } - var row = this.position.row; - for(var i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) { + // erase old items + // :TODO: optimize this: only needed if a item is removed or new max width < old. + if(this.oldDimens) { + const blank = new Array(Math.max(this.oldDimens.width, this.dimens.width)).join(' '); + let seq = ansi.goto(this.position.row, this.position.col) + this.getSGR() + blank; + let row = this.position.row + 1; + const endRow = (row + this.oldDimens.height) - 2; + + while(row < endRow) { + seq += ansi.goto(row, this.position.col) + blank; + row += 1; + } + this.client.term.write(seq); + delete this.oldDimens; + } + + let row = this.position.row; + for(let i = this.viewWindow.top; i <= this.viewWindow.bottom; ++i) { this.items[i].row = row; row += this.itemSpacing + 1; this.items[i].focused = this.focusedItemIndex === i; @@ -181,6 +196,11 @@ VerticalMenuView.prototype.getData = function() { }; VerticalMenuView.prototype.setItems = function(items) { + // if we have items already, save off their drawing area so we don't leave fragments at redraw + if(this.items && this.items.length) { + this.oldDimens = this.dimens; + } + VerticalMenuView.super_.prototype.setItems.call(this, items); this.positionCacheExpired = true; diff --git a/docs/config.md b/docs/config.md index afdcf4e2..00ea751e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -6,6 +6,9 @@ The main system configuration file, `config.hjson` both overrides defaults and p **Windows note**: **~** resolves to *C:\Users\YOURLOGINNAME\* on modern installations, e.g. *C:\Users\NuSkooler\\.config\enigma-bbs\config.hjson* +### oputil.js +Please see `oputil.js config` for configuration generation options. + ### Example: System Name `core/config.js` provides the default system name as follows: ```javascript diff --git a/docs/index.md b/docs/index.md index 6e2e4bb1..78b75e5c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,15 @@ openssl genrsa -des3 -out ./misc/ssh_private_key.pem 2048 ## Create a Minimal Config The main system configuration is handled via `~/.config/enigma-bbs/config.hjson`. This is a [HJSON](http://hjson.org/) file (compiliant JSON is also OK). See [Configuration](config.md) for more information. +### Via oputil.js +`oputil.js` can be utilized to generate your **initial** configuration. **This is the recommended way for all new users**: + + optutil.js config --new + +You wil be asked a series of basic questions. + +### Example Starting Configuration + ```hjson { general: { @@ -79,13 +88,18 @@ The main system configuration is handled via `~/.config/enigma-bbs/config.hjson` ./main.js ``` +## Monitoring Logs +Logs are produced by Bunyan which outputs each entry as a JSON object. To tail logs in a colorized and pretty pretty format, issue the following command: + + tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | /path/to/enigma-bbs/node_modules/bunyan/bin/bunyan + ENiGMA½ does not produce much to standard out. See below for tailing the log file to see what's going on. ### Points of Interest * Default ports are 8888 (Telnet) and 8889 (SSH) * Note that on *nix systems port such as telnet/23 are privileged (e.g. require root). See [this SO article](http://stackoverflow.com/questions/16573668/best-practices-when-running-node-js-with-port-80-ubuntu-linode) for some tips on using these ports on your system if desired. -* The first user you create via applying is the SysOp (aka root) -* You may want to tail the logfile with Bunyan: `tail -F ./logs/enigma-bbs.log | ./node_modules/bunyan/bin/bunyan` +* **The first user you create via applying is the SysOp** (aka root) +* You may want to tail the logfile with Bunyan. See Monitoring Logs above. # Advanced Installation If you've become convinced you would like a "production" BBS running ENiGMA½ a more advanced installation may be in order. diff --git a/mods/onelinerz.js b/mods/onelinerz.js index 80bd301e..6244e968 100644 --- a/mods/onelinerz.js +++ b/mods/onelinerz.js @@ -14,6 +14,15 @@ const async = require('async'); const _ = require('lodash'); const moment = require('moment'); +/* + Module :TODO: + * Add pipe code support + - override max length & monitor *display* len as user types in order to allow for actual display len with color + * Add preview control: Shows preview with pipe codes resolved + * Add ability to at least alternate formatStrings -- every other +*/ + + exports.moduleInfo = { name : 'Onelinerz', desc : 'Standard local onelinerz', @@ -147,6 +156,11 @@ function OnelinerzModule(options) { entriesView.focusItems = entriesView.items; // :TODO: this is a hack entriesView.redraw(); + return callback(null); + }, + function finalPrep(callback) { + const promptView = self.viewControllers.view.getView(MciCodeIds.ViewForm.AddPrompt); + promptView.setFocusItemIndex(1); // default to NO return callback(null); } ], diff --git a/mods/themes/luciano_blocktronics/ONEADD.ANS b/mods/themes/luciano_blocktronics/ONEADD.ANS new file mode 100644 index 00000000..d5b84759 Binary files /dev/null and b/mods/themes/luciano_blocktronics/ONEADD.ANS differ diff --git a/mods/themes/luciano_blocktronics/ONELINER.ANS b/mods/themes/luciano_blocktronics/ONELINER.ANS index b494dc7b..e41069e0 100644 Binary files a/mods/themes/luciano_blocktronics/ONELINER.ANS and b/mods/themes/luciano_blocktronics/ONELINER.ANS differ diff --git a/oputil.js b/oputil.js index d1af5e9a..ea75b272 100755 --- a/oputil.js +++ b/oputil.js @@ -27,34 +27,37 @@ const ExitCodes = { BAD_ARGS : -3, } +const USAGE_HELP = { + General : +`usage: optutil.js [--version] [--help] + [] + +global args: + --config PATH : specify config path (${getDefaultConfigPath()}) + +commands: + user : user utilities + config : config file management + +`, + User : +`usage: optutil.js user --user USERNAME + +valid args: + --user USERNAME : specify username + -- password PASS : specify password (to reset) +`, + + Config : +`usage: optutil.js config + +valid args: + --new : generate a new/initial configuration +` +} + function printUsage(command) { - var usage; - - switch(command) { - case '' : - usage = - 'usage: oputil.js [--version] [--help]\n' + - ' []' + - '\n\n' + - 'global args:\n' + - ' --config PATH : specify config path' + - '\n\n' + - 'commands:\n' + - ' user : User utilities' + - '\n'; - break; - - case 'user' : - usage = - 'usage: optutil.js user --user USERNAME \n' + - '\n' + - 'valid args:\n' + - ' --user USERNAME : specify username\n' + - ' --password PASS : reset password to PASS'; - break; - } - - console.error(usage); + console.error(USAGE_HELP[command]); } function initConfig(cb) { @@ -66,7 +69,7 @@ function initConfig(cb) { function handleUserCommand() { if(true === argv.help || !_.isString(argv.user) || 0 === argv.user.length) { process.exitCode = ExitCodes.ERROR; - return printUsage('user'); + return printUsage('User'); } if(_.isString(argv.password)) { @@ -142,7 +145,7 @@ const QUESTIONS = { { name : 'configPath', message : 'Configuration path:', - default : getDefaultConfigPath(), + default : argv.config ? argv.config : getDefaultConfigPath(), when : answers => answers.createNewConfig }, ], @@ -334,21 +337,30 @@ function askQuestions(cb) { } function handleConfigCommand() { - askQuestions( (err, configPath, config) => { - if(err) { - return; - } - - config = hjson.stringify(config, { bracesSameLine : true, spaces : '\t' } ); - - try { - fs.writeFileSync(configPath, config, 'utf8'); - console.info('Configuration generated'); - } catch(e) { - console.error('Exception attempting to create config: ' + e.toString()); - } - }); - + if(true === argv.help) { + process.exitCode = ExitCodes.ERROR; + return printUsage('Config'); + } + + if(argv.new) { + askQuestions( (err, configPath, config) => { + if(err) { + return; + } + + config = hjson.stringify(config, { bracesSameLine : true, spaces : '\t' } ); + + try { + fs.writeFileSync(configPath, config, 'utf8'); + console.info('Configuration generated'); + } catch(e) { + console.error('Exception attempting to create config: ' + e.toString()); + } + }); + } else { + process.exitCode = ExitCodes.ERROR; + return printUsage('Config'); + } } function main() { @@ -362,7 +374,7 @@ function main() { if(0 === argv._.length || 'help' === argv._[0]) { - printUsage(''); + printUsage('General'); process.exit(ExitCodes.SUCCESS); } diff --git a/package.json b/package.json index 51d5b29b..0dba8a29 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "ssh2": "^0.4.13", "string-format": "davidchambers/string-format#mini-language", "temp": "^0.8.3", - "fs-extra" : "0.26.x" + "inquirer" : "^1.1.0", + "fs-extra" : "0.26.x" }, "engines": { "node": ">=0.12.2"