+ NNTP Content Server
* Read-only to public conf/areas only for now * Missing some protocol support * Could use better encoding practices and ANSI prep
This commit is contained in:
parent
cde329b439
commit
772022f0d0
|
@ -404,6 +404,33 @@ function getDefaultConfig() {
|
||||||
// Set messageConferences{} to maps of confTag -> [ areaTag1, areaTag2, ... ]
|
// Set messageConferences{} to maps of confTag -> [ areaTag1, areaTag2, ... ]
|
||||||
// to export message confs/areas
|
// to export message confs/areas
|
||||||
//
|
//
|
||||||
|
},
|
||||||
|
|
||||||
|
nntp : {
|
||||||
|
// internal caching of groups, message lists, etc.
|
||||||
|
cache : {
|
||||||
|
maxItems : 200,
|
||||||
|
maxAge : 1000 * 30, // 30s
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// Set publicMessageConferences{} to a map of confTag -> [ areaTag1, areaTag2, ... ]
|
||||||
|
// in order to export *public* conf/areas that are available to anonymous
|
||||||
|
// NNTP users. Other conf/areas: Standard ACS rules apply.
|
||||||
|
//
|
||||||
|
publicMessageConferences: {},
|
||||||
|
|
||||||
|
nntp : {
|
||||||
|
enabled : false,
|
||||||
|
port : 8119,
|
||||||
|
},
|
||||||
|
|
||||||
|
nntps : {
|
||||||
|
enabled : false,
|
||||||
|
port : 8563,
|
||||||
|
certPem : paths.join(__dirname, './../config/nntps_cert.pem'),
|
||||||
|
keyPem : paths.join(__dirname, './../config/nntps_key.pem'),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ const crypto = require('crypto');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const querystring = require('querystring');
|
const querystring = require('querystring');
|
||||||
const fs = require('fs');
|
const fs = require('fs-extra');
|
||||||
const SSHClient = require('ssh2').Client;
|
const SSHClient = require('ssh2').Client;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -238,7 +238,7 @@ module.exports = class Message {
|
||||||
filter.ids - use with resultType='uuid'
|
filter.ids - use with resultType='uuid'
|
||||||
filter.toUserName
|
filter.toUserName
|
||||||
filter.fromUserName
|
filter.fromUserName
|
||||||
filter.replyToMesageId
|
filter.replyToMessageId
|
||||||
|
|
||||||
filter.newerThanTimestamp - may not be used with |date|
|
filter.newerThanTimestamp - may not be used with |date|
|
||||||
filter.date - moment object - may not be used with |newerThanTimestamp|
|
filter.date - moment object - may not be used with |newerThanTimestamp|
|
||||||
|
@ -253,7 +253,7 @@ module.exports = class Message {
|
||||||
filter.order = ascending | (descending)
|
filter.order = ascending | (descending)
|
||||||
|
|
||||||
filter.limit
|
filter.limit
|
||||||
filter.resultType = (id) | uuid | count
|
filter.resultType = (id) | uuid | count | messageList
|
||||||
filter.extraFields = []
|
filter.extraFields = []
|
||||||
|
|
||||||
filter.privateTagUserId = <userId> - if set, only private messages belonging to <userId> are processed
|
filter.privateTagUserId = <userId> - if set, only private messages belonging to <userId> are processed
|
||||||
|
@ -529,22 +529,22 @@ module.exports = class Message {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// :TODO: this should only take a UUID...
|
load(loadWith, cb) {
|
||||||
load(options, cb) {
|
assert(_.isString(loadWith.uuid) || _.isNumber(loadWith.messageId));
|
||||||
assert(_.isString(options.uuid));
|
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
async.series(
|
async.series(
|
||||||
[
|
[
|
||||||
function loadMessage(callback) {
|
function loadMessage(callback) {
|
||||||
|
const whereField = loadWith.uuid ? 'message_uuid' : 'message_id';
|
||||||
msgDb.get(
|
msgDb.get(
|
||||||
`SELECT message_id, area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject,
|
`SELECT message_id, area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject,
|
||||||
message, modified_timestamp, view_count
|
message, modified_timestamp, view_count
|
||||||
FROM message
|
FROM message
|
||||||
WHERE message_uuid=?
|
WHERE ${whereField} = ?
|
||||||
LIMIT 1;`,
|
LIMIT 1;`,
|
||||||
[ options.uuid ],
|
[ loadWith.uuid || loadWith.messageId ],
|
||||||
(err, msgRow) => {
|
(err, msgRow) => {
|
||||||
if(err) {
|
if(err) {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
|
|
|
@ -0,0 +1,769 @@
|
||||||
|
/* 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 {
|
||||||
|
getMessageAreaByTag,
|
||||||
|
getMessageConferenceByTag,
|
||||||
|
getMessageListForArea,
|
||||||
|
} = require('../../message_area.js');
|
||||||
|
const User = require('../../user.js');
|
||||||
|
const Errors = require('../../enig_error.js').Errors;
|
||||||
|
const Message = require('../../message.js');
|
||||||
|
const FTNAddress = require('../../ftn_address.js');
|
||||||
|
const {
|
||||||
|
isAnsi,
|
||||||
|
cleanControlCodes,
|
||||||
|
splitTextAtTerms,
|
||||||
|
} = require('../../string_util.js');
|
||||||
|
const AnsiPrep = require('../../ansi_prep.js');
|
||||||
|
|
||||||
|
// deps
|
||||||
|
const NNTPServerBase = require('nntp-server');
|
||||||
|
const _ = require('lodash');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const asyncReduce = require('async/reduce');
|
||||||
|
const asyncMap = require('async/map');
|
||||||
|
const asyncSeries = require('async/series');
|
||||||
|
const LRU = require('lru-cache');
|
||||||
|
const iconv = require('iconv-lite');
|
||||||
|
|
||||||
|
//
|
||||||
|
// Network News Transfer Protocol (NNTP)
|
||||||
|
//
|
||||||
|
// RFCS
|
||||||
|
// - https://www.w3.org/Protocols/rfc977/rfc977
|
||||||
|
// - https://tools.ietf.org/html/rfc3977
|
||||||
|
// - https://tools.ietf.org/html/rfc2980
|
||||||
|
// - https://tools.ietf.org/html/rfc5536
|
||||||
|
|
||||||
|
//
|
||||||
|
exports.moduleInfo = {
|
||||||
|
name : 'NNTP',
|
||||||
|
desc : 'Network News Transfer Protocol (NNTP) Server',
|
||||||
|
author : 'NuSkooler',
|
||||||
|
packageName : 'codes.l33t.enigma.nntp.server',
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
General TODO
|
||||||
|
- ACS checks need worked out. Currently ACS relies on |client|. We need a client
|
||||||
|
spec that can be created even without a login server. Some checks and simply
|
||||||
|
return false/fail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
class NNTPServer extends NNTPServerBase {
|
||||||
|
constructor(options, serverName) {
|
||||||
|
super(options);
|
||||||
|
|
||||||
|
this.log = Log.child( { server : serverName } );
|
||||||
|
|
||||||
|
const config = Config();
|
||||||
|
this.groupCache = new LRU({
|
||||||
|
max : _.get(config, 'contentServers.nntp.cache.maxItems', 200),
|
||||||
|
maxAge : _.get(config, 'contentServers.nntp.cache.maxAge', 1000 * 30), // default=30s
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_needAuth(session, command) {
|
||||||
|
return super._needAuth(session, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
_authenticate(session) {
|
||||||
|
const username = session.authinfo_user;
|
||||||
|
const password = session.authinfo_pass;
|
||||||
|
|
||||||
|
this.log.trace( { username }, 'Authentication request');
|
||||||
|
|
||||||
|
return new Promise( resolve => {
|
||||||
|
const user = new User();
|
||||||
|
user.authenticate(username, password, err => {
|
||||||
|
if(err) {
|
||||||
|
this.log.debug( { username, reason : err.message }, 'Authentication failure');
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.authUser = user;
|
||||||
|
|
||||||
|
this.log.debug( { username }, 'User authenticated successfully');
|
||||||
|
return resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageListIndexByMessageID(id, session) {
|
||||||
|
return id - _.get(session.groupInfo.messageList, [ 0, 'messageId' ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
isGroupSelected(session) {
|
||||||
|
return Array.isArray(_.get(session, 'groupInfo.messageList'));
|
||||||
|
}
|
||||||
|
|
||||||
|
getJAMStyleFrom(message, fromName) {
|
||||||
|
//
|
||||||
|
// Try to to create a (JamNTTPd) JAM style "From" field:
|
||||||
|
//
|
||||||
|
// - If we're dealing with a FTN address, create an email-like format
|
||||||
|
// but do not include ':' or '/' characters as it may cause clients
|
||||||
|
// to puke. FTN addresses are formatted how JamNTTPd does it for
|
||||||
|
// some sort of compliance. We also extend up to 5D addressing.
|
||||||
|
// - If we have an email address, then it's ready to go.
|
||||||
|
//
|
||||||
|
const remoteFrom = _.get(message.meta, [ 'System', Message.SystemMetaNames.RemoteFromUser ]);
|
||||||
|
let jamStyleFrom;
|
||||||
|
if(remoteFrom) {
|
||||||
|
const flavor = _.get(message.meta, [ 'System', Message.SystemMetaNames.ExternalFlavor ]);
|
||||||
|
switch(flavor) {
|
||||||
|
case [ Message.AddressFlavor.FTN ] :
|
||||||
|
{
|
||||||
|
let ftnAddr = FTNAddress.fromString(remoteFrom);
|
||||||
|
if(ftnAddr && ftnAddr.isValid()) {
|
||||||
|
// In general, addresses are in point, node, net, zone, domain order
|
||||||
|
if(ftnAddr.domain) { // 5D
|
||||||
|
// point.node.net.zone@domain or node.net.zone@domain
|
||||||
|
jamStyleFrom = `${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}@${ftnAddr.domain}`;
|
||||||
|
if(ftnAddr.point) {
|
||||||
|
jamStyleFrom = `${ftnAddr.point}.` + jamStyleFrom;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(ftnAddr.point) {
|
||||||
|
jamStyleFrom = `${ftnAddr.point}@${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}`;
|
||||||
|
} else {
|
||||||
|
jamStyleFrom = `0@${ftnAddr.node}.${ftnAddr.net}.${ftnAddr.zone}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case [ Message.AddressFlavor.Email ] :
|
||||||
|
jamStyleFrom = `${fromName} <${remoteFrom}>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!jamStyleFrom) {
|
||||||
|
jamStyleFrom = fromName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jamStyleFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
populateNNTPHeaders(session, message, cb) {
|
||||||
|
//
|
||||||
|
// Build compliant headers
|
||||||
|
//
|
||||||
|
// Resources:
|
||||||
|
// - https://tools.ietf.org/html/rfc5536#section-3.1
|
||||||
|
// - https://github.com/ftnapps/jamnntpd/blob/master/src/nntpserv.c#L962
|
||||||
|
//
|
||||||
|
const toName = this.getMessageTo(message);
|
||||||
|
const fromName = this.getMessageFrom(message);
|
||||||
|
|
||||||
|
message.nntpHeaders = {
|
||||||
|
From : this.getJAMStyleFrom(message, fromName),
|
||||||
|
'X-Comment-To' : toName,
|
||||||
|
Newsgroups : session.group.name,
|
||||||
|
Subject : message.subject,
|
||||||
|
Date : this.getMessageDate(message),
|
||||||
|
'Message-ID' : this.getMessageIdentifier(message),
|
||||||
|
Path : 'ENiGMA1/2!not-for-mail',
|
||||||
|
'Content-Type' : 'text/plain; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalFlavor = _.get(message.meta.System, [ Message.SystemMetaNames.ExternalFlavor ]);
|
||||||
|
if(externalFlavor) {
|
||||||
|
message.nntpHeaders['X-ENiG-MessageFlavor'] = externalFlavor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any FTN properties -> X-FTN-*
|
||||||
|
_.each(message.meta.FtnProperty, (v, k) => {
|
||||||
|
const suffix = {
|
||||||
|
[ Message.FtnPropertyNames.FtnTearLine ] : 'Tearline',
|
||||||
|
[ Message.FtnPropertyNames.FtnOrigin ] : 'Origin',
|
||||||
|
[ Message.FtnPropertyNames.FtnArea ] : 'AREA',
|
||||||
|
[ Message.FtnPropertyNames.FtnSeenBy ] : 'SEEN-BY',
|
||||||
|
}[k];
|
||||||
|
|
||||||
|
if(suffix) {
|
||||||
|
// some special treatment.
|
||||||
|
if('Tearline' === suffix) {
|
||||||
|
v = v.replace(/^--- /, '');
|
||||||
|
} else if('Origin' === suffix) {
|
||||||
|
v = v.replace(/^[ ]{1,2}\* Origin: /, '');
|
||||||
|
}
|
||||||
|
if(Array.isArray(v)) { // ie: SEEN-BY[] -> one big list
|
||||||
|
v = v.join(' ');
|
||||||
|
}
|
||||||
|
message.nntpHeaders[`X-FTN-${suffix}`] = v.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other FTN kludges
|
||||||
|
_.each(message.meta.FtnKludge, (v, k) => {
|
||||||
|
if(Array.isArray(v)) {
|
||||||
|
v = v.join(' '); // same as above
|
||||||
|
}
|
||||||
|
message.nntpHeaders[`X-FTN-${k.toUpperCase()}`] = v.toString().trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// Set X-FTN-To and X-FTN-From:
|
||||||
|
// - If remote to/from : joeuser <remoteAddr>
|
||||||
|
// - Without remote : joeuser
|
||||||
|
//
|
||||||
|
const remoteFrom = _.get(message.meta, [ 'System', Message.SystemMetaNames.RemoteFromUser ]);
|
||||||
|
message.nntpHeaders['X-FTN-From'] = remoteFrom ? `${fromName} <${remoteFrom}>` : fromName;
|
||||||
|
const remoteTo = _.get(message.meta [ 'System', Message.SystemMetaNames.RemoteToUser ]);
|
||||||
|
message.nntpHeaders['X-FTN-To'] = remoteTo ? `${toName} <${remoteTo}>` : toName;
|
||||||
|
|
||||||
|
if(!message.replyToMsgId) {
|
||||||
|
return cb(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// replyToMessageId -> Message-ID formatted ID
|
||||||
|
const filter = {
|
||||||
|
resultType : 'uuid',
|
||||||
|
ids : [ parseInt(message.replyToMsgId) ],
|
||||||
|
limit : 1,
|
||||||
|
};
|
||||||
|
Message.findMessages(filter, (err, uuids) => {
|
||||||
|
if(!err && Array.isArray(uuids)) {
|
||||||
|
message.nntpHeaders.References = this.makeMessageIdentifier(message.replyToMsgId, uuids[0]);
|
||||||
|
}
|
||||||
|
return cb(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageUUIDFromMessageID(session, messageId) {
|
||||||
|
let messageUuid;
|
||||||
|
|
||||||
|
// Direct ID request
|
||||||
|
if((_.isString(messageId) && '<' !== messageId.charAt(0)) || _.isNumber(messageId)) {
|
||||||
|
// group must be in session
|
||||||
|
if(!this.isGroupSelected(session)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageId = parseInt(messageId);
|
||||||
|
if(isNaN(messageId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Adjust to offset in message list & get UUID
|
||||||
|
// This works since we create "pseudo IDs" to return to NNTP
|
||||||
|
// by using firstRealID + index. A find on |index| member would
|
||||||
|
// also work, but would be O(n).
|
||||||
|
//
|
||||||
|
const mlIndex = this.getMessageListIndexByMessageID(messageId, session);
|
||||||
|
messageUuid = _.get(session.groupInfo.messageList, [ mlIndex, 'messageUuid']);
|
||||||
|
} else {
|
||||||
|
// <Message-ID> request
|
||||||
|
[ , messageUuid ] = this.getMessageIdentifierParts(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!_.isString(messageUuid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getArticle(session, messageId) {
|
||||||
|
return new Promise( resolve => {
|
||||||
|
this.log.trace( { messageId }, 'Get article request');
|
||||||
|
|
||||||
|
const messageUuid = this.getMessageUUIDFromMessageID(session, messageId);
|
||||||
|
if(!messageUuid) {
|
||||||
|
this.log.debug( { messageId }, 'Unable to retrieve message UUID for article request');
|
||||||
|
return resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = new Message();
|
||||||
|
asyncSeries(
|
||||||
|
[
|
||||||
|
(callback) => {
|
||||||
|
return message.load( { uuid : messageUuid }, callback);
|
||||||
|
},
|
||||||
|
(callback) => {
|
||||||
|
// :TODO: Must validate access! See Gopher, etc. !!!!!
|
||||||
|
// :TODO: we can only do this if a <message-id> style was sent in, not a direct ID ??????
|
||||||
|
if(session.groupInfo.areaTag !== message.areaTag) {
|
||||||
|
return resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!this.hasConfAndAreaReadAccess(session, session.groupInfo.confTag, session.groupInfo.areaTag)) {
|
||||||
|
this.log.info(`Access denied to message ${messageUuid}`);
|
||||||
|
return resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback(null);
|
||||||
|
},
|
||||||
|
(callback) => {
|
||||||
|
return this.populateNNTPHeaders(session, message, callback);
|
||||||
|
},
|
||||||
|
(callback) => {
|
||||||
|
return this.prepareMessageBody(message, callback);
|
||||||
|
}
|
||||||
|
],
|
||||||
|
err => {
|
||||||
|
if(err) {
|
||||||
|
this.log.error( { error : err.message, messageId }, 'Failed to load article');
|
||||||
|
return resolve(null);
|
||||||
|
}
|
||||||
|
return resolve(message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRange(session, first, last, options) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
//
|
||||||
|
// Build an array of message objects that can later
|
||||||
|
// be used with the various _build* methods.
|
||||||
|
//
|
||||||
|
// Messages must belong to the range of *pseudo IDs*
|
||||||
|
// aka |index|.
|
||||||
|
//
|
||||||
|
// :TODO: Handle |options|
|
||||||
|
if(!this.isGroupSelected(session)) {
|
||||||
|
return resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuids = session.groupInfo.messageList.filter(m => {
|
||||||
|
if(m.areaTag !== session.groupInfo.areaTag) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if(m.index < first || m.index > last) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).map(m => {
|
||||||
|
return { uuid : m.messageUuid, index : m.index }
|
||||||
|
});
|
||||||
|
|
||||||
|
asyncMap(uuids, (msgInfo, nextMessageUuid) => {
|
||||||
|
const message = new Message();
|
||||||
|
message.load( { uuid : msgInfo.uuid }, err => {
|
||||||
|
if(err) {
|
||||||
|
return nextMessageUuid(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.index = msgInfo.index;
|
||||||
|
|
||||||
|
this.populateNNTPHeaders(session, message, () => {
|
||||||
|
this.prepareMessageBody(message, () => {
|
||||||
|
return nextMessageUuid(null, message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err, messages) => {
|
||||||
|
return resolve(err ? null : messages);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectGroup (session, groupName) {
|
||||||
|
this.log.trace( { groupName }, 'Select group request');
|
||||||
|
|
||||||
|
return new Promise( resolve => {
|
||||||
|
this.getGroup(session, groupName, (err, group) => {
|
||||||
|
if(err) {
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.group = Object.assign(
|
||||||
|
{}, // start clean
|
||||||
|
{
|
||||||
|
description : group.friendlyDesc || group.friendlyName,
|
||||||
|
current_article : group.nntp.total ? group.nntp.min_index : 0,
|
||||||
|
},
|
||||||
|
group.nntp
|
||||||
|
);
|
||||||
|
|
||||||
|
session.groupInfo = group; // full set of info
|
||||||
|
|
||||||
|
return resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_getGroups(session, time, wildmat) {
|
||||||
|
this.log.trace( { time, wildmat }, 'Get groups request');
|
||||||
|
|
||||||
|
// :TODO: handle time - probably use as caching mechanism - must consider user/auth/rights
|
||||||
|
// :TODO: handle |time| if possible.
|
||||||
|
return new Promise( (resolve, reject) => {
|
||||||
|
const config = Config();
|
||||||
|
|
||||||
|
// :TODO: merge confs avail to authenticated user
|
||||||
|
const publicConfs = _.get(config, 'contentServers.nntp.publicMessageConferences', {});
|
||||||
|
|
||||||
|
asyncReduce(Object.keys(publicConfs), [], (groups, confTag, nextConfTag) => {
|
||||||
|
const areaTags = publicConfs[confTag];
|
||||||
|
// :TODO: merge area tags available to authenticated user
|
||||||
|
asyncMap(areaTags, (areaTag, nextAreaTag) => {
|
||||||
|
const groupName = this.getGroupName(confTag, areaTag);
|
||||||
|
|
||||||
|
// filter on |wildmat| if supplied. We will remove
|
||||||
|
// empty areas below in the final results.
|
||||||
|
if(wildmat && !wildmat.test(groupName)) {
|
||||||
|
return nextAreaTag(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getGroup(session, groupName, (err, group) => {
|
||||||
|
if(err) {
|
||||||
|
return nextAreaTag(null, null); // try others
|
||||||
|
}
|
||||||
|
return nextAreaTag(null, group.nntp);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err, areas) => {
|
||||||
|
if(err) {
|
||||||
|
return nextConfTag(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
areas = areas.filter(a => a && Object.keys(a).length > 0); // remove empty
|
||||||
|
groups.push(...areas);
|
||||||
|
|
||||||
|
return nextConfTag(null, groups);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err, groups) => {
|
||||||
|
if(err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
return resolve(groups);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfAndAreaPubliclyExposed(confTag, areaTag) {
|
||||||
|
const publicAreaTags = _.get(Config(), [ 'contentServers', 'nntp', 'publicMessageConferences', confTag ] );
|
||||||
|
return Array.isArray(publicAreaTags) && publicAreaTags.includes(areaTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasConfAndAreaReadAccess(session, confTag, areaTag) {
|
||||||
|
if(Message.isPrivateAreaTag(areaTag)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.isConfAndAreaPubliclyExposed(confTag, areaTag)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// further checks require an authenticated user & ACS
|
||||||
|
if(!session || !session.authUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conf = getMessageConferenceByTag(confTag);
|
||||||
|
if(!conf) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// :TODO: validate ACS
|
||||||
|
|
||||||
|
const area = getMessageAreaByTag(areaTag, confTag);
|
||||||
|
if(!area) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// :TODO: validate ACS
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroup(session, groupName, cb) {
|
||||||
|
let group = this.groupCache.get(groupName);
|
||||||
|
if(group) {
|
||||||
|
return cb(null, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ confTag, areaTag ] = groupName.split('.');
|
||||||
|
if(!confTag || !areaTag) {
|
||||||
|
return cb(Errors.UnexpectedState(`Invalid NNTP group name: ${groupName}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!this.hasConfAndAreaReadAccess(session, confTag, areaTag)) {
|
||||||
|
return cb(Errors.AccessDenied(`No access to conference ${confTag} and/or area ${areaTag}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = getMessageAreaByTag(areaTag, confTag);
|
||||||
|
if(!area) {
|
||||||
|
return cb(Errors.DoesNotExist(`No area for areaTag "${areaTag}" / confTag "${confTag}"`));
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageListForArea(null, areaTag, (err, messageList) => {
|
||||||
|
if(err) {
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(0 === messageList.length) {
|
||||||
|
//
|
||||||
|
// Handle empty group
|
||||||
|
// See https://tools.ietf.org/html/rfc3977#section-6.1.1.2
|
||||||
|
//
|
||||||
|
return cb(null, {
|
||||||
|
messageList : [],
|
||||||
|
confTag,
|
||||||
|
areaTag,
|
||||||
|
friendlyName : area.name,
|
||||||
|
friendlyDesc : area.desc,
|
||||||
|
nntp : {
|
||||||
|
name : groupName,
|
||||||
|
min_index : 0,
|
||||||
|
max_index : 0,
|
||||||
|
total : 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMsg = messageList[0];
|
||||||
|
|
||||||
|
// node-nntp wants "index"
|
||||||
|
let index = firstMsg.messageId;
|
||||||
|
messageList.forEach(m => {
|
||||||
|
m.index = index;
|
||||||
|
++index;
|
||||||
|
});
|
||||||
|
|
||||||
|
group = {
|
||||||
|
messageList,
|
||||||
|
confTag,
|
||||||
|
areaTag,
|
||||||
|
friendlyName : area.name,
|
||||||
|
friendlyDesc : area.desc,
|
||||||
|
nntp : {
|
||||||
|
name : groupName,
|
||||||
|
min_index : firstMsg.messageId,
|
||||||
|
max_index : firstMsg.messageId + messageList.length - 1,
|
||||||
|
total : messageList.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.groupCache.set(groupName, group);
|
||||||
|
|
||||||
|
return cb(null, group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHead(session, message) {
|
||||||
|
return _.map(message.nntpHeaders, (v, k) => `${k}: ${v}`).join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBody(session, message) {
|
||||||
|
return message.preparedBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHeaderField(session, message, field) {
|
||||||
|
const body = message.preparedBody || message.message;
|
||||||
|
const value = {
|
||||||
|
':bytes' : Buffer.byteLength(body).toString(),
|
||||||
|
':lines' : splitTextAtTerms(body).length.toString(),
|
||||||
|
}[field] || _.find(message.nntpHeaders, (v, k) => {
|
||||||
|
return k.toLowerCase() === field;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!value) {
|
||||||
|
this.log.debug(`No value for requested header field "${field}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getOverviewFmt(session) {
|
||||||
|
return super._getOverviewFmt(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getNewNews(session, time, wildmat) {
|
||||||
|
throw new Error('method `nntp._getNewNews` is not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageDate(message) {
|
||||||
|
// https://tools.ietf.org/html/rfc5536#section-3.1.1 -> https://tools.ietf.org/html/rfc5322#section-3.3
|
||||||
|
return message.modTimestamp.format('ddd, D MMM YYYY HH:mm:ss ZZ');
|
||||||
|
}
|
||||||
|
|
||||||
|
makeMessageIdentifier(messageId, messageUuid) {
|
||||||
|
//
|
||||||
|
// Spec : RFC-5536 Section 3.1.3 @ https://tools.ietf.org/html/rfc5536#section-3.1.3
|
||||||
|
// Example : <2456.0f6587f7-5512-4d03-8740-4d592190145a@enigma-bbs>
|
||||||
|
//
|
||||||
|
return `<${messageId}.${messageUuid}@enigma-bbs>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageIdentifier(message) {
|
||||||
|
return this.makeMessageIdentifier(message.messageId, message.messageUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageIdentifierParts(messageId) {
|
||||||
|
const m = messageId.match(/<([0-9]+)\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})@enigma-bbs>/);
|
||||||
|
if(m) {
|
||||||
|
return [ m[1], m[2] ];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageTo(message) {
|
||||||
|
// :TODO: same as From -- check config
|
||||||
|
return message.toUserName;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageFrom(message) {
|
||||||
|
// :TODO: NNTP config > conf > area config for real names
|
||||||
|
return message.fromUserName;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareMessageBody(message, cb) {
|
||||||
|
if(isAnsi(message.message)) {
|
||||||
|
AnsiPrep(
|
||||||
|
message.message,
|
||||||
|
{
|
||||||
|
termWidth : 1000, // unrealistically long; don't want to wrap, really.
|
||||||
|
cols : 1000, // ...see above.
|
||||||
|
rows : 'auto',
|
||||||
|
asciiMode : true, // Export to ASCII
|
||||||
|
fillLines : false, // Don't fill up columns
|
||||||
|
},
|
||||||
|
(err, prepped) => {
|
||||||
|
message.preparedBody = prepped || message.message;
|
||||||
|
return cb(null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message.preparedBody = cleanControlCodes(message.message, { all : true });
|
||||||
|
return cb(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroupName(confTag, areaTag) {
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// input : fsxNet (confTag) fsx_bbs (areaTag)
|
||||||
|
// output: fsx_net.fsx_bbs
|
||||||
|
//
|
||||||
|
// Note also that periods are replaced in conf and area
|
||||||
|
// tags such that we *only* have a period separator
|
||||||
|
// between the two for a group name!
|
||||||
|
//
|
||||||
|
return `${_.snakeCase(confTag).replace(/\./g, '_')}.${_.snakeCase(areaTag).replace(/\./g, '_')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getModule = class NNTPServerModule extends ServerModule {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return this.enableNntp || this.enableNttps;
|
||||||
|
}
|
||||||
|
|
||||||
|
get enableNntp() {
|
||||||
|
return _.get(Config(), 'contentServers.nntp.nntp.enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get enableNttps() {
|
||||||
|
return _.get(Config(), 'contentServers.nntp.nntps.enabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured() {
|
||||||
|
const config = Config();
|
||||||
|
|
||||||
|
//
|
||||||
|
// Any conf/areas exposed?
|
||||||
|
//
|
||||||
|
const publicConfs = _.get(config, 'contentServers.nntp.publicMessageConferences', {});
|
||||||
|
const areasExposed = _.some(publicConfs, areas => {
|
||||||
|
return Array.isArray(areas) && areas.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!areasExposed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nntp = _.get(config, 'contentServers.nntp.nntp');
|
||||||
|
if(nntp && this.enableNntp) {
|
||||||
|
if(isNaN(nntp.port)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nntps = _.get(config, 'contentServers.nntp.nntps');
|
||||||
|
if(nntps && this.enableNttps) {
|
||||||
|
if(isNaN(nntps.port)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!_.isString(nntps.certPem) || !_.isString(nntps.keyPem)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
createServer() {
|
||||||
|
if(!this.isEnabled() || !this.isConfigured()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = Config();
|
||||||
|
|
||||||
|
const commonOptions = {
|
||||||
|
//requireAuth : true, // :TODO: re-enable!
|
||||||
|
// :TODO: override |session| - use our own debug to Bunyan, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
if(this.enableNntp) {
|
||||||
|
this.nntpServer = new NNTPServer(
|
||||||
|
// :TODO: according to docs: if connection is non-tls, but behind proxy (assuming TLS termination?!!) then set this to true
|
||||||
|
Object.assign( { secure : false }, commonOptions),
|
||||||
|
'NNTP'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.enableNttps) {
|
||||||
|
this.nntpsServer = new NNTPServer(
|
||||||
|
Object.assign(
|
||||||
|
{
|
||||||
|
secure : true,
|
||||||
|
tls : {
|
||||||
|
cert : fs.readFileSync(config.contentServers.nntp.nntps.certPem),
|
||||||
|
key : fs.readFileSync(config.contentServers.nntp.nntps.keyPem),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commonOptions
|
||||||
|
),
|
||||||
|
'NTTPS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listen() {
|
||||||
|
const config = Config();
|
||||||
|
[ 'nntp', 'nntps' ].forEach( service => {
|
||||||
|
const server = this[`${service}Server`];
|
||||||
|
if(server) {
|
||||||
|
const port = config.contentServers.nntp[service].port;
|
||||||
|
server.listen(this.listenURI(port, service))
|
||||||
|
.catch(e => {
|
||||||
|
Log.warn( { error : e.message, port }, `${service.toUpperCase()} failed to listen`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// :TODO: listen() needs to be async. I always should have been...
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
listenURI(port, service = 'nntp') {
|
||||||
|
return `${service}://localhost:${port}`;
|
||||||
|
}
|
||||||
|
};
|
|
@ -222,6 +222,39 @@
|
||||||
// messageConferences: {
|
// messageConferences: {
|
||||||
// some_conf: [ "area_tag1", "area_tag2" ]
|
// some_conf: [ "area_tag1", "area_tag2" ]
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
// You may also wish to enable NNTP services
|
||||||
|
nntp: {
|
||||||
|
//
|
||||||
|
// Set publicMessageConferences{} to configure
|
||||||
|
// publicly exposed conferences & areas.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// publicMessageConferences: {
|
||||||
|
// some_conf: [ "area_tag1", "area_tag2" ]
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
publicMessageConferences: {}
|
||||||
|
|
||||||
|
// non-secure
|
||||||
|
nntp: {
|
||||||
|
enabled: false
|
||||||
|
port: XXXXX
|
||||||
|
}
|
||||||
|
|
||||||
|
// secure (TLS)
|
||||||
|
nntps: {
|
||||||
|
enabled: false
|
||||||
|
port: XXXXX
|
||||||
|
|
||||||
|
//
|
||||||
|
// You will need a SSL/TLS certificate and key
|
||||||
|
//
|
||||||
|
certPem: XXXXX
|
||||||
|
keyPem: XXXXX
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,9 @@
|
||||||
"uuid-parse": "^1.0.0",
|
"uuid-parse": "^1.0.0",
|
||||||
"ws": "^6.1.2",
|
"ws": "^6.1.2",
|
||||||
"xxhash": "^0.2.4",
|
"xxhash": "^0.2.4",
|
||||||
"yazl": "^2.5.0"
|
"yazl": "^2.5.0",
|
||||||
|
"nntp-server": "^1.0.3",
|
||||||
|
"lru-cache" : "^5.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
"devDependencies": {},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
Loading…
Reference in New Issue