diff --git a/core/config.js b/core/config.js index 345f4c09..f75be3de 100644 --- a/core/config.js +++ b/core/config.js @@ -404,6 +404,33 @@ function getDefaultConfig() { // Set messageConferences{} to maps of confTag -> [ areaTag1, areaTag2, ... ] // 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'), + } } }, diff --git a/core/exodus.js b/core/exodus.js index e25b4973..0d439392 100644 --- a/core/exodus.js +++ b/core/exodus.js @@ -17,7 +17,7 @@ const crypto = require('crypto'); const moment = require('moment'); const https = require('https'); const querystring = require('querystring'); -const fs = require('fs'); +const fs = require('fs-extra'); const SSHClient = require('ssh2').Client; /* diff --git a/core/message.js b/core/message.js index df0804d5..34c590bb 100644 --- a/core/message.js +++ b/core/message.js @@ -238,7 +238,7 @@ module.exports = class Message { filter.ids - use with resultType='uuid' filter.toUserName filter.fromUserName - filter.replyToMesageId + filter.replyToMessageId filter.newerThanTimestamp - may not be used with |date| filter.date - moment object - may not be used with |newerThanTimestamp| @@ -253,7 +253,7 @@ module.exports = class Message { filter.order = ascending | (descending) filter.limit - filter.resultType = (id) | uuid | count + filter.resultType = (id) | uuid | count | messageList filter.extraFields = [] filter.privateTagUserId = - if set, only private messages belonging to are processed @@ -529,22 +529,22 @@ module.exports = class Message { }); } - // :TODO: this should only take a UUID... - load(options, cb) { - assert(_.isString(options.uuid)); + load(loadWith, cb) { + assert(_.isString(loadWith.uuid) || _.isNumber(loadWith.messageId)); const self = this; async.series( [ function loadMessage(callback) { + const whereField = loadWith.uuid ? 'message_uuid' : 'message_id'; msgDb.get( `SELECT message_id, area_tag, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, message, modified_timestamp, view_count FROM message - WHERE message_uuid=? + WHERE ${whereField} = ? LIMIT 1;`, - [ options.uuid ], + [ loadWith.uuid || loadWith.messageId ], (err, msgRow) => { if(err) { return callback(err); diff --git a/core/servers/content/nntp.js b/core/servers/content/nntp.js new file mode 100644 index 00000000..cf2a43b2 --- /dev/null +++ b/core/servers/content/nntp.js @@ -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 + // - 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 { + // 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 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}`; + } +}; diff --git a/misc/config_template.in.hjson b/misc/config_template.in.hjson index 489a7cb3..504cd02d 100644 --- a/misc/config_template.in.hjson +++ b/misc/config_template.in.hjson @@ -222,6 +222,39 @@ // messageConferences: { // 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 + } } } diff --git a/package.json b/package.json index f6425f01..89f99a00 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "uuid-parse": "^1.0.0", "ws": "^6.1.2", "xxhash": "^0.2.4", - "yazl": "^2.5.0" + "yazl": "^2.5.0", + "nntp-server": "^1.0.3", + "lru-cache" : "^5.1.1" }, "devDependencies": {}, "engines": {