From 3f942871ae7ec9533093e2d4a1c14b213af608c4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 7 Sep 2017 21:21:24 -0600 Subject: [PATCH 001/102] * Update packages * Remove application/x-arj from mimeUtils hack - is included in mime-db now. --- core/mime_util.js | 1 - package.json | 32 ++++++++++++++++---------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/core/mime_util.js b/core/mime_util.js index bdb88a53..1e2bd32c 100644 --- a/core/mime_util.js +++ b/core/mime_util.js @@ -14,7 +14,6 @@ function startup(cb) { // Add in types (not yet) supported by mime-db -- and therefor, mime-types // const ADDITIONAL_EXT_MIMETYPES = { - arj : 'application/x-arj', ans : 'text/x-ansi', gz : 'application/gzip', // not in mime-types 2.1.15 :( }; diff --git a/package.json b/package.json index ec508841..219a4290 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "enigma-bbs", - "version": "0.0.7-alpha", + "version": "0.0.8-alpha", "description": "ENiGMA½ Bulletin Board System", "author": "Bryan Ashby ", "license": "BSD-2-Clause", @@ -19,34 +19,34 @@ "retro" ], "dependencies": { - "async": "^2.4.0", + "async": "^2.5.0", "binary": "0.3.x", "buffers": "NuSkooler/node-buffers", - "bunyan": "^1.8.10", - "farmhash": "^1.2.1", - "fs-extra": "^3.0.1", + "bunyan": "^1.8.12", + "exiftool": "^0.0.3", + "farmhash": "^2.0.1", + "fs-extra": "^4.0.1", "gaze": "^1.1.2", + "graceful-fs": "^4.1.11", "hashids": "^1.1.1", - "hjson": "^2.4.2", - "iconv-lite": "^0.4.17", - "inquirer": "^3.0.6", + "hjson": "^3.1.0", + "iconv-lite": "^0.4.18", + "inquirer": "^3.2.3", "later": "1.2.0", "lodash": "^4.17.4", - "mime-types": "^2.1.15", + "mime-types": "^2.1.17", "minimist": "1.2.x", "moment": "^2.18.1", - "nodemailer": "^4.0.1", + "node-glob": "^1.2.0", + "nodemailer": "^4.1.0", "ptyw.js": "NuSkooler/ptyw.js", "sanitize-filename": "^1.6.1", - "sqlite3": "^3.1.1", + "sqlite3": "^3.1.9", "ssh2": "^0.5.5", "temptmp": "^1.0.0", - "uuid": "^3.0.1", + "uuid": "^3.1.0", "uuid-parse": "^1.0.0", - "ws" : "^3.0.0", - "graceful-fs" : "^4.1.11", - "exiftool" : "^0.0.3", - "node-glob" : "^1.2.0" + "ws": "^3.1.0" }, "devDependencies": {}, "engines": { From 79e410315c83afbe6dff3199d9c4d630886e0b6f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 8 Sep 2017 23:07:11 -0600 Subject: [PATCH 002/102] Remove a extra line when quoting --- core/fse.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/fse.js b/core/fse.js index ed9d3b13..2ccc6951 100644 --- a/core/fse.js +++ b/core/fse.js @@ -995,14 +995,14 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul const quoteMsgView = this.viewControllers.quoteBuilder.getView(1); const msgView = this.viewControllers.body.getView(1); - let quoteLines = quoteMsgView.getData(); + let quoteLines = quoteMsgView.getData().trim(); - if(quoteLines.trim().length > 0) { + if(quoteLines.length > 0) { if(this.replyIsAnsi) { const bodyMessageView = this.viewControllers.body.getView(1); quoteLines += `${ansi.normal()}${bodyMessageView.getSGRFor('text')}`; } - msgView.addText(`${quoteLines}\n`); + msgView.addText(`${quoteLines}\n\n`); } quoteMsgView.setText(''); From 18461e594adf4bd1de80d538daa41df4c9947eb1 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 8 Sep 2017 23:11:01 -0600 Subject: [PATCH 003/102] Add --update option to fb scan --- core/oputil/oputil_file_base.js | 85 +++++++++++++++++++++++++-------- core/oputil/oputil_help.js | 1 + 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index 5cbe10e3..3f791267 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -34,7 +34,7 @@ exports.handleFileBaseCommand = handleFileBaseCommand; let fileArea; // required during init -function finalizeEntryAndPersist(fileEntry, descHandler, cb) { +function finalizeEntryAndPersist(isUpdate, fileEntry, descHandler, cb) { async.series( [ function getDescFromHandlerIfNeeded(callback) { @@ -53,18 +53,24 @@ function finalizeEntryAndPersist(fileEntry, descHandler, cb) { return callback(null); }, function getDescFromUserIfNeeded(callback) { - if(false === argv.prompt || ( fileEntry.desc && fileEntry.desc.length > 0 ) ) { + if(fileEntry.desc && fileEntry.desc.length > 0 ) { return callback(null); } - const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; + const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; + const descFromFile = getDescFromFileName(fileEntry.fileName); + + if(false === argv.prompt) { + fileEntry.desc = descFromFile; + return callback(null); + } const questions = [ { name : 'desc', message : `Description for ${fileEntry.fileName}:`, type : 'input', - default : getDescFromFileName(fileEntry.fileName), + default : descFromFile, } ]; @@ -74,7 +80,7 @@ function finalizeEntryAndPersist(fileEntry, descHandler, cb) { }); }, function persist(callback) { - fileEntry.persist( err => { + fileEntry.persist(isUpdate, err => { return callback(err); }); } @@ -104,6 +110,12 @@ function scanFileAreaForChanges(areaInfo, options, cb) { return !asi.storageTag || sl.storageTag === asi.storageTag; }); }); + + function updateTags(fe) { + if(Array.isArray(options.tags)) { + fe.hashTags = new Set(options.tags); + } + } async.eachSeries(storageLocations, (storageLoc, nextLocation) => { async.waterfall( @@ -153,27 +165,58 @@ function scanFileAreaForChanges(areaInfo, options, cb) { }, (err, fileEntry, dupeEntries) => { if(err) { - // :TODO: Log me!!! console.info(`Error: ${err.message}`); return nextFile(null); // try next anyway - } + } - if(dupeEntries.length > 0) { - // :TODO: Handle duplidates -- what to do here??? + // + // We'll update the entry if the following conditions are met: + // * We have a single duplicate, and: + // * --update-desc was passed or the existing entry's desc or + // longDesc are blank/empty + // + if(argv['update'] && 1 === dupeEntries.length) { + const FileEntry = require('../../core/file_entry.js'); + const existingEntry = new FileEntry(); + + return existingEntry.load(dupeEntries[0].fileId, err => { + if(err) { + console.info('Dupe (cannot update)'); + return nextFile(null); + } + + // + // Update only if tags or desc changed + // + const optTags = Array.isArray(options.tags) ? new Set(options.tags) : existingEntry.hashTags; + const tagsEq = _.isEqual(optTags, existingEntry.hashTags); + + if( tagsEq && + fileEntry.desc === existingEntry.desc && + fileEntry.descLong == existingEntry.descLong) + { + console.info('Dupe'); + return nextFile(null); + } + + console.info('Dupe (updating)'); + updateTags(existingEntry); + + finalizeEntryAndPersist(true, existingEntry, descHandler, err => { + return nextFile(err); + }); + }); + } else if(dupeEntries.length > 0) { console.info('Dupe'); return nextFile(null); - } else { - console.info('Done!'); - if(Array.isArray(options.tags)) { - options.tags.forEach(tag => { - fileEntry.hashTags.add(tag); - }); - } - - finalizeEntryAndPersist(fileEntry, descHandler, err => { - return nextFile(err); - }); } + + console.info('Done!'); + updateTags(fileEntry); + + finalizeEntryAndPersist(false, fileEntry, descHandler, err => { + return nextFile(err); + }); } ); }); @@ -518,7 +561,7 @@ function moveFiles() { function removeFiles() { // - // REMOVE SHA|FILE_ID [SHA|FILE_ID ...] + // REMOVE FILENAME_WC|SHA|FILE_ID [SHA|FILE_ID ...] } function handleFileBaseCommand() { diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index 5c20e3a5..c49200b0 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -65,6 +65,7 @@ scan args: other sources such as FILE_ID.DIZ. if PATH is specified, use DESCRIPT.ION at PATH instead of looking in specific storage locations + --update attempt to update information for existing entries info args: --show-desc display short description, if any From 1e2729186904620f9a65430c2db1bcb3e300492f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 8 Sep 2017 23:27:28 -0600 Subject: [PATCH 004/102] Fix typo --- core/file_area_web.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/file_area_web.js b/core/file_area_web.js index bac85de7..aa60974f 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -284,7 +284,7 @@ class FileAreaWebAccess { resp.on('finish', () => { // transfer completed fully - this.updateDownloadStatsForUserIdAndSystemAndSystem(servedItem.userId, stats.size); + this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size); }); const headers = { From 42d61908024a1708360b3c56f13afbd8ab2fdcb4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 8 Sep 2017 23:36:26 -0600 Subject: [PATCH 005/102] Additional logging --- core/servers/content/web.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 31c617e2..55fd37f2 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -148,7 +148,7 @@ exports.getModule = class WebServerModule extends ServerModule { const routeKey = route.getRouteKey(); if(routeKey in this.routes) { - Log.warn( { route : route }, 'Cannot add route: duplicate method/path combination exists' ); + Log.warn( { route : route, routeKey : routeKey }, 'Cannot add route: duplicate method/path combination exists' ); return false; } From 1c2926fbe91eeca49e40b7aedb794b8199bcff88 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 9 Sep 2017 11:48:27 -0600 Subject: [PATCH 006/102] Roll farmhash version back - has bugs --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 219a4290..f8393142 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "buffers": "NuSkooler/node-buffers", "bunyan": "^1.8.12", "exiftool": "^0.0.3", - "farmhash": "^2.0.1", + "farmhash": "1.2.1", "fs-extra": "^4.0.1", "gaze": "^1.1.2", "graceful-fs": "^4.1.11", From 3980c8acae3e1bbb6f0ee08460357fa0dda7e691 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 9 Sep 2017 11:48:43 -0600 Subject: [PATCH 007/102] Add new file base system stats and MCI codes --- core/bbs.js | 11 +++++++ core/config.js | 5 ++++ core/file_base_area.js | 65 ++++++++++++++++++++++++++++++++++++++++++ core/predefined_mci.js | 10 ++++++- core/string_util.js | 4 +-- 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/core/bbs.js b/core/bbs.js index c43d63a3..02058bce 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -234,6 +234,17 @@ function initialize(cb) { } ); }, + function initFileAreaStats(callback) { + const getAreaStats = require('./file_base_area.js').getAreaStats; + getAreaStats( (err, stats) => { + if(!err) { + const StatLog = require('./stat_log.js'); + StatLog.setNonPeristentSystemStat('file_base_area_stats', stats); + } + + return callback(null); + }); + }, function initMCI(callback) { return require('./predefined_mci.js').init(callback); }, diff --git a/core/config.js b/core/config.js index 449a5c98..85632000 100644 --- a/core/config.js +++ b/core/config.js @@ -713,6 +713,11 @@ function getDefaultConfig() { action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', }, + updateFileAreaStats : { + schedule : 'every 1 hours', + action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent', + }, + forgotPasswordMaintenance : { schedule : 'every 24 hours', action : '@method:core/web_password_reset.js:performMaintenanceTask', diff --git a/core/file_base_area.js b/core/file_base_area.js index 79b91ce8..93587aa7 100644 --- a/core/file_base_area.js +++ b/core/file_base_area.js @@ -13,6 +13,7 @@ const Log = require('./logger.js').log; const resolveMimeType = require('./mime_util.js').resolveMimeType; const stringFormat = require('./string_format.js'); const wordWrapText = require('./word_wrap.js').wordWrapText; +const StatLog = require('./stat_log.js'); // deps const _ = require('lodash'); @@ -39,6 +40,10 @@ exports.changeFileAreaWithOptions = changeFileAreaWithOptions; exports.scanFile = scanFile; exports.scanFileAreaForChanges = scanFileAreaForChanges; exports.getDescFromFileName = getDescFromFileName; +exports.getAreaStats = getAreaStats; + +// for scheduler: +exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent; const WellKnownAreaTags = exports.WellKnownAreaTags = { Invalid : '', @@ -856,4 +861,64 @@ function getDescFromFileName(fileName) { const name = paths.basename(fileName, ext); return _.upperFirst(name.replace(/[\-_.+]/g, ' ').replace(/\s+/g, ' ')); +} + +// +// Return an object of stats about an area(s) +// +// { +// +// totalFiles : , +// totalBytes : , +// areas : { +// : { +// files : , +// bytes : +// } +// } +// } +// +function getAreaStats(cb) { + FileDb.all( + `SELECT DISTINCT f.area_tag, COUNT(f.file_id) AS total_files, SUM(m.meta_value) AS total_byte_size + FROM file f, file_meta m + WHERE f.file_id = m.file_id AND m.meta_name='byte_size' + GROUP BY f.area_tag;`, + (err, statRows) => { + if(err) { + return cb(err); + } + + if(!statRows || 0 === statRows.length) { + return cb(Errors.DoesNotExist('No file areas to acquire stats from')); + } + + return cb( + null, + statRows.reduce( (stats, v) => { + stats.totalFiles = (stats.totalFiles || 0) + v.total_files; + stats.totalBytes = (stats.totalBytes || 0) + v.total_byte_size; + + stats.areas = stats.areas || {}; + + stats.areas[v.area_tag] = { + files : v.total_files, + bytes : v.total_byte_size, + }; + return stats; + }, {}) + ); + } + ); +} + +// method exposed for event scheduler +function updateAreaStatsScheduledEvent(args, cb) { + getAreaStats( (err, stats) => { + if(!err) { + StatLog.setNonPeristentSystemStat('file_base_area_stats', stats); + } + + return cb(err); + }); } \ No newline at end of file diff --git a/core/predefined_mci.js b/core/predefined_mci.js index afebd24c..ac8cffe7 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -201,12 +201,20 @@ const PREDEFINED_MCI_GENERATORS = { const byteSize = StatLog.getSystemStatNum('ul_total_bytes'); return formatByteSize(byteSize, true); // true=withAbbr }, + TF : function totalFilesOnSystem() { + const areaStats = StatLog.getSystemStat('file_base_area_stats'); + return _.get(areaStats, 'totalFiles', 0).toString(); + }, + TB : function totalBytesOnSystem() { + const areaStats = StatLog.getSystemStat('file_base_area_stats'); + const totalBytes = parseInt(_.get(areaStats, 'totalBytes', 0)); + return formatByteSize(totalBytes, true); // true=withAbbr + }, // :TODO: PT - Messages posted *today* (Obv/2) // -> Include FTN/etc. // :TODO: NT - New users today (Obv/2) // :TODO: CT - Calls *today* (Obv/2) - // :TODO: TF - Total files on the system (Obv/2) // :TODO: FT - Files uploaded/added *today* (Obv/2) // :TODO: DD - Files downloaded *today* (iNiQUiTY) // :TODO: TP - total message/posts on the system (Obv/2) diff --git a/core/string_util.js b/core/string_util.js index ed6df231..d6f5e08a 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -326,9 +326,7 @@ function formatByteSizeAbbr(byteSize) { return SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; } -function formatByteSize(byteSize, withAbbr, decimals) { - withAbbr = withAbbr || false; - decimals = decimals || 3; +function formatByteSize(byteSize, withAbbr = false, decimals = 2) { const i = 0 === byteSize ? byteSize : Math.floor(Math.log(byteSize) / Math.log(1024)); let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)); if(withAbbr) { From 9cc14b570894f4a0aa333b6e3ca27780eb5835f6 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 9 Sep 2017 12:11:55 -0600 Subject: [PATCH 008/102] Use nicely formatted number values for MCI stats --- core/predefined_mci.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/predefined_mci.js b/core/predefined_mci.js index ac8cffe7..7fe921b3 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -45,11 +45,11 @@ function getUserRatio(client, propA, propB) { } function userStatAsString(client, statName, defaultValue) { - return (StatLog.getUserStat(client.user, statName) || defaultValue).toString(); + return (StatLog.getUserStat(client.user, statName) || defaultValue).toLocaleString(); } function sysStatAsString(statName, defaultValue) { - return (StatLog.getSystemStat(statName) || defaultValue).toString(); + return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString(); } const PREDEFINED_MCI_GENERATORS = { @@ -177,7 +177,7 @@ const PREDEFINED_MCI_GENERATORS = { AN : function activeNodes() { return clientConnections.getActiveConnections().length.toString(); }, - TC : function totalCalls() { return StatLog.getSystemStat('login_count').toString(); }, + TC : function totalCalls() { return StatLog.getSystemStat('login_count').toLocaleString(); }, RR : function randomRumor() { // start the process of picking another random one @@ -203,7 +203,7 @@ const PREDEFINED_MCI_GENERATORS = { }, TF : function totalFilesOnSystem() { const areaStats = StatLog.getSystemStat('file_base_area_stats'); - return _.get(areaStats, 'totalFiles', 0).toString(); + return _.get(areaStats, 'totalFiles', 0).toLocaleString(); }, TB : function totalBytesOnSystem() { const areaStats = StatLog.getSystemStat('file_base_area_stats'); From d545c86616135142a33b7fd93fb01bc809cd079b Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 9 Sep 2017 12:34:31 -0600 Subject: [PATCH 009/102] Add {totalFiles} and {totalBytes} stats to area selection --- mods/file_base_area_select.js | 62 +++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/mods/file_base_area_select.js b/mods/file_base_area_select.js index 5eef583b..ca182d6c 100644 --- a/mods/file_base_area_select.js +++ b/mods/file_base_area_select.js @@ -3,14 +3,12 @@ // enigma-bbs const MenuModule = require('../core/menu_module.js').MenuModule; -const Config = require('../core/config.js').config; const stringFormat = require('../core/string_format.js'); -const ViewController = require('../core/view_controller.js').ViewController; const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; +const StatLog = require('../core/stat_log.js'); // deps -const async = require('async'); -const _ = require('lodash'); +const async = require('async'); exports.moduleInfo = { name : 'File Area Selector', @@ -60,25 +58,47 @@ exports.getModule = class FileAreaSelectModule extends MenuModule { return cb(err); } - this.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => { - if(err) { + const self = this; + + async.series( + [ + function mergeAreaStats(callback) { + const areaStats = StatLog.getSystemStat('file_base_area_stats') || { areas : {} }; + + self.availAreas.forEach(area => { + const stats = areaStats.areas[area.areaTag]; + area.totalFiles = stats ? stats.files : 0; + area.totalBytes = stats ? stats.bytes : 0; + }); + + return callback(null); + }, + function prepView(callback) { + self.prepViewController('allViews', 0, { mciMap : mciData.menu }, (err, vc) => { + if(err) { + return callback(err); + } + + const areaListView = vc.getView(MciViewIds.areaList); + + const areaListFormat = self.config.areaListFormat || '{name}'; + + areaListView.setItems(self.availAreas.map(a => stringFormat(areaListFormat, a) ) ); + + if(self.config.areaListFocusFormat) { + areaListView.setFocusItems(self.availAreas.map(a => stringFormat(self.config.areaListFocusFormat, a) ) ); + } + + areaListView.redraw(); + + return callback(null); + }); + } + ], + err => { return cb(err); } - - const areaListView = vc.getView(MciViewIds.areaList); - - const areaListFormat = this.config.areaListFormat || '{name}'; - - areaListView.setItems(this.availAreas.map(a => stringFormat(areaListFormat, a) ) ); - - if(this.config.areaListFocusFormat) { - areaListView.setFocusItems(this.availAreas.map(a => stringFormat(this.config.areaListFocusFormat, a) ) ); - } - - areaListView.redraw(); - - return cb(null); - }); + ); }); } }; From 9d093905619c7cd6ec10255b943409399eb1b5d5 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 9 Sep 2017 13:59:23 -0600 Subject: [PATCH 010/102] Add countWithAbbr and countAbbr format specifiers --- core/string_format.js | 18 ++++++++++++------ core/string_util.js | 29 +++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/core/string_format.js b/core/string_format.js index dd1ece78..297d92b2 100644 --- a/core/string_format.js +++ b/core/string_format.js @@ -2,12 +2,15 @@ 'use strict'; const EnigError = require('./enig_error.js').EnigError; -const pad = require('./string_util.js').pad; -const stylizeString = require('./string_util.js').stylizeString; -const renderStringLength = require('./string_util.js').renderStringLength; -const renderSubstr = require('./string_util.js').renderSubstr; -const formatByteSize = require('./string_util.js').formatByteSize; -const formatByteSizeAbbr = require('./string_util.js').formatByteSizeAbbr; + +const { + pad, + stylizeString, + renderStringLength, + renderSubstr, + formatByteSize, formatByteSizeAbbr, + formatCount, formatCountAbbr, +} = require('./string_util.js'); // deps const _ = require('lodash'); @@ -273,6 +276,9 @@ const transformers = { sizeWithAbbr : (n) => formatByteSize(n, true, 2), sizeWithoutAbbr : (n) => formatByteSize(n, false, 2), sizeAbbr : (n) => formatByteSizeAbbr(n), + countWithAbbr : (n) => formatCount(n, true, 0), + countWithoutAbbr : (n) => formatCount(n, false, 0), + countAbbr : (n) => formatCountAbbr(n), }; function transformValue(transformerName, value) { diff --git a/core/string_util.js b/core/string_util.js index d6f5e08a..08987058 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -23,6 +23,8 @@ exports.renderSubstr = renderSubstr; exports.renderStringLength = renderStringLength; exports.formatByteSizeAbbr = formatByteSizeAbbr; exports.formatByteSize = formatByteSize; +exports.formatCountAbbr = formatCountAbbr; +exports.formatCount = formatCount; exports.cleanControlCodes = cleanControlCodes; exports.isAnsi = isAnsi; exports.isAnsiLine = isAnsiLine; @@ -316,21 +318,40 @@ function renderStringLength(s) { return len; } -const SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :) +const BYTE_SIZE_ABBRS = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ]; // :) function formatByteSizeAbbr(byteSize) { if(0 === byteSize) { - return SIZE_ABBRS[0]; // B + return BYTE_SIZE_ABBRS[0]; // B } - return SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; + return BYTE_SIZE_ABBRS[Math.floor(Math.log(byteSize) / Math.log(1024))]; } function formatByteSize(byteSize, withAbbr = false, decimals = 2) { const i = 0 === byteSize ? byteSize : Math.floor(Math.log(byteSize) / Math.log(1024)); let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)); if(withAbbr) { - result += ` ${SIZE_ABBRS[i]}`; + result += ` ${BYTE_SIZE_ABBRS[i]}`; + } + return result; +} + +const COUNT_ABBRS = [ '', 'K', 'M', 'B', 'T', 'P', 'E', 'Z', 'Y' ]; + +function formatCountAbbr(count) { + if(count < 1000) { + return ''; + } + + return COUNT_ABBRS[Math.floor(Math.log(count) / Math.log(1000))]; +} + +function formatCount(count, withAbbr = false, decimals = 2) { + const i = 0 === count ? count : Math.floor(Math.log(count) / Math.log(1000)); + let result = parseFloat((count / Math.pow(1000, i)).toFixed(decimals)); + if(withAbbr) { + result += `${COUNT_ABBRS[i]}`; } return result; } From a91ae779be65ad30bea76edfd61aac63a22ac939 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 10 Sep 2017 20:51:30 -0600 Subject: [PATCH 011/102] Add skipAcsCheck option to getAvailableFileAReas() --- core/file_base_area.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/file_base_area.js b/core/file_base_area.js index 93587aa7..ed34607d 100644 --- a/core/file_base_area.js +++ b/core/file_base_area.js @@ -65,6 +65,10 @@ function getAvailableFileAreas(client, options) { return true; } + if(options.skipAcsCheck) { + return false; // no ACS checks (below) + } + if(options.writeAcs && !client.acs.hasFileAreaWrite(areaInfo)) { return true; // omit } @@ -900,7 +904,7 @@ function getAreaStats(cb) { stats.totalBytes = (stats.totalBytes || 0) + v.total_byte_size; stats.areas = stats.areas || {}; - + stats.areas[v.area_tag] = { files : v.total_files, bytes : v.total_byte_size, From 861055d93502ebdd1429627bba7230ee25f9aad3 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 10 Sep 2017 20:51:43 -0600 Subject: [PATCH 012/102] Add some new ASCII output options to AnsiPrep --- core/ansi_prep.js | 16 ++++++++++++---- core/file_entry.js | 4 ++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/core/ansi_prep.js b/core/ansi_prep.js index 29bd5ee4..45b93d32 100644 --- a/core/ansi_prep.js +++ b/core/ansi_prep.js @@ -23,6 +23,8 @@ module.exports = function ansiPrep(input, options, cb) { options.rows = options.rows || options.termHeight || 'auto'; options.startCol = options.startCol || 1; options.exportMode = options.exportMode || false; + options.fillLines = _.get(options, 'fillLines', true); + options.indent = options.indent || 0; // in auto we start out at 25 rows, but can always expand for more const canvas = Array.from( { length : 'auto' === options.rows ? 25 : options.rows }, () => Array.from( { length : options.cols}, () => new Object() ) ); @@ -111,15 +113,18 @@ module.exports = function ansiPrep(input, options, cb) { const lastCol = getLastPopulatedColumn(row) + 1; let i; - line = ''; + line = options.indent ? + output.length > 0 ? ' '.repeat(options.indent) : '' : + ''; + for(i = 0; i < lastCol; ++i) { const col = row[i]; - sgr = 0 === i ? + sgr = !options.asciiMode && 0 === i ? col.initialSgr ? ANSI.getSGRFromGraphicRendition(col.initialSgr) : '' : ''; - if(col.sgr) { + if(!options.asciiMode && col.sgr) { sgr += ANSI.getSGRFromGraphicRendition(col.sgr); } @@ -129,7 +134,10 @@ module.exports = function ansiPrep(input, options, cb) { output += line; if(i < row.length) { - output += `${ANSI.blackBG()}${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`; + output += `${options.asciiMode ? '' : ANSI.blackBG()}`; + if(options.fillLines) { + output += `${row.slice(i).map( () => ' ').join('')}`;//${lastSgr}`; + } } if(options.startCol + i < options.termWidth || options.forceLineTerm) { diff --git a/core/file_entry.js b/core/file_entry.js index b88d41a0..02912c35 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -520,6 +520,10 @@ module.exports = class FileEntry { sql += `${sqlWhere} ${sqlOrderBy};`; + if(_.isNumber(filter.limit)) { + sql += `LIMIT ${filter.limit}`; + } + const matchingFileIds = []; fileDb.each(sql, (err, fileId) => { if(fileId) { From 50bac9585723f461856b320056c141a2b870b8ea Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 11 Sep 2017 21:01:35 -0600 Subject: [PATCH 013/102] * Fix ANSI description display during upload * Major improvements to upload: Allow user to properly edit descriptions even if provided by .diz/system/etc. --- core/multi_line_edit_text_view.js | 28 +++++++++++++----- core/string_util.js | 4 +++ mods/upload.js | 48 ++++++++++++++++++++----------- 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js index 9d46e8a1..55e84080 100644 --- a/core/multi_line_edit_text_view.js +++ b/core/multi_line_edit_text_view.js @@ -1068,9 +1068,9 @@ MultiLineEditTextView.prototype.setFocus = function(focused) { MultiLineEditTextView.super_.prototype.setFocus.call(this, focused); }; -MultiLineEditTextView.prototype.setText = function(text) { +MultiLineEditTextView.prototype.setText = function(text, options = { scrollMode : 'default' } ) { this.textLines = [ ]; - this.addText(text); + this.addText(text, options); /*this.insertRawText(text); if(this.isEditMode()) { @@ -1085,13 +1085,27 @@ MultiLineEditTextView.prototype.setAnsi = function(ansi, options = { prepped : f return this.setAnsiWithOptions(ansi, options, cb); }; -MultiLineEditTextView.prototype.addText = function(text) { +MultiLineEditTextView.prototype.addText = function(text, options = { scrollMode : 'default' }) { this.insertRawText(text); - if(this.isEditMode() || this.autoScroll) { - this.cursorEndOfDocument(); - } else { - this.cursorStartOfDocument(); + switch(options.scrollMode) { + case 'default' : + if(this.isEditMode() || this.autoScroll) { + this.cursorEndOfDocument(); + } else { + this.cursorStartOfDocument(); + } + break; + + case 'top' : + case 'start' : + this.cursorStartOfDocument(); + break; + + case 'end' : + case 'bottom' : + this.cursorEndOfDocument(); + break; } }; diff --git a/core/string_util.js b/core/string_util.js index 08987058..238aeeee 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -623,6 +623,10 @@ function isFormattedLine(line) { } function isAnsi(input) { + if(!input || 0 === input.length) { + return false; + } + // // * ANSI found - limited, just colors // * Full ANSI art diff --git a/mods/upload.js b/mods/upload.js index 5c5fd5b2..30c84c48 100644 --- a/mods/upload.js +++ b/mods/upload.js @@ -15,6 +15,7 @@ const pathWithTerminatingSeparator = require('../core/file_util.js').pathWithTe const Log = require('../core/logger.js').log; const Errors = require('../core/enig_error.js').Errors; const FileEntry = require('../core/file_entry.js'); +const isAnsi = require('../core/string_util.js').isAnsi; // deps const async = require('async'); @@ -421,9 +422,8 @@ exports.getModule = class UploadModule extends MenuModule { return nextEntry(err); } - // if the file entry did *not* have a desc, take the user desc - if(!this.fileEntryHasDetectedDesc(newEntry)) { - newEntry.desc = newValues.shortDesc.trim(); + if(!newEntry.descIsAnsi) { + newEntry.desc = _.trimEnd(newValues.shortDesc); } if(newValues.estYear.length > 0) { @@ -659,14 +659,16 @@ exports.getModule = class UploadModule extends MenuModule { displayFileDetailsPageForUploadEntry(fileEntry, cb) { const self = this; - async.series( + async.waterfall( [ function prepArtAndViewController(callback) { return self.prepViewControllerWithArt( 'fileDetails', FormIds.fileDetails, { clearScreen : true, trailingLF : false }, - callback + err => { + return callback(err); + } ); }, function populateViews(callback) { @@ -679,18 +681,32 @@ exports.getModule = class UploadModule extends MenuModule { tagsView.setText( Array.from(fileEntry.hashTags).join(',') ); // :TODO: optional 'hashTagsSep' like file list/browse yearView.setText(fileEntry.meta.est_release_year || ''); - if(self.fileEntryHasDetectedDesc(fileEntry)) { - descView.setPropertyValue('mode', 'preview'); - descView.setText(fileEntry.desc); - descView.acceptsFocus = false; - self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.tags); - } else { - descView.setPropertyValue('mode', 'edit'); - descView.setText(getDescFromFileName(fileEntry.fileName)); // try to come up with something good as a default - descView.acceptsFocus = true; - self.viewControllers.fileDetails.switchFocus(MciViewIds.fileDetails.desc); - } + if(isAnsi(fileEntry.desc)) { + fileEntry.descIsAnsi = true; + return descView.setAnsi( + fileEntry.desc, + { + prepped : false, + forceLineTerm : true, + }, + () => { + return callback(null, descView, 'preview', MciViewIds.fileDetails.tags); + } + ); + } else { + const hasDesc = self.fileEntryHasDetectedDesc(fileEntry); + descView.setText( + hasDesc ? fileEntry.desc : getDescFromFileName(fileEntry.fileName), + { scrollMode : 'top' } // override scroll mode; we want to be @ top + ); + return callback(null, descView, 'edit', hasDesc ? MciViewIds.fileDetails.tags : MciViewIds.fileDetails.desc); + } + }, + function finalizeViews(descView, descViewMode, focusId, callback) { + descView.setPropertyValue('mode', descViewMode); + descView.acceptsFocus = 'preview' === descViewMode ? false : true; + self.viewControllers.fileDetails.switchFocus(focusId); return callback(null); } ], From 68247d87e81a23efd800b728f4d1c5329fb3dfb3 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 14 Sep 2017 20:54:35 -0600 Subject: [PATCH 014/102] Add filename order by option for search --- core/file_base_filter.js | 1 + mods/menu.hjson | 1 + 2 files changed, 2 insertions(+) diff --git a/core/file_base_filter.js b/core/file_base_filter.js index fadd41fd..a7185316 100644 --- a/core/file_base_filter.js +++ b/core/file_base_filter.js @@ -24,6 +24,7 @@ module.exports = class FileBaseFilters { 'user_rating', 'est_release_year', 'byte_size', + 'file_name', ]; } diff --git a/mods/menu.hjson b/mods/menu.hjson index d1822cfc..dbe99d85 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -2762,6 +2762,7 @@ "rating", "estimated year", "size", + "filename", ] argName: sortByIndex } From 1e250f06d9391c60a6ab7d06946c535acb5fedcb Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 16 Sep 2017 17:13:11 -0600 Subject: [PATCH 015/102] * Fix major issue with SQLite transactions + aync code causing collisions --- core/database.js | 12 +++++-- core/file_entry.js | 55 +++++++++++++++++-------------- core/message.js | 57 +++++++++++++++----------------- core/user.js | 82 ++++++++++++++++------------------------------ core/user_group.js | 13 +++++--- mods/bbs_list.js | 9 +++-- mods/onelinerz.js | 9 +++-- package.json | 1 + 8 files changed, 116 insertions(+), 122 deletions(-) diff --git a/core/database.js b/core/database.js index d4fb4795..a6286279 100644 --- a/core/database.js +++ b/core/database.js @@ -6,6 +6,7 @@ const conf = require('./config.js'); // deps const sqlite3 = require('sqlite3'); +const sqlite3Trans = require('sqlite3-transactions'); const paths = require('path'); const async = require('async'); const _ = require('lodash'); @@ -13,14 +14,19 @@ const assert = require('assert'); const moment = require('moment'); // database handles -let dbs = {}; +const dbs = {}; +exports.getTransactionDatabase = getTransactionDatabase; exports.getModDatabasePath = getModDatabasePath; exports.getISOTimestampString = getISOTimestampString; exports.initializeDatabases = initializeDatabases; exports.dbs = dbs; +function getTransactionDatabase(db) { + return new sqlite3Trans.TransactionDatabase(db); +} + function getDatabasePath(name) { return paths.join(conf.config.paths.db, `${name}.sqlite3`); } @@ -55,7 +61,7 @@ function getISOTimestampString(ts) { function initializeDatabases(cb) { async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { - dbs[dbName] = new sqlite3.Database(getDatabasePath(dbName), err => { + dbs[dbName] = new sqlite3Trans.TransactionDatabase(new sqlite3.Database(getDatabasePath(dbName), err => { if(err) { return cb(err); } @@ -65,7 +71,7 @@ function initializeDatabases(cb) { return next(null); }); }); - }); + })); }, err => { return cb(err); }); diff --git a/core/file_entry.js b/core/file_entry.js index 02912c35..3a5b3df2 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -110,9 +110,8 @@ module.exports = class FileEntry { } const self = this; - let inTransaction = false; - async.series( + async.waterfall( [ function check(callback) { if(isUpdate && !self.fileId) { @@ -121,22 +120,20 @@ module.exports = class FileEntry { return callback(null); }, function startTrans(callback) { - return fileDb.run('BEGIN;', callback); + return fileDb.beginTransaction(callback); }, - function storeEntry(callback) { - inTransaction = true; - + function storeEntry(trans, callback) { if(isUpdate) { - fileDb.run( + trans.run( `REPLACE INTO file (file_id, area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) VALUES(?, ?, ?, ?, ?, ?, ?, ?);`, [ self.fileId, self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], err => { - return callback(err); + return callback(err, trans); } ); } else { - fileDb.run( + trans.run( `REPLACE INTO file (area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp) VALUES(?, ?, ?, ?, ?, ?, ?);`, [ self.areaTag, self.fileSha256, self.fileName, self.storageTag, self.desc, self.descLong, getISOTimestampString() ], @@ -144,34 +141,34 @@ module.exports = class FileEntry { if(!err) { self.fileId = this.lastID; } - return callback(err); + return callback(err, trans); } ); } }, - function storeMeta(callback) { + function storeMeta(trans, callback) { async.each(Object.keys(self.meta), (n, next) => { const v = self.meta[n]; - return FileEntry.persistMetaValue(self.fileId, n, v, next); + return FileEntry.persistMetaValue(self.fileId, n, v, trans, next); }, err => { - return callback(err); + return callback(err, trans); }); }, - function storeHashTags(callback) { + function storeHashTags(trans, callback) { const hashTagsArray = Array.from(self.hashTags); async.each(hashTagsArray, (hashTag, next) => { - return FileEntry.persistHashTag(self.fileId, hashTag, next); + return FileEntry.persistHashTag(self.fileId, hashTag, trans, next); }, err => { - return callback(err); + return callback(err, trans); }); } ], - err => { + (err, trans) => { // :TODO: Log orig err - if(inTransaction) { - fileDb.run(err ? 'ROLLBACK;' : 'COMMIT;', err => { + if(trans) { + trans[err ? 'rollback' : 'commit'](err => { return cb(err); }); } else { @@ -207,8 +204,13 @@ module.exports = class FileEntry { ); } - static persistMetaValue(fileId, name, value, cb) { - return fileDb.run( + static persistMetaValue(fileId, name, value, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = fileDb; + } + + return transOrDb.run( `REPLACE INTO file_meta (file_id, meta_name, meta_value) VALUES (?, ?, ?);`, [ fileId, name, value ], @@ -249,15 +251,20 @@ module.exports = class FileEntry { ); } - static persistHashTag(fileId, hashTag, cb) { - fileDb.serialize( () => { + static persistHashTag(fileId, hashTag, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = fileDb; + } + + transOrDb.serialize( () => { fileDb.run( `INSERT OR IGNORE INTO hash_tag (hash_tag) VALUES (?);`, [ hashTag ] ); - fileDb.run( + transOrDb.run( `REPLACE INTO file_hash_tag (hash_tag_id, file_id) VALUES ( (SELECT hash_tag_id diff --git a/core/message.js b/core/message.js index 710444ce..a251ef50 100644 --- a/core/message.js +++ b/core/message.js @@ -321,8 +321,13 @@ Message.prototype.load = function(options, cb) { ); }; -Message.prototype.persistMetaValue = function(category, name, value, cb) { - const metaStmt = msgDb.prepare( +Message.prototype.persistMetaValue = function(category, name, value, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = msgDb; + } + + const metaStmt = transOrDb.prepare( `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value) VALUES (?, ?, ?, ?);`); @@ -341,18 +346,6 @@ Message.prototype.persistMetaValue = function(category, name, value, cb) { }); }; -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()) { @@ -361,14 +354,12 @@ Message.prototype.persist = function(cb) { const self = this; - async.series( + async.waterfall( [ function beginTransaction(callback) { - Message.startTransaction(err => { - return callback(err); - }); + return msgDb.beginTransaction(callback); }, - function storeMessage(callback) { + function storeMessage(trans, callback) { // generate a UUID for this message if required (general case) const msgTimestamp = moment(); if(!self.uuid) { @@ -379,7 +370,7 @@ Message.prototype.persist = function(cb) { self.message); } - msgDb.run( + trans.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, getISOTimestampString(msgTimestamp) ], @@ -388,13 +379,13 @@ Message.prototype.persist = function(cb) { self.messageId = this.lastID; } - return callback(err); + return callback(err, trans); } ); }, - function storeMeta(callback) { + function storeMeta(trans, callback) { if(!self.meta) { - return callback(null); + return callback(null, trans); } /* Example of self.meta: @@ -410,7 +401,7 @@ Message.prototype.persist = function(cb) { */ 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 => { + self.persistMetaValue(category, name, self.meta[category][name], trans, err => { nextName(err); }); }, err => { @@ -418,18 +409,22 @@ Message.prototype.persist = function(cb) { }); }, err => { - callback(err); + callback(err, trans); }); }, - function storeHashTags(callback) { + function storeHashTags(trans, callback) { // :TODO: hash tag support - return callback(null); + return callback(null, trans); } ], - err => { - Message.endTransaction(err, transErr => { - return cb(err ? err : transErr, self.messageId); - }); + (err, trans) => { + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr, self.messageId); + }); + } else { + return cb(err); + } } ); }; diff --git a/core/user.js b/core/user.js index 15e5a844..fb125f6b 100644 --- a/core/user.js +++ b/core/user.js @@ -189,15 +189,13 @@ module.exports = class User { // :TODO: set various defaults, e.g. default activation status, etc. self.properties.account_status = Config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active; - async.series( + async.waterfall( [ function beginTransaction(callback) { - userDb.run('BEGIN;', err => { - return callback(err); - }); + return userDb.beginTransaction(callback); }, - function createUserRec(callback) { - userDb.run( + function createUserRec(trans, callback) { + trans.run( `INSERT INTO user (user_name) VALUES (?);`, [ self.username ], @@ -213,11 +211,11 @@ module.exports = class User { self.properties.account_status = User.AccountStatus.active; } - return callback(null); + return callback(null, trans); } ); }, - function genAuthCredentials(callback) { + function genAuthCredentials(trans, callback) { User.generatePasswordDerivedKeyAndSalt(password, (err, info) => { if(err) { return callback(err); @@ -225,85 +223,56 @@ module.exports = class User { self.properties.pw_pbkdf2_salt = info.salt; self.properties.pw_pbkdf2_dk = info.dk; - return callback(null); + return callback(null, trans); }); }, - function setInitialGroupMembership(callback) { + function setInitialGroupMembership(trans, callback) { self.groups = Config.users.defaultGroups; if(User.RootUserID === self.userId) { // root/SysOp? self.groups.push('sysops'); } - return callback(null); + return callback(null, trans); }, - function saveAll(callback) { - self.persist(false, err => { - return callback(err); + function saveAll(trans, callback) { + self.persistWithTransaction(trans, err => { + return callback(err, trans); }); } ], - err => { - if(err) { - const originalError = err; - userDb.run('ROLLBACK;', err => { - assert(!err); - return cb(originalError); + (err, trans) => { + if(trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr); }); } else { - userDb.run('COMMIT;', err => { - return cb(err); - }); + return cb(err); } } ); } - persist(useTransaction, cb) { + persistWithTransaction(trans, cb) { assert(this.userId > 0); const self = this; async.series( [ - function beginTransaction(callback) { - if(useTransaction) { - userDb.run('BEGIN;', err => { - return callback(err); - }); - } else { - return callback(null); - } - }, function saveProps(callback) { - self.persistProperties(self.properties, err => { + self.persistProperties(self.properties, trans, err => { return callback(err); }); }, function saveGroups(callback) { - userGroup.addUserToGroups(self.userId, self.groups, err => { + userGroup.addUserToGroups(self.userId, self.groups, trans, err => { return callback(err); }); } ], err => { - if(err) { - if(useTransaction) { - userDb.run('ROLLBACK;', err => { - return cb(err); - }); - } else { - return cb(err); - } - } else { - if(useTransaction) { - userDb.run('COMMIT;', err => { - return cb(err); - }); - } else { - return cb(null); - } - } + return cb(err); } ); } @@ -340,13 +309,18 @@ module.exports = class User { ); } - persistProperties(properties, cb) { + persistProperties(properties, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = userDb; + } + const self = this; // update live props _.merge(this.properties, properties); - const stmt = userDb.prepare( + const stmt = transOrDb.prepare( `REPLACE INTO user_property (user_id, prop_name, prop_value) VALUES (?, ?, ?);` ); diff --git a/core/user_group.js b/core/user_group.js index 2fcaacf3..3903f2c3 100644 --- a/core/user_group.js +++ b/core/user_group.js @@ -33,8 +33,13 @@ function getGroupsForUser(userId, cb) { }); } -function addUserToGroup(userId, groupName, cb) { - userDb.run( +function addUserToGroup(userId, groupName, transOrDb, cb) { + if(!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = userDb; + } + + transOrDb.run( 'REPLACE INTO user_group_member (group_name, user_id) ' + 'VALUES(?, ?);', [ groupName, userId ], @@ -44,10 +49,10 @@ function addUserToGroup(userId, groupName, cb) { ); } -function addUserToGroups(userId, groups, cb) { +function addUserToGroups(userId, groups, transOrDb, cb) { async.each(groups, function item(groupName, next) { - addUserToGroup(userId, groupName, next); + addUserToGroup(userId, groupName, transOrDb, next); }, function complete(err) { cb(err); }); diff --git a/mods/bbs_list.js b/mods/bbs_list.js index e24beba6..07af017c 100644 --- a/mods/bbs_list.js +++ b/mods/bbs_list.js @@ -3,7 +3,10 @@ // ENiGMA½ const MenuModule = require('../core/menu_module.js').MenuModule; -const getModDatabasePath = require('../core/database.js').getModDatabasePath; +const { + getModDatabasePath, + getTransactionDatabase +} = require('../core/database.js').getModDatabasePath; const ViewController = require('../core/view_controller.js').ViewController; const ansi = require('../core/ansi_term.js'); const theme = require('../core/theme.js'); @@ -392,10 +395,10 @@ exports.getModule = class BBSListModule extends MenuModule { async.series( [ function openDatabase(callback) { - self.database = new sqlite3.Database( + self.database = getTransactionDatabase(new sqlite3.Database( getModDatabasePath(moduleInfo), callback - ); + )); }, function createTables(callback) { self.database.serialize( () => { diff --git a/mods/onelinerz.js b/mods/onelinerz.js index 335c25ce..49ecd3db 100644 --- a/mods/onelinerz.js +++ b/mods/onelinerz.js @@ -3,7 +3,10 @@ // ENiGMA½ const MenuModule = require('../core/menu_module.js').MenuModule; -const getModDatabasePath = require('../core/database.js').getModDatabasePath; +const { + getModDatabasePath, + getTransactionDatabase +} = require('../core/database.js').getModDatabasePath; const ViewController = require('../core/view_controller.js').ViewController; const theme = require('../core/theme.js'); const ansi = require('../core/ansi_term.js'); @@ -263,12 +266,12 @@ exports.getModule = class OnelinerzModule extends MenuModule { async.series( [ function openDatabase(callback) { - self.db = new sqlite3.Database( + self.db = getTransactionDatabase(new sqlite3.Database( getModDatabasePath(exports.moduleInfo), err => { return callback(err); } - ); + )); }, function createTables(callback) { self.db.run( diff --git a/package.json b/package.json index f8393142..cdd1ab3a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "ptyw.js": "NuSkooler/ptyw.js", "sanitize-filename": "^1.6.1", "sqlite3": "^3.1.9", + "sqlite3-transactions": "^0.0.5", "ssh2": "^0.5.5", "temptmp": "^1.0.0", "uuid": "^3.1.0", From 59826930e4026bdc679bdb0c4a025cc9c089145c Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 16 Sep 2017 17:24:26 -0600 Subject: [PATCH 016/102] Fix requires --- mods/bbs_list.js | 4 +++- mods/onelinerz.js | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/mods/bbs_list.js b/mods/bbs_list.js index 07af017c..ec964a36 100644 --- a/mods/bbs_list.js +++ b/mods/bbs_list.js @@ -3,10 +3,12 @@ // ENiGMA½ const MenuModule = require('../core/menu_module.js').MenuModule; + const { getModDatabasePath, getTransactionDatabase -} = require('../core/database.js').getModDatabasePath; +} = require('../core/database.js'); + const ViewController = require('../core/view_controller.js').ViewController; const ansi = require('../core/ansi_term.js'); const theme = require('../core/theme.js'); diff --git a/mods/onelinerz.js b/mods/onelinerz.js index 49ecd3db..065c0a30 100644 --- a/mods/onelinerz.js +++ b/mods/onelinerz.js @@ -3,10 +3,12 @@ // ENiGMA½ const MenuModule = require('../core/menu_module.js').MenuModule; -const { + +const { getModDatabasePath, getTransactionDatabase -} = require('../core/database.js').getModDatabasePath; +} = require('../core/database.js'); + const ViewController = require('../core/view_controller.js').ViewController; const theme = require('../core/theme.js'); const ansi = require('../core/ansi_term.js'); From 985a14629a76169a3223c3676d46bec5c18cc4d7 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 18 Sep 2017 20:16:42 -0600 Subject: [PATCH 017/102] Fix document output to better work with reality --- util/exiftool2desc.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/util/exiftool2desc.js b/util/exiftool2desc.js index 210c800d..4b3b350f 100755 --- a/util/exiftool2desc.js +++ b/util/exiftool2desc.js @@ -43,12 +43,12 @@ function documentFile(metadata) { return; } - let desc = `${metadata.author||'Unknown Author'} - ${metadata.title||'Unknown'}`; - const created = moment(metadata.createdate); - if(created.isValid()) { - desc += ` (${created.format('YYYY')})`; + let result = metadata.author || ''; + if(result) { + result += ' - '; } - return desc; + result += metadata.title || 'Unknown Title'; + return result; } function imageFile(metadata) { From 5f9b3eb90d43f4b190cf5d9eea16bc8561450ba3 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 18 Sep 2017 21:05:38 -0600 Subject: [PATCH 018/102] Switch to sqltie-trans, a more updated transaction handling module --- README.md | 2 +- core/database.js | 6 +++--- docs/mods.md | 9 +++++++++ package.json | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 docs/mods.md diff --git a/README.md b/README.md index 39ee0561..3795dd30 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! ## Features Available Now * Multi platform: Anywhere [Node.js](https://nodejs.org/) runs likely works (known to work under Linux, FreeBSD, OpenBSD, OS X and Windows) * Unlimited multi node support (for all those BBS "callers"!) - * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based mods + * **Highly** customizable via [HJSON](http://hjson.org/) based configuration, menus, and themes in addition to JavaScript based [mods](docs/mods.md) * [MCI support](docs/mci.md) for lightbars, toggles, input areas, and so on plus many other other bells and whistles * Telnet, **SSH**, and both secure and non-secure [WebSocket](https://en.wikipedia.org/wiki/WebSocket) access built in! Additional servers are easy to implement * [CP437](http://www.ascii-codes.com/) and UTF-8 output diff --git a/core/database.js b/core/database.js index a6286279..41aa7a41 100644 --- a/core/database.js +++ b/core/database.js @@ -6,7 +6,7 @@ const conf = require('./config.js'); // deps const sqlite3 = require('sqlite3'); -const sqlite3Trans = require('sqlite3-transactions'); +const sqlite3Trans = require('sqlite3-trans'); const paths = require('path'); const async = require('async'); const _ = require('lodash'); @@ -24,7 +24,7 @@ exports.initializeDatabases = initializeDatabases; exports.dbs = dbs; function getTransactionDatabase(db) { - return new sqlite3Trans.TransactionDatabase(db); + return sqlite3Trans.wrap(db); } function getDatabasePath(name) { @@ -61,7 +61,7 @@ function getISOTimestampString(ts) { function initializeDatabases(cb) { async.eachSeries( [ 'system', 'user', 'message', 'file' ], (dbName, next) => { - dbs[dbName] = new sqlite3Trans.TransactionDatabase(new sqlite3.Database(getDatabasePath(dbName), err => { + dbs[dbName] = sqlite3Trans.wrap(new sqlite3.Database(getDatabasePath(dbName), err => { if(err) { return cb(err); } diff --git a/docs/mods.md b/docs/mods.md new file mode 100644 index 00000000..3abc7e2f --- /dev/null +++ b/docs/mods.md @@ -0,0 +1,9 @@ +# Mods + + +## Existing Mods +* **Married Bob Fetch Event**: An event for fetching the latest Married Bob ANSI's for display on you board. ACiDic release [ACD-MB4E.ZIP](https://l33t.codes/outgoing/ACD/ACD-MB4E.ZIP). Can also be [found on GitHub](https://github.com/NuSkooler/enigma-bbs-married_bob_evt) +* **Latest Files Announcement**: An event for posting the latest file arrivals of your board to message areas such as FTN style networks. ACiDic release [ACD-LFA1.ZIP](https://l33t.codes/outgoing/ACD/ACD-LFA1.ZIP) Also [found on GitHub](https://github.com/NuSkooler/enigma-bbs-latest_files_announce_evt) +* **Message Post Event**: An event for posting messages/ads to networks. ACiDic release [ACD-MP4E.ZIP](https://l33t.codes/outgoing/ACD/ACD-MP4E.ZIP) + +See also [ACiDic BBS Mods by Myself](https://l33t.codes/acidic-mods-by-myself/) \ No newline at end of file diff --git a/package.json b/package.json index cdd1ab3a..249a56c2 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "ptyw.js": "NuSkooler/ptyw.js", "sanitize-filename": "^1.6.1", "sqlite3": "^3.1.9", - "sqlite3-transactions": "^0.0.5", + "sqlite3-trans" : "^1.1.0", "ssh2": "^0.5.5", "temptmp": "^1.0.0", "uuid": "^3.1.0", From 7837a2a7bdd4db82a9c38e15a1c1c23aa760d860 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 18 Sep 2017 21:11:17 -0600 Subject: [PATCH 019/102] Update sqlite3-trans version again --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 249a56c2..ef9da900 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "ptyw.js": "NuSkooler/ptyw.js", "sanitize-filename": "^1.6.1", "sqlite3": "^3.1.9", - "sqlite3-trans" : "^1.1.0", + "sqlite3-trans" : "^1.2.0", "ssh2": "^0.5.5", "temptmp": "^1.0.0", "uuid": "^3.1.0", From b0260049ba39b55050562017722e455d80d150dc Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 21 Sep 2017 21:23:30 -0600 Subject: [PATCH 020/102] Add VTX hyperlink support for URLs --- core/ansi_term.js | 13 ++++++++++++- core/client.js | 7 ++++++- core/config.js | 5 ++++- core/string_format.js | 2 ++ mods/file_area_list.js | 4 ++-- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/core/ansi_term.js b/core/ansi_term.js index 8b4094f1..504a1709 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -56,7 +56,7 @@ exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias; exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias; exports.setCursorStyle = setCursorStyle; exports.setEmulatedBaudRate = setEmulatedBaudRate; - +exports.getVtxHyperlink = getVtxHyperlink; // // See also @@ -485,3 +485,14 @@ function setEmulatedBaudRate(rate) { }[rate] || 0; return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed); } + +function getVtxHyperlink(client, url, text) { + if(!client.terminalSupports('vtx_hyperlink')) { + return ''; + } + + text = text || url; + + url = url.split('').map(c => c.charCodeAt(0)).join(';'); + return `${ESC_CSI}1;${text.length};1;1;${url}\\`; +} \ No newline at end of file diff --git a/core/client.js b/core/client.js index 4501396d..71d7a9ad 100644 --- a/core/client.js +++ b/core/client.js @@ -493,10 +493,15 @@ Client.prototype.defaultHandlerMissingMod = function(err) { }; Client.prototype.terminalSupports = function(query) { + const termClient = this.term.termClient; + switch(query) { case 'vtx_audio' : // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt - return this.termClient === 'vtx'; + return 'vtx' === termClient; + + case 'vtx_hyperlink' : + return termClient === 'vtx'; default : return false; diff --git a/core/config.js b/core/config.js index 85632000..75be7795 100644 --- a/core/config.js +++ b/core/config.js @@ -158,7 +158,10 @@ function getDefaultConfig() { newUserNames : [ 'new', 'apply' ], // Names reserved for applying // :TODO: Mystic uses TRASHCAN.DAT for this -- is there a reason to support something like that? - badUserNames : [ 'sysop', 'admin', 'administrator', 'root', 'all' ], + badUserNames : [ + 'sysop', 'admin', 'administrator', 'root', 'all', + 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix' + ], }, // :TODO: better name for "defaults"... which is redundant here! diff --git a/core/string_format.js b/core/string_format.js index 297d92b2..7fb7109a 100644 --- a/core/string_format.js +++ b/core/string_format.js @@ -271,8 +271,10 @@ const transformers = { styleMixed : (s) => stylizeString(s, 'mixed'), styleL33t : (s) => stylizeString(s, 'l33t'), + // :TODO: // toMegs(), toKilobytes(), ... // toList(), toCommaList(), + sizeWithAbbr : (n) => formatByteSize(n, true, 2), sizeWithoutAbbr : (n) => formatByteSize(n, false, 2), sizeAbbr : (n) => formatByteSizeAbbr(n), diff --git a/mods/file_area_list.js b/mods/file_area_list.js index 076d2a98..21b5557d 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -274,7 +274,7 @@ exports.getModule = class FileAreaList extends MenuModule { } else { const webDlExpireTimeFormat = config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - entryInfo.webDlLink = serveItem.url; + entryInfo.webDlLink = ansi.getVtxHyperlink(this.client, serveItem.url) + serveItem.url; entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); } @@ -497,7 +497,7 @@ exports.getModule = class FileAreaList extends MenuModule { const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - self.currentFileEntry.entryInfo.webDlLink = url; + self.currentFileEntry.entryInfo.webDlLink = ansi.getVtxHyperlink(self.client, url) + url; self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat); return callback(null); From 1ad5b125f5bd54607757c183135de8c2aad21b37 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 23 Sep 2017 23:03:21 -0600 Subject: [PATCH 021/102] oputil fb rm|remove|del|delete functionality --- core/file_entry.js | 36 +++++- core/oputil/oputil_file_base.js | 190 +++++++++++++++++++++++--------- core/oputil/oputil_help.js | 14 ++- 3 files changed, 178 insertions(+), 62 deletions(-) diff --git a/core/file_entry.js b/core/file_entry.js index 3a5b3df2..f5286dd3 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -11,6 +11,7 @@ const async = require('async'); const _ = require('lodash'); const paths = require('path'); const fse = require('fs-extra'); +const { unlink } = require('graceful-fs'); const FILE_TABLE_MEMBERS = [ 'file_id', 'area_tag', 'file_sha256', 'file_name', 'storage_tag', @@ -541,6 +542,40 @@ module.exports = class FileEntry { }); } + static removeEntry(srcFileEntry, options, cb) { + if(!_.isFunction(cb) && _.isFunction(options)) { + cb = options; + options = {}; + } + + async.series( + [ + function removeFromDatabase(callback) { + fileDb.run( + `DELETE FROM file + WHERE file_id = ?;`, + [ srcFileEntry.fileId ], + err => { + return callback(err); + } + ); + }, + function optionallyRemovePhysicalFile(callback) { + if(true !== options.removePhysFile) { + return callback(null); + } + + unlink(srcFileEntry.filePath, err => { + return callback(err); + }); + } + ], + err => { + return cb(err); + } + ); + } + static moveEntry(srcFileEntry, destAreaTag, destStorageTag, destFileName, cb) { if(!cb && _.isFunction(destFileName)) { cb = destFileName; @@ -550,7 +585,6 @@ module.exports = class FileEntry { const srcPath = srcFileEntry.filePath; const dstDir = FileEntry.getAreaStorageDirectoryByTag(destStorageTag); - if(!dstDir) { return cb(Errors.Invalid('Invalid storage tag')); } diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index 3f791267..d9ad9366 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -438,6 +438,62 @@ function scanFileAreas() { ); } +function expandFileTargets(targets, cb) { + let entries = []; + + // Each entry may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] + const FileEntry = require('../../core/file_entry.js'); + + async.eachSeries(targets, (areaAndStorage, next) => { + const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); + + if(areaInfo) { + // AREA_TAG[@STORAGE_TAG] - all files in area@tag + const findFilter = { + areaTag : areaAndStorage.areaTag, + }; + + if(areaAndStorage.storageTag) { + findFilter.storageTag = areaAndStorage.storageTag; + } + + FileEntry.findFiles(findFilter, (err, fileIds) => { + if(err) { + return next(err); + } + + async.each(fileIds, (fileId, nextFileId) => { + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + if(!err) { + entries.push(fileEntry); + } + return nextFileId(err); + }); + }, + err => { + return next(err); + }); + }); + + } else { + // FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA + // :TODO: FULL_PATH -> entries + getFileEntries(areaAndStorage.pattern, (err, fileEntries) => { + if(err) { + return next(err); + } + + entries = entries.concat(fileEntries); + return next(null); + }); + } + }, + err => { + return cb(err, entries); + }); +} + function moveFiles() { // // oputil fb move SRC [SRC2 ...] DST @@ -450,8 +506,9 @@ function moveFiles() { } const moveArgs = argv._.slice(2); - let src = getAreaAndStorage(moveArgs.slice(0, -1)); - let dst = getAreaAndStorage(moveArgs.slice(-1))[0]; + const src = getAreaAndStorage(moveArgs.slice(0, -1)); + const dst = getAreaAndStorage(moveArgs.slice(-1))[0]; + let FileEntry; async.waterfall( @@ -465,8 +522,6 @@ function moveFiles() { }); }, function validateAndExpandSourceAndDest(callback) { - let srcEntries = []; - const areaInfo = fileArea.getFileAreaByTag(dst.areaTag); if(areaInfo) { dst.areaInfo = areaInfo; @@ -474,57 +529,9 @@ function moveFiles() { return callback(Errors.DoesNotExist('Invalid or unknown destination area')); } - // Each SRC may be PATH|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] FileEntry = require('../../core/file_entry.js'); - async.eachSeries(src, (areaAndStorage, next) => { - const areaInfo = fileArea.getFileAreaByTag(areaAndStorage.areaTag); - - if(areaInfo) { - // AREA_TAG[@STORAGE_TAG] - all files in area@tag - src.areaInfo = areaInfo; - - const findFilter = { - areaTag : areaAndStorage.areaTag, - }; - - if(areaAndStorage.storageTag) { - findFilter.storageTag = areaAndStorage.storageTag; - } - - FileEntry.findFiles(findFilter, (err, fileIds) => { - if(err) { - return next(err); - } - - async.each(fileIds, (fileId, nextFileId) => { - const fileEntry = new FileEntry(); - fileEntry.load(fileId, err => { - if(!err) { - srcEntries.push(fileEntry); - } - return nextFileId(err); - }); - }, - err => { - return next(err); - }); - }); - - } else { - // FILENAME_WC|FILE_ID|SHA|PARTIAL_SHA - // :TODO: FULL_PATH -> entries - getFileEntries(areaAndStorage.pattern, (err, entries) => { - if(err) { - return next(err); - } - - srcEntries = srcEntries.concat(entries); - return next(null); - }); - } - }, - err => { + expandFileTargets(src, (err, srcEntries) => { return callback(err, srcEntries); }); }, @@ -555,13 +562,80 @@ function moveFiles() { return callback(err); }); } - ] + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } ); } function removeFiles() { // - // REMOVE FILENAME_WC|SHA|FILE_ID [SHA|FILE_ID ...] + // oputil fb rm|remove|del|delete SRC [SRC2 ...] + // + // SRC: FILENAME_WC|FILE_ID|SHA|AREA_TAG[@STORAGE_TAG] + // + // AREA_TAG[@STORAGE_TAG] remove all entries matching + // supplied area/storage tags + // + // --phys-file removes backing physical file(s) + // + if(argv._.length < 3) { + return printUsageAndSetExitCode(getHelpFor('FileBase'), ExitCodes.ERROR); + } + + const removePhysFile = argv['phys-file']; + + const src = getAreaAndStorage(argv._.slice(2)); + + async.waterfall( + [ + function init(callback) { + return initConfigAndDatabases( err => { + if(!err) { + fileArea = require('../../core/file_base_area.js'); + } + return callback(err); + }); + }, + function expandSources(callback) { + expandFileTargets(src, (err, srcEntries) => { + return callback(err, srcEntries); + }); + }, + function removeEntries(srcEntries, callback) { + const FileEntry = require('../../core/file_entry.js'); + + const extraOutput = removePhysFile ? ' (including physical file)' : ''; + + async.eachSeries(srcEntries, (entry, nextEntry) => { + + process.stdout.write(`Removing ${entry.filePath}${extraOutput}... `); + + FileEntry.removeEntry(entry, { removePhysFile }, err => { + if(err) { + console.info(`Failed: ${err.message}`); + } else { + console.info('Done'); + } + + return nextEntry(err); + }); + }, err => { + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } + } + ); } function handleFileBaseCommand() { @@ -582,7 +656,13 @@ function handleFileBaseCommand() { return ({ info : displayFileAreaInfo, scan : scanFileAreas, + + mv : moveFiles, move : moveFiles, + + rm : removeFiles, remove : removeFiles, + del : removeFiles, + delete : removeFiles, }[action] || errUsage)(); } \ No newline at end of file diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index c49200b0..1a2b42bf 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -45,7 +45,7 @@ import-areas args: --type TYPE specifies area import type. valid options are "bbs" and "na" `, FileBase : -`usage: oputil.js fb [] [] +`usage: oputil.js fb [] actions: scan AREA_TAG[@STORAGE_TAG] scan specified area @@ -53,14 +53,16 @@ actions: info AREA_TAG|SHA|FILE_ID display information about areas and/or files SHA may be a full or partial SHA-256 - move SRC [SRC...]] DST move entry(s) from SRC to DST - * SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG] - * DST: AREA_TAG[@STORAGE_TAG] + mv SRC [SRC...] DST move entry(s) from SRC to DST + SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG] + DST: AREA_TAG[@STORAGE_TAG] - remove SHA|FILE_ID removes a entry from the system + rm SRC [SRC...] remove entry(s) from the system matching SRC + SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG] scan args: --tags TAG1,TAG2,... specify tag(s) to assign to discovered entries + --desc-file [PATH] prefer file descriptions from DESCRIPT.ION file over other sources such as FILE_ID.DIZ. if PATH is specified, use DESCRIPT.ION at PATH instead @@ -71,7 +73,7 @@ info args: --show-desc display short description, if any remove args: - --delete also remove underlying physical file + --phys-file also remove underlying physical file `, FileOpsInfo : ` From 48c6edc5b39b478c0c2366c9eb2625f6ac3bc99e Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 23 Sep 2017 23:17:16 -0600 Subject: [PATCH 022/102] Rename VTX Hyperlink stuff --- core/ansi_term.js | 8 ++++---- core/client.js | 2 +- mods/file_area_list.js | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/core/ansi_term.js b/core/ansi_term.js index 504a1709..7eb10ec2 100644 --- a/core/ansi_term.js +++ b/core/ansi_term.js @@ -56,7 +56,7 @@ exports.getSyncTERMFontFromAlias = getSyncTERMFontFromAlias; exports.setSyncTermFontWithAlias = setSyncTermFontWithAlias; exports.setCursorStyle = setCursorStyle; exports.setEmulatedBaudRate = setEmulatedBaudRate; -exports.getVtxHyperlink = getVtxHyperlink; +exports.vtxHyperlink = vtxHyperlink; // // See also @@ -486,13 +486,13 @@ function setEmulatedBaudRate(rate) { return 0 === speed ? exports.emulationSpeed() : exports.emulationSpeed(1, speed); } -function getVtxHyperlink(client, url, text) { +function vtxHyperlink(client, url, len) { if(!client.terminalSupports('vtx_hyperlink')) { return ''; } - text = text || url; + len = len || url.length; url = url.split('').map(c => c.charCodeAt(0)).join(';'); - return `${ESC_CSI}1;${text.length};1;1;${url}\\`; + return `${ESC_CSI}1;${len};1;1;${url}\\`; } \ No newline at end of file diff --git a/core/client.js b/core/client.js index 71d7a9ad..08776bf4 100644 --- a/core/client.js +++ b/core/client.js @@ -501,7 +501,7 @@ Client.prototype.terminalSupports = function(query) { return 'vtx' === termClient; case 'vtx_hyperlink' : - return termClient === 'vtx'; + return 'vtx' === termClient; default : return false; diff --git a/mods/file_area_list.js b/mods/file_area_list.js index 21b5557d..64a663cb 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -274,7 +274,7 @@ exports.getModule = class FileAreaList extends MenuModule { } else { const webDlExpireTimeFormat = config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - entryInfo.webDlLink = ansi.getVtxHyperlink(this.client, serveItem.url) + serveItem.url; + entryInfo.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; entryInfo.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); } @@ -497,7 +497,7 @@ exports.getModule = class FileAreaList extends MenuModule { const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; - self.currentFileEntry.entryInfo.webDlLink = ansi.getVtxHyperlink(self.client, url) + url; + self.currentFileEntry.entryInfo.webDlLink = ansi.vtxHyperlink(self.client, url) + url; self.currentFileEntry.entryInfo.webDlExpire = expireTime.format(webDlExpireTimeFormat); return callback(null); From 47551b18034c872f349b281756c9715c71a37e75 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 24 Sep 2017 11:15:26 -0600 Subject: [PATCH 023/102] Add isNixTerm(), use includes vs indexOf on array search --- core/client_term.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/core/client_term.js b/core/client_term.js index 9992f9f3..b313841e 100644 --- a/core/client_term.js +++ b/core/client_term.js @@ -110,6 +110,17 @@ ClientTerminal.prototype.disconnect = function() { this.output = null; }; +ClientTerminal.prototype.isNixTerm = function() { + // + // Standard *nix type terminals + // + if(this.termType.startsWith('xterm')) { + return true; + } + + return [ 'xterm', 'linux', 'screen', 'dumb', 'rxvt', 'konsole', 'gnome', 'x11 terminal emulator' ].includes(this.termType); +}; + ClientTerminal.prototype.isANSI = function() { // // ANSI terminals should be encoded to CP437 @@ -142,7 +153,7 @@ ClientTerminal.prototype.isANSI = function() { // linux: // * JuiceSSH (note: TERM=linux also) // - return [ 'ansi', 'pcansi', 'pc-ansi', 'ansi-bbs', 'qansi', 'scoansi', 'syncterm' ].indexOf(this.termType) > -1; + return [ 'ansi', 'pcansi', 'pc-ansi', 'ansi-bbs', 'qansi', 'scoansi', 'syncterm' ].includes(this.termType); }; // :TODO: probably need to update these to convert IAC (0xff) -> IACIAC (escape it) From e37409e9b5e350901564bfa74ca6980b09691f24 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 24 Sep 2017 11:15:38 -0600 Subject: [PATCH 024/102] * Separate out DEL vs backspace when possible for ANSI-BBS terminals. *nix terminals don't send us what we need, but deal with it. * Handle delete in MultiLineTextEditView. More to come soon! --- core/client.js | 16 +++++++++++- core/multi_line_edit_text_view.js | 41 ++++++++++++++++++------------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/core/client.js b/core/client.js index 08776bf4..424748a6 100644 --- a/core/client.js +++ b/core/client.js @@ -306,7 +306,21 @@ function Client(input, output) { key.name = 'line feed'; } else if('\t' === s) { key.name = 'tab'; - } else if ('\b' === s || '\x7f' === s || '\x1b\x7f' === s || '\x1b\b' === s) { + } else if('\x7f' === s) { + // + // Backspace vs delete is a crazy thing, especially in *nix. + // - ANSI-BBS uses 0x7f for DEL + // - xterm et. al clients send 0x7f for backspace... ugg. + // + // See http://www.hypexr.org/linux_ruboff.php + // And a great discussion @ https://lists.debian.org/debian-i18n/1998/04/msg00015.html + // + if(self.term.isNixTerm()) { + key.name = 'backspace'; + } else { + key.name = 'delete'; + } + } else if ('\b' === s || '\x1b\x7f' === s || '\x1b\b' === s) { // backspace, CTRL-H key.name = 'backspace'; key.meta = ('\x1b' === s.charAt(0)); diff --git a/core/multi_line_edit_text_view.js b/core/multi_line_edit_text_view.js index 55e84080..68d8b3d6 100644 --- a/core/multi_line_edit_text_view.js +++ b/core/multi_line_edit_text_view.js @@ -67,7 +67,7 @@ const SPECIAL_KEY_MAP_DEFAULT = { 'line feed' : [ 'return' ], exit : [ 'esc' ], backspace : [ 'backspace' ], - delete : [ 'del' ], + delete : [ 'delete' ], tab : [ 'tab' ], up : [ 'up arrow' ], down : [ 'down arrow' ], @@ -354,21 +354,14 @@ function MultiLineEditTextView(options) { }; this.removeCharactersFromText = function(index, col, operation, count) { - if('right' === operation) { + if('delete' === operation) { self.textLines[index].text = - self.textLines[index].text.slice(col, count) + + self.textLines[index].text.slice(0, col) + self.textLines[index].text.slice(col + count); - self.cursorPos.col -= count; - self.updateTextWordWrap(index); self.redrawRows(self.cursorPos.row, self.dimens.height); - - if(0 === self.textLines[index].text) { - - } else { - self.redrawRows(self.cursorPos.row, self.dimens.height); - } + self.moveClientCursorToCursorPos(); } else if ('backspace' === operation) { // :TODO: method for splicing text self.textLines[index].text = @@ -868,11 +861,25 @@ function MultiLineEditTextView(options) { }; this.keyPressDelete = function() { - self.removeCharactersFromText( - self.getTextLinesIndex(), - self.cursorPos.col, - 'right', - 1); + const lineIndex = self.getTextLinesIndex(); + + if(0 === self.cursorPos.col && 0 === self.textLines[lineIndex].text.length && self.textLines.length > 0) { + // + // Start of line and nothing left. Just delete the line + // + self.removeCharactersFromText( + lineIndex, + 0, + 'delete line' + ); + } else { + self.removeCharactersFromText( + lineIndex, + self.cursorPos.col, + 'delete', + 1 + ); + } self.emitEditPosition(); }; @@ -1143,7 +1150,7 @@ const HANDLED_SPECIAL_KEYS = [ 'line feed', 'insert', 'tab', - 'backspace', 'del', + 'backspace', 'delete', 'delete line', ]; From 88049a3c7ac47217b38b6bfa9889c049eadf7f9a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 24 Sep 2017 11:35:12 -0600 Subject: [PATCH 025/102] Prefer FILE_ID.ANS > FILE_ID.DIZ --- core/config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/config.js b/core/config.js index 75be7795..da210a75 100644 --- a/core/config.js +++ b/core/config.js @@ -653,8 +653,9 @@ function getDefaultConfig() { fileNamePatterns: { // These are NOT case sensitive // FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ + // Some groups include a FILE_ID.ANS. We try to use that over FILE_ID.DIZ if available. desc : [ - '^[^/\]*FILE_ID\.DIZ$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$' + '^[^/\]*FILE_ID\.ANS$', '^[^/\]*FILE_ID\.DIZ$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$' ], // common README filename - https://en.wikipedia.org/wiki/README From 37c78209a8a78b6b11bb66b6adcd0a3be46b550e Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 26 Sep 2017 10:39:07 -0600 Subject: [PATCH 026/102] Fix up system internal file areas --- core/config.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/core/config.js b/core/config.js index da210a75..549b44a2 100644 --- a/core/config.js +++ b/core/config.js @@ -689,14 +689,21 @@ function getDefaultConfig() { // Non-absolute paths are relative to |areaStoragePrefix|. // storageTags : { - sys_msg_attach : 'msg_attach', + sys_msg_attach : 'sys_msg_attach', + sys_temp_download : 'sys_temp_download', }, areas: { system_message_attachment : { - name : 'Message attachments', + name : 'System Message Attachments', desc : 'File attachments to messages', - storageTags : 'sys_msg_attach', // may be string or array of strings + storageTags : [ 'sys_msg_attach' ], + }, + + system_temporary_download : { + name : 'System Temporary Downloads', + desc : 'Temporary downloadables', + storageTags : [ 'sys_temp_download' ], } } }, From f105c25e17622360db297876f7f91b687cd2113c Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 26 Sep 2017 10:39:23 -0600 Subject: [PATCH 027/102] Add file_web_serve_batch table --- core/database.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/database.js b/core/database.js index 41aa7a41..14b3bf95 100644 --- a/core/database.js +++ b/core/database.js @@ -374,6 +374,15 @@ const DB_INIT_TABLE = { );` ); + dbs.file.run( + `CREATE TABLE IF NOT EXISTS file_web_serve_batch ( + hash_id VARCHAR NOT NULL, + file_id INTEGER NOT NULL, + + UNIQUE(hash_id, file_id) + );` + ); + return cb(null); } }; \ No newline at end of file From 59da1a2461cdc4a2a17692799fc00d5db5b103b3 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 26 Sep 2017 10:40:30 -0600 Subject: [PATCH 028/102] * Add getAvailableFileAreaTags() * Properly check area tags for system internal --- core/file_base_area.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/file_base_area.js b/core/file_base_area.js index ed34607d..1d1062a9 100644 --- a/core/file_base_area.js +++ b/core/file_base_area.js @@ -28,6 +28,7 @@ const moment = require('moment'); exports.isInternalArea = isInternalArea; exports.getAvailableFileAreas = getAvailableFileAreas; +exports.getAvailableFileAreaTags = getAvailableFileAreaTags; exports.getSortedAvailableFileAreas = getSortedAvailableFileAreas; exports.isValidStorageTag = isValidStorageTag; exports.getAreaStorageDirectoryByTag = getAreaStorageDirectoryByTag; @@ -48,10 +49,11 @@ exports.updateAreaStatsScheduledEvent = updateAreaStatsScheduledEvent; const WellKnownAreaTags = exports.WellKnownAreaTags = { Invalid : '', MessageAreaAttach : 'system_message_attachment', + TempDownloads : 'system_temporary_download', }; function isInternalArea(areaTag) { - return areaTag === WellKnownAreaTags.MessageAreaAttach; + return [ WellKnownAreaTags.MessageAreaAttach, WellKnownAreaTags.TempDownloads ].includes(areaTag); } function getAvailableFileAreas(client, options) { @@ -77,6 +79,10 @@ function getAvailableFileAreas(client, options) { }); } +function getAvailableFileAreaTags(client, options) { + return _.map(getAvailableFileAreas(client, options), area => area.areaTag); +} + function getSortedAvailableFileAreas(client, options) { const areas = _.map(getAvailableFileAreas(client, options), v => v); sortAreasOrConfs(areas); From 0f9e545b7bf62e516e32fc78e4ca8d069517b44e Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 26 Sep 2017 10:41:41 -0600 Subject: [PATCH 029/102] Allow filter on 1:n area tags in findFiles(). Add ability to calc sha256 if not already set (use sparingly!) --- core/file_entry.js | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/core/file_entry.js b/core/file_entry.js index f5286dd3..84c6559f 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -11,7 +11,8 @@ const async = require('async'); const _ = require('lodash'); const paths = require('path'); const fse = require('fs-extra'); -const { unlink } = require('graceful-fs'); +const { unlink, readFile } = require('graceful-fs'); +const crypto = require('crypto'); const FILE_TABLE_MEMBERS = [ 'file_id', 'area_tag', 'file_sha256', 'file_name', 'storage_tag', @@ -120,6 +121,26 @@ module.exports = class FileEntry { } return callback(null); }, + function calcSha256IfNeeded(callback) { + if(self.fileSha256) { + return callback(null); + } + + if(isUpdate) { + return callback(Errors.MissingParam('fileSha256 property must be set for updates!')); + } + + readFile(self.filePath, (err, data) => { + if(err) { + return callback(err); + } + + const sha256 = crypto.createHash('sha256'); + sha256.update(data); + self.fileSha256 = sha256.digest('hex'); + return callback(null); + }); + }, function startTrans(callback) { return fileDb.beginTransaction(callback); }, @@ -169,8 +190,8 @@ module.exports = class FileEntry { (err, trans) => { // :TODO: Log orig err if(trans) { - trans[err ? 'rollback' : 'commit'](err => { - return cb(err); + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(transErr ? transErr : err); }); } else { return cb(err); @@ -459,7 +480,12 @@ module.exports = class FileEntry { } if(filter.areaTag && filter.areaTag.length > 0) { - appendWhereClause(`f.area_tag = "${filter.areaTag}"`); + if(Array.isArray(filter.areaTag)) { + const areaList = filter.areaTag.map(t => `"${t}"`).join(', '); + appendWhereClause(`f.area_tag IN(${areaList})`); + } else { + appendWhereClause(`f.area_tag = "${filter.areaTag}"`); + } } if(filter.metaPairs && filter.metaPairs.length > 0) { From 8479091d330aa21b934cec37ad1ce4766b491842 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 26 Sep 2017 10:42:53 -0600 Subject: [PATCH 030/102] Filter out system areas --- mods/file_area_list.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mods/file_area_list.js b/mods/file_area_list.js index 64a663cb..4937eb49 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -675,7 +675,13 @@ exports.getModule = class FileAreaList extends MenuModule { loadFileIds(force, cb) { if(force || (_.isUndefined(this.fileList) || _.isUndefined(this.fileListPosition))) { this.fileListPosition = 0; - FileEntry.findFiles(this.filterCriteria, (err, fileIds) => { + + const filterCriteria = Object.assign({}, this.filterCriteria); + if(!filterCriteria.areaTag) { + filterCriteria.areaTag = FileArea.getAvailableFileAreaTags(this.client); + } + + FileEntry.findFiles(filterCriteria, (err, fileIds) => { this.fileList = fileIds; return cb(err); }); From e555a28160ca2e0cb87c8ee754963e06c6e698c3 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 26 Sep 2017 10:43:22 -0600 Subject: [PATCH 031/102] Filter out system areas --- core/new_scan.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/core/new_scan.js b/core/new_scan.js index 3c3f1371..a75a5b2d 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -2,13 +2,14 @@ 'use strict'; // ENiGMA½ -const msgArea = require('./message_area.js'); -const MenuModule = require('./menu_module.js').MenuModule; -const ViewController = require('./view_controller.js').ViewController; -const stringFormat = require('./string_format.js'); -const FileEntry = require('./file_entry.js'); -const FileBaseFilters = require('./file_base_filter.js'); -const Errors = require('./enig_error.js').Errors; +const msgArea = require('./message_area.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const stringFormat = require('./string_format.js'); +const FileEntry = require('./file_entry.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const Errors = require('./enig_error.js').Errors; +const { getAvailableFileAreaTags } = require('./file_base_area.js'); // deps const _ = require('lodash'); @@ -166,8 +167,13 @@ exports.getModule = class NewScanModule extends MenuModule { newScanFileBase(cb) { // :TODO: add in steps + const filterCriteria = { + newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user), + areaTag : getAvailableFileAreaTags(this.client), + }; + FileEntry.findFiles( - { newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user) }, + filterCriteria, (err, fileIds) => { if(err || 0 === fileIds.length) { return cb(err ? err : Errors.DoesNotExist('No more new files')); From dc2b3031fd95df19ea9a44090afad11ed7d3c395 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 26 Sep 2017 10:44:15 -0600 Subject: [PATCH 032/102] * Change how hashids are generated for web file area: include a 'type' * Add support for web *batch* downloads via streaming zip file creation * Add new web download manager and batch mode display * Add extra info to 'standard' downloads mod/menu --- core/file_area_web.js | 336 ++++++++++++++++++------- mods/file_base_download_manager.js | 38 ++- mods/file_base_web_download_manager.js | 287 +++++++++++++++++++++ package.json | 3 +- 4 files changed, 560 insertions(+), 104 deletions(-) create mode 100644 mods/file_base_web_download_manager.js diff --git a/core/file_area_web.js b/core/file_area_web.js index aa60974f..a8d095d6 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -22,14 +22,7 @@ const paths = require('path'); const async = require('async'); const fs = require('graceful-fs'); const mimeTypes = require('mime-types'); -const _ = require('lodash'); - - /* - :TODO: - * Load temp download URLs @ startup & set expire timers via scheduler. - * At creation, set expire timer via scheduler - * - */ +const yazl = require('yazl'); function notEnabledError() { return Errors.General('Web server is not enabled', ErrNotEnabled); @@ -59,7 +52,7 @@ class FileAreaWebAccess { const routeAdded = self.webServer.instance.addRoute({ method : 'GET', path : Config.fileBase.web.routePath, - handler : self.routeWebRequestForFile.bind(self), + handler : self.routeWebRequest.bind(self), }); return callback(routeAdded ? null : Errors.General('Failed adding route')); } else { @@ -81,6 +74,13 @@ class FileAreaWebAccess { return this.webServer.instance.isEnabled(); } + static getHashIdTypes() { + return { + SingleFile : 0, + BatchArchive : 1, + }; + } + load(cb) { // // Load entries, register expiration timers @@ -141,12 +141,14 @@ class FileAreaWebAccess { WHERE hash_id = ?`, [ hashId ], (err, result) => { - if(err) { - return cb(err); + if(err || !result) { + return cb(err ? err : Errors.DoesNotExist('Invalid or missing hash ID')); } const decoded = this.hashids.decode(hashId); - if(!result || 2 !== decoded.length) { + + // decode() should provide an array of [ userId, hashIdType, id, ... ] + if(!Array.isArray(decoded) || decoded.length < 3) { return cb(Errors.Invalid('Invalid or unknown hash ID')); } @@ -155,7 +157,8 @@ class FileAreaWebAccess { { hashId : hashId, userId : decoded[0], - fileId : decoded[1], + hashIdType : decoded[1], + fileIds : decoded.slice(2), expireTimestamp : moment(result.expire_timestamp), } ); @@ -163,46 +166,26 @@ class FileAreaWebAccess { ); } - getHashId(client, fileEntry) { - // - // Hashid is a unique combination of userId & fileId - // - return this.hashids.encode(client.user.userId, fileEntry.fileId); + getSingleFileHashId(client, fileEntry) { + return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().SingleFile, [ fileEntry.fileId ] ); } - buildTempDownloadLink(client, fileEntry, hashId) { - hashId = hashId || this.getHashId(client, fileEntry); + getBatchArchiveHashId(client, batchId) { + return this.getHashId(client, FileAreaWebAccess.getHashIdTypes().BatchArchive, batchId); + } + + getHashId(client, hashIdType, identifier) { + return this.hashids.encode(client.user.userId, hashIdType, identifier); + } + + buildSingleFileTempDownloadLink(client, fileEntry, hashId) { + hashId = hashId || this.getSingleFileHashId(client, fileEntry); return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); - /* - - // - // Create a URL such as - // https://l33t.codes:44512/f/qFdxyZr - // - // Prefer HTTPS over HTTP. Be explicit about the port - // only if non-standard. - // - let schema; - let port; - if(_.isString(Config.contentServers.web.overrideUrlPrefix)) { - return `${Config.contentServers.web.overrideUrlPrefix}${Config.fileBase.web.path}${hashId}`; - } else { - if(Config.contentServers.web.https.enabled) { - schema = 'https://'; - port = (443 === Config.contentServers.web.https.port) ? - '' : - `:${Config.contentServers.web.https.port}`; - } else { - schema = 'http://'; - port = (80 === Config.contentServers.web.http.port) ? - '' : - `:${Config.contentServers.web.http.port}`; - } - - return `${schema}${Config.contentServers.web.domain}${port}${Config.fileBase.web.path}${hashId}`; - } - */ + } + + buildBatchArchiveTempDownloadLink(client, hashId) { + return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`); } getExistingTempDownloadServeItem(client, fileEntry, cb) { @@ -210,49 +193,95 @@ class FileAreaWebAccess { return cb(notEnabledError()); } - const hashId = this.getHashId(client, fileEntry); + const hashId = this.getSingleFileHashId(client, fileEntry); this.loadServedHashId(hashId, (err, servedItem) => { if(err) { return cb(err); } - servedItem.url = this.buildTempDownloadLink(client, fileEntry); + servedItem.url = this.buildSingleFileTempDownloadLink(client, fileEntry); return cb(null, servedItem); }); } + _addOrUpdateHashIdRecord(dbOrTrans, hashId, expireTime, cb) { + // add/update rec with hash id and (latest) timestamp + dbOrTrans.run( + `REPLACE INTO file_web_serve (hash_id, expire_timestamp) + VALUES (?, ?);`, + [ hashId, getISOTimestampString(expireTime) ], + err => { + if(err) { + return cb(err); + } + + this.scheduleExpire(hashId, expireTime); + + return cb(null); + } + ); + } + createAndServeTempDownload(client, fileEntry, options, cb) { if(!this.isEnabled()) { return cb(notEnabledError()); } - const hashId = this.getHashId(client, fileEntry); - const url = this.buildTempDownloadLink(client, fileEntry, hashId); + const hashId = this.getSingleFileHashId(client, fileEntry); + const url = this.buildSingleFileTempDownloadLink(client, fileEntry, hashId); options.expireTime = options.expireTime || moment().add(2, 'days'); - // add/update rec with hash id and (latest) timestamp - FileDb.run( - `REPLACE INTO file_web_serve (hash_id, expire_timestamp) - VALUES (?, ?);`, - [ hashId, getISOTimestampString(options.expireTime) ], - err => { + this._addOrUpdateHashIdRecord(FileDb, hashId, options.expireTime, err => { + return cb(err, url); + }); + } + + createAndServeTempBatchDownload(client, fileEntries, options, cb) { + if(!this.isEnabled()) { + return cb(notEnabledError()); + } + + const batchId = moment().utc().unix(); + const hashId = this.getBatchArchiveHashId(client, batchId); + const url = this.buildBatchArchiveTempDownloadLink(client, hashId); + options.expireTime = options.expireTime || moment().add(2, 'days'); + + FileDb.beginTransaction( (err, trans) => { + if(err) { + return cb(err); + } + + this._addOrUpdateHashIdRecord(trans, hashId, options.expireTime, err => { if(err) { - return cb(err); + return trans.rollback( () => { + return cb(err); + }); } - this.scheduleExpire(hashId, options.expireTime); - - return cb(null, url); - } - ); + async.eachSeries(fileEntries, (entry, nextEntry) => { + trans.run( + `INSERT INTO file_web_serve_batch (hash_id, file_id) + VALUES (?, ?);`, + [ hashId, entry.fileId ], + err => { + return nextEntry(err); + } + ); + }, err => { + trans[err ? 'rollback' : 'commit']( () => { + return cb(err, url); + }); + }); + }); + }); } fileNotFound(resp) { return this.webServer.instance.fileNotFound(resp); } - routeWebRequestForFile(req, resp) { + routeWebRequest(req, resp) { const hashId = paths.basename(req.url); this.loadServedHashId(hashId, (err, servedItem) => { @@ -261,44 +290,157 @@ class FileAreaWebAccess { return this.fileNotFound(resp); } - const fileEntry = new FileEntry(); - fileEntry.load(servedItem.fileId, err => { + const hashIdTypes = FileAreaWebAccess.getHashIdTypes(); + switch(servedItem.hashIdType) { + case hashIdTypes.SingleFile : + return this.routeWebRequestForSingleFile(servedItem, req, resp); + + case hashIdTypes.BatchArchive : + return this.routeWebRequestForBatchArchive(servedItem, req, resp); + + default : + return this.fileNotFound(resp); + } + }); + } + + routeWebRequestForSingleFile(servedItem, req, resp) { + const fileEntry = new FileEntry(); + + servedItem.fileId = servedItem.fileIds[0]; + + fileEntry.load(servedItem.fileId, err => { + if(err) { + return this.fileNotFound(resp); + } + + const filePath = fileEntry.filePath; + if(!filePath) { + return this.fileNotFound(resp); + } + + fs.stat(filePath, (err, stats) => { if(err) { return this.fileNotFound(resp); } - const filePath = fileEntry.filePath; - if(!filePath) { + resp.on('close', () => { + // connection closed *before* the response was fully sent + // :TODO: Log and such + }); + + resp.on('finish', () => { + // transfer completed fully + this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size); + }); + + const headers = { + 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), + 'Content-Length' : stats.size, + 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, + }; + + const readStream = fs.createReadStream(filePath); + resp.writeHead(200, headers); + return readStream.pipe(resp); + }); + }); + } + + routeWebRequestForBatchArchive(servedItem, req, resp) { + // + // We are going to build an on-the-fly zip file stream of 1:n + // files in the batch. + // + // First, collect all file IDs + // + const self = this; + + async.waterfall( + [ + function fetchFileIds(callback) { + FileDb.all( + `SELECT file_id + FROM file_web_serve_batch + WHERE hash_id = ?;`, + [ servedItem.hashId ], + (err, fileIdRows) => { + if(err || !Array.isArray(fileIdRows) || 0 === fileIdRows.length) { + return callback(Errors.DoesNotExist('Could not get file IDs for batch')); + } + + return callback(null, fileIdRows.map(r => r.file_id)); + } + ); + }, + function loadFileEntries(fileIds, callback) { + const filePaths = []; + async.eachSeries(fileIds, (fileId, nextFileId) => { + const fileEntry = new FileEntry(); + fileEntry.load(fileId, err => { + if(!err) { + filePaths.push(fileEntry.filePath); + } + return nextFileId(err); + }); + }, err => { + if(err) { + return callback(Errors.DoesNotExist('Coudl not load file IDs for batch')); + } + + return callback(null, filePaths); + }); + }, + function createAndServeStream(filePaths, callback) { + const zipFile = new yazl.ZipFile(); + + filePaths.forEach(fp => { + zipFile.addFile( + fp, // path to physical file + paths.basename(fp), // filename/path *stored in archive* + { + compress : false, // :TODO: do this smartly - if ext is in set = false, else true via isArchive() or such... mimeDB has this for us. + } + ); + }); + + zipFile.end( finalZipSize => { + if(-1 === finalZipSize) { + return callback(Errors.UnexpectedState('Unable to acquire final zip size')); + } + + resp.on('close', () => { + // connection closed *before* the response was fully sent + // :TODO: Log and such + }); + + resp.on('finish', () => { + // transfer completed fully + self.updateDownloadStatsForUserIdAndSystem(servedItem.userId, finalZipSize); + }); + + const batchFileName = `batch_${servedItem.hashId}.zip`; + + const headers = { + 'Content-Type' : mimeTypes.contentType(batchFileName) || mimeTypes.contentType('.bin'), + 'Content-Length' : finalZipSize, + 'Content-Disposition' : `attachment; filename="${batchFileName}"`, + }; + + resp.writeHead(200, headers); + return zipFile.outputStream.pipe(resp); + }); + } + ], + err => { + if(err) { + // :TODO: Log me! return this.fileNotFound(resp); } - fs.stat(filePath, (err, stats) => { - if(err) { - return this.fileNotFound(resp); - } - - resp.on('close', () => { - // connection closed *before* the response was fully sent - // :TODO: Log and such - }); - - resp.on('finish', () => { - // transfer completed fully - this.updateDownloadStatsForUserIdAndSystem(servedItem.userId, stats.size); - }); - - const headers = { - 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), - 'Content-Length' : stats.size, - 'Content-Disposition' : `attachment; filename="${fileEntry.fileName}"`, - }; - - const readStream = fs.createReadStream(filePath); - resp.writeHead(200, headers); - return readStream.pipe(resp); - }); - }); - }); + // ...otherwise, we would have called resp() already. + } + ); } updateDownloadStatsForUserIdAndSystem(userId, dlBytes, cb) { diff --git a/mods/file_base_download_manager.js b/mods/file_base_download_manager.js index 812a2422..382a7305 100644 --- a/mods/file_base_download_manager.js +++ b/mods/file_base_download_manager.js @@ -9,10 +9,12 @@ const theme = require('../core/theme.js'); const ansi = require('../core/ansi_term.js'); const Errors = require('../core/enig_error.js').Errors; const stringFormat = require('../core/string_format.js'); +const FileAreaWeb = require('../core/file_area_web.js'); // deps const async = require('async'); const _ = require('lodash'); +const moment = require('moment'); exports.moduleInfo = { name : 'File Base Download Queue Manager', @@ -22,17 +24,15 @@ exports.moduleInfo = { const FormIds = { queueManager : 0, - details : 1, }; const MciViewIds = { queueManager : { - queue : 1, - navMenu : 2, - }, - details : { + queue : 1, + navMenu : 2, - } + customRangeStart : 10, + }, }; exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { @@ -126,6 +126,26 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { return cb(null); } + displayWebDownloadLinkForFileEntry(fileEntry) { + FileAreaWeb.getExistingTempDownloadServeItem(this.client, fileEntry, (err, serveItem) => { + if(serveItem && serveItem.url) { + const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + fileEntry.webDlLink = ansi.vtxHyperlink(this.client, serveItem.url) + serveItem.url; + fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + } else { + fileEntry.webDlLink = ''; + fileEntry.webDlExpire = ''; + } + + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, fileEntry, + { filter : [ '{webDlLink}', '{webDlExpire}' ] } + ); + }); + } + updateDownloadQueueView(cb) { const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); if(!queueView) { @@ -138,7 +158,13 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); + queueView.on('index update', idx => { + const fileEntry = this.dlQueue.items[idx]; + this.displayWebDownloadLinkForFileEntry(fileEntry); + }); + queueView.redraw(); + this.displayWebDownloadLinkForFileEntry(this.dlQueue.items[0]); return cb(null); } diff --git a/mods/file_base_web_download_manager.js b/mods/file_base_web_download_manager.js new file mode 100644 index 00000000..d171cfdb --- /dev/null +++ b/mods/file_base_web_download_manager.js @@ -0,0 +1,287 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('../core/menu_module.js').MenuModule; +const ViewController = require('../core/view_controller.js').ViewController; +const DownloadQueue = require('../core/download_queue.js'); +const theme = require('../core/theme.js'); +const ansi = require('../core/ansi_term.js'); +const Errors = require('../core/enig_error.js').Errors; +const stringFormat = require('../core/string_format.js'); +const FileAreaWeb = require('../core/file_area_web.js'); +const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled; +const Config = require('../core/config.js').config; + +// deps +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); + +exports.moduleInfo = { + name : 'File Base Download Web Queue Manager', + desc : 'Module for interacting with web backed download queue/batch', + author : 'NuSkooler', +}; + +const FormIds = { + queueManager : 0 +}; + +const MciViewIds = { + queueManager : { + queue : 1, + navMenu : 2, + + customRangeStart : 10, + } +}; + +exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule { + + constructor(options) { + super(options); + + this.dlQueue = new DownloadQueue(this.client); + + this.menuMethods = { + removeItem : (formData, extraArgs, cb) => { + const selectedItem = this.dlQueue.items[formData.value.queueItem]; + if(!selectedItem) { + return cb(null); + } + + this.dlQueue.removeItems(selectedItem.fileId); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView(formData.value.queueItem, cb); + }, + clearQueue : (formData, extraArgs, cb) => { + this.dlQueue.clear(); + + // :TODO: broken: does not redraw menu properly - needs fixed! + return this.removeItemsFromDownloadQueueView('all', cb); + }, + getBatchLink : (formData, extraArgs, cb) => { + return this.generateAndDisplayBatchLink(cb); + } + }; + } + + initSequence() { + if(0 === this.dlQueue.items.length) { + return this.gotoMenu(this.menuConfig.config.emptyQueueMenu || 'fileBaseDownloadManagerEmptyQueue'); + } + + const self = this; + + async.series( + [ + function beforeArt(callback) { + return self.beforeArt(callback); + }, + function display(callback) { + return self.displayQueueManagerPage(false, callback); + } + ], + () => { + return self.finishedLoading(); + } + ); + } + + removeItemsFromDownloadQueueView(itemIndex, cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + return cb(Errors.DoesNotExist('Queue view does not exist')); + } + + if('all' === itemIndex) { + queueView.setItems([]); + queueView.setFocusItems([]); + } else { + queueView.removeItem(itemIndex); + } + + queueView.redraw(); + return cb(null); + } + + displayFileInfoForFileEntry(fileEntry) { + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, fileEntry, + { filter : [ '{webDlLink}', '{webDlExpire}', '{fileName}' ] } // :TODO: Others.... + ); + } + + updateDownloadQueueView(cb) { + const queueView = this.viewControllers.queueManager.getView(MciViewIds.queueManager.queue); + if(!queueView) { + return cb(Errors.DoesNotExist('Queue view does not exist')); + } + + const queueListFormat = this.menuConfig.config.queueListFormat || '{webDlLink}'; + const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat; + + queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); + queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); + + queueView.on('index update', idx => { + const fileEntry = this.dlQueue.items[idx]; + this.displayFileInfoForFileEntry(fileEntry); + }); + + queueView.redraw(); + this.displayFileInfoForFileEntry(this.dlQueue.items[0]); + + return cb(null); + } + + generateAndDisplayBatchLink(cb) { + const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempBatchDownload( + this.client, + this.dlQueue.items, + { + expireTime : expireTime + }, + (err, webBatchDlLink) => { + // :TODO: handle not enabled -> display such + if(err) { + return cb(err); + } + + const webDlExpireTimeFormat = this.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + const formatObj = { + webBatchDlLink : ansi.vtxHyperlink(this.client, webBatchDlLink) + webBatchDlLink, + webBatchDlExpire : expireTime.format(webDlExpireTimeFormat), + }; + + this.updateCustomViewTextsWithFilter( + 'queueManager', + MciViewIds.queueManager.customRangeStart, + formatObj, + { filter : Object.keys(formatObj).map(k => '{' + k + '}' ) } + ); + + return cb(null); + } + ); + } + + displayQueueManagerPage(clearScreen, cb) { + const self = this; + + async.series( + [ + function prepArtAndViewController(callback) { + return self.displayArtAndPrepViewController('queueManager', { clearScreen : clearScreen }, callback); + }, + function prepareQueueDownloadLinks(callback) { + const webDlExpireTimeFormat = self.menuConfig.config.webDlExpireTimeFormat || 'YYYY-MMM-DD @ h:mm'; + + async.each(self.dlQueue.items, (fileEntry, nextFileEntry) => { + FileAreaWeb.getExistingTempDownloadServeItem(self.client, fileEntry, (err, serveItem) => { + if(err) { + if(ErrNotEnabled === err.reasonCode) { + return nextFileEntry(err); // we should have caught this prior + } + + const expireTime = moment().add(Config.fileBase.web.expireMinutes, 'minutes'); + + FileAreaWeb.createAndServeTempDownload( + self.client, + fileEntry, + { expireTime : expireTime }, + (err, url) => { + if(err) { + return nextFileEntry(err); + } + + fileEntry.webDlLinkRaw = url; + fileEntry.webDlLink = ansi.vtxHyperlink(self.client, url) + url; + fileEntry.webDlExpire = expireTime.format(webDlExpireTimeFormat); + + return nextFileEntry(null); + } + ); + } else { + fileEntry.webDlLinkRaw = serveItem.url; + fileEntry.webDlLink = ansi.vtxHyperlink(self.client, serveItem.url) + serveItem.url; + fileEntry.webDlExpire = moment(serveItem.expireTimestamp).format(webDlExpireTimeFormat); + return nextFileEntry(null); + } + }); + }, err => { + return callback(err); + }); + }, + function populateViews(callback) { + return self.updateDownloadQueueView(callback); + } + ], + err => { + if(cb) { + return cb(err); + } + } + ); + } + + displayArtAndPrepViewController(name, options, cb) { + const self = this; + const config = this.menuConfig.config; + + async.waterfall( + [ + function readyAndDisplayArt(callback) { + if(options.clearScreen) { + self.client.term.rawWrite(ansi.resetScreen()); + } + + theme.displayThemedAsset( + config.art[name], + self.client, + { font : self.menuConfig.font, trailingLF : false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + function prepeareViewController(artData, callback) { + if(_.isUndefined(self.viewControllers[name])) { + const vcOpts = { + client : self.client, + formId : FormIds[name], + }; + + if(!_.isUndefined(options.noInput)) { + vcOpts.noInput = options.noInput; + } + + const vc = self.addViewController(name, new ViewController(vcOpts)); + + const loadOpts = { + callingMenu : self, + mciMap : artData.mciMap, + formId : FormIds[name], + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } + + self.viewControllers[name].setFocus(true); + return callback(null); + + }, + ], + err => { + return cb(err); + } + ); + } +}; + \ No newline at end of file diff --git a/package.json b/package.json index ef9da900..224fda84 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "temptmp": "^1.0.0", "uuid": "^3.1.0", "uuid-parse": "^1.0.0", - "ws": "^3.1.0" + "ws": "^3.1.0", + "yazl" : "^2.4.2" }, "devDependencies": {}, "engines": { From 38b9bf2c30687ac239c18efd914c567a1791189f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 28 Sep 2017 21:34:46 -0600 Subject: [PATCH 033/102] Fix typo in persistHashTag() --- core/file_entry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/file_entry.js b/core/file_entry.js index 84c6559f..32903315 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -280,7 +280,7 @@ module.exports = class FileEntry { } transOrDb.serialize( () => { - fileDb.run( + transOrDb.run( `INSERT OR IGNORE INTO hash_tag (hash_tag) VALUES (?);`, [ hashTag ] From e84920e508ab9c382b005cf5c8a7c06555b8c04d Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 28 Sep 2017 21:34:58 -0600 Subject: [PATCH 034/102] Add web d/l manager to menu.hjson --- mods/menu.hjson | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/mods/menu.hjson b/mods/menu.hjson index 08202ca6..0dffdcbb 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -2934,6 +2934,64 @@ } } + fileBaseWebDownloadManager: { + desc: Web D/L Manager + module: file_base_web_download_manager + config: { + art: { + queueManager: FWDLMGR + batchList: BATDLINF + } + emptyQueueMenu: fileBaseDownloadManagerEmptyQueue + } + form: { + 0: { + mci: { + VM1: { + argName: queueItem + } + HM2: { + focus: true + items: [ "get batch link", "quit", "help" ] + argName: navSelect + } + } + + submit: { + *: [ + { + value: { navSelect: 0 } + action: @method:getBatchLink + } + { + value: { navSelect: 1 } + action: @systemMethod:prevMenu + } + ] + } + + actionKeys: [ + { + keys: [ "b", "shift + b" ] + action: @method:getBatchLink + } + { + keys: [ "delete", "r", "shift + r" ] + action: @method:removeItem + } + { + keys: [ "c", "shift + c" ] + action: @method:clearQueue + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + fileBaseDownloadManagerEmptyQueue: { desc: Empty Download Queue art: FEMPTYQ From efee0eafdee6950aaf0ea6a501671ec11d821ba2 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 29 Sep 2017 16:30:05 -0600 Subject: [PATCH 035/102] Minor document updates --- docs/menu_system.md | 32 +++++++++++++++++++++----------- docs/modding.md | 12 ++++++++++++ docs/msg_networks.md | 17 ++++++++++++----- 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 docs/modding.md diff --git a/docs/menu_system.md b/docs/menu_system.md index 1dea637c..026ef648 100644 --- a/docs/menu_system.md +++ b/docs/menu_system.md @@ -1,17 +1,26 @@ # Menu System -ENiGMA½'s menu system is highly flexible and moddable. The possibilities are almost endless! By modifying your `menu.hjson` you will be able to create a custom look and feel unique to your board. +ENiGMA½'s menu system is highly flexible and moddable. The possibilities are almost endless! + +This document and others will refer to `menu.hjson`. This should be seen as an alias to `yourboardname.hjson` (or whatever you reference in `config.hjson` using the `menuFile` property — see below). By modifying your `menu.hjson` you will be able to create a custom experience unique to your board. The default `menu.hjson` file lives within the `mods` directory. It is **highly recommended** to specify another file by setting the `menuFile` property in your `config.hjson` file: ```hjson general: { /* Can also specify a full path */ - menuFile: mybbs.hjson + menuFile: yourboardname.hjson } ``` -(You can start by copying the default `menu.hjson` to `mybbs.hjson`) + +You can start by copying the default `mods/menu.hjson` to `yourboardname.hjson`. ## The Basics -Like all configuration within ENiGMA½, menu configuration is done via a HJSON file. This file is located in the `mods` directory: `mods/menu.hjson`. +Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format. + +Entries in `menu.hjson` are objects defining a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to: +* Classical Main, Messages, and File menus +* Art file display +* Module driven menus such as door launchers + Each entry in `menu.hjson` defines an object that represents a menu. These objects live within the `menus` parent object. Each object's *key* is a menu name you can reference within other menus in the system. @@ -26,9 +35,9 @@ telnetConnected: { } ``` -The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the server's config). +The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config). -An art pattern of `CONNECT` is set telling the system to look for `CONNECT` in the current theme location, then in the common `mods/art` directory where `` represents a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. You can be explicit here if desired, by specifying a file extension. +An art pattern of `CONNECT` is set telling the system to look for `CONNECT.*` where `` represents a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. If desired, you can also be explicit by supplying a full filename with an extention such as `CONNECT.ANS`. The entry `next` sets up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`. @@ -47,20 +56,21 @@ matrix: { submit: true focus: true items: [ "login", "apply", "log off" ] + argName: matrixSubmit } } submit: { *: [ { - value: { 1: 0 } + value: { matrixSubmit: 0 } action: @menu:login } { - value: { 1: 1 }, + value: { matrixSubmit: 1 }, action: @menu:newUserApplication } { - value: { 1: 2 }, + value: { matrixSubmit: 2 }, action: @menu:logoff } ] @@ -71,6 +81,6 @@ matrix: { } ``` -In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. +In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` for this action as `matrixSubmit`. -The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ 1: 0 }` or view ID 1, value 0 will match causing `action` of `@menu:login` to be executed (go to `login` menu). \ No newline at end of file +The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ matrixSubmit: 0 }` will match causing `action` of `@menu:login` to be executed (go to `login` menu). diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 00000000..5dcf5a29 --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,12 @@ +# Modding + +## General Configuraiton +See [Configuration](config.md) + +## Menus +See [Menu System](menu_system.md) + +## Theming +Take a look at how the default `luciano_blocktronics` theme found under `mods/themes` works! + +TODO document me! \ No newline at end of file diff --git a/docs/msg_networks.md b/docs/msg_networks.md index ca0afe17..1bb46c8c 100644 --- a/docs/msg_networks.md +++ b/docs/msg_networks.md @@ -6,7 +6,7 @@ Message networks are configured in `messageNetworks` section of `config.hjson`. * `originLine` (optional): Overrwrite the default origin line for networks that support it. For example: `originLine: Xibalba - xibalba.l33t.codes:44510` ## FidoNet Technology Network (FTN) -FTN networks are configured under the `messageNetworks::ftn` section of `config.hjson`. +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. @@ -30,7 +30,7 @@ The `networks` section contains a sub section for network(s) you wish you join y ``` ### 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. +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 (In fact you can import AREAS.BBS using `oputil.js`!) When importing, messages will be placed in the local area that matches key under `areas`. @@ -57,11 +57,11 @@ When importing, messages will be placed in the local area that matches key under ``` ### 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`. +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**. + * `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) @@ -85,7 +85,7 @@ A node entry starts with a FTN style address (up to 5D) **as a key** in `config. scannerTossers: { ftn_bso: { nodes: { - "46:*: { + "46:*": { packetType: 2+ packetPassword: mypass encoding: cp437 @@ -97,6 +97,13 @@ A node entry starts with a FTN style address (up to 5D) **as a key** in `config. } ``` +#### TIC Support +ENiGMA½ supports TIC files. This is handled by mapping TIC areas to local file areas. + +Under a given node (described above) TIC configuration may be supplied. + +TODO + #### 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. From b6e628014a5fbdc53a7dfe31e36a8f57cc139900 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 29 Sep 2017 16:45:59 -0600 Subject: [PATCH 036/102] More doc updates --- docs/config.md | 26 +++++++++++++++----------- docs/modding.md | 5 ++++- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/config.md b/docs/config.md index ca760676..ff653e37 100644 --- a/docs/config.md +++ b/docs/config.md @@ -6,11 +6,16 @@ The main system configuration file, `config.hjson` both overrides defaults and p **Windows note**: **~** resolves to *C:\Users\YOURLOGINNAME\* on modern Windows installations, e.g. `C:\Users\NuSkooler\.config\enigma-bbs\config.hjson` -### oputil.js -Please see `oputil.js config` for configuration generation options. +### Creating a Configuration +Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory: +``` +./oputil.js config new +``` -### Example: System Name -`core/config.js` provides the default system name as follows: +You will be asked a series of questions to create an initial configuration. + +### Overriding Defaults +The file `core/config.js` provides various defaults to the system that you can override via `config.hjson`. For example, the default system name is defined as follows: ```javascript general : { boardName : 'Another Fine ENiGMA½ System' @@ -26,17 +31,14 @@ general: { (Note the very slightly different syntax. **You can use standard JSON if you wish**) +While not everything that is available in your `config.hjson` file can be found defaulted in `core/config.js`, a lot is. [Poke around and see what you can find](https://github.com/NuSkooler/enigma-bbs/blob/master/core/config.js)! + ### Specific Areas of Interest -* [Menu System](menu_system.md) * [Message Conferences](msg_conf_area.md) * [Message Networks](msg_networks.md) * [File Base](file_base.md) * [File Archives & Archivers](archives.md) -* [Doors](doors.md) -* [MCI Codes](mci.md) * [Web Server](web_server.md) -...and other stuff [in the /docs directory](./) - ### A Sample Configuration Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked. @@ -125,5 +127,7 @@ Below is a **sample** `config.hjson` illustrating various (but certainly not all } ``` -## Menus -See [the menu system docs](menu_system.md) \ No newline at end of file +## See Also +* [Modding](modding.md) +* [Doors](doors.md) +* [MCI Codes](mci.md) diff --git a/docs/modding.md b/docs/modding.md index 5dcf5a29..9449a91e 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -9,4 +9,7 @@ See [Menu System](menu_system.md) ## Theming Take a look at how the default `luciano_blocktronics` theme found under `mods/themes` works! -TODO document me! \ No newline at end of file +TODO document me! + +## Add-On Modules +See [Mods](mods.md) \ No newline at end of file From 8b7cf1f2100f1c297a09f67b16767e494df66beb Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 29 Sep 2017 19:43:22 -0600 Subject: [PATCH 037/102] Add extra logging around TIC processing --- core/scanner_tossers/ftn_bso.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 039caac7..65009333 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1289,6 +1289,7 @@ function FTNMessageScanTossModule() { return ticFileInfo.validate(config, (err, localInfo) => { if(err) { + Log.trace( { reason : err.message }, 'Validation failure'); return callback(err); } @@ -1349,10 +1350,17 @@ function FTNMessageScanTossModule() { localInfo.existingFileId = fileIds[0]; // fetch old filename - we may need to remove it if replacing with a new name - FileEntry.loadBasicEntry(localInfo.existingFileId, {}, (cb, info) => { - localInfo.oldFileName = info.fileName; - localInfo.oldStorageTag = info.storageTag; - return callback(null, localInfo); + FileEntry.loadBasicEntry(localInfo.existingFileId, {}, (err, info) => { + if(info) { + Log.trace( + { fileId : localInfo.existingFileId, oldFileName : info.fileName, oldStorageTag : info.storageTag }, + 'Existing TIC file target to be replaced' + ); + + localInfo.oldFileName = info.fileName; + localInfo.oldStorageTag = info.storageTag; + } + return callback(null, localInfo); // continue even if we couldn't find an old match }); } else if(fileIds.legnth > 1) { return callback(Errors.General(`More than one existing entry for TIC in ${localInfo.areaTag} ([${fileIds.join(', ')}])`)); @@ -1397,6 +1405,10 @@ function FTNMessageScanTossModule() { ticFileInfo.filePath, scanOpts, (err, fileEntry) => { + if(err) { + Log.trace( { reason : err.message }, 'Scanning failed'); + } + localInfo.fileEntry = fileEntry; return callback(err, localInfo); } @@ -1441,6 +1453,7 @@ function FTNMessageScanTossModule() { self.copyTicAttachment(ticFileInfo.filePath, dst, isUpdate, (err, finalPath) => { if(err) { + Log.info( { reason : err.message }, 'Failed to copy TIC attachment'); return callback(err); } @@ -1474,7 +1487,7 @@ function FTNMessageScanTossModule() { ], (err, localInfo) => { if(err) { - Log.error( { error : err.message, reason : err.reason, tic : ticFileInfo.path }, 'Failed import/update TIC record' ); + Log.error( { error : err.message, reason : err.reason, tic : ticFileInfo.filePath }, 'Failed import/update TIC record' ); } else { Log.debug( { tic : ticFileInfo.path, file : ticFileInfo.filePath, area : localInfo.areaTag }, From 5cbbd76411ccfd4d7cef20a194f8595eaaedb8a0 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 30 Sep 2017 12:34:10 -0600 Subject: [PATCH 038/102] Updates to oputil when --update with desc/descLong --- core/oputil/oputil_file_base.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index d9ad9366..0c4c6065 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -172,10 +172,10 @@ function scanFileAreaForChanges(areaInfo, options, cb) { // // We'll update the entry if the following conditions are met: // * We have a single duplicate, and: - // * --update-desc was passed or the existing entry's desc or + // * --update was passed or the existing entry's desc or // longDesc are blank/empty // - if(argv['update'] && 1 === dupeEntries.length) { + if(argv.update && 1 === dupeEntries.length) { const FileEntry = require('../../core/file_entry.js'); const existingEntry = new FileEntry(); @@ -200,6 +200,8 @@ function scanFileAreaForChanges(areaInfo, options, cb) { } console.info('Dupe (updating)'); + existingEntry.desc = fileEntry.desc; + existingEntry.descLong = fileEntry.descLong; updateTags(existingEntry); finalizeEntryAndPersist(true, existingEntry, descHandler, err => { From 1ad7ce848a962d4b6cdd897dbd6b7d4e36f94a42 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 30 Sep 2017 15:37:46 -0600 Subject: [PATCH 039/102] Upgrade farmhash to v2.0.4+ --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 224fda84..f23ece19 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "buffers": "NuSkooler/node-buffers", "bunyan": "^1.8.12", "exiftool": "^0.0.3", - "farmhash": "1.2.1", + "farmhash": "^2.0.4", "fs-extra": "^4.0.1", "gaze": "^1.1.2", "graceful-fs": "^4.1.11", From af52ed6153327dd9a82a4bd639e066aa75fb2442 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 1 Oct 2017 11:07:49 -0600 Subject: [PATCH 040/102] Better handling of TIC import descriptions * Add descPriority config option (default='diz') * Really prefer diz/ldesc over *generated* descriptions e.g. from filename or info extractors --- core/config.js | 1 + core/file_base_area.js | 12 ++++++++---- core/scanner_tossers/ftn_bso.js | 26 +++++++++++++++++++++----- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/core/config.js b/core/config.js index 549b44a2..e33648b9 100644 --- a/core/config.js +++ b/core/config.js @@ -639,6 +639,7 @@ function getDefaultConfig() { secureInOnly : true, // only bring in from secure inbound (|secInbound| path, password protected) uploadBy : 'ENiGMA TIC', // default upload by username (override @ network) allowReplace : false, // use "Replaces" TIC field + descPriority : 'diz', // May be diz=.DIZ/etc., or tic=from TIC Ldesc } } }, diff --git a/core/file_base_area.js b/core/file_base_area.js index 1d1062a9..f3845cc1 100644 --- a/core/file_base_area.js +++ b/core/file_base_area.js @@ -356,7 +356,8 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) { // Assume FILE_ID.DIZ, NFO files, etc. are CP437. // // :TODO: This isn't really always the case - how to handle this? We could do a quick detection... - fileEntry[descType] = iconv.decode(sliceAtSauceMarker(data, 0x1a), 'cp437'); + fileEntry[descType] = iconv.decode(sliceAtSauceMarker(data, 0x1a), 'cp437'); + fileEntry[`${descType}Src`] = 'descFile'; return next(null); }); }); @@ -404,7 +405,8 @@ function extractAndProcessSingleArchiveEntry(fileEntry, filePath, archiveEntries function processSingleExtractedFile(extractedFile, callback) { populateFileEntryInfoFromFile(fileEntry, extractedFile, err => { if(!fileEntry.desc) { - fileEntry.desc = getDescFromFileName(filePath); + fileEntry.desc = getDescFromFileName(filePath); + fileEntry.descSrc = 'fileName'; } return callback(err); }); @@ -529,7 +531,8 @@ function populateFileEntryInfoFromFile(fileEntry, filePath, cb) { stdout = (wordWrapText( stdout, { width : 45 } ).wrapped || []).join('\n'); } - fileEntry[key] = stdout; + fileEntry[key] = stdout; + fileEntry[`${key}Src`] = 'infoTool'; } } @@ -551,7 +554,8 @@ function populateFileEntryNonArchive(fileEntry, filePath, stepInfo, iterator, cb function getDescriptions(callback) { populateFileEntryInfoFromFile(fileEntry, filePath, err => { if(!fileEntry.desc) { - fileEntry.desc = getDescFromFileName(filePath); + fileEntry.desc = getDescFromFileName(filePath); + fileEntry.descSrc = 'fileName'; } return callback(err); }); diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 65009333..7e6e924b 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1321,7 +1321,7 @@ function FTNMessageScanTossModule() { // Lastly, we will only replace if the item is in the same/specified area // and that come from the same origin as a previous entry. // - const allowReplace = _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'allowReplace' ] ) || Config.scannerTossers.ftn_bso.tic.allowReplace; + const allowReplace = _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'allowReplace' ], Config.scannerTossers.ftn_bso.tic.allowReplace); const replaces = ticFileInfo.getAsString('Replaces'); if(!allowReplace || !replaces) { @@ -1377,7 +1377,7 @@ function FTNMessageScanTossModule() { short_file_name : ticFileInfo.getAsString('File').toUpperCase(), // upper to ensure no case issues later; this should be a DOS 8.3 name tic_origin : ticFileInfo.getAsString('Origin'), tic_desc : ticFileInfo.getAsString('Desc'), - upload_by_username : _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'uploadBy' ]) || Config.scannerTossers.ftn_bso.tic.uploadBy, + upload_by_username : _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'uploadBy' ], Config.scannerTossers.ftn_bso.tic.uploadBy), } }; @@ -1432,9 +1432,25 @@ function FTNMessageScanTossModule() { localInfo.fileEntry.areaTag = localInfo.areaTag; localInfo.fileEntry.fileName = ticFileInfo.longFileName; - // we default to .DIZ/etc. desc, but use from TIC if needed - if(!localInfo.fileEntry.desc || 0 === localInfo.fileEntry.desc.length) { - localInfo.fileEntry.desc = ticFileInfo.getAsString('Ldesc') || ticFileInfo.getAsString('Desc') || getDescFromFileName(ticFileInfo.filePath); + // + // We may now have two descriptions: from .DIZ/etc. or the TIC itself. + // Determine which one to use using |descPriority| and availability. + // + // We will still fallback as needed from -> -> + // + const descPriority = _.get( + Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'descPriority' ], + Config.scannerTossers.ftn_bso.tic.descPriority + ); + + if('tic' === descPriority) { + const origDesc = localInfo.fileEntry.desc; + localInfo.fileEntry.desc = ticFileInfo.getAsString('Ldesc') || origDesc || getDescFromFileName(ticFileInfo.filePath); + } else { + // see if we got desc from .DIZ/etc. + const fromDescFile = 'descFile' === localInfo.fileEntry.descSrc; + localInfo.fileEntry.desc = fromDescFile ? localInfo.fileEntry.desc : ticFileInfo.getAsString('Ldesc'); + localInfo.fileEntry.desc = localInfo.fileEntry.desc || getDescFromFileName(ticFileInfo.filePath); } const areaStorageDir = getAreaStorageDirectoryByTag(storageTag); From ecc6562b7975b0eda60a9736883ff4583a717880 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 29 Oct 2017 12:47:23 +0000 Subject: [PATCH 041/102] Add VTXClient docs --- docs/images/vtxclient.png | Bin 0 -> 130515 bytes docs/vtx_web_client.md | 87 ++++++++++++++++++++++++++++++++++++++ misc/vtx/vtx.html | 27 ++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 docs/images/vtxclient.png create mode 100644 docs/vtx_web_client.md create mode 100644 misc/vtx/vtx.html diff --git a/docs/images/vtxclient.png b/docs/images/vtxclient.png new file mode 100644 index 0000000000000000000000000000000000000000..99261ced646d1a1caf710aaca694d42ca47ce815 GIT binary patch literal 130515 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

7h<34FTU<$wruVgQ$ci`;FTlE>QiwiTBzZu8 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 + + + + + + + + + +

+
+ +
+
+ + \ No newline at end of file From 0ea36e4574dada7e46c2e6ad1a9984a11426f760 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 29 Oct 2017 12:48:17 +0000 Subject: [PATCH 042/102] Updated Raspberry Pi docs --- docs/rpi.md | 49 +++++++++++++++++++++---------------------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/docs/rpi.md b/docs/rpi.md index 4794bbcf..f474e959 100644 --- a/docs/rpi.md +++ b/docs/rpi.md @@ -3,39 +3,32 @@ ENiGMA½ can run under your Linux / RPi installation! The following instructions should help get you started. ## Tested RPi Models -###Model A -Works, but fairly slow (Node itself is not the fastest on this device). May work better overlocked, etc. +### Model A +Works, but fairly slow when browsing message areas (Node itself is not the fastest on this device). May work better overlocked, etc. -###v2 Model B -Works well with default rasbian, follow the normal quickstart install procedure, except for installing nodejs. To install nodejs do the following: - - curl -sL https://deb.nodesource.com/setup_4.x | sudo -E bash - - sudo apt-get install -y nodejs +### v2 Model B +Works well with Raspbian! Keep in mind, compiling the dependencies with `npm install` will take some time and appear to hang. Just be patient. -##Example Configuration: RPi Model A + Minibian +## Example Configuration: RPi Model A + Raspbian Stretch Lite ### Basic Instructions -1. Download and `dd` the Minibian .img file from https://minibianpi.wordpress.com/ to a SDCARD. Cards >= 16GB recommended. -2. After booting Minibian, expand your file system. See http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi for information. -3. Update & upgrade: `apt-get update && apt-get upgrade` -4. It is recommended that you install `sudo` and create an admin user: `apt-get install sudo`, `adduser `, `adduser sudo` (reboot & login as the user your just created) -5. We want to build dependencies with a updated version of GCC. The following works to install GCC 4.9 on Minibian "wheezy": -a. Update */etc/apt/sources.list* replacing all "wheezy" with "jessie" -b. `sudo apt-get update` -c. `sudo apt-get install gcc-4.9 g++-4.9` -d. Update */etc/apt/sources.list* reverting all "jessie" back to "wheezy" -e. `sudo apt-get update` -f. Update alternatives: `sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.9 60 --slave /usr/bin/g++ g++ /usr/bin/g++-4.9` -6. Install dependencies: `sudo apt-get install make python libicu-dev libssl-dev git` -7. Install the latest Node.js from here: http://node-arm.herokuapp.com/ (**only download the .dep and dpkg install it!**) -8. The RPi A has very low memory, we'll need a swap file: -a. `sudo dd if=/dev/zero of=tmpswap bs=1024 count=1M` -b. `sudo mkswap tmpswap` -c. `sudo swapon tmpswap` -9. Clone enigma-bbs.git -10. Install dependencies. Here we will force GCC 4.9 for compilation: `CC=gcc-4.9 npm install` -11. Follow generic setup for creating a config.hjson, etc. and you should be ready to go! +1. Download [Raspbian Stretch Lite](https://www.raspberrypi.org/downloads/raspbian/). Follow the instructions +on the [Raspbian site](https://www.raspberrypi.org/documentation/installation/installing-images/README.md) regarding how +to get it written to an SD card. + +2. Run `sudo raspi-config`, then: + 1. Set your timezone (option 4, option I2) + 2. Enable SSH (option 5, option P2) + 3. Expand the filesystem to use the entire SD card (option 7, option A1) + +3. Update & upgrade all packages: `apt-get update && apt-get upgrade` + +4. Install required packages: `sudo apt install lrzsz p7zip-full` + +5. Follow the [Quickstart](docs/index.md) instructions to install ENiGMA½. + +6. Profit! From 6c4745ee22ddd3a0b78e31a4db433021c9bb2290 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 29 Oct 2017 12:49:08 +0000 Subject: [PATCH 043/102] Typo fixes --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 1147c578..48fdc090 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ For Windows environments or if you simply like to do things manually, read on... ### New to Node -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 (Please consider the `install.sh` approach unless you really want to manually install!): +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 environments (Please consider the `install.sh` approach unless you really want to manually install!): ```bash curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.0/install.sh | bash @@ -64,7 +64,7 @@ The main system configuration is handled via `~/.config/enigma-bbs/config.hjson` ./oputil.js config new ``` -(You wil be asked a series of basic questions) +(You will be asked a series of basic questions) #### Example Starting Configuration Below is an _example_ configuration. It is recommended that you at least **start with a generated configuration using oputil.js described above**. From 7f18f3d6140b8a6253cec45cd59469329c937527 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 29 Oct 2017 12:50:25 +0000 Subject: [PATCH 044/102] Add note about LetsEncrypt to webserver docs --- docs/web_server.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/web_server.md b/docs/web_server.md index 8c341191..1f87cd80 100644 --- a/docs/web_server.md +++ b/docs/web_server.md @@ -1,8 +1,10 @@ # Web Server -ENiGMA½ comes with a built in *content server* for supporting both HTTP and HTTPS. Currently the [File Bases](file_base.md) registers routes for file downloads, and static files can also be served for your BBS. Other features will likely come in the future or you can easily write your own! +ENiGMA½ comes with a built in *content server* for supporting both HTTP and HTTPS. Currently the +[File Bases](file_base.md) registers routes for file downloads, and static files can also be served for your BBS. Other features will likely come in the future or you can easily write your own! ## Configuration -By default the web server is not enabled. To enable it, you will need to at a minimum configure two keys in the `contentServers::web` section of `config.hjson`: +By default the web server is not enabled. To enable it, you will need to at a minimum configure two keys in +the `contentServers::web` section of `config.hjson`: ```hjson contentServers: { @@ -16,12 +18,17 @@ contentServers: { } ``` -This will configure HTTP for port 8080 (override with `port`). To additionally enable HTTPS, you will need a PEM encoded SSL certificate and private key. Once obtained, simply enable the HTTPS server: +This will configure HTTP for port 8080 (override with `port`). To additionally enable HTTPS, you will need a +PEM encoded SSL certificate and private key. [LetsEncrypt](https://letsencrypt.org/) supply free trusted +certificates that work perfectly with ENiGMA½. + +Once obtained, simply enable the HTTPS server: + ```hjson contentServers: { web: { domain: bbs.yourdomain.com - // set 'overrideUrlPrefix' if for example, you use a transparent proxy in front of ENiGMA and need to be explicit about URLs the system hands out + // set 'overrideUrlPrefix' if for example, you use a transparent proxy in front of ENiGMA and need to be explicit about URLs the system hands out overrideUrlPrefix: https://bbs.yourdomain.com https: { enabled: true @@ -37,4 +44,5 @@ contentServers: { Static files live relative to the `contentServers::web::staticRoot` path which defaults to `enigma-bbs/www`. ### Custom Error Pages -Customized error pages can be created for [HTTP error codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_Client_Error) by providing a `.html` file in the *static routes* area. For example: `404.html`. +Customized error pages can be created for [HTTP error codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#4xx_Client_Error) +by providing a `.html` file in the *static routes* area. For example: `404.html`. From d26117e428bbf810f654831d827f0e72886e7619 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 29 Oct 2017 12:51:04 +0000 Subject: [PATCH 045/102] Typo fix in file base docs --- docs/file_base.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file_base.md b/docs/file_base.md index bfd802b9..893a93de 100644 --- a/docs/file_base.md +++ b/docs/file_base.md @@ -1,7 +1,7 @@ # File Bases Starting with version 0.0.4-alpha, ENiGMA½ has support for File Bases! Documentation below covers setup of file area(s), but first some information on what to expect: -## A Different Appoach +## A Different Approach ENiGMA½ has strayed away from the old familure setup here and instead takes a more modern approach: * [Gazelle](https://whatcd.github.io/Gazelle/) inspired system for searching & browsing files * No File Conferences (just areas!) From 40ba4f5075c326d1978c502d96735671e3f0b2e4 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 29 Oct 2017 12:51:56 +0000 Subject: [PATCH 046/102] Add TIC packet info to msg_networks docs --- docs/msg_networks.md | 69 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/msg_networks.md b/docs/msg_networks.md index 1bb46c8c..11fa9202 100644 --- a/docs/msg_networks.md +++ b/docs/msg_networks.md @@ -100,9 +100,74 @@ A node entry starts with a FTN style address (up to 5D) **as a key** in `config. #### TIC Support ENiGMA½ supports TIC files. This is handled by mapping TIC areas to local file areas. -Under a given node (described above) TIC configuration may be supplied. +Under a given node (like the one configured above), TIC configuration may be supplied: + +```hjson +{ + scannerTossers: { + ftn_bso: { + nodes: { + "46:*": { + packetType: 2+ + packetPassword: mypass + encoding: cp437 + archiveType: zip + tic: { + password: TESTY-TEST + uploadBy: Agoranet TIC + allowReplace: true + } + } + } + } + } +} +``` + +You then need to configure the mapping between TIC areas you want to carry, and the file +base area for them to be tossed to. Start by creating a storage tag and file base, if you haven't +already: + +````hjson +fileBase: { + areaStoragePrefix: /home/bbs/file_areas/ + + storageTags: { + msg_network: "msg_network" + } + + areas: { + msgNetworks: { + name: Message Networks + desc: Message networks news & info + storageTags: [ + "msg_network" + ] + } + } +} + +```` +and then create the mapping between the TIC area and the file area created: + +````hjson +ticAreas: { + agn_node: { + areaTag: msgNetworks + hashTags: agoranet,nodelist + storageTag: msg_network + } + + agn_info: { + areaTag: msgNetworks + hashTags: agoranet,infopack + storageTag: msg_network + } +} + +```` +Multiple TIC areas can be mapped to a single file base area. -TODO #### 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. From 40e269fa8dea5d06f8e04e0b97c729eb8080f667 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 29 Oct 2017 12:52:31 +0000 Subject: [PATCH 047/102] Fix alt text on ENiGMA logo image --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3795dd30..0773f3fe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ENiGMA½ BBS Software -![alt text](docs/images/enigma-bbs.png "ENiGMA½ BBS") +![ENiGMA½ BBS](docs/images/enigma-bbs.png "ENiGMA½ BBS") ENiGMA½ is a modern BBS software with a nostalgic flair! From e55b4aa50b99627ebb9f373014f56df2615e36a3 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 29 Oct 2017 20:03:33 -0600 Subject: [PATCH 048/102] Return event in unknownOption() --- core/servers/login/telnet.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index 4377f029..06961800 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -88,6 +88,7 @@ const SB_COMMANDS = { // // Resources // * http://mars.netanya.ac.il/~unesco/cdrom/booklet/HTML/NETWORKING/node300.html +// * http://www.networksorcery.com/enp/protocol/telnet.htm // const OPTIONS = { TRANSMIT_BINARY : 0, // http://tools.ietf.org/html/rfc856 @@ -186,6 +187,8 @@ const OPTION_NAMES = Object.keys(OPTIONS).reduce(function(names, name) { function unknownOption(bufs, i, event) { Log.warn( { bufs : bufs, i : i, event : event }, 'Unknown Telnet option'); + event.buf = bufs.splice(0, i).toBuffer(); + return event; } const OPTION_IMPLS = {}; From 2efc522d680feaf95fba0327d0d9857b54f438c5 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 29 Oct 2017 20:03:49 -0600 Subject: [PATCH 049/102] Mask out passwordConfirm --- core/logger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/logger.js b/core/logger.js index 3b0b47e2..f90aec41 100644 --- a/core/logger.js +++ b/core/logger.js @@ -62,7 +62,7 @@ module.exports = class Log { // Use a regexp -- we don't know how nested fields we want to seek and destroy may be // return JSON.parse( - JSON.stringify(obj).replace(/"(password|key|authCode)"\s?:\s?"([^"]+)"/, (match, valueName) => { + JSON.stringify(obj).replace(/"(password|passwordConfirm|key|authCode)"\s?:\s?"([^"]+)"/, (match, valueName) => { return `"${valueName}":"********"`; }) ); From 4b0ef8543291df80712e7e0c7d66ebbc57fa1224 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 29 Oct 2017 20:04:10 -0600 Subject: [PATCH 050/102] Allow index.html in root --- core/servers/content/web.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 55fd37f2..92aa05c3 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -158,6 +158,11 @@ exports.getModule = class WebServerModule extends ServerModule { routeRequest(req, resp) { const route = _.find(this.routes, r => r.matchesRequest(req) ); + + if(!route && '/' === req.url) { + return this.routeIndex(req, resp); + } + return route ? route.handler(req, resp) : this.accessDenied(resp); } @@ -196,9 +201,20 @@ exports.getModule = class WebServerModule extends ServerModule { return this.respondWithError(resp, 404, 'File not found.', 'File Not Found'); } + routeIndex(req, resp) { + const filePath = paths.join(Config.contentServers.web.staticRoot, 'index.html'); + + return this.returnStaticPage(filePath, resp); + } + routeStaticFile(req, resp) { const fileName = req.url.substr(req.url.indexOf('/', 1)); const filePath = paths.join(Config.contentServers.web.staticRoot, fileName); + + return this.returnStaticPage(filePath, resp); + } + + returnStaticPage(filePath, resp) { const self = this; fs.stat(filePath, (err, stats) => { From a5f72a345ceeae3496206ea3aa3f467806fca363 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 29 Oct 2017 21:02:36 -0600 Subject: [PATCH 051/102] Fix Content-Type --- core/servers/content/web.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/servers/content/web.js b/core/servers/content/web.js index 92aa05c3..8a9903b4 100644 --- a/core/servers/content/web.js +++ b/core/servers/content/web.js @@ -223,7 +223,7 @@ exports.getModule = class WebServerModule extends ServerModule { } const headers = { - 'Content-Type' : mimeTypes.contentType(filePath) || mimeTypes.contentType('.bin'), + 'Content-Type' : mimeTypes.contentType(paths.basename(filePath)) || mimeTypes.contentType('.bin'), 'Content-Length' : stats.size, }; From d5059525102cffa1783e827c561fc24a1c520b41 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Thu, 2 Nov 2017 00:41:20 +0000 Subject: [PATCH 052/102] CombatNet support! --- README.md | 2 +- core/combatnet.js | 115 ++++++++++++++++++++++++++++++++++++++++++++++ docs/doors.md | 16 +++++++ mods/menu.hjson | 16 ++++++- package.json | 1 + 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 core/combatnet.js diff --git a/README.md b/README.md index 83fb50ea..93752343 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ENiGMA½ is a modern BBS software with a nostalgic flair! * Renegade style pipe color codes * [SQLite](http://sqlite.org/) storage of users, message areas, and so on * Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption - * [Door support](docs/doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), and [Exodus](https://oddnetwork.org/exodus/) support! + * [Door support](docs/doors.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), [Exodus](https://oddnetwork.org/exodus/) and [CombatNet](http://combatnet.us/) support! * [Bunyan](https://github.com/trentm/node-bunyan) logging * [Message networks](docs/msg_networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export * [Gazelle](https://github.com/WhatCD/Gazelle) inspirted File Bases including fast fully indexed full text search (FTS), #tags, and HTTP(S) temporary download URLs using a built in [web server](docs/web_server.md). Legacy X/Y/Z modem also supported! diff --git a/core/combatnet.js b/core/combatnet.js new file mode 100644 index 00000000..6cde9c7b --- /dev/null +++ b/core/combatnet.js @@ -0,0 +1,115 @@ +/* jslint node: true */ +'use strict'; + +// enigma-bbs +const MenuModule = require('../core/menu_module.js').MenuModule; +const resetScreen = require('../core/ansi_term.js').resetScreen; + +// deps +const async = require('async'); +const _ = require('lodash'); +const RLogin = require('rlogin'); + +exports.moduleInfo = { + name : 'CombatNet', + desc : 'CombatNet Access Module', + author : 'Dave Stephens', +}; + +exports.getModule = class CombatNetModule extends MenuModule { + constructor(options) { + super(options); + + // establish defaults + this.config = options.menuConfig.config; + this.config.host = this.config.host || 'bbs.combatnet.us'; + this.config.rloginPort = this.config.rloginPort || 4513; + } + + initSequence() { + const self = this; + + async.series( + [ + function validateConfig(callback) { + if(!_.isString(self.config.password)) { + return callback(new Error('Config requires "password"!')); + } + if(!_.isString(self.config.bbsTag)) { + return callback(new Error('Config requires "bbsTag"!')); + } + return callback(null); + }, + function establishRloginConnection(callback) { + self.client.term.write(resetScreen()); + self.client.term.write('Connecting to CombatNet, please wait...\n'); + + const restorePipeToNormal = function() { + self.client.term.output.removeListener('data', sendToRloginBuffer); + }; + + const rlogin = new RLogin( + { 'clientUsername' : self.config.password, + 'serverUsername' : `${self.config.bbsTag}${self.client.user.username}`, + 'host' : self.config.host, + 'port' : self.config.rloginPort, + 'terminalType' : self.client.term.termClient, + 'terminalSpeed' : 57600 + } + ); + + // If there was an error ... + rlogin.on('error', err => { + self.client.log.info(`CombatNet rlogin client error: ${err.message}`); + restorePipeToNormal(); + callback(err); + }); + + // If we've been disconnected ... + rlogin.on('disconnect', () => { + self.client.log.info(`Disconnected from CombatNet`); + restorePipeToNormal(); + callback(null); + }); + + function sendToRloginBuffer(buffer) { + rlogin.send(buffer); + }; + + rlogin.on("connect", + /* The 'connect' event handler will be supplied with one argument, + a boolean indicating whether or not the connection was established. */ + + function(state) { + if(state) { + self.client.log.info('Connected to CombatNet'); + self.client.term.output.on('data', sendToRloginBuffer); + + } else { + return callback(new Error('Failed to establish establish CombatNet connection')); + } + } + ); + + // If data (a Buffer) has been received from the server ... + rlogin.on("data", (data) => { + self.client.term.rawWrite(data); + }); + + // connect... + rlogin.connect(); + + // note: no explicit callback() until we're finished! + } + ], + err => { + if(err) { + self.client.log.warn( { error : err.message }, 'CombatNet error'); + } + + // if the client is still here, go to previous + self.prevMenu(); + } + ); + } +}; diff --git a/docs/doors.md b/docs/doors.md index 702d75d9..0ca55c23 100644 --- a/docs/doors.md +++ b/docs/doors.md @@ -194,6 +194,22 @@ doorParty: { Fill in `username`, `password`, and `bbsTag` with credentials provided to you and you should be in business! +## The CombatNet Module +The `combatnet` module provides native support for [CombatNet](http://combatnet.us/). Add the following to your menu config: + +````hjson +combatNet: { + desc: Using CombatNet + module: @systemModule:combatnet + config: { + bbsTag: CBNxxx + password: XXXXXXXXX + } +} +```` +Update `bbsTag` (in the format CBNxxx) and `password` with the details provided when you register, then +you should be ready to rock! + # Resources ### DOSBox diff --git a/mods/menu.hjson b/mods/menu.hjson index 0dffdcbb..b445ec85 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -1646,6 +1646,10 @@ action: @menu:doorParty } { + value: { command: "CN" } + action: @menu:combatNet + } + { value: { command: "AGENT" } action: @menu:telnetBridgeAgency } @@ -1689,7 +1693,7 @@ } } - // DoorParty! support. You'll need to registger to obtain credentials + // DoorParty! support. You'll need to register to obtain credentials doorParty: { desc: Using DoorParty! module: @systemModule:door_party @@ -1700,6 +1704,16 @@ } } + // CombatNet support. You'll need to register at http://combatnet.us/ to obtain credentials + combatNet: { + desc: Using CombatNet + module: @systemModule:combatnet + config: { + bbsTag: CBNxxx + password: XXXXXXXXX + } + } + telnetBridgeAgency: { desc: Connected to HappyLand BBS module: telnet_bridge diff --git a/package.json b/package.json index 232e1dca..29bd16da 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "node-glob": "^1.2.0", "nodemailer": "^4.1.0", "ptyw.js": "NuSkooler/ptyw.js", + "rlogin": "^1.0.0", "sane": "^2.2.0", "sanitize-filename": "^1.6.1", "sqlite3": "^3.1.9", From 6d31589c8b8871a96f9c1026e3a232a23175a9fa Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 12 Nov 2017 18:55:57 -0700 Subject: [PATCH 053/102] Add PCB/WildCat!, WWIV, Renegade, etc. color code support to file descriptions --- core/color_codes.js | 158 +++++++++++++++++++++++++++++++++++++++++ mods/file_area_list.js | 47 +++++++----- 2 files changed, 187 insertions(+), 18 deletions(-) diff --git a/core/color_codes.js b/core/color_codes.js index 4dc8da99..7e71bd1c 100644 --- a/core/color_codes.js +++ b/core/color_codes.js @@ -11,11 +11,13 @@ exports.enigmaToAnsi = enigmaToAnsi; exports.stripPipeCodes = exports.stripEnigmaCodes = stripEnigmaCodes; exports.pipeStrLen = exports.enigmaStrLen = enigmaStrLen; exports.pipeToAnsi = exports.renegadeToAnsi = renegadeToAnsi; +exports.controlCodesToAnsi = controlCodesToAnsi; // :TODO: Not really happy with the module name of "color_codes". Would like something better + // Also add: // * fromCelerity(): | // * fromPCBoard(): (@X) @@ -148,3 +150,159 @@ function renegadeToAnsi(s, client) { return (0 === result.length ? s : result + s.substr(lastIndex)); } + +// +// Converts various control codes popular in BBS packages +// to ANSI escape sequences. Additionaly supports ENiGMA style +// MCI codes. +// +// Supported control code formats: +// * Renegade : |## +// * PCBoard : @X## where the first number/char is FG color, and second is BG +// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix +// * WWIV : ^# +// +// TODO: Add Synchronet and Celerity format support +// +// Resources: +// * http://wiki.synchro.net/custom:colors +// +function controlCodesToAnsi(s, client) { + const RE = /(\|([A-Z0-9]{2})|\|)|(\@X([0-9A-F]{2}))|(\@([0-9A-F]{2})\@)|(\x03[0-9]|\x03)/g; // eslint-disable-line no-control-regex + + let m; + let result = ''; + let lastIndex = 0; + let v; + let fg; + let bg; + + while((m = RE.exec(s))) { + switch(m[0].charAt(0)) { + case '|' : + // Renegade or ENiGMA MCI + v = parseInt(m[2], 10); + + if(isNaN(v)) { + v = getPredefinedMCIValue(client, m[2]) || m[0]; // value itself or literal + } + + if(_.isString(v)) { + result += s.substr(lastIndex, m.index - lastIndex) + v; + } else { + v = ansi.sgr({ + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'bold', 'black' ], + 9 : [ 'bold', 'blue' ], + 10 : [ 'bold', 'green' ], + 11 : [ 'bold', 'cyan' ], + 12 : [ 'bold', 'red' ], + 13 : [ 'bold', 'magenta' ], + 14 : [ 'bold', 'yellow' ], + 15 : [ 'bold', 'white' ], + + 16 : [ 'blackBG' ], + 17 : [ 'blueBG' ], + 18 : [ 'greenBG' ], + 19 : [ 'cyanBG' ], + 20 : [ 'redBG' ], + 21 : [ 'magentaBG' ], + 22 : [ 'yellowBG' ], + 23 : [ 'whiteBG' ], + }[v] || 'normal'); + + result += s.substr(lastIndex, m.index - lastIndex) + v; + } + break; + + case '@' : + // PCBoard @X## or Wildcat! @##@ + if('@' === m[0].substr(-1)) { + // Wildcat! + v = m[6]; + } else { + v = m[4]; + } + + fg = { + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'blink', 'black' ], + 9 : [ 'blink', 'blue' ], + A : [ 'blink', 'green' ], + B : [ 'blink', 'cyan' ], + C : [ 'blink', 'red' ], + D : [ 'blink', 'magenta' ], + E : [ 'blink', 'yellow' ], + F : [ 'blink', 'white' ], + }[v.charAt(0)] || ['normal']; + + bg = { + 0 : [ 'blackBG' ], + 1 : [ 'blueBG' ], + 2 : [ 'greenBG' ], + 3 : [ 'cyanBG' ], + 4 : [ 'redBG' ], + 5 : [ 'magentaBG' ], + 6 : [ 'yellowBG' ], + 7 : [ 'whiteBG' ], + + 8 : [ 'bold', 'blackBG' ], + 9 : [ 'bold', 'blueBG' ], + A : [ 'bold', 'greenBG' ], + B : [ 'bold', 'cyanBG' ], + C : [ 'bold', 'redBG' ], + D : [ 'bold', 'magentaBG' ], + E : [ 'bold', 'yellowBG' ], + F : [ 'bold', 'whiteBG' ], + }[v.charAt(1)] || [ 'normal' ]; + + v = ansi.sgr(fg.concat(bg)); + result += s.substr(lastIndex, m.index - lastIndex) + v; + break; + + case '\x03' : + v = parseInt(m[8], 10); + + if(isNaN(v)) { + v += m[0]; + } else { + v = ansi.sgr({ + 0 : [ 'reset', 'black' ], + 1 : [ 'bold', 'cyan' ], + 2 : [ 'bold', 'yellow' ], + 3 : [ 'reset', 'magenta' ], + 4 : [ 'bold', 'white', 'blueBG' ], + 5 : [ 'reset', 'green' ], + 6 : [ 'bold', 'blink', 'red' ], + 7 : [ 'bold', 'blue' ], + 8 : [ 'reset', 'blue' ], + 9 : [ 'reset', 'cyan' ], + }[v] || 'normal'); + } + + result += s.substr(lastIndex, m.index - lastIndex) + v; + + break; + } + + lastIndex = RE.lastIndex; + } + + return (0 === result.length ? s : result + s.substr(lastIndex)); +} \ No newline at end of file diff --git a/mods/file_area_list.js b/mods/file_area_list.js index 4937eb49..0c24f9f8 100644 --- a/mods/file_area_list.js +++ b/mods/file_area_list.js @@ -2,22 +2,23 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const ansi = require('../core/ansi_term.js'); -const theme = require('../core/theme.js'); -const FileEntry = require('../core/file_entry.js'); -const stringFormat = require('../core/string_format.js'); -const FileArea = require('../core/file_base_area.js'); -const Errors = require('../core/enig_error.js').Errors; -const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled; -const ArchiveUtil = require('../core/archive_util.js'); -const Config = require('../core/config.js').config; -const DownloadQueue = require('../core/download_queue.js'); -const FileAreaWeb = require('../core/file_area_web.js'); -const FileBaseFilters = require('../core/file_base_filter.js'); -const resolveMimeType = require('../core/mime_util.js').resolveMimeType; -const isAnsi = require('../core/string_util.js').isAnsi; +const MenuModule = require('../core/menu_module.js').MenuModule; +const ViewController = require('../core/view_controller.js').ViewController; +const ansi = require('../core/ansi_term.js'); +const theme = require('../core/theme.js'); +const FileEntry = require('../core/file_entry.js'); +const stringFormat = require('../core/string_format.js'); +const FileArea = require('../core/file_base_area.js'); +const Errors = require('../core/enig_error.js').Errors; +const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled; +const ArchiveUtil = require('../core/archive_util.js'); +const Config = require('../core/config.js').config; +const DownloadQueue = require('../core/download_queue.js'); +const FileAreaWeb = require('../core/file_area_web.js'); +const FileBaseFilters = require('../core/file_base_filter.js'); +const resolveMimeType = require('../core/mime_util.js').resolveMimeType; +const isAnsi = require('../core/string_util.js').isAnsi; +const controlCodesToAnsi = require('../core/color_codes.js').controlCodesToAnsi; // deps const async = require('async'); @@ -385,9 +386,19 @@ exports.getModule = class FileAreaList extends MenuModule { if(_.isString(self.currentFileEntry.desc)) { const descView = self.viewControllers.browse.getView(MciViewIds.browse.desc); if(descView) { - if(isAnsi(self.currentFileEntry.desc)) { + // + // For descriptions we want to support as many color code systems + // as we can for coverage of what is found in the while (e.g. Renegade + // pipes, PCB @X##, etc.) + // + // MLTEV doesn't support all of this, so convert. If we produced ANSI + // esc sequences, we'll proceed with specialization, else just treat + // it as text. + // + const desc = controlCodesToAnsi(self.currentFileEntry.desc); + if(desc.length != self.currentFileEntry.desc.length || isAnsi(desc)) { descView.setAnsi( - self.currentFileEntry.desc, + desc, { prepped : false, forceLineTerm : true From f0b9cd102d2d20af1ee2610bead548688d91e34f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 18 Nov 2017 14:09:17 -0700 Subject: [PATCH 054/102] Fix some year est issues & add ability for oputil fb scan --update to pick up years --- core/config.js | 11 ++++++++--- core/oputil/oputil_file_base.js | 14 +++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/core/config.js b/core/config.js index e33648b9..367143b6 100644 --- a/core/config.js +++ b/core/config.js @@ -670,12 +670,17 @@ function getDefaultConfig() { // Patterns should produce the year in the first submatch. // The extracted year may be YY or YYYY // - '\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]|[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', // yyyy-mm-dd, m/d/yyyy, mm-dd-yyyy, etc. - "\\b('[1789][0-9])\\b", // eslint-disable-line quotes + '\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]\\b', // yyyy-mm-dd, yyyy/mm/dd, ... + '\\b[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[1-2][0-9][0-9]{2}))\\b', // mm/dd/yyyy, mm.dd.yyyy, ... + '\\b((?:[1789][0-9]))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]\\b', // yy-mm-dd, yy-mm-dd, ... + '\\b[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[1789][0-9]))\\b', // mm-dd-yy, mm/dd/yy, ... + //'\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]|[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', // yyyy-mm-dd, m/d/yyyy, mm-dd-yyyy, etc. + //"\\b('[1789][0-9])\\b", // eslint-disable-line quotes '\\b[0-3]?[0-9][\\-\\/\\.](?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)[\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', '\\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december),?\\s[0-9]+(?:st|nd|rd|th)?,?\\s((?:[0-9]{2})?[0-9]{2})\\b', // November 29th, 1997 - '\\(((?:19|20)[0-9]{2})\\)', // (19xx) or (20xx) -- do this before 19xx 20xx such that this has priority + '\\(((?:19|20)[0-9]{2})\\)', // (19xx) or (20xx) -- with parens -- do this before 19xx 20xx such that this has priority '\\b((?:19|20)[0-9]{2})\\b', // simple 19xx or 20xx with word boundaries + '\\b\'([17-9][0-9])\\b', // '95, '17, ... // :TODO: DD/MMM/YY, DD/MMMM/YY, DD/MMM/YYYY, etc. ], diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index 0c4c6065..835db5b9 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -172,8 +172,8 @@ function scanFileAreaForChanges(areaInfo, options, cb) { // // We'll update the entry if the following conditions are met: // * We have a single duplicate, and: - // * --update was passed or the existing entry's desc or - // longDesc are blank/empty + // * --update was passed or the existing entry's desc, + // longDesc, or yearEst are blank/empty // if(argv.update && 1 === dupeEntries.length) { const FileEntry = require('../../core/file_entry.js'); @@ -193,15 +193,19 @@ function scanFileAreaForChanges(areaInfo, options, cb) { if( tagsEq && fileEntry.desc === existingEntry.desc && - fileEntry.descLong == existingEntry.descLong) + fileEntry.descLong == existingEntry.descLong && + fileEntry.meta.est_release_year == existingEntry.meta.est_release_year) { console.info('Dupe'); return nextFile(null); } console.info('Dupe (updating)'); - existingEntry.desc = fileEntry.desc; - existingEntry.descLong = fileEntry.descLong; + + // don't allow overwrite of values if new version is blank + existingEntry.desc = fileEntry.desc || existingEntry.desc; + existingEntry.descLong = fileEntry.descLong || existingEntry.descLong; + existingEntry.meta.est_release_year = fileEntry.meta.est_release_year || existingEntry.meta.est_release_year; updateTags(existingEntry); finalizeEntryAndPersist(true, existingEntry, descHandler, err => { From 02cd8c26c72c72f69f1e94954eeb205df6b38929 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 18 Nov 2017 14:14:19 -0700 Subject: [PATCH 055/102] Minor fix --- core/oputil/oputil_file_base.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index 835db5b9..a00f0889 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -173,7 +173,7 @@ function scanFileAreaForChanges(areaInfo, options, cb) { // We'll update the entry if the following conditions are met: // * We have a single duplicate, and: // * --update was passed or the existing entry's desc, - // longDesc, or yearEst are blank/empty + // longDesc, or est_release_year meta are blank/empty // if(argv.update && 1 === dupeEntries.length) { const FileEntry = require('../../core/file_entry.js'); @@ -205,7 +205,11 @@ function scanFileAreaForChanges(areaInfo, options, cb) { // don't allow overwrite of values if new version is blank existingEntry.desc = fileEntry.desc || existingEntry.desc; existingEntry.descLong = fileEntry.descLong || existingEntry.descLong; - existingEntry.meta.est_release_year = fileEntry.meta.est_release_year || existingEntry.meta.est_release_year; + + if(fileEntry.meta.est_release_year) { + existingEntry.meta.est_release_year = fileEntry.meta.est_release_year; + } + updateTags(existingEntry); finalizeEntryAndPersist(true, existingEntry, descHandler, err => { From 617f0ef07ecadbd47b4c3b71f2526132a369774a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 18 Nov 2017 16:15:50 -0700 Subject: [PATCH 056/102] Add extended pipe color codes (24-31) ala Mystic et. al. --- core/color_codes.js | 100 +++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 58 deletions(-) diff --git a/core/color_codes.js b/core/color_codes.js index 7e71bd1c..2e368aa3 100644 --- a/core/color_codes.js +++ b/core/color_codes.js @@ -87,6 +87,46 @@ function enigmaStrLen(s) { return stripEnigmaCodes(s).length; } +function ansiSgrFromRenegadeColorCode(cc) { + return ansi.sgr({ + 0 : [ 'reset', 'black' ], + 1 : [ 'reset', 'blue' ], + 2 : [ 'reset', 'green' ], + 3 : [ 'reset', 'cyan' ], + 4 : [ 'reset', 'red' ], + 5 : [ 'reset', 'magenta' ], + 6 : [ 'reset', 'yellow' ], + 7 : [ 'reset', 'white' ], + + 8 : [ 'bold', 'black' ], + 9 : [ 'bold', 'blue' ], + 10 : [ 'bold', 'green' ], + 11 : [ 'bold', 'cyan' ], + 12 : [ 'bold', 'red' ], + 13 : [ 'bold', 'magenta' ], + 14 : [ 'bold', 'yellow' ], + 15 : [ 'bold', 'white' ], + + 16 : [ 'blackBG' ], + 17 : [ 'blueBG' ], + 18 : [ 'greenBG' ], + 19 : [ 'cyanBG' ], + 20 : [ 'redBG' ], + 21 : [ 'magentaBG' ], + 22 : [ 'yellowBG' ], + 23 : [ 'whiteBG' ], + + 24 : [ 'bold', 'blackBG' ], + 25 : [ 'bold', 'blueBG' ], + 26 : [ 'bold', 'greenBG' ], + 27 : [ 'bold', 'cyanBG' ], + 28 : [ 'bold', 'redBG' ], + 29 : [ 'bold', 'magentaBG' ], + 30 : [ 'bold', 'yellowBG' ], + 31 : [ 'bold', 'whiteBG' ], + }[cc] || 'normal'); +} + function renegadeToAnsi(s, client) { if(-1 == s.indexOf('|')) { return s; // no pipe codes present @@ -113,35 +153,7 @@ function renegadeToAnsi(s, client) { if(_.isString(val)) { result += s.substr(lastIndex, m.index - lastIndex) + val; } else { - var attr = ansi.sgr({ - 0 : [ 'reset', 'black' ], - 1 : [ 'reset', 'blue' ], - 2 : [ 'reset', 'green' ], - 3 : [ 'reset', 'cyan' ], - 4 : [ 'reset', 'red' ], - 5 : [ 'reset', 'magenta' ], - 6 : [ 'reset', 'yellow' ], - 7 : [ 'reset', 'white' ], - - 8 : [ 'bold', 'black' ], - 9 : [ 'bold', 'blue' ], - 10 : [ 'bold', 'green' ], - 11 : [ 'bold', 'cyan' ], - 12 : [ 'bold', 'red' ], - 13 : [ 'bold', 'magenta' ], - 14 : [ 'bold', 'yellow' ], - 15 : [ 'bold', 'white' ], - - 16 : [ 'blackBG' ], - 17 : [ 'blueBG' ], - 18 : [ 'greenBG' ], - 19 : [ 'cyanBG' ], - 20 : [ 'redBG' ], - 21 : [ 'magentaBG' ], - 22 : [ 'yellowBG' ], - 23 : [ 'whiteBG' ], - }[val] || 'normal'); - + const attr = ansiSgrFromRenegadeColorCode(val); result += s.substr(lastIndex, m.index - lastIndex) + attr; } @@ -190,35 +202,7 @@ function controlCodesToAnsi(s, client) { if(_.isString(v)) { result += s.substr(lastIndex, m.index - lastIndex) + v; } else { - v = ansi.sgr({ - 0 : [ 'reset', 'black' ], - 1 : [ 'reset', 'blue' ], - 2 : [ 'reset', 'green' ], - 3 : [ 'reset', 'cyan' ], - 4 : [ 'reset', 'red' ], - 5 : [ 'reset', 'magenta' ], - 6 : [ 'reset', 'yellow' ], - 7 : [ 'reset', 'white' ], - - 8 : [ 'bold', 'black' ], - 9 : [ 'bold', 'blue' ], - 10 : [ 'bold', 'green' ], - 11 : [ 'bold', 'cyan' ], - 12 : [ 'bold', 'red' ], - 13 : [ 'bold', 'magenta' ], - 14 : [ 'bold', 'yellow' ], - 15 : [ 'bold', 'white' ], - - 16 : [ 'blackBG' ], - 17 : [ 'blueBG' ], - 18 : [ 'greenBG' ], - 19 : [ 'cyanBG' ], - 20 : [ 'redBG' ], - 21 : [ 'magentaBG' ], - 22 : [ 'yellowBG' ], - 23 : [ 'whiteBG' ], - }[v] || 'normal'); - + v = ansiSgrFromRenegadeColorCode(v); result += s.substr(lastIndex, m.index - lastIndex) + v; } break; From 57d46dd57ea3f5d5675eac85f548b8358b811328 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Wed, 22 Nov 2017 23:27:33 +0000 Subject: [PATCH 057/102] Mega rejig! --- {mods/art => art/general}/CONNECT1.ANS | Bin {mods/art => art/general}/DOORMANY.ANS | Bin {mods/art => art/general}/GNSPMPT.ANS | Bin {mods/art => art/general}/LOGPMPT.ANS | Bin {mods/art => art/general}/NEWSCAN.ANS | Bin {mods/art => art/general}/NEWUSER1.ANS | Bin {mods/art => art/general}/ONEADD.ANS | Bin {mods/art => art/general}/ONELINER.ANS | Bin {mods/art => art/general}/PRELOGAD.ANS | Bin {mods/art => art/general}/WELCOME1.ANS | 0 {mods/art => art/general}/WELCOME2.ANS | Bin .../general}/demo_edit_text_view.ans | Bin .../general}/demo_edit_text_view1.ans | Bin .../general}/demo_fse_local_user.ans | Bin .../general}/demo_fse_netmail_body.ans | Bin .../general}/demo_fse_netmail_footer_edit.ans | Bin .../demo_fse_netmail_footer_edit_menu.ans | Bin .../general}/demo_fse_netmail_header.ans | Bin .../general}/demo_fse_netmail_help.ans | Bin .../general}/demo_horizontal_menu_view1.ans | Bin .../general}/demo_mask_edit_text_view1.ans | Bin .../demo_multi_line_edit_text_view1.ans | Bin .../art => art/general}/demo_selection_vm.ans | Bin .../general}/demo_spin_and_toggle.ans | Bin .../general}/demo_vertical_menu_view1.ans | Bin {mods/art => art/general}/erc.ans | Bin {mods/art => art/general}/menu_prompt.ans | Bin .../general}/msg_area_footer_view.ans | Bin {mods/art => art/general}/msg_area_list.ans | Bin .../general}/msg_area_post_header.ans | Bin .../general}/msg_area_view_header.ans | Bin {mods/art => art/general}/test.ans | 0 .../themes/luciano_blocktronics/BBSADD.ANS | Bin .../themes/luciano_blocktronics/BBSLIST.ANS | Bin .../themes/luciano_blocktronics/CCHANGE.ANS | Bin .../themes/luciano_blocktronics/CHANGE.ANS | Bin .../themes/luciano_blocktronics/CONFSCR.ANS | Bin .../themes/luciano_blocktronics/DONE.ANS | Bin .../themes/luciano_blocktronics/DOORMNU.ANS | Bin .../themes/luciano_blocktronics/FAREASEL.ANS | Bin .../themes/luciano_blocktronics/FBHELP.ANS | Bin .../themes/luciano_blocktronics/FBNORES.ANS | Bin .../themes/luciano_blocktronics/FBRWSE.ANS | Bin .../themes/luciano_blocktronics/FDETAIL.ANS | Bin .../themes/luciano_blocktronics/FDETGEN.ANS | Bin .../themes/luciano_blocktronics/FDETLST.ANS | Bin .../themes/luciano_blocktronics/FDETNFO.ANS | Bin .../themes/luciano_blocktronics/FDLMGR.ANS | Bin .../themes/luciano_blocktronics/FEMPTYQ.ANS | Bin .../themes/luciano_blocktronics/FFILEDT.ANS | Bin .../themes/luciano_blocktronics/FILPMPT.ANS | Bin .../themes/luciano_blocktronics/FMENU.ANS | Bin .../themes/luciano_blocktronics/FNEWBRWSE.ANS | Bin .../themes/luciano_blocktronics/FORGOTPW.ANS | Bin .../luciano_blocktronics/FORGOTPWSENT.ANS | Bin .../themes/luciano_blocktronics/FPROSEL.ANS | Bin .../themes/luciano_blocktronics/FSEARCH.ANS | Bin .../themes/luciano_blocktronics/IDLELOG.ANS | Bin .../themes/luciano_blocktronics/LASTCALL.ANS | Bin .../themes/luciano_blocktronics/LETTER.ANS | Bin .../themes/luciano_blocktronics/MAILMNU.ANS | Bin .../themes/luciano_blocktronics/MATRIX.ANS | Bin .../themes/luciano_blocktronics/MMENU.ANS | Bin .../themes/luciano_blocktronics/MNUPRMT.ANS | Bin .../themes/luciano_blocktronics/MSGBODY.ANS | Bin .../themes/luciano_blocktronics/MSGEFTR.ANS | Bin .../themes/luciano_blocktronics/MSGEHDR.ANS | Bin .../themes/luciano_blocktronics/MSGEHLP.ANS | Bin .../themes/luciano_blocktronics/MSGEMFT.ANS | Bin .../themes/luciano_blocktronics/MSGLIST.ANS | Bin .../themes/luciano_blocktronics/MSGMNU.ANS | Bin .../themes/luciano_blocktronics/MSGPMPT.ANS | Bin .../themes/luciano_blocktronics/MSGQUOT.ANS | Bin .../themes/luciano_blocktronics/MSGVFTR.ANS | Bin .../themes/luciano_blocktronics/MSGVHDR.ANS | Bin .../themes/luciano_blocktronics/MSGVHLP.ANS | Bin .../themes/luciano_blocktronics/NEWMSGS.ANS | Bin .../themes/luciano_blocktronics/NUA.ANS | Bin .../themes/luciano_blocktronics/ONEADD.ANS | Bin .../themes/luciano_blocktronics/ONELINER.ANS | Bin .../themes/luciano_blocktronics/PAUSE.ANS | Bin .../themes/luciano_blocktronics/RATEFILE.ANS | Bin .../themes/luciano_blocktronics/RUMORADD.ANS | Bin .../themes/luciano_blocktronics/RUMORS.ANS | Bin .../themes/luciano_blocktronics/STATUS.ANS | Bin .../themes/luciano_blocktronics/SYSSTAT.ANS | Bin .../themes/luciano_blocktronics/TBRIDGE.ANS | Bin .../themes/luciano_blocktronics/TOONODE.ANS | Bin .../themes/luciano_blocktronics/ULCHECK.ANS | Bin .../themes/luciano_blocktronics/ULDETAIL.ANS | Bin .../themes/luciano_blocktronics/ULDUPES.ANS | Bin .../themes/luciano_blocktronics/ULNOAREA.ANS | Bin .../themes/luciano_blocktronics/ULOPTS.ANS | Bin .../themes/luciano_blocktronics/USERLOG.ANS | Bin .../themes/luciano_blocktronics/USERLST.ANS | Bin .../themes/luciano_blocktronics/WHOSON.ANS | Bin .../themes/luciano_blocktronics/theme.hjson | 0 {mods => config}/menu.hjson | 0 {mods => config}/prompt.hjson | 0 core/config.js | 15 ++++---- core/config_util.js | 9 +++-- core/module_util.js | 1 + core/oputil/oputil_common.js | 2 +- mods/{ => system}/abracadabra.js | 10 +++--- mods/{ => system}/bbs_link.js | 6 ++-- mods/{ => system}/bbs_list.js | 14 ++++---- mods/{ => system}/erc_client.js | 4 +-- mods/{ => system}/file_area_filter_edit.js | 10 +++--- mods/{ => system}/file_area_list.js | 34 +++++++++--------- mods/{ => system}/file_base_area_select.js | 8 ++--- .../file_base_download_manager.js | 16 ++++----- mods/{ => system}/file_base_search.js | 8 ++--- .../file_base_web_download_manager.js | 20 +++++------ .../file_transfer_protocol_select.js | 8 ++--- mods/{ => system}/last_callers.js | 10 +++--- mods/{ => system}/msg_area_list.js | 12 +++---- mods/{ => system}/msg_area_post_fse.js | 4 +-- mods/{ => system}/msg_area_reply_fse.js | 2 +- mods/{ => system}/msg_area_view_fse.js | 4 +-- mods/{ => system}/msg_conf_list.js | 12 +++---- mods/{ => system}/msg_list.js | 10 +++--- mods/{ => system}/nua.js | 12 +++---- mods/{ => system}/onelinerz.js | 12 +++---- mods/{ => system}/rumorz.js | 14 ++++---- mods/{ => system}/telnet_bridge.js | 6 ++-- mods/{ => system}/upload.js | 28 +++++++-------- mods/{ => system}/user_list.js | 8 ++--- mods/{ => system}/whos_online.js | 8 ++--- mods/user/.keep | 0 129 files changed, 153 insertions(+), 154 deletions(-) rename {mods/art => art/general}/CONNECT1.ANS (100%) rename {mods/art => art/general}/DOORMANY.ANS (100%) rename {mods/art => art/general}/GNSPMPT.ANS (100%) rename {mods/art => art/general}/LOGPMPT.ANS (100%) rename {mods/art => art/general}/NEWSCAN.ANS (100%) rename {mods/art => art/general}/NEWUSER1.ANS (100%) rename {mods/art => art/general}/ONEADD.ANS (100%) rename {mods/art => art/general}/ONELINER.ANS (100%) rename {mods/art => art/general}/PRELOGAD.ANS (100%) rename {mods/art => art/general}/WELCOME1.ANS (100%) rename {mods/art => art/general}/WELCOME2.ANS (100%) rename {mods/art => art/general}/demo_edit_text_view.ans (100%) rename {mods/art => art/general}/demo_edit_text_view1.ans (100%) rename {mods/art => art/general}/demo_fse_local_user.ans (100%) rename {mods/art => art/general}/demo_fse_netmail_body.ans (100%) rename {mods/art => art/general}/demo_fse_netmail_footer_edit.ans (100%) rename {mods/art => art/general}/demo_fse_netmail_footer_edit_menu.ans (100%) rename {mods/art => art/general}/demo_fse_netmail_header.ans (100%) rename {mods/art => art/general}/demo_fse_netmail_help.ans (100%) rename {mods/art => art/general}/demo_horizontal_menu_view1.ans (100%) rename {mods/art => art/general}/demo_mask_edit_text_view1.ans (100%) rename {mods/art => art/general}/demo_multi_line_edit_text_view1.ans (100%) rename {mods/art => art/general}/demo_selection_vm.ans (100%) rename {mods/art => art/general}/demo_spin_and_toggle.ans (100%) rename {mods/art => art/general}/demo_vertical_menu_view1.ans (100%) rename {mods/art => art/general}/erc.ans (100%) rename {mods/art => art/general}/menu_prompt.ans (100%) rename {mods/art => art/general}/msg_area_footer_view.ans (100%) rename {mods/art => art/general}/msg_area_list.ans (100%) rename {mods/art => art/general}/msg_area_post_header.ans (100%) rename {mods/art => art/general}/msg_area_view_header.ans (100%) rename {mods/art => art/general}/test.ans (100%) rename {mods => art}/themes/luciano_blocktronics/BBSADD.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/BBSLIST.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/CCHANGE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/CHANGE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/CONFSCR.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/DONE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/DOORMNU.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FAREASEL.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FBHELP.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FBNORES.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FBRWSE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FDETAIL.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FDETGEN.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FDETLST.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FDETNFO.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FDLMGR.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FEMPTYQ.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FFILEDT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FILPMPT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FMENU.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FNEWBRWSE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FORGOTPW.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FORGOTPWSENT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FPROSEL.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/FSEARCH.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/IDLELOG.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/LASTCALL.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/LETTER.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MAILMNU.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MATRIX.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MMENU.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MNUPRMT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGBODY.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGEFTR.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGEHDR.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGEHLP.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGEMFT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGLIST.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGMNU.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGPMPT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGQUOT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGVFTR.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGVHDR.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/MSGVHLP.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/NEWMSGS.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/NUA.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/ONEADD.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/ONELINER.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/PAUSE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/RATEFILE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/RUMORADD.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/RUMORS.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/STATUS.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/SYSSTAT.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/TBRIDGE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/TOONODE.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/ULCHECK.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/ULDETAIL.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/ULDUPES.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/ULNOAREA.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/ULOPTS.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/USERLOG.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/USERLST.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/WHOSON.ANS (100%) rename {mods => art}/themes/luciano_blocktronics/theme.hjson (100%) rename {mods => config}/menu.hjson (100%) rename {mods => config}/prompt.hjson (100%) rename mods/{ => system}/abracadabra.js (94%) rename mods/{ => system}/bbs_link.js (96%) rename mods/{ => system}/bbs_list.js (96%) rename mods/{ => system}/erc_client.js (97%) rename mods/{ => system}/file_area_filter_edit.js (95%) rename mods/{ => system}/file_area_list.js (94%) rename mods/{ => system}/file_base_area_select.js (88%) rename mods/{ => system}/file_base_download_manager.js (92%) rename mods/{ => system}/file_base_search.js (89%) rename mods/{ => system}/file_base_web_download_manager.js (91%) rename mods/{ => system}/file_transfer_protocol_select.js (93%) rename mods/{ => system}/last_callers.js (92%) rename mods/{ => system}/msg_area_list.js (91%) rename mods/{ => system}/msg_area_post_fse.js (90%) rename mods/{ => system}/msg_area_reply_fse.js (81%) rename mods/{ => system}/msg_area_view_fse.js (95%) rename mods/{ => system}/msg_conf_list.js (89%) rename mods/{ => system}/msg_list.js (95%) rename mods/{ => system}/nua.js (91%) rename mods/{ => system}/onelinerz.js (95%) rename mods/{ => system}/rumorz.js (92%) rename mods/{ => system}/telnet_bridge.js (94%) rename mods/{ => system}/upload.js (94%) rename mods/{ => system}/user_list.js (91%) rename mods/{ => system}/whos_online.js (87%) create mode 100644 mods/user/.keep diff --git a/mods/art/CONNECT1.ANS b/art/general/CONNECT1.ANS similarity index 100% rename from mods/art/CONNECT1.ANS rename to art/general/CONNECT1.ANS diff --git a/mods/art/DOORMANY.ANS b/art/general/DOORMANY.ANS similarity index 100% rename from mods/art/DOORMANY.ANS rename to art/general/DOORMANY.ANS diff --git a/mods/art/GNSPMPT.ANS b/art/general/GNSPMPT.ANS similarity index 100% rename from mods/art/GNSPMPT.ANS rename to art/general/GNSPMPT.ANS diff --git a/mods/art/LOGPMPT.ANS b/art/general/LOGPMPT.ANS similarity index 100% rename from mods/art/LOGPMPT.ANS rename to art/general/LOGPMPT.ANS diff --git a/mods/art/NEWSCAN.ANS b/art/general/NEWSCAN.ANS similarity index 100% rename from mods/art/NEWSCAN.ANS rename to art/general/NEWSCAN.ANS diff --git a/mods/art/NEWUSER1.ANS b/art/general/NEWUSER1.ANS similarity index 100% rename from mods/art/NEWUSER1.ANS rename to art/general/NEWUSER1.ANS diff --git a/mods/art/ONEADD.ANS b/art/general/ONEADD.ANS similarity index 100% rename from mods/art/ONEADD.ANS rename to art/general/ONEADD.ANS diff --git a/mods/art/ONELINER.ANS b/art/general/ONELINER.ANS similarity index 100% rename from mods/art/ONELINER.ANS rename to art/general/ONELINER.ANS diff --git a/mods/art/PRELOGAD.ANS b/art/general/PRELOGAD.ANS similarity index 100% rename from mods/art/PRELOGAD.ANS rename to art/general/PRELOGAD.ANS diff --git a/mods/art/WELCOME1.ANS b/art/general/WELCOME1.ANS similarity index 100% rename from mods/art/WELCOME1.ANS rename to art/general/WELCOME1.ANS diff --git a/mods/art/WELCOME2.ANS b/art/general/WELCOME2.ANS similarity index 100% rename from mods/art/WELCOME2.ANS rename to art/general/WELCOME2.ANS diff --git a/mods/art/demo_edit_text_view.ans b/art/general/demo_edit_text_view.ans similarity index 100% rename from mods/art/demo_edit_text_view.ans rename to art/general/demo_edit_text_view.ans diff --git a/mods/art/demo_edit_text_view1.ans b/art/general/demo_edit_text_view1.ans similarity index 100% rename from mods/art/demo_edit_text_view1.ans rename to art/general/demo_edit_text_view1.ans diff --git a/mods/art/demo_fse_local_user.ans b/art/general/demo_fse_local_user.ans similarity index 100% rename from mods/art/demo_fse_local_user.ans rename to art/general/demo_fse_local_user.ans diff --git a/mods/art/demo_fse_netmail_body.ans b/art/general/demo_fse_netmail_body.ans similarity index 100% rename from mods/art/demo_fse_netmail_body.ans rename to art/general/demo_fse_netmail_body.ans diff --git a/mods/art/demo_fse_netmail_footer_edit.ans b/art/general/demo_fse_netmail_footer_edit.ans similarity index 100% rename from mods/art/demo_fse_netmail_footer_edit.ans rename to art/general/demo_fse_netmail_footer_edit.ans diff --git a/mods/art/demo_fse_netmail_footer_edit_menu.ans b/art/general/demo_fse_netmail_footer_edit_menu.ans similarity index 100% rename from mods/art/demo_fse_netmail_footer_edit_menu.ans rename to art/general/demo_fse_netmail_footer_edit_menu.ans diff --git a/mods/art/demo_fse_netmail_header.ans b/art/general/demo_fse_netmail_header.ans similarity index 100% rename from mods/art/demo_fse_netmail_header.ans rename to art/general/demo_fse_netmail_header.ans diff --git a/mods/art/demo_fse_netmail_help.ans b/art/general/demo_fse_netmail_help.ans similarity index 100% rename from mods/art/demo_fse_netmail_help.ans rename to art/general/demo_fse_netmail_help.ans diff --git a/mods/art/demo_horizontal_menu_view1.ans b/art/general/demo_horizontal_menu_view1.ans similarity index 100% rename from mods/art/demo_horizontal_menu_view1.ans rename to art/general/demo_horizontal_menu_view1.ans diff --git a/mods/art/demo_mask_edit_text_view1.ans b/art/general/demo_mask_edit_text_view1.ans similarity index 100% rename from mods/art/demo_mask_edit_text_view1.ans rename to art/general/demo_mask_edit_text_view1.ans diff --git a/mods/art/demo_multi_line_edit_text_view1.ans b/art/general/demo_multi_line_edit_text_view1.ans similarity index 100% rename from mods/art/demo_multi_line_edit_text_view1.ans rename to art/general/demo_multi_line_edit_text_view1.ans diff --git a/mods/art/demo_selection_vm.ans b/art/general/demo_selection_vm.ans similarity index 100% rename from mods/art/demo_selection_vm.ans rename to art/general/demo_selection_vm.ans diff --git a/mods/art/demo_spin_and_toggle.ans b/art/general/demo_spin_and_toggle.ans similarity index 100% rename from mods/art/demo_spin_and_toggle.ans rename to art/general/demo_spin_and_toggle.ans diff --git a/mods/art/demo_vertical_menu_view1.ans b/art/general/demo_vertical_menu_view1.ans similarity index 100% rename from mods/art/demo_vertical_menu_view1.ans rename to art/general/demo_vertical_menu_view1.ans diff --git a/mods/art/erc.ans b/art/general/erc.ans similarity index 100% rename from mods/art/erc.ans rename to art/general/erc.ans diff --git a/mods/art/menu_prompt.ans b/art/general/menu_prompt.ans similarity index 100% rename from mods/art/menu_prompt.ans rename to art/general/menu_prompt.ans diff --git a/mods/art/msg_area_footer_view.ans b/art/general/msg_area_footer_view.ans similarity index 100% rename from mods/art/msg_area_footer_view.ans rename to art/general/msg_area_footer_view.ans diff --git a/mods/art/msg_area_list.ans b/art/general/msg_area_list.ans similarity index 100% rename from mods/art/msg_area_list.ans rename to art/general/msg_area_list.ans diff --git a/mods/art/msg_area_post_header.ans b/art/general/msg_area_post_header.ans similarity index 100% rename from mods/art/msg_area_post_header.ans rename to art/general/msg_area_post_header.ans diff --git a/mods/art/msg_area_view_header.ans b/art/general/msg_area_view_header.ans similarity index 100% rename from mods/art/msg_area_view_header.ans rename to art/general/msg_area_view_header.ans diff --git a/mods/art/test.ans b/art/general/test.ans similarity index 100% rename from mods/art/test.ans rename to art/general/test.ans diff --git a/mods/themes/luciano_blocktronics/BBSADD.ANS b/art/themes/luciano_blocktronics/BBSADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/BBSADD.ANS rename to art/themes/luciano_blocktronics/BBSADD.ANS diff --git a/mods/themes/luciano_blocktronics/BBSLIST.ANS b/art/themes/luciano_blocktronics/BBSLIST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/BBSLIST.ANS rename to art/themes/luciano_blocktronics/BBSLIST.ANS diff --git a/mods/themes/luciano_blocktronics/CCHANGE.ANS b/art/themes/luciano_blocktronics/CCHANGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/CCHANGE.ANS rename to art/themes/luciano_blocktronics/CCHANGE.ANS diff --git a/mods/themes/luciano_blocktronics/CHANGE.ANS b/art/themes/luciano_blocktronics/CHANGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/CHANGE.ANS rename to art/themes/luciano_blocktronics/CHANGE.ANS diff --git a/mods/themes/luciano_blocktronics/CONFSCR.ANS b/art/themes/luciano_blocktronics/CONFSCR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/CONFSCR.ANS rename to art/themes/luciano_blocktronics/CONFSCR.ANS diff --git a/mods/themes/luciano_blocktronics/DONE.ANS b/art/themes/luciano_blocktronics/DONE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/DONE.ANS rename to art/themes/luciano_blocktronics/DONE.ANS diff --git a/mods/themes/luciano_blocktronics/DOORMNU.ANS b/art/themes/luciano_blocktronics/DOORMNU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/DOORMNU.ANS rename to art/themes/luciano_blocktronics/DOORMNU.ANS diff --git a/mods/themes/luciano_blocktronics/FAREASEL.ANS b/art/themes/luciano_blocktronics/FAREASEL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FAREASEL.ANS rename to art/themes/luciano_blocktronics/FAREASEL.ANS diff --git a/mods/themes/luciano_blocktronics/FBHELP.ANS b/art/themes/luciano_blocktronics/FBHELP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBHELP.ANS rename to art/themes/luciano_blocktronics/FBHELP.ANS diff --git a/mods/themes/luciano_blocktronics/FBNORES.ANS b/art/themes/luciano_blocktronics/FBNORES.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBNORES.ANS rename to art/themes/luciano_blocktronics/FBNORES.ANS diff --git a/mods/themes/luciano_blocktronics/FBRWSE.ANS b/art/themes/luciano_blocktronics/FBRWSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FBRWSE.ANS rename to art/themes/luciano_blocktronics/FBRWSE.ANS diff --git a/mods/themes/luciano_blocktronics/FDETAIL.ANS b/art/themes/luciano_blocktronics/FDETAIL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETAIL.ANS rename to art/themes/luciano_blocktronics/FDETAIL.ANS diff --git a/mods/themes/luciano_blocktronics/FDETGEN.ANS b/art/themes/luciano_blocktronics/FDETGEN.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETGEN.ANS rename to art/themes/luciano_blocktronics/FDETGEN.ANS diff --git a/mods/themes/luciano_blocktronics/FDETLST.ANS b/art/themes/luciano_blocktronics/FDETLST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETLST.ANS rename to art/themes/luciano_blocktronics/FDETLST.ANS diff --git a/mods/themes/luciano_blocktronics/FDETNFO.ANS b/art/themes/luciano_blocktronics/FDETNFO.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDETNFO.ANS rename to art/themes/luciano_blocktronics/FDETNFO.ANS diff --git a/mods/themes/luciano_blocktronics/FDLMGR.ANS b/art/themes/luciano_blocktronics/FDLMGR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FDLMGR.ANS rename to art/themes/luciano_blocktronics/FDLMGR.ANS diff --git a/mods/themes/luciano_blocktronics/FEMPTYQ.ANS b/art/themes/luciano_blocktronics/FEMPTYQ.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FEMPTYQ.ANS rename to art/themes/luciano_blocktronics/FEMPTYQ.ANS diff --git a/mods/themes/luciano_blocktronics/FFILEDT.ANS b/art/themes/luciano_blocktronics/FFILEDT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FFILEDT.ANS rename to art/themes/luciano_blocktronics/FFILEDT.ANS diff --git a/mods/themes/luciano_blocktronics/FILPMPT.ANS b/art/themes/luciano_blocktronics/FILPMPT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FILPMPT.ANS rename to art/themes/luciano_blocktronics/FILPMPT.ANS diff --git a/mods/themes/luciano_blocktronics/FMENU.ANS b/art/themes/luciano_blocktronics/FMENU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FMENU.ANS rename to art/themes/luciano_blocktronics/FMENU.ANS diff --git a/mods/themes/luciano_blocktronics/FNEWBRWSE.ANS b/art/themes/luciano_blocktronics/FNEWBRWSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FNEWBRWSE.ANS rename to art/themes/luciano_blocktronics/FNEWBRWSE.ANS diff --git a/mods/themes/luciano_blocktronics/FORGOTPW.ANS b/art/themes/luciano_blocktronics/FORGOTPW.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FORGOTPW.ANS rename to art/themes/luciano_blocktronics/FORGOTPW.ANS diff --git a/mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS b/art/themes/luciano_blocktronics/FORGOTPWSENT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FORGOTPWSENT.ANS rename to art/themes/luciano_blocktronics/FORGOTPWSENT.ANS diff --git a/mods/themes/luciano_blocktronics/FPROSEL.ANS b/art/themes/luciano_blocktronics/FPROSEL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FPROSEL.ANS rename to art/themes/luciano_blocktronics/FPROSEL.ANS diff --git a/mods/themes/luciano_blocktronics/FSEARCH.ANS b/art/themes/luciano_blocktronics/FSEARCH.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/FSEARCH.ANS rename to art/themes/luciano_blocktronics/FSEARCH.ANS diff --git a/mods/themes/luciano_blocktronics/IDLELOG.ANS b/art/themes/luciano_blocktronics/IDLELOG.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/IDLELOG.ANS rename to art/themes/luciano_blocktronics/IDLELOG.ANS diff --git a/mods/themes/luciano_blocktronics/LASTCALL.ANS b/art/themes/luciano_blocktronics/LASTCALL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/LASTCALL.ANS rename to art/themes/luciano_blocktronics/LASTCALL.ANS diff --git a/mods/themes/luciano_blocktronics/LETTER.ANS b/art/themes/luciano_blocktronics/LETTER.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/LETTER.ANS rename to art/themes/luciano_blocktronics/LETTER.ANS diff --git a/mods/themes/luciano_blocktronics/MAILMNU.ANS b/art/themes/luciano_blocktronics/MAILMNU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MAILMNU.ANS rename to art/themes/luciano_blocktronics/MAILMNU.ANS diff --git a/mods/themes/luciano_blocktronics/MATRIX.ANS b/art/themes/luciano_blocktronics/MATRIX.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MATRIX.ANS rename to art/themes/luciano_blocktronics/MATRIX.ANS diff --git a/mods/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MMENU.ANS rename to art/themes/luciano_blocktronics/MMENU.ANS diff --git a/mods/themes/luciano_blocktronics/MNUPRMT.ANS b/art/themes/luciano_blocktronics/MNUPRMT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MNUPRMT.ANS rename to art/themes/luciano_blocktronics/MNUPRMT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGBODY.ANS b/art/themes/luciano_blocktronics/MSGBODY.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGBODY.ANS rename to art/themes/luciano_blocktronics/MSGBODY.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEFTR.ANS b/art/themes/luciano_blocktronics/MSGEFTR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEFTR.ANS rename to art/themes/luciano_blocktronics/MSGEFTR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEHDR.ANS b/art/themes/luciano_blocktronics/MSGEHDR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEHDR.ANS rename to art/themes/luciano_blocktronics/MSGEHDR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEHLP.ANS b/art/themes/luciano_blocktronics/MSGEHLP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEHLP.ANS rename to art/themes/luciano_blocktronics/MSGEHLP.ANS diff --git a/mods/themes/luciano_blocktronics/MSGEMFT.ANS b/art/themes/luciano_blocktronics/MSGEMFT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGEMFT.ANS rename to art/themes/luciano_blocktronics/MSGEMFT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGLIST.ANS b/art/themes/luciano_blocktronics/MSGLIST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGLIST.ANS rename to art/themes/luciano_blocktronics/MSGLIST.ANS diff --git a/mods/themes/luciano_blocktronics/MSGMNU.ANS b/art/themes/luciano_blocktronics/MSGMNU.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGMNU.ANS rename to art/themes/luciano_blocktronics/MSGMNU.ANS diff --git a/mods/themes/luciano_blocktronics/MSGPMPT.ANS b/art/themes/luciano_blocktronics/MSGPMPT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGPMPT.ANS rename to art/themes/luciano_blocktronics/MSGPMPT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGQUOT.ANS b/art/themes/luciano_blocktronics/MSGQUOT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGQUOT.ANS rename to art/themes/luciano_blocktronics/MSGQUOT.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVFTR.ANS b/art/themes/luciano_blocktronics/MSGVFTR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVFTR.ANS rename to art/themes/luciano_blocktronics/MSGVFTR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVHDR.ANS b/art/themes/luciano_blocktronics/MSGVHDR.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVHDR.ANS rename to art/themes/luciano_blocktronics/MSGVHDR.ANS diff --git a/mods/themes/luciano_blocktronics/MSGVHLP.ANS b/art/themes/luciano_blocktronics/MSGVHLP.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/MSGVHLP.ANS rename to art/themes/luciano_blocktronics/MSGVHLP.ANS diff --git a/mods/themes/luciano_blocktronics/NEWMSGS.ANS b/art/themes/luciano_blocktronics/NEWMSGS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/NEWMSGS.ANS rename to art/themes/luciano_blocktronics/NEWMSGS.ANS diff --git a/mods/themes/luciano_blocktronics/NUA.ANS b/art/themes/luciano_blocktronics/NUA.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/NUA.ANS rename to art/themes/luciano_blocktronics/NUA.ANS diff --git a/mods/themes/luciano_blocktronics/ONEADD.ANS b/art/themes/luciano_blocktronics/ONEADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ONEADD.ANS rename to art/themes/luciano_blocktronics/ONEADD.ANS diff --git a/mods/themes/luciano_blocktronics/ONELINER.ANS b/art/themes/luciano_blocktronics/ONELINER.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ONELINER.ANS rename to art/themes/luciano_blocktronics/ONELINER.ANS diff --git a/mods/themes/luciano_blocktronics/PAUSE.ANS b/art/themes/luciano_blocktronics/PAUSE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/PAUSE.ANS rename to art/themes/luciano_blocktronics/PAUSE.ANS diff --git a/mods/themes/luciano_blocktronics/RATEFILE.ANS b/art/themes/luciano_blocktronics/RATEFILE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RATEFILE.ANS rename to art/themes/luciano_blocktronics/RATEFILE.ANS diff --git a/mods/themes/luciano_blocktronics/RUMORADD.ANS b/art/themes/luciano_blocktronics/RUMORADD.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RUMORADD.ANS rename to art/themes/luciano_blocktronics/RUMORADD.ANS diff --git a/mods/themes/luciano_blocktronics/RUMORS.ANS b/art/themes/luciano_blocktronics/RUMORS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/RUMORS.ANS rename to art/themes/luciano_blocktronics/RUMORS.ANS diff --git a/mods/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/STATUS.ANS rename to art/themes/luciano_blocktronics/STATUS.ANS diff --git a/mods/themes/luciano_blocktronics/SYSSTAT.ANS b/art/themes/luciano_blocktronics/SYSSTAT.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/SYSSTAT.ANS rename to art/themes/luciano_blocktronics/SYSSTAT.ANS diff --git a/mods/themes/luciano_blocktronics/TBRIDGE.ANS b/art/themes/luciano_blocktronics/TBRIDGE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/TBRIDGE.ANS rename to art/themes/luciano_blocktronics/TBRIDGE.ANS diff --git a/mods/themes/luciano_blocktronics/TOONODE.ANS b/art/themes/luciano_blocktronics/TOONODE.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/TOONODE.ANS rename to art/themes/luciano_blocktronics/TOONODE.ANS diff --git a/mods/themes/luciano_blocktronics/ULCHECK.ANS b/art/themes/luciano_blocktronics/ULCHECK.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULCHECK.ANS rename to art/themes/luciano_blocktronics/ULCHECK.ANS diff --git a/mods/themes/luciano_blocktronics/ULDETAIL.ANS b/art/themes/luciano_blocktronics/ULDETAIL.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULDETAIL.ANS rename to art/themes/luciano_blocktronics/ULDETAIL.ANS diff --git a/mods/themes/luciano_blocktronics/ULDUPES.ANS b/art/themes/luciano_blocktronics/ULDUPES.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULDUPES.ANS rename to art/themes/luciano_blocktronics/ULDUPES.ANS diff --git a/mods/themes/luciano_blocktronics/ULNOAREA.ANS b/art/themes/luciano_blocktronics/ULNOAREA.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULNOAREA.ANS rename to art/themes/luciano_blocktronics/ULNOAREA.ANS diff --git a/mods/themes/luciano_blocktronics/ULOPTS.ANS b/art/themes/luciano_blocktronics/ULOPTS.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/ULOPTS.ANS rename to art/themes/luciano_blocktronics/ULOPTS.ANS diff --git a/mods/themes/luciano_blocktronics/USERLOG.ANS b/art/themes/luciano_blocktronics/USERLOG.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/USERLOG.ANS rename to art/themes/luciano_blocktronics/USERLOG.ANS diff --git a/mods/themes/luciano_blocktronics/USERLST.ANS b/art/themes/luciano_blocktronics/USERLST.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/USERLST.ANS rename to art/themes/luciano_blocktronics/USERLST.ANS diff --git a/mods/themes/luciano_blocktronics/WHOSON.ANS b/art/themes/luciano_blocktronics/WHOSON.ANS similarity index 100% rename from mods/themes/luciano_blocktronics/WHOSON.ANS rename to art/themes/luciano_blocktronics/WHOSON.ANS diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson similarity index 100% rename from mods/themes/luciano_blocktronics/theme.hjson rename to art/themes/luciano_blocktronics/theme.hjson diff --git a/mods/menu.hjson b/config/menu.hjson similarity index 100% rename from mods/menu.hjson rename to config/menu.hjson diff --git a/mods/prompt.hjson b/config/prompt.hjson similarity index 100% rename from mods/prompt.hjson rename to config/prompt.hjson diff --git a/core/config.js b/core/config.js index 367143b6..fa7d346c 100644 --- a/core/config.js +++ b/core/config.js @@ -111,11 +111,8 @@ function init(configPath, options, cb) { } function getDefaultPath() { - const base = miscUtil.resolvePath('~/'); - if(base) { - // e.g. /home/users/joeuser/.config/enigma-bbs/config.hjson - return paths.join(base, '.config', 'enigma-bbs', 'config.hjson'); - } + // e.g. /enigma-bbs-install-path/config/config.hjson + return './config/config.hjson'; } function getDefaultConfig() { @@ -193,15 +190,17 @@ function getDefaultConfig() { }, paths : { - mods : paths.join(__dirname, './../mods/'), + config : paths.join(__dirname, './../config/'), + mods : paths.join(__dirname, './../mods/system'), + userMods : paths.join(__dirname, './../mods/user'), loginServers : paths.join(__dirname, './servers/login/'), contentServers : paths.join(__dirname, './servers/content/'), scannerTossers : paths.join(__dirname, './scanner_tossers/'), mailers : paths.join(__dirname, './mailers/') , - art : paths.join(__dirname, './../mods/art/'), - themes : paths.join(__dirname, './../mods/themes/'), + art : paths.join(__dirname, './../art/general/'), + themes : paths.join(__dirname, './../art/themes/'), logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such db : paths.join(__dirname, './../db/'), modsDb : paths.join(__dirname, './../db/mods/'), diff --git a/core/config_util.js b/core/config_util.js index f078f758..3ed7b98c 100644 --- a/core/config_util.js +++ b/core/config_util.js @@ -1,16 +1,15 @@ /* jslint node: true */ 'use strict'; - -var configCache = require('./config_cache.js'); - -var paths = require('path'); +const Config = require('./config.js').config; +const configCache = require('./config_cache.js'); +const paths = require('path'); exports.getFullConfig = getFullConfig; function getFullConfig(filePath, cb) { // |filePath| is assumed to be in 'mods' if it's only a file name if('.' === paths.dirname(filePath)) { - filePath = paths.join(__dirname, '../mods', filePath); + filePath = paths.join(Config.paths.config, filePath); } configCache.getConfig(filePath, function loaded(err, configJson) { diff --git a/core/module_util.js b/core/module_util.js index 67e87306..b730d0ca 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -102,6 +102,7 @@ function loadModulesForCategory(category, iterator, complete) { function getModulePaths() { return [ Config.paths.mods, + Config.paths.userMods, Config.paths.loginServers, Config.paths.contentServers, Config.paths.scannerTossers, diff --git a/core/oputil/oputil_common.js b/core/oputil/oputil_common.js index 21e1a6d0..14edd518 100644 --- a/core/oputil/oputil_common.js +++ b/core/oputil/oputil_common.js @@ -45,7 +45,7 @@ function printUsageAndSetExitCode(errMsg, exitCode) { } function getDefaultConfigPath() { - return resolvePath('~/.config/enigma-bbs/config.hjson'); + return './config/config.hjson'; } function getConfigPath() { diff --git a/mods/abracadabra.js b/mods/system/abracadabra.js similarity index 94% rename from mods/abracadabra.js rename to mods/system/abracadabra.js index a84d2c63..595f84d4 100644 --- a/mods/abracadabra.js +++ b/mods/system/abracadabra.js @@ -1,11 +1,11 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const DropFile = require('../core/dropfile.js').DropFile; -const door = require('../core/door.js'); -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const DropFile = require('../../core/dropfile.js').DropFile; +const door = require('../../core/door.js'); +const theme = require('../../core/theme.js'); +const ansi = require('../../core/ansi_term.js'); const async = require('async'); const assert = require('assert'); diff --git a/mods/bbs_link.js b/mods/system/bbs_link.js similarity index 96% rename from mods/bbs_link.js rename to mods/system/bbs_link.js index 0cf0a5db..1d5492df 100644 --- a/mods/bbs_link.js +++ b/mods/system/bbs_link.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; +const MenuModule = require('../../core/menu_module.js').MenuModule; +const resetScreen = require('../../core/ansi_term.js').resetScreen; const async = require('async'); const _ = require('lodash'); @@ -10,7 +10,7 @@ const http = require('http'); const net = require('net'); const crypto = require('crypto'); -const packageJson = require('../package.json'); +const packageJson = require('../../package.json'); /* Expected configuration block: diff --git a/mods/bbs_list.js b/mods/system/bbs_list.js similarity index 96% rename from mods/bbs_list.js rename to mods/system/bbs_list.js index ec964a36..8ae94c6c 100644 --- a/mods/bbs_list.js +++ b/mods/system/bbs_list.js @@ -2,18 +2,18 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; +const MenuModule = require('../../core/menu_module.js').MenuModule; const { getModDatabasePath, getTransactionDatabase -} = require('../core/database.js'); +} = require('../../core/database.js'); -const ViewController = require('../core/view_controller.js').ViewController; -const ansi = require('../core/ansi_term.js'); -const theme = require('../core/theme.js'); -const User = require('../core/user.js'); -const stringFormat = require('../core/string_format.js'); +const ViewController = require('../../core/view_controller.js').ViewController; +const ansi = require('../../core/ansi_term.js'); +const theme = require('../../core/theme.js'); +const User = require('../../core/user.js'); +const stringFormat = require('../../core/string_format.js'); // deps const async = require('async'); diff --git a/mods/erc_client.js b/mods/system/erc_client.js similarity index 97% rename from mods/erc_client.js rename to mods/system/erc_client.js index 02b42ad5..cdc71521 100644 --- a/mods/erc_client.js +++ b/mods/system/erc_client.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const stringFormat = require('../../core/string_format.js'); // deps const async = require('async'); diff --git a/mods/file_area_filter_edit.js b/mods/system/file_area_filter_edit.js similarity index 95% rename from mods/file_area_filter_edit.js rename to mods/system/file_area_filter_edit.js index cb3322f9..0ff8b37d 100644 --- a/mods/file_area_filter_edit.js +++ b/mods/system/file_area_filter_edit.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../core/file_base_filter.js'); -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('../../core/file_base_filter.js'); +const stringFormat = require('../../core/string_format.js'); // deps const async = require('async'); diff --git a/mods/file_area_list.js b/mods/system/file_area_list.js similarity index 94% rename from mods/file_area_list.js rename to mods/system/file_area_list.js index 0c24f9f8..5359e48e 100644 --- a/mods/file_area_list.js +++ b/mods/system/file_area_list.js @@ -2,23 +2,23 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const ansi = require('../core/ansi_term.js'); -const theme = require('../core/theme.js'); -const FileEntry = require('../core/file_entry.js'); -const stringFormat = require('../core/string_format.js'); -const FileArea = require('../core/file_base_area.js'); -const Errors = require('../core/enig_error.js').Errors; -const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled; -const ArchiveUtil = require('../core/archive_util.js'); -const Config = require('../core/config.js').config; -const DownloadQueue = require('../core/download_queue.js'); -const FileAreaWeb = require('../core/file_area_web.js'); -const FileBaseFilters = require('../core/file_base_filter.js'); -const resolveMimeType = require('../core/mime_util.js').resolveMimeType; -const isAnsi = require('../core/string_util.js').isAnsi; -const controlCodesToAnsi = require('../core/color_codes.js').controlCodesToAnsi; +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const ansi = require('../../core/ansi_term.js'); +const theme = require('../../core/theme.js'); +const FileEntry = require('../../core/file_entry.js'); +const stringFormat = require('../../core/string_format.js'); +const FileArea = require('../../core/file_base_area.js'); +const Errors = require('../../core/enig_error.js').Errors; +const ErrNotEnabled = require('../../core/enig_error.js').ErrorReasons.NotEnabled; +const ArchiveUtil = require('../../core/archive_util.js'); +const Config = require('../../core/config.js').config; +const DownloadQueue = require('../../core/download_queue.js'); +const FileAreaWeb = require('../../core/file_area_web.js'); +const FileBaseFilters = require('../../core/file_base_filter.js'); +const resolveMimeType = require('../../core/mime_util.js').resolveMimeType; +const isAnsi = require('../../core/string_util.js').isAnsi; +const controlCodesToAnsi = require('../../core/color_codes.js').controlCodesToAnsi; // deps const async = require('async'); diff --git a/mods/file_base_area_select.js b/mods/system/file_base_area_select.js similarity index 88% rename from mods/file_base_area_select.js rename to mods/system/file_base_area_select.js index ca182d6c..38b7eba7 100644 --- a/mods/file_base_area_select.js +++ b/mods/system/file_base_area_select.js @@ -2,10 +2,10 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const stringFormat = require('../core/string_format.js'); -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const StatLog = require('../core/stat_log.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const stringFormat = require('../../core/string_format.js'); +const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; +const StatLog = require('../../core/stat_log.js'); // deps const async = require('async'); diff --git a/mods/file_base_download_manager.js b/mods/system/file_base_download_manager.js similarity index 92% rename from mods/file_base_download_manager.js rename to mods/system/file_base_download_manager.js index 382a7305..15a892f1 100644 --- a/mods/file_base_download_manager.js +++ b/mods/system/file_base_download_manager.js @@ -2,14 +2,14 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const DownloadQueue = require('../core/download_queue.js'); -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); -const Errors = require('../core/enig_error.js').Errors; -const stringFormat = require('../core/string_format.js'); -const FileAreaWeb = require('../core/file_area_web.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const DownloadQueue = require('../../core/download_queue.js'); +const theme = require('../../core/theme.js'); +const ansi = require('../../core/ansi_term.js'); +const Errors = require('../../core/enig_error.js').Errors; +const stringFormat = require('../../core/string_format.js'); +const FileAreaWeb = require('../../core/file_area_web.js'); // deps const async = require('async'); diff --git a/mods/file_base_search.js b/mods/system/file_base_search.js similarity index 89% rename from mods/file_base_search.js rename to mods/system/file_base_search.js index e984e1a4..d3ebd1db 100644 --- a/mods/file_base_search.js +++ b/mods/system/file_base_search.js @@ -2,10 +2,10 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../core/file_base_filter.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('../../core/file_base_filter.js'); // deps const async = require('async'); diff --git a/mods/file_base_web_download_manager.js b/mods/system/file_base_web_download_manager.js similarity index 91% rename from mods/file_base_web_download_manager.js rename to mods/system/file_base_web_download_manager.js index d171cfdb..9acad951 100644 --- a/mods/file_base_web_download_manager.js +++ b/mods/system/file_base_web_download_manager.js @@ -2,16 +2,16 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const DownloadQueue = require('../core/download_queue.js'); -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); -const Errors = require('../core/enig_error.js').Errors; -const stringFormat = require('../core/string_format.js'); -const FileAreaWeb = require('../core/file_area_web.js'); -const ErrNotEnabled = require('../core/enig_error.js').ErrorReasons.NotEnabled; -const Config = require('../core/config.js').config; +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const DownloadQueue = require('../../core/download_queue.js'); +const theme = require('../../core/theme.js'); +const ansi = require('../../core/ansi_term.js'); +const Errors = require('../../core/enig_error.js').Errors; +const stringFormat = require('../../core/string_format.js'); +const FileAreaWeb = require('../../core/file_area_web.js'); +const ErrNotEnabled = require('../../core/enig_error.js').ErrorReasons.NotEnabled; +const Config = require('../../core/config.js').config; // deps const async = require('async'); diff --git a/mods/file_transfer_protocol_select.js b/mods/system/file_transfer_protocol_select.js similarity index 93% rename from mods/file_transfer_protocol_select.js rename to mods/system/file_transfer_protocol_select.js index 6efa5a93..c731dff2 100644 --- a/mods/file_transfer_protocol_select.js +++ b/mods/system/file_transfer_protocol_select.js @@ -2,10 +2,10 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const Config = require('../core/config.js').config; -const stringFormat = require('../core/string_format.js'); -const ViewController = require('../core/view_controller.js').ViewController; +const MenuModule = require('../../core/menu_module.js').MenuModule; +const Config = require('../../core/config.js').config; +const stringFormat = require('../../core/string_format.js'); +const ViewController = require('../../core/view_controller.js').ViewController; // deps const async = require('async'); diff --git a/mods/last_callers.js b/mods/system/last_callers.js similarity index 92% rename from mods/last_callers.js rename to mods/system/last_callers.js index afb429d8..85d4bef0 100644 --- a/mods/last_callers.js +++ b/mods/system/last_callers.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const StatLog = require('../core/stat_log.js'); -const User = require('../core/user.js'); -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const StatLog = require('../../core/stat_log.js'); +const User = require('../../core/user.js'); +const stringFormat = require('../../core/string_format.js'); // deps const moment = require('moment'); diff --git a/mods/msg_area_list.js b/mods/system/msg_area_list.js similarity index 91% rename from mods/msg_area_list.js rename to mods/system/msg_area_list.js index a6a0df4c..51b18953 100644 --- a/mods/msg_area_list.js +++ b/mods/system/msg_area_list.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const displayThemeArt = require('../core/theme.js').displayThemeArt; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const messageArea = require('../../core/message_area.js'); +const displayThemeArt = require('../../core/theme.js').displayThemeArt; +const resetScreen = require('../../core/ansi_term.js').resetScreen; +const stringFormat = require('../../core/string_format.js'); // deps const async = require('async'); diff --git a/mods/msg_area_post_fse.js b/mods/system/msg_area_post_fse.js similarity index 90% rename from mods/msg_area_post_fse.js rename to mods/system/msg_area_post_fse.js index 21b5d068..a0671f85 100644 --- a/mods/msg_area_post_fse.js +++ b/mods/system/msg_area_post_fse.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; -const persistMessage = require('../core/message_area.js').persistMessage; +const FullScreenEditorModule = require('../../core/fse.js').FullScreenEditorModule; +const persistMessage = require('../../core/message_area.js').persistMessage; const _ = require('lodash'); const async = require('async'); diff --git a/mods/msg_area_reply_fse.js b/mods/system/msg_area_reply_fse.js similarity index 81% rename from mods/msg_area_reply_fse.js rename to mods/system/msg_area_reply_fse.js index d1cb5faa..497c8de7 100644 --- a/mods/msg_area_reply_fse.js +++ b/mods/system/msg_area_reply_fse.js @@ -1,7 +1,7 @@ /* jslint node: true */ 'use strict'; -var FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; +var FullScreenEditorModule = require('../../core/fse.js').FullScreenEditorModule; exports.getModule = AreaReplyFSEModule; diff --git a/mods/msg_area_view_fse.js b/mods/system/msg_area_view_fse.js similarity index 95% rename from mods/msg_area_view_fse.js rename to mods/system/msg_area_view_fse.js index de4657f1..7cb5a1b8 100644 --- a/mods/msg_area_view_fse.js +++ b/mods/system/msg_area_view_fse.js @@ -2,8 +2,8 @@ 'use strict'; // ENiGMA½ -const FullScreenEditorModule = require('../core/fse.js').FullScreenEditorModule; -const Message = require('../core/message.js'); +const FullScreenEditorModule = require('../../core/fse.js').FullScreenEditorModule; +const Message = require('../../core/message.js'); // deps const _ = require('lodash'); diff --git a/mods/msg_conf_list.js b/mods/system/msg_conf_list.js similarity index 89% rename from mods/msg_conf_list.js rename to mods/system/msg_conf_list.js index 91c24de4..06e9d59b 100644 --- a/mods/msg_conf_list.js +++ b/mods/system/msg_conf_list.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const displayThemeArt = require('../core/theme.js').displayThemeArt; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const messageArea = require('../../core/message_area.js'); +const displayThemeArt = require('../../core/theme.js').displayThemeArt; +const resetScreen = require('../../core/ansi_term.js').resetScreen; +const stringFormat = require('../../core/string_format.js'); // deps const async = require('async'); diff --git a/mods/msg_list.js b/mods/system/msg_list.js similarity index 95% rename from mods/msg_list.js rename to mods/system/msg_list.js index bc80e27b..28d1b609 100644 --- a/mods/msg_list.js +++ b/mods/system/msg_list.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const messageArea = require('../core/message_area.js'); -const stringFormat = require('../core/string_format.js'); -const MessageAreaConfTempSwitcher = require('../core/mod_mixins.js').MessageAreaConfTempSwitcher; +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const messageArea = require('../../core/message_area.js'); +const stringFormat = require('../../core/string_format.js'); +const MessageAreaConfTempSwitcher = require('../../core/mod_mixins.js').MessageAreaConfTempSwitcher; // deps const async = require('async'); diff --git a/mods/nua.js b/mods/system/nua.js similarity index 91% rename from mods/nua.js rename to mods/system/nua.js index 878e0581..7b4611d6 100644 --- a/mods/nua.js +++ b/mods/system/nua.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const User = require('../core/user.js'); -const theme = require('../core/theme.js'); -const login = require('../core/system_menu_method.js').login; -const Config = require('../core/config.js').config; -const messageArea = require('../core/message_area.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const User = require('../../core/user.js'); +const theme = require('../../core/theme.js'); +const login = require('../../core/system_menu_method.js').login; +const Config = require('../../core/config.js').config; +const messageArea = require('../../core/message_area.js'); exports.moduleInfo = { name : 'NUA', diff --git a/mods/onelinerz.js b/mods/system/onelinerz.js similarity index 95% rename from mods/onelinerz.js rename to mods/system/onelinerz.js index 065c0a30..416124c6 100644 --- a/mods/onelinerz.js +++ b/mods/system/onelinerz.js @@ -2,17 +2,17 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; +const MenuModule = require('../../core/menu_module.js').MenuModule; const { getModDatabasePath, getTransactionDatabase -} = require('../core/database.js'); +} = require('../../core/database.js'); -const ViewController = require('../core/view_controller.js').ViewController; -const theme = require('../core/theme.js'); -const ansi = require('../core/ansi_term.js'); -const stringFormat = require('../core/string_format.js'); +const ViewController = require('../../core/view_controller.js').ViewController; +const theme = require('../../core/theme.js'); +const ansi = require('../../core/ansi_term.js'); +const stringFormat = require('../../core/string_format.js'); // deps const sqlite3 = require('sqlite3'); diff --git a/mods/rumorz.js b/mods/system/rumorz.js similarity index 92% rename from mods/rumorz.js rename to mods/system/rumorz.js index 20aace03..e85271dc 100644 --- a/mods/rumorz.js +++ b/mods/system/rumorz.js @@ -2,13 +2,13 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const theme = require('../core/theme.js'); -const resetScreen = require('../core/ansi_term.js').resetScreen; -const StatLog = require('../core/stat_log.js'); -const renderStringLength = require('../core/string_util.js').renderStringLength; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const theme = require('../../core/theme.js'); +const resetScreen = require('../../core/ansi_term.js').resetScreen; +const StatLog = require('../../core/stat_log.js'); +const renderStringLength = require('../../core/string_util.js').renderStringLength; +const stringFormat = require('../../core/string_format.js'); // deps const async = require('async'); diff --git a/mods/telnet_bridge.js b/mods/system/telnet_bridge.js similarity index 94% rename from mods/telnet_bridge.js rename to mods/system/telnet_bridge.js index 1dbb1ae9..42c73217 100644 --- a/mods/telnet_bridge.js +++ b/mods/system/telnet_bridge.js @@ -2,9 +2,9 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const resetScreen = require('../core/ansi_term.js').resetScreen; -const setSyncTermFontWithAlias = require('../core/ansi_term.js').setSyncTermFontWithAlias; +const MenuModule = require('../../core/menu_module.js').MenuModule; +const resetScreen = require('../../core/ansi_term.js').resetScreen; +const setSyncTermFontWithAlias = require('../../core/ansi_term.js').setSyncTermFontWithAlias; // deps const async = require('async'); diff --git a/mods/upload.js b/mods/system/upload.js similarity index 94% rename from mods/upload.js rename to mods/system/upload.js index 30c84c48..8e545452 100644 --- a/mods/upload.js +++ b/mods/system/upload.js @@ -2,20 +2,20 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../core/menu_module.js').MenuModule; -const stringFormat = require('../core/string_format.js'); -const getSortedAvailableFileAreas = require('../core/file_base_area.js').getSortedAvailableFileAreas; -const getAreaDefaultStorageDirectory = require('../core/file_base_area.js').getAreaDefaultStorageDirectory; -const scanFile = require('../core/file_base_area.js').scanFile; -const getFileAreaByTag = require('../core/file_base_area.js').getFileAreaByTag; -const getDescFromFileName = require('../core/file_base_area.js').getDescFromFileName; -const ansiGoto = require('../core/ansi_term.js').goto; -const moveFileWithCollisionHandling = require('../core/file_util.js').moveFileWithCollisionHandling; -const pathWithTerminatingSeparator = require('../core/file_util.js').pathWithTerminatingSeparator; -const Log = require('../core/logger.js').log; -const Errors = require('../core/enig_error.js').Errors; -const FileEntry = require('../core/file_entry.js'); -const isAnsi = require('../core/string_util.js').isAnsi; +const MenuModule = require('../../core/menu_module.js').MenuModule; +const stringFormat = require('../../core/string_format.js'); +const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; +const getAreaDefaultStorageDirectory = require('../../core/file_base_area.js').getAreaDefaultStorageDirectory; +const scanFile = require('../../core/file_base_area.js').scanFile; +const getFileAreaByTag = require('../../core/file_base_area.js').getFileAreaByTag; +const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; +const ansiGoto = require('../../core/ansi_term.js').goto; +const moveFileWithCollisionHandling = require('../../core/file_util.js').moveFileWithCollisionHandling; +const pathWithTerminatingSeparator = require('../../core/file_util.js').pathWithTerminatingSeparator; +const Log = require('../../core/logger.js').log; +const Errors = require('../../core/enig_error.js').Errors; +const FileEntry = require('../../core/file_entry.js'); +const isAnsi = require('../../core/string_util.js').isAnsi; // deps const async = require('async'); diff --git a/mods/user_list.js b/mods/system/user_list.js similarity index 91% rename from mods/user_list.js rename to mods/system/user_list.js index b2a88e79..7b85b331 100644 --- a/mods/user_list.js +++ b/mods/system/user_list.js @@ -1,10 +1,10 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../core/menu_module.js').MenuModule; -const User = require('../core/user.js'); -const ViewController = require('../core/view_controller.js').ViewController; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const User = require('../../core/user.js'); +const ViewController = require('../../core/view_controller.js').ViewController; +const stringFormat = require('../../core/string_format.js'); const moment = require('moment'); const async = require('async'); diff --git a/mods/whos_online.js b/mods/system/whos_online.js similarity index 87% rename from mods/whos_online.js rename to mods/system/whos_online.js index a0a87829..cec3bb4b 100644 --- a/mods/whos_online.js +++ b/mods/system/whos_online.js @@ -2,10 +2,10 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../core/menu_module.js').MenuModule; -const ViewController = require('../core/view_controller.js').ViewController; -const getActiveNodeList = require('../core/client_connections.js').getActiveNodeList; -const stringFormat = require('../core/string_format.js'); +const MenuModule = require('../../core/menu_module.js').MenuModule; +const ViewController = require('../../core/view_controller.js').ViewController; +const getActiveNodeList = require('../../core/client_connections.js').getActiveNodeList; +const stringFormat = require('../../core/string_format.js'); // deps const async = require('async'); diff --git a/mods/user/.keep b/mods/user/.keep new file mode 100644 index 00000000..e69de29b From 618ecc07142ad580a865148fd5b0a4364872eae1 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Fri, 24 Nov 2017 23:23:15 +0000 Subject: [PATCH 058/102] Move modules in mods into /core --- config/menu.hjson | 70 +++++++++---------- {mods/system => core}/abracadabra.js | 10 +-- {mods/system => core}/bbs_link.js | 6 +- {mods/system => core}/bbs_list.js | 14 ++-- {mods/system => core}/erc_client.js | 4 +- .../system => core}/file_area_filter_edit.js | 10 +-- {mods/system => core}/file_area_list.js | 34 ++++----- .../system => core}/file_base_area_select.js | 8 +-- .../file_base_download_manager.js | 16 ++--- {mods/system => core}/file_base_search.js | 8 +-- .../file_base_web_download_manager.js | 20 +++--- .../file_transfer_protocol_select.js | 8 +-- {mods/system => core}/last_callers.js | 10 +-- core/module_util.js | 1 - {mods/system => core}/msg_area_list.js | 12 ++-- {mods/system => core}/msg_area_post_fse.js | 4 +- {mods/system => core}/msg_area_reply_fse.js | 2 +- {mods/system => core}/msg_area_view_fse.js | 4 +- {mods/system => core}/msg_conf_list.js | 12 ++-- {mods/system => core}/msg_list.js | 10 +-- {mods/system => core}/nua.js | 12 ++-- {mods/system => core}/onelinerz.js | 12 ++-- {mods/system => core}/rumorz.js | 14 ++-- {mods/system => core}/telnet_bridge.js | 6 +- {mods/system => core}/upload.js | 28 ++++---- {mods/system => core}/user_list.js | 8 +-- {mods/system => core}/whos_online.js | 8 +-- mods/{user => }/.keep | 0 28 files changed, 175 insertions(+), 176 deletions(-) rename {mods/system => core}/abracadabra.js (94%) rename {mods/system => core}/bbs_link.js (96%) rename {mods/system => core}/bbs_list.js (96%) rename {mods/system => core}/erc_client.js (97%) rename {mods/system => core}/file_area_filter_edit.js (95%) rename {mods/system => core}/file_area_list.js (94%) rename {mods/system => core}/file_base_area_select.js (88%) rename {mods/system => core}/file_base_download_manager.js (92%) rename {mods/system => core}/file_base_search.js (89%) rename {mods/system => core}/file_base_web_download_manager.js (91%) rename {mods/system => core}/file_transfer_protocol_select.js (93%) rename {mods/system => core}/last_callers.js (92%) rename {mods/system => core}/msg_area_list.js (91%) rename {mods/system => core}/msg_area_post_fse.js (90%) rename {mods/system => core}/msg_area_reply_fse.js (81%) rename {mods/system => core}/msg_area_view_fse.js (95%) rename {mods/system => core}/msg_conf_list.js (89%) rename {mods/system => core}/msg_list.js (95%) rename {mods/system => core}/nua.js (91%) rename {mods/system => core}/onelinerz.js (95%) rename {mods/system => core}/rumorz.js (92%) rename {mods/system => core}/telnet_bridge.js (94%) rename {mods/system => core}/upload.js (94%) rename {mods/system => core}/user_list.js (91%) rename {mods/system => core}/whos_online.js (87%) rename mods/{user => }/.keep (100%) diff --git a/config/menu.hjson b/config/menu.hjson index b445ec85..ce5e8a38 100644 --- a/config/menu.hjson +++ b/config/menu.hjson @@ -220,7 +220,7 @@ } newUserApplication: { - module: nua + module: @systemModule:nua art: NUA options: { menuFlags: [ "noHistory" ] @@ -341,7 +341,7 @@ // Canceling this form logs off vs falling back to matrix // newUserApplicationSsh: { - module: nua + module: @systemModule:nua art: NUA fallback: logoff options: { @@ -445,7 +445,7 @@ newUserFeedbackToSysOp: { desc: Feedback to SysOp - module: msg_area_post_fse + module: @systemModule:msg_area_post_fse next: [ { acs: AS2 @@ -579,7 +579,7 @@ fullLoginSequenceLastCallers: { desc: Last Callers - module: last_callers + module: @systemModule:last_callers art: LASTCALL options: { pause: true @@ -589,7 +589,7 @@ } fullLoginSequenceWhosOnline: { desc: Who's Online - module: whos_online + module: @systemModule:whos_online art: WHOSON options: { pause: true } next: fullLoginSequenceOnelinerz @@ -597,7 +597,7 @@ fullLoginSequenceOnelinerz: { desc: Viewing Onelinerz - module: onelinerz + module: @systemModule:onelinerz next: [ { // calls >= 2 @@ -732,7 +732,7 @@ newScanMessageList: { desc: New Messages - module: msg_list + module: @systemModule:msg_list art: NEWMSGS config: { menuViewPost: messageAreaViewPost @@ -772,7 +772,7 @@ } newScanFileBaseList: { - module: file_area_list + module: @systemModule:file_area_list desc: New Files config: { art: { @@ -1019,14 +1019,14 @@ mainMenuLastCallers: { desc: Last Callers - module: last_callers + module: @systemModule:last_callers art: LASTCALL options: { pause: true } } mainMenuWhosOnline: { desc: Who's Online - module: whos_online + module: @systemModule:whos_online art: WHOSON options: { pause: true } } @@ -1045,7 +1045,7 @@ mainMenuUserList: { desc: User Listing - module: user_list + module: @systemModule:user_list art: USERLST form: { 0: { @@ -1166,7 +1166,7 @@ mainMenuFeedbackToSysOp: { desc: Feedback to SysOp - module: msg_area_post_fse + module: @systemModule:msg_area_post_fse config: { art: { header: MSGEHDR @@ -1286,7 +1286,7 @@ mainMenuOnelinerz: { desc: Viewing Onelinerz - module: onelinerz + module: @systemModule:onelinerz options: { cls: true } @@ -1372,7 +1372,7 @@ mainMenuRumorz: { desc: Rumorz - module: rumorz + module: @systemModule:rumorz options: { cls: true } @@ -1458,7 +1458,7 @@ ercClient: { art: erc - module: erc_client + module: @systemModule:erc_client config: { host: localhost port: 5001 @@ -1510,7 +1510,7 @@ bbsList: { desc: Viewing BBS List - module: bbs_list + module: @systemModule:bbs_list options: { cls: true } @@ -1661,7 +1661,7 @@ // doorPimpWars: { desc: Playing PimpWars - module: abracadabra + module: @systemModule:abracadabra config: { name: PimpWars dropFileType: DORINFO @@ -1684,7 +1684,7 @@ // doorTradeWars2002BBSLink: { desc: Playing TW 2002 (BBSLink) - module: bbs_link + module: @systemModule:bbs_link config: { sysCode: XXXXXXXX authCode: XXXXXXXX @@ -1716,7 +1716,7 @@ telnetBridgeAgency: { desc: Connected to HappyLand BBS - module: telnet_bridge + module: @systemModule:telnet_bridge config: { host: agency.bbs.geek.nz } @@ -1779,7 +1779,7 @@ messageAreaChangeCurrentConference: { art: CCHANGE - module: msg_conf_list + module: @systemModule:msg_conf_list form: { 0: { mci: { @@ -1810,7 +1810,7 @@ messageAreaChangeCurrentArea: { // :TODO: rename this art to ACHANGE art: CHANGE - module: msg_area_list + module: @systemModule:msg_area_list form: { 0: { mci: { @@ -1839,7 +1839,7 @@ } messageAreaMessageList: { - module: msg_list + module: @systemModule:msg_list art: MSGLIST config: { menuViewPost: messageAreaViewPost @@ -1875,7 +1875,7 @@ } messageAreaViewPost: { - module: msg_area_view_fse + module: @systemModule:msg_area_view_fse config: { art: { header: MSGVHDR @@ -1991,7 +1991,7 @@ } messageAreaReplyPost: { - module: msg_area_post_fse + module: @systemModule:msg_area_post_fse config: { art: { header: MSGEHDR @@ -2150,7 +2150,7 @@ // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu messageAreaNewPost: { desc: Posting message, - module: msg_area_post_fse + module: @systemModule:msg_area_post_fse config: { art: { header: MSGEHDR @@ -2306,7 +2306,7 @@ mailMenuCreateMessage: { desc: Mailing Someone - module: msg_area_post_fse + module: @systemModule:msg_area_post_fse config: { art: { header: MSGEHDR @@ -2423,7 +2423,7 @@ } mailMenuInbox: { - module: msg_list + module: @systemModule:msg_list art: MSGLIST config: { menuViewPost: messageAreaViewPost @@ -2501,7 +2501,7 @@ } fileBaseListEntries: { - module: file_area_list + module: @systemModule:file_area_list desc: Browsing Files config: { art: { @@ -2669,7 +2669,7 @@ fileBaseBrowseByAreaSelect: { desc: Browsing File Areas - module: file_base_area_select + module: @systemModule:file_base_area_select art: FAREASEL form: { 0: { @@ -2725,7 +2725,7 @@ } fileBaseSearch: { - module: file_base_search + module: @systemModule:file_base_search desc: Searching Files art: FSEARCH form: { @@ -2799,7 +2799,7 @@ fileAreaFilterEditor: { desc: File Filter Editor - module: file_area_filter_edit + module: @systemModule:file_area_filter_edit art: FFILEDT form: { 0: { @@ -2889,7 +2889,7 @@ fileBaseDownloadManager: { desc: Download Manager - module: file_base_download_manager + module: @systemModule:file_base_download_manager config: { art: { queueManager: FDLMGR @@ -2950,7 +2950,7 @@ fileBaseWebDownloadManager: { desc: Web D/L Manager - module: file_base_web_download_manager + module: @systemModule:file_base_web_download_manager config: { art: { queueManager: FWDLMGR @@ -3017,7 +3017,7 @@ fileTransferProtocolSelection: { desc: Protocol selection - module: file_transfer_protocol_select + module: @systemModule:file_transfer_protocol_select art: FPROSEL form: { 0: { @@ -3049,7 +3049,7 @@ fileBaseUploadFiles: { desc: Uploading - module: upload + module: @systemModule:upload config: { art: { options: ULOPTS diff --git a/mods/system/abracadabra.js b/core/abracadabra.js similarity index 94% rename from mods/system/abracadabra.js rename to core/abracadabra.js index 595f84d4..85d1e205 100644 --- a/mods/system/abracadabra.js +++ b/core/abracadabra.js @@ -1,11 +1,11 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../../core/menu_module.js').MenuModule; -const DropFile = require('../../core/dropfile.js').DropFile; -const door = require('../../core/door.js'); -const theme = require('../../core/theme.js'); -const ansi = require('../../core/ansi_term.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const DropFile = require('./dropfile.js').DropFile; +const door = require('./door.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); const async = require('async'); const assert = require('assert'); diff --git a/mods/system/bbs_link.js b/core/bbs_link.js similarity index 96% rename from mods/system/bbs_link.js rename to core/bbs_link.js index 1d5492df..be341115 100644 --- a/mods/system/bbs_link.js +++ b/core/bbs_link.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../../core/menu_module.js').MenuModule; -const resetScreen = require('../../core/ansi_term.js').resetScreen; +const MenuModule = require('./menu_module.js').MenuModule; +const resetScreen = require('./ansi_term.js').resetScreen; const async = require('async'); const _ = require('lodash'); @@ -10,7 +10,7 @@ const http = require('http'); const net = require('net'); const crypto = require('crypto'); -const packageJson = require('../../package.json'); +const packageJson = require('../package.json'); /* Expected configuration block: diff --git a/mods/system/bbs_list.js b/core/bbs_list.js similarity index 96% rename from mods/system/bbs_list.js rename to core/bbs_list.js index 8ae94c6c..33a7ff59 100644 --- a/mods/system/bbs_list.js +++ b/core/bbs_list.js @@ -2,18 +2,18 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; +const MenuModule = require('./menu_module.js').MenuModule; const { getModDatabasePath, getTransactionDatabase -} = require('../../core/database.js'); +} = require('./database.js'); -const ViewController = require('../../core/view_controller.js').ViewController; -const ansi = require('../../core/ansi_term.js'); -const theme = require('../../core/theme.js'); -const User = require('../../core/user.js'); -const stringFormat = require('../../core/string_format.js'); +const ViewController = require('./view_controller.js').ViewController; +const ansi = require('./ansi_term.js'); +const theme = require('./theme.js'); +const User = require('./user.js'); +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/system/erc_client.js b/core/erc_client.js similarity index 97% rename from mods/system/erc_client.js rename to core/erc_client.js index cdc71521..4fb549f6 100644 --- a/mods/system/erc_client.js +++ b/core/erc_client.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../../core/menu_module.js').MenuModule; -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/system/file_area_filter_edit.js b/core/file_area_filter_edit.js similarity index 95% rename from mods/system/file_area_filter_edit.js rename to core/file_area_filter_edit.js index 0ff8b37d..4a53096c 100644 --- a/mods/system/file_area_filter_edit.js +++ b/core/file_area_filter_edit.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../../core/file_base_filter.js'); -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('./file_base_filter.js'); +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/system/file_area_list.js b/core/file_area_list.js similarity index 94% rename from mods/system/file_area_list.js rename to core/file_area_list.js index 5359e48e..3bcfd7c2 100644 --- a/mods/system/file_area_list.js +++ b/core/file_area_list.js @@ -2,23 +2,23 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const ansi = require('../../core/ansi_term.js'); -const theme = require('../../core/theme.js'); -const FileEntry = require('../../core/file_entry.js'); -const stringFormat = require('../../core/string_format.js'); -const FileArea = require('../../core/file_base_area.js'); -const Errors = require('../../core/enig_error.js').Errors; -const ErrNotEnabled = require('../../core/enig_error.js').ErrorReasons.NotEnabled; -const ArchiveUtil = require('../../core/archive_util.js'); -const Config = require('../../core/config.js').config; -const DownloadQueue = require('../../core/download_queue.js'); -const FileAreaWeb = require('../../core/file_area_web.js'); -const FileBaseFilters = require('../../core/file_base_filter.js'); -const resolveMimeType = require('../../core/mime_util.js').resolveMimeType; -const isAnsi = require('../../core/string_util.js').isAnsi; -const controlCodesToAnsi = require('../../core/color_codes.js').controlCodesToAnsi; +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const ansi = require('./ansi_term.js'); +const theme = require('./theme.js'); +const FileEntry = require('./file_entry.js'); +const stringFormat = require('./string_format.js'); +const FileArea = require('./file_base_area.js'); +const Errors = require('./enig_error.js').Errors; +const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; +const ArchiveUtil = require('./archive_util.js'); +const Config = require('./config.js').config; +const DownloadQueue = require('./download_queue.js'); +const FileAreaWeb = require('./file_area_web.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const resolveMimeType = require('./mime_util.js').resolveMimeType; +const isAnsi = require('./string_util.js').isAnsi; +const controlCodesToAnsi = require('./color_codes.js').controlCodesToAnsi; // deps const async = require('async'); diff --git a/mods/system/file_base_area_select.js b/core/file_base_area_select.js similarity index 88% rename from mods/system/file_base_area_select.js rename to core/file_base_area_select.js index 38b7eba7..8abb668e 100644 --- a/mods/system/file_base_area_select.js +++ b/core/file_base_area_select.js @@ -2,10 +2,10 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../../core/menu_module.js').MenuModule; -const stringFormat = require('../../core/string_format.js'); -const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; -const StatLog = require('../../core/stat_log.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const stringFormat = require('./string_format.js'); +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const StatLog = require('./stat_log.js'); // deps const async = require('async'); diff --git a/mods/system/file_base_download_manager.js b/core/file_base_download_manager.js similarity index 92% rename from mods/system/file_base_download_manager.js rename to core/file_base_download_manager.js index 15a892f1..7444af56 100644 --- a/mods/system/file_base_download_manager.js +++ b/core/file_base_download_manager.js @@ -2,14 +2,14 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const DownloadQueue = require('../../core/download_queue.js'); -const theme = require('../../core/theme.js'); -const ansi = require('../../core/ansi_term.js'); -const Errors = require('../../core/enig_error.js').Errors; -const stringFormat = require('../../core/string_format.js'); -const FileAreaWeb = require('../../core/file_area_web.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const DownloadQueue = require('./download_queue.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const Errors = require('./enig_error.js').Errors; +const stringFormat = require('./string_format.js'); +const FileAreaWeb = require('./file_area_web.js'); // deps const async = require('async'); diff --git a/mods/system/file_base_search.js b/core/file_base_search.js similarity index 89% rename from mods/system/file_base_search.js rename to core/file_base_search.js index d3ebd1db..adb618d0 100644 --- a/mods/system/file_base_search.js +++ b/core/file_base_search.js @@ -2,10 +2,10 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; -const FileBaseFilters = require('../../core/file_base_filter.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const FileBaseFilters = require('./file_base_filter.js'); // deps const async = require('async'); diff --git a/mods/system/file_base_web_download_manager.js b/core/file_base_web_download_manager.js similarity index 91% rename from mods/system/file_base_web_download_manager.js rename to core/file_base_web_download_manager.js index 9acad951..dea7c5a8 100644 --- a/mods/system/file_base_web_download_manager.js +++ b/core/file_base_web_download_manager.js @@ -2,16 +2,16 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const DownloadQueue = require('../../core/download_queue.js'); -const theme = require('../../core/theme.js'); -const ansi = require('../../core/ansi_term.js'); -const Errors = require('../../core/enig_error.js').Errors; -const stringFormat = require('../../core/string_format.js'); -const FileAreaWeb = require('../../core/file_area_web.js'); -const ErrNotEnabled = require('../../core/enig_error.js').ErrorReasons.NotEnabled; -const Config = require('../../core/config.js').config; +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const DownloadQueue = require('./download_queue.js'); +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const Errors = require('./enig_error.js').Errors; +const stringFormat = require('./string_format.js'); +const FileAreaWeb = require('./file_area_web.js'); +const ErrNotEnabled = require('./enig_error.js').ErrorReasons.NotEnabled; +const Config = require('./config.js').config; // deps const async = require('async'); diff --git a/mods/system/file_transfer_protocol_select.js b/core/file_transfer_protocol_select.js similarity index 93% rename from mods/system/file_transfer_protocol_select.js rename to core/file_transfer_protocol_select.js index c731dff2..f1b3dbed 100644 --- a/mods/system/file_transfer_protocol_select.js +++ b/core/file_transfer_protocol_select.js @@ -2,10 +2,10 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../../core/menu_module.js').MenuModule; -const Config = require('../../core/config.js').config; -const stringFormat = require('../../core/string_format.js'); -const ViewController = require('../../core/view_controller.js').ViewController; +const MenuModule = require('./menu_module.js').MenuModule; +const Config = require('./config.js').config; +const stringFormat = require('./string_format.js'); +const ViewController = require('./view_controller.js').ViewController; // deps const async = require('async'); diff --git a/mods/system/last_callers.js b/core/last_callers.js similarity index 92% rename from mods/system/last_callers.js rename to core/last_callers.js index 85d4bef0..3a889468 100644 --- a/mods/system/last_callers.js +++ b/core/last_callers.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const StatLog = require('../../core/stat_log.js'); -const User = require('../../core/user.js'); -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const StatLog = require('./stat_log.js'); +const User = require('./user.js'); +const stringFormat = require('./string_format.js'); // deps const moment = require('moment'); diff --git a/core/module_util.js b/core/module_util.js index b730d0ca..67e87306 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -102,7 +102,6 @@ function loadModulesForCategory(category, iterator, complete) { function getModulePaths() { return [ Config.paths.mods, - Config.paths.userMods, Config.paths.loginServers, Config.paths.contentServers, Config.paths.scannerTossers, diff --git a/mods/system/msg_area_list.js b/core/msg_area_list.js similarity index 91% rename from mods/system/msg_area_list.js rename to core/msg_area_list.js index 51b18953..eaedbef8 100644 --- a/mods/system/msg_area_list.js +++ b/core/msg_area_list.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const messageArea = require('../../core/message_area.js'); -const displayThemeArt = require('../../core/theme.js').displayThemeArt; -const resetScreen = require('../../core/ansi_term.js').resetScreen; -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const messageArea = require('./message_area.js'); +const displayThemeArt = require('./theme.js').displayThemeArt; +const resetScreen = require('./ansi_term.js').resetScreen; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/system/msg_area_post_fse.js b/core/msg_area_post_fse.js similarity index 90% rename from mods/system/msg_area_post_fse.js rename to core/msg_area_post_fse.js index a0671f85..c13f39a6 100644 --- a/mods/system/msg_area_post_fse.js +++ b/core/msg_area_post_fse.js @@ -1,8 +1,8 @@ /* jslint node: true */ 'use strict'; -const FullScreenEditorModule = require('../../core/fse.js').FullScreenEditorModule; -const persistMessage = require('../../core/message_area.js').persistMessage; +const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; +const persistMessage = require('./message_area.js').persistMessage; const _ = require('lodash'); const async = require('async'); diff --git a/mods/system/msg_area_reply_fse.js b/core/msg_area_reply_fse.js similarity index 81% rename from mods/system/msg_area_reply_fse.js rename to core/msg_area_reply_fse.js index 497c8de7..24ee5377 100644 --- a/mods/system/msg_area_reply_fse.js +++ b/core/msg_area_reply_fse.js @@ -1,7 +1,7 @@ /* jslint node: true */ 'use strict'; -var FullScreenEditorModule = require('../../core/fse.js').FullScreenEditorModule; +var FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; exports.getModule = AreaReplyFSEModule; diff --git a/mods/system/msg_area_view_fse.js b/core/msg_area_view_fse.js similarity index 95% rename from mods/system/msg_area_view_fse.js rename to core/msg_area_view_fse.js index 7cb5a1b8..02915f79 100644 --- a/mods/system/msg_area_view_fse.js +++ b/core/msg_area_view_fse.js @@ -2,8 +2,8 @@ 'use strict'; // ENiGMA½ -const FullScreenEditorModule = require('../../core/fse.js').FullScreenEditorModule; -const Message = require('../../core/message.js'); +const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; +const Message = require('./message.js'); // deps const _ = require('lodash'); diff --git a/mods/system/msg_conf_list.js b/core/msg_conf_list.js similarity index 89% rename from mods/system/msg_conf_list.js rename to core/msg_conf_list.js index 06e9d59b..6f42cf36 100644 --- a/mods/system/msg_conf_list.js +++ b/core/msg_conf_list.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const messageArea = require('../../core/message_area.js'); -const displayThemeArt = require('../../core/theme.js').displayThemeArt; -const resetScreen = require('../../core/ansi_term.js').resetScreen; -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const messageArea = require('./message_area.js'); +const displayThemeArt = require('./theme.js').displayThemeArt; +const resetScreen = require('./ansi_term.js').resetScreen; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/system/msg_list.js b/core/msg_list.js similarity index 95% rename from mods/system/msg_list.js rename to core/msg_list.js index 28d1b609..e5a69e80 100644 --- a/mods/system/msg_list.js +++ b/core/msg_list.js @@ -2,11 +2,11 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const messageArea = require('../../core/message_area.js'); -const stringFormat = require('../../core/string_format.js'); -const MessageAreaConfTempSwitcher = require('../../core/mod_mixins.js').MessageAreaConfTempSwitcher; +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const messageArea = require('./message_area.js'); +const stringFormat = require('./string_format.js'); +const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher; // deps const async = require('async'); diff --git a/mods/system/nua.js b/core/nua.js similarity index 91% rename from mods/system/nua.js rename to core/nua.js index 7b4611d6..7939e739 100644 --- a/mods/system/nua.js +++ b/core/nua.js @@ -2,12 +2,12 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const User = require('../../core/user.js'); -const theme = require('../../core/theme.js'); -const login = require('../../core/system_menu_method.js').login; -const Config = require('../../core/config.js').config; -const messageArea = require('../../core/message_area.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const User = require('./user.js'); +const theme = require('./theme.js'); +const login = require('./system_menu_method.js').login; +const Config = require('./config.js').config; +const messageArea = require('./message_area.js'); exports.moduleInfo = { name : 'NUA', diff --git a/mods/system/onelinerz.js b/core/onelinerz.js similarity index 95% rename from mods/system/onelinerz.js rename to core/onelinerz.js index 416124c6..9e89addf 100644 --- a/mods/system/onelinerz.js +++ b/core/onelinerz.js @@ -2,17 +2,17 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; +const MenuModule = require('./menu_module.js').MenuModule; const { getModDatabasePath, getTransactionDatabase -} = require('../../core/database.js'); +} = require('./database.js'); -const ViewController = require('../../core/view_controller.js').ViewController; -const theme = require('../../core/theme.js'); -const ansi = require('../../core/ansi_term.js'); -const stringFormat = require('../../core/string_format.js'); +const ViewController = require('./view_controller.js').ViewController; +const theme = require('./theme.js'); +const ansi = require('./ansi_term.js'); +const stringFormat = require('./string_format.js'); // deps const sqlite3 = require('sqlite3'); diff --git a/mods/system/rumorz.js b/core/rumorz.js similarity index 92% rename from mods/system/rumorz.js rename to core/rumorz.js index e85271dc..b83853f0 100644 --- a/mods/system/rumorz.js +++ b/core/rumorz.js @@ -2,13 +2,13 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const theme = require('../../core/theme.js'); -const resetScreen = require('../../core/ansi_term.js').resetScreen; -const StatLog = require('../../core/stat_log.js'); -const renderStringLength = require('../../core/string_util.js').renderStringLength; -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const theme = require('./theme.js'); +const resetScreen = require('./ansi_term.js').resetScreen; +const StatLog = require('./stat_log.js'); +const renderStringLength = require('./string_util.js').renderStringLength; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/system/telnet_bridge.js b/core/telnet_bridge.js similarity index 94% rename from mods/system/telnet_bridge.js rename to core/telnet_bridge.js index 42c73217..3232228a 100644 --- a/mods/system/telnet_bridge.js +++ b/core/telnet_bridge.js @@ -2,9 +2,9 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const resetScreen = require('../../core/ansi_term.js').resetScreen; -const setSyncTermFontWithAlias = require('../../core/ansi_term.js').setSyncTermFontWithAlias; +const MenuModule = require('./menu_module.js').MenuModule; +const resetScreen = require('./ansi_term.js').resetScreen; +const setSyncTermFontWithAlias = require('./ansi_term.js').setSyncTermFontWithAlias; // deps const async = require('async'); diff --git a/mods/system/upload.js b/core/upload.js similarity index 94% rename from mods/system/upload.js rename to core/upload.js index 8e545452..5a49a0ca 100644 --- a/mods/system/upload.js +++ b/core/upload.js @@ -2,20 +2,20 @@ 'use strict'; // enigma-bbs -const MenuModule = require('../../core/menu_module.js').MenuModule; -const stringFormat = require('../../core/string_format.js'); -const getSortedAvailableFileAreas = require('../../core/file_base_area.js').getSortedAvailableFileAreas; -const getAreaDefaultStorageDirectory = require('../../core/file_base_area.js').getAreaDefaultStorageDirectory; -const scanFile = require('../../core/file_base_area.js').scanFile; -const getFileAreaByTag = require('../../core/file_base_area.js').getFileAreaByTag; -const getDescFromFileName = require('../../core/file_base_area.js').getDescFromFileName; -const ansiGoto = require('../../core/ansi_term.js').goto; -const moveFileWithCollisionHandling = require('../../core/file_util.js').moveFileWithCollisionHandling; -const pathWithTerminatingSeparator = require('../../core/file_util.js').pathWithTerminatingSeparator; -const Log = require('../../core/logger.js').log; -const Errors = require('../../core/enig_error.js').Errors; -const FileEntry = require('../../core/file_entry.js'); -const isAnsi = require('../../core/string_util.js').isAnsi; +const MenuModule = require('./menu_module.js').MenuModule; +const stringFormat = require('./string_format.js'); +const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; +const getAreaDefaultStorageDirectory = require('./file_base_area.js').getAreaDefaultStorageDirectory; +const scanFile = require('./file_base_area.js').scanFile; +const getFileAreaByTag = require('./file_base_area.js').getFileAreaByTag; +const getDescFromFileName = require('./file_base_area.js').getDescFromFileName; +const ansiGoto = require('./ansi_term.js').goto; +const moveFileWithCollisionHandling = require('./file_util.js').moveFileWithCollisionHandling; +const pathWithTerminatingSeparator = require('./file_util.js').pathWithTerminatingSeparator; +const Log = require('./logger.js').log; +const Errors = require('./enig_error.js').Errors; +const FileEntry = require('./file_entry.js'); +const isAnsi = require('./string_util.js').isAnsi; // deps const async = require('async'); diff --git a/mods/system/user_list.js b/core/user_list.js similarity index 91% rename from mods/system/user_list.js rename to core/user_list.js index 7b85b331..be85c586 100644 --- a/mods/system/user_list.js +++ b/core/user_list.js @@ -1,10 +1,10 @@ /* jslint node: true */ 'use strict'; -const MenuModule = require('../../core/menu_module.js').MenuModule; -const User = require('../../core/user.js'); -const ViewController = require('../../core/view_controller.js').ViewController; -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const User = require('./user.js'); +const ViewController = require('./view_controller.js').ViewController; +const stringFormat = require('./string_format.js'); const moment = require('moment'); const async = require('async'); diff --git a/mods/system/whos_online.js b/core/whos_online.js similarity index 87% rename from mods/system/whos_online.js rename to core/whos_online.js index cec3bb4b..6abd76ef 100644 --- a/mods/system/whos_online.js +++ b/core/whos_online.js @@ -2,10 +2,10 @@ 'use strict'; // ENiGMA½ -const MenuModule = require('../../core/menu_module.js').MenuModule; -const ViewController = require('../../core/view_controller.js').ViewController; -const getActiveNodeList = require('../../core/client_connections.js').getActiveNodeList; -const stringFormat = require('../../core/string_format.js'); +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const getActiveNodeList = require('./client_connections.js').getActiveNodeList; +const stringFormat = require('./string_format.js'); // deps const async = require('async'); diff --git a/mods/user/.keep b/mods/.keep similarity index 100% rename from mods/user/.keep rename to mods/.keep From 32557975d9a589b9365bb0e8c51d6c132acd32cb Mon Sep 17 00:00:00 2001 From: David Stephens Date: Fri, 24 Nov 2017 23:33:45 +0000 Subject: [PATCH 059/102] Update mod paths in config.js --- core/config.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/config.js b/core/config.js index fa7d346c..506b5c28 100644 --- a/core/config.js +++ b/core/config.js @@ -191,8 +191,7 @@ function getDefaultConfig() { paths : { config : paths.join(__dirname, './../config/'), - mods : paths.join(__dirname, './../mods/system'), - userMods : paths.join(__dirname, './../mods/user'), + mods : paths.join(__dirname, './../mods/'), loginServers : paths.join(__dirname, './servers/login/'), contentServers : paths.join(__dirname, './servers/content/'), From 521e38d7e9ad10669e095767e2a4bb97ebaca60d Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sat, 25 Nov 2017 22:45:19 +0000 Subject: [PATCH 060/102] Supply config path to main.js and oputil.js, rather than specific config file --- core/bbs.js | 8 +++++--- core/config.js | 4 ++-- core/config_util.js | 2 +- core/oputil/oputil_common.js | 5 +++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/core/bbs.js b/core/bbs.js index 02058bce..43bf7cf3 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -29,11 +29,12 @@ const ENIGMA_COPYRIGHT = 'ENiGMA½ Copyright (c) 2014-2017 Bryan Ashby'; const HELP = `${ENIGMA_COPYRIGHT} usage: main.js +eg : main.js --config /enigma_install_path/config/ valid args: --version : display version --help : displays this help - --config PATH : override default config.hjson path + --config PATH : override default config path `; function printHelpAndExit() { @@ -56,7 +57,8 @@ function main() { return callback(null, configOverridePath || conf.getDefaultPath(), _.isString(configOverridePath)); }, function initConfig(configPath, configPathSupplied, callback) { - conf.init(resolvePath(configPath), function configInit(err) { + const configFile = configPath + 'config.hjson'; + conf.init(resolvePath(configFile), function configInit(err) { // // If the user supplied a path and we can't read/parse it @@ -65,7 +67,7 @@ function main() { if(err) { if('ENOENT' === err.code) { if(configPathSupplied) { - console.error('Configuration file does not exist: ' + configPath); + console.error('Configuration file does not exist: ' + configFile); } else { configPathSupplied = null; // make non-fatal; we'll go with defaults } diff --git a/core/config.js b/core/config.js index 506b5c28..a33efb99 100644 --- a/core/config.js +++ b/core/config.js @@ -111,8 +111,8 @@ function init(configPath, options, cb) { } function getDefaultPath() { - // e.g. /enigma-bbs-install-path/config/config.hjson - return './config/config.hjson'; + // e.g. /enigma-bbs-install-path/config/ + return './config/'; } function getDefaultConfig() { diff --git a/core/config_util.js b/core/config_util.js index 3ed7b98c..40723d9a 100644 --- a/core/config_util.js +++ b/core/config_util.js @@ -7,7 +7,7 @@ const paths = require('path'); exports.getFullConfig = getFullConfig; function getFullConfig(filePath, cb) { - // |filePath| is assumed to be in 'mods' if it's only a file name + // |filePath| is assumed to be in the config path if it's only a file name if('.' === paths.dirname(filePath)) { filePath = paths.join(Config.paths.config, filePath); } diff --git a/core/oputil/oputil_common.js b/core/oputil/oputil_common.js index 14edd518..e175a166 100644 --- a/core/oputil/oputil_common.js +++ b/core/oputil/oputil_common.js @@ -45,11 +45,12 @@ function printUsageAndSetExitCode(errMsg, exitCode) { } function getDefaultConfigPath() { - return './config/config.hjson'; + return './config/'; } function getConfigPath() { - return argv.config ? argv.config : config.getDefaultPath(); + const baseConfigPath = argv.config ? argv.config : config.getDefaultPath(); + return baseConfigPath + 'config.hjson'; } function initConfig(cb) { From 3e268f4b27cb4e6f938931d1dc5a9bbfd1b43531 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sat, 25 Nov 2017 23:08:38 +0000 Subject: [PATCH 061/102] Update docs to reflect config changes --- docs/config.md | 4 +--- docs/index.md | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/config.md b/docs/config.md index 4e510b35..e2122f9f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,9 +2,7 @@ Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error. ## System Configuration -The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `~/.config/enigma-bbs/config.hjson` though you can override this with the `--config` parameter when invoking `main.js`. Values found in core/config.js may be overridden by simply providing the object members you wish replace. - -**Windows note**: **~** resolves to *C:\Users\YOURLOGINNAME\* on modern Windows installations, e.g. `C:\Users\NuSkooler\.config\enigma-bbs\config.hjson` +The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace. ### Creating a Configuration Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory: diff --git a/docs/index.md b/docs/index.md index 909ff836..99f83e32 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,7 +55,7 @@ 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. +The main system configuration is handled via `/enigma-bbs-install-path/config/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**: @@ -110,7 +110,7 @@ Below is an _example_ configuration. It is recommended that you at least **start Read the Points of Interest below for more info. Also check-out all the other documentation files in the [docs](.) directory. ## Points of Interest -* **The first user you create via register/applying (user ID = 1) will be automatically be added to the `sysops` group. And thus becomes SysOp.** (aka root) +* **The first user you create via register/applying (user ID = 1) will be automatically be added to the `sysops` group, and thus becomes SysOp.** (aka root) * Default port for Telnet is 8888 and for SSH 8889 * 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. * All data is stored by default in Sqlite3 database files, within the `db` sub folder. Including user data, messages, system logs and file meta data. From b25b96d9dec5a46da8fa1ba2daf4b357f7e1b992 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 26 Nov 2017 09:09:11 +0000 Subject: [PATCH 062/102] * Move default cert path into config * Update docs to reflect changes * More doc tweaks for new structure --- core/config.js | 18 +++++++++--------- docs/config.md | 2 +- docs/index.md | 2 +- docs/menu_system.md | 2 +- docs/modding.md | 2 +- docs/mods.md | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/core/config.js b/core/config.js index a33efb99..73f0fa28 100644 --- a/core/config.js +++ b/core/config.js @@ -124,8 +124,8 @@ function getDefaultConfig() { loginAttempts : 3, - menuFile : 'menu.hjson', // Override to use something else, e.g. demo.hjson. Can be a full path (defaults to ./mods) - promptFile : 'prompt.hjson', // Override to use soemthing else, e.g. myprompt.hjson. Can be a full path (defaults to ./mods) + menuFile : 'menu.hjson', // Override to use something else, e.g. demo.hjson. Can be a full path (defaults to ./config) + promptFile : 'prompt.hjson', // Override to use soemthing else, e.g. myprompt.hjson. Can be a full path (defaults to ./config) }, // :TODO: see notes below about 'theme' section - move this! @@ -215,18 +215,18 @@ function getDefaultConfig() { }, ssh : { port : 8889, - enabled : false, // defualt to false as PK/pass in config.hjson are required + enabled : false, // default to false as PK/pass in config.hjson are required // // Private key in PEM format // // Generating your PK: - // > openssl genrsa -des3 -out ./misc/ssh_private_key.pem 2048 + // > openssl genrsa -des3 -out ./config/ssh_private_key.pem 2048 // // Then, set servers.ssh.privateKeyPass to the password you use above // in your config.hjson // - privateKeyPem : paths.join(__dirname, './../misc/ssh_private_key.pem'), + privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'), firstMenu : 'sshConnected', firstMenuNewUser : 'sshConnectedNewUser', }, @@ -234,8 +234,8 @@ function getDefaultConfig() { port : 8810, // ws:// enabled : false, securePort : 8811, // wss:// - must provide certPem and keyPem - certPem : paths.join(__dirname, './../misc/https_cert.pem'), - keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'), + certPem : paths.join(__dirname, './../config/https_cert.pem'), + keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), }, }, @@ -271,8 +271,8 @@ function getDefaultConfig() { https : { enabled : false, port : 8443, - certPem : paths.join(__dirname, './../misc/https_cert.pem'), - keyPem : paths.join(__dirname, './../misc/https_cert_key.pem'), + certPem : paths.join(__dirname, './../config/https_cert.pem'), + keyPem : paths.join(__dirname, './../config/https_cert_key.pem'), } } }, diff --git a/docs/config.md b/docs/config.md index e2122f9f..98a6730f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,7 +2,7 @@ Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error. ## System Configuration -The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace. +The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace. ### Creating a Configuration Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory: diff --git a/docs/index.md b/docs/index.md index 99f83e32..2320829c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,7 +51,7 @@ npm install ## Generate a SSH Private Key To utilize the SSH server, a SSH Private Key will need generated. This step can be skipped if you do not wish to enable SSH access. ```bash -openssl genrsa -des3 -out ./misc/ssh_private_key.pem 2048 +openssl genrsa -des3 -out ./config/ssh_private_key.pem 2048 ``` ### Create a Minimal Config diff --git a/docs/menu_system.md b/docs/menu_system.md index 026ef648..aef51199 100644 --- a/docs/menu_system.md +++ b/docs/menu_system.md @@ -3,7 +3,7 @@ ENiGMA½'s menu system is highly flexible and moddable. The possibilities are al This document and others will refer to `menu.hjson`. This should be seen as an alias to `yourboardname.hjson` (or whatever you reference in `config.hjson` using the `menuFile` property — see below). By modifying your `menu.hjson` you will be able to create a custom experience unique to your board. -The default `menu.hjson` file lives within the `mods` directory. It is **highly recommended** to specify another file by setting the `menuFile` property in your `config.hjson` file: +The default `menu.hjson` file lives within the `config` directory. It is **highly recommended** to specify another file by setting the `menuFile` property in your `config.hjson` file: ```hjson general: { /* Can also specify a full path */ diff --git a/docs/modding.md b/docs/modding.md index 9449a91e..609729b4 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -7,7 +7,7 @@ See [Configuration](config.md) See [Menu System](menu_system.md) ## Theming -Take a look at how the default `luciano_blocktronics` theme found under `mods/themes` works! +Take a look at how the default `luciano_blocktronics` theme found under `art/themes` works! TODO document me! diff --git a/docs/mods.md b/docs/mods.md index 3abc7e2f..f73a3fc6 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -1,5 +1,5 @@ # Mods - +Custom mods should be added to `/enigma-install-path/mods`. ## Existing Mods * **Married Bob Fetch Event**: An event for fetching the latest Married Bob ANSI's for display on you board. ACiDic release [ACD-MB4E.ZIP](https://l33t.codes/outgoing/ACD/ACD-MB4E.ZIP). Can also be [found on GitHub](https://github.com/NuSkooler/enigma-bbs-married_bob_evt) From 10044b6749fc08fed83d89653a1acacbf792fa56 Mon Sep 17 00:00:00 2001 From: David Stephens Date: Sun, 26 Nov 2017 18:26:56 +0000 Subject: [PATCH 063/102] Switch to xxhash to save farmhash jiggery-pokery when initialising Docker image --- core/art.js | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/art.js b/core/art.js index 28657fc0..19e0bafe 100644 --- a/core/art.js +++ b/core/art.js @@ -14,7 +14,7 @@ const paths = require('path'); const assert = require('assert'); const iconv = require('iconv-lite'); const _ = require('lodash'); -const farmhash = require('farmhash'); +const xxhash = require('xxhash'); exports.getArt = getArt; exports.getArtFromPath = getArtFromPath; @@ -288,7 +288,7 @@ function display(client, art, options, cb) { } if(!options.disableMciCache) { - artHash = farmhash.hash32(art); + artHash = xxhash.hash(new Buffer(art), 0xCAFEBABE); // see if we have a mciMap cached for this art if(client.mciCache) { diff --git a/package.json b/package.json index 29bd16da..566a454d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "buffers": "NuSkooler/node-buffers", "bunyan": "^1.8.12", "exiftool": "^0.0.3", - "farmhash": "^2.0.4", "fs-extra": "^4.0.1", "graceful-fs": "^4.1.11", "hashids": "^1.1.1", @@ -52,6 +51,7 @@ "uuid": "^3.1.0", "uuid-parse": "^1.0.0", "ws": "^3.1.0", + "xxhash": "^0.2.4", "yazl" : "^2.4.2" }, "devDependencies": {}, From 7af30ea112989531b7b1ea585fa4f4e710cc81da Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 28 Nov 2017 20:17:44 -0700 Subject: [PATCH 064/102] Proceed telnet login even if term type is not received [right away]; Allows older DOS terms to function --- core/servers/login/telnet.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js index 06961800..a6fa0deb 100644 --- a/core/servers/login/telnet.js +++ b/core/servers/login/telnet.js @@ -541,6 +541,13 @@ function TelnetClient(input, output) { const logger = self.log || Log; return logger.warn(info, `Telnet: ${msg}`); }; + + this.readyNow = () => { + if(!this.didReady) { + this.didReady = true; + this.emit('ready', { firstMenu : Config.loginServers.telnet.firstMenu } ); + } + }; } util.inherits(TelnetClient, baseClient.Client); @@ -633,10 +640,7 @@ TelnetClient.prototype.handleSbCommand = function(evt) { self.negotiationsComplete = true; // :TODO: throw in a array of what we've taken care. Complete = array satisified or timeout - if(!self.didReady) { - self.didReady = true; - self.emit('ready', { firstMenu : Config.loginServers.telnet.firstMenu } ); - } + self.readyNow(); } else if('new environment' === evt.option) { // // Handling is as follows: @@ -832,6 +836,18 @@ exports.getModule = class TelnetServerModule extends LoginServerModule { client.banner(); this.handleNewClient(client, sock, ModuleInfo); + + // + // Set a timeout and attempt to proceed even if we don't know + // the term type yet, which is the preferred trigger + // for moving along + // + setTimeout( () => { + if(!client.didReady) { + Log.info('Proceeding after 3s without knowing term type'); + client.readyNow(); + } + }, 3000); }); this.server.on('error', err => { From 99898c83524f7684b108e083052022603eac0387 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 29 Nov 2017 12:13:03 -0700 Subject: [PATCH 065/102] Add VTXClient note --- art/general/NEWUSER1.ANS | Bin 751 -> 884 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/art/general/NEWUSER1.ANS b/art/general/NEWUSER1.ANS index 70edd11e6df33bb840d6263ae879e8aa7e16f474..267c331c52c418b8f044ea9709276641b2494dcc 100644 GIT binary patch delta 178 zcmaFQ`h{)7Q^tDFJcW{sRE4CX{PN<|A{~X2qDqDQ(h{(sbhM$hv2kuI3!0Lo^4c7L}zIO}@|gN{yA7fq{`RfI)}>41_(Md=`OlGRFV_ delta 25 hcmeyu_MUaaQ^v_zOiws#nHU%t83P#jCx Date: Wed, 29 Nov 2017 12:24:21 -0700 Subject: [PATCH 066/102] Initial 0.0.7-alpha to 0.0.8-alpha upgrade instructions --- UPGRADE.md | 151 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 86 insertions(+), 65 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 802a8092..e1a6f42f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,65 +1,86 @@ -# Introduction -This document covers basic upgrade notes for major ENiGMA½ version updates. - - -# Before Upgrading -* Always back up your system! -* At least back up the `db` directory and your `menu.hjson` (or renamed equivalent) - - -# General Notes -Upgrades often come with changes to the default `menu.hjson`. It is wise to -use a *different* file name for your BBS's version of this file and point to -it via `config.hjson`. For example: - -```hjson -general: { - menuFile: my_bbs.hjson -} -``` - -After updating code, use a program such as DiffMerge to merge in updates to -`my_bbs.hjson` from the shipping `menu.hjson`. - - -# Upgrading the Code -Upgrading from GitHub is easy: - -```bash -cd /path/to/enigma-bbs -git pull -rm -rf npm_modules # do this any time you update Node.js itself -npm install -``` - - -# Problems -Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or -[file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues). - - -# 0.0.1-alpha to 0.0.4-alpha -## Node.js 6.x+ LTS is now **required** -You will need to upgrade Node.js to [6.x+](https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V6.md). If using [nvm](https://github.com/creationix/nvm) (you should be!) the process will go something like this: -```bash -nvm install 6 -nvm alias default 6 -``` - -### ES6 -Newly written code will use ES6 and a lot of code has started the migration process. Of note is the `MenuModule` class. If you have created a mod that inherits from `MenuModule`, you will need to upgrade your class to ES6. - -## Manual Database Upgrade -A few upgrades need to be made to your SQLite databases: - -```bash -rm db/file.sqltie3 # safe to delete this time as it was not used previously -sqlite3 db/message.sqlite -sqlite> INSERT INTO message_fts(message_fts) VALUES('rebuild'); -``` - -## Archiver Changes -If you have overridden or made additions to archivers in your `config.hjson` you will need to update them. See [Archive Configuration](docs/archive.md) and `core/config.js` - -## File Base Configuration -As 0.0.4-alpha contains file bases, you'll want to create a suitable configuration if you wish to use the feature. See [File Base Configuration](docs/file_base.md). +# Introduction +This document covers basic upgrade notes for major ENiGMA½ version updates. + + +# Before Upgrading +* Always back up your system! +* At least back up the `db` directory and your `menu.hjson` (or renamed equivalent) + + +# General Notes +Upgrades often come with changes to the default `menu.hjson`. It is wise to +use a *different* file name for your BBS's version of this file and point to +it via `config.hjson`. For example: + +```hjson +general: { + menuFile: my_bbs.hjson +} +``` + +After updating code, use a program such as DiffMerge to merge in updates to +`my_bbs.hjson` from the shipping `menu.hjson`. + + +# Upgrading the Code +Upgrading from GitHub is easy: + +```bash +cd /path/to/enigma-bbs +git pull +rm -rf npm_modules # do this any time you update Node.js itself +npm install +``` + + +# Problems +Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or +[file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues). + +# 0.0.7-alpha to 0.0.8-alpha +ENiGMA 0.0.8-alpha comes with some structure changes: +* Configuration files are defaulted to `./config`. Related, the `--config` option now points to a configuration **directory** +* `./mods/art` has been moved to `./art/general` +* `./mods` is now reserved for actual user addon modules +* Themes have been moved from `./mods/themes` to `./art/themes` + +With the above changes, you'll need to to at least: +* Move your `~/.config/enigma-bbs/config.hjson` to `./config/config.hjson` or utlize the `--config` option. +* Move your `prompt.hjson` and `menu.hjson` (e.g. `myboardname.hjson`) to `./config` +* Move any non-theme art files, and theme directories to their appropriate locations mentioned above +* Move any module directories such as `message_post_evt` to `./mods/` + +# 0.0.6-alpha to 0.0.7-alpha +No issues + +# 0.0.5-alpha to 0.0.6-alpha +No issues + +# 0.0.4-alpha to 0.0.5-alpha +No issues + +# 0.0.1-alpha to 0.0.4-alpha +## Node.js 6.x+ LTS is now **required** +You will need to upgrade Node.js to [6.x+](https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V6.md). If using [nvm](https://github.com/creationix/nvm) (you should be!) the process will go something like this: +```bash +nvm install 6 +nvm alias default 6 +``` + +### ES6 +Newly written code will use ES6 and a lot of code has started the migration process. Of note is the `MenuModule` class. If you have created a mod that inherits from `MenuModule`, you will need to upgrade your class to ES6. + +## Manual Database Upgrade +A few upgrades need to be made to your SQLite databases: + +```bash +rm db/file.sqltie3 # safe to delete this time as it was not used previously +sqlite3 db/message.sqlite +sqlite> INSERT INTO message_fts(message_fts) VALUES('rebuild'); +``` + +## Archiver Changes +If you have overridden or made additions to archivers in your `config.hjson` you will need to update them. See [Archive Configuration](docs/archive.md) and `core/config.js` + +## File Base Configuration +As 0.0.4-alpha contains file bases, you'll want to create a suitable configuration if you wish to use the feature. See [File Base Configuration](docs/file_base.md). From 22b09d80187b8816c8c0301fb147dbd9248ebc7a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 30 Nov 2017 11:39:01 -0700 Subject: [PATCH 067/102] Fix unpipe crash --- core/telnet_bridge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/telnet_bridge.js b/core/telnet_bridge.js index 3232228a..fa1754a5 100644 --- a/core/telnet_bridge.js +++ b/core/telnet_bridge.js @@ -48,7 +48,7 @@ class TelnetClientConnection extends EventEmitter { this.pipeRestored = true; // client may have bailed - if(_.has(this, 'client.term.output')) { + if(null !== _.get(this, 'client.term.output', null)) { if(this.bridgeConnection) { this.client.term.output.unpipe(this.bridgeConnection); } From 1849d275f5cfd2f1cc671bab815e26f101ede6a4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 30 Nov 2017 17:15:18 -0700 Subject: [PATCH 068/102] Make @systemModule implicit; require @userModule for user modules --- UPGRADE.md | 4 +++ config/menu.hjson | 86 +++++++++++++++++++++++------------------------ core/asset.js | 17 +++++----- docs/doors.md | 4 +-- 4 files changed, 58 insertions(+), 53 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index e1a6f42f..c7651fcd 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -44,11 +44,15 @@ ENiGMA 0.0.8-alpha comes with some structure changes: * `./mods` is now reserved for actual user addon modules * Themes have been moved from `./mods/themes` to `./art/themes` +With the change to the `./mods` directory, `@systemModule` is now implied for `module` declarations in `menu.hjson`. To use a user module in `./mods` you must specify `@userModule`! + With the above changes, you'll need to to at least: * Move your `~/.config/enigma-bbs/config.hjson` to `./config/config.hjson` or utlize the `--config` option. * Move your `prompt.hjson` and `menu.hjson` (e.g. `myboardname.hjson`) to `./config` * Move any non-theme art files, and theme directories to their appropriate locations mentioned above * Move any module directories such as `message_post_evt` to `./mods/` +* Move any certificates, pub/private keys, etc. from `./misc` to `./config` +* Specify user modules as `@userModule:my_module_name` # 0.0.6-alpha to 0.0.7-alpha No issues diff --git a/config/menu.hjson b/config/menu.hjson index ce5e8a38..cac6e647 100644 --- a/config/menu.hjson +++ b/config/menu.hjson @@ -220,7 +220,7 @@ } newUserApplication: { - module: @systemModule:nua + module: nua art: NUA options: { menuFlags: [ "noHistory" ] @@ -341,7 +341,7 @@ // Canceling this form logs off vs falling back to matrix // newUserApplicationSsh: { - module: @systemModule:nua + module: nua art: NUA fallback: logoff options: { @@ -445,7 +445,7 @@ newUserFeedbackToSysOp: { desc: Feedback to SysOp - module: @systemModule:msg_area_post_fse + module: msg_area_post_fse next: [ { acs: AS2 @@ -579,7 +579,7 @@ fullLoginSequenceLastCallers: { desc: Last Callers - module: @systemModule:last_callers + module: last_callers art: LASTCALL options: { pause: true @@ -589,7 +589,7 @@ } fullLoginSequenceWhosOnline: { desc: Who's Online - module: @systemModule:whos_online + module: whos_online art: WHOSON options: { pause: true } next: fullLoginSequenceOnelinerz @@ -597,7 +597,7 @@ fullLoginSequenceOnelinerz: { desc: Viewing Onelinerz - module: @systemModule:onelinerz + module: onelinerz next: [ { // calls >= 2 @@ -709,7 +709,7 @@ fullLoginSequenceNewScan: { desc: Performing New Scan - module: @systemModule:new_scan + module: new_scan art: NEWSCAN next: fullLoginSequenceSysStats config: { @@ -732,7 +732,7 @@ newScanMessageList: { desc: New Messages - module: @systemModule:msg_list + module: msg_list art: NEWMSGS config: { menuViewPost: messageAreaViewPost @@ -772,7 +772,7 @@ } newScanFileBaseList: { - module: @systemModule:file_area_list + module: file_area_list desc: New Files config: { art: { @@ -1019,14 +1019,14 @@ mainMenuLastCallers: { desc: Last Callers - module: @systemModule:last_callers + module: last_callers art: LASTCALL options: { pause: true } } mainMenuWhosOnline: { desc: Who's Online - module: @systemModule:whos_online + module: whos_online art: WHOSON options: { pause: true } } @@ -1045,7 +1045,7 @@ mainMenuUserList: { desc: User Listing - module: @systemModule:user_list + module: user_list art: USERLST form: { 0: { @@ -1066,7 +1066,7 @@ } mainMenuUserConfig: { - module: @systemModule:user_config + module: user_config art: CONFSCR form: { 0: { @@ -1157,7 +1157,7 @@ mainMenuGlobalNewScan: { desc: Performing New Scan - module: @systemModule:new_scan + module: new_scan art: NEWSCAN config: { messageListMenu: newScanMessageList @@ -1166,7 +1166,7 @@ mainMenuFeedbackToSysOp: { desc: Feedback to SysOp - module: @systemModule:msg_area_post_fse + module: msg_area_post_fse config: { art: { header: MSGEHDR @@ -1286,7 +1286,7 @@ mainMenuOnelinerz: { desc: Viewing Onelinerz - module: @systemModule:onelinerz + module: onelinerz options: { cls: true } @@ -1372,7 +1372,7 @@ mainMenuRumorz: { desc: Rumorz - module: @systemModule:rumorz + module: rumorz options: { cls: true } @@ -1458,7 +1458,7 @@ ercClient: { art: erc - module: @systemModule:erc_client + module: erc_client config: { host: localhost port: 5001 @@ -1510,7 +1510,7 @@ bbsList: { desc: Viewing BBS List - module: @systemModule:bbs_list + module: bbs_list options: { cls: true } @@ -1661,7 +1661,7 @@ // doorPimpWars: { desc: Playing PimpWars - module: @systemModule:abracadabra + module: abracadabra config: { name: PimpWars dropFileType: DORINFO @@ -1684,7 +1684,7 @@ // doorTradeWars2002BBSLink: { desc: Playing TW 2002 (BBSLink) - module: @systemModule:bbs_link + module: bbs_link config: { sysCode: XXXXXXXX authCode: XXXXXXXX @@ -1696,7 +1696,7 @@ // DoorParty! support. You'll need to register to obtain credentials doorParty: { desc: Using DoorParty! - module: @systemModule:door_party + module: door_party config: { username: XXXXXXXX password: XXXXXXXX @@ -1707,7 +1707,7 @@ // CombatNet support. You'll need to register at http://combatnet.us/ to obtain credentials combatNet: { desc: Using CombatNet - module: @systemModule:combatnet + module: combatnet config: { bbsTag: CBNxxx password: XXXXXXXXX @@ -1716,7 +1716,7 @@ telnetBridgeAgency: { desc: Connected to HappyLand BBS - module: @systemModule:telnet_bridge + module: telnet_bridge config: { host: agency.bbs.geek.nz } @@ -1779,7 +1779,7 @@ messageAreaChangeCurrentConference: { art: CCHANGE - module: @systemModule:msg_conf_list + module: msg_conf_list form: { 0: { mci: { @@ -1810,7 +1810,7 @@ messageAreaChangeCurrentArea: { // :TODO: rename this art to ACHANGE art: CHANGE - module: @systemModule:msg_area_list + module: msg_area_list form: { 0: { mci: { @@ -1839,7 +1839,7 @@ } messageAreaMessageList: { - module: @systemModule:msg_list + module: msg_list art: MSGLIST config: { menuViewPost: messageAreaViewPost @@ -1875,7 +1875,7 @@ } messageAreaViewPost: { - module: @systemModule:msg_area_view_fse + module: msg_area_view_fse config: { art: { header: MSGVHDR @@ -1991,7 +1991,7 @@ } messageAreaReplyPost: { - module: @systemModule:msg_area_post_fse + module: msg_area_post_fse config: { art: { header: MSGEHDR @@ -2150,7 +2150,7 @@ // :TODO: messageAreaSelect (change msg areas -> call @systemMethod -> fallback to menu messageAreaNewPost: { desc: Posting message, - module: @systemModule:msg_area_post_fse + module: msg_area_post_fse config: { art: { header: MSGEHDR @@ -2306,7 +2306,7 @@ mailMenuCreateMessage: { desc: Mailing Someone - module: @systemModule:msg_area_post_fse + module: msg_area_post_fse config: { art: { header: MSGEHDR @@ -2423,7 +2423,7 @@ } mailMenuInbox: { - module: @systemModule:msg_list + module: msg_list art: MSGLIST config: { menuViewPost: messageAreaViewPost @@ -2501,7 +2501,7 @@ } fileBaseListEntries: { - module: @systemModule:file_area_list + module: file_area_list desc: Browsing Files config: { art: { @@ -2669,7 +2669,7 @@ fileBaseBrowseByAreaSelect: { desc: Browsing File Areas - module: @systemModule:file_base_area_select + module: file_base_area_select art: FAREASEL form: { 0: { @@ -2725,7 +2725,7 @@ } fileBaseSearch: { - module: @systemModule:file_base_search + module: file_base_search desc: Searching Files art: FSEARCH form: { @@ -2799,7 +2799,7 @@ fileAreaFilterEditor: { desc: File Filter Editor - module: @systemModule:file_area_filter_edit + module: file_area_filter_edit art: FFILEDT form: { 0: { @@ -2889,7 +2889,7 @@ fileBaseDownloadManager: { desc: Download Manager - module: @systemModule:file_base_download_manager + module: file_base_download_manager config: { art: { queueManager: FDLMGR @@ -2950,7 +2950,7 @@ fileBaseWebDownloadManager: { desc: Web D/L Manager - module: @systemModule:file_base_web_download_manager + module: file_base_web_download_manager config: { art: { queueManager: FWDLMGR @@ -3017,7 +3017,7 @@ fileTransferProtocolSelection: { desc: Protocol selection - module: @systemModule:file_transfer_protocol_select + module: file_transfer_protocol_select art: FPROSEL form: { 0: { @@ -3049,7 +3049,7 @@ fileBaseUploadFiles: { desc: Uploading - module: @systemModule:upload + module: upload config: { art: { options: ULOPTS @@ -3187,7 +3187,7 @@ sendFilesToUser: { desc: Downloading - module: @systemModule:file_transfer + module: file_transfer config: { // defaults - generally use extraArgs protocol: zmodem8kSexyz @@ -3197,7 +3197,7 @@ recvFilesFromUser: { desc: Uploading - module: @systemModule:file_transfer + module: file_transfer config: { // defaults - generally use extraArgs protocol: zmodem8kSexyz @@ -3569,7 +3569,7 @@ "art" : "test.ans" }, "demoFullScreenEditor" : { - "module" : "@systemModule:fse", + "module" : "fse", "config" : { "editorType" : "netMail", "art" : { diff --git a/core/asset.js b/core/asset.js index 0731a1b7..9f2831b7 100644 --- a/core/asset.js +++ b/core/asset.js @@ -21,7 +21,7 @@ const ALL_ASSETS = [ 'art', 'menu', 'method', - 'module', + 'userModule', 'systemMethod', 'systemModule', 'prompt', @@ -58,12 +58,12 @@ function getAssetWithShorthand(spec, defaultType) { assert(_.isString(asset.type)); return asset; - } else { - return { - type : defaultType, - asset : spec, - }; } + + return { + type : defaultType, + asset : spec, + }; } function getArtAsset(spec) { @@ -78,13 +78,14 @@ function getArtAsset(spec) { } function getModuleAsset(spec) { - const asset = getAssetWithShorthand(spec, 'module'); + const asset = getAssetWithShorthand(spec, 'systemModule'); if(!asset) { return null; } - assert( ['module', 'systemModule' ].indexOf(asset.type) > -1); + assert( ['userModule', 'systemModule' ].includes(asset.type) ); + return asset; } diff --git a/docs/doors.md b/docs/doors.md index 0ca55c23..5f72d761 100644 --- a/docs/doors.md +++ b/docs/doors.md @@ -183,7 +183,7 @@ The module `door_party` provides native support for [DoorParty!](http://www.thro ```hjson doorParty: { desc: Using DoorParty! - module: @systemModule:door_party + module: door_party config: { username: XXXXXXXX password: XXXXXXXX @@ -200,7 +200,7 @@ The `combatnet` module provides native support for [CombatNet](http://combatnet. ````hjson combatNet: { desc: Using CombatNet - module: @systemModule:combatnet + module: combatnet config: { bbsTag: CBNxxx password: XXXXXXXXX From 7f80f4a7af7ee481141253f1a5b01921b099e4be Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 2 Dec 2017 19:06:07 -0700 Subject: [PATCH 069/102] * Fix 'noHistory' flag and it's usage to be more natural * Add 'popParent' menu flag (works like 'noHistory' used to) --- config/menu.hjson | 14 +++++--------- core/file_base_area_select.js | 2 +- core/file_base_search.js | 2 +- core/menu_stack.js | 9 +++++++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/config/menu.hjson b/config/menu.hjson index cac6e647..0f1fa168 100644 --- a/config/menu.hjson +++ b/config/menu.hjson @@ -215,16 +215,14 @@ desc: Applying options: { pause: true - cls: true + cls: true + menuFlags: [ "noHistory" ] } } newUserApplication: { module: nua art: NUA - options: { - menuFlags: [ "noHistory" ] - } next: [ { // Initial SysOp does not send feedback to themselves @@ -333,6 +331,7 @@ options: { pause: true cls: true + menuFlags: [ "noHistory" ] } } @@ -344,9 +343,6 @@ module: nua art: NUA fallback: logoff - options: { - menuFlags: [ "noHistory" ] - } next: newUserFeedbackToSysOpPreamble form: { 0: { @@ -2720,7 +2716,7 @@ art: FBNORES options: { pause: true - menuFlags: [ "noHistory" ] + menuFlags: [ "noHistory", "popParent" ] } } @@ -3011,7 +3007,7 @@ art: FEMPTYQ options: { pause: true - menuFlags: [ "noHistory" ] + menuFlags: [ "noHistory", "popParent" ] } } diff --git a/core/file_base_area_select.js b/core/file_base_area_select.js index 8abb668e..5ec266fd 100644 --- a/core/file_base_area_select.js +++ b/core/file_base_area_select.js @@ -40,7 +40,7 @@ exports.getModule = class FileAreaSelectModule extends MenuModule { extraArgs : { filterCriteria : filterCriteria, }, - menuFlags : [ 'noHistory' ], + menuFlags : [ 'popParent' ], }; return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); diff --git a/core/file_base_search.js b/core/file_base_search.js index adb618d0..2ff27f8a 100644 --- a/core/file_base_search.js +++ b/core/file_base_search.js @@ -112,7 +112,7 @@ exports.getModule = class FileBaseSearch extends MenuModule { extraArgs : { filterCriteria : filterCriteria, }, - menuFlags : [ 'noHistory' ], + menuFlags : [ 'popParent' ], }; return this.gotoMenu(this.menuConfig.config.fileBaseListEntriesMenu || 'fileBaseListEntries', menuOpts, cb); diff --git a/core/menu_stack.js b/core/menu_stack.js index f4b29460..b4bebea6 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -129,15 +129,19 @@ module.exports = class MenuStack { } else { self.client.log.debug( { menuName : name }, 'Goto menu module'); + const menuFlags = (options && Array.isArray(options.menuFlags)) ? options.menuFlags : modInst.menuConfig.options.menuFlags; + if(currentModuleInfo) { // save stack state currentModuleInfo.savedState = currentModuleInfo.instance.getSaveState(); currentModuleInfo.instance.leave(); - const menuFlags = (options && Array.isArray(options.menuFlags)) ? options.menuFlags : modInst.menuConfig.options.menuFlags; + if(currentModuleInfo.menuFlags.includes('noHistory')) { + this.pop(); + } - if(menuFlags.includes('noHistory')) { + if(menuFlags.includes('popParent')) { this.pop().instance.leave(); // leave & remove current } } @@ -146,6 +150,7 @@ module.exports = class MenuStack { name : name, instance : modInst, extraArgs : loadOpts.extraArgs, + menuFlags : menuFlags, }); // restore previous state if requested From 1c5a00313b1d38ceb0cedfb0475f6f0e3090ecf4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 12 Dec 2017 21:32:01 -0700 Subject: [PATCH 070/102] Minor adjustment on tags to allow comma separated/etc. --- core/file_entry.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/file_entry.js b/core/file_entry.js index 32903315..6edd7ef8 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -528,8 +528,8 @@ module.exports = class FileEntry { } if(filter.tags && filter.tags.length > 0) { - // build list of quoted tags; filter.tags comes in as a space separated values - const tags = filter.tags.split(' ').map( tag => `"${tag}"` ).join(','); + // build list of quoted tags; filter.tags comes in as a space and/or comma separated values + const tags = filter.tags.replace(/,/g, ' ').replace(/\s{2,}/g, ' ').split(' ').map( tag => `"${tag}"` ).join(','); appendWhereClause( `f.file_id IN ( From fc40641eebfb80a7b44504888afb75a6b4affdfa Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 31 Dec 2017 17:54:11 -0700 Subject: [PATCH 071/102] NetMail avail to oputil & export - WIP --- .eslintrc.json | 3 +- config/prompt.hjson | 14 + core/config.js | 17 +- core/ftn_mail_packet.js | 52 +- core/ftn_util.js | 15 + core/message.js | 6 +- core/oputil/oputil_file_base.js | 2 +- core/oputil/oputil_help.js | 8 + core/oputil/oputil_main.js | 20 +- core/oputil/oputil_message_base.js | 150 ++++ core/scanner_tossers/ftn_bso.js | 1078 ++++++++++++++++++---------- package.json | 16 +- 12 files changed, 961 insertions(+), 420 deletions(-) create mode 100644 core/oputil/oputil_message_base.js diff --git a/.eslintrc.json b/.eslintrc.json index c7757f0d..5e9b45b6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -24,6 +24,7 @@ "error", "always" ], - "comma-dangle": 0 + "comma-dangle": 0, + "no-trailing-spaces" :"warn" } } \ No newline at end of file diff --git a/config/prompt.hjson b/config/prompt.hjson index 83f01ec5..b6bf6691 100644 --- a/config/prompt.hjson +++ b/config/prompt.hjson @@ -72,6 +72,20 @@ } } + loginSequenceFlavorSelect: { + art: LOGINSEL + mci: { + TM1: { + argName: promptValue + items: [ "yes", "no" ] + focus: true + focusItemIndex: 1 + hotKeys: { Y: 0, N: 1 } + hotKeySubmit: true + } + } + } + loginGlobalNewScan: { art: GNSPMPT mci: { diff --git a/core/config.js b/core/config.js index 73f0fa28..34be676e 100644 --- a/core/config.js +++ b/core/config.js @@ -19,7 +19,7 @@ function hasMessageConferenceAndArea(config) { assert(_.isObject(config.messageConferences)); // we create one ourself! const nonInternalConfs = Object.keys(config.messageConferences).filter(confTag => { - return 'system_internal' !== confTag; + return 'system_internal' !== confTag; }); if(0 === nonInternalConfs.length) { @@ -53,12 +53,12 @@ function init(configPath, options, cb) { if(!_.isString(configPath)) { return callback(null, { } ); } - + fs.readFile(configPath, { encoding : 'utf8' }, (err, configData) => { if(err) { return callback(err); } - + let configJson; try { configJson = hjson.parse(configData, options); @@ -70,7 +70,7 @@ function init(configPath, options, cb) { }); }, function mergeWithDefaultConfig(configJson, callback) { - + const mergedConfig = _.mergeWith( getDefaultConfig(), configJson, (conf1, conf2) => { @@ -616,10 +616,11 @@ function getDefaultConfig() { 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/'), - reject : paths.join(__dirname, './../mail/reject/'), // bad pkt, bundles, TIC attachments that fail any check, etc. + outbound : paths.join(__dirname, './../mail/ftn_out/'), + inbound : paths.join(__dirname, './../mail/ftn_in/'), + secInbound : paths.join(__dirname, './../mail/ftn_secin/'), + reject : paths.join(__dirname, './../mail/reject/'), // bad pkt, bundles, TIC attachments that fail any check, etc. + outboundNetMail : paths.join(__dirname, './../mail/ftn_netmail_out/'), // set 'retain' to a valid path to keep good pkt files }, diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index 095628ea..137a2583 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -393,9 +393,19 @@ function Packet(options) { }; function addKludgeLine(line) { - const sepIndex = line.indexOf(':'); - const key = line.substr(0, sepIndex).toUpperCase(); - const value = line.substr(sepIndex + 1).trim(); + // + // We have to special case INTL/TOPT/FMPT as they don't contain + // a ':' name/value separator like the rest of the kludge lines... because stupdity. + // + let key = line.substr(0, 4); + let value; + if( ['INTL', 'TOPT', 'FMPT' ].includes(key)) { + value = line.substr(4).trim(); + } else { + const sepIndex = line.indexOf(':'); + key = line.substr(0, sepIndex).toUpperCase(); + value = line.substr(sepIndex + 1).trim(); + } // // Allow mapped value to be either a key:value if there is only @@ -639,7 +649,7 @@ function Packet(options) { this.getMessageEntryBuffer = function(message, options, cb) { - function getAppendMeta(k, m) { + function getAppendMeta(k, m, sepChar=':') { let append = ''; if(m) { let a = m; @@ -647,7 +657,7 @@ function Packet(options) { a = [ a ]; } a.forEach(v => { - append += `${k}: ${v}\r`; + append += `${k}${sepChar} ${v}\r`; }); } return append; @@ -693,10 +703,21 @@ function Packet(options) { msgBody += `AREA:${message.meta.FtnProperty.ftn_area}\r`; // note: no ^A (0x01) } + // :TODO: DRY with similar function in this file! Object.keys(message.meta.FtnKludge).forEach(k => { - // we want PATH to be last - if('PATH' !== k) { - msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + switch(k) { + case 'PATH' : + break; // skip & save for last + + case 'FMPT' : + case 'TOPT' : + case 'INTL' : + msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); // no sepChar + break; + + default : + msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + break; } }); @@ -810,14 +831,14 @@ function Packet(options) { // :TODO: Put this in it's own method let msgBody = ''; - function appendMeta(k, m) { + function appendMeta(k, m, sepChar=':') { if(m) { let a = m; if(!_.isArray(a)) { a = [ a ]; } a.forEach(v => { - msgBody += `${k}: ${v}\r`; + msgBody += `${k}${sepChar} ${v}\r`; }); } } @@ -832,9 +853,14 @@ function Packet(options) { } Object.keys(message.meta.FtnKludge).forEach(k => { - // we want PATH to be last - if('PATH' !== k) { - appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); + switch(k) { + case 'PATH' : break; // skip & save for last + + case 'FMPT' : + case 'TOPT' : + case 'INTL' : appendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); break; // no sepChar + + default : appendMeta(`\x01${k}`, message.meta.FtnKludge[k]); break; } }); diff --git a/core/ftn_util.js b/core/ftn_util.js index 4d779c4a..68ddf343 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -26,6 +26,7 @@ exports.getUTCTimeZoneOffset = getUTCTimeZoneOffset; exports.getOrigin = getOrigin; exports.getTearLine = getTearLine; exports.getVia = getVia; +exports.getIntl = getIntl; exports.getAbbreviatedNetNodeList = getAbbreviatedNetNodeList; exports.parseAbbreviatedNetNodeList = parseAbbreviatedNetNodeList; exports.getUpdatedSeenByEntries = getUpdatedSeenByEntries; @@ -222,6 +223,20 @@ function getVia(address) { return `${addrStr} @${dateTime} ENiGMA1/2 ${version}`; } +// +// Creates a INTL kludge value as per FTS-4001 +// http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac +// +function getIntl(toAddress, fromAddress) { + // + // INTL differs from 'standard' kludges in that there is no ':' after "INTL" + // + // ""INTL "" "" + // "...These addresses shall be given on the form :/" + // + return `${toAddress.toString('3D')} ${fromAddress.toString('3D')}`; +} + function getAbbreviatedNetNodeList(netNodes) { let abbrList = ''; let currNet; diff --git a/core/message.js b/core/message.js index a251ef50..b5b1e541 100644 --- a/core/message.js +++ b/core/message.js @@ -124,11 +124,13 @@ Message.FtnPropertyNames = { // Note: kludges are stored with their names as-is Message.prototype.setLocalToUserId = function(userId) { - this.meta.System.local_to_user_id = userId; + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalToUserID] = userId; }; Message.prototype.setLocalFromUserId = function(userId) { - this.meta.System.local_from_user_id = userId; + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId; }; Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) { diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js index a00f0889..d6a18026 100644 --- a/core/oputil/oputil_file_base.js +++ b/core/oputil/oputil_file_base.js @@ -8,7 +8,7 @@ const argv = require('./oputil_common.js').argv; const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; const getHelpFor = require('./oputil_help.js').getHelpFor; const getAreaAndStorage = require('./oputil_common.js').getAreaAndStorage; -const Errors = require('../../core/enig_error.js').Errors; +const Errors = require('../enig_error.js').Errors; const async = require('async'); const fs = require('graceful-fs'); diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index 1a2b42bf..b4c48267 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -84,6 +84,14 @@ general information: FILENAME_WC filename with * and ? wildcard support. may match 0:n entries SHA full or partial SHA-256 FILE_ID a file identifier. see file.sqlite3 +`, + MessageBase : + `usage: oputil.js mb [] + + actions: + areafix CMD1 CMD2 ... ADDR sends an AreaFix NetMail to ADDR with the supplied command(s) + one or more commands may be supplied. commands that are multi + part such as "%COMPRESS ZIP" should be quoted. ` }; diff --git a/core/oputil/oputil_main.js b/core/oputil/oputil_main.js index 83af6b5e..aa373ef2 100644 --- a/core/oputil/oputil_main.js +++ b/core/oputil/oputil_main.js @@ -7,6 +7,7 @@ const argv = require('./oputil_common.js').argv; const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; const handleUserCommand = require('./oputil_user.js').handleUserCommand; const handleFileBaseCommand = require('./oputil_file_base.js').handleFileBaseCommand; +const handleMessageBaseCommand = require('./oputil_message_base.js').handleMessageBaseCommand; const handleConfigCommand = require('./oputil_config.js').handleConfigCommand; const getHelpFor = require('./oputil_help.js').getHelpFor; @@ -26,19 +27,10 @@ module.exports = function() { } switch(argv._[0]) { - case 'user' : - handleUserCommand(); - break; - - case 'config' : - handleConfigCommand(); - break; - - case 'fb' : - handleFileBaseCommand(); - break; - - default: - return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.BAD_COMMAND); + case 'user' : return handleUserCommand(); + case 'config' : return handleConfigCommand(); + case 'fb' : return handleFileBaseCommand(); + case 'mb' : return handleMessageBaseCommand(); + default : return printUsageAndSetExitCode(getHelpFor('General'), ExitCodes.BAD_COMMAND); } }; diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js new file mode 100644 index 00000000..77a63e9f --- /dev/null +++ b/core/oputil/oputil_message_base.js @@ -0,0 +1,150 @@ +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const printUsageAndSetExitCode = require('./oputil_common.js').printUsageAndSetExitCode; +const ExitCodes = require('./oputil_common.js').ExitCodes; +const argv = require('./oputil_common.js').argv; +const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; +const getHelpFor = require('./oputil_help.js').getHelpFor; +const Address = require('../ftn_address.js'); +const Errors = require('../enig_error.js').Errors; + +// deps +const async = require('async'); + +exports.handleMessageBaseCommand = handleMessageBaseCommand; + +function areaFix() { + // + // oputil mb areafix CMD1 CMD2 ... ADDR [--password PASS] + // + if(argv._.length < 3) { + return printUsageAndSetExitCode( + getHelpFor('MessageBase'), + ExitCodes.ERROR + ); + } + + async.waterfall( + [ + function init(callback) { + return initConfigAndDatabases(callback); + }, + function validateAddress(callback) { + const addrArg = argv._.slice(-1)[0]; + const ftnAddr = Address.fromString(addrArg); + + if(!ftnAddr) { + return callback(Errors.Invalid(`"${addrArg}" is not a valid FTN address`)); + } + + // + // We need to validate the address targets a system we know unless + // the --force option is used + // + // :TODO: + return callback(null, ftnAddr); + }, + function fetchFromUser(ftnAddr, callback) { + // + // --from USER || +op from system + // + // If possible, we want the user ID of the supplied user as well + // + const User = require('../user.js'); + + if(argv.from) { + User.getUserIdAndName(argv.from, (err, userId, fromName) => { + if(err) { + return callback(null, ftnAddr, argv.from, 0); + } + + // fromName is the same as argv.from, but case may be differnet (yet correct) + return callback(null, ftnAddr, fromName, userId); + }); + } else { + User.getUserName(User.RootUserID, (err, fromName) => { + return callback(null, ftnAddr, fromName || 'SysOp', err ? 0 : User.RootUserID); + }); + } + }, + function createMessage(ftnAddr, fromName, fromUserId, callback) { + // + // Build message as commands separated by line feed + // + // We need to remove quotes from arguments. These are required + // in the case of e.g. removing an area: "-SOME_AREA" would end + // up confusing minimist, therefor they must be quoted: "'-SOME_AREA'" + // + const messageBody = argv._.slice(2, -1).map(arg => { + return arg.replace(/["']/g, ''); + }).join('\n') + '\n'; + + const Message = require('../message.js'); + + const message = new Message({ + toUserName : argv.to || 'AreaFix', + fromUserName : fromName, + subject : argv.password || '', + message : messageBody, + areaTag : Message.WellKnownAreaTags.Private, // mark private + meta : { + FtnProperty : { + [ Message.FtnPropertyNames.FtnDestZone ] : ftnAddr.zone, + [ Message.FtnPropertyNames.FtnDestNetwork ] : ftnAddr.net, + [ Message.FtnPropertyNames.FtnDestNode ] : ftnAddr.node, + } + } + }); + + if(ftnAddr.point) { + message.meta.FtnProperty[Message.FtnPropertyNames.FtnDestPoint] = ftnAddr.point; + } + + if(0 !== fromUserId) { + message.setLocalFromUserId(fromUserId); + } + + return callback(null, message); + }, + function persistMessage(message, callback) { + // + // :TODO: + // - Persist message in private outgoing (sysop out box) + // - Make necessary changes such that the message is exported properly + // + console.log(message); + message.persist(err => { + return callback(err); + }); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(`${err.message}${err.reason ? ': ' + err.reason : ''}`); + } + } + ); +} + +function handleMessageBaseCommand() { + + function errUsage() { + return printUsageAndSetExitCode( + getHelpFor('MessageBase'), + ExitCodes.ERROR + ); + } + + if(true === argv.help) { + return errUsage(); + } + + const action = argv._[1]; + + return({ + areafix : areaFix, + }[action] || errUsage)(); +} \ No newline at end of file diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index f4e5091d..996dd3ef 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -43,14 +43,14 @@ exports.moduleInfo = { /* :TODO: - * Support (approx) max bundle size + * 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 * Validate packet passwords!!!! => secure vs insecure landing areas - -*/ + +*/ exports.getModule = FTNMessageScanTossModule; @@ -58,32 +58,31 @@ const SCHEDULE_REGEXP = /(?:^|or )?(@watch\:|@immediate)([^\0]+)?$/; function FTNMessageScanTossModule() { MessageScanTossModule.call(this); - - let self = this; + + const self = this; this.archUtil = ArchiveUtil.getInstance(); if(_.has(Config, 'scannerTossers.ftn_bso')) { - this.moduleConfig = Config.scannerTossers.ftn_bso; + this.moduleConfig = Config.scannerTossers.ftn_bso; } - + this.getDefaultNetworkName = function() { if(this.moduleConfig.defaultNetwork) { return this.moduleConfig.defaultNetwork.toLowerCase(); } - + const networkNames = Object.keys(Config.messageNetworks.ftn.networks); if(1 === networkNames.length) { return networkNames[0].toLowerCase(); } }; - - + this.getDefaultZone = function(networkName) { if(_.isNumber(Config.messageNetworks.ftn.networks[networkName].defaultZone)) { return Config.messageNetworks.ftn.networks[networkName].defaultZone; } - + // non-explicit: default to local address zone const networkLocalAddress = Config.messageNetworks.ftn.networks[networkName].localAddress; if(networkLocalAddress) { @@ -91,45 +90,45 @@ function FTNMessageScanTossModule() { return addr.zone; } }; - + /* this.isDefaultDomainZone = function(networkName, address) { - const defaultNetworkName = this.getDefaultNetworkName(); + 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); + 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); + const localAddress = Address.fromString(network.localAddress); return !_.isUndefined(localAddress) && localAddress.isPatternMatch(remoteAddressPattern); - }); + }); }; - + this.getLocalAreaTagByFtnAreaTag = function(ftnAreaTag) { ftnAreaTag = ftnAreaTag.toUpperCase(); // always compare upper return _.findKey(Config.messageNetworks.ftn.areas, areaConf => { return areaConf.tag.toUpperCase() === ftnAreaTag; }); }; - + this.getExportType = function(nodeConfig) { - return _.isString(nodeConfig.exportType) ? nodeConfig.exportType.toLowerCase() : 'crash'; + 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)); @@ -137,13 +136,13 @@ function FTNMessageScanTossModule() { return seenByAddrs; }; */ - + this.messageHasValidMSGID = function(msg) { - return _.isString(msg.meta.FtnKludge.MSGID) && msg.meta.FtnKludge.MSGID.length > 0; + return _.isString(msg.meta.FtnKludge.MSGID) && msg.meta.FtnKludge.MSGID.length > 0; }; - + /* - this.getOutgoingPacketDir = function(networkName, destAddress) { + this.getOutgoingEchoMailPacketDir = function(networkName, destAddress) { let dir = this.moduleConfig.paths.outbound; if(!this.isDefaultDomainZone(networkName, destAddress)) { const hexZone = `000${destAddress.zone.toString(16)}`.substr(-3); @@ -152,31 +151,31 @@ function FTNMessageScanTossModule() { return dir; }; */ - - this.getOutgoingPacketDir = function(networkName, destAddress) { + + this.getOutgoingEchoMailPacketDir = function(networkName, destAddress) { networkName = networkName.toLowerCase(); - + let dir = this.moduleConfig.paths.outbound; - - const defaultNetworkName = this.getDefaultNetworkName(); + + const defaultNetworkName = this.getDefaultNetworkName(); const defaultZone = this.getDefaultZone(networkName); - + let zoneExt; if(defaultZone !== destAddress.zone) { zoneExt = '.' + `000${destAddress.zone.toString(16)}`.substr(-3); } else { zoneExt = ''; } - + if(defaultNetworkName === networkName) { dir = paths.join(dir, `outbound${zoneExt}`); } else { dir = paths.join(dir, `${networkName}${zoneExt}`); } - + return dir; }; - + this.getOutgoingPacketFileName = function(basePath, messageId, isTemp, fileCase) { // // Generating an outgoing packet file name comes with a few issues: @@ -189,15 +188,15 @@ function FTNMessageScanTossModule() { // 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) + // * 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'; - + let fileName = `${name}.${ext}`; if('upper' === fileCase) { fileName = fileName.toUpperCase(); @@ -205,10 +204,10 @@ function FTNMessageScanTossModule() { return paths.join(basePath, fileName); }; - + this.getOutgoingFlowFileExtension = function(destAddress, flowType, exportType, fileCase) { let ext; - + switch(flowType) { case 'mail' : ext = `${exportType.toLowerCase()[0]}ut`; break; case 'ref' : ext = `${exportType.toLowerCase()[0]}lo`; break; @@ -220,20 +219,20 @@ function FTNMessageScanTossModule() { if('upper' === fileCase) { ext = ext.toUpperCase(); } - - return ext; + + return ext; }; this.getOutgoingFlowFileName = function(basePath, destAddress, flowType, exportType, fileCase) { - let basename; - + let basename; + const ext = self.getOutgoingFlowFileExtension( - destAddress, - flowType, - exportType, + destAddress, + flowType, + exportType, fileCase ); - + if(destAddress.point) { } else { @@ -242,32 +241,32 @@ function FTNMessageScanTossModule() { // node. This seems to match what Mystic does // basename = - `0000${destAddress.net.toString(16)}`.substr(-4) + - `0000${destAddress.node.toString(16)}`.substr(-4); + `0000${destAddress.net.toString(16)}`.substr(-4) + + `0000${destAddress.node.toString(16)}`.substr(-4); } if('upper' === fileCase) { basename = basename.toUpperCase(); } - + 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 + // * 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 @@ -279,19 +278,19 @@ function FTNMessageScanTossModule() { 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); + 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(''); + const EXT_SUFFIXES = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); let fileName = `${basename}.${moment().format('dd').toLowerCase()}`; async.detectSeries(EXT_SUFFIXES, (suffix, callback) => { - const checkFileName = fileName + suffix; + const checkFileName = fileName + suffix; fs.stat(paths.join(basePath, checkFileName), err => { callback(null, (err && 'ENOENT' === err.code) ? true : false); }); @@ -299,32 +298,40 @@ function FTNMessageScanTossModule() { if(finalSuffix) { return cb(null, paths.join(basePath, fileName + finalSuffix)); } - - return cb(new Error('Could not acquire a bundle filename!')); + + return 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 = + // :TODO: Only set DEST information for EchoMail - NetMail should already have it in message + 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; + + // tear line and origin can both go in EchoMail & NetMail + message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); + message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(options.network.localAddress); + + // :TODO: Need an explicit isNetMail() check or more generally + let ftnAttribute = ftnMailPacket.Packet.Attribute.Local; // message from our system - + if(message.isPrivate()) { + // These should be set for Private/NetMail already + assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_node))); + assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_network))); + ftnAttribute |= ftnMailPacket.Packet.Attribute.Private; - + // // NetMail messages need a FRL-1005.001 "Via" line // http://ftsc.org/docs/frl-1005.001 @@ -334,7 +341,25 @@ function FTNMessageScanTossModule() { } message.meta.FtnKludge.Via = message.meta.FtnKludge.Via || []; message.meta.FtnKludge.Via.push(ftnUtil.getVia(options.network.localAddress)); + + // + // We need to set INTL, and possibly FMPT and/or TOPT + // See http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac + // + message.meta.FtnKludge.INTL = ftnUtil.getIntl(options.network.localAddress, options.destAddress); + + if(_.isNumber(options.network.localAddress.point) && options.network.localAddress.point > 0) { + message.meta.FtnKludge.FMPT = options.network.localAddress.point; + } + + if(_.get(message, 'meta.FtnProperty.ftn_dest_point', 0) > 0) { + message.meta.FtnKludge.TOPT = message.meta.FtnProperty.ftn_dest_point; + } } else { + // We need to set some destination info for EchoMail + message.meta.FtnProperty.ftn_dest_node = options.destAddress.node; + message.meta.FtnProperty.ftn_dest_network = options.destAddress.net; + // // Set appropriate attribute flag for export type // @@ -343,52 +368,51 @@ function FTNMessageScanTossModule() { 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; - + // + 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 = + const seenByAdditions = [ `${options.network.localAddress.net}/${options.network.localAddress.node}` ].concat(Config.messageNetworks.ftn.areas[message.areaTag].uplinks); - message.meta.FtnProperty.ftn_seen_by = + 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 = + 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(); - + message.meta.FtnKludge.TID = ftnUtil.getProductIdentifier(); + // // Determine CHRS and actual internal encoding name. If the message has an // explicit encoding set, use it. Otherwise, try to preserve any CHRS/encoding already set. @@ -403,77 +427,77 @@ function FTNMessageScanTossModule() { encoding = encFromChars; } } - + // // Ensure we ended up with something useable. If not, back to utf8! // if(!iconv.encodingExists(encoding)) { Log.debug( { encoding : encoding }, 'Unknown encoding. Falling back to utf8'); encoding = 'utf8'; - } - + } + options.encoding = encoding; // save for later message.meta.FtnKludge.CHRS = ftnUtil.getCharacterSetIdentifierByEncoding(encoding); - // :TODO: FLAGS kludge? + // :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) => { + + Message.getMetaValuesByMessageId(message.replyToMsgId, 'FtnKludge', 'MSGID', (err, msgIdVal) => { if(!err) { - assert(_.isString(msgIdVal), 'Expected string but got ' + (typeof msgIdVal) + ' (' + msgIdVal + ')'); + assert(_.isString(msgIdVal), 'Expected string but got ' + (typeof msgIdVal) + ' (' + msgIdVal + ')'); // 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]) { @@ -485,46 +509,112 @@ function FTNMessageScanTossModule() { 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 = + 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); + return 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); + return cb(err); }); }; - + + this.getNodeConfigByAddress = function(addr) { + addr = _.isString(addr) ? Address.fromString(addr) : addr; + + // :TODO: sort wildcard nodes{} entries by most->least explicit according to FTN hierarchy + return _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { + return addr.isPatternMatch(nodeAddrWildcard); + }); + }; + + // :TODO: deprecate this in favor of getNodeConfigByAddress() 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.exportNetMailMessagePacket = function(message, exportOpts, cb) { + // + // For NetMail, we always create a *single* packet per message. + // + async.series( + [ + function generalPrep(callback) { + // :TODO: Double check Via spec -- seems like it may be wrong + self.prepareMessage(message, exportOpts); + + return self.setReplyKludgeFromReplyToMsgId(message, callback); + }, + function createPacket(callback) { + const 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 + exportOpts.pktFileName = self.getOutgoingPacketFileName( + self.exportTempDir, + message.messageId, + false, // createTempPacket=false + exportOpts.fileCase + ); + + const ws = fs.createWriteStream(exportOpts.pktFileName); + + packet.writeHeader(ws, packetHeader); + + packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => { + if(err) { + return callback(err); + } + + ws.write(msgBuf); + + packet.writeTerminator(ws); + + ws.end(); + ws.once('finish', () => { + return callback(null); + }); + }); + } + ], + err => { + return cb(err); + } + ); + }; + this.exportMessagesByUuid = function(messageUuids, exportOpts, cb) { // // This method has a lot of madness going on: @@ -538,7 +628,7 @@ function FTNMessageScanTossModule() { let ws; let remainMessageBuf; let remainMessageId; - const createTempPacket = !_.isString(exportOpts.nodeConfig.archiveType) || 0 === exportOpts.nodeConfig.archiveType.length; + const createTempPacket = !_.isString(exportOpts.nodeConfig.archiveType) || 0 === exportOpts.nodeConfig.archiveType.length; function finalizePacket(cb) { packet.writeTerminator(ws); @@ -547,10 +637,10 @@ function FTNMessageScanTossModule() { return cb(null); }); } - + async.each(messageUuids, (msgUuid, nextUuid) => { let message = new Message(); - + async.series( [ function finalizePrevious(callback) { @@ -565,47 +655,47 @@ function FTNMessageScanTossModule() { 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) { + 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, + self.exportTempDir, + message.messageId, createTempPacket, exportOpts.fileCase ); exportedFiles.push(pktFileName); - + ws = fs.createWriteStream(pktFileName); - + currPacketSize = packet.writeHeader(ws, packetHeader); - + if(remainMessageBuf) { currPacketSize += packet.writeMessageEntry(ws, remainMessageBuf); remainMessageBuf = null; - } + } } - - callback(null); + + callback(null); }, function appendMessage(callback) { packet.getMessageEntryBuffer(message, exportOpts, (err, msgBuf) => { @@ -614,16 +704,16 @@ function FTNMessageScanTossModule() { } currPacketSize += msgBuf.length; - + if(currPacketSize >= self.moduleConfig.packetTargetByteSize) { - remainMessageBuf = msgBuf; // save for next packet - remainMessageId = message.messageId; + remainMessageBuf = msgBuf; // save for next packet + remainMessageId = message.messageId; } else { ws.write(msgBuf); } - + return callback(null); - }); + }); }, function storeStateFlags0Meta(callback) { message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), err => { @@ -641,9 +731,9 @@ function FTNMessageScanTossModule() { }); } else { callback(null); - } + } } - ], + ], err => { nextUuid(err); } @@ -665,26 +755,26 @@ function FTNMessageScanTossModule() { 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, + self.exportTempDir, + remainMessageId, createTempPacket, exportOpts.filleCase ); exportedFiles.push(pktFileName); - + ws = fs.createWriteStream(pktFileName); - + packet.writeHeader(ws, packetHeader); ws.write(remainMessageBuf); return finalizePacket(callback); @@ -696,18 +786,168 @@ function FTNMessageScanTossModule() { err => { cb(err, exportedFiles); } - ); - } + ); + } }); }; - - this.exportMessagesToUplinks = function(messageUuids, areaConfig, cb) { + + this.getNetMailRoute = function(dstAddr) { + // + // messageNetworks.ftn.netMail.routes{} full|wildcard -> full adddress lookup + // + const routes = _.get(Config, 'messageNetworks.ftn.netMail.routes'); + if(!routes) { + return; + } + + return _.find(routes, (route, addrWildcard) => { + return dstAddr.isPatternMatch(addrWildcard); + }); + + /* + const route = _.find(routes, (route, addrWildcard) => { + return dstAddr.isPatternMatch(addrWildcard); + }); + + if(route && route.address) { + return Address.fromString(route.address); + } + */ + }; + + this.getAcceptableNetMailNetworkInfoFromAddress = function(dstAddr, cb) { + // + // Attempt to find an acceptable network configuration using the following + // lookup order (most to least explicit config): + // + // 1) Routes: messageNetworks.ftn.netMail.routes{} -> scannerTossers.ftn_bso.nodes{} -> config + // - Where we send may not be where dstAddress is (it's routed!); use network found in route + // for local address + // 2) Direct to nodes: scannerTossers.ftn_bso.nodes{} -> config + // - Where we send is direct to dstAddr; use scannerTossers.ftn_bso.defaultNetwork to + // for local address + // 3) Nodelist DB lookup (use default config) + // - Where we send is direct to dstAddr + // + //const routeAddress = this.getNetMailRouteAddress(dstAddr) || dstAddr; + const route = this.getNetMailRoute(dstAddr); + + let routeAddress; + let networkName; + if(route) { + routeAddress = Address.fromString(route.address); + networkName = route.network || Config.scannerTossers.ftn_bso.defaultNetwork; + } else { + routeAddress = dstAddr; + networkName = Config.scannerTossers.ftn_bso.defaultNetwork; + } + + const config = _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { + return routeAddress.isPatternMatch(nodeAddrWildcard); + }) || { + packetType : '2+', + encoding : Config.scannerTossers.ftn_bso.packetMsgEncoding, + }; + + return cb( + config ? null : Errors.DoesNotExist(`No configuration found for ${dstAddr.toString()}`), + config, routeAddress, networkName + ); + }; + + this.exportNetMailMessagesToUplinks = function(messagesOrMessageUuids, cb) { + // for each message/UUID, find where to send the thing + async.each(messagesOrMessageUuids, (msgOrUuid, nextMessageOrUuid) => { + + const exportOpts = {}; + const message = new Message(); + + async.series( + [ + function loadMessage(callback) { + if(_.isString(msgOrUuid)) { + message.load( { uuid : msgOrUuid }, err => { + return callback(err, message); + }); + } else { + return callback(null, msgOrUuid); + } + }, + function discoverUplink(callback) { + const dstAddr = new Address({ + zone : parseInt(message.meta.FtnProperty.ftn_dest_zone), + net : parseInt(message.meta.FtnProperty.ftn_dest_network), + node : parseInt(message.meta.FtnProperty.ftn_dest_node), + point : parseInt(message.meta.FtnProperty.ftn_dest_point) || null, // point is optional + }); + + return self.getAcceptableNetMailNetworkInfoFromAddress(dstAddr, (err, config, routeAddress, networkName) => { + if(err) { + return callback(err); + } + + + + exportOpts.nodeConfig = config; + exportOpts.destAddress = routeAddress; + exportOpts.fileCase = config.fileCase || 'lower'; + exportOpts.network = Config.messageNetworks.ftn.networks[networkName]; + exportOpts.networkName = networkName; + + if(!exportOpts.network) { + return callback(Errors.DoesNotExist(`No configuration found for network ${networkName}`)); + } + + return callback(null); + }); + }, + function createOutgoingDir(callback) { + // ensure outgoing NetMail directory exists + return fse.mkdirs(Config.scannerTossers.ftn_bso.paths.outboundNetMail, callback); + }, + function exportPacket(callback) { + return self.exportNetMailMessagePacket(message, exportOpts, callback); + }, + function moveToOutgoing(callback) { + const newExt = exportOpts.fileCase === 'lower' ? '.pkt' : '.PKT'; + const newPath = paths.join( + Config.scannerTossers.ftn_bso.paths.outboundNetMail, + `${paths.basename(exportOpts.pktFileName, paths.extname(exportOpts.pktFileName))}${newExt}` + ); + + return fse.move(exportOpts.pktFileName, newPath, callback); + }, + function storeStateFlags0Meta(callback) { + return message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), callback); + }, + function storeMsgIdMeta(callback) { + // Store meta as if we had imported this message -- for later reference + if(message.meta.FtnKludge.MSGID) { + return message.persistMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.MSGID, callback); + } + + return callback(null); + } + ], + err => { + if(err) { + Log.warn( { error :err.message }, 'Error exporting message' ); + } + return nextMessageOrUuid(null); + } + ); + }, err => { + return cb(err); + }); + }; + + this.exportEchoMailMessagesToUplinks = 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], @@ -715,14 +955,14 @@ function FTNMessageScanTossModule() { networkName : areaConfig.network, fileCase : self.moduleConfig.nodes[nodeConfigKey].fileCase || 'lower', }; - + if(_.isString(exportOpts.network.localAddress)) { - exportOpts.network.localAddress = Address.fromString(exportOpts.network.localAddress); + exportOpts.network.localAddress = Address.fromString(exportOpts.network.localAddress); } - - const outgoingDir = self.getOutgoingPacketDir(exportOpts.networkName, exportOpts.destAddress); + + const outgoingDir = self.getOutgoingEchoMailPacketDir(exportOpts.networkName, exportOpts.destAddress); const exportType = self.getExportType(exportOpts.nodeConfig); - + async.waterfall( [ function createOutgoingDir(callback) { @@ -746,17 +986,17 @@ function FTNMessageScanTossModule() { 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, + exportOpts.nodeConfig.archiveType, tempBundlePath, exportedFileNames, err => { callback(err, [ tempBundlePath ] ); } - ); + ); }); } else { callback(null, exportedFileNames); @@ -769,18 +1009,18 @@ function FTNMessageScanTossModule() { // // For a given temporary .pk_ file, we need to move it to the outoing // directory with the appropriate BSO style filename. - // + // const newExt = self.getOutgoingFlowFileExtension( exportOpts.destAddress, - 'mail', + 'mail', exportType, exportOpts.fileCase ); - + const newPath = paths.join( - outgoingDir, + outgoingDir, `${paths.basename(oldPath, ext)}${newExt}`); - + fse.move(oldPath, newPath, nextFile); } else { const newPath = paths.join(outgoingDir, paths.basename(oldPath)); @@ -789,25 +1029,25 @@ function FTNMessageScanTossModule() { Log.warn( { oldPath : oldPath, newPath : newPath, error : err.toString() }, 'Failed moving temporary bundle file!'); - + return nextFile(); } - + // // For bundles, we need to append to the appropriate flow file // const flowFilePath = self.getOutgoingFlowFileName( - outgoingDir, + outgoingDir, exportOpts.destAddress, 'ref', exportType, exportOpts.fileCase ); - + // directive of '^' = delete file after transfer self.flowFileAppendRefs(flowFilePath, [ newPath ], '^', err => { if(err) { - Log.warn( { path : flowFilePath }, 'Failed appending flow reference record!'); + Log.warn( { path : flowFilePath }, 'Failed appending flow reference record!'); } nextFile(); }); @@ -823,10 +1063,10 @@ function FTNMessageScanTossModule() { } nextUplink(); } - ); + ); }, cb); // complete }; - + this.setReplyToMsgIdFtnReplyKludge = function(message, cb) { // // Given a FTN REPLY kludge, set |message.replyToMsgId|, if possible, @@ -838,12 +1078,12 @@ function FTNMessageScanTossModule() { // nothing to do return cb(); } - + Message.getMessageIdsByMetaValue('FtnKludge', 'MSGID', message.meta.FtnKludge.REPLY, (err, msgIds) => { if(msgIds && msgIds.length > 0) { // expect a single match, but dupe checking is not perfect - warn otherwise if(1 === msgIds.length) { - message.replyToMsgId = msgIds[0]; + message.replyToMsgId = msgIds[0]; } else { Log.warn( { msgIds : msgIds, replyKludge : message.meta.FtnKludge.REPLY }, 'Found 2:n MSGIDs matching REPLY kludge!'); } @@ -851,14 +1091,14 @@ function FTNMessageScanTossModule() { cb(); }); }; - + this.importEchoMailToArea = function(localAreaTag, header, message, cb) { async.series( [ - function validateDestinationAddress(callback) { - const localNetworkPattern = `${message.meta.FtnProperty.ftn_dest_network}/${message.meta.FtnProperty.ftn_dest_node}`; + 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 checkForDupeMSGID(callback) { @@ -881,7 +1121,7 @@ function FTNMessageScanTossModule() { }, function basicSetup(callback) { message.areaTag = localAreaTag; - + // // If we *allow* dupes (disabled by default), then just generate // a random UUID. Otherwise, don't assign the UUID just yet. It will be @@ -891,8 +1131,8 @@ function FTNMessageScanTossModule() { // just generate a UUID & therefor always allow for dupes message.uuid = uuidV4(); } - - callback(null); + + callback(null); }, function setReplyToMessageId(callback) { self.setReplyToMsgIdFtnReplyKludge(message, () => { @@ -902,15 +1142,15 @@ function FTNMessageScanTossModule() { function persistImport(callback) { // mark as imported message.meta.System.state_flags0 = Message.StateFlags0.Imported.toString(); - + // save to disc message.persist(err => { - callback(err); + callback(err); }); } - ], + ], err => { - cb(err); + cb(err); } ); }; @@ -924,37 +1164,37 @@ function FTNMessageScanTossModule() { message.message += `${message.meta.FtnProperty.ftn_origin}\r\n`; } }; - + // - // Ref. implementations on import: + // 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 : false }; // needed so we can calc message UUID without these; we'll add later - + let importStats = { areaSuccess : {}, // areaTag->count areaFail : {}, // areaTag->count otherFail : 0, }; - + new ftnMailPacket.Packet(packetOpts).read(packetPath, (entryType, entryData, next) => { if('header' === entryType) { packetHeader = entryData; - + const localNetworkName = self.getNetworkNameByAddress(packetHeader.destAddress); if(!_.isString(localNetworkName)) { const addrString = new Address(packetHeader.destAddress).toString(); return next(new Error(`No local configuration for packet addressed to ${addrString}`)); } else { - - // :TODO: password needs validated - need to determine if it will use the same node config (which can have wildcards) or something else?! + + // :TODO: password needs validated - need to determine if it will use the same node config (which can have wildcards) or something else?! return next(null); } - + } else if('message' === entryType) { const message = entryData; const areaTag = message.meta.FtnProperty.ftn_area; @@ -972,37 +1212,37 @@ function FTNMessageScanTossModule() { 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 || '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, MSGID : msgId }, + { area : localAreaTag, subject : message.subject, uuid : message.uuid, MSGID : msgId }, 'Not importing non-unique message'); - + return next(null); } } else { // bump area success importStats.areaSuccess[localAreaTag] = (importStats.areaSuccess[localAreaTag] || 0) + 1; } - + return next(err); }); } else { // // No local area configured for this import // - // :TODO: Handle the "catch all" case, if configured + // :TODO: Handle the "catch all" case, if configured Log.warn( { areaTag : areaTag }, 'No local area configured for this packet file!'); - + // bump generic failure importStats.otherFail += 1; - + return next(null); } } else { @@ -1027,7 +1267,7 @@ function FTNMessageScanTossModule() { } else { Log.info(finalStats, 'Import complete'); } - + cb(err); }); }; @@ -1048,7 +1288,7 @@ function FTNMessageScanTossModule() { if(!_.isString(self.moduleConfig.paths.retain)) { return cb(null); } - + archivePath = paths.join(self.moduleConfig.paths.retain, `good-pkt-${ts}--${fn}`); } else if('good' !== status) { archivePath = paths.join(self.moduleConfig.paths.reject, `${status}-${type}--${ts}-${fn}`); @@ -1066,7 +1306,7 @@ function FTNMessageScanTossModule() { return cb(null); // never fatal }); }; - + this.importPacketFilesFromDirectory = function(importDir, password, cb) { async.waterfall( [ @@ -1081,12 +1321,12 @@ function FTNMessageScanTossModule() { function importPacketFiles(packetFiles, callback) { let rejects = []; async.eachSeries(packetFiles, (packetFile, nextFile) => { - self.importMessagesFromPacketFile(paths.join(importDir, packetFile), '', err => { + self.importMessagesFromPacketFile(paths.join(importDir, packetFile), '', err => { if(err) { - Log.debug( - { path : paths.join(importDir, packetFile), error : err.toString() }, + Log.debug( + { path : paths.join(importDir, packetFile), error : err.toString() }, 'Failed to import packet file'); - + rejects.push(packetFile); } nextFile(); @@ -1097,13 +1337,13 @@ function FTNMessageScanTossModule() { }); }, function handleProcessedFiles(packetFiles, rejects, callback) { - async.each(packetFiles, (packetFile, nextFile) => { + async.each(packetFiles, (packetFile, nextFile) => { // possibly archive, then remove original const fullPath = paths.join(importDir, packetFile); self.maybeArchiveImportFile( - fullPath, - 'pkt', - rejects.includes(packetFile) ? 'reject' : 'good', + fullPath, + 'pkt', + rejects.includes(packetFile) ? 'reject' : 'good', () => { fs.unlink(fullPath, () => { return nextFile(null); @@ -1120,7 +1360,7 @@ function FTNMessageScanTossModule() { } ); }; - + this.importFromDirectory = function(inboundType, importDir, cb) { async.waterfall( [ @@ -1138,7 +1378,7 @@ function FTNMessageScanTossModule() { const fext = paths.extname(f); return bundleRegExp.test(fext); }); - + async.map(files, (file, transform) => { const fullPath = paths.join(importDir, file); self.archUtil.detectType(fullPath, (err, archName) => { @@ -1151,48 +1391,48 @@ function FTNMessageScanTossModule() { }, function importBundles(bundleFiles, callback) { let rejects = []; - + async.each(bundleFiles, (bundleFile, nextFile) => { if(_.isUndefined(bundleFile.archName)) { - Log.warn( + Log.warn( { fileName : bundleFile.path }, 'Unknown bundle archive type'); - + rejects.push(bundleFile.path); - + return nextFile(); // unknown archive type } Log.debug( { bundleFile : bundleFile }, 'Processing bundle' ); - + self.archUtil.extractTo( bundleFile.path, self.importTempDir, bundleFile.archName, err => { - if(err) { + if(err) { Log.warn( { path : bundleFile.path, error : err.message }, 'Failed to extract bundle'); - + rejects.push(bundleFile.path); } - - nextFile(); + + 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) { @@ -1209,7 +1449,7 @@ function FTNMessageScanTossModule() { return nextFile(null); }); } - ); + ); }, err => { callback(err); }); @@ -1225,41 +1465,45 @@ function FTNMessageScanTossModule() { } ); }; - + this.createTempDirectories = function(cb) { temptmp.mkdir( { prefix : 'enigftnexport-' }, (err, tempDir) => { if(err) { return cb(err); } - + self.exportTempDir = tempDir; - + temptmp.mkdir( { prefix : '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; + this.exportingEnd = function(cb) { + this.exportRunning = false; + + if(cb) { + return cb(null); + } }; this.copyTicAttachment = function(src, dst, isUpdate, cb) { if(isUpdate) { - fse.copy(src, dst, err => { + fse.copy(src, dst, { overwrite : true }, err => { return cb(err, dst); }); } else { @@ -1274,8 +1518,6 @@ function FTNMessageScanTossModule() { }; this.processSingleTicFile = function(ticFileInfo, cb) { - const self = this; - Log.debug( { tic : ticFileInfo.path, file : ticFileInfo.getAsString('File') }, 'Processing TIC file'); async.waterfall( @@ -1389,8 +1631,8 @@ function FTNMessageScanTossModule() { // // We may have TIC auto-tagging for this node and/or specific (remote) area // - const hashTags = - localInfo.hashTags || + const hashTags = + localInfo.hashTags || _.get(Config.scannerTossers.ftn_bso.nodes, [ localInfo.node, 'tic', 'hashTags' ] ); // catch-all*/ if(hashTags) { @@ -1462,7 +1704,7 @@ function FTNMessageScanTossModule() { if(isUpdate) { // we need to *update* an existing record/file - localInfo.fileEntry.fileId = localInfo.existingFileId; + localInfo.fileEntry.fileId = localInfo.existingFileId; } const dst = paths.join(areaStorageDir, localInfo.fileEntry.fileName); @@ -1476,13 +1718,13 @@ function FTNMessageScanTossModule() { if(dst !== finalPath) { localInfo.fileEntry.fileName = paths.basename(finalPath); } - + localInfo.fileEntry.persist(isUpdate, err => { return callback(err, localInfo); }); - }); + }); }, - // :TODO: from here, we need to re-toss files if needed, before they are removed + // :TODO: from here, we need to re-toss files if needed, before they are removed function cleanupOldFile(localInfo, callback) { if(!localInfo.existingFileId) { return callback(null, localInfo); @@ -1490,7 +1732,7 @@ function FTNMessageScanTossModule() { const oldStorageDir = getAreaStorageDirectoryByTag(localInfo.oldStorageTag); const oldPath = paths.join(oldStorageDir, localInfo.oldFileName); - + fs.unlink(oldPath, err => { if(err) { Log.warn( { error : err.message, oldPath : oldPath }, 'Failed removing old physical file during TIC replacement'); @@ -1527,6 +1769,145 @@ function FTNMessageScanTossModule() { return cb(err); }); }; + + + this.performEchoMailExport = function(cb) { + // + // 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;` + ; + + 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.exportEchoMailMessagesToUplinks(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); + } + ], + () => { + return nextArea(); + } + ); + }, + err => { + return cb(err); + }); + }; + + this.performNetMailExport = function(cb) { + // + // Select all messages with a |message_id| > |lastScanId| in the private area + // that also *do not* have a local user ID meta value but *do* have a FTN dest + // network meta value. + // + // Just like EchoMail, we 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! + // + // :TODO: fill out the rest of the consts here + // :TODO: this statement is crazy ugly + const getNewUuidsSql = + `SELECT message_id, message_uuid + FROM message m + WHERE area_tag = '${Message.WellKnownAreaTags.Private}' 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' OR meta_name='local_to_user_id')) = 0 + AND + (SELECT COUNT(message_id) + FROM message_meta + WHERE message_id = m.message_id AND meta_category='FtnProperty' AND meta_name='ftn_dest_network') = 1 + ORDER BY message_id; + `; + + async.waterfall( + [ + function getLastScanId(callback) { + return self.getAreaLastScanId(Message.WellKnownAreaTags.Private, callback); + }, + function getNewUuids(lastScanId, callback) { + msgDb.all(getNewUuidsSql, [ lastScanId ], (err, rows) => { + if(err) { + return callback(err); + } + + if(0 === rows.length) { + return cb(null); // note |cb| -- early bail out! + } + + return callback(null, rows); + }); + }, + function exportMessages(rows, callback) { + const messageUuids = rows.map(r => r.message_uuid); + return self.exportNetMailMessagesToUplinks(messageUuids, callback); + } + ], + err => { + return cb(err); + } + ); + }; + + this.isNetMailMessage = function(message) { + return message.isPrivate() && + null === _.get(message.meta, 'System.LocalToUserID', null) && + null !== _.get(message.meta, 'FtnProperty.ftn_dest_network') + ; + }; } require('util').inherits(FTNMessageScanTossModule, MessageScanTossModule); @@ -1574,19 +1955,19 @@ FTNMessageScanTossModule.prototype.processTicFilesInDirectory = function(importD if(err) { // archive rejected TIC stuff (.TIC + attach) async.each( [ ticFileInfo.path, ticFileInfo.filePath ], (path, nextPath) => { - if(!path) { // possibly rejected due to "File" not existing/etc. + if(!path) { // possibly rejected due to "File" not existing/etc. return nextPath(null); } self.maybeArchiveImportFile( - path, - 'tic', + path, + 'tic', 'reject', () => { return nextPath(null); } ); - }, + }, () => { self.removeAssocTicFiles(ticFileInfo, () => { return nextTicInfo(null); @@ -1596,7 +1977,7 @@ FTNMessageScanTossModule.prototype.processTicFilesInDirectory = function(importD self.removeAssocTicFiles(ticFileInfo, () => { return nextTicInfo(null); }); - } + } }); }, err => { return callback(err); @@ -1611,59 +1992,59 @@ FTNMessageScanTossModule.prototype.processTicFilesInDirectory = function(importD 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) { Log.debug( - { - schedule : this.moduleConfig.schedule.export, + { + schedule : this.moduleConfig.schedule.export, schedOK : -1 === exportSchedule.sched.error, next : moment(later.schedule(exportSchedule.sched).next(1)).format('ddd, MMM Do, YYYY @ h:m:ss a'), immediate : exportSchedule.immediate ? true : false, }, 'Export schedule loaded' ); - + if(exportSchedule.sched) { this.exportTimer = later.setInterval( () => { if(this.exportingStart()) { 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) { Log.debug( @@ -1675,13 +2056,13 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { }, 'Import schedule loaded' ); - - if(importSchedule.sched) { + + if(importSchedule.sched) { this.importTimer = later.setInterval( () => { - tryImportNow('Performing scheduled message import/toss...'); + tryImportNow('Performing scheduled message import/toss...'); }, importSchedule.sched); } - + if(_.isString(importSchedule.watchFile)) { const watcher = sane( paths.dirname(importSchedule.watchFile), @@ -1711,22 +2092,22 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { } } } - + 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 // @@ -1737,9 +2118,9 @@ FTNMessageScanTossModule.prototype.shutdown = function(cb) { paths : paths, sessionId : temptmp.sessionId, }; - + Log.trace(fullStats, 'Temporary directories cleaned up'); - + FTNMessageScanTossModule.super_.prototype.shutdown.call(this, cb); }); @@ -1750,9 +2131,9 @@ FTNMessageScanTossModule.prototype.performImport = function(cb) { if(!this.hasValidConfiguration()) { return cb(new Error('Missing or invalid configuration')); } - + const self = this; - + async.each( [ 'inbound', 'secInbound' ], (inboundType, nextDir) => { self.importFromDirectory(inboundType, self.moduleConfig.paths[inboundType], () => { return nextDir(null); @@ -1768,77 +2149,18 @@ FTNMessageScanTossModule.prototype.performExport = function(cb) { if(!this.hasValidConfiguration()) { return cb(new Error('Missing or invalid configuration')); } - - // - // 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;`; - - let 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); - } - ], - () => { - return nextArea(); + + const self = this; + + async.eachSeries( [ 'EchoMail', 'NetMail' ], (type, nextType) => { + self[`perform${type}Export`]( err => { + if(err) { + Log.warn( { error : err.message, type : type }, 'Error(s) during export' ); } - ); - }, err => { - return cb(err); + return nextType(null); // try next, always + }); + }, () => { + return cb(null); }); }; @@ -1849,27 +2171,37 @@ FTNMessageScanTossModule.prototype.record = function(message) { if(true !== this.exportImmediate || !this.hasValidConfiguration()) { return; } - - if(message.isPrivate()) { - // :TODO: support NetMail + + const info = { uuid : message.uuid, subject : message.subject }; + + function exportLog(err) { + if(err) { + Log.warn(info, 'Failed exporting message'); + } else { + Log.info(info, 'Message exported'); + } + } + + if(this.isNetMailMessage(message)) { + Object.assign(info, { type : 'NetMail' } ); + + if(this.exportingStart()) { + this.exportNetMailMessagesToUplinks( [ message.uuid ], err => { + this.exportingEnd( () => exportLog(err) ); + }); + } } else if(message.areaTag) { + Object.assign(info, { type : 'EchoMail' } ); + 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(); + this.exportEchoMailMessagesToUplinks( [ message.uuid ], areaConfig, err => { + this.exportingEnd( () => exportLog(err) ); }); - } - } + } + } }; diff --git a/package.json b/package.json index 566a454d..678628f1 100644 --- a/package.json +++ b/package.json @@ -27,32 +27,32 @@ "buffers": "NuSkooler/node-buffers", "bunyan": "^1.8.12", "exiftool": "^0.0.3", - "fs-extra": "^4.0.1", + "fs-extra": "^5.0.0", + "glob": "^7.1.2", "graceful-fs": "^4.1.11", "hashids": "^1.1.1", "hjson": "^3.1.0", "iconv-lite": "^0.4.18", - "inquirer": "^3.2.3", + "inquirer": "^4.0.1", "later": "1.2.0", "lodash": "^4.17.4", "mime-types": "^2.1.17", "minimist": "1.2.x", - "moment": "^2.18.1", - "node-glob": "^1.2.0", - "nodemailer": "^4.1.0", + "moment": "^2.20.0", + "nodemailer": "^4.4.1", "ptyw.js": "NuSkooler/ptyw.js", "rlogin": "^1.0.0", "sane": "^2.2.0", "sanitize-filename": "^1.6.1", "sqlite3": "^3.1.9", - "sqlite3-trans" : "^1.2.0", + "sqlite3-trans": "^1.2.0", "ssh2": "^0.5.5", "temptmp": "^1.0.0", "uuid": "^3.1.0", "uuid-parse": "^1.0.0", - "ws": "^3.1.0", + "ws": "^3.3.3", "xxhash": "^0.2.4", - "yazl" : "^2.4.2" + "yazl": "^2.4.2" }, "devDependencies": {}, "engines": { From 11a19d899e99e4f46a1732b436a788000b11c13f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 31 Dec 2017 18:45:39 -0700 Subject: [PATCH 072/102] * Use per-network outbound for NetMail just like EchoMail * Use BSO style FLO file for NetMail * Some code cleanup --- core/config.js | 87 ++++++++++++++++----------------- core/scanner_tossers/ftn_bso.js | 25 ++++++++-- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/core/config.js b/core/config.js index 34be676e..f9f277a8 100644 --- a/core/config.js +++ b/core/config.js @@ -2,7 +2,6 @@ 'use strict'; // ENiGMA½ -const miscUtil = require('./misc_util.js'); // deps const fs = require('graceful-fs'); @@ -32,7 +31,7 @@ function hasMessageConferenceAndArea(config) { _.forEach(nonInternalConfs, confTag => { if(_.has(config.messageConferences[confTag], 'areas') && Object.keys(config.messageConferences[confTag].areas) > 0) - { + { result = true; return false; // stop iteration } @@ -67,12 +66,12 @@ function init(configPath, options, cb) { } return callback(null, configJson); - }); + }); }, function mergeWithDefaultConfig(configJson, callback) { const mergedConfig = _.mergeWith( - getDefaultConfig(), + getDefaultConfig(), configJson, (conf1, conf2) => { // Arrays should always concat if(_.isArray(conf1)) { @@ -147,15 +146,13 @@ function getDefaultConfig() { webMax : 255, requireActivation : false, // require SysOp activation? false = auto-activate - invalidUsernames : [], groups : [ 'users', 'sysops' ], // built in groups defaultGroups : [ 'users' ], // default groups new users belong to newUserNames : [ 'new', 'apply' ], // Names reserved for applying - // :TODO: Mystic uses TRASHCAN.DAT for this -- is there a reason to support something like that? - badUserNames : [ + badUserNames : [ 'sysop', 'admin', 'administrator', 'root', 'all', 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix' ], @@ -187,7 +184,7 @@ function getDefaultConfig() { menus : { cls : true, // Clear screen before each menu by default? - }, + }, paths : { config : paths.join(__dirname, './../config/'), @@ -202,11 +199,11 @@ function getDefaultConfig() { themes : paths.join(__dirname, './../art/themes/'), logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such db : paths.join(__dirname, './../db/'), - modsDb : paths.join(__dirname, './../db/mods/'), + modsDb : paths.join(__dirname, './../db/mods/'), dropFiles : paths.join(__dirname, './../dropfiles/'), // + "/node/ misc : paths.join(__dirname, './../misc/'), }, - + loginServers : { telnet : { port : 8888, @@ -219,7 +216,7 @@ function getDefaultConfig() { // // Private key in PEM format - // + // // Generating your PK: // > openssl genrsa -des3 -out ./config/ssh_private_key.pem 2048 // @@ -248,7 +245,7 @@ function getDefaultConfig() { resetPassword : { // // The following templates have these variables available to them: - // + // // * %BOARDNAME% : Name of BBS // * %USERNAME% : Username of whom to reset password // * %TOKEN% : Reset token @@ -263,10 +260,10 @@ function getDefaultConfig() { // resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'), }, - + http : { enabled : false, - port : 8080, + port : 8080, }, https : { enabled : false, @@ -283,10 +280,10 @@ function getDefaultConfig() { }, Exiftool : { cmd : 'exiftool', - args : [ + args : [ '-charset', 'utf8', '{filePath}', // exclude the following: - '--directory', '--filepermissions', '--exiftoolversion', '--filename', '--filesize', + '--directory', '--filepermissions', '--exiftoolversion', '--filename', '--filesize', '--filemodifydate', '--fileaccessdate', '--fileinodechangedate', '--createdate', '--modifydate', '--metadatadate', '--xmptoolkit' ] @@ -305,7 +302,7 @@ function getDefaultConfig() { // // // :TODO: text/x-ansi -> SAUCE extraction for .ans uploads - // :TODO: textual : bool -- if text, we can view. + // :TODO: textual : bool -- if text, we can view. // :TODO: asText : { cmd, args[] } -> viewable text // @@ -388,7 +385,7 @@ function getDefaultConfig() { sig : '526172211a0700', offset : 0, archiveHandler : 'Rar', - }, + }, 'application/gzip' : { desc : 'Gzip Archive', sig : '1f8b', @@ -400,28 +397,28 @@ function getDefaultConfig() { desc : 'BZip2 Archive', sig : '425a68', offset : 0, - archiveHandler : '7Zip', + archiveHandler : '7Zip', }, 'application/x-lzh-compressed' : { desc : 'LHArc Archive', sig : '2d6c68', offset : 2, - archiveHandler : 'Lha', + archiveHandler : 'Lha', }, 'application/x-7z-compressed' : { desc : '7-Zip Archive', sig : '377abcaf271c', offset : 0, - archiveHandler : '7Zip', + archiveHandler : '7Zip', } // :TODO: update archives::formats to fall here // * archive handler -> archiveHandler (consider archive if archiveHandler present) // * sig, offset, ... // * mime-db -> exts lookup - // * + // * }, - + archives : { archivers : { '7Zip' : { @@ -514,7 +511,7 @@ function getDefaultConfig() { list : { cmd : 'tar', args : [ '-tvf', '{archivePath}' ], - entryMatch : '^[drwx\\-]{10}\\s[A-Za-z0-9\\/]+\\s+([0-9]+)\\s[0-9]{4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s([^\\r\\n]+)$', + entryMatch : '^[drwx\\-]{10}\\s[A-Za-z0-9\\/]+\\s+([0-9]+)\\s[0-9]{4}\\-[0-9]{2}\\-[0-9]{2}\\s[0-9]{2}\\:[0-9]{2}\\s([^\\r\\n]+)$', }, extract : { cmd : 'tar', @@ -523,7 +520,7 @@ function getDefaultConfig() { } }, }, - + fileTransferProtocols : { // // See http://www.synchro.net/docs/sexyz.txt for information on SEXYZ @@ -539,7 +536,7 @@ function getDefaultConfig() { recvCmd : 'sexyz', recvArgs : [ '-telnet', '-8', 'rz', '{uploadDir}' ], recvArgsNonBatch : [ '-telnet', '-8', 'rz', '{fileName}' ], - } + } }, xmodemSexyz : { @@ -570,7 +567,7 @@ function getDefaultConfig() { name : 'ZModem 8k', type : 'external', sort : 2, - external : { + external : { sendCmd : 'sz', // Avail on Debian/Ubuntu based systems as the package "lrzsz" sendArgs : [ // :TODO: try -q @@ -581,11 +578,11 @@ function getDefaultConfig() { '--zmodem', '--binary', '--restricted', '--keep-uppercase', // dumps to CWD which is set to {uploadDir} ], // :TODO: can we not just use --escape ? - escapeTelnet : true, // set to true to escape Telnet codes such as IAC - } + escapeTelnet : true, // set to true to escape Telnet codes such as IAC + } } }, - + messageAreaDefaults : { // // The following can be override per-area as well @@ -594,11 +591,11 @@ function getDefaultConfig() { maxAgeDays : 0, // 0 = unlimited }, - messageConferences : { + messageConferences : { system_internal : { name : 'System Internal', desc : 'Built in conference for private messages, bulletins, etc.', - + areas : { private_mail : { name : 'Private Mail', @@ -612,7 +609,7 @@ function getDefaultConfig() { } } }, - + scannerTossers : { ftn_bso : { paths : { @@ -620,7 +617,7 @@ function getDefaultConfig() { inbound : paths.join(__dirname, './../mail/ftn_in/'), secInbound : paths.join(__dirname, './../mail/ftn_secin/'), reject : paths.join(__dirname, './../mail/reject/'), // bad pkt, bundles, TIC attachments that fail any check, etc. - outboundNetMail : paths.join(__dirname, './../mail/ftn_netmail_out/'), + //outboundNetMail : paths.join(__dirname, './../mail/ftn_netmail_out/'), // set 'retain' to a valid path to keep good pkt files }, @@ -644,7 +641,7 @@ function getDefaultConfig() { }, fileBase: { - // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|: + // areas with an explicit |storageDir| will be stored relative to |areaStoragePrefix|: areaStoragePrefix : paths.join(__dirname, './../file_base/'), maxDescFileByteSize : 471859, // ~1/4 MB @@ -654,12 +651,12 @@ function getDefaultConfig() { // These are NOT case sensitive // FILE_ID.DIZ - https://en.wikipedia.org/wiki/FILE_ID.DIZ // Some groups include a FILE_ID.ANS. We try to use that over FILE_ID.DIZ if available. - desc : [ + desc : [ '^[^/\]*FILE_ID\.ANS$', '^[^/\]*FILE_ID\.DIZ$', '^[^/\]*DESC\.SDI$', '^[^/\]*DESCRIPT\.ION$', '^[^/\]*FILE\.DES$', '^[^/\]*FILE\.SDI$', '^[^/\]*DISK\.ID$' ], // common README filename - https://en.wikipedia.org/wiki/README - descLong : [ + descLong : [ '^[^/\]*\.NFO$', '^[^/\]*README\.1ST$', '^[^/\]*README\.NOW$', '^[^/\]*README\.TXT$', '^[^/\]*READ\.ME$', '^[^/\]*README$', '^[^/\]*README\.md$' ], }, @@ -675,7 +672,7 @@ function getDefaultConfig() { '\\b[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[1789][0-9]))\\b', // mm-dd-yy, mm/dd/yy, ... //'\\b((?:[1-2][0-9][0-9]{2}))[\\-\\/\\.][0-3]?[0-9][\\-\\/\\.][0-3]?[0-9]|[0-3]?[0-9][\\-\\/\\.][0-3]?[0-9][\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', // yyyy-mm-dd, m/d/yyyy, mm-dd-yyyy, etc. //"\\b('[1789][0-9])\\b", // eslint-disable-line quotes - '\\b[0-3]?[0-9][\\-\\/\\.](?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)[\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', + '\\b[0-3]?[0-9][\\-\\/\\.](?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december)[\\-\\/\\.]((?:[0-9]{2})?[0-9]{2})\\b', '\\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|may|june|july|august|september|october|november|december),?\\s[0-9]+(?:st|nd|rd|th)?,?\\s((?:[0-9]{2})?[0-9]{2})\\b', // November 29th, 1997 '\\(((?:19|20)[0-9]{2})\\)', // (19xx) or (20xx) -- with parens -- do this before 19xx 20xx such that this has priority '\\b((?:19|20)[0-9]{2})\\b', // simple 19xx or 20xx with word boundaries @@ -692,7 +689,7 @@ function getDefaultConfig() { // // File area storage location tag/value pairs. // Non-absolute paths are relative to |areaStoragePrefix|. - // + // storageTags : { sys_msg_attach : 'sys_msg_attach', sys_temp_download : 'sys_temp_download', @@ -712,20 +709,20 @@ function getDefaultConfig() { } } }, - + eventScheduler : { - + events : { trimMessageAreas : { // may optionally use [or ]@watch:/path/to/file schedule : 'every 24 hours', - + // action: // - @method:path/to/module.js:theMethodName // (path is relative to engima base dir) // - // - @execute:/path/to/something/executable.sh - // + // - @execute:/path/to/something/executable.sh + // action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', }, @@ -739,7 +736,7 @@ function getDefaultConfig() { action : '@method:core/web_password_reset.js:performMaintenanceTask', args : [ '24 hours' ] // items older than this will be removed } - } + } }, misc : { diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 996dd3ef..ac6ead52 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -893,6 +893,8 @@ function FTNMessageScanTossModule() { exportOpts.fileCase = config.fileCase || 'lower'; exportOpts.network = Config.messageNetworks.ftn.networks[networkName]; exportOpts.networkName = networkName; + exportOpts.outgoingDir = self.getOutgoingEchoMailPacketDir(exportOpts.networkName, exportOpts.destAddress); + exportOpts.exportType = self.getExportType(config); if(!exportOpts.network) { return callback(Errors.DoesNotExist(`No configuration found for network ${networkName}`)); @@ -902,20 +904,33 @@ function FTNMessageScanTossModule() { }); }, function createOutgoingDir(callback) { - // ensure outgoing NetMail directory exists - return fse.mkdirs(Config.scannerTossers.ftn_bso.paths.outboundNetMail, callback); + // ensure outgoing NetMail directory exists + return fse.mkdirs(exportOpts.outgoingDir, callback); + //return fse.mkdirs(Config.scannerTossers.ftn_bso.paths.outboundNetMail, callback); }, function exportPacket(callback) { return self.exportNetMailMessagePacket(message, exportOpts, callback); }, function moveToOutgoing(callback) { const newExt = exportOpts.fileCase === 'lower' ? '.pkt' : '.PKT'; - const newPath = paths.join( - Config.scannerTossers.ftn_bso.paths.outboundNetMail, + exportOpts.exportedToPath = paths.join( + exportOpts.outgoingDir, + //Config.scannerTossers.ftn_bso.paths.outboundNetMail, `${paths.basename(exportOpts.pktFileName, paths.extname(exportOpts.pktFileName))}${newExt}` ); - return fse.move(exportOpts.pktFileName, newPath, callback); + return fse.move(exportOpts.pktFileName, exportOpts.exportedToPath, callback); + }, + function prepareFloFile(callback) { + const flowFilePath = self.getOutgoingFlowFileName( + exportOpts.outgoingDir, + exportOpts.destAddress, + 'ref', + exportOpts.exportType, + exportOpts.fileCase + ); + + return self.flowFileAppendRefs(flowFilePath, [ exportOpts.exportedToPath ], '^', callback); }, function storeStateFlags0Meta(callback) { return message.persistMetaValue('System', 'state_flags0', Message.StateFlags0.Exported.toString(), callback); From 6d929237d22e7e9d90bc1bf7f202400d6bba746f Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 1 Jan 2018 13:32:55 -0700 Subject: [PATCH 073/102] * Handle import of NetMail messages * Add NetMail aliases support (name -> localname lookup, e.g. "root" -> "NuSkooler" * Minor code changes / cleanup --- core/fse.js | 6 +- core/message_area.js | 4 +- core/scanner_tossers/ftn_bso.js | 155 ++++++++++++++++++++------------ 3 files changed, 102 insertions(+), 63 deletions(-) diff --git a/core/fse.js b/core/fse.js index 2ccc6951..d3262433 100644 --- a/core/fse.js +++ b/core/fse.js @@ -264,12 +264,12 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul isEditMode() { return 'edit' === this.editorMode; } - + isViewMode() { return 'view' === this.editorMode; } - isLocalEmail() { + isPrivateMail() { return Message.WellKnownAreaTags.Private === this.messageAreaTag; } @@ -411,7 +411,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul return callback(null); }, function populateLocalUserInfo(callback) { - if(self.isLocalEmail()) { + if(self.isPrivateMail()) { self.message.setLocalFromUserId(self.client.user.userId); if(self.toUserId > 0) { diff --git a/core/message_area.js b/core/message_area.js index 484d22ec..aa893073 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -347,13 +347,13 @@ function getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, what) 'COUNT() AS count' : 'message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count'; - let sql = + let sql = `SELECT ${selectWhat} FROM message WHERE area_tag = "${areaTag}" AND message_id > ${lastMessageId}`; if(Message.isPrivateAreaTag(areaTag)) { - sql += + sql += ` AND message_id in ( SELECT message_id FROM message_meta diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index ac6ead52..354755fd 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -20,6 +20,7 @@ const getDescFromFileName = require('../file_base_area.js').getDescFromFileNa const copyFileWithCollisionHandling = require('../file_util.js').copyFileWithCollisionHandling; const getAreaStorageDirectoryByTag = require('../file_base_area.js').getAreaStorageDirectoryByTag; const isValidStorageTag = require('../file_base_area.js').isValidStorageTag; +const User = require('../user.js'); // deps const moment = require('moment'); @@ -310,22 +311,17 @@ function FTNMessageScanTossModule() { message.meta.FtnProperty = message.meta.FtnProperty || {}; message.meta.FtnKludge = message.meta.FtnKludge || {}; - // :TODO: Only set DEST information for EchoMail - NetMail should already have it in message 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; // tear line and origin can both go in EchoMail & NetMail message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(options.network.localAddress); - // :TODO: Need an explicit isNetMail() check or more generally - let ftnAttribute = - ftnMailPacket.Packet.Attribute.Local; // message from our system + let ftnAttribute = ftnMailPacket.Packet.Attribute.Local; // message from our system - if(message.isPrivate()) { + if(self.isNetMailMessage(message)) { // These should be set for Private/NetMail already assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_node))); assert(_.isNumber(parseInt(message.meta.FtnProperty.ftn_dest_network))); @@ -438,7 +434,6 @@ function FTNMessageScanTossModule() { options.encoding = encoding; // save for later message.meta.FtnKludge.CHRS = ftnUtil.getCharacterSetIdentifierByEncoding(encoding); - // :TODO: FLAGS kludge? }; this.setReplyKludgeFromReplyToMsgId = function(message, cb) { @@ -565,7 +560,6 @@ function FTNMessageScanTossModule() { async.series( [ function generalPrep(callback) { - // :TODO: Double check Via spec -- seems like it may be wrong self.prepareMessage(message, exportOpts); return self.setReplyKludgeFromReplyToMsgId(message, callback); @@ -886,8 +880,6 @@ function FTNMessageScanTossModule() { return callback(err); } - - exportOpts.nodeConfig = config; exportOpts.destAddress = routeAddress; exportOpts.fileCase = config.fileCase || 'lower'; @@ -904,9 +896,8 @@ function FTNMessageScanTossModule() { }); }, function createOutgoingDir(callback) { - // ensure outgoing NetMail directory exists + // ensure outgoing NetMail directory exists return fse.mkdirs(exportOpts.outgoingDir, callback); - //return fse.mkdirs(Config.scannerTossers.ftn_bso.paths.outboundNetMail, callback); }, function exportPacket(callback) { return self.exportNetMailMessagePacket(message, exportOpts, callback); @@ -915,7 +906,6 @@ function FTNMessageScanTossModule() { const newExt = exportOpts.fileCase === 'lower' ? '.pkt' : '.PKT'; exportOpts.exportedToPath = paths.join( exportOpts.outgoingDir, - //Config.scannerTossers.ftn_bso.paths.outboundNetMail, `${paths.basename(exportOpts.pktFileName, paths.extname(exportOpts.pktFileName))}${newExt}` ); @@ -1107,14 +1097,29 @@ function FTNMessageScanTossModule() { }); }; - this.importEchoMailToArea = function(localAreaTag, header, message, cb) { + this.getLocalUserNameFromAlias = function(lookup) { + lookup = lookup.toLowerCase(); + + const aliases = _.get(Config, 'messageNetworks.ftn.netMail.aliases'); + if(!aliases) { + return lookup; // keep orig + } + + const alias = _.find(aliases, (localName, alias) => { + return alias.toLowerCase() === lookup; + }); + + return alias || lookup; + }; + + this.importMailToArea = function(config, 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')); + return callback(_.isString(localNetworkName) ? null : new Error('Packet destination is not us')); }, function checkForDupeMSGID(callback) { // @@ -1135,23 +1140,49 @@ function FTNMessageScanTossModule() { }); }, function basicSetup(callback) { - message.areaTag = localAreaTag; + message.areaTag = config.localAreaTag; // // 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) { + if(true === _.get(Config, [ 'messageNetworks', 'ftn', 'areas', config.localAreaTag, 'allowDupes' ], false)) { // just generate a UUID & therefor always allow for dupes message.uuid = uuidV4(); } - callback(null); + return callback(null); }, function setReplyToMessageId(callback) { self.setReplyToMsgIdFtnReplyKludge(message, () => { - callback(null); + return callback(null); + }); + }, + function setupPrivateMessage(callback) { + // + // If this is a private message (e.g. NetMail) we set the local user ID + // + if(Message.WellKnownAreaTags.Private !== config.localAreaTag) { + return callback(null); + } + + const lookupName = self.getLocalUserNameFromAlias(message.toUserName); + + // :TODO: take into account aliasing, e.g. "root" -> SysOp + User.getUserIdAndName(lookupName, (err, localToUserId, localUserName) => { + if(err) { + return callback(Errors.DoesNotExist(`Could not get local user ID for "${message.toUserName}": ${err.message}`)); + } + + // we do this after such that error cases can be preseved above + if(lookupName !== message.toUserName) { + message.toUserName = localUserName; + } + + // set the meta information - used elsehwere for retrieval + message.meta.System[Message.SystemMetaNames.LocalToUserID] = localToUserId; + return callback(null); }); }, function persistImport(callback) { @@ -1160,7 +1191,7 @@ function FTNMessageScanTossModule() { // save to disc message.persist(err => { - callback(err); + return callback(err); }); } ], @@ -1214,45 +1245,15 @@ function FTNMessageScanTossModule() { const message = entryData; const areaTag = message.meta.FtnProperty.ftn_area; + let localAreaTag; if(areaTag) { - // - // EchoMail - // - const localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); - if(localAreaTag) { - message.uuid = Message.createMessageUUID( - localAreaTag, - message.modTimestamp, - message.subject, - message.message); + localAreaTag = self.getLocalAreaTagByFtnAreaTag(areaTag); - 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 || '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, MSGID : msgId }, - 'Not importing non-unique message'); - - return next(null); - } - } else { - // bump area success - importStats.areaSuccess[localAreaTag] = (importStats.areaSuccess[localAreaTag] || 0) + 1; - } - - return next(err); - }); - } else { + if(!localAreaTag) { // // No local area configured for this import // - // :TODO: Handle the "catch all" case, if configured + // :TODO: Handle the "catch all" area bucket case if configured Log.warn( { areaTag : areaTag }, 'No local area configured for this packet file!'); // bump generic failure @@ -1262,11 +1263,49 @@ function FTNMessageScanTossModule() { } } else { // - // NetMail + // No area tag: If marked private in attributes, this is a NetMail // - Log.warn('NetMail import not yet implemented!'); - return next(null); + if(message.meta.FtnProperty.ftn_attr_flags & ftnMailPacket.Packet.Attribute.Private) { + localAreaTag = Message.WellKnownAreaTags.Private; + } else { + Log.warn('Non-private message without area tag'); + importStats.otherFail += 1; + return next(null); + } } + + message.uuid = Message.createMessageUUID( + localAreaTag, + message.modTimestamp, + message.subject, + message.message); + + self.appendTearAndOrigin(message); + + const importConfig = { + localAreaTag : localAreaTag, + }; + + self.importMailToArea(importConfig, packetHeader, message, err => { + if(err) { + // bump area fail stats + importStats.areaFail[localAreaTag] = (importStats.areaFail[localAreaTag] || 0) + 1; + + 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, MSGID : msgId }, + 'Not importing non-unique message'); + + return next(null); + } + } else { + // bump area success + importStats.areaSuccess[localAreaTag] = (importStats.areaSuccess[localAreaTag] || 0) + 1; + } + + return next(err); + }); } }, err => { // From bbd70f2fea294f4de9102a86794c2e5e6a60e2a4 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 1 Jan 2018 15:13:56 -0700 Subject: [PATCH 074/102] Minor log changes --- core/oputil/oputil_message_base.js | 10 ++++------ core/scanner_tossers/ftn_bso.js | 8 ++++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js index 77a63e9f..ac1d42fc 100644 --- a/core/oputil/oputil_message_base.js +++ b/core/oputil/oputil_message_base.js @@ -109,13 +109,11 @@ function areaFix() { return callback(null, message); }, function persistMessage(message, callback) { - // - // :TODO: - // - Persist message in private outgoing (sysop out box) - // - Make necessary changes such that the message is exported properly - // - console.log(message); + // :TODO: Persist message in private outgoing (sysop out box) (TBD: implementation) message.persist(err => { + if(!err) { + console.log('AreaFix message persisted and will be exported as per configuration'); + } return callback(err); }); } diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 354755fd..e8124f1a 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -2051,11 +2051,11 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { let self = this; - function tryImportNow(reasonDesc) { + function tryImportNow(reasonDesc, extraInfo) { if(!importing) { importing = true; - Log.info( { module : exports.moduleInfo.name }, reasonDesc); + Log.info( Object.assign({ module : exports.moduleInfo.name }, extraInfo), reasonDesc); self.performImport( () => { importing = false; @@ -2129,7 +2129,7 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { watcher.on(event, (fileName, fileRoot) => { const eventPath = paths.join(fileRoot, fileName); if(paths.join(fileRoot, fileName) === importSchedule.watchFile) { - tryImportNow(`Performing import/toss due to @watch: ${eventPath} (${event})`); + tryImportNow('Performing import/toss due to @watch', { eventPath, event } ); } }); }); @@ -2140,7 +2140,7 @@ FTNMessageScanTossModule.prototype.startup = function(cb) { // fse.exists(importSchedule.watchFile, exists => { if(exists) { - tryImportNow(`Performing import/toss due to @watch: ${importSchedule.watchFile} (initial exists)`); + tryImportNow('Performing import/toss due to @watch', { eventPath : importSchedule.watchFile, event : 'initial exists' } ); } }); } From e7109b0f0c61f9f09915f2cb7f3b728342127ab8 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 1 Jan 2018 17:50:27 -0700 Subject: [PATCH 075/102] Minor fix --- core/scanner_tossers/ftn_bso.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index e8124f1a..35cf6316 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1959,7 +1959,7 @@ function FTNMessageScanTossModule() { this.isNetMailMessage = function(message) { return message.isPrivate() && null === _.get(message.meta, 'System.LocalToUserID', null) && - null !== _.get(message.meta, 'FtnProperty.ftn_dest_network') + null !== _.get(message.meta, 'FtnProperty.ftn_dest_network', null) ; }; } From 84a1f70fc26ecd1be1275971a82773250f23638e Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 1 Jan 2018 18:10:38 -0700 Subject: [PATCH 076/102] * Add some user lookup functionality * Fix INTL to/from order * Remove VIA kludge when initially creating a NetMail message --- core/scanner_tossers/ftn_bso.js | 8 ++++--- core/user.js | 38 ++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 35cf6316..a72c45ff 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -332,17 +332,20 @@ function FTNMessageScanTossModule() { // NetMail messages need a FRL-1005.001 "Via" line // http://ftsc.org/docs/frl-1005.001 // + // :TODO: We need to do this when FORWARDING NetMail + /* 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)); + */ // // We need to set INTL, and possibly FMPT and/or TOPT // See http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac // - message.meta.FtnKludge.INTL = ftnUtil.getIntl(options.network.localAddress, options.destAddress); + message.meta.FtnKludge.INTL = ftnUtil.getIntl(options.destAddress, options.network.localAddress); if(_.isNumber(options.network.localAddress.point) && options.network.localAddress.point > 0) { message.meta.FtnKludge.FMPT = options.network.localAddress.point; @@ -1169,8 +1172,7 @@ function FTNMessageScanTossModule() { const lookupName = self.getLocalUserNameFromAlias(message.toUserName); - // :TODO: take into account aliasing, e.g. "root" -> SysOp - User.getUserIdAndName(lookupName, (err, localToUserId, localUserName) => { + User.getUserIdAndNameByLookup(lookupName, (err, localToUserId, localUserName) => { if(err) { return callback(Errors.DoesNotExist(`Could not get local user ID for "${message.toUserName}": ${err.message}`)); } diff --git a/core/user.js b/core/user.js index fb125f6b..db9245f5 100644 --- a/core/user.js +++ b/core/user.js @@ -414,12 +414,48 @@ module.exports = class User { if(row) { return cb(null, row.id, row.user_name); } - + return cb(Errors.DoesNotExist('No matching username')); } ); } + static getUserIdAndNameByRealName(realName, cb) { + userDb.get( + `SELECT id, user_name + FROM user + WHERE id = ( + SELECT user_id + FROM user_property + WHERE prop_name='real_name' AND prop_value=? + );`, + [ realName ], + (err, row) => { + if(err) { + return cb(err); + } + + if(row) { + return cb(null, row.id, row.user_name); + } + + return cb(Errors.DoesNotExist('No matching real name')); + } + ); + } + + static getUserIdAndNameByLookup(lookup, cb) { + User.getUserIdAndName(lookup, (err, userId, userName) => { + if(err) { + User.getUserIdAndNameByRealName(lookup, (err, userId, userName) => { + return cb(err, userId, userName); + }); + } else { + return cb(null, userId, userName); + } + }); + } + static getUserName(userId, cb) { userDb.get( `SELECT user_name From b97f96ce184434eac0e9d1c139a641cef7ee2336 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Mon, 1 Jan 2018 18:43:05 -0700 Subject: [PATCH 077/102] * Fix Via parsing * Use LIKE for real name lookup --- core/ftn_mail_packet.js | 12 +++++++----- core/user.js | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index 137a2583..c94eb425 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -397,10 +397,10 @@ function Packet(options) { // We have to special case INTL/TOPT/FMPT as they don't contain // a ':' name/value separator like the rest of the kludge lines... because stupdity. // - let key = line.substr(0, 4); + let key = line.substr(0, 4).trim(); let value; - if( ['INTL', 'TOPT', 'FMPT' ].includes(key)) { - value = line.substr(4).trim(); + if( ['INTL', 'TOPT', 'FMPT', 'Via' ].includes(key)) { + value = line.substr(key.length).trim(); } else { const sepIndex = line.indexOf(':'); key = line.substr(0, sepIndex).toUpperCase(); @@ -709,13 +709,14 @@ function Packet(options) { case 'PATH' : break; // skip & save for last + case 'Via' : case 'FMPT' : case 'TOPT' : - case 'INTL' : + case 'INTL' : msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); // no sepChar break; - default : + default : msgBody += getAppendMeta(`\x01${k}`, message.meta.FtnKludge[k]); break; } @@ -856,6 +857,7 @@ function Packet(options) { switch(k) { case 'PATH' : break; // skip & save for last + case 'Via' : case 'FMPT' : case 'TOPT' : case 'INTL' : appendMeta(`\x01${k}`, message.meta.FtnKludge[k], ''); break; // no sepChar diff --git a/core/user.js b/core/user.js index db9245f5..09d26163 100644 --- a/core/user.js +++ b/core/user.js @@ -427,7 +427,7 @@ module.exports = class User { WHERE id = ( SELECT user_id FROM user_property - WHERE prop_name='real_name' AND prop_value=? + WHERE prop_name='real_name' AND prop_value LIKE ? );`, [ realName ], (err, row) => { From f967ce1ce683082994c0e47be321a5183a51f1bd Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 5 Jan 2018 22:02:36 -0700 Subject: [PATCH 078/102] * Fix String vs Address when creating (NetMail) packets causing orig address info to not be recorded correctly --- core/ftn_mail_packet.js | 44 ++++++++++++++++++++++----------- core/ftn_util.js | 2 +- core/scanner_tossers/ftn_bso.js | 21 ++++++++-------- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/core/ftn_mail_packet.js b/core/ftn_mail_packet.js index c94eb425..d84b4a69 100644 --- a/core/ftn_mail_packet.js +++ b/core/ftn_mail_packet.js @@ -19,15 +19,6 @@ const moment = require('moment'); exports.Packet = 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 "*" ? - -*/ - const FTN_PACKET_HEADER_SIZE = 58; // fixed header size const FTN_PACKET_HEADER_TYPE = 2; const FTN_PACKET_MESSAGE_TYPE = 2; @@ -63,7 +54,7 @@ class PacketHeader { this.capWord = 0x0001; this.capWordValidate = ((this.capWord & 0xff) << 8) | ((this.capWord >> 8) & 0xff); // swap - + this.prodCodeHi = 0xfe; // see above this.prodRevHi = 0; } @@ -358,9 +349,9 @@ function Packet(options) { buffer.writeUInt16LE(packetHeader.origPoint, 50); buffer.writeUInt16LE(packetHeader.destPoint, 52); buffer.writeUInt32LE(packetHeader.prodData, 54); - + ws.write(buffer); - + return buffer.length; }; @@ -646,9 +637,29 @@ function Packet(options) { }); }); }; - + + this.sanatizeFtnProperties = function(message) { + [ + Message.FtnPropertyNames.FtnOrigNode, + Message.FtnPropertyNames.FtnDestNode, + Message.FtnPropertyNames.FtnOrigNetwork, + Message.FtnPropertyNames.FtnDestNetwork, + Message.FtnPropertyNames.FtnAttrFlags, + Message.FtnPropertyNames.FtnCost, + Message.FtnPropertyNames.FtnOrigZone, + Message.FtnPropertyNames.FtnDestZone, + Message.FtnPropertyNames.FtnOrigPoint, + Message.FtnPropertyNames.FtnDestPoint, + Message.FtnPropertyNames.FtnAttribute, + ].forEach( propName => { + if(message.meta.FtnProperty[propName]) { + message.meta.FtnProperty[propName] = parseInt(message.meta.FtnProperty[propName]) || 0; + } + }); + }; + this.getMessageEntryBuffer = function(message, options, cb) { - + function getAppendMeta(k, m, sepChar=':') { let append = ''; if(m) { @@ -667,7 +678,10 @@ function Packet(options) { [ function prepareHeaderAndKludges(callback) { const basicHeader = new Buffer(34); - + + // ensure address FtnProperties are numbers + self.sanatizeFtnProperties(message); + 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); diff --git a/core/ftn_util.js b/core/ftn_util.js index 68ddf343..a02d8e1c 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -189,7 +189,7 @@ function getQuotePrefix(name) { // function getOrigin(address) { const origin = _.has(Config, 'messageNetworks.originLine') ? - Config.messageNetworks.originLine : + Config.messageNetworks.originLine : Config.general.boardName; const addrStr = new Address(address).toString('5D'); diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index a72c45ff..f6072ff3 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -308,16 +308,18 @@ function FTNMessageScanTossModule() { // // Set various FTN kludges/etc. // + const localAddress = new Address(options.network.localAddress); // ensure we have an Address obj not a string version + 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_orig_network = options.network.localAddress.net; + message.meta.FtnProperty.ftn_orig_node = localAddress.node; + message.meta.FtnProperty.ftn_orig_network = localAddress.net; message.meta.FtnProperty.ftn_cost = 0; // tear line and origin can both go in EchoMail & NetMail message.meta.FtnProperty.ftn_tear_line = ftnUtil.getTearLine(); - message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(options.network.localAddress); + message.meta.FtnProperty.ftn_origin = ftnUtil.getOrigin(localAddress); let ftnAttribute = ftnMailPacket.Packet.Attribute.Local; // message from our system @@ -345,10 +347,10 @@ function FTNMessageScanTossModule() { // We need to set INTL, and possibly FMPT and/or TOPT // See http://retro.fidoweb.ru/docs/index=ftsc&doc=FTS-4001&enc=mac // - message.meta.FtnKludge.INTL = ftnUtil.getIntl(options.destAddress, options.network.localAddress); + message.meta.FtnKludge.INTL = ftnUtil.getIntl(options.destAddress, localAddress); - if(_.isNumber(options.network.localAddress.point) && options.network.localAddress.point > 0) { - message.meta.FtnKludge.FMPT = options.network.localAddress.point; + if(_.isNumber(localAddress.point) && localAddress.point > 0) { + message.meta.FtnKludge.FMPT = localAddress.point; } if(_.get(message, 'meta.FtnProperty.ftn_dest_point', 0) > 0) { @@ -378,15 +380,14 @@ function FTNMessageScanTossModule() { // 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); + [ `${localAddress.net}/${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.FtnKludge.PATH = ftnUtil.getUpdatedPathEntries(message.meta.FtnKludge.PATH, localAddress); } message.meta.FtnProperty.ftn_attr_flags = ftnAttribute; @@ -398,7 +399,7 @@ function FTNMessageScanTossModule() { // export that failed to finish // if(!message.meta.FtnKludge.MSGID) { - message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier(message, options.network.localAddress); + message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier(message, localAddress); } message.meta.FtnKludge.TZUTC = ftnUtil.getUTCTimeZoneOffset(); From ab12fb5d79c8356823ea46396127ea75abf9ee6b Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 5 Jan 2018 22:03:33 -0700 Subject: [PATCH 079/102] Lookup username and real name in various scenarios --- core/fse.js | 4 ++-- core/message.js | 2 +- core/oputil/oputil_message_base.js | 2 +- core/system_view_validate.js | 32 +++++++++++++++++++++--------- main.js | 2 +- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/core/fse.js b/core/fse.js index d3262433..4a6c8eb1 100644 --- a/core/fse.js +++ b/core/fse.js @@ -413,13 +413,13 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul function populateLocalUserInfo(callback) { if(self.isPrivateMail()) { self.message.setLocalFromUserId(self.client.user.userId); - + if(self.toUserId > 0) { self.message.setLocalToUserId(self.toUserId); callback(null); } else { // we need to look it up - User.getUserIdAndName(self.message.toUserName, function userInfo(err, toUserId) { + User.getUserIdAndNameByLookup(self.message.toUserName, function userInfo(err, toUserId) { if(err) { callback(err); } else { diff --git a/core/message.js b/core/message.js index b5b1e541..7fb57d20 100644 --- a/core/message.js +++ b/core/message.js @@ -112,7 +112,7 @@ Message.FtnPropertyNames = { 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 diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js index ac1d42fc..04d26435 100644 --- a/core/oputil/oputil_message_base.js +++ b/core/oputil/oputil_message_base.js @@ -55,7 +55,7 @@ function areaFix() { const User = require('../user.js'); if(argv.from) { - User.getUserIdAndName(argv.from, (err, userId, fromName) => { + User.getUserIdAndNameByLookup(argv.from, (err, userId, fromName) => { if(err) { return callback(null, ftnAddr, argv.from, 0); } diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 63c7b2bc..07bbcab2 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -9,13 +9,14 @@ const Log = require('./logger.js').log; // deps const fs = require('graceful-fs'); -exports.validateNonEmpty = validateNonEmpty; -exports.validateMessageSubject = validateMessageSubject; -exports.validateUserNameAvail = validateUserNameAvail; -exports.validateUserNameExists = validateUserNameExists; -exports.validateEmailAvail = validateEmailAvail; -exports.validateBirthdate = validateBirthdate; -exports.validatePasswordSpec = validatePasswordSpec; +exports.validateNonEmpty = validateNonEmpty; +exports.validateMessageSubject = validateMessageSubject; +exports.validateUserNameAvail = validateUserNameAvail; +exports.validateUserNameExists = validateUserNameExists; +exports.validateUserNameOrRealNameExists = validateUserNameOrRealNameExists; +exports.validateEmailAvail = validateEmailAvail; +exports.validateBirthdate = validateBirthdate; +exports.validatePasswordSpec = validatePasswordSpec; function validateNonEmpty(data, cb) { return cb(data && data.length > 0 ? null : new Error('Field cannot be empty')); @@ -42,11 +43,12 @@ function validateUserNameAvail(data, cb) { } else if(/^[0-9]+$/.test(data)) { return cb(new Error('Username cannot be a number')); } else { - User.getUserIdAndName(data, function userIdAndName(err) { + // a new user name cannot be an existing user name or an existing real name + User.getUserIdAndNameByLookup(data, function userIdAndName(err) { if(!err) { // err is null if we succeeded -- meaning this user exists already return cb(new Error('Username unavailable')); } - + return cb(null); }); } @@ -65,6 +67,18 @@ function validateUserNameExists(data, cb) { }); } +function validateUserNameOrRealNameExists(data, cb) { + const invalidUserNameError = new Error('Invalid username'); + + if(0 === data.length) { + return cb(invalidUserNameError); + } + + User.getUserIdAndNameByLookup(data, err => { + return cb(err ? invalidUserNameError : null); + }); +} + function validateEmailAvail(data, cb) { // // This particular method allows empty data - e.g. no email entered diff --git a/main.js b/main.js index 0bc7bee9..53a320f6 100755 --- a/main.js +++ b/main.js @@ -7,6 +7,6 @@ ENiGMA½ entry point If this file does not run directly, ensure it's executable: - > chmod u+x main.js + > chmod u+x main.js */ require('./core/bbs.js').main(); \ No newline at end of file From 99244aa2e4e7946b0cab6344493449c0d322273c Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 6 Jan 2018 13:24:35 -0700 Subject: [PATCH 080/102] * Use Zone:Net/* for lookup before defualt local address when setting 'from' for NetMail --- core/oputil/oputil_message_base.js | 2 +- core/scanner_tossers/ftn_bso.js | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js index 04d26435..1312f2a4 100644 --- a/core/oputil/oputil_message_base.js +++ b/core/oputil/oputil_message_base.js @@ -112,7 +112,7 @@ function areaFix() { // :TODO: Persist message in private outgoing (sysop out box) (TBD: implementation) message.persist(err => { if(!err) { - console.log('AreaFix message persisted and will be exported as per configuration'); + console.log('AreaFix message persisted and will be exported at next scheduled scan'); } return callback(err); }); diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index f6072ff3..123c0b7a 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -822,8 +822,9 @@ function FTNMessageScanTossModule() { // - Where we send may not be where dstAddress is (it's routed!); use network found in route // for local address // 2) Direct to nodes: scannerTossers.ftn_bso.nodes{} -> config - // - Where we send is direct to dstAddr; use scannerTossers.ftn_bso.defaultNetwork to - // for local address + // - Where we send is direct to dstAddr; + // - Attempt to match address in messageNetworks.ftn.networks{}, else + // use scannerTossers.ftn_bso.defaultNetwork to for local address // 3) Nodelist DB lookup (use default config) // - Where we send is direct to dstAddr // @@ -836,8 +837,12 @@ function FTNMessageScanTossModule() { routeAddress = Address.fromString(route.address); networkName = route.network || Config.scannerTossers.ftn_bso.defaultNetwork; } else { - routeAddress = dstAddr; - networkName = Config.scannerTossers.ftn_bso.defaultNetwork; + routeAddress = dstAddr; + + networkName = this.getNetworkNameByAddressPattern(`${dstAddr.zone}:${dstAddr.net}/*`); + if(!networkName) { + networkName = Config.scannerTossers.ftn_bso.defaultNetwork; + } } const config = _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { From c4c30e0c0d5b3acb7d57fae2a4b0d596413d8495 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 9 Jan 2018 18:43:04 -0700 Subject: [PATCH 081/102] Add some logging --- core/file_area_web.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/file_area_web.js b/core/file_area_web.js index a8d095d6..6440eb58 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -284,6 +284,8 @@ class FileAreaWebAccess { routeWebRequest(req, resp) { const hashId = paths.basename(req.url); + Log.debug( { hashId : hashId, url : req.url }, 'File area web request'); + this.loadServedHashId(hashId, (err, servedItem) => { if(err) { @@ -305,6 +307,8 @@ class FileAreaWebAccess { } routeWebRequestForSingleFile(servedItem, req, resp) { + Log.debug( { servedItem : servedItem }, 'Single file web request'); + const fileEntry = new FileEntry(); servedItem.fileId = servedItem.fileIds[0]; @@ -348,6 +352,8 @@ class FileAreaWebAccess { } routeWebRequestForBatchArchive(servedItem, req, resp) { + Log.debug( { servedItem : servedItem }, 'Batch file web request'); + // // We are going to build an on-the-fly zip file stream of 1:n // files in the batch. @@ -392,8 +398,14 @@ class FileAreaWebAccess { }); }, function createAndServeStream(filePaths, callback) { + Log.trace( { filePaths : filePaths }, 'Creating zip archive for batch web request'); + const zipFile = new yazl.ZipFile(); + zipFile.on('error', err => { + Log.warn( { error : err.message }, 'Error adding file to batch web request archive'); + }); + filePaths.forEach(fp => { zipFile.addFile( fp, // path to physical file From d225d78fa961d8839d0f8d36280815e9111a10d6 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 9 Jan 2018 19:38:36 -0700 Subject: [PATCH 082/102] At least for now, use FTN-compliant MSGID for NetMail exports --- core/ftn_util.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/core/ftn_util.js b/core/ftn_util.js index a02d8e1c..39093fab 100644 --- a/core/ftn_util.js +++ b/core/ftn_util.js @@ -135,9 +135,20 @@ function getMessageSerialNumber(messageId) { // // ENiGMA½: .@<5dFtnAddress> // -function getMessageIdentifier(message, address) { +// 0.0.8-alpha: +// Made compliant with FTN spec *when exporting NetMail* due to +// Mystic rejecting messages with the true-unique version. +// Strangely, Synchronet uses the unique format and Mystic does +// OK with it. Will need to research further. Note also that +// g00r00 was kind enough to fix Mystic to allow for the Sync/Enig +// format, but that will only help when using newer Mystic versions. +// +function getMessageIdentifier(message, address, isNetMail = false) { const addrStr = new Address(address).toString('5D'); - return `${message.messageId}.${message.areaTag.toLowerCase()}@${addrStr} ${getMessageSerialNumber(message.messageId)}`; + return isNetMail ? + `${addrStr} ${getMessageSerialNumber(message.messageId)}` : + `${message.messageId}.${message.areaTag.toLowerCase()}@${addrStr} ${getMessageSerialNumber(message.messageId)}` + ; } // From f939babe728318ce0dcaac4fe0868ddfbfff9c66 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 9 Jan 2018 20:48:47 -0700 Subject: [PATCH 083/102] Updates and isNetmail=isPrivate --- core/scanner_tossers/ftn_bso.js | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 123c0b7a..bba0c313 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -399,7 +399,11 @@ function FTNMessageScanTossModule() { // export that failed to finish // if(!message.meta.FtnKludge.MSGID) { - message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier(message, localAddress); + message.meta.FtnKludge.MSGID = ftnUtil.getMessageIdentifier( + message, + localAddress, + message.isPrivate() // true = isNetMail + ); } message.meta.FtnKludge.TZUTC = ftnUtil.getUTCTimeZoneOffset(); @@ -819,32 +823,29 @@ function FTNMessageScanTossModule() { // lookup order (most to least explicit config): // // 1) Routes: messageNetworks.ftn.netMail.routes{} -> scannerTossers.ftn_bso.nodes{} -> config - // - Where we send may not be where dstAddress is (it's routed!); use network found in route - // for local address + // - Where we send may not be where dstAddress is (it's routed!) // 2) Direct to nodes: scannerTossers.ftn_bso.nodes{} -> config - // - Where we send is direct to dstAddr; - // - Attempt to match address in messageNetworks.ftn.networks{}, else - // use scannerTossers.ftn_bso.defaultNetwork to for local address - // 3) Nodelist DB lookup (use default config) // - Where we send is direct to dstAddr // - //const routeAddress = this.getNetMailRouteAddress(dstAddr) || dstAddr; + // In both cases, attempt to look up Zone:Net/* to discover local "from" network/address + // falling back to Config.scannerTossers.ftn_bso.defaultNetwork + // const route = this.getNetMailRoute(dstAddr); let routeAddress; let networkName; if(route) { routeAddress = Address.fromString(route.address); - networkName = route.network || Config.scannerTossers.ftn_bso.defaultNetwork; + networkName = route.network; } else { routeAddress = dstAddr; - - networkName = this.getNetworkNameByAddressPattern(`${dstAddr.zone}:${dstAddr.net}/*`); - if(!networkName) { - networkName = Config.scannerTossers.ftn_bso.defaultNetwork; - } } + networkName = networkName || + this.getNetworkNameByAddressPattern(`${routeAddress.zone}:${routeAddress.net}/*`) || + Config.scannerTossers.ftn_bso.defaultNetwork + ; + const config = _.find(this.moduleConfig.nodes, (node, nodeAddrWildcard) => { return routeAddress.isPatternMatch(nodeAddrWildcard); }) || { From ad60e5a7dfe28850a0df0b4e00a0f15c3860c3c8 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Tue, 9 Jan 2018 22:13:29 -0700 Subject: [PATCH 084/102] Split AreaFix with \r\n --- core/oputil/oputil_message_base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js index 1312f2a4..2ab93104 100644 --- a/core/oputil/oputil_message_base.js +++ b/core/oputil/oputil_message_base.js @@ -79,7 +79,7 @@ function areaFix() { // const messageBody = argv._.slice(2, -1).map(arg => { return arg.replace(/["']/g, ''); - }).join('\n') + '\n'; + }).join('\r\n') + '\n'; const Message = require('../message.js'); From 30fd001db3d05232b4326d612a85f059eb00658e Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 11 Jan 2018 21:12:07 -0700 Subject: [PATCH 085/102] Fixed servedItem log --- core/file_area_web.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/core/file_area_web.js b/core/file_area_web.js index 6440eb58..b12f4f7d 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -152,16 +152,18 @@ class FileAreaWebAccess { return cb(Errors.Invalid('Invalid or unknown hash ID')); } - return cb( - null, - { - hashId : hashId, - userId : decoded[0], - hashIdType : decoded[1], - fileIds : decoded.slice(2), - expireTimestamp : moment(result.expire_timestamp), - } - ); + const servedItem = { + hashId : hashId, + userId : decoded[0], + hashIdType : decoded[1], + expireTimestamp : moment(result.expire_timestamp), + }; + + if(FileAreaWebAccess.getHashIdTypes().SingleFile === servedItem.hashIdType) { + servedItem.fileIds = decoded.slice(2); + } + + return cb(null, servedItem); } ); } From a2e8fa65105b8415b6f4ee878fef24a1ac5b17f1 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 11 Jan 2018 21:16:06 -0700 Subject: [PATCH 086/102] Add allowOlder opt to setFileBaseLastViewdFileIdForUser() --- core/file_base_filter.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/file_base_filter.js b/core/file_base_filter.js index a7185316..320d36d3 100644 --- a/core/file_base_filter.js +++ b/core/file_base_filter.js @@ -136,9 +136,14 @@ module.exports = class FileBaseFilters { return parseInt((user.properties.user_file_base_last_viewed || 0)); } - static setFileBaseLastViewedFileIdForUser(user, fileId, cb) { + static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) { + if(!cb && _.isFunction(allowOlder)) { + cb = allowOlder; + allowOlder = false; + } + const current = FileBaseFilters.getFileBaseLastViewedFileIdByUser(user); - if(fileId < current) { + if(!allowOlder && fileId < current) { if(cb) { cb(null); } From 4e4ee6b8ce2efca5a5e8a906b77ea169f7456a65 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 11 Jan 2018 21:16:37 -0700 Subject: [PATCH 087/102] cleanup --- core/file_base_search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/file_base_search.js b/core/file_base_search.js index 2ff27f8a..27656123 100644 --- a/core/file_base_search.js +++ b/core/file_base_search.js @@ -56,7 +56,7 @@ exports.getModule = class FileBaseSearch extends MenuModule { }, function populateAreas(callback) { self.availAreas = [ { name : '-ALL-' } ].concat(getSortedAvailableFileAreas(self.client) || []); - + const areasView = vc.getView(MciViewIds.search.area); areasView.setItems( self.availAreas.map( a => a.name ) ); areasView.redraw(); From fa1bffeaf847fb37ee75fa8e8943270272d46188 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 11 Jan 2018 21:17:26 -0700 Subject: [PATCH 088/102] Fix limit when fetching entries, allow moment timestamps --- core/file_entry.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/core/file_entry.js b/core/file_entry.js index 6edd7ef8..8bf7a69d 100644 --- a/core/file_entry.js +++ b/core/file_entry.js @@ -13,10 +13,11 @@ const paths = require('path'); const fse = require('fs-extra'); const { unlink, readFile } = require('graceful-fs'); const crypto = require('crypto'); +const moment = require('moment'); const FILE_TABLE_MEMBERS = [ 'file_id', 'area_tag', 'file_sha256', 'file_name', 'storage_tag', - 'desc', 'desc_long', 'upload_timestamp' + 'desc', 'desc_long', 'upload_timestamp' ]; const FILE_WELL_KNOWN_META = { @@ -424,7 +425,11 @@ module.exports = class FileEntry { let sqlWhere = ''; let sqlOrderBy; const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC'; - + + if(moment.isMoment(filter.newerThanTimestamp)) { + filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp); + } + function getOrderByWithCast(ob) { if( [ 'dl_count', 'est_release_year', 'byte_size' ].indexOf(filter.sort) > -1 ) { return `ORDER BY CAST(${ob} AS INTEGER)`; @@ -444,7 +449,7 @@ module.exports = class FileEntry { if(filter.sort && filter.sort.length > 0) { if(Object.keys(FILE_WELL_KNOWN_META).indexOf(filter.sort) > -1) { // sorting via a meta value? - sql = + sql = `SELECT DISTINCT f.file_id FROM file f, file_meta m`; @@ -461,7 +466,7 @@ module.exports = class FileEntry { WHERE file_id = f.file_id) AS avg_rating FROM file f`; - + sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`; } else { sql = @@ -472,7 +477,7 @@ module.exports = class FileEntry { } } } else { - sql = + sql = `SELECT DISTINCT f.file_id FROM file f`; @@ -552,12 +557,14 @@ module.exports = class FileEntry { appendWhereClause(`f.file_id > ${filter.newerThanFileId}`); } - sql += `${sqlWhere} ${sqlOrderBy};`; + sql += `${sqlWhere} ${sqlOrderBy}`; if(_.isNumber(filter.limit)) { - sql += `LIMIT ${filter.limit}`; + sql += ` LIMIT ${filter.limit}`; } + sql += ';'; + const matchingFileIds = []; fileDb.each(sql, (err, fileId) => { if(fileId) { From c5e3220c1d040e794748df92677eead85395e228 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 11 Jan 2018 21:17:59 -0700 Subject: [PATCH 089/102] Add support for finding messages by date for msg pointers --- core/message_area.js | 46 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/core/message_area.js b/core/message_area.js index aa893073..e81c85e1 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -2,17 +2,19 @@ 'use strict'; // ENiGMA½ -const msgDb = require('./database.js').dbs.message; -const Config = require('./config.js').config; -const Message = require('./message.js'); -const Log = require('./logger.js').log; -const msgNetRecord = require('./msg_network.js').recordMessage; -const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +const msgDb = require('./database.js').dbs.message; +const Config = require('./config.js').config; +const Message = require('./message.js'); +const Log = require('./logger.js').log; +const msgNetRecord = require('./msg_network.js').recordMessage; +const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +const { getISOTimestampString } = require('./database.js'); // deps const async = require('async'); const _ = require('lodash'); const assert = require('assert'); +const moment = require('moment'); exports.getAvailableMessageConferences = getAvailableMessageConferences; exports.getSortedAvailMessageConferences = getSortedAvailMessageConferences; @@ -28,6 +30,7 @@ exports.tempChangeMessageConfAndArea = tempChangeMessageConfAndArea; exports.getMessageListForArea = getMessageListForArea; exports.getNewMessageCountInAreaForUser = getNewMessageCountInAreaForUser; exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; +exports.getMessageIdNewerThanTimestampByArea = getMessageIdNewerThanTimestampByArea; exports.getMessageAreaLastReadId = getMessageAreaLastReadId; exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId; exports.persistMessage = persistMessage; @@ -482,6 +485,28 @@ function getMessageListForArea(options, areaTag, cb) { ); } +function getMessageIdNewerThanTimestampByArea(areaTag, newerThanTimestamp, cb) { + if(moment.isMoment(newerThanTimestamp)) { + newerThanTimestamp = getISOTimestampString(newerThanTimestamp); + } + + msgDb.get( + `SELECT message_id + FROM message + WHERE area_tag = ? AND DATETIME(modified_timestamp) > DATETIME("${newerThanTimestamp}", "+1 seconds") + ORDER BY modified_timestamp ASC + LIMIT 1;`, + [ areaTag ], + (err, row) => { + if(err) { + return cb(err); + } + + return cb(null, row ? row.message_id : null); + } + ); +} + function getMessageAreaLastReadId(userId, areaTag, cb) { msgDb.get( 'SELECT message_id ' + @@ -494,7 +519,12 @@ function getMessageAreaLastReadId(userId, areaTag, cb) { ); } -function updateMessageAreaLastReadId(userId, areaTag, messageId, cb) { +function updateMessageAreaLastReadId(userId, areaTag, messageId, allowOlder, cb) { + if(!cb && _.isFunction(allowOlder)) { + cb = allowOlder; + allowOlder = false; + } + // :TODO: likely a better way to do this... async.waterfall( [ @@ -505,7 +535,7 @@ function updateMessageAreaLastReadId(userId, areaTag, messageId, cb) { }); }, function update(lastId, callback) { - if(messageId > lastId) { + if(allowOlder || messageId > lastId) { msgDb.run( 'REPLACE INTO user_message_area_last_read (user_id, area_tag, message_id) ' + 'VALUES (?, ?, ?);', From 00deb3fe72dda04ed9cafcbbef9d468373b7cfdd Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 11 Jan 2018 21:39:14 -0700 Subject: [PATCH 090/102] * Add concept of external flavor to import/exported mails, e.g. 'ftn' * Add to/from remote user meta for opaqe addrs, e.g. 'ftn' flavor can use FTN-style addresses * Allow replys from inbox to a NetMail --- core/fse.js | 54 ++++++++++++++++++------------ core/message.js | 22 ++++++++++++ core/new_scan.js | 5 +-- core/oputil/oputil_message_base.js | 12 ++----- core/scanner_tossers/ftn_bso.js | 49 +++++++++++++++++---------- 5 files changed, 93 insertions(+), 49 deletions(-) diff --git a/core/fse.js b/core/fse.js index 4a6c8eb1..d84de1e9 100644 --- a/core/fse.js +++ b/core/fse.js @@ -411,30 +411,42 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul return callback(null); }, function populateLocalUserInfo(callback) { - if(self.isPrivateMail()) { - self.message.setLocalFromUserId(self.client.user.userId); - - if(self.toUserId > 0) { - self.message.setLocalToUserId(self.toUserId); - callback(null); - } else { - // we need to look it up - User.getUserIdAndNameByLookup(self.message.toUserName, function userInfo(err, toUserId) { - if(err) { - callback(err); - } else { - self.message.setLocalToUserId(toUserId); - callback(null); - } - }); - } - } else { - callback(null); + if(!self.isPrivateMail()) { + return callback(null); } + + // :TODO: shouldn't local from user ID be set for all mail? + self.message.setLocalFromUserId(self.client.user.userId); + + if(self.toUserId > 0) { + self.message.setLocalToUserId(self.toUserId); + return callback(null); + } + + // + // If the message we're replying to is from a remote user + // don't try to look up the local user ID. Instead, mark the mail + // for export with the remote to address. + // + if(self.replyToMessage.isFromRemoteUser()) { + self.message.setRemoteToUser(self.replyToMessage.meta.System[Message.SystemMetaNames.RemoteFromUser]); + self.message.setExternalFlavor(self.replyToMessage.meta.System[Message.SystemMetaNames.ExternalFlavor]); + return callback(null); + } + + // we need to look it up + User.getUserIdAndNameByLookup(self.message.toUserName, (err, toUserId) => { + if(err) { + return callback(err); + } + + self.message.setLocalToUserId(toUserId); + return callback(null); + }); } ], - function complete(err) { - cb(err, self.message); + err => { + return cb(err, self.message); } ); } diff --git a/core/message.js b/core/message.js index 7fb57d20..360b211a 100644 --- a/core/message.js +++ b/core/message.js @@ -76,6 +76,10 @@ function Message(options) { this.isPrivate = function() { return Message.isPrivateAreaTag(this.areaTag); }; + + this.isFromRemoteUser = function() { + return null !== _.get(this, 'meta.System.remote_from_user', null); + }; } Message.WellKnownAreaTags = { @@ -93,6 +97,14 @@ Message.SystemMetaNames = { LocalFromUserID : 'local_from_user_id', StateFlags0 : 'state_flags0', // See Message.StateFlags0 ExplicitEncoding : 'explicit_encoding', // Explicitly set encoding when exporting/etc. + ExternalFlavor : 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.ExternalFlavors + RemoteToUser : 'remote_to_user', // Opaque value depends on external system, e.g. FTN address + RemoteFromUser : 'remote_from_user', // Opaque value depends on external system, e.g. FTN address +}; + +// Types for Message.SystemMetaNames.ExternalFlavor meta +Message.ExternalFlavors = { + FTN : 'ftn', // FTN style }; Message.StateFlags0 = { @@ -133,6 +145,16 @@ Message.prototype.setLocalFromUserId = function(userId) { this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId; }; +Message.prototype.setRemoteToUser = function(remoteTo) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo; +}; + +Message.prototype.setExternalFlavor = function(flavor) { + this.meta.System = this.meta.System || {}; + this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor; +}; + Message.createMessageUUID = function(areaTag, modTimestamp, subject, body) { assert(_.isString(areaTag)); assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp)); diff --git a/core/new_scan.js b/core/new_scan.js index a75a5b2d..f3e851fb 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -170,6 +170,7 @@ exports.getModule = class NewScanModule extends MenuModule { const filterCriteria = { newerThanFileId : FileBaseFilters.getFileBaseLastViewedFileIdByUser(this.client.user), areaTag : getAvailableFileAreaTags(this.client), + order : 'ascending', // oldest first }; FileEntry.findFiles( @@ -179,11 +180,11 @@ exports.getModule = class NewScanModule extends MenuModule { return cb(err ? err : Errors.DoesNotExist('No more new files')); } - FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[0] ); + FileBaseFilters.setFileBaseLastViewedFileIdForUser( this.client.user, fileIds[fileIds.length - 1] ); const menuOpts = { extraArgs : { - fileList : fileIds, + fileList : fileIds, }, }; diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js index 2ab93104..b1c6673e 100644 --- a/core/oputil/oputil_message_base.js +++ b/core/oputil/oputil_message_base.js @@ -90,18 +90,13 @@ function areaFix() { message : messageBody, areaTag : Message.WellKnownAreaTags.Private, // mark private meta : { - FtnProperty : { - [ Message.FtnPropertyNames.FtnDestZone ] : ftnAddr.zone, - [ Message.FtnPropertyNames.FtnDestNetwork ] : ftnAddr.net, - [ Message.FtnPropertyNames.FtnDestNode ] : ftnAddr.node, + System : { + [ Message.SystemMetaNames.RemoteToUser ] : ftnAddr.toString(), // where to send it + [ Message.SystemMetaNames.ExternalFlavor ] : Message.ExternalFlavors.FTN, // on FTN-style network } } }); - if(ftnAddr.point) { - message.meta.FtnProperty[Message.FtnPropertyNames.FtnDestPoint] = ftnAddr.point; - } - if(0 !== fromUserId) { message.setLocalFromUserId(fromUserId); } @@ -109,7 +104,6 @@ function areaFix() { return callback(null, message); }, function persistMessage(message, callback) { - // :TODO: Persist message in private outgoing (sysop out box) (TBD: implementation) message.persist(err => { if(!err) { console.log('AreaFix message persisted and will be exported at next scheduled scan'); diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index bba0c313..c7e3edc9 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -45,12 +45,8 @@ exports.moduleInfo = { /* :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 * Validate packet passwords!!!! => secure vs insecure landing areas - */ exports.getModule = FTNMessageScanTossModule; @@ -878,12 +874,7 @@ function FTNMessageScanTossModule() { } }, function discoverUplink(callback) { - const dstAddr = new Address({ - zone : parseInt(message.meta.FtnProperty.ftn_dest_zone), - net : parseInt(message.meta.FtnProperty.ftn_dest_network), - node : parseInt(message.meta.FtnProperty.ftn_dest_node), - point : parseInt(message.meta.FtnProperty.ftn_dest_point) || null, // point is optional - }); + const dstAddr = new Address(message.meta.System[Message.SystemMetaNames.RemoteToUser]); return self.getAcceptableNetMailNetworkInfoFromAddress(dstAddr, (err, config, routeAddress, networkName) => { if(err) { @@ -1152,6 +1143,9 @@ function FTNMessageScanTossModule() { function basicSetup(callback) { message.areaTag = config.localAreaTag; + // indicate this was imported from FTN + message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.ExternalFlavors.FTN; + // // If we *allow* dupes (disabled by default), then just generate // a random UUID. Otherwise, don't assign the UUID just yet. It will be @@ -1177,6 +1171,22 @@ function FTNMessageScanTossModule() { return callback(null); } + // + // Create a meta value for the *remote* from user. In the case here with FTN, + // their fully qualified FTN from address + // + const intlKludge = _.get(message, 'meta.FtnKludge.INTL'); + if(intlKludge && intlKludge.length > 0) { + let fromAddress = intlKludge.split(' ')[0]; + + const fromPointKludge = _.get(message, 'meta.FtnKludge.FMPT'); + if(fromPointKludge) { + fromAddress += `.${fromPointKludge}`; + } + + message.meta.System[Message.SystemMetaNames.RemoteFromUser] = fromAddress; + } + const lookupName = self.getLocalUserNameFromAlias(message.toUserName); User.getUserIdAndNameByLookup(lookupName, (err, localToUserId, localUserName) => { @@ -1911,8 +1921,7 @@ function FTNMessageScanTossModule() { this.performNetMailExport = function(cb) { // // Select all messages with a |message_id| > |lastScanId| in the private area - // that also *do not* have a local user ID meta value but *do* have a FTN dest - // network meta value. + // that are schedule for export to FTN-style networks. // // Just like EchoMail, we additionally exclude messages with the System state_flags0 // which will be present for imported or already exported messages @@ -1927,12 +1936,18 @@ function FTNMessageScanTossModule() { WHERE area_tag = '${Message.WellKnownAreaTags.Private}' 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' OR meta_name='local_to_user_id')) = 0 + WHERE message_id = m.message_id + AND meta_category = 'System' + AND (meta_name = 'state_flags0' OR meta_name = 'local_to_user_id') + ) = 0 AND (SELECT COUNT(message_id) FROM message_meta - WHERE message_id = m.message_id AND meta_category='FtnProperty' AND meta_name='ftn_dest_network') = 1 + WHERE message_id = m.message_id + AND meta_category = 'System' + AND meta_name = '${Message.SystemMetaNames.ExternalFlavor}' + AND meta_value = '${Message.ExternalFlavors.FTN}' + ) = 1 ORDER BY message_id; `; @@ -1967,8 +1982,8 @@ function FTNMessageScanTossModule() { this.isNetMailMessage = function(message) { return message.isPrivate() && - null === _.get(message.meta, 'System.LocalToUserID', null) && - null !== _.get(message.meta, 'FtnProperty.ftn_dest_network', null) + null === _.get(message, 'meta.System.LocalToUserID', null) && + Message.ExternalFlavors.FTN === _.get(message, 'meta.System.external_flavor', null) ; }; } From 27fcd40900921a421ff93d90be096379b37d44b9 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 12 Jan 2018 19:06:33 -0700 Subject: [PATCH 091/102] Fix remote from user @ import --- core/scanner_tossers/ftn_bso.js | 40 +++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index c7e3edc9..8b86e850 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1113,6 +1113,32 @@ function FTNMessageScanTossModule() { return alias || lookup; }; + this.getAddressesFromNetMailMessage = function(message) { + const intlKludge = _.get(message, 'meta.FtnKludge.INTL'); + + if(!intlKludge) { + return {}; + } + + let [ to, from ] = intlKludge.split(' '); + if(!to || !from) { + return {}; + } + + const fromPoint = _.get(message, 'meta.FtnKludge.FMPT'); + const toPoint = _.get(message, 'meta.FtnKludge.TOPT'); + + if(fromPoint) { + from += `.${fromPoint}`; + } + + if(toPoint) { + to += `.${toPoint}`; + } + + return { to : Address.fromString(to), from : Address.fromString(from) }; + }; + this.importMailToArea = function(config, header, message, cb) { async.series( [ @@ -1175,18 +1201,14 @@ function FTNMessageScanTossModule() { // Create a meta value for the *remote* from user. In the case here with FTN, // their fully qualified FTN from address // - const intlKludge = _.get(message, 'meta.FtnKludge.INTL'); - if(intlKludge && intlKludge.length > 0) { - let fromAddress = intlKludge.split(' ')[0]; + const { from } = self.getAddressesFromNetMailMessage(message); - const fromPointKludge = _.get(message, 'meta.FtnKludge.FMPT'); - if(fromPointKludge) { - fromAddress += `.${fromPointKludge}`; - } - - message.meta.System[Message.SystemMetaNames.RemoteFromUser] = fromAddress; + if(!from) { + return callback(Errors.Invalid('Cannot import FTN NetMail without valid INTL line')); } + message.meta.System[Message.SystemMetaNames.RemoteFromUser] = from.toString(); + const lookupName = self.getLocalUserNameFromAlias(message.toUserName); User.getUserIdAndNameByLookup(lookupName, (err, localToUserId, localUserName) => { From 2bc8e417e4a66ba1c3a4ed557e768685d61e803b Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Fri, 12 Jan 2018 23:44:22 -0700 Subject: [PATCH 092/102] Fix non-reply crash --- core/fse.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/core/fse.js b/core/fse.js index d84de1e9..824704dc 100644 --- a/core/fse.js +++ b/core/fse.js @@ -342,8 +342,6 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul // really don't like ANSI messages in UTF-8 encoding (they should!) // msgOpts.meta = { System : { 'explicit_encoding' : Config.scannerTossers.ftn_bso.packetAnsiMsgEncoding || 'cp437' } }; - // :TODO: change to \r\nESC[A - //msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}${msgOpts.message}`; msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}\r\n${ansi.up()}${msgOpts.message}`; } } @@ -428,7 +426,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul // don't try to look up the local user ID. Instead, mark the mail // for export with the remote to address. // - if(self.replyToMessage.isFromRemoteUser()) { + if(self.replyToMessage && self.replyToMessage.isFromRemoteUser()) { self.message.setRemoteToUser(self.replyToMessage.meta.System[Message.SystemMetaNames.RemoteFromUser]); self.message.setExternalFlavor(self.replyToMessage.meta.System[Message.SystemMetaNames.ExternalFlavor]); return callback(null); From 08ea798d53afa4b0d16c4b22e5f7e0bd22e18783 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 13 Jan 2018 08:57:13 -0700 Subject: [PATCH 093/102] Rename to AddressFlavor --- core/mail_util.js | 81 +++++++++++++++++++++++++++++++++++++++++++++++ core/message.js | 10 +++--- 2 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 core/mail_util.js diff --git a/core/mail_util.js b/core/mail_util.js new file mode 100644 index 00000000..654b1617 --- /dev/null +++ b/core/mail_util.js @@ -0,0 +1,81 @@ +/* jslint node: true */ +'use strict'; + +const Address = require('./ftn_address.js'); +const Message = require('./message.js'); + +exports.getAddressedToInfo = getAddressedToInfo; + +const EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +/* + Input Output + ---------------------------------------------------------------------------------------------------- + User { name : 'User', flavor : 'local' } + Some User { name : 'Some User', flavor : 'local' } + JoeUser @ 1:103/75 { name : 'JoeUser', flavor : 'ftn', remote : '1:103/75' } + Bob@1:103/705@fidonet.org { name : 'Bob', flavor : 'ftn', remote : '1:103/705@fidonet.org' } + 1:103/705@fidonet.org { flavor : 'ftn', remote : '1:103/705@fidonet.org' } + Jane <23:4/100> { name : 'Jane', flavor : 'ftn', remote : '23:4/100' } + 43:20/100.2 { flavor : 'ftn', remote : '43:20/100.2' } + foo@host.com { name : 'foo', flavor : 'email', remote : 'foo@host.com' } + Bar { name : 'Bar', flavor : 'email', remote : 'baz@foobar.com' } +*/ +function getAddressedToInfo(input) { + input = input.trim(); + + const firstAtPos = input.indexOf('@'); + + if(firstAtPos < 0) { + let addr = Address.fromString(input); + if(Address.isValidAddress(addr)) { + return { flavor : Message.AddressFlavor.FTN, remote : input }; + } + + const lessThanPos = input.indexOf('<'); + if(lessThanPos < 0) { + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + const greaterThanPos = input.indexOf('>'); + if(greaterThanPos < lessThanPos) { + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + addr = Address.fromString(input.slice(lessThanPos + 1, greaterThanPos)); + if(Address.isValidAddress(addr)) { + return { name : input.slice(0, lessThanPos).trim(), flavor : Message.AddressFlavor.FTN, remote : addr.toString() }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + const lessThanPos = input.indexOf('<'); + const greaterThanPos = input.indexOf('>'); + if(lessThanPos > 0 && greaterThanPos > lessThanPos) { + const addr = input.slice(lessThanPos + 1, greaterThanPos); + const m = addr.match(EMAIL_REGEX); + if(m) { + return { name : input.slice(0, lessThanPos).trim(), flavor : Message.AddressFlavor.Email, remote : addr }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; + } + + let m = input.match(EMAIL_REGEX); + if(m) { + return { name : input.slice(0, firstAtPos), flavor : Message.AddressFlavor.Email, remote : input }; + } + + let addr = Address.fromString(input); // 5D? + if(Address.isValidAddress(addr)) { + return { flavor : Message.AddressFlavor.FTN, remote : addr.toString() } ; + } + + addr = Address.fromString(input.slice(firstAtPos + 1).trim()); + if(Address.isValidAddress(addr)) { + return { name : input.slice(0, firstAtPos).trim(), flavor : Message.AddressFlavor.FTN, remote : addr.toString() }; + } + + return { name : input, flavor : Message.AddressFlavor.Local }; +} diff --git a/core/message.js b/core/message.js index 360b211a..5d3c8db9 100644 --- a/core/message.js +++ b/core/message.js @@ -97,14 +97,16 @@ Message.SystemMetaNames = { LocalFromUserID : 'local_from_user_id', StateFlags0 : 'state_flags0', // See Message.StateFlags0 ExplicitEncoding : 'explicit_encoding', // Explicitly set encoding when exporting/etc. - ExternalFlavor : 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.ExternalFlavors - RemoteToUser : 'remote_to_user', // Opaque value depends on external system, e.g. FTN address - RemoteFromUser : 'remote_from_user', // Opaque value depends on external system, e.g. FTN address + ExternalFlavor : 'external_flavor', // "Flavor" of message - imported from or to be exported to. See Message.AddressFlavor + RemoteToUser : 'remote_to_user', // Opaque value depends on external system, e.g. FTN address + RemoteFromUser : 'remote_from_user', // Opaque value depends on external system, e.g. FTN address }; // Types for Message.SystemMetaNames.ExternalFlavor meta -Message.ExternalFlavors = { +Message.AddressFlavor = { + Local : 'local', // local / non-remote addressing FTN : 'ftn', // FTN style + Email : 'email', }; Message.StateFlags0 = { From 149f8bd9f541940ca4602a872584832cf5d0b0f8 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 13 Jan 2018 08:57:54 -0700 Subject: [PATCH 094/102] Add valid check methods --- core/ftn_address.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/ftn_address.js b/core/ftn_address.js index 9edb3819..f0936e1d 100644 --- a/core/ftn_address.js +++ b/core/ftn_address.js @@ -20,6 +20,15 @@ module.exports = class Address { } } + static isValidAddress(addr) { + return addr && addr.isValid(); + } + + isValid() { + // FTN address is valid if we have at least a net/node + return _.isNumber(this.net) && _.isNumber(this.node); + } + isEqual(other) { if(_.isString(other)) { other = Address.fromString(other); From 9a00b3eb156b95c7cfcf78b4b0577a6f7587736a Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 13 Jan 2018 08:58:28 -0700 Subject: [PATCH 095/102] Add validateGeneralMailAddressedTo() --- core/system_view_validate.js | 39 ++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 07bbcab2..e2a5b2e0 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -2,9 +2,11 @@ 'use strict'; // ENiGMA½ -const User = require('./user.js'); -const Config = require('./config.js').config; -const Log = require('./logger.js').log; +const User = require('./user.js'); +const Config = require('./config.js').config; +const Log = require('./logger.js').log; +const { getAddressedToInfo } = require('./mail_util.js'); +const Message = require('./message.js'); // deps const fs = require('graceful-fs'); @@ -14,6 +16,7 @@ exports.validateMessageSubject = validateMessageSubject; exports.validateUserNameAvail = validateUserNameAvail; exports.validateUserNameExists = validateUserNameExists; exports.validateUserNameOrRealNameExists = validateUserNameOrRealNameExists; +exports.validateGeneralMailAddressedTo = validateGeneralMailAddressedTo; exports.validateEmailAvail = validateEmailAvail; exports.validateBirthdate = validateBirthdate; exports.validatePasswordSpec = validatePasswordSpec; @@ -55,30 +58,44 @@ function validateUserNameAvail(data, cb) { } } -function validateUserNameExists(data, cb) { - const invalidUserNameError = new Error('Invalid username'); +const invalidUserNameError = () => new Error('Invalid username'); +function validateUserNameExists(data, cb) { if(0 === data.length) { - return cb(invalidUserNameError); + return cb(invalidUserNameError()); } User.getUserIdAndName(data, (err) => { - return cb(err ? invalidUserNameError : null); + return cb(err ? invalidUserNameError() : null); }); } function validateUserNameOrRealNameExists(data, cb) { - const invalidUserNameError = new Error('Invalid username'); - if(0 === data.length) { - return cb(invalidUserNameError); + return cb(invalidUserNameError()); } User.getUserIdAndNameByLookup(data, err => { - return cb(err ? invalidUserNameError : null); + return cb(err ? invalidUserNameError() : null); }); } +function validateGeneralMailAddressedTo(data, cb) { + // + // Allow any supported addressing: + // - Local username or real name + // - Supported remote flavors such as FTN, email, ... + // + // :TODO: remove hard-coded FTN check here. We need a decent way to register global supported flavors with modules. + const addressedToInfo = getAddressedToInfo(data); + + if(Message.AddressFlavor.FTN === addressedToInfo.flavor) { + return cb(null); + } + + return validateUserNameOrRealNameExists(data, cb); +} + function validateEmailAvail(data, cb) { // // This particular method allows empty data - e.g. no email entered From 84fd0ff6d21a23fb07af3ea11c233da6bc2715ba Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 13 Jan 2018 09:06:50 -0700 Subject: [PATCH 096/102] Add ability to send directly to a NetMail address --- core/fse.js | 13 +++++++++++++ core/oputil/oputil_message_base.js | 2 +- core/scanner_tossers/ftn_bso.js | 6 +++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/core/fse.js b/core/fse.js index 824704dc..51b8dc5c 100644 --- a/core/fse.js +++ b/core/fse.js @@ -15,6 +15,7 @@ const stringFormat = require('./string_format.js'); const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher; const { isAnsi, cleanControlCodes, insert } = require('./string_util.js'); const Config = require('./config.js').config; +const { getAddressedToInfo } = require('./mail_util.js'); // deps const async = require('async'); @@ -432,6 +433,18 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul return callback(null); } + // + // Detect if the user is attempting to send to a remote mail type that we support + // + // :TODO: how to plug in support without tying to various types here? isSupportedExteranlType() or such + const addressedToInfo = getAddressedToInfo(self.message.toUserName); + if(addressedToInfo.name && Message.AddressFlavor.FTN === addressedToInfo.flavor) { + self.message.setRemoteToUser(addressedToInfo.remote); + self.message.setExternalFlavor(addressedToInfo.flavor); + self.message.toUserName = addressedToInfo.name; + return callback(null); + } + // we need to look it up User.getUserIdAndNameByLookup(self.message.toUserName, (err, toUserId) => { if(err) { diff --git a/core/oputil/oputil_message_base.js b/core/oputil/oputil_message_base.js index b1c6673e..6e89cf73 100644 --- a/core/oputil/oputil_message_base.js +++ b/core/oputil/oputil_message_base.js @@ -92,7 +92,7 @@ function areaFix() { meta : { System : { [ Message.SystemMetaNames.RemoteToUser ] : ftnAddr.toString(), // where to send it - [ Message.SystemMetaNames.ExternalFlavor ] : Message.ExternalFlavors.FTN, // on FTN-style network + [ Message.SystemMetaNames.ExternalFlavor ] : Message.AddressFlavor.FTN, // on FTN-style network } } }); diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 8b86e850..4285884e 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1170,7 +1170,7 @@ function FTNMessageScanTossModule() { message.areaTag = config.localAreaTag; // indicate this was imported from FTN - message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.ExternalFlavors.FTN; + message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.AddressFlavor.FTN; // // If we *allow* dupes (disabled by default), then just generate @@ -1968,7 +1968,7 @@ function FTNMessageScanTossModule() { WHERE message_id = m.message_id AND meta_category = 'System' AND meta_name = '${Message.SystemMetaNames.ExternalFlavor}' - AND meta_value = '${Message.ExternalFlavors.FTN}' + AND meta_value = '${Message.AddressFlavor.FTN}' ) = 1 ORDER BY message_id; `; @@ -2005,7 +2005,7 @@ function FTNMessageScanTossModule() { this.isNetMailMessage = function(message) { return message.isPrivate() && null === _.get(message, 'meta.System.LocalToUserID', null) && - Message.ExternalFlavors.FTN === _.get(message, 'meta.System.external_flavor', null) + Message.AddressFlavor.FTN === _.get(message, 'meta.System.external_flavor', null) ; }; } From 011c863547d1bc9fe320cc6ad3d84d4136b36a04 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 13 Jan 2018 09:08:17 -0700 Subject: [PATCH 097/102] Use validateGeneralMailAddressedTo for private mail box --- config/menu.hjson | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/menu.hjson b/config/menu.hjson index 0f1fa168..2225f274 100644 --- a/config/menu.hjson +++ b/config/menu.hjson @@ -2324,7 +2324,7 @@ ET2: { argName: to focus: true - validate: @systemMethod:validateUserNameExists + validate: @systemMethod:validateGeneralMailAddressedTo } ET3: { argName: subject From e7b0e4af30dfe8a709670c82bc07f03ae6c2a982 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 14 Jan 2018 13:52:40 -0700 Subject: [PATCH 098/102] Add private exported + sent mail cleanup to trimMessageAreasScheduledEvent() scheduled event --- core/config.js | 5 +- core/fse.js | 2 +- core/message_area.js | 87 ++++++++++++++++++++++++--------- core/scanner_tossers/ftn_bso.js | 8 ++- 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/core/config.js b/core/config.js index f9f277a8..62ce6032 100644 --- a/core/config.js +++ b/core/config.js @@ -598,8 +598,9 @@ function getDefaultConfig() { areas : { private_mail : { - name : 'Private Mail', - desc : 'Private user to user mail/email', + name : 'Private Mail', + desc : 'Private user to user mail/email', + maxExternalSentAgeDays : 30, // max external "outbox" item age }, local_bulletin : { diff --git a/core/fse.js b/core/fse.js index 51b8dc5c..b266a9c9 100644 --- a/core/fse.js +++ b/core/fse.js @@ -990,7 +990,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul this.viewControllers.body.switchFocus(1); this.observeEditorEvents(); - }; + } switchToFooter() { this.viewControllers.header.setFocus(false); diff --git a/core/message_area.js b/core/message_area.js index e81c85e1..53dd3086 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -598,11 +598,11 @@ function trimMessageAreasScheduledEvent(args, cb) { LIMIT -1 OFFSET ${areaInfo.maxMessages} );`, [ areaInfo.areaTag.toLowerCase() ], - err => { + function result(err) { // no arrow func; need this if(err) { - Log.error( { areaInfo : areaInfo, err : err, type : 'maxMessages' }, 'Error trimming message area'); + Log.error( { areaInfo : areaInfo, error : err.message, type : 'maxMessages' }, 'Error trimming message area'); } else { - Log.debug( { areaInfo : areaInfo, type : 'maxMessages' }, 'Area trimmed successfully'); + Log.debug( { areaInfo : areaInfo, type : 'maxMessages', count : this.changes }, 'Area trimmed successfully'); } return cb(err); } @@ -618,21 +618,25 @@ function trimMessageAreasScheduledEvent(args, cb) { `DELETE FROM message WHERE area_tag = ? AND modified_timestamp < date('now', '-${areaInfo.maxAgeDays} days');`, [ areaInfo.areaTag ], - err => { + function result(err) { // no arrow func; need this if(err) { - Log.warn( { areaInfo : areaInfo, err : err, type : 'maxAgeDays' }, 'Error trimming message area'); + Log.warn( { areaInfo : areaInfo, error : err.message, type : 'maxAgeDays' }, 'Error trimming message area'); } else { - Log.debug( { areaInfo : areaInfo, type : 'maxAgeDays' }, 'Area trimmed successfully'); + Log.debug( { areaInfo : areaInfo, type : 'maxAgeDays', count : this.changes }, 'Area trimmed successfully'); } return cb(err); } ); } - + async.waterfall( - [ + [ function getAreaTags(callback) { - let areaTags = []; + const areaTags = []; + + // + // We use SQL here vs API such that no-longer-used tags are picked up + // msgDb.each( `SELECT DISTINCT area_tag FROM message;`, @@ -640,7 +644,11 @@ function trimMessageAreasScheduledEvent(args, cb) { if(err) { return callback(err); } - areaTags.push(row.area_tag); + + // We treat private mail special + if(!Message.isPrivateAreaTag(row.area_tag)) { + areaTags.push(row.area_tag); + } }, err => { return callback(err, areaTags); @@ -652,30 +660,26 @@ function trimMessageAreasScheduledEvent(args, cb) { // determine maxMessages & maxAgeDays per area areaTags.forEach(areaTag => { - + let maxMessages = Config.messageAreaDefaults.maxMessages; let maxAgeDays = Config.messageAreaDefaults.maxAgeDays; - + const area = getMessageAreaByTag(areaTag); // note: we don't know the conf here if(area) { - if(area.maxMessages) { - maxMessages = area.maxMessages; - } - if(area.maxAgeDays) { - maxAgeDays = area.maxAgeDays; - } + maxMessages = area.maxMessages || maxMessages; + maxAgeDays = area.maxAgeDays || maxAgeDays; } areaInfos.push( { areaTag : areaTag, maxMessages : maxMessages, maxAgeDays : maxAgeDays, - } ); + } ); }); return callback(null, areaInfos); }, - function trimAreas(areaInfos, callback) { + function trimGeneralAreas(areaInfos, callback) { async.each( areaInfos, (areaInfo, next) => { @@ -691,11 +695,50 @@ function trimMessageAreasScheduledEvent(args, cb) { }, callback ); - } + }, + function trimExternalPrivateSentMail(callback) { + // + // *External* (FTN, email, ...) outgoing is cleaned up *after export* + // if it is older than the configured |maxExternalSentAgeDays| days + // + // Outgoing externally exported private mail is: + // - In the 'private_mail' area + // - Marked exported (state_flags0 exported bit set) + // - Marked with any external flavor (we don't mark local) + // + const maxExternalSentAgeDays = _.get( + Config, + 'messageConferences.system_internal.areas.private_mail.maxExternalSentAgeDays', + 30 + ); + + msgDb.run( + `DELETE FROM message + WHERE message_id IN ( + SELECT m.message_id + FROM message m + JOIN message_meta mms + ON m.message_id = mms.message_id AND + (mms.meta_category='System' AND mms.meta_name='${Message.SystemMetaNames.StateFlags0}' AND (mms.meta_value & ${Message.StateFlags0.Exported} = ${Message.StateFlags0.Exported})) + JOIN message_meta mmf + ON m.message_id = mmf.message_id AND + (mmf.meta_category='System' AND mmf.meta_name='${Message.SystemMetaNames.ExternalFlavor}') + WHERE m.area_tag='${Message.WellKnownAreaTags.Private}' AND DATETIME('now') > DATETIME(m.modified_timestamp, '+${maxExternalSentAgeDays} days') + );`, + function results(err) { // no arrow func; need this + if(err) { + Log.warn( { error : err.message }, 'Error trimming private externally sent messages'); + } else { + Log.debug( { count : this.changes }, 'Private externally sent messages trimmed successfully'); + } + } + ); + + return callback(null); + } ], err => { return cb(err); } ); - } \ No newline at end of file diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 4285884e..830e9daa 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1884,7 +1884,11 @@ function FTNMessageScanTossModule() { ORDER BY message_id;` ; - async.each(Object.keys(Config.messageNetworks.ftn.areas), (areaTag, nextArea) => { + // we shouldn't, but be sure we don't try to pick up private mail here + const areaTags = Object.keys(Config.messageNetworks.ftn.areas) + .filter(areaTag => Message.WellKnownAreaTags.Private !== areaTag); + + async.each(areaTags, (areaTag, nextArea) => { const areaConfig = Config.messageNetworks.ftn.areas[areaTag]; if(!this.isAreaConfigValid(areaConfig)) { return nextArea(); @@ -1948,10 +1952,10 @@ function FTNMessageScanTossModule() { // Just like EchoMail, we 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! // // :TODO: fill out the rest of the consts here // :TODO: this statement is crazy ugly + // :TODO: this should really check for extenral "flavor" and not-already-exported state_flags0 const getNewUuidsSql = `SELECT message_id, message_uuid FROM message m From ab6e097a4f3ccfe60097b68a0c87ba012f77caa0 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 14 Jan 2018 15:00:27 -0700 Subject: [PATCH 099/102] FTN packet dump util --- util/dump_ftn_packet.js | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100755 util/dump_ftn_packet.js diff --git a/util/dump_ftn_packet.js b/util/dump_ftn_packet.js new file mode 100755 index 00000000..144d18f3 --- /dev/null +++ b/util/dump_ftn_packet.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +/* jslint node: true */ +/* eslint-disable no-console */ +'use strict'; + +const { Packet } = require('../core/ftn_mail_packet.js'); + +const argv = require('minimist')(process.argv.slice(2)); + +function main() { + if(0 === argv._.length) { + console.error('usage: dump_ftn_packet.js PATH'); + process.exitCode = -1; + return; + } + + const packet = new Packet(); + const packetPath = argv._[0]; + + packet.read( + packetPath, + (dataType, data, next) => { + if('header' === dataType) { + console.info('--- header ---'); + console.info(`Created : ${data.created.format('dddd, MMMM Do YYYY, h:mm:ss a')}`); + console.info(`Dst. Addr : ${data.destAddress.toString()}`); + console.info(`Src. Addr : ${data.origAddress.toString()}`); + console.info('--- raw header ---'); + console.info(data); + console.info('--------------'); + console.info(''); + } else if('message' === dataType) { + console.info('--- message ---'); + console.info(`To : ${data.toUserName}`); + console.info(`From : ${data.fromUserName}`); + console.info(`Subject : ${data.subject}`); + console.info('--- raw message ---'); + console.info(data); + console.info('---------------'); + } + + return next(null); + }, + () => { + console.info(''); + console.info('--- EOF --- '); + console.info(''); + } + ); +} + +main(); From 136d21276c18f8ef5a2c4c3d8551230c78254e74 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 14 Jan 2018 17:09:23 -0700 Subject: [PATCH 100/102] Add new scan pointers for mb/fb --- art/themes/luciano_blocktronics/FMENU.ANS | Bin 3587 -> 3635 bytes art/themes/luciano_blocktronics/MSGMNU.ANS | Bin 3361 -> 3510 bytes .../luciano_blocktronics/SETFNSDATE.ANS | Bin 0 -> 407 bytes .../luciano_blocktronics/SETMNSDATE.ANS | Bin 0 -> 512 bytes config/menu.hjson | 85 ++++++ core/scanner_tossers/ftn_bso.js | 7 +- core/set_newscan_date.js | 261 ++++++++++++++++++ 7 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 art/themes/luciano_blocktronics/SETFNSDATE.ANS create mode 100644 art/themes/luciano_blocktronics/SETMNSDATE.ANS create mode 100644 core/set_newscan_date.js diff --git a/art/themes/luciano_blocktronics/FMENU.ANS b/art/themes/luciano_blocktronics/FMENU.ANS index a187491b8ea1d3615241ca70d469f5c0097bc40e..55879dd7e6b50547ef71dbaa3c7090cac8a5ddea 100644 GIT binary patch delta 94 zcmZpc*(|f+E1RLEvvjm!Zh?Yyw1Ks;S#EJ^i9%j#xk7PrVxB@ler8@tYLP-pVo9oW yw6S?EP^rP>?`)Y&CT5en*+VC%u`4qfSx)}R?l$=*yYglij+v~CCnn$HRRsXf`5%V> delta 49 zcmdli(=4;$D;txE*<>~LOh(Je57{-C3@s*4WDlJj!67r5kHeYC%ye@h#~fD18I$?= FQ~`Uh4jBLd diff --git a/art/themes/luciano_blocktronics/MSGMNU.ANS b/art/themes/luciano_blocktronics/MSGMNU.ANS index e27fed731dfca52ef127d946b6eb39994b54c8a0..ce6f4815645341d1f226f2de457a4156a048fe62 100644 GIT binary patch delta 752 zcmZ9KJxc>Y5QZ@^iCzp?iJC-KG^m|>ce$u(`~WQ!D?u?;FlS82DtHH$e$;}-Ep60N zEDVB$jac|A?%)dlfPX-6W^Q9J)v+`0`_6N-dq4V`zOJNZZoqW;A9ZF<<05FOu9rR12oc%JcSl>7A=O5UlAFF8Pbi`>FY;@ zQshF~Jh z&^}?#Uipw!%B~F!(&_EL{fzHsLnm!W7tcz8JH|VZs|jO}6c$kzO}%Ky^Tdp@g#?~* zDGk`(5)(PRh=O7Sg{cb!6BK7FK|fOG_e#!y`vy14q~TLYYH`5O~K9I(0WEebd@4L zz}5etOCg2(CptNK&b{e&^PTT?PQHxiZ09WDa-(8(uPwb?$oodc<=x)Pyj4K7;Q9<_ z2s-#>s(Fb+ZS29aO@u9za490*^6viH#kxX@XOWFP`0?7M2wuet;D)+VO*W^`_RqRx~F8)dM%NJwsWDz8jS&)rC%CHY0s# z59$1o@Qy}fzZH41)>Y7Em-Sob*Mp$y)gybVOe)_S5xw^L)uPH|*hrSZlHeszv?6Xb zOrEaF9xk=FsgV;MMso8ue={m2@`YxqpgXFI4X>&7DJjhRA?_t%7^are^1?1TnPp)5 z9Bk=#(^13#ZSrdJqkpFqm47~tFT^sGf!P8%4u}5a4lJZs6a}w+m_FW9KLl%q;RF+5 n7>WUtY$X(oF(K%f0-y3R7!au5f{h3c7m&+b#J1)gb{#;Yt9)nkj8wUvvtRFf zI^Ec$!;G)UW@QqjrgLBXByi9}aTc{quZ=}4DSxUMWfvtT^)Lv+a2`|wSJiQU3Y(Gk H-S>O}v}aWs literal 0 HcmV?d00001 diff --git a/art/themes/luciano_blocktronics/SETMNSDATE.ANS b/art/themes/luciano_blocktronics/SETMNSDATE.ANS new file mode 100644 index 0000000000000000000000000000000000000000..4d3b43a314582ba6d98d9721010a2432cb717c88 GIT binary patch literal 512 zcmb`DF;BxV5QUQsWo5(5EoH@yk*Ub!1gm051-QWADWtAcsTU*~1pk14jX2w(LJUlJ zuyd#PzIzwucy-Pzd4UCUs$ou+_(``!U`|w0VQZ|_3j?*b2A1X#Y@?TOrB{ZLmjomX zrh)*3VQwBM3j60_1ENrgMHr(ulzT0_ZKmVCp5sH;P84Mu{t3M?u_&B1{Ek9{l?Vo2}oiWAq=M=8jaA6w_ZwuBj&D z{`CpB<8PC(VRMdWG4bu61X`~5yW+&^`^In{M*q_(xtMdo)kmIXd47`}W$_$e*DX9O IbgB;b7b=f&VgLXD literal 0 HcmV?d00001 diff --git a/config/menu.hjson b/config/menu.hjson index 2225f274..8eaedb1e 100644 --- a/config/menu.hjson +++ b/config/menu.hjson @@ -1766,6 +1766,10 @@ value: { command: "]" } action: @systemMethod:nextArea } + { + value: { command: "D" } + action: @menu:messageAreaSetNewScanDate + } { value: 1 action: @menu:messageArea @@ -1803,6 +1807,47 @@ } } + messageAreaSetNewScanDate: { + module: set_newscan_date + desc: Message Base + art: SETMNSDATE + config: { + target: message + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + SM2: { + argName: targetSelection + submit: false + justify: right + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + messageAreaChangeCurrentArea: { // :TODO: rename this art to ACHANGE art: CHANGE @@ -2493,9 +2538,49 @@ value: { menuOption: "S" } action: @menu:fileBaseSearch } + { + value: { menuOption: "P" } + action: @menu:fileBaseSetNewScanDate + } ] } + fileBaseSetNewScanDate: { + module: set_newscan_date + desc: File Base + art: SETFNSDATE + config: { + target: file + scanDateFormat: YYYYMMDD + } + form: { + 0: { + mci: { + ME1: { + focus: true + submit: true + argName: scanDate + maskPattern: "####/##/##" + } + } + submit: { + *: [ + { + value: { scanDate: null } + action: @method:scanDateSubmit + } + ] + } + actionKeys: [ + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + } + } + fileBaseListEntries: { module: file_area_list desc: Browsing Files diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index 830e9daa..8da71d02 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -349,8 +349,8 @@ function FTNMessageScanTossModule() { message.meta.FtnKludge.FMPT = localAddress.point; } - if(_.get(message, 'meta.FtnProperty.ftn_dest_point', 0) > 0) { - message.meta.FtnKludge.TOPT = message.meta.FtnProperty.ftn_dest_point; + if(_.isNumber(options.destAddress.point) && options.destAddress.point > 0) { + message.meta.FtnKludge.TOPT = options.destAddress.point; } } else { // We need to set some destination info for EchoMail @@ -1954,8 +1954,7 @@ function FTNMessageScanTossModule() { // // // :TODO: fill out the rest of the consts here - // :TODO: this statement is crazy ugly - // :TODO: this should really check for extenral "flavor" and not-already-exported state_flags0 + // :TODO: this statement is crazy ugly -- use JOIN / NOT EXISTS for state_flags & 0x02 const getNewUuidsSql = `SELECT message_id, message_uuid FROM message m diff --git a/core/set_newscan_date.js b/core/set_newscan_date.js new file mode 100644 index 00000000..0e29e999 --- /dev/null +++ b/core/set_newscan_date.js @@ -0,0 +1,261 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const MenuModule = require('./menu_module.js').MenuModule; +const ViewController = require('./view_controller.js').ViewController; +const Errors = require('./enig_error.js').Errors; +const FileEntry = require('./file_entry.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const { getAvailableFileAreaTags } = require('./file_base_area.js'); +const { + getSortedAvailMessageConferences, + getSortedAvailMessageAreasByConfTag, + updateMessageAreaLastReadId, + getMessageIdNewerThanTimestampByArea +} = require('./message_area.js'); +const stringFormat = require('./string_format.js'); + +// deps +const async = require('async'); +const moment = require('moment'); +const _ = require('lodash'); + +exports.moduleInfo = { + name : 'Set New Scan Date', + desc : 'Sets new scan date for applicable scans', + author : 'NuSkooler', +}; + +const MciViewIds = { + main : { + scanDate : 1, + targetSelection : 2, + } +}; + +// :TODO: for messages, we could insert "conf - all areas" into targets, and allow such + +exports.getModule = class SetNewScanDate extends MenuModule { + constructor(options) { + super(options); + + const config = this.menuConfig.config; + + this.target = config.target || 'message'; + this.scanDateFormat = config.scanDateFormat || 'YYYYMMDD'; + + this.menuMethods = { + scanDateSubmit : (formData, extraArgs, cb) => { + let scanDate = _.get(formData, 'value.scanDate'); + if(!scanDate) { + return cb(Errors.MissingParam('"scanDate" missing from form data')); + } + + scanDate = moment(scanDate, this.scanDateFormat); + if(!scanDate.isValid()) { + return cb(Errors.Invalid(`"${_.get(formData, 'value.scanDate')}" is not a valid date`)); + } + + const targetSelection = _.get(formData, 'value.targetSelection'); // may be undefined if N/A + + this[`setNewScanDateFor${_.capitalize(this.target)}Base`](targetSelection, scanDate, () => { + return this.prevMenu(cb); + }); + }, + }; + } + + setNewScanDateForMessageBase(targetSelection, scanDate, cb) { + const target = this.targetSelections[targetSelection]; + if(!target) { + return cb(Errors.UnexpectedState('Unable to get target in which to set new scan')); + } + + // selected area, or all of 'em + let updateAreaTags; + if('' === target.area.areaTag) { + updateAreaTags = this.targetSelections + .map( targetSelection => targetSelection.area.areaTag ) + .filter( areaTag => areaTag ); // remove the blank 'all' entry + } else { + updateAreaTags = [ target.area.areaTag ]; + } + + async.each(updateAreaTags, (areaTag, nextAreaTag) => { + getMessageIdNewerThanTimestampByArea(areaTag, scanDate, (err, messageId) => { + if(err) { + return nextAreaTag(err); + } + + if(!messageId) { + return nextAreaTag(null); // nothing to do + } + + messageId = Math.max(messageId - 1, 0); + + return updateMessageAreaLastReadId( + this.client.user.userId, + areaTag, + messageId, + true, // allowOlder + nextAreaTag + ); + }); + }, err => { + return cb(err); + }); + } + + setNewScanDateForFileBase(targetSelection, scanDate, cb) { + // + // ENiGMA doesn't currently have the concept of per-area + // scan pointers for users, so we use all areas avail + // to the user. + // + const filterCriteria = { + areaTag : getAvailableFileAreaTags(this.client), + newerThanTimestamp : scanDate, + limit : 1, + orderBy : 'upload_timestamp', + order : 'ascending', + }; + + FileEntry.findFiles(filterCriteria, (err, fileIds) => { + if(err) { + return cb(err); + } + + if(!fileIds || 0 === fileIds.length) { + // nothing to do + return cb(null); + } + + const pointerFileId = Math.max(fileIds[0] - 1, 0); + + return FileBaseFilters.setFileBaseLastViewedFileIdForUser( + this.client.user, + pointerFileId, + true, // allowOlder + cb + ); + }); + } + + loadAvailMessageBaseSelections(cb) { + // + // Create an array of objects with conf/area information per entry, + // sorted naturally or via the 'sort' member in config + // + const selections = []; + getSortedAvailMessageConferences(this.client).forEach(conf => { + getSortedAvailMessageAreasByConfTag(conf.confTag, { client : this.client } ).forEach(area => { + selections.push({ + conf : { + confTag : conf.confTag, + name : conf.conf.name, + desc : conf.conf.desc, + }, + area : { + areaTag : area.areaTag, + name : area.area.name, + desc : area.area.desc, + } + }); + }); + }); + + selections.unshift({ + conf : { + confTag : '', + name : 'All conferences', + desc : 'All conferences', + }, + area : { + areaTag : '', + name : 'All areas', + desc : 'All areas', + } + }); + + // Find current conf/area & move it directly under "All" + const currConfTag = this.client.user.properties.message_conf_tag; + const currAreaTag = this.client.user.properties.message_area_tag; + if(currConfTag && currAreaTag) { + const confAreaIndex = selections.findIndex( confArea => { + return confArea.conf.confTag === currConfTag && confArea.area.areaTag === currAreaTag; + }); + + if(confAreaIndex > -1) { + selections.splice(1, 0, selections.splice(confAreaIndex, 1)[0]); + } + } + + this.targetSelections = selections; + + return cb(null); + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + const self = this; + const vc = self.addViewController( 'main', new ViewController( { client : this.client } ) ); + + async.series( + [ + function validateConfig(callback) { + if(![ 'message', 'file' ].includes(self.target)) { + return callback(Errors.Invalid(`Invalid "target" in config: ${self.target}`)); + } + // :TOD0: validate scanDateFormat + return callback(null); + }, + function loadFromConfig(callback) { + return vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); + }, + function loadAvailSelections(callback) { + switch(self.target) { + case 'message' : + return self.loadAvailMessageBaseSelections(callback); + + default : + return callback(null); + } + }, + function populateForm(callback) { + const today = moment(); + + const scanDateView = vc.getView(MciViewIds.main.scanDate); + + // :TODO: MaskTextEditView needs some love: If setText() with input that matches the mask, we should ignore the non-mask chars! Hack in place for now + const scanDateFormat = self.scanDateFormat.replace(/[\/\-. ]/g, ''); + scanDateView.setText(today.format(scanDateFormat)); + + if('message' === self.target) { + const messageSelectionsFormat = self.menuConfig.config.messageSelectionsFormat || '{conf.name} - {area.name}'; + const messageSelectionFocusFormat = self.menuConfig.config.messageSelectionFocusFormat || messageSelectionsFormat; + + const targetSelectionView = vc.getView(MciViewIds.main.targetSelection); + + targetSelectionView.setItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection))); + targetSelectionView.setFocusItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection))); + + targetSelectionView.setFocusItemIndex(0); + } + + self.viewControllers.main.resetInitialFocus(); + //vc.switchFocus(MciViewIds.main.scanDate); + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } +}; From 8804bcc5b26fd4cc3c306201272a5176e7fdcf35 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 14 Jan 2018 19:40:40 -0700 Subject: [PATCH 101/102] * Luciano Blocktronics themed web download manager --- art/themes/luciano_blocktronics/FWDLMGR.ANS | Bin 0 -> 2410 bytes art/themes/luciano_blocktronics/theme.hjson | 21 ++++++++++++++++++++ config/menu.hjson | 6 +++++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 art/themes/luciano_blocktronics/FWDLMGR.ANS diff --git a/art/themes/luciano_blocktronics/FWDLMGR.ANS b/art/themes/luciano_blocktronics/FWDLMGR.ANS new file mode 100644 index 0000000000000000000000000000000000000000..b0e7fffc2dc2b2135b582710406cce810fe22ed9 GIT binary patch literal 2410 zcmb_eO;Zy=6a;T_^`yD%#k2bnvT9BNQfL(c6@)`hh(S`7(GtOfzl!4TWUJ<%Sa$cD zncVGGr#G!i_glpWE;|`-=^~tW4o>#>4n{lK zWVAop9e-fd(EO_!u**8;LlxZxmVz;?Dsc|n5pjLFV0zRPJa;}{%)XwwnHkHR+1!u$ zFByofU;?PpC9z74qNwt~JwKil@FuW9BFf09vI?*fOiH)SvV%YK0S0?fq4I-X!KeBM zpMur8<*P1AOI#;KJ-QhXRVfgVFeN2bk&Le*etCzMR#;jKS#qPAbYlPno7pY7f_BDx2LA=Xp`F|;l9!WAmoxY6 zC-wl;4KH) zW@4{6G#i^06d43v_+CvSAvv6oNGe)d9MFScvV*yCOF0%x>Xh_hu>5uRpwOW|!bDBb zD9Ka0@gc-eG?d95ri5}1f^;ccTk`MM^Q&aXX0X!A zP$U!(+};{t{X4YaqeTyL+}uR%IzEw~VTTeaSw|h?3s<#5Gu&_(_+~>{8R87Z8h_DO zGFm=UPdmwEnyjEH7YLhUB?|rqb=aFVyzV^P;3ICL!MxCT$JE%+-LZPMqz%{H387R- zKO6R+hu6O}e&gBU=;QV{&k^~*o-!}$ye!H`kJr}L*Wax@UDNaA?baka-W}N~ef|OA Cy7z?u literal 0 HcmV?d00001 diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index d553ed83..19f63194 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -787,6 +787,27 @@ } } + fileBaseWebDownloadManager: { + config: { + queueListFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusQueueListFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" + queueManagerInfoFormat10: "|03batch|08: |03{webBatchDlLink}" + queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}" + } + + 0: { + mci: { + VM1: { + height: 8 + } + HM2: { + width: 50 + focusTextStyle: first lower + } + } + } + } + fileBaseUploadFiles: { config: { // processing diff --git a/config/menu.hjson b/config/menu.hjson index 8eaedb1e..386c6f89 100644 --- a/config/menu.hjson +++ b/config/menu.hjson @@ -2530,6 +2530,10 @@ value: { menuOption: "D" } action: @menu:fileBaseDownloadManager } + { + value: { menuOption: "W" } + action: @menu:fileBaseWebDownloadManager + } { value: { menuOption: "U" } action: @menu:fileBaseUploadFiles @@ -3262,7 +3266,7 @@ art: ULNOAREA options: { pause: true - menuFlags: [ "noHistory" ] + menuFlags: [ "noHistory", "popParent" ] } } From 28e4a5d8a298e6cee958f9725c6361f9b065d10d Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sun, 14 Jan 2018 20:28:19 -0700 Subject: [PATCH 102/102] Add WHATSNEW.md --- WHATSNEW.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 WHATSNEW.md diff --git a/WHATSNEW.md b/WHATSNEW.md new file mode 100644 index 00000000..667c296f --- /dev/null +++ b/WHATSNEW.md @@ -0,0 +1,26 @@ +# Whats New +This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub. + +## 0.0.8-alpha +* [Mystic BBS style](http://wiki.mysticbbs.com/doku.php?id=displaycodes) extended pipe color codes. These allow for example, to set "iCE" background colors. +* File descriptions (FILE_ID.DIZ, etc.) now support Renegade |## pipe, PCBoard, and other less common color codes found commonly in BBS era scene releases. +* New menu stack flags: `noHistory` now works as expected, and a new addition of `popParent`. See the default `menu.hjson` for usage. +* File structure changes making ENiGMA½ much easier to maintain and run in Docker. Thanks to RiPuk ([Dave Stephens](https://github.com/davestephens))! See [UPGRADE.md](UPGRADE.md) for details. +* Switch to pure JS [xxhash](https://github.com/mscdex/node-xxhash) instead of farmhash. Too many issues on ARM and other less popular CPUs with farmhash ([Dave Stephens](https://github.com/davestephens)) +* Native [CombatNet](http://combatnet.us/) support! ([Dave Stephens](https://github.com/davestephens)) +* Fix various issues with legacy DOS Telnet terminals. Note that some may still have issues with extensive CPR usage by ENiGMA½ that will be addressed in a future release. +* Added web (http://, https://) based download manager including batch downloads. Clickable links if using [VTXClient](https://github.com/codewar65/VTX_ClientServer)! +* General VTX hyperlink support for web links +* DEL vs Backspace key differences in FSE +* Correly parse oddball `INTL`, `TOPT`, `FMPT`, `Via`, etc. FTN kludge lines +* NetMail support! You can now send and receive NetMail. To send a NetMail address a external user using `Name
` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`) +* `oputil.js`: Added `mb areafix` command to quickly send AreaFix messages from the command line. You can manually send them from personal mail as well. +* `oputil.js fb rm|remove|del|delete` functionality to remove file base entries +* Users can now (re)set File and Message base pointers +* Add `--update` option to `oputil.js fb scan` +* Fix @watch path support for event scheduler including FTN, e.g. when looking for a `toss!.now` file produced by Binkd. + +...LOTS more! + +## Pre 0.0.8-alpha +See GitHub \ No newline at end of file