/* jslint node: true */ 'use strict'; // ENiGMA½ const Log = require('../../logger.js').log; const { ServerModule } = require('../../server_module.js'); const Config = require('../../config.js').get; const { Errors } = require('../../enig_error.js'); const { splitTextAtTerms, isAnsi, stripAnsiControlCodes, wildcardMatch, } = require('../../string_util.js'); const { getMessageConferenceByTag, getMessageAreaByTag, getMessageListForArea, getAvailableMessageAreasByConfTag, } = require('../../message_area.js'); const { sortAreasOrConfs } = require('../../conf_area_util.js'); const AnsiPrep = require('../../ansi_prep.js'); const { wordWrapText } = require('../../word_wrap.js'); const { stripMciColorCodes } = require('../../color_codes.js'); // deps const net = require('net'); const _ = require('lodash'); const fs = require('graceful-fs'); const paths = require('path'); const moment = require('moment'); const ModuleInfo = (exports.moduleInfo = { name: 'Gopher', desc: 'A RFC-1436-ish Gopher Server', author: 'NuSkooler', packageName: 'codes.l33t.enigma.gopher.server', notes: 'https://tools.ietf.org/html/rfc1436', }); const Message = require('../../message.js'); const ItemTypes = { Invalid: '', // not really a type, of course! // Canonical, RFC-1436 TextFile: '0', SubMenu: '1', CCSONameserver: '2', Error: '3', BinHexFile: '4', DOSFile: '5', UuEncodedFile: '6', FullTextSearch: '7', Telnet: '8', BinaryFile: '9', AltServer: '+', GIFFile: 'g', ImageFile: 'I', Telnet3270: 'T', // Non-canonical HtmlFile: 'h', InfoMessage: 'i', SoundFile: 's', }; exports.getModule = class GopherModule extends ServerModule { constructor() { super(); this.routes = new Map(); // selector->generator => gopher item this.log = Log.child({ server: 'Gopher' }); } createServer(cb) { if (!this.enabled) { return cb(null); } const config = Config(); this.publicHostname = config.contentServers.gopher.publicHostname; this.publicPort = config.contentServers.gopher.publicPort; this.addRoute( /^\/?msgarea(\/[a-z0-9_-]+(\/[a-z0-9_-]+)?(\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(_raw)?)?)?\/?\r\n$/, this.messageAreaGenerator ); this.addRoute(/^(\/?[^\t\r\n]*)\r\n$/, this.staticGenerator); this.server = net.createServer(socket => { socket.setEncoding('ascii'); socket.on('data', data => { // sanitize a bit - bots like to inject garbage data = data.replace(/[^ -~\t\r\n]/g, ''); if (data) { this.routeRequest(data, socket); } else { this.notFoundGenerator('**invalid selector**', res => { return socket.end(`${res}`); }); } }); socket.on('error', err => { if ('ECONNRESET' !== err.code) { // normal this.log.trace({ error: err.message }, 'Socket error'); } }); }); return cb(null); } listen(cb) { if (!this.enabled) { return cb(null); } const config = Config(); const port = parseInt(config.contentServers.gopher.port); if (isNaN(port)) { this.log.warn( { port: config.contentServers.gopher.port, server: ModuleInfo.name }, 'Invalid Gopher port' ); return cb( Errors.Invalid(`Invalid port: ${config.contentServers.gopher.port}`) ); } return this.server.listen(port, config.contentServers.gopher.address, cb); } get enabled() { return ( _.get(Config(), 'contentServers.gopher.enabled', false) && this.isConfigured() ); } isConfigured() { // public hostname & port must be set; responses contain them! const config = Config(); return ( _.isString(_.get(config, 'contentServers.gopher.publicHostname')) && _.isNumber(_.get(config, 'contentServers.gopher.publicPort')) ); } addRoute(selectorRegExp, generatorHandler) { if (_.isString(selectorRegExp)) { try { selectorRegExp = new RegExp(`${selectorRegExp}\r\n`); } catch (e) { this.log.warn({ pattern: selectorRegExp }, 'Invalid RegExp for selector'); return false; } } this.routes.set(selectorRegExp, generatorHandler.bind(this)); } routeRequest(selector, socket) { let match; for (let [regex, gen] of this.routes) { match = selector.match(regex); if (match) { return gen(match, res => { return socket.end(`${res}`); }); } } this.notFoundGenerator(selector, res => { return socket.end(`${res}`); }); } makeItem(itemType, text, selector, hostname, port) { selector = selector || ''; // e.g. for info hostname = hostname || this.publicHostname; port = port || this.publicPort; return `${itemType}${text}\t${selector}\t${hostname}\t${port}\r\n`; } staticGenerator(selectorMatch, cb) { this.log.debug( { selector: selectorMatch[1] || '(gophermap)' }, 'Serving static content' ); const requestedPath = selectorMatch[1]; let path = this.resolveContentPath(requestedPath); if (!path) { return cb('Not found'); } fs.stat(path, (err, stats) => { if (err) { return cb('Not found'); } let isGopherMap = false; if (stats.isDirectory()) { path = paths.join(path, 'gophermap'); isGopherMap = true; } fs.readFile(path, isGopherMap ? 'utf8' : null, (err, content) => { if (err) { let content = 'You have reached an ENiGMA½ Gopher server!\r\n'; content += this.makeItem( ItemTypes.SubMenu, 'Public Message Area', '/msgarea' ); return cb(content); } if (isGopherMap) { // Convert any UNIX style LF's to DOS CRLF's content = content.replace(/\r?\n/g, '\r\n'); // variable support content = content .replace(/{publicHostname}/g, this.publicHostname) .replace(/{publicPort}/g, this.publicPort); } return cb(content); }); }); } resolveContentPath(requestPath) { const staticRoot = _.get(Config(), 'contentServers.gopher.staticRoot'); const path = paths.resolve(staticRoot, `.${requestPath}`); if (path.startsWith(staticRoot)) { return path; } } notFoundGenerator(selector, cb) { this.log.debug({ selector }, 'Serving not found content'); return cb('Not found'); } _getConfigForConferenceTag(confTag) { const sysConfig = Config(); let config = _.get(sysConfig, [ 'contentServers', 'gopher', 'exposedConfAreas', confTag, ]); if (config) { return [config, false]; // new } return [ _.get(sysConfig, ['contentServers', 'gopher', 'messageConferences', confTag]), true, ]; } isAreaAndConfExposed(confTag, areaTag) { const [confConfig, isLegacy] = this._getConfigForConferenceTag(confTag); if (isLegacy) { return Array.isArray(confConfig) && confConfig.includes(areaTag); } if (!Array.isArray(confConfig.include)) { return false; } let exposed = false; for (let rule of confConfig.include) { if (wildcardMatch(areaTag, rule)) { exposed = true; break; } } // may still be excluded for (let rule of confConfig.exclude || []) { if (wildcardMatch(areaTag, rule)) { exposed = false; break; } } return exposed; } prepareMessageBody(body, cb) { // // From RFC-1436: // "User display strings are intended to be displayed on a line on a // typical screen for a user's viewing pleasure. While many screens can // accommodate 80 character lines, some space is needed to display a tag // of some sort to tell the user what sort of item this is. Because of // this, the user display string should be kept under 70 characters in // length. Clients may truncate to a length convenient to them." // // Messages on BBSes however, have generally been <= 79 characters. If we // start wrapping earlier, things will generally be OK except: // * When we're doing with FTN-style quoted lines // * When dealing with ANSI/ASCII art // // Anyway, the spec says "should" and not MUST or even SHOULD! ...so, to // to follow the KISS principle: Wrap at 79. // const WordWrapColumn = 79; if (isAnsi(body)) { AnsiPrep( body, { cols: WordWrapColumn, // See notes above forceLineTerm: true, // Ensure each line is term'd asciiMode: true, // Export to ASCII fillLines: false, // Don't fill up to |cols| }, (err, prepped) => { return cb(prepped || body); } ); } else { const cleaned = stripMciColorCodes( stripAnsiControlCodes(body, { all: true }) ); const prepped = splitTextAtTerms(cleaned) .map(l => (wordWrapText(l, { width: WordWrapColumn }).wrapped || []).join('\n') ) .join('\n'); return cb(prepped); } } shortenSubject(subject) { return _.truncate(subject, { length: 30 }); } messageAreaGenerator(selectorMatch, cb) { this.log.trace({ selector: selectorMatch[0] }, 'Message area request'); // // Selector should be: // /msgarea - list confs // /msgarea/conftag - list areas in conf // /msgarea/conftag/areatag - list messages in area // /msgarea/conftag/areatag/ - message as text // /msgarea/conftag/areatag/_raw - full message as text + headers // if (selectorMatch[3] || selectorMatch[4]) { // message selector - display message // message //const raw = selectorMatch[4] ? true : false; // :TODO: support 'raw' const msgUuid = selectorMatch[3].replace(/\r\n|\//g, ''); const confTag = selectorMatch[1].substr(1).split('/')[0]; const areaTag = selectorMatch[2].replace(/\r\n|\//g, ''); return this._displayMessage(selectorMatch, msgUuid, confTag, areaTag, cb); } else if (selectorMatch[2]) { // conf/area selector -- list messages in area const confTag = selectorMatch[1].substr(1).split('/')[0]; const areaTag = selectorMatch[2].replace(/\r\n|\//g, ''); const area = getMessageAreaByTag(areaTag); return this._listMessagesInArea(selectorMatch, confTag, areaTag, area, cb); } else if (selectorMatch[1]) { // message conference selector -- list areas in this conference const confTag = selectorMatch[1].replace(/\r\n|\//g, ''); return this._listExposedMessageConferenceAreas(selectorMatch, confTag, cb); } else { // message area base selector -- list exposed message conferences return this._listExposedMessageConferences(cb); } } _makeAvailableMessageConferencesResponse(messageConferences, cb) { sortAreasOrConfs(messageConferences); const response = [ this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), this.makeItem(ItemTypes.InfoMessage, 'Available Message Conferences'), this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), this.makeItem(ItemTypes.InfoMessage, ''), ...messageConferences.map(conf => this.makeItem( ItemTypes.SubMenu, `${conf.name} ${conf.desc ? '- ' + conf.desc : ''}`, `/msgarea/${conf.confTag}` ) ), ].join(''); this.log.debug('Gopher serving message conference list'); return cb(response); } _exposedMessageConferenceTags(obj) { return Object.keys(obj || {}) .map(confTag => Object.assign({ confTag }, getMessageConferenceByTag(confTag)) ) .filter(conf => conf); // remove any baddies } _noExposedMessageConferences(cb) { return cb( this.makeItem(ItemTypes.InfoMessage, 'No message conferences available') ); } // newer format _listExposedMessageConferences(cb) { let exposedConfs = _.get(Config(), 'contentServers.gopher.exposedConfAreas'); if (!_.isObject(exposedConfs)) { return this._listExposedMessageConferencesLegacy(cb); } exposedConfs = this._exposedMessageConferenceTags(exposedConfs); if (0 === exposedConfs.length) { return this._noExposedMessageConferences(cb); } return this._makeAvailableMessageConferencesResponse(exposedConfs, cb); } // older deprecated format _listExposedMessageConferencesLegacy(cb) { const exposedConfs = this._exposedMessageConferenceTags( _.get(Config(), 'contentServers.gopher.messageConferences') ); if (0 === exposedConfs.length) { return this._noExposedMessageConferences(cb); } return this._makeAvailableMessageConferencesResponse(exposedConfs, cb); } _makeAvailableMessageAreasResponse(exposedConf, exposedAreas, cb) { // ensure nothing private is present exposedAreas = exposedAreas.filter( area => area && !Message.isPrivateAreaTag(area.areaTag) ); if (0 === exposedAreas.length) { return cb(this.makeItem(ItemTypes.InfoMessage, 'No message areas available')); } sortAreasOrConfs(exposedAreas); const response = [ this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), this.makeItem(ItemTypes.InfoMessage, `Message areas in ${exposedConf.name}`), this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), ...exposedAreas.map(area => this.makeItem( ItemTypes.SubMenu, `${area.name} ${area.desc ? '- ' + area.desc : ''}`, `/msgarea/${exposedConf.confTag}/${area.areaTag}` ) ), ].join(''); this.log.debug( { confTag: exposedConf.confTag }, 'Gopher serving message area list' ); return cb(response); } _listExposedMessageConferenceAreas(selectorMatch, confTag, cb) { // // New system -- exposedConfAreas: // We have a required array |include| of area tags that may // contain wildcards and a _optional_ |exclude| array that // overrides any includes // // Deprecated -- messageConferences: // The key should point to an array of area tags // const [confConfig, isLegacy] = this._getConfigForConferenceTag(confTag); const messageConference = getMessageConferenceByTag(confTag); // we need the actual conf! if (!messageConference) { return this.notFoundGenerator(selectorMatch, cb); } let areas; if (isLegacy) { areas = (confConfig || []).map(areaTag => Object.assign({ areaTag }, getMessageAreaByTag(areaTag)) ); } else { // new system is more complex here, but nicer for the +op to manage areas = getAvailableMessageAreasByConfTag(confTag); if (!Array.isArray(confConfig.include)) { return cb( this.makeItem(ItemTypes.InfoMessage, 'No message areas available') ); } // filters |areas| down to what |includes| matches areas = _.filter(areas, (area, areaTag) => { for (let rule of confConfig.include) { if (wildcardMatch(areaTag, rule)) { area.areaTag = areaTag; return true; } } return false; }); // now filter out any excludes, if present if (Array.isArray(confConfig.exclude)) { areas = _.filter(areas, area => { for (let rule of confConfig.exclude) { if (wildcardMatch(area.areaTag, rule)) { return false; } } return true; }); } } return this._makeAvailableMessageAreasResponse(messageConference, areas, cb); } _listMessagesInArea(selectorMatch, confTag, areaTag, area, cb) { if (Message.isPrivateAreaTag(areaTag)) { this.log.warn({ areaTag }, `Gopher attempted access to private "${areaTag}"`); return cb(this.makeItem(ItemTypes.InfoMessage, 'Area is private')); } if (!area || !this.isAreaAndConfExposed(confTag, areaTag)) { this.log.warn( { confTag, areaTag }, `Gopher attempted access to non-exposed "${confTag}"/"${areaTag}"` ); return this.notFoundGenerator(selectorMatch, cb); } const filter = { resultType: 'messageList', sort: 'messageId', order: 'descending', // we want newest messages first for Gopher }; return getMessageListForArea(null, areaTag, filter, (err, msgList) => { const response = [ this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), this.makeItem(ItemTypes.InfoMessage, `Messages in ${area.name}`), this.makeItem(ItemTypes.InfoMessage, '(newest first)'), this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), ...msgList.map(msg => { let m; try { m = moment(msg.modTimestamp); } catch (e) { this.log.warn( `Error parsing "${msg.modTimestamp}"; expected timestamp: ${e.message}` ); m = moment(); } return this.makeItem( ItemTypes.TextFile, `${m.format('YYYY-MM-DD hh:mma')}: ${this.shortenSubject( msg.subject )} (${msg.fromUserName} to ${msg.toUserName})`, `/msgarea/${confTag}/${areaTag}/${msg.messageUuid}` ); }), ].join(''); this.log.debug({ confTag, areaTag }, 'Gopher serving message list'); return cb(response); }); } _displayMessage(selectorMatch, msgUuid, confTag, areaTag, cb) { const message = new Message(); return message.load({ uuid: msgUuid }, err => { if (err) { this.log.debug( { uuid: msgUuid }, 'Attempted access to non-existent message UUID!' ); return this.notFoundGenerator(selectorMatch, cb); } if ( message.areaTag !== areaTag || !this.isAreaAndConfExposed(confTag, areaTag) ) { this.log.warn( { areaTag }, `Gopher attempted access to non-exposed "${confTag}"/"${areaTag}"` ); return this.notFoundGenerator(selectorMatch, cb); } if (Message.isPrivateAreaTag(areaTag)) { this.log.warn( { areaTag }, `Gopher attempted access to message in private "${areaTag}"` ); return this.notFoundGenerator(selectorMatch, cb); } this.prepareMessageBody(message.message, msgBody => { const response = `${'-'.repeat(70)} To : ${message.toUserName} From : ${message.fromUserName} When : ${moment(message.modTimestamp).format('dddd, MMMM Do YYYY, h:mm:ss a (UTCZ)')} Subject: ${message.subject} ID : ${message.messageUuid} (${message.messageId}) ${'-'.repeat(70)} ${msgBody} `; this.log.debug( { confTag, areaTag, uuid: message.messageUuid, }, `Gopher serving message "${message.subject}"` ); return cb(response); }); }); } };