From 9a7e90b9b26ab061d7d0c43da6f22c50866595e9 Mon Sep 17 00:00:00 2001 From: NuSkooler Date: Thu, 16 Oct 2014 20:21:06 -0600 Subject: [PATCH] + Initial source checkin --- core/ansi_escape_parser.js | 285 ++++++++++++ core/ansi_term.js | 338 ++++++++++++++ core/art.js | 672 ++++++++++++++++++++++++++++ core/bbs.js | 155 +++++++ core/client.js | 89 ++++ core/client_term.js | 101 +++++ core/config.js | 50 +++ core/line_editor.js | 64 +++ core/logger.js | 39 ++ core/misc_util.js | 28 ++ core/modules.js | 85 ++++ core/servers/ssh.js | 30 ++ core/servers/telnet.js | 729 +++++++++++++++++++++++++++++++ main.js | 11 + misc/default_key.dsa | 12 + misc/default_key.dsa.pub | 1 + misc/default_key.rsa | 27 ++ misc/default_key.rsa.pub | 1 + mods/art/CONNECT1.ANS | Bin 0 -> 2019 bytes mods/art/DM-ENIG.ANS | 19 + mods/art/DM-ENIG.PCB | 19 + mods/art/DM-ENIG2-MATRIX.ANS | Bin 0 -> 3118 bytes mods/art/DM-ENIG2.ANS | 21 + mods/art/IC-CS.ANS | 551 +++++++++++++++++++++++ mods/art/MATRIX1.ANS | 19 + mods/art/SO-CC1.ANS | 823 +++++++++++++++++++++++++++++++++++ mods/art/we-wrong.ans | 22 + mods/art/zO-theGhetto.ans | 74 ++++ mods/connect.js | 55 +++ mods/matrix.js | 36 ++ package.json | 5 + 31 files changed, 4361 insertions(+) create mode 100644 core/ansi_escape_parser.js create mode 100644 core/ansi_term.js create mode 100644 core/art.js create mode 100644 core/bbs.js create mode 100644 core/client.js create mode 100644 core/client_term.js create mode 100644 core/config.js create mode 100644 core/line_editor.js create mode 100644 core/logger.js create mode 100644 core/misc_util.js create mode 100644 core/modules.js create mode 100644 core/servers/ssh.js create mode 100644 core/servers/telnet.js create mode 100644 main.js create mode 100644 misc/default_key.dsa create mode 100644 misc/default_key.dsa.pub create mode 100644 misc/default_key.rsa create mode 100644 misc/default_key.rsa.pub create mode 100644 mods/art/CONNECT1.ANS create mode 100644 mods/art/DM-ENIG.ANS create mode 100644 mods/art/DM-ENIG.PCB create mode 100644 mods/art/DM-ENIG2-MATRIX.ANS create mode 100644 mods/art/DM-ENIG2.ANS create mode 100644 mods/art/IC-CS.ANS create mode 100644 mods/art/MATRIX1.ANS create mode 100644 mods/art/SO-CC1.ANS create mode 100644 mods/art/we-wrong.ans create mode 100644 mods/art/zO-theGhetto.ans create mode 100644 mods/connect.js create mode 100644 mods/matrix.js create mode 100644 package.json diff --git a/core/ansi_escape_parser.js b/core/ansi_escape_parser.js new file mode 100644 index 00000000..89ac9a99 --- /dev/null +++ b/core/ansi_escape_parser.js @@ -0,0 +1,285 @@ +/* jslint node: true */ +'use strict'; + +var events = require('events'); +var util = require('util'); +var miscUtil = require('./misc_util.js'); + +exports.ANSIEscapeParser = ANSIEscapeParser; + + +function ANSIEscapeParser(options) { + var self = this; + + events.EventEmitter.call(this); + + this.column = 1; + this.row = 1; + this.flags = 0x00; + this.scrollBack = 0; + + + + options = miscUtil.valueWithDefault(options, { + mciReplaceChar : '', + termHeight : 25, + termWidth : 80, + }); + + this.mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, ''); + this.termHeight = miscUtil.valueWithDefault(options.termHeight, 25); + this.termWidth = miscUtil.valueWithDefault(options.termWidth, 80); + + function getArgArray(array) { + var i = array.length; + while(i--) { + array[i] = parseInt(array[i], 10); + } + return array; + } + + self.moveCursor = function(cols, rows) { + self.column += cols; + self.row += rows; + + self.column = Math.max(self.column, 1); + self.column = Math.min(self.column, self.termWidth); + self.row = Math.max(self.row, 1); + self.row = Math.min(self.row, self.termHeight); + + self.emit('move cursor', self.column, self.row); + self.rowUpdated(); + }; + + self.saveCursorPosition = function() { + self.savedPosition = { + row : self.row, + column : self.column + }; + }; + + self.restoreCursorPosition = function() { + self.row = self.savedPosition.row; + self.column = self.savedPosition.column; + delete self.savedPosition; + self.rowUpdated(); + }; + + self.clearScreen = function() { + // :TODO: should be doing something with row/column? + self.emit('clear screen'); + }; + + self.resetColor = function() { + self.fgColor = 7; + self.bgColor = 0; + }; + + self.rowUpdated = function() { + self.emit('row update', self.row + self.scrollBack); + }; + + function literal(text) { + var CR = 0x0d; + var LF = 0x0a; + var charCode; + + var len = text.length; + for(var i = 0; i < len; i++) { + charCode = text.charCodeAt(i) & 0xff; // ensure 8 bit + switch(charCode) { + case CR : + self.column = 1; + break; + + case LF : + self.row++; + self.rowUpdated(); + break; + + default : + // wrap + if(self.column === self.termWidth) { + self.column = 1; + self.row++; + self.rowUpdated(); + } else { + self.column++; + } + break; + } + + if(self.row === 26) { + self.scrollBack++; + self.row--; + self.rowUpdated(); + } + } + + self.emit('chunk', text); + } + + function mci(mciCode, args) { + console.log(mciCode, args); + } + + function getProcessedMCI(mci) { + if(self.mciReplaceChar.length > 0) { + return new Array(mci.length + 1).join(self.mciReplaceChar); + } else { + return mci; + } + } + + function parseMCI(buffer) { + var mciRe = /\%([A-Z]{2}[0-9]{1,2})(?:\(([0-9A-Z,]+)\))*/g; + var pos = 0; + var match; + var mciCode; + var args; + + do { + pos = mciRe.lastIndex; + match = mciRe.exec(buffer); + + if(null !== match) { + if(match.index > pos) { + literal(buffer.slice(pos, match.index)); + } + + mciCode = match[1]; + + if(match[2]) { + args = match[2].split(','); + } else { + args = []; + } + + + self.emit('mci', mciCode, args); + + self.emit('chunk', getProcessedMCI(match[0])); + } + + } while(0 !== mciRe.lastIndex); + + if(pos < buffer.length) { + literal(buffer.slice(pos)); + } + } + + self.parse = function(buffer, savedRe) { + // :TODO: ensure this conforms to ANSI-BBS / CTerm / bansi.txt for movement/etc. + var re = /(?:\x1b\x5b)([\?=;0-9]*?)([ABCDHJKfhlmnpsu])/g; + var pos = 0; + var match; + var opCode; + var args; + + // ignore anything past EOF marker, if any + buffer = buffer.split(String.fromCharCode(0x1a), 1)[0]; + + do { + pos = re.lastIndex; + match = re.exec(buffer); + + if(null !== match) { + if(match.index > pos) { + parseMCI(buffer.slice(pos, match.index)); + } + + opCode = match[2]; + args = getArgArray(match[1].split(';')); + + escape(opCode, args); + + self.emit('chunk', match[0]); + } + + } while(0 !== re.lastIndex); + + if(pos < buffer.length) { + parseMCI(buffer.slice(pos)); + } + + self.emit('complete'); + }; + + function escape(opCode, args) { + var arg; + var i; + var len; + + switch(opCode) { + // cursor up + case 'A' : + arg = args[0] || 1; + self.moveCursor(0, -arg); + break; + + // cursor down + case 'B' : + arg = args[0] || 1; + self.moveCursor(0, arg); + break; + + // cursor forward/right + case 'C' : + arg = args[0] || 1; + self.moveCursor(arg, 0); + break; + + // cursor back/left + case 'D' : + arg = args[0] || 1; + self.moveCursor(-arg, 0); + break; + + case 'f' : // horiz & vertical + case 'H' : // cursor position + self.row = args[0] || 1; + self.column = args[1] || 1; + self.rowUpdated(); + break; + + // save position + case 's' : + self.saveCursorPosition(); + break; + + // restore position + case 'u' : + self.restoreCursorPosition(); + break; + + // set graphic rendition + case 'm' : + for(i = 0, len = args.length; i < len; ++i) { + arg = args[i]; + if(0x00 === arg) { + self.flags = 0x00; + self.resetColor(); + } else { + switch(Math.floor(arg / 10)) { + case 0 : self.flags |= arg; break; + case 3 : self.fgColor = arg; break; + case 4 : self.bgColor = arg; break; + //case 3 : self.fgColor = arg - 30; break; + //case 4 : self.bgColor = arg - 40; break; + } + } + } + break; + + // erase display/screen + case 'J' : + // :TODO: Handle others + if(2 === args[0]) { + self.clearScreen(); + } + break; + } + } +} + +util.inherits(ANSIEscapeParser, events.EventEmitter); \ No newline at end of file diff --git a/core/ansi_term.js b/core/ansi_term.js new file mode 100644 index 00000000..4b7b57bd --- /dev/null +++ b/core/ansi_term.js @@ -0,0 +1,338 @@ +/* jslint node: true */ +'use strict'; + +// +// ANSI Terminal Support +// +// Resources: +// * http://ansi-bbs.org/ +// * http://www.bbsdocumentary.com/library/PROGRAMS/GRAPHICS/ANSI/ansisys.txt +// * http://en.wikipedia.org/wiki/ANSI_escape_code +// + +var assert = require('assert'); +var binary = require('binary'); +var miscUtil = require('./misc_util.js'); + +exports.sgr = sgr; +exports.clearScreen = clearScreen; +exports.clearScreenGoHome = clearScreenGoHome; +exports.normal = normal; +exports.goHome = goHome; +exports.disableVT100LineWrapping = disableVT100LineWrapping; +exports.setSyncTermFont = setSyncTermFont; +exports.fromPipeCode = fromPipeCode; +exports.forEachControlCode = forEachControlCode; + + +// +// See also +// https://github.com/TooTallNate/ansi.js/blob/master/lib/ansi.js + +var ESC_CSI = '\u001b['; + +var CONTROL = { + up : 'A', + down : 'B', + forward : 'C', + back : 'D', + nextLine : 'E', + prevLine : 'F', + horizAbsolute : 'G', + eraseData : 'J', + scrollUp : 'S', + scrollDown : 'T', + savePos : 's', + restorePos : 'u', + queryPos : '6n', + goto : 'H', // row Pr, column Pc -- same as f + gotoAlt : 'f' // same as H +}; + +/* + DECTERM stuff. Probably never need + hide : '?25l', + show : '?25h',*/ + +// +// Select Graphics Rendition +// See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt +// +var SGR = { + reset : 0, + bold : 1, + dim : 2, + blink : 5, + fastBlink : 6, + negative : 7, + hidden : 8, + + normal : 22, + steady : 25, + positive : 27, + + black : 30, + red : 31, + green : 32, + yellow : 33, + blue : 34, + magenta : 35, + cyan : 36, + white : 37, + + blackBG : 40, + redBG : 41, + greenBG : 42, + yellowBG : 43, + blueBG : 44, + magentaBG : 45, + cyanBG : 47, + whiteBG : 47, +}; + +// See http://cvs.synchro.net/cgi-bin/viewcvs.cgi/*checkout*/src/conio/cterm.txt +// :TODO: document +var SYNC_TERM_FONTS = [ + 'cp437', + 'cp1251', + 'koi8_r', + 'iso8859_2', + 'iso8859_4', + 'cp866', + 'iso8859_9', + 'haik8', + 'iso8859_8', + 'koi8_u', + 'iso8859_15', + 'iso8859_4', + 'koi8_r_b', + 'iso8859_4', + 'iso8859_5', + 'ARMSCII_8', + 'iso8859_15', + 'cp850', + 'cp850', + 'cp885', + 'cp1251', + 'iso8859_7', + 'koi8-r_c', + 'iso8859_4', + 'iso8859_1', + 'cp866', + 'cp437', + 'cp866', + 'cp885', + 'cp866_u', + 'iso8859_1', + 'cp1131', + 'c64_upper', + 'c64_lower', + 'c128_upper', + 'c128_lower', + 'atari', + 'pot_noodle', + 'mo_soul', + 'microknight', + 'topaz' +]; + +// Create methods such as up(), nextLine(),... +Object.keys(CONTROL).forEach(function onControlName(name) { + var code = CONTROL[name]; + + exports[name] = function() { + var c = code; + if(arguments.length > 0) { + // arguments are array like -- we want an array + c = Array.prototype.slice.call(arguments).map(Math.round).join(';') + code; + } + return ESC_CSI + c; + }; +}); + +// Create a reverse map of CONTROL values to their key/names + +/* +var CONTROL_REVERSE_MAP = {}; +Object.keys(CONTROL).forEach(function onControlName(name) { + var code = CONTROL[name]; + + CONTROL_REVERSE_MAP[code] = name; +}); +*/ + +var CONTROL_RESPONSE = { + 'R' : 'position', +}; + +// :TODO: move this to misc utils or such -- use here & parser +function getIntArgArray(array) { + var i = array.length; + while(i--) { + array[i] = parseInt(array[i], 10); + } + return array; +} + +// :TODO: rename this +function forEachControlCode(data, cb) { + //var re = /\u001b\[([0-9\;])*[R]/g; + + var len = data.length; + var pos = 0; + + while(pos < len) { + if(0x1b !== data[pos++] || 0x5b !== data[pos++]) { + continue; + } + + var params = ''; + + while(pos < len) { + var c = data[pos++]; + + if(((c > 64) && (c < 91)) || ((c > 96) && (c < 123))) { + c = String.fromCharCode(c); + var name = CONTROL_RESPONSE[c]; + if(name) { + params = getIntArgArray(params.split(';')); + cb(name, params); + } + } + + params += String.fromCharCode(c); + } + } +} + +// Create various color methods such as white(), yellowBG(), reset(), ... +Object.keys(SGR).forEach(function onSgrName(name) { + var code = SGR[name]; + + exports[name] = function() { + return ESC_CSI + code + 'm'; + }; +}); + +function sgr() { + // + // - Allow an single array or variable number of arguments + // - Each element can be either a integer or string found in SGR + // which in turn maps to a integer + // + if(arguments.length <= 0) { + return ''; + } + + var result = ''; + + // :TODO: this method needs a lot of cleanup! + + var args = Array.isArray(arguments[0]) ? arguments[0] : arguments; + for(var i = 0; i < args.length; i++) { + if(typeof args[i] === 'string') { + if(args[i] in SGR) { + if(result.length > 0) { + result += ';'; + } + result += SGR[args[i]]; + } + } else if(typeof args[i] === 'number') { + if(result.length > 0) { + result += ';'; + } + result += args[i]; + } + } + return ESC_CSI + result + 'm'; +} + +/////////////////////////////////////////////////////////////////////////////// +// Shortcuts for common functions +/////////////////////////////////////////////////////////////////////////////// + +function clearScreen() { + return exports.eraseData(2); +} + +function clearScreenGoHome() { + return exports.goto(1,1) + exports.eraseData(2); +} + +function normal() { + return sgr(['normal', 'reset']); +} + +function goHome() { + return exports.goto(); // no params = home = 1,1 +} + +// +// See http://www.termsys.demon.co.uk/vtANSI_BBS.htm +// +function disableVT100LineWrapping() { + return ESC_CSI + '7l'; +} + +function setSyncTermFont(name, fontPage) { + fontPage = miscUtil.valueWithDefault(fontPage, 0); + + assert(fontPage === 0 || fontPage === 1); // see spec + + var i = SYNC_TERM_FONTS.indexOf(name); + if(-1 != i) { + return ESC_CSI + fontPage + ';' + i + ' D'; + } + return ''; +} + +// Also add: +// * fromRenegade(): |<0-23> +// * fromCelerity(): | +// * fromPCBoard(): (@X@) +// * fromWildcat(): (@@ (same as PCBoard without 'X' prefix) +// * fromWWIV(): <0-7> +// * fromSyncronet(): +// See http://wiki.synchro.net/custom:colors +function fromPipeCode(s) { + if(-1 == s.indexOf('|')) { + return s; // no pipe codes present + } + + var result = ''; + var re = /\|(\d{2,3}|\|)/g; + var m; + var lastIndex = 0; + while((m = re.exec(s))) { + var val = m[1]; + + if('|' == val) { + result += '|'; + continue; + } + + // convert to number + val = parseInt(val, 10); + if(isNaN(val)) { + val = 0; + } + + assert(val >= 0 && val <= 256); + + var attr = ''; + if(7 == val) { + attr = sgr('normal'); + } else if (val < 7 || val >= 16) { + attr = sgr(['normal', val]); + } else if (val <= 15) { + attr = sgr(['normal', val - 8, 'bold']); + } + + result += s.substr(lastIndex, m.index - lastIndex) + attr; + lastIndex = re.lastIndex; + } + + result = (0 === result.length ? s : result + s.substr(lastIndex)); + + return result; +} \ No newline at end of file diff --git a/core/art.js b/core/art.js new file mode 100644 index 00000000..f57888bd --- /dev/null +++ b/core/art.js @@ -0,0 +1,672 @@ +/* jslint node: true */ +'use strict'; + +var fs = require('fs'); +var paths = require('path'); +var assert = require('assert'); +var iconv = require('iconv-lite'); +var conf = require('./config.js'); +var miscUtil = require('./misc_util.js'); +var binary = require('binary'); +var events = require('events'); +var util = require('util'); +var ansi = require('./ansi_term.js'); +var aep = require('./ansi_escape_parser.js'); + +exports.getArt = getArt; +exports.getArtFromPath = getArtFromPath; +exports.display = display; +exports.defaultEncodingFromExtension = defaultEncodingFromExtension; +exports.ArtDisplayer = ArtDisplayer; + + +var SAUCE_SIZE = 128; +var SAUCE_ID = new Buffer([0x53, 0x41, 0x55, 0x43, 0x45]); // 'SAUCE' +var COMNT_ID = new Buffer([0x43, 0x4f, 0x4d, 0x4e, 0x54]); // 'COMNT' + +// :TODO: Return MCI code information +// :TODO: process SAUCE comments +// :TODO: return font + font mapped information from SAUCE + +var SUPPORTED_ART_TYPES = { + // :TODO: the defualt encoding are really useless if they are all the same ... + // perhaps .ansamiga and .ascamiga could be supported as well as overrides via conf + '.ans' : { name : 'ANSI', defaultEncoding : 'cp437', eof : 0x1a }, + '.asc' : { name : 'ASCII', defaultEncoding : 'cp437', eof : 0x1a }, + '.pcb' : { name : 'PCBoard', defaultEncoding : 'cp437', eof : 0x1a }, + '.bbs' : { name : 'Wildcat', defaultEncoding : 'cp437', eof : 0x1a }, + '.txt' : { name : 'Text', defaultEncoding : 'cp437', eof : 0x1a }, // :TODO: think about this more... + // :TODO: extentions for wwiv, renegade, celerity, syncronet, ... + // :TODO: extension for atari + // :TODO: extension for topaz ansi/ascii. +}; + +// +// See +// http://www.acid.org/info/sauce/sauce.htm +// +// :TODO: Move all SAUCE stuff to sauce.js +function readSAUCE(data, cb) { + if(data.length < SAUCE_SIZE) { + cb(new Error('No SAUCE record present')); + return; + } + + var offset = data.length - SAUCE_SIZE; + var sauceRec = data.slice(offset); + + binary.parse(sauceRec) + .buffer('id', 5) + .buffer('version', 2) + .buffer('title', 35) + .buffer('author', 20) + .buffer('group', 20) + .buffer('date', 8) + .word32lu('fileSize') + .word8('dataType') + .word8('fileType') + .word16lu('tinfo1') + .word16lu('tinfo2') + .word16lu('tinfo3') + .word16lu('tinfo4') + .word8('numComments') + .word8('flags') + .buffer('tinfos', 22) // SAUCE 00.5 + .tap(function onVars(vars) { + + if(!SAUCE_ID.equals(vars.id)) { + cb(new Error('No SAUCE record present')); + return; + } + + var ver = vars.version.toString('cp437'); + + if('00' !== ver) { + cb(new Error('Unsupported SAUCE version: ' + ver)); + return; + } + + var sauce = { + id : vars.id.toString('cp437'), + version : vars.version.toString('cp437'), + title : vars.title.toString('cp437').trim(), + author : vars.author.toString('cp437').trim(), + group : vars.group.toString('cp437').trim(), + date : vars.date.toString('cp437').trim(), + fileSize : vars.fileSize, + dataType : vars.dataType, + fileType : vars.fileType, + tinfo1 : vars.tinfo1, + tinfo2 : vars.tinfo2, + tinfo3 : vars.tinfo3, + tinfo4 : vars.tinfo4, + numComments : vars.numComments, + flags : vars.flags, + tinfos : vars.tinfos, + }; + + var dt = SAUCE_DATA_TYPES[sauce.dataType]; + if(dt && dt.parser) { + sauce[dt.name] = dt.parser(sauce); + } + + cb(null, sauce); + }); +} + +// :TODO: These need completed: +var SAUCE_DATA_TYPES = {}; +SAUCE_DATA_TYPES[0] = { name : 'None' }; +SAUCE_DATA_TYPES[1] = { name : 'Character', parser : parseCharacterSAUCE }; +SAUCE_DATA_TYPES[2] = 'Bitmap'; +SAUCE_DATA_TYPES[3] = 'Vector'; +SAUCE_DATA_TYPES[4] = 'Audio'; +SAUCE_DATA_TYPES[5] = 'BinaryText'; +SAUCE_DATA_TYPES[6] = 'XBin'; +SAUCE_DATA_TYPES[7] = 'Archive'; +SAUCE_DATA_TYPES[8] = 'Executable'; + +var SAUCE_CHARACTER_FILE_TYPES = {}; +SAUCE_CHARACTER_FILE_TYPES[0] = 'ASCII'; +SAUCE_CHARACTER_FILE_TYPES[1] = 'ANSi'; +SAUCE_CHARACTER_FILE_TYPES[2] = 'ANSiMation'; +SAUCE_CHARACTER_FILE_TYPES[3] = 'RIP script'; +SAUCE_CHARACTER_FILE_TYPES[4] = 'PCBoard'; +SAUCE_CHARACTER_FILE_TYPES[5] = 'Avatar'; +SAUCE_CHARACTER_FILE_TYPES[6] = 'HTML'; +SAUCE_CHARACTER_FILE_TYPES[7] = 'Source'; +SAUCE_CHARACTER_FILE_TYPES[8] = 'TundraDraw'; + +// +// Map of SAUCE font -> encoding hint +// +// Note that this is the same mapping that x84 uses. Be compatible! +// +var SAUCE_FONT_TO_ENCODING_HINT = { + 'Amiga MicroKnight' : 'amiga', + 'Amiga MicroKnight+' : 'amiga', + 'Amiga mOsOul' : 'amiga', + 'Amiga P0T-NOoDLE' : 'amiga', + 'Amiga Topaz 1' : 'amiga', + 'Amiga Topaz 1+' : 'amiga', + 'Amiga Topaz 2' : 'amiga', + 'Amiga Topaz 2+' : 'amiga', + 'Atari ATASCII' : 'atari', + 'IBM EGA43' : 'cp437', + 'IBM EGA' : 'cp437', + 'IBM VGA25G' : 'cp437', + 'IBM VGA50' : 'cp437', + 'IBM VGA' : 'cp437', +}; + +['437', '720', '737', '775', '819', '850', '852', '855', '857', '858', +'860', '861', '862', '863', '864', '865', '866', '869', '872'].forEach(function onPage(page) { + var codec = 'cp' + page; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA43 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM EGA ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA25g ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA50 ' + page] = codec; + SAUCE_FONT_TO_ENCODING_HINT['IBM VGA ' + page] = codec; +}); + +function parseCharacterSAUCE(sauce) { + var result = {}; + + result.fileType = SAUCE_CHARACTER_FILE_TYPES[sauce.fileType] || 'Unknown'; + + if(sauce.fileType === 0 || sauce.fileType === 1 || sauce.fileType === 2) { + var i = 0; + while(i < sauce.tinfos.length && sauce.tinfos[i] !== 0x00) { + ++i; + } + var fontName = sauce.tinfos.slice(0, i).toString('cp437'); + if(fontName.length > 0) { + result.fontName = fontName; + } + } + + return result; +} + +function sliceAtEOF(data, eofMarker) { + var eof = data.length; + // :TODO: max scan back or other beter way of doing this?! + for(var i = data.length - 1; i > 0; i--) { + if(data[i] === eofMarker) { + eof = i; + break; + } + } + return data.slice(0, eof); +} + +function getArtFromPath(path, options, cb) { + fs.readFile(path, function onData(err, data) { + if(err) { + cb(err); + return; + } + + // + // Convert from encodedAs -> j + // + var ext = paths.extname(path).toLowerCase(); + var encoding = options.encodedAs || defaultEncodingFromExtension(ext); + + // :TODO: how are BOM's currently handled if present? Are they removed? Do we need to? + + function sliceOfData() { + if(options.fullFile === true) { + return iconv.decode(data, encoding); + } else { + var eofMarker = defaultEofFromExtension(ext); + return iconv.decode(sliceAtEOF(data, eofMarker), encoding); + } + } + + function getResult(sauce) { + var result = { + data : sliceOfData(), + fromPath : path, + }; + + if(sauce) { + result.sauce = sauce; + } + + return result; + } + + if(options.readSauce === true) { + readSAUCE(data, function onSauce(err, sauce) { + if(err) { + cb(null, getResult()); + } else { + // + // If a encoding was not provided & we have a mapping from + // the information provided by SAUCE, use that. + // + if(!options.encodedAs) { + if(sauce.Character && sauce.Character.fontName) { + var enc = SAUCE_FONT_TO_ENCODING_HINT[sauce.Character.fontName]; + if(enc) { + encoding = enc; + } + } + } + cb(null, getResult(sauce)); + } + }); + } else { + cb(null, getResult()); + } + }); +} + +function getArt(name, options, cb) { + var ext = paths.extname(name); + + options.basePath = miscUtil.valueWithDefault(options.basePath, conf.config.paths.art); + options.asAnsi = miscUtil.valueWithDefault(options.asAnsi, true); + + // :TODO: make use of asAnsi option and convert from supported -> ansi + + if('' !== ext) { + options.types = [ ext.toLowerCase() ]; + } else { + if(typeof options.types === 'undefined') { + options.types = Object.keys(SUPPORTED_ART_TYPES); + } else if(typeof options.types === 'string') { + options.types = [ options.types.toLowerCase() ]; + } + } + + // If an extension is provided, just read the file now + if('' !== ext) { + var directPath = paths.join(options.basePath, name); + getArtFromPath(directPath, options, cb); + return; + } + + fs.readdir(options.basePath, function onFiles(err, files) { + if(err) { + cb(err); + return; + } + + var filtered = files.filter(function onFile(file) { + // + // Ignore anything not allowed in |options.types| + // + var fext = paths.extname(file); + if(options.types.indexOf(fext.toLowerCase()) < 0) { + return false; + } + + var bn = paths.basename(file, fext).toLowerCase(); + if(options.random) { + var suppliedBn = paths.basename(name, fext).toLowerCase(); + // + // Random selection enabled. We'll allow for + // basename1.ext, basename2.ext, ... + // + if(bn.indexOf(suppliedBn) !== 0) { + return false; + } + var num = bn.substr(suppliedBn.length); + if(num.length > 0) { + if(isNaN(parseInt(num, 10))) { + return false; + } + } + } else { + // + // We've already validated the extension (above). Must be an exact + // match to basename here + // + if(bn != paths.basename(name, fext).toLowerCase()) { + return false; + } + } + return true; + }); + + if(filtered.length > 0) { + // + // We should now have: + // - Exactly (1) item in |filtered| if non-random + // - 1:n items in |filtered| to choose from if random + // + var readPath; + if(options.random) { + readPath = paths.join(options.basePath, filtered[Math.floor(Math.random() * filtered.length)]); + } else { + assert(1 === filtered.length); + readPath = paths.join(options.basePath, filtered[0]); + } + + getArtFromPath(readPath, options, cb); + } else { + cb(new Error('No matching art for supplied criteria')); + } + }); +} + +// :TODO: need a showArt() +// - center (if term width > 81) +// - interruptable +// - pausable: by user key and/or by page size (e..g term height) + + +function defaultEncodingFromExtension(ext) { + return SUPPORTED_ART_TYPES[ext.toLowerCase()].defaultEncoding; +} + +function defaultEofFromExtension(ext) { + return SUPPORTED_ART_TYPES[ext.toLowerCase()].eof; +} + +function ArtDisplayer(client) { + if(!(this instanceof ArtDisplayer)) { + return new ArtDisplayer(client); + } + + events.EventEmitter.call(this); + + this.client = client; +} + +util.inherits(ArtDisplayer, events.EventEmitter); + +// :TODO: change to display(art, options, cb) +// cb(err, mci) + +function display(art, options, cb) { + if(!art || 0 === art.length) { + cb(new Error('Missing or empty art')); + return; + } + + if('undefined' === typeof options) { + cb(new Error('Missing options')); + return; + } + + if('undefined' === typeof options.client) { + cb(new Error('Missing client in options')); + return; + } + + var cancelKeys = miscUtil.valueWithDefault(options.cancelKeys, []); + var pauseKeys = miscUtil.valueWithDefault(options.pauseKeys, []); + var pauseAtTermHeight = miscUtil.valueWithDefault(options.pauseAtTermHeight, false); + var mciReplaceChar = miscUtil.valueWithDefault(options.mciReplaceChar, ''); + + // :TODO: support pause/cancel & pause @ termHeight + var canceled = false; + + var parser = new aep.ANSIEscapeParser({ + mciReplaceChar : mciReplaceChar, + termHeight : options.client.term.termHeight, + termWidth : options.client.term.termWidth, + }); + + var mci = {}; + var mciPosQueue = []; + var emitter = null; + var parseComplete = false; + + parser.on('mci', function onMCI(mciCode, args) { + if(mci[mciCode]) { + mci[mciCode].altColor = { + fg : parser.fgColor, + bg : parser.bgColor, + flags : parser.flags, + }; + } else { + mci[mciCode] = { + args : args, + color : { + fg : parser.fgColor, + bg : parser.bgColor, + flags : parser.flags, + } + }; + + mciPosQueue.push(mciCode); + + if(!emitter) { + emitter = options.client.on('onPosition', function onPosition(pos) { + if(mciPosQueue.length > 0) { + var forMciCode = mciPosQueue.shift(); + mci[forMciCode].position = pos; + + if(parseComplete && 0 === mciPosQueue.length) { + cb(null, mci); + } + } + }); + } + + options.client.term.write(ansi.queryPos()); + } + }); + + parser.on('chunk', function onChunk(chunk) { + options.client.term.write(chunk); + }); + + parser.on('complete', function onComplete() { + parseComplete = true; + + if(0 === mciPosQueue.length) { + cb(null, mci); + } + }); + + parser.parse(art); +} + +ArtDisplayer.prototype.display = function(art, options) { + var client = this.client; + var self = this; + + var cancelKeys = miscUtil.valueWithDefault(options.cancelKeys, []); + var pauseKeys = miscUtil.valueWithDefault(options.pauseKeys, []); + var pauseAtTermHeight = miscUtil.valueWithDefault(options.pauseAtTermHeight, false); + + var canceled = false; + if(cancelKeys.length > 0 || pauseKeys.length > 0) { + var onDataKeyCheck = function(data) { + var key = String.fromCharCode(data[0]); + if(-1 !== cancelKeys.indexOf(key)) { + canceled = true; + removeDataListener(); + } + }; + client.on('data', onDataKeyCheck); + } + + function removeDataListener() { + client.removeListener('data', onDataKeyCheck); + } + + // + // Try to split lines supporting various linebreaks we may encounter: + // - DOS \r\n + // - *nix \n + // - Old Apple \r + // - Unicode PARAGRAPH SEPARATOR (U+2029) and LINE SEPARATOR (U+2028) + // + // See also http://stackoverflow.com/questions/5034781/js-regex-to-split-by-line + // + var lines = art.split(/\r?\n|\r|[\u2028\u2029]/); + var i = 0; + var count = lines.length; + if(0 === count) { + return; + } + + var termHeight = client.term.termHeight; + + var aep = require('./ansi_escape_parser.js'); + var p = new aep.ANSIEscapeParser(); + + var currentRow = 0; + var lastRow = 0; + p.on('row update', function onRowUpdated(row) { + currentRow = row; + }); + + //-------- + var mci = {}; + var mciPosQueue = []; + var parseComplete = false; + + var emitter = null; + + p.on('mci', function onMCI(mciCode, args) { + if(mci[mciCode]) { + mci[mciCode].fgColorAlt = p.fgColor; + mci[mciCode].bgColorAlt = p.bgColor; + mci[mciCode].flagsAlt = p.flags; + } else { + mci[mciCode] = { + args : args, + fgColor : p.fgColor, + bgColor : p.bgColor, + flags : p.flags, + }; + + mciPosQueue.push(mciCode); + + if(!emitter) { + emitter = client.on('onPosition', function onPosition(pos) { + if(mciPosQueue.length > 0) { + var mc = mciPosQueue.shift(); + console.log('position @ ' + mc + ': ' + pos); + mci[mc].pos = pos; + + if(parseComplete && 0 === mciPosQueue.length) { + //console.log(mci); + var p1 = mci['LV1'].pos; + client.term.write(ansi.sgr(['red'])); + var g = ansi.goto(p1); + console.log(g); + client.term.write(ansi.goto(p1[0], p1[1])); + client.term.write('Hello, World'); + } + } + }); + } + } + }); + + p.on('chunk', function onChunk(chunk) { + client.term.write(chunk); + }); + + p.on('complete', function onComplete() { + //console.log(mci); + parseComplete = true; + if(0 === mciPosQueue.length) { + console.log('mci from complete'); + console.log(mci); + } + }); + + p.parse(art); + + + //----------- + /* + var line; + (function nextLine() { + if(i === count) { + self.emit('complete'); + removeDataListener(); + return; + } + + if(canceled) { + self.emit('canceled'); + removeDataListener(); + return; + } + + line = lines[i]; + client.term.write(line + '\n'); + p.parse(line + '\r\n'); + i++; + + if(pauseAtTermHeight && currentRow !== lastRow && (0 === currentRow % termHeight)) { + lastRow = currentRow; + client.getch(function onKey(k) { + nextLine(); + }); + } else { + setTimeout(nextLine, 20); + } + })(); + + */ + +/* + + (function nextLine() { + if(i === count) { + client.emit('complete', true); + removeDataListener(); + return; + } + + if(canceled) { + console.log('canceled'); + client.emit('canceled'); + removeDataListener(); + return; + } + + client.term.write(lines[i] + '\n'); + + // + // :TODO: support pauseAtTermHeight: + // + // - All cursor movement should be recorded for pauseAtTermHeight support & + // handling > termWidth scenarios + // - MCI codes should be processed + // - All other ANSI/CSI ignored + // - Count normal chars + // + + //setTimeout(nextLine, 20); + //i++; + + if(pauseAtTermHeight && i > 0 && (0 === i % termHeight)) { + console.log('pausing @ ' + i); + client.getch(function onKey() { + i++; + nextLine(); + }); + } else { + i++; + // :TODO: If local, use setTimeout(nextLine, 20) or so -- allow to pause/cancel + //process.nextTick(nextLine); + setTimeout(nextLine, 20); + } + + })(); +*/ +}; + +// +// ANSI parser for quick scanning & handling +// of basic ANSI sequences that can be used for output to clients: +// +function ANSIOutputParser(ansi) { + // + // cb's + // - onMCI + // - onTermHeight + // - +} \ No newline at end of file diff --git a/core/bbs.js b/core/bbs.js new file mode 100644 index 00000000..23a9a18f --- /dev/null +++ b/core/bbs.js @@ -0,0 +1,155 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +var conf = require('./config.js'); +var modules = require('./modules.js'); +var logger = require('./logger.js'); +var miscUtil = require('./misc_util.js'); + +var iconv = require('iconv-lite'); +var paths = require('path'); + +exports.bbsMain = function() { + var mainArgs = parseArgs(); + + var configPathSupplied = false; + var configPath = conf.defaultPath(); + + if(mainArgs.indexOf('--help') > 0) { + // :TODO: display help + } else { + var argCount = mainArgs.length; + for(var i = 0; i < argCount; ++i) { + var arg = mainArgs[i]; + if('--config' == arg) { + configPathSupplied = true; + configPath = mainArgs[i + 1]; + } + } + } + + try { + conf.initFromFile(configPath); + } catch(e) { + // + // If the user supplied a config and we can't read, parse, whatever + // then output a error and bail. + // + if(configPathSupplied) { + if(e.code === 'ENOENT') { + console.error('Configuration file does not exist: ' + configPath); + } + return; + } + + console.log('No configuration file found, creating defaults.'); + conf.createDefault(); + } + + logger.init(); + + preServingInit(); + + startListening(); + + process.on('SIGINT', function onSigInt() { + // :TODO: for any client in |clientConnections|, if 'ready', send a "Server Disconnecting" + semi-gracefull hangup + // e.g. client.disconnectNow() + + logger.log.info('Process interrupted, shutting down'); + process.exit(); + }); +}; + +function parseArgs() { + var args = []; + process.argv.slice(2).forEach(function(val, index, array) { + args.push(val); + }); + + return args; +} + +function preServingInit() { + iconv.extendNodeEncodings(); +} + +var clientConnections = []; + +function startListening() { + if(!conf.config.servers) { + // :TODO: Log error ... output to stderr as well. We can do it all with the logger + return []; + } + + modules.loadModulesForCategory('servers', function onServerModule(err, module) { + if(err) { + logger.log.info(err); + return; + } + + var port = parseInt(module.runtime.config.port); + if(isNaN(port)) { + logger.log.error({ port : module.runtime.config.port, server : module.moduleInfo.name }, 'Cannot load server (Invalid port)'); + return; + } + + var server = module.createServer(); + + // :TODO: handle maxConnections, e.g. conf.maxConnections + + server.on('client', function onClient(client) { + // + // Start tracking the client. We'll assign it an ID which is + // just the index in our connections array. + // + if(typeof client.runtime === 'undefined') { + client.runtime = {}; + } + + addNewClient(client); + //client.runtime.id = clientConnections.push(client) - 1; + + //logger.log.info({ clientId : client.runtime.id, from : client.address(), server : module.moduleInfo.name }, 'Client connected'); + + client.on('ready', function onClientReady() { + // Go to module -- use default error handler + modules.goto(conf.config.entryMod, client); + }); + + client.on('end', function onClientEnd() { + logger.log.info({ clientId : client.runtime.id }, 'Client disconnected'); + + removeClient(client); + }); + + client.on('error', function onClientError(err) { + logger.log.info({ clientId : client.runtime.id }, 'Connection error: %s' % err.message); + }); + + client.on('close', function onClientClose(hadError) { + var l = hadError ? logger.log.info : logger.log.debug; + l({ clientId : client.runtime.id }, 'Connection closed'); + removeClient(client); + }); + }); + + server.listen(port); + logger.log.info({ server : module.moduleInfo.name, port : port }, 'Listening for connections'); + }); +} + +function addNewClient(client) { + var id = client.runtime.id = clientConnections.push(client) - 1; + logger.log.debug('Connection count is now %d', clientConnections.length); + return id; +} + +function removeClient(client) { + var i = clientConnections.indexOf(client); + if(i > -1) { + clientConnections.splice(i, 1); + logger.log.debug('Connection count is now %d', clientConnections.length); + } +} \ No newline at end of file diff --git a/core/client.js b/core/client.js new file mode 100644 index 00000000..b2bc01a7 --- /dev/null +++ b/core/client.js @@ -0,0 +1,89 @@ +/* jslint node: true */ +'use strict'; + +var stream = require('stream'); +var term = require('./client_term.js'); +var assert = require('assert'); +var miscUtil = require('./misc_util.js'); +var ansi = require('./ansi_term.js'); +var logger = require('./logger.js'); + +exports.Client = Client; + +function Client(input, output) { + stream.call(this); + + var self = this; + + this.input = input; + this.output = output; + this.term = new term.ClientTerminal(this.output); + + self.on('data', function onData(data) { + console.log('data: ' + data.length); + handleANSIControlResponse(data); + }); + + function handleANSIControlResponse(data) { + console.log(data); + ansi.forEachControlCode(data, function onControlResponse(name, params) { + var eventName = 'on' + name[0].toUpperCase() + name.substr(1); + console.log(eventName + ': ' + params); + self.emit(eventName, params); + }); + } +} + +require('util').inherits(Client, stream); + +Client.prototype.end = function () { + return this.output.end.apply(this.output, arguments); +}; + +Client.prototype.destroy = function () { + return this.output.destroy.apply(this.output, arguments); +}; + +Client.prototype.destroySoon = function () { + return this.output.destroySoon.apply(this.output, arguments); +}; + +Client.prototype.getch = function(cb) { + this.input.once('data', function onData(data) { + // :TODO: needs work. What about F keys and the like? + assert(data.length === 1); + cb(data); + }); +}; + +Client.prototype.address = function() { + return this.input.address(); +}; + +/////////////////////////////////////////////////////////////////////////////// +// Default error handlers +/////////////////////////////////////////////////////////////////////////////// + +Client.prototype.defaultHandlerMissingMod = function(err) { + var self = this; + + function handler(err) { + logger.log.error(err); + + self.term.write('An unrecoverable error has been encountered!\n'); + self.term.write('This has been logged for your SysOp to review.\n'); + self.term.write('\nGoodbye!\n'); + + + //self.term.write(err); + + //if(miscUtil.isDevelopment() && err.stack) { + // self.term.write('\n' + err.stack + '\n'); + //} + + self.end(); + } + + return handler; +}; + diff --git a/core/client_term.js b/core/client_term.js new file mode 100644 index 00000000..df569366 --- /dev/null +++ b/core/client_term.js @@ -0,0 +1,101 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +var logger = require('./logger.js'); + +var iconv = require('iconv-lite'); +var assert = require('assert'); + +iconv.extendNodeEncodings(); + +exports.ClientTerminal = ClientTerminal; + +function ClientTerminal(output) { + this.output = output; + + var self = this; + + var outputEncoding = 'cp437'; + assert(iconv.encodingExists(outputEncoding)); + + // convert line feeds such as \n -> \r\n + this.convertLF = true; + + // + // Some terminal we handle specially + // They can also be found in this.env{} + // + var termType = 'unknown'; + var termHeight = 0; + var termWidth = 0; + + // Raw values set by e.g. telnet NAWS, ENVIRONMENT, etc. + this.env = {}; + + Object.defineProperty(this, 'outputEncoding', { + get : function() { + return outputEncoding; + }, + set : function(enc) { + if(iconv.encodingExists(enc)) { + outputEncoding = enc; + } else { + logger.log.warn({ encoding : enc }, 'Unknown encoding'); + } + } + }); + + Object.defineProperty(this, 'termType', { + get : function() { + return termType; + }, + set : function(ttype) { + termType = ttype.toLowerCase(); + + // + // ANSI terminals should be encoded to CP437 + // + if('ansi' == termType) { + this.outputEncoding = 'cp437'; + } else { + // :TODO: See how x84 does this -- only set if local/remote are binary + this.outputEncoding = 'utf8'; + } + } + }); + + Object.defineProperty(this, 'termWidth', { + get : function() { + return termWidth; + }, + set : function(width) { + if(width > 0) { + termWidth = width; + } + } + }); + + Object.defineProperty(this, 'termHeight', { + get : function() { + return termHeight; + }, + set : function(height) { + if(height > 0) { + termHeight = height; + } + } + }); +} + +ClientTerminal.prototype.isANSI = function() { + return 'ansi' === this.termType; +}; + +ClientTerminal.prototype.write = function(s) { + if(this.convertLF && typeof s === 'string') { + s = s.replace(/\n/g, '\r\n'); + } + + this.output.write(iconv.encode(s, this.outputEncoding)); +}; diff --git a/core/config.js b/core/config.js new file mode 100644 index 00000000..6614c833 --- /dev/null +++ b/core/config.js @@ -0,0 +1,50 @@ +/* jslint node: true */ +'use strict'; + +var fs = require('fs'); +var paths = require('path'); +var miscUtil = require('./misc_util.js'); + +module.exports = { + config : undefined, + + defaultPath : function() { + var base = miscUtil.resolvePath('~/'); + if(base) { + return paths.join(base, '.enigmabbs', 'config.json'); + } + }, + + initFromFile : function(path, cb) { + var data = fs.readFileSync(path, 'utf8'); + this.config = JSON.parse(data); + }, + + createDefault : function() { + this.config = { + bbsName : 'Another Fine ENiGMA½ BBS', + + entryMod : 'connect', + + paths : { + mods : paths.join(__dirname, './../mods/'), + servers : paths.join(__dirname, './servers/'), + art : paths.join(__dirname, './../mods/art/'), + logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such + }, + + servers : { + telnet : { + port : 8888, + enabled : true, + }, + ssh : { + port : 8889, + enabled : false, + rsaPrivateKey : paths.join(__dirname, './../misc/default_key.rsa'), + dsaPrivateKey : paths.join(__dirname, './../misc/default_key.dsa'), + } + }, + }; + } +}; \ No newline at end of file diff --git a/core/line_editor.js b/core/line_editor.js new file mode 100644 index 00000000..b3cd1726 --- /dev/null +++ b/core/line_editor.js @@ -0,0 +1,64 @@ +"use strict"; + +var assert = require('assert'); +var miscUtil = require('./misc_util.js'); + +exports.LineEditor = LineEditor; + +var STANDARD_KEYSET = { + refresh : [ 12 ], + backspace : [ 8, 127 ], + backword : [ 23 ], + enter : [ 10 ], + exit : [ 27 ], +}; + +// :TODO: Rename to TextEdit +// :TODO: TextEdit should be single or multi line + + +function LineEditor(client, options) { + var self = this; + + self.client = client; + self.valueText = ''; + + if(typeof options !== 'undefined') { + self.options.keyset = miscUtil.valueWithDefault(options.keyset, STANDARD_KEYSET); + } else { + self.options = { + keyset : STANDARD_KEYSET, + }; + } + + + this.client.on('data', function onData(data) { + assert(1 === data.length); + self.onCh(data); + + }); +}; + +LineEditor.prototype.isKey = function(setName, ch) { + return this.options.keyset[setName].indexOf(ch) > -1; +} + +LineEditor.prototype.onCh = function(ch) { + if(this.isKey('refresh', ch)) { + + } else if(this.isKey('backspace', ch)) { + + } else if(this.isKey('backword', ch)) { + + } else if(this.isKey('enter', ch)) { + + } else if(this.isKey('exit', ch)) { + + } else { + + // :TODO: filter out chars + // :TODO: check max width + this.valueText += ch; + this.client.term.write(ch); + } +}; diff --git a/core/logger.js b/core/logger.js new file mode 100644 index 00000000..a9c35e18 --- /dev/null +++ b/core/logger.js @@ -0,0 +1,39 @@ +"use strict"; + +var bunyan = require('bunyan'); +var miscUtil = require('./misc_util.js'); +var paths = require('path'); +var conf = require('./config.js'); + +module.exports = { + log : undefined, + + init : function() { + //var ringBufferLimit = miscUtil.valueWithDefault(config.logRingBufferLimit, 100); + var logPath = miscUtil.valueWithDefault(conf.config.paths.logs); + var logFile = paths.join(logPath, 'enigma-bbs.log'); + + // :TODO: make this configurable -- + // user should be able to configure rotations, levels to file vs ringBuffer, + // completely disable logging, etc. + + this.log = bunyan.createLogger({ + name : 'ENiGMA½ BBS', + streams : [ + { + type : 'rotating-file', + path : logFile, + period : '1d', + count : 3, + level : 'trace' + } + /*, + { + type : 'raw', + stream : ringBuffer, + level : 'trace' + }*/ + ] + }); + } +}; diff --git a/core/misc_util.js b/core/misc_util.js new file mode 100644 index 00000000..1a19dc5d --- /dev/null +++ b/core/misc_util.js @@ -0,0 +1,28 @@ +"use strict"; + +var paths = require('path'); + +exports.isProduction = isProduction; +exports.isDevelopment = isDevelopment; +exports.valueWithDefault = valueWithDefault; +exports.resolvePath = resolvePath; + +function isProduction() { + var env = process.env.NODE_ENV || 'dev'; + return 'production' === env; +}; + +function isDevelopment() { + return (!(isProduction())); +}; + +function valueWithDefault(val, defVal) { + return (typeof val !== 'undefined' ? val : defVal); +}; + +function resolvePath(path) { + if(path.substr(0, 2) === '~/') { + path = (process.env.HOME || process.env.HOMEPATH || process.env.HOMEDIR || process.cwd()) + path.substr(1); + } + return paths.resolve(path); +}; \ No newline at end of file diff --git a/core/modules.js b/core/modules.js new file mode 100644 index 00000000..b2ca3464 --- /dev/null +++ b/core/modules.js @@ -0,0 +1,85 @@ +/* jslint node: true */ +'use strict'; + +var fs = require('fs'); +var paths = require('path'); +var conf = require('./config.js'); +var miscUtil = require('./misc_util.js'); +var logger = require('./logger.js'); + +// exports +exports.loadModule = loadModule; +exports.loadModulesForCategory = loadModulesForCategory; + +exports.goto = goto; + +function loadModule(name, category, cb) { + var config = conf.config; + var path = config.paths[category]; + + if(!path) { + cb(new Error('not sure where to look for "' + name + '" of category "' + category + '"')); + return; + } + + // update conf to point at this module's section, if any + config = config[category] ? config[category][name] : null; + + if(config && false === config.enabled) { + cb(new Error('module "' + name + '" is disabled')); + return; + } + + try { + var mod = require(paths.join(path, name + '.js')); + + if(!mod.moduleInfo) { + cb(new Error('module is missing \'moduleInfo\' section')); + return; + } + + mod.runtime = { + config : config + }; + + cb(null, mod); + } catch(e) { + cb(e); + } +}; + +function loadModulesForCategory(category, cb) { + var path = conf.config.paths[category]; + + fs.readdir(path, function onFiles(err, files) { + if(err) { + cb(err); + return; + } + + var filtered = files.filter(function onFilter(file) { return '.js' === paths.extname(file); }); + filtered.forEach(function onFile(file) { + var modName = paths.basename(file, '.js'); + loadModule(paths.basename(file, '.js'), category, cb); + }); + }); +}; + + +function goto(name, client, cb) { + // Assign a default missing module handler callback if none was provided + cb = miscUtil.valueWithDefault(cb, client.defaultHandlerMissingMod()); + + loadModule(name, 'mods', function onMod(err, mod) { + if(err) { + cb(err); + } else { + try { + logger.log.debug({ moduleName : name }, 'Goto module'); + mod.entryPoint(client); + } catch (e) { + cb(e); + } + } + }); +}; diff --git a/core/servers/ssh.js b/core/servers/ssh.js new file mode 100644 index 00000000..150b4c8c --- /dev/null +++ b/core/servers/ssh.js @@ -0,0 +1,30 @@ +"use strict"; + +var libssh = require('ssh'); +var conf = require('../config.js'); + +/* + Notes on getting libssh to work. This will ultimately require some contribs back + * Can't install without --nodedir= as had to upgrade node on the box for other reasons + * From ssh dir, node-gyp --nodedir=... configure build + * nan is out of date and doesn't work with existing node. Had to update. ( was "~0.6.0") (npm update after this) + * +*/ + +exports.moduleInfo = { + name : 'SSH', + desc : 'SSH Server', + author : 'NuSkooler' +}; + +function createServer() { + var server = libssh.createServer( + conf.config.servers.ssh.rsaPrivateKey, + conf.config.servers.ssh.dsaPrivateKey); + + server.on('connection', function onConnection(session) { + console.log('ermergerd') + }); + + return server; +} \ No newline at end of file diff --git a/core/servers/telnet.js b/core/servers/telnet.js new file mode 100644 index 00000000..a10a735c --- /dev/null +++ b/core/servers/telnet.js @@ -0,0 +1,729 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +var baseClient = require('../client.js'); +var logger = require('../logger.js'); + +var net = require('net'); +var buffers = require('buffers'); +var binary = require('binary'); +var stream = require('stream'); +var assert = require('assert'); + +//var debug = require('debug')('telnet'); + +exports.moduleInfo = { + name : 'Telnet', + desc : 'Telnet Server', + author : 'NuSkooler' +}; + +exports.createServer = createServer; + +// +// Telnet Protocol Resources +// * http://pcmicro.com/netfoss/telnet.html +// * http://mud-dev.wikidot.com/telnet:negotiation +// + +/* + TODO: + * Document COMMANDS -- add any missing + * Document OPTIONS -- add any missing + * Internally handle OPTIONS: + * Some should be emitted generically + * Some shoudl be handled internally -- denied, handled, etc. + * + + * Allow term (ttype) to be set by environ sub negotiation + + * Process terms in loop.... research needed + + * Handle will/won't + * Handle do's, .. + * Some won't should close connection + + * Options/Commands we don't understand shouldn't crash the server!! + + +*/ + +var COMMANDS = { + SE : 240, // End of Sub-Negotation Parameters + NOP : 241, // No Operation + DM : 242, // Data Mark + BRK : 243, // Break + IP : 244, // Interrupt Process + AO : 245, // Abort Output + AYT : 246, // Are You There? + EC : 247, // Erase Character + EL : 248, // Erase Line + GA : 249, // Go Ahead + SB : 250, // Start Sub-Negotiation Parameters + WILL : 251, // + WONT : 252, + DO : 253, + DONT : 254, + IAC : 255, // (Data Byte) +}; + +// +// Resources: +// * http://www.faqs.org/rfcs/rfc1572.html +// +var SB_COMMANDS = { + IS : 0, + SEND : 1, + INFO : 2, +}; + +// +// Telnet Options +// +// Resources +// * http://mars.netanya.ac.il/~unesco/cdrom/booklet/HTML/NETWORKING/node300.html +// +var OPTIONS = { + TRANSMIT_BINARY : 0, // http://tools.ietf.org/html/rfc856 + ECHO : 1, // http://tools.ietf.org/html/rfc857 + // RECONNECTION : 2 + SUPPRESS_GO_AHEAD : 3, // aka 'SGA': RFC 858 @ http://tools.ietf.org/html/rfc858 + //APPROX_MESSAGE_SIZE : 4 + STATUS : 5, // http://tools.ietf.org/html/rfc859 + TIMING_MARK : 6, // http://tools.ietf.org/html/rfc860 + //RC_TRANS_AND_ECHO : 7, // aka 'RCTE' @ http://www.rfc-base.org/txt/rfc-726.txt + //OUPUT_LINE_WIDTH : 8, + //OUTPUT_PAGE_SIZE : 9, // + //OUTPUT_CARRIAGE_RETURN_DISP : 10, // RFC 652 + //OUTPUT_HORIZ_TABSTOPS : 11, // RFC 653 + //OUTPUT_HORIZ_TAB_DISP : 12, // RFC 654 + //OUTPUT_FORMFEED_DISP : 13, // RFC 655 + //OUTPUT_VERT_TABSTOPS : 14, // RFC 656 + //OUTPUT_VERT_TAB_DISP : 15, // RFC 657 + //OUTPUT_LF_DISP : 16, // RFC 658 + //EXTENDED_ASCII : 17, // RFC 659 + //LOGOUT : 18, // RFC 727 + //BYTE_MACRO : 19, // RFC 753 + //DATA_ENTRY_TERMINAL : 20, // RFC 1043 + //SUPDUP : 21, // RFC 736 + //SUPDUP_OUTPUT : 22, // RFC 749 + //SEND_LOCATION : 23, // RFC 779 + TERMINAL_TYPE : 24, // aka 'TTYPE': RFC 1091 @ http://tools.ietf.org/html/rfc1091 + //END_OF_RECORD : 25, // RFC 885 + //TACACS_USER_ID : 26, // RFC 927 + //OUTPUT_MARKING : 27, // RFC 933 + //TERMINCAL_LOCATION_NUMBER : 28, // RFC 946 + //TELNET_3270_REGIME : 29, // RFC 1041 + WINDOW_SIZE : 31, // aka 'NAWS': RFC 1073 @ http://tools.ietf.org/html/rfc1073 + TERMINAL_SPEED : 32, // RFC 1079 @ http://tools.ietf.org/html/rfc1079 + REMOTE_FLOW_CONTROL : 33, // RFC 1072 @ http://tools.ietf.org/html/rfc1372 + LINEMODE : 34, // RFC 1184 @ http://tools.ietf.org/html/rfc1184 + X_DISPLAY_LOCATION : 35, // aka 'XDISPLOC': RFC 1096 @ http://tools.ietf.org/html/rfc1096 + NEW_ENVIRONMENT_DEP : 36, // aka 'NEW-ENVIRON': RFC 1408 @ http://tools.ietf.org/html/rfc1408 (note: RFC 1572 is an update to this) + AUTHENTICATION : 37, // RFC 2941 @ http://tools.ietf.org/html/rfc2941 + ENCRYPT : 38, // RFC 2946 @ http://tools.ietf.org/html/rfc2946 + NEW_ENVIRONMENT : 39, // aka 'NEW-ENVIRON': RFC 1572 @ http://tools.ietf.org/html/rfc1572 (note: update to RFC 1408) + //TN3270E : 40, // RFC 2355 + //XAUTH : 41, + //CHARSET : 42, // RFC 2066 + //REMOTE_SERIAL_PORT : 43, + //COM_PORT_CONTROL : 44, // RFC 2217 + //SUPRESS_LOCAL_ECHO : 45, + //START_TLS : 46, + //KERMIT : 47, // RFC 2840 + //SEND_URL : 48, + //FORWARD_X : 49, + + //PRAGMA_LOGON : 138, + //SSPI_LOGON : 139, + //PRAGMA_HEARTBEAT : 140 + + EXTENDED_OPTIONS_LIST : 255, // RFC 861 (STD 32) +} + +// Commands used within NEW_ENVIRONMENT[_DEP] +var NEW_ENVIRONMENT_COMMANDS = { + VAR : 0, + VALUE : 1, + ESC : 2, + USERVAR : 3, +}; + +var IAC_BUF = new Buffer([ COMMANDS.IAC ]); +var SB_BUF = new Buffer([ COMMANDS.SB ]); +var SE_BUF = new Buffer([ COMMANDS.SE ]); +var IAC_SE_BUF = new Buffer([ COMMANDS.IAC, COMMANDS.SE ]); + +var COMMAND_NAMES = Object.keys(COMMANDS).reduce(function(names, name) { + names[COMMANDS[name]] = name.toLowerCase(); + return names; +}, {}); + +var COMMAND_IMPLS = {}; +['do', 'dont', 'will', 'wont', 'sb'].forEach(function(command) { + var code = COMMANDS[command.toUpperCase()]; + COMMAND_IMPLS[code] = function(bufs, i, event) { + if(bufs.length < (i + 1)) { + return MORE_DATA_REQUIRED; + } + return parseOption(bufs, i, event); + } +}); + +// :TODO: See TooTallNate's telnet.js: Handle COMMAND_IMPL for IAC in binary mode + +// Create option names such as 'transmit binary' -> OPTIONS.TRANSMIT_BINARY +var OPTION_NAMES = Object.keys(OPTIONS).reduce(function(names, name) { + names[OPTIONS[name]] = name.toLowerCase().replace(/_/g, ' '); + return names; +}, {}); + +var OPTION_IMPLS = {}; +// :TODO: fill in the rest... +OPTION_IMPLS.NO_ARGS = +OPTION_IMPLS[OPTIONS.ECHO] = +OPTION_IMPLS[OPTIONS.STATUS] = +OPTION_IMPLS[OPTIONS.LINEMODE] = +OPTION_IMPLS[OPTIONS.TRANSMIT_BINARY] = +OPTION_IMPLS[OPTIONS.AUTHENTICATION] = +OPTION_IMPLS[OPTIONS.TERMINAL_SPEED] = +//OPTION_IMPLS[OPTIONS.TERMINAL_TYPE] = +OPTION_IMPLS[OPTIONS.REMOTE_FLOW_CONTROL] = +OPTION_IMPLS[OPTIONS.X_DISPLAY_LOCATION] = + +OPTION_IMPLS[OPTIONS.SUPPRESS_GO_AHEAD] = function(bufs, i, event) { + event.buf = bufs.splice(0, i).toBuffer(); + return event; +}; + +OPTION_IMPLS[OPTIONS.TERMINAL_TYPE] = function(bufs, i, event) { + if(event.commandCode !== COMMANDS.SB) { + OPTION_IMPLS.NO_ARGS(bufs, i, event); + } else { + // We need 4 bytes header + data + IAC SE + if(bufs.length < 7) { + return MORE_DATA_REQUIRED; + } + + var end = bufs.indexOf(IAC_SE_BUF, 5); // look past header bytes + if(-1 === end) { + return MORE_DATA_REQUIRED; + } + + // eat up and process the header + var buf = bufs.splice(0, 4).toBuffer(); + binary.parse(buf) + .word8('iac1') + .word8('sb') + .word8('ttype') + .word8('is') + .tap(function(vars) { + assert(vars.iac1 === COMMANDS.IAC); + assert(vars.sb === COMMANDS.SB); + assert(vars.ttype === OPTIONS.TERMINAL_TYPE); + assert(vars.is === SB_COMMANDS.IS); + }); + + // eat up the rest + end -= 4; + buf = bufs.splice(0, end).toBuffer(); + + // + // From this point -> |end| is our ttype + // + // Look for trailing NULL(s). Client such as Netrunner do this. + // + var trimAt = 0; + for(; trimAt < buf.length; ++trimAt) { + if(0x00 === buf[trimAt]) { + break; + } + } + + event.ttype = buf.toString('ascii', 0, trimAt); + + // pop off the terminating IAC SE + bufs.splice(0, 2); + } + + return event; +}; + +OPTION_IMPLS[OPTIONS.WINDOW_SIZE] = function(bufs, i, event) { + if(event.commandCode !== COMMANDS.SB) { + OPTION_IMPLS.NO_ARGS(bufs, i, event); + } else { + // we need 9 bytes + if(bufs.length < 9) { + return MORE_DATA_REQUIRED; + } + + event.buf = bufs.splice(0, 9).toBuffer(); + binary.parse(event.buf) + .word8('iac1') + .word8('sb') + .word8('naws') + .word16bu('width') + .word16bu('height') + .word8('iac2') + .word8('se') + .tap(function(vars) { + assert(vars.iac1 == COMMANDS.IAC); + assert(vars.sb == COMMANDS.SB); + assert(vars.naws == OPTIONS.WINDOW_SIZE); + assert(vars.iac2 == COMMANDS.IAC); + assert(vars.se == COMMANDS.SE); + + event.cols = event.columns = event.width = vars.width; + event.rows = event.height = vars.height; + }); + } + return event; +}; + +// Build an array of delimiters for parsing NEW_ENVIRONMENT[_DEP] +var NEW_ENVIRONMENT_DELIMITERS = []; +Object.keys(NEW_ENVIRONMENT_COMMANDS).forEach(function onKey(k) { + NEW_ENVIRONMENT_DELIMITERS.push(NEW_ENVIRONMENT_COMMANDS[k]); +}); + +// Handle the deprecated RFC 1408 & the updated RFC 1572: +OPTION_IMPLS[OPTIONS.NEW_ENVIRONMENT_DEP] = +OPTION_IMPLS[OPTIONS.NEW_ENVIRONMENT] = function(bufs, i, event) { + if(event.commandCode !== COMMANDS.SB) { + OPTION_IMPLS.NO_ARGS(bufs, i, event); + } else { + // We need 4 bytes header + payload + IAC SE + if(bufs.length < 7) { + return MORE_DATA_REQUIRED; + } + + var end = bufs.indexOf(IAC_SE_BUF, 5); // look past header bytes + if(-1 === end) { + return MORE_DATA_REQUIRED; + } + + // eat up and process the header + var buf = bufs.splice(0, 4).toBuffer(); + binary.parse(buf) + .word8('iac1') + .word8('sb') + .word8('newEnv') + .word8('isOrInfo') // initial=IS, updates=INFO + .tap(function(vars) { + assert(vars.iac1 === COMMANDS.IAC); + assert(vars.sb === COMMANDS.SB); + assert(vars.newEnv === OPTIONS.NEW_ENVIRONMENT || vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP); + assert(vars.isOrInfo === SB_COMMANDS.IS || vars.isOrInfo === SB_COMMANDS.INFO); + + event.type = vars.isOrInfo; + + if(vars.newEnv === OPTIONS.NEW_ENVIRONMENT_DEP) { + logger.log.warn('Handling deprecated RFC 1408 NEW-ENVIRON'); + } + }); + + // eat up the rest + end -= 4; + buf = bufs.splice(0, end).toBuffer(); + + // + // This part can become messy. The basic spec is: + // IAC SB NEW-ENVIRON IS type ... [ VALUE ... ] [ type ... [ VALUE ... ] [ ... ] ] IAC SE + // + // See RFC 1572 @ http://www.faqs.org/rfcs/rfc1572.html + // + // Start by splitting up the remaining buffer. Keep the delimiters + // as prefixes we can use for processing. + // + // :TODO: Currently not supporting ESCaped values (ESC + ). Probably not really in the wild, but we should be compliant + var params = []; + var p = 0; + for(var j = 0, l = buf.length; j < l; ++j) { + if(NEW_ENVIRONMENT_DELIMITERS.indexOf(buf[j]) === -1) { + continue; + } + + params.push(buf.slice(p, j)); + p = j; + } + + // remainder + if(p < l) { + params.push(buf.slice(p, l)); + } + + var varName; + event.envVars = {}; + // :TODO: handle cases where a variable was present in a previous exchange, but missing here...e.g removed + for(var j = 0; j < params.length; ++j) { + if(params[j].length < 2) { + continue; + } + + var cmd = params[j].readUInt8(); + if(cmd === NEW_ENVIRONMENT_COMMANDS.VAR || cmd === NEW_ENVIRONMENT_COMMANDS.USERVAR) { + varName = params[j].slice(1).toString('utf8'); // :TODO: what encoding should this really be? + } else { + event.envVars[varName] = params[j].slice(1).toString('utf8'); // :TODO: again, what encoding? + } + } + + // pop off remaining IAC SE + bufs.splice(0, 2); + } + + return event; +}; + +var MORE_DATA_REQUIRED = 0xfeedface; + +function parseBufs(bufs) { + assert(bufs.length >= 2); + assert(bufs.get(0) === COMMANDS.IAC) + return parseCommand(bufs, 1, {}); +} + +function parseCommand(bufs, i, event) { + var command = bufs.get(i); // :TODO: fix deprecation... [i] is not the same + event.commandCode = command; + event.command = COMMAND_NAMES[command]; + + var handler = COMMAND_IMPLS[command]; + if(handler) { + //return COMMAND_IMPLS[command](bufs, i + 1, event); + return handler(bufs, i + 1, event); + } else { + assert(2 == bufs.length); // IAC + COMMAND + event.buf = bufs.splice(0, 2).toBuffer(); + return event; + } +} + +function parseOption(bufs, i, event) { + var option = bufs.get(i); // :TODO: fix deprecation... [i] is not the same + event.optionCode = option; + event.option = OPTION_NAMES[option]; + return OPTION_IMPLS[option](bufs, i + 1, event); +} + + +function TelnetClient(input, output) { + baseClient.Client.apply(this, arguments); + + var self = this; + + var bufs = buffers(); + this.bufs = bufs; + + var readyFired = false; + var encodingSet = false; + + this.negotiationsComplete = false; // are we in the 'negotiation' phase? + this.didReady = false; // have we emit the 'ready' event? + + this.input.on('data', function onData(b) { + bufs.push(b); + + var i; + while((i = bufs.indexOf(IAC_BUF)) >= 0) { + // :TODO: Android client Irssi ConnectBot asserts here: + assert(bufs.length > (i + 1), 'bufs.length=' + bufs.length + ' i=' + i + ' bufs=' + bufs); + + if(i > 0) { + self.emit('data', bufs.splice(0, i).toBuffer()); + } + + i = parseBufs(bufs); + + if(MORE_DATA_REQUIRED === i) { + break; + } else { + //self.emit('event', i); // generic event + //self.emit(i.command, i); // "will", "wont", ... + + if(i.option) { + self.emit(i.option, i); // "transmit binary", "echo", ... + } + + self.handleTelnetEvent(i); + + if(i.data) { + self.emit('data', i.data); + } + } + } + + if(MORE_DATA_REQUIRED !== i && bufs.length > 0) { + // + // Standard data payload. This can still be "non-user" data + // such as ANSI control, but we don't handle that here. + // + self.emit('data', bufs.splice(0).toBuffer()); + } + + }); + + this.input.on('end', function() { + self.emit('end'); + }); +} + +require('util').inherits(TelnetClient, baseClient.Client); + +/////////////////////////////////////////////////////////////////////////////// +// Telnet Command/Option handling +/////////////////////////////////////////////////////////////////////////////// +TelnetClient.prototype.handleTelnetEvent = function(evt) { + // handler name e.g. 'handleWontCommand' + var handlerName = 'handle' + evt.command.charAt(0).toUpperCase() + evt.command.substr(1) + 'Command'; + + if(this[handlerName]) { + // specialized + this[handlerName](evt); + } else { + // generic-ish + this.handleMiscCommand(evt); + } +}; + +TelnetClient.prototype.handleWillCommand = function(evt) { + if('terminal type' === evt.option) { + // + // See RFC 1091 @ http://www.faqs.org/rfcs/rfc1091.html + // + this.requestTerminalType(); + } else if('environment variables' === evt.option) { + // + // See RFC 1572 @ http://www.faqs.org/rfcs/rfc1572.html + // + this.requestEnvironmentVariables(); + } else { + // :TODO: temporary: + console.log('will ' + JSON.stringify(evt)); + } +}; + +TelnetClient.prototype.handleWontCommand = function(evt) { + console.log('wont ' + JSON.stringify(evt)); +}; + +TelnetClient.prototype.handleDoCommand = function(evt) { + // :TODO: handle the rest, e.g. echo nd the like + + if('linemode' === evt.option) { + // + // Client wants to enable linemode editing. Denied. + // + this.wont.linemode(); + } else if('encrypt' === evt.option) { + // + // Client wants to enable encryption. Denied. + // + this.wont.encrypt(); + } else { + // :TODO: temporary: + console.log('do ' + JSON.stringify(evt)); + } +}; + +TelnetClient.prototype.handleDontCommand = function(evt) { + console.log('dont ' + JSON.stringify(evt)); +}; + +TelnetClient.prototype.setTermType = function(ttype) { + this.term.env['TERM'] = ttype; + this.term.termType = ttype; + + logger.log.debug({ termType : ttype }, 'Set terminal type'); +} + +TelnetClient.prototype.handleSbCommand = function(evt) { + var self = this; + + if('terminal type' === evt.option) { + // + // See RFC 1091 @ http://www.faqs.org/rfcs/rfc1091.html + // + // :TODO: According to RFC 1091 @ http://www.faqs.org/rfcs/rfc1091.html + // We should keep asking until we see a repeat. From there, determine the best type/etc. + self.setTermType(evt.ttype); + + 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'); + } + } else if('new environment' === evt.option) { + // + // Handling is as follows: + // * Map 'TERM' -> 'termType' and only update if ours is 'unknown' + // * Map COLUMNS -> 'termWidth' and only update if ours is 0 + // * Map ROWS -> 'termHeight' and only update if ours is 0 + // * Add any new variables, ignore any existing + // + Object.keys(evt.envVars).forEach(function onEnv(name) { + if('TERM' === name && 'unknown' === self.term.termType) { + self.setTermType(evt.envVars[name]); + } else if('COLUMNS' === name && 0 === self.term.termWidth) { + self.term.termWidth = parseInt(evt.envVars[name]); + } else if('ROWS' === name && 0 === self.term.termHeight) { + self.term.termHeight = parseInt(evt.envVars[name]); + } else { + if(name in self.term.env) { + assert(evt.type === SB_COMMANDS.INFO); + + logger.log.warn( + { varName : name, value : evt.envVars[name], existingValue : self.term.env[name] }, + 'Environment variable already exists'); + } else { + self.term.env[name] = evt.envVars[name]; + } + } + }); + } else if('window size' === evt.option) { + // + // Update termWidth & termHeight. + // Set LINES and COLUMNS environment variables as well. + // + self.term.termWidth = evt.width; + self.term.termHeight = evt.height; + + if(evt.width > 0) { + self.term.env['COLUMNS'] = evt.height; + } + + if(evt.height > 0) { + self.term.env['ROWS'] = evt.height; + } + + logger.log.debug({ termWidth : evt.width , termHeight : evt.height }, 'Window size updated'); + } else { + console.log('unhandled SB: ' + JSON.stringify(evt)); + } +}; + +var IGNORED_COMMANDS = []; +[ COMMANDS.EL, COMMANDS.GA, COMMANDS.NOP, COMMANDS.DM, COMMANDS.BRK ].forEach(function onCommandCode(cc) { + IGNORED_COMMANDS.push(cc); +}); + + +TelnetClient.prototype.handleMiscCommand = function(evt) { + assert(evt.command !== 'undefined' && evt.command.length > 0); + + // + // See: + // * RFC 854 @ http://tools.ietf.org/html/rfc854 + // + if('ip' === evt.command) { + // Interrupt Process (IP) + logger.log.debug('Interrupt Process (IP) - Ending'); + this.input.end(); + } else if('ayt' === evt.command) { + this.output.write('\b'); + logger.log.debug('Are You There (AYT) - Replied "\\b"'); + } else if(IGNORED_COMMANDS.indexOf(evt.commandCode)) { + logger.log.debug({ evt : evt }, 'Ignoring command'); + } else { + logger.log.warn({ evt : evt }, 'Unknown command'); + } +}; + +TelnetClient.prototype.requestTerminalType = function() { + var buf = Buffer([ COMMANDS.IAC, COMMANDS.SB, OPTIONS.TERMINAL_TYPE, SB_COMMANDS.SEND, COMMANDS.IAC, COMMANDS.SE ]); + /* + var buf = Buffer(6); + buf[0] = COMMANDS.IAC; + buf[1] = COMMANDS.SB; + buf[2] = OPTIONS.TERMINAL_TYPE; + buf[3] = SB_COMMANDS.SEND; + buf[4] = COMMANDS.IAC; + buf[5] = COMMANDS.SE; + */ + + return this.output.write(buf); +}; + +var WANTED_ENVIRONMENT_VARIABLES = [ 'LINES', 'COLUMNS', 'TERM' ]; + +TelnetClient.prototype.requestEnvironmentVariables = function() { + var bufs = buffers(); + + bufs.push(new Buffer([ COMMANDS.IAC, COMMANDS.SB, OPTIONS.NEW_ENVIRONMENT, SB_COMMANDS.SEND ])); + + for(var i = 0; i < WANTED_ENVIRONMENT_VARIABLES.length; ++i) { + bufs.push(new Buffer( [ ENVIRONMENT_VARIABLES_COMMANDS.VAR ])); + bufs.push(new Buffer(WANTED_ENVIRONMENT_VARIABLES[i])); // :TODO: encoding here?! UTF-8 will work, but shoudl be more explicit + } + + bufs.push(new Buffer([ COMMANDS.IAC, COMMANDS.SE ])); + + return this.output.write(bufs.toBuffer()); +}; + +TelnetClient.prototype.banner = function() { + + // :TODO: See x84 implementation here. + // First, we should probably buffer then send. + + this.will.echo(); + + this.will.suppress_go_ahead(); + this.do.suppress_go_ahead(); + + this.do.transmit_binary(); + this.will.transmit_binary(); + + this.do.terminal_type(); + + this.do.window_size(); + this.do.new_environment(); +} + +function Command(command, client) { + this.command = COMMANDS[command.toUpperCase()]; + this.client = client; +}; + +// Create Command objects with echo, transmit_binary, ... +Object.keys(OPTIONS).forEach(function(name) { + var code = OPTIONS[name]; + + Command.prototype[name.toLowerCase()] = function() { + var buf = Buffer(3); + buf[0] = COMMANDS.IAC; + buf[1] = this.command; + buf[2] = code; + return this.client.output.write(buf); + } +}); + +// Create do, dont, etc. methods on Client +['do', 'dont', 'will', 'wont'].forEach(function(command) { + function get() { + return new Command(command, this); + } + + Object.defineProperty(TelnetClient.prototype, command, { + get : get, + enumerable : true, + configurable : true + }); +}); + + +function createServer() { + var server = net.createServer(function onConnection(sock) { + var self = this; + var client = new TelnetClient(sock, sock); + + client.banner(); + + self.emit('client', client); + }); + + return server; +}; diff --git a/main.js b/main.js new file mode 100644 index 00000000..1ad3b05b --- /dev/null +++ b/main.js @@ -0,0 +1,11 @@ +"use strict"; + +var net = require('net'); +var fs = require('fs'); +var paths = require('path'); + +// ENiGMA½ +var bbs = require('./core/bbs.js') + +// The entry point. +bbs.bbsMain(); \ No newline at end of file diff --git a/misc/default_key.dsa b/misc/default_key.dsa new file mode 100644 index 00000000..35cfd46b --- /dev/null +++ b/misc/default_key.dsa @@ -0,0 +1,12 @@ +-----BEGIN DSA PRIVATE KEY----- +MIIBugIBAAKBgQDA9HhqZZrq167Vz/AJECJGjJB0DKu72Qji0HfZxDBw/mrsRh89 +W/0LQSIOBROIGczGyxuelUAwQxzx5sN3LBABPGutKat4FbxsKO5Ix6VODt5gmT82 +eyXm9XpV49EDRoO3AxZJoc+Fmiy7meqtAoGNG9Y4K7fRsq9NTNMgt5w4RQIVAIDp +xj5whr2w95anI/UBs9GPvodpAoGABzR9OqO/yrgOmjq+vg6+T+RRQNB7dRjfsY9A +NJFKC15ZNsLFXJSyKV067GW/6AYYaqJokz93ZS/azuNRm4GesbvIltri3MXZ0cRF +m9zazKS4OlCwCySBIai4Hjv4oNSV+hiYqEsNYA8fubP9fILXioD0ojwIC7tJam9d +UTPOhNMCgYAh8P2Cu6hE6qJy9NToeaimRcIT3UCqnqORWrelOymmrX2v9wboB2DB +Ehm4RpJhPps8NnxIg4fDxARqBVpgAkizfWlzdJMw5JFz3qm5To7MMD4AJ9ZbtpHE +uTGglej7v5jTM+qk6hlB5mNgvqHgtKkyLLMZz2PTzRo1+bJ6bA0c2AIUFUxwM+1H +zd9qN20heajSqKqiZ4g= +-----END DSA PRIVATE KEY----- diff --git a/misc/default_key.dsa.pub b/misc/default_key.dsa.pub new file mode 100644 index 00000000..cd62c525 --- /dev/null +++ b/misc/default_key.dsa.pub @@ -0,0 +1 @@ +ssh-dss AAAAB3NzaC1kc3MAAACBAMD0eGplmurXrtXP8AkQIkaMkHQMq7vZCOLQd9nEMHD+auxGHz1b/QtBIg4FE4gZzMbLG56VQDBDHPHmw3csEAE8a60pq3gVvGwo7kjHpU4O3mCZPzZ7Jeb1elXj0QNGg7cDFkmhz4WaLLuZ6q0CgY0b1jgrt9Gyr01M0yC3nDhFAAAAFQCA6cY+cIa9sPeWpyP1AbPRj76HaQAAAIAHNH06o7/KuA6aOr6+Dr5P5FFA0Ht1GN+xj0A0kUoLXlk2wsVclLIpXTrsZb/oBhhqomiTP3dlL9rO41GbgZ6xu8iW2uLcxdnRxEWb3NrMpLg6ULALJIEhqLgeO/ig1JX6GJioSw1gDx+5s/18gteKgPSiPAgLu0lqb11RM86E0wAAAIAh8P2Cu6hE6qJy9NToeaimRcIT3UCqnqORWrelOymmrX2v9wboB2DBEhm4RpJhPps8NnxIg4fDxARqBVpgAkizfWlzdJMw5JFz3qm5To7MMD4AJ9ZbtpHEuTGglej7v5jTM+qk6hlB5mNgvqHgtKkyLLMZz2PTzRo1+bJ6bA0c2A== ENiGMA½ Default DSA Key diff --git a/misc/default_key.rsa b/misc/default_key.rsa new file mode 100644 index 00000000..1e122239 --- /dev/null +++ b/misc/default_key.rsa @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAns9TDs2FY3dXmGtE5a3tu5GS3I9/O9a9NU1yPrcuwhXgOL3D +hPYjbWT8ypKZyMMwdRNxmfnXNQjooBt2CdcVjezrWPTfexQRNNEfcQ7JmUPKrtQQ +ZZRU+ubFMycgNyDt3w3SwgisBFcb9qiFj1Y12jpKpkvnB+HtuV5HA0XODoI4OA93 +mef02q+rbHfyT3EDlwBLJcZiew/ALlOiB/bACHXkYoal979HRTV4aW8Xx2WxstLD +bBaZSuQJQFNLR2DsivHHs0JlxcO4D6oBiw8mTr1SRrunDldmndCYPp66gpsJf+7B +5PwrQ20hOG+UEp7CReBvX4y7AD2TP6nKjJ96EQIDAQABAoIBADQtMcC/TX/sid8N +/0jm6CCkJ3hHvSnP18JXe52XWclynoSF9GMO63zZnSSBHf2UA6uzg7NvHoHSN3vD +CfkZrX8/w+MqMPKjGTQdMRNYJGwylbjy+6AkJ/d3Mnsn87GpI/4Q8JnHd5zFce6M +V7VMQc09MaGJdpJkbFJm4zX0DUs+gN2liq+lOQWVBBY3baAS9ZhDGAnxgDzCteRQ +lJYbKbO4MqwNvzKRgUjCNMpf28tUXu1JzK7NosQ0eA8yz3il8MQuD7+1Kmaxrkhv +/FhV9GaVod4HB7iwVyM4cvwXDGtMg8W0IU6aaqmctWvEckCnImuqRrzxseeAQA8K +fgTeIwECgYEA0Q38Rk1SryCMKtJZisQCwsNN8eFs7tT9QhpJxQG4JsJtjid4q+Ze +YMOVDwSdlsPu3XPGql/88mawjOSQLw2bQ9kOOjm3/1G1XoKqCajmWyvYznLc22/b +wN3zeubkk2/PHPP8h43WZCLxnfbMGyEXxCmOZYz/AIv3CKlA5KnM9ZkCgYEAwnjl +ZB+kh8AnejDhcCiNbk0Bt8xYMHiewSU5jW3sCrXpcZ7fPLERJhxiMEVT7WfnIdb7 +z1OZOqEnl+XhqGIJRLRgM2FrFSHgWbdATcSNLYaMVrMwGOcaHB/Ww2ffVhRa2B5H +16yol6oeya7OubQE+4JeqPU3thCR+OGFCgl8AzkCgYAgLr/8bqDg9ui9Gt7P/vbR +1tEkAGkNp1pJl9RtcKNRzDKjZK4uAGvdB57lzfcYo5cdHlqiFd7Rzj81oWYU6EpT +upF3IzWRJ5n9L65PiGHV/T0GIyae3Wx+F20JKYxcqiS/N0xT7eDoT3zUrY6kSobn +GuKh/X+wLNvdyHiF+yHjWQKBgEWn6/Iq61G8X+eCbp0vyUjCyGS/Cq+B5MslvDGO +N8e2VMFfuHISsiPGctz7WA5m8wDJ8jPWq8G2S2o66tLXGp7DRNnigOp20dcQrjWs +uyydTtMXJ5kj3dRJCzG+mN5KOCfE4NK9gz2fr5tpw538fvRfQ+ycuLG7ya0tieuI +biTxAoGAZAglKOHJIqLXoBPT3GH2nDl0BDEJ/3Ob7g54nupz753LO/6MlFGGX7Cg +X360g9Snk08+pYDos6ayT/vmeegbqfV7A+1sXHsGGuYyaLSkTjdcIUV/eF5h/HbE +CnRfXZe2jsFzYNK835BcYIndDIElyW6jipkgkwwWCOMqPC9Ulto= +-----END RSA PRIVATE KEY----- diff --git a/misc/default_key.rsa.pub b/misc/default_key.rsa.pub new file mode 100644 index 00000000..f265c28d --- /dev/null +++ b/misc/default_key.rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCez1MOzYVjd1eYa0Tlre27kZLcj3871r01TXI+ty7CFeA4vcOE9iNtZPzKkpnIwzB1E3GZ+dc1COigG3YJ1xWN7OtY9N97FBE00R9xDsmZQ8qu1BBllFT65sUzJyA3IO3fDdLCCKwEVxv2qIWPVjXaOkqmS+cH4e25XkcDRc4Ogjg4D3eZ5/Tar6tsd/JPcQOXAEslxmJ7D8AuU6IH9sAIdeRihqX3v0dFNXhpbxfHZbGy0sNsFplK5AlAU0tHYOyK8cezQmXFw7gPqgGLDyZOvVJGu6cOV2ad0Jg+nrqCmwl/7sHk/CtDbSE4b5QSnsJF4G9fjLsAPZM/qcqMn3oR ENiGMA½ Default RSA Key diff --git a/mods/art/CONNECT1.ANS b/mods/art/CONNECT1.ANS new file mode 100644 index 0000000000000000000000000000000000000000..2c9425c628e8ba6d30d16173d7389e3d80d53743 GIT binary patch literal 2019 zcmb_dy-ve05KbqgNS)a5l1Fge1cW-33Lyq0Bm_$~cE20sby6v>!gqI$?c|!a10Je6 zKHv9qXWO)PSKHmz`gLjA2X`>-*7+j_&$pOwcD18`AA?vd_VS76ylX>WP3xM&(X_Sm z-R~Gl^K&UEFr}yv-6W*O_ZR~P12zO$TYoZ&2m_3n9IFaDz#|gw2W*0j#xpYlah_tJ zhfxRutCZRXeHY*=Mg){3R;+tkUzgdkn8I)qJ*K^f$s%0G;0tLcW^-$T+zxA87iJUy zgLaLUGDWOpP3~@7?xo)BU7#Yi<=S=htXMh#UvfMt)@j1hcHP(ssj|be!?B#$RpnGW zwkrn;Orpn%E453~Gd&E!9!IT}>rqKU!1;26+@i;tc;dvpkwNwYot;Ua!Gx?+CdTQ^ zA|~gmV*>iJZJ8wto&~vMb8C!fDz_YUcxZ4saEsxh6D7{;p1E_n<8``af9K9Z!Biq& zT7Hl#K#s1?AJl0Fr|Cy~78S$<8UkNJS290Ifjd05Z3^HX(i!F?VTzppYh6qUuDLg^ z<#X8-=>mStnCf_j>CzZGgkBdOcr!5M7tx+x48C?`6|Z9B`ELrtc?uiy&2r4>ec`b# p?>_dQhi7YP;h%=Ho7z4)`+ZXstJQl^7CdfWpWdpkm%Ys5?;k77l`Q}O literal 0 HcmV?d00001 diff --git a/mods/art/DM-ENIG.ANS b/mods/art/DM-ENIG.ANS new file mode 100644 index 00000000..b09c5ad7 --- /dev/null +++ b/mods/art/DM-ENIG.ANS @@ -0,0 +1,19 @@ + °°°°°°°°°ܲÜÜ  °  ÜÜ  + °°°°°° ÜÜßÛÛÛ²ÛÛÛÛÛß  °°  °°° °°°° ° ÜÜÜßßßÜܲÛÛÛÛÛß + °ÜÜÜßßÞÛÛÛÛ²²ÛÝ °°  ÜÜÜ ÜÜ °ÜÜÜßßÛ²²ÛÛÝ +ßÜÜÜÛÛÛ²Û  °ÛÛÛ²²²±±ÜÜÜßßÛÛÛÛ²ÛßÛ²²²²±ÜÜܲ²²²°° °°ÞÛÛÛÛÛ² + ÞÛ²²ÛÛÛÝÞÛ²²±±±°Ý ²±Üܰ±ß Þ²²²²ÛÝ Þ²²±±±±±²²²±±±Þ²²ÛÛÛÝ + ÛÛÛÛ²²ß     ²²±±°°°ÛÛ° °°°°Ý  ±±±²² ²²±±°° °±±±°°°°±±²²ÛÝ   +°°²Û²²±°°°° °  ßßß°°ÛÛÜÞ°°°°±±°° °° °° °°±± Þ °±°°°°Þ °°°°°°° °° ° °°°°°±±²°°   °Þ²±±°° ÜÜ    °°±±²Û  Þ±±°°°  °°°±± Þ°°°°±Ý Ü ÞÛ °°±±  +Ü Þ±°° °°Ý  Ü ÜÛÛÜÜÜ ÜÛ²²²ÛÛÝÞܲß ÞÛ²²±°° °°±±±± Ü ±±±±²Û ÞÛÜÛÛ²Û ÞÛÛÛÛ°  ÞÜ  +°Ý ° °°±²Ý °°ÛÜ ÛÛ²²ÛÛÛÛÛÛ²ÛÛ²Ý °° ßßßßÛ²±±ßÞ±±²² Þ°Ý Û²²ÛÛÝ Û°°ÛÛÝ °°°°ÛÛÝ ÛÛ° +ß  °°°±±²² ßß²ß ÞÛÛÛÛÛÛ²ÛÛÛÛÛÛ² ßßß² ßß ²²ÛÝ ß²ß ÞÛ²Û²² ÜÜÜ ß Þ±±°°°Ý ß²ß +Þ±±±²²ÛÝ  ÛÛÛÛÛÛÛÛÛÛÛÛÛÛßßÛÛ²Ûßß ßßßßß Û²²±±±± +Û²²ÛÛÛÛ ÞÛÛÛÛÛ²Û Ûßß Þ²ÜÛÛ²²²ÛÝ + ÜÜßß ßßßÜÜÜ ÛÛÛÛ²ß ÜÜÛÛÛÛÞÛÛ²ÛÛ  + °° ßßßßß°°° °° enigma«bbs softÛÛÛ²²Ûݰ ÞÛÛÛÛ² °° +dangermouse °°Û²ÛÛÛÛÛ²Û²Û ° +  °°  Û²ÛÛÛÛÜÜÜÜßßßßßßßßÜÜÜ +ÞÛÛßß°°°°° + ° ß °°°°°°°°°° + \ No newline at end of file diff --git a/mods/art/DM-ENIG.PCB b/mods/art/DM-ENIG.PCB new file mode 100644 index 00000000..247bd14a --- /dev/null +++ b/mods/art/DM-ENIG.PCB @@ -0,0 +1,19 @@ +@X0F @X04°°°°°°°°° @X0FܲÜÜ @X0E° @X0FÜÜ +@X0F @X04°°°@X4F°@X04°° @X0FÜÜßÛÛÛ²@X7FÛ@X4FÛÛ@X0FÛÛß @X07°° @X0E°°° °°°° ° @X0FÜÜÜßßßÜܲÛÛÛÛÛß +@X0F @X04° @X0FÜÜÜßß ÞÛ@X7FÛÛÛ@X4F²²@X0FÛÝ @X07°° @X0FÜÜÜ ÜÜ @X0E°@X0FÜÜÜßß Û@X4F²²@X0FÛÛÝ +@X0FßÜÜÜÛ@X7FÛÛ@X0F²Û @X04° @X0FÛ@X7FÛÛ²²²±±@X0F ÜÜÜßßÛÛÛÛ@X4F²Û@X0Fß Û²@X7F²²²±@X0FÜÜܲ@X7F²²²@X0F°° °° ÞÛÛÛÛÛ² +@X0F Þ@X7FÛ@X4F²²@X7FÛÛÛ@X0FÝ ÞÛ@X7F²²±±±°@X0FÝ @X7F²±@X0FÜÜ@X7F°±@X0Fß Þ@X7F²²²²Û@X0FÝ Þ@X7F²²±±±±±²²²±±±@X0F Þ@X7F²²@X0FÛ@X4FÛ@X0FÛÝ +@X0F @X7FÛÛÛÛ²²ß@X07 @X7F²²±±°°°@X07ÛÛ@X7F° °°°@X78°@X07Ý @X78 @X7F±±±²²@X08 @X7F²²±±°° °±±±°°°@X0F @X7F°±±²²Û@X0FÝ +@X0F°°²Û@X7F²²±°°@X0F°° ° @X07ßßß@X7F°°@X07ÛÛÜÞ@X7F°°@X78°°±±@X0F°° °° °@X7F° °°±± @X07Þ@X7F °±°@X78°°°@X07Þ@X7F °°@X78°°°@X0F°° °° ° °°@X7F°°°±±²@X0F°° +@X0F °Þ@X7F²±±°° @X07ÜÜ @X78°°±±²@X08Û Þ@X78±±°°@X7F° @X78°@X7F°@X78°±±@X08 @X07Þ@X7F°@X78°°°±@X08Ý @X01Ü @X07ÞÛ@X7F °°±±@X07 +@X01Ü @X07Þ@X7F±°° @X78°°@X08Ý @X01Ü @X08ÜÛÛÜÜÜ ÜÛ@X78²²²@X08ÛÛÝ@X01ÞÜ²ß @X08ÞÛ@X78²²±°° °°±±±±@X07 @X01Ü @X78±±±±²@X08Û @X01ÞÛÜÛÛ²Û @X07ÞÛÛÛÛ@X7F° @X07 @X01ÞÜ +@X19°@X01Ý @X7F° @X78°°±²@X08Ý @X19°°@X01ÛÜ @X08ÛÛ@X78²²@X08ÛÛÛÛÛÛ@X78²@X08ÛÛ²Ý @X19°°@X07 @X08ßßßßÛ@X78²±±@X08ßÞ@X78±±²²@X08 @X01Þ@X19°@X01Ý @X08Û@X78²²Û@X08ÛÝ @X01Û@X19°°@X01ÛÛÝ @X78°°°°@X07ÛÛÝ @X01ÛÛ@X19°@X07 +@X01ß @X78°°°±±²²@X08 @X01ßß²ß @X08ÞÛÛ@X78ÛÛ@X08ÛÛ²ÛÛÛÛÛÛ² @X01ßßß² ß @X08ß @X78²²@X08ÛÝ @X01ß²ß @X08ÞÛ²Û²² ÜÜÜ @X01ß @X08Þ@X78±±°°°@X07Ý @X01ß²ß +@X01 @X08Þ@X78±±±²²@X08ÛÝ ÛÛÛÛÛÛÛÛÛÛÛÛÛÛßß ÛÛ² Ûßß ßßß ßß Û@X78²²±±±±@X07 +@X0F @X08Û@X78²²ÛÛÛ@X08Û ÞÛÛÛÛÛ²Û Ûßß Þ² Ü ÛÛ@X78²²²@X08ÛÝ +@X08 ÜÜßß ßßßÜÜÜ ÛÛÛÛ²ß ÜÜÛÛÛÛ ÞÛ@X78Û²@X08ÛÛ +@X08 °° ßßßßß @X0C°°° °° @X0Feni@X4Fg@X0Fma« @X07bbs soft @X08ÛÛÛ@X78²²@X08ÛÝ @X04° @X08ÞÛÛÛÛ² @X04°° +@X08dangermouse °° Û²ÛÛÛÛ Û@X78²Û@X08²Û @X04° +@X04 @X08°° Û²ÛÛÛÛÜÜÜÜßßßßßßßßÜÜÜ +@X08 ÞÛÛßß @X04°@X4F°@X04°°° +@X04 @X08° ß @X04°°°°°°°°°° \ No newline at end of file diff --git a/mods/art/DM-ENIG2-MATRIX.ANS b/mods/art/DM-ENIG2-MATRIX.ANS new file mode 100644 index 0000000000000000000000000000000000000000..f6158c202c807b87a2f830bc9deebcccdbdbd213 GIT binary patch literal 3118 zcmb_e%WfM-5R|VBcnXk1c$rIp1aN?TkYWs;9L7lu$Vp(qzVKvNj3U4z8j=-%RRD5- zXKC%97^$l68IEKh&4a|9>8`G-?wRFeRV}8~qMgUds+lh*t9sE^F@(tqfBk8z2R_C9 zzMrhxHuinL-;YP1L;<8qAd}TGzJ3;_HLI8*nE3o|NdVF&yoA-NAu)>M2)w41#8ceBiZi3qg6O)1- zs5M)sLe{jy0|jaSu2G5K4j%?jR_;*~Z_}Q*_v2AWD;^8JX4Z60zmz`=UB^Dv3tu$l zgH=t!NqX9Niwhf}XGnVXm=H6RMq>airSBb4)1W??)NFOP+ikb9G!r!O=)`un^Im*V zyWnHlCnhp;(avD7zSC->A=_y_r|r(XiALjL`Fe zok%EvRJL1hzg9R=XaTvR63$3(bxD}t=B%0;fHmu^^a6`pCdxY`t2jH!%qpr~5R&ka z#CCOv!Wip-HfR+VtQkB!qG|4^ItJokCxqkU>wq;*DwtAJt%7^%<2`aiHzb0kEualH zSvQ?YaXg%du0Z{sZE)A*Sm2ORBc-Y*J*$ZHw4q_J$u}6t6?4N-9Noxkm+K8IGH`{JTb^D394IThD(4;T~r`*C8sm zq|expzesW|0~I+I7NE!GBY9&L$0NS0?V}`I2dxn*77e&uC|9)Z$$S+;jD>c*8k{^l z9lV>FDWbxVd~^1>fx_Wr(8KyC!%fc`hM9g>6c*dAQDc)*a^uOV)@0Q@T5?wT`=O;= zT;x?edv$rfzKWZh>orLoer9p=pY@x!wx*vLcvr{pnhSh6@&=6?6y`6Mh(^2$;WC7? z5Uwr9$If6f_@m{(p#?22nZ_o5ck;6SzNsFbJpAJatm+vkJdwH4ZkF4t0noeX*Ov7{ z_%SRumw#WJ-&{#b0o~k#Zh%3hK7}@v0K~E(P7~sb>-AN5a(3}G%`TFV4&%(>!9@|s?`#