2018-04-16 02:25:56 +00:00
|
|
|
/* jslint node: true */
|
|
|
|
'use strict';
|
|
|
|
|
2018-06-23 03:26:46 +00:00
|
|
|
// ENiGMA½
|
2018-12-15 08:55:38 +00:00
|
|
|
const Log = require('../../logger.js').log;
|
|
|
|
const { ServerModule } = require('../../server_module.js');
|
|
|
|
const Config = require('../../config.js').get;
|
2018-12-27 09:46:16 +00:00
|
|
|
const { Errors } = require('../../enig_error.js');
|
2018-04-16 02:25:56 +00:00
|
|
|
const {
|
2018-06-22 05:15:04 +00:00
|
|
|
splitTextAtTerms,
|
|
|
|
isAnsi,
|
2018-12-18 05:08:59 +00:00
|
|
|
stripAnsiControlCodes
|
2018-12-15 08:55:38 +00:00
|
|
|
} = require('../../string_util.js');
|
2018-04-16 02:25:56 +00:00
|
|
|
const {
|
2018-06-22 05:15:04 +00:00
|
|
|
getMessageConferenceByTag,
|
|
|
|
getMessageAreaByTag,
|
|
|
|
getMessageListForArea,
|
2018-12-15 08:55:38 +00:00
|
|
|
} = 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');
|
2018-04-16 02:25:56 +00:00
|
|
|
|
2018-06-23 03:26:46 +00:00
|
|
|
// deps
|
|
|
|
const net = require('net');
|
|
|
|
const _ = require('lodash');
|
|
|
|
const fs = require('graceful-fs');
|
|
|
|
const paths = require('path');
|
|
|
|
const moment = require('moment');
|
2018-04-16 02:25:56 +00:00
|
|
|
|
|
|
|
const ModuleInfo = exports.moduleInfo = {
|
2018-06-23 03:26:46 +00:00
|
|
|
name : 'Gopher',
|
2018-11-21 04:02:30 +00:00
|
|
|
desc : 'A RFC-1436-ish Gopher Server',
|
2018-06-23 03:26:46 +00:00
|
|
|
author : 'NuSkooler',
|
|
|
|
packageName : 'codes.l33t.enigma.gopher.server',
|
2018-11-21 04:02:30 +00:00
|
|
|
notes : 'https://tools.ietf.org/html/rfc1436',
|
2018-04-16 02:25:56 +00:00
|
|
|
};
|
|
|
|
|
2018-06-23 03:26:46 +00:00
|
|
|
const Message = require('../../message.js');
|
2018-04-16 02:25:56 +00:00
|
|
|
|
|
|
|
const ItemTypes = {
|
2018-06-23 03:26:46 +00:00
|
|
|
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',
|
2018-04-16 02:25:56 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
exports.getModule = class GopherModule extends ServerModule {
|
|
|
|
|
2018-06-22 05:15:04 +00:00
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
|
2018-06-23 03:26:46 +00:00
|
|
|
this.routes = new Map(); // selector->generator => gopher item
|
2018-06-22 05:15:04 +00:00
|
|
|
this.log = Log.child( { server : 'Gopher' } );
|
|
|
|
}
|
|
|
|
|
2018-12-27 09:19:26 +00:00
|
|
|
createServer(cb) {
|
2018-06-22 05:15:04 +00:00
|
|
|
if(!this.enabled) {
|
2018-12-27 09:19:26 +00:00
|
|
|
return cb(null);
|
2018-06-22 05:15:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const config = Config();
|
|
|
|
this.publicHostname = config.contentServers.gopher.publicHostname;
|
2018-06-23 03:26:46 +00:00
|
|
|
this.publicPort = config.contentServers.gopher.publicPort;
|
2018-06-22 05:15:04 +00:00
|
|
|
|
|
|
|
this.addRoute(/^\/?\r\n$/, this.defaultGenerator);
|
|
|
|
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.server = net.createServer( socket => {
|
|
|
|
socket.setEncoding('ascii');
|
|
|
|
|
|
|
|
socket.on('data', data => {
|
|
|
|
this.routeRequest(data, socket);
|
|
|
|
});
|
|
|
|
|
|
|
|
socket.on('error', err => {
|
2018-06-23 03:26:46 +00:00
|
|
|
if('ECONNRESET' !== err.code) { // normal
|
2018-06-22 05:15:04 +00:00
|
|
|
this.log.trace( { error : err.message }, 'Socket error');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
2018-12-27 09:19:26 +00:00
|
|
|
|
|
|
|
return cb(null);
|
2018-06-22 05:15:04 +00:00
|
|
|
}
|
|
|
|
|
2018-12-27 09:46:16 +00:00
|
|
|
listen(cb) {
|
2018-06-22 05:15:04 +00:00
|
|
|
if(!this.enabled) {
|
2018-12-27 09:46:16 +00:00
|
|
|
return cb(null);
|
2018-06-22 05:15:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 port' );
|
2018-12-27 09:46:16 +00:00
|
|
|
return cb(Errors.Invalid(`Invalid port: ${config.contentServers.gopher.port}`));
|
2018-06-22 05:15:04 +00:00
|
|
|
}
|
|
|
|
|
2019-04-10 02:24:52 +00:00
|
|
|
return this.server.listen(port, config.contentServers.gopher.address, cb);
|
2018-06-22 05:15:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
get enabled() {
|
|
|
|
return _.get(Config(), 'contentServers.gopher.enabled', false) && this.isConfigured();
|
|
|
|
}
|
|
|
|
|
|
|
|
isConfigured() {
|
2018-06-23 03:26:46 +00:00
|
|
|
// public hostname & port must be set; responses contain them!
|
2018-06-22 05:15:04 +00:00
|
|
|
const config = Config();
|
|
|
|
return _.isString(_.get(config, 'contentServers.gopher.publicHostname')) &&
|
2018-06-23 03:26:46 +00:00
|
|
|
_.isNumber(_.get(config, 'contentServers.gopher.publicPort'));
|
2018-06-22 05:15:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2018-06-23 03:26:46 +00:00
|
|
|
selector = selector || ''; // e.g. for info
|
2018-06-22 05:15:04 +00:00
|
|
|
hostname = hostname || this.publicHostname;
|
|
|
|
port = port || this.publicPort;
|
|
|
|
return `${itemType}${text}\t${selector}\t${hostname}\t${port}\r\n`;
|
|
|
|
}
|
|
|
|
|
|
|
|
defaultGenerator(selectorMatch, cb) {
|
2018-07-08 02:01:52 +00:00
|
|
|
this.log.debug( { selector : selectorMatch[0] }, 'Serving default content');
|
2018-06-22 05:15:04 +00:00
|
|
|
|
2018-11-21 04:02:30 +00:00
|
|
|
let bannerFile = _.get(Config(), 'contentServers.gopher.bannerFile', 'gopher_banner.asc');
|
2018-06-22 05:15:04 +00:00
|
|
|
bannerFile = paths.isAbsolute(bannerFile) ? bannerFile : paths.join(__dirname, '../../../misc', bannerFile);
|
|
|
|
fs.readFile(bannerFile, 'utf8', (err, banner) => {
|
|
|
|
if(err) {
|
|
|
|
return cb('You have reached an ENiGMA½ Gopher server!');
|
|
|
|
}
|
|
|
|
|
|
|
|
banner = splitTextAtTerms(banner).map(l => this.makeItem(ItemTypes.InfoMessage, l)).join('');
|
|
|
|
banner += this.makeItem(ItemTypes.SubMenu, 'Public Message Area', '/msgarea');
|
|
|
|
return cb(banner);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
notFoundGenerator(selector, cb) {
|
2018-07-08 02:01:52 +00:00
|
|
|
this.log.debug( { selector }, 'Serving not found content');
|
2018-06-22 05:15:04 +00:00
|
|
|
return cb('Not found');
|
|
|
|
}
|
|
|
|
|
|
|
|
isAreaAndConfExposed(confTag, areaTag) {
|
|
|
|
const conf = _.get(Config(), [ 'contentServers', 'gopher', 'messageConferences', confTag ]);
|
|
|
|
return Array.isArray(conf) && conf.includes(areaTag);
|
|
|
|
}
|
|
|
|
|
|
|
|
prepareMessageBody(body, cb) {
|
2018-11-21 04:02:30 +00:00
|
|
|
//
|
|
|
|
// 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;
|
2018-06-22 05:15:04 +00:00
|
|
|
if(isAnsi(body)) {
|
|
|
|
AnsiPrep(
|
|
|
|
body,
|
|
|
|
{
|
2018-11-21 04:02:30 +00:00
|
|
|
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|
|
2018-06-22 05:15:04 +00:00
|
|
|
},
|
|
|
|
(err, prepped) => {
|
|
|
|
return cb(prepped || body);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
} else {
|
2018-12-15 08:55:38 +00:00
|
|
|
const cleaned = stripMciColorCodes(
|
2018-12-18 05:08:59 +00:00
|
|
|
stripAnsiControlCodes(body, { all : true } )
|
2018-12-15 08:55:38 +00:00
|
|
|
);
|
|
|
|
const prepped =
|
|
|
|
splitTextAtTerms(cleaned)
|
|
|
|
.map(l => (wordWrapText(l, { width : WordWrapColumn } ).wrapped || []).join('\n'))
|
|
|
|
.join('\n');
|
2018-11-21 04:02:30 +00:00
|
|
|
|
|
|
|
return cb(prepped);
|
2018-06-22 05:15:04 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
shortenSubject(subject) {
|
|
|
|
return _.truncate(subject, { length : 30 } );
|
|
|
|
}
|
|
|
|
|
|
|
|
messageAreaGenerator(selectorMatch, cb) {
|
2018-07-08 02:01:52 +00:00
|
|
|
this.log.debug( { selector : selectorMatch[0] }, 'Serving message area content');
|
2018-06-22 05:15:04 +00:00
|
|
|
//
|
2018-06-23 03:26:46 +00:00
|
|
|
// Selector should be:
|
|
|
|
// /msgarea - list confs
|
|
|
|
// /msgarea/conftag - list areas in conf
|
|
|
|
// /msgarea/conftag/areatag - list messages in area
|
|
|
|
// /msgarea/conftag/areatag/<UUID> - message as text
|
|
|
|
// /msgarea/conftag/areatag/<UUID>_raw - full message as text + headers
|
2018-06-22 05:15:04 +00:00
|
|
|
//
|
|
|
|
if(selectorMatch[3] || selectorMatch[4]) {
|
2018-06-23 03:26:46 +00:00
|
|
|
// message
|
2018-06-22 05:15:04 +00:00
|
|
|
//const raw = selectorMatch[4] ? true : false;
|
2018-06-23 03:26:46 +00:00
|
|
|
// :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, '');
|
|
|
|
const message = new Message();
|
2018-06-22 05:15:04 +00:00
|
|
|
|
|
|
|
return message.load( { uuid : msgUuid }, err => {
|
|
|
|
if(err) {
|
2018-11-22 00:55:31 +00:00
|
|
|
this.log.debug( { uuid : msgUuid }, 'Attempted access to non-existent message UUID!');
|
2018-06-22 05:15:04 +00:00
|
|
|
return this.notFoundGenerator(selectorMatch, cb);
|
|
|
|
}
|
|
|
|
|
|
|
|
if(message.areaTag !== areaTag || !this.isAreaAndConfExposed(confTag, areaTag)) {
|
|
|
|
this.log.warn( { areaTag }, 'Attempted access to non-exposed conference and/or area!');
|
|
|
|
return this.notFoundGenerator(selectorMatch, cb);
|
|
|
|
}
|
|
|
|
|
|
|
|
if(Message.isPrivateAreaTag(areaTag)) {
|
|
|
|
this.log.warn( { areaTag }, 'Attempted access to message in private area!');
|
|
|
|
return this.notFoundGenerator(selectorMatch, cb);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.prepareMessageBody(message.message, msgBody => {
|
|
|
|
const response = `${'-'.repeat(70)}
|
2018-04-16 02:25:56 +00:00
|
|
|
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}
|
2018-06-23 03:26:46 +00:00
|
|
|
`;
|
2018-06-22 05:15:04 +00:00
|
|
|
return cb(response);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
} else if(selectorMatch[2]) {
|
2018-06-23 03:26:46 +00:00
|
|
|
// list messages in area
|
|
|
|
const confTag = selectorMatch[1].substr(1).split('/')[0];
|
|
|
|
const areaTag = selectorMatch[2].replace(/\r\n|\//g, '');
|
|
|
|
const area = getMessageAreaByTag(areaTag);
|
2018-06-22 05:15:04 +00:00
|
|
|
|
|
|
|
if(Message.isPrivateAreaTag(areaTag)) {
|
|
|
|
this.log.warn( { areaTag }, 'Attempted access to private area!');
|
|
|
|
return cb(this.makeItem(ItemTypes.InfoMessage, 'Area is private'));
|
|
|
|
}
|
|
|
|
|
|
|
|
if(!area || !this.isAreaAndConfExposed(confTag, areaTag)) {
|
|
|
|
this.log.warn( { confTag, areaTag }, 'Attempted access to non-exposed conference and/or area!');
|
|
|
|
return this.notFoundGenerator(selectorMatch, cb);
|
|
|
|
}
|
|
|
|
|
2018-11-22 00:55:31 +00:00
|
|
|
const filter = {
|
|
|
|
resultType : 'messageList',
|
|
|
|
sort : 'messageId',
|
|
|
|
order : 'descending', // we want newest messages first for Gopher
|
|
|
|
};
|
|
|
|
|
|
|
|
return getMessageListForArea(null, areaTag, filter, (err, msgList) => {
|
2018-06-22 05:15:04 +00:00
|
|
|
const response = [
|
|
|
|
this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)),
|
|
|
|
this.makeItem(ItemTypes.InfoMessage, `Messages in ${area.name}`),
|
2018-11-22 00:55:31 +00:00
|
|
|
this.makeItem(ItemTypes.InfoMessage, '(newest first)'),
|
2018-06-22 05:15:04 +00:00
|
|
|
this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)),
|
|
|
|
...msgList.map(msg => this.makeItem(
|
|
|
|
ItemTypes.TextFile,
|
|
|
|
`${moment(msg.modTimestamp).format('YYYY-MM-DD hh:mma')}: ${this.shortenSubject(msg.subject)} (${msg.fromUserName} to ${msg.toUserName})`,
|
|
|
|
`/msgarea/${confTag}/${areaTag}/${msg.messageUuid}`
|
|
|
|
))
|
|
|
|
].join('');
|
|
|
|
|
|
|
|
return cb(response);
|
|
|
|
});
|
|
|
|
} else if(selectorMatch[1]) {
|
2018-06-23 03:26:46 +00:00
|
|
|
// list areas in conf
|
2018-06-22 05:15:04 +00:00
|
|
|
const sysConfig = Config();
|
2018-06-23 03:26:46 +00:00
|
|
|
const confTag = selectorMatch[1].replace(/\r\n|\//g, '');
|
|
|
|
const conf = _.get(sysConfig, [ 'contentServers', 'gopher', 'messageConferences', confTag ]) && getMessageConferenceByTag(confTag);
|
2018-06-22 05:15:04 +00:00
|
|
|
if(!conf) {
|
|
|
|
return this.notFoundGenerator(selectorMatch, cb);
|
|
|
|
}
|
|
|
|
|
|
|
|
const areas = _.get(sysConfig, [ 'contentServers', 'gopher', 'messageConferences', confTag ], {})
|
|
|
|
.map(areaTag => Object.assign( { areaTag }, getMessageAreaByTag(areaTag)))
|
|
|
|
.filter(area => area && !Message.isPrivateAreaTag(area.areaTag));
|
|
|
|
|
|
|
|
if(0 === areas.length) {
|
|
|
|
return cb(this.makeItem(ItemTypes.InfoMessage, 'No message areas available'));
|
|
|
|
}
|
|
|
|
|
|
|
|
sortAreasOrConfs(areas);
|
|
|
|
|
|
|
|
const response = [
|
|
|
|
this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)),
|
|
|
|
this.makeItem(ItemTypes.InfoMessage, `Message areas in ${conf.name}`),
|
|
|
|
this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)),
|
2018-12-15 09:06:15 +00:00
|
|
|
...areas.map(area => this.makeItem(ItemTypes.SubMenu, `${area.name} ${area.desc ? '- ' + area.desc : ''}`, `/msgarea/${confTag}/${area.areaTag}`))
|
2018-06-22 05:15:04 +00:00
|
|
|
].join('');
|
|
|
|
|
|
|
|
return cb(response);
|
|
|
|
} else {
|
2018-06-23 03:26:46 +00:00
|
|
|
// message area base (list confs)
|
2018-06-22 05:15:04 +00:00
|
|
|
const confs = Object.keys(_.get(Config(), 'contentServers.gopher.messageConferences', {}))
|
|
|
|
.map(confTag => Object.assign( { confTag }, getMessageConferenceByTag(confTag)))
|
2018-06-23 03:26:46 +00:00
|
|
|
.filter(conf => conf); // remove any baddies
|
2018-06-22 05:15:04 +00:00
|
|
|
|
|
|
|
if(0 === confs.length) {
|
|
|
|
return cb(this.makeItem(ItemTypes.InfoMessage, 'No message conferences available'));
|
|
|
|
}
|
|
|
|
|
|
|
|
sortAreasOrConfs(confs);
|
|
|
|
|
|
|
|
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, ''),
|
2018-12-15 09:06:15 +00:00
|
|
|
...confs.map(conf => this.makeItem(ItemTypes.SubMenu, `${conf.name} ${conf.desc ? '- ' + conf.desc : ''}`, `/msgarea/${conf.confTag}`))
|
2018-06-22 05:15:04 +00:00
|
|
|
].join('');
|
|
|
|
|
|
|
|
return cb(response);
|
|
|
|
}
|
|
|
|
}
|
2018-04-16 02:25:56 +00:00
|
|
|
};
|