+ Initial source checkin

This commit is contained in:
NuSkooler 2014-10-16 20:21:06 -06:00
parent 9804c93f2e
commit 9a7e90b9b2
31 changed files with 4361 additions and 0 deletions

285
core/ansi_escape_parser.js Normal file
View file

@ -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);

338
core/ansi_term.js Normal file
View file

@ -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(): |<case sensitive letter>
// * fromPCBoard(): (@X<bg><fg>@)
// * fromWildcat(): (@<bg><fg>@ (same as PCBoard without 'X' prefix)
// * fromWWIV(): <ctrl-c><0-7>
// * fromSyncronet(): <ctrl-a><colorCode>
// 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;
}

672
core/art.js Normal file
View file

@ -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
// -
}

155
core/bbs.js Normal file
View file

@ -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);
}
}

89
core/client.js Normal file
View file

@ -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;
};

101
core/client_term.js Normal file
View file

@ -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));
};

50
core/config.js Normal file
View file

@ -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'),
}
},
};
}
};

64
core/line_editor.js Normal file
View file

@ -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);
}
};

39
core/logger.js Normal file
View file

@ -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'
}*/
]
});
}
};

28
core/misc_util.js Normal file
View file

@ -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);
};

85
core/modules.js Normal file
View file

@ -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);
}
}
});
};

30
core/servers/ssh.js Normal file
View file

@ -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;
}

729
core/servers/telnet.js Normal file
View file

@ -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 + <type>). 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;
};