diff --git a/core/message.js b/core/message.js index 3c0b9c00..6a063802 100644 --- a/core/message.js +++ b/core/message.js @@ -55,6 +55,7 @@ const ADDRESS_FLAVOR = { FTN: 'ftn', // FTN style Email: 'email', // From email QWK: 'qwk', // QWK packet + NNTP: 'nntp', // NNTP article POST; often a email address }; const STATE_FLAGS0 = { diff --git a/core/servers/content/nntp.js b/core/servers/content/nntp.js index 95ed9326..dbc3febc 100644 --- a/core/servers/content/nntp.js +++ b/core/servers/content/nntp.js @@ -9,6 +9,7 @@ const { getTransactionDatabase, getModDatabasePath } = require('../../database.j const { getMessageAreaByTag, getMessageConferenceByTag, + persistMessage, } = require('../../message_area.js'); const User = require('../../user.js'); const Errors = require('../../enig_error.js').Errors; @@ -21,6 +22,7 @@ const { } = require('../../string_util.js'); const AnsiPrep = require('../../ansi_prep.js'); const { stripMciColorCodes } = require('../../color_codes.js'); +const ACS = require('../../acs'); // deps const NNTPServerBase = require('nntp-server'); @@ -111,6 +113,37 @@ class NNTPDatabase { let nntpDatabase; +const AuthCommands = 'POST'; + +// these aren't exported by the NNTP module, unfortunantely +const Responses = { + ArticlePostedOk: '240 article posted ok', + + SendArticle: '340 send article to be posted', + + ArticlePostFailed: '441 posting failed', + AuthRequired: '480 authentication required', +}; + +const PostCommand = { + head: 'POST', + validate: /^POST$/i, + + run(session, cmd) { + if (!session.authenticated) { + session.receivingPostArticle = false; // ensure reset + return Responses.AuthRequired; + } + + session.receivingPostArticle = true; + return Responses.SendArticle; + }, + + capability(session, report) { + report.push('POST'); + }, +}; + class NNTPServer extends NNTPServerBase { constructor(options, serverName) { super(options); @@ -125,6 +158,10 @@ class NNTPServer extends NNTPServerBase { } _needAuth(session, command) { + if (AuthCommands.includes(command)) { + return !session.authenticated && !session.authUser; + } + return super._needAuth(session, command); } @@ -132,7 +169,8 @@ class NNTPServer extends NNTPServerBase { const username = session.authinfo_user; const password = session.authinfo_pass; - this.log.trace({ username }, 'Authentication request'); + // :TODO: log IP address on these.... + this.log.debug({ username }, `NNTP authentication request for "${username}"`); return new Promise(resolve => { const user = new User(); @@ -140,17 +178,19 @@ class NNTPServer extends NNTPServerBase { { type: User.AuthFactor1Types.Password, username, password }, err => { if (err) { - // :TODO: Log IP address - this.log.debug( + this.log.warn( { username, reason: err.message }, - 'Authentication failure' + `NNTP authentication failure for "${username}"` ); return resolve(false); } session.authUser = user; - this.log.debug({ username }, 'User authenticated successfully'); + this.log.info( + { username }, + `NTTP authentication success for "${username}"` + ); return resolve(true); } ); @@ -592,15 +632,33 @@ class NNTPServer extends NNTPServerBase { if (!conf) { return false; } - // :TODO: validate ACS const area = getMessageAreaByTag(areaTag, confTag); if (!area) { return false; } - // :TODO: validate ACS - return false; + const acs = new ACS({ client: null, user: session.authUser }); + return acs.hasMessageConfRead(conf) && acs.hasMessageAreaRead(area); + } + + static hasConfAndAreaWriteAccess(session, confTag, areaTag) { + if (Message.isPrivateAreaTag(areaTag)) { + return false; + } + + const conf = getMessageConferenceByTag(confTag); + if (!conf) { + return false; + } + + const area = getMessageAreaByTag(areaTag, confTag); + if (!area) { + return false; + } + + const acs = new ACS({ client: null, user: session.authUser }); + return acs.hasMessageConfWrite(conf) && acs.hasMessageAreaWrite(area); } getGroup(session, groupName, cb) { @@ -919,8 +977,223 @@ class NNTPServer extends NNTPServerBase { areaTag ).replace(/\./g, '_')}`; } + + static _importMessage(session, articleLines, cb) { + asyncWaterfall( + [ + callback => { + return NNTPServer._parseArticleLines(articleLines, callback); + }, + (parsed, callback) => { + // gather some initially important bits + const subject = parsed.header.get('subject'); + const to = parsed.header.get('to'); // non-standard, may be missing + const from = parsed.header.get('from'); + const date = parsed.header.get('date'); + const newsgroups = parsed.header + .get('newsgroups') + .split(',') + .map(ng => { + const [confTag, areaTag] = ng.split('.'); + return { confTag, areaTag }; + }); + + // validate areaTag exists -- currently only a single area/post; no x-posts + // :TODO: look into x-posting + const area = getMessageAreaByTag(newsgroups[0].areaTag); + if (!area) { + return callback( + Errors.DoesNotExist( + `No area by tag "${newsgroups[0].areaTag}" exists!` + ) + ); + } + + // NOTE: Not all ACS checks work with NNTP since we don't have a standard client; + // If a particular ACS requires a |client|, it will return false! + if ( + !NNTPServer.hasConfAndAreaWriteAccess( + session, + area.confTag, + area.areaTag + ) + ) { + return callback( + Errors.AccessDenied( + `No ACS to ${area.confTag}/${area.areaTag}` + ) + ); + } + + if ( + !_.isString(subject) || + !_.isString(from) || + !_.isString(date) || + !Array.isArray(newsgroups) + ) { + return callback( + Errors.Invalid('Missing one or more required article fields') + ); + } + + return callback(null, { + subject, + from, + date, + newsgroups, + to, + parsed, + }); + }, + (msgData, callback) => { + if (msgData.to) { + return callback(null, msgData); + } + + // + // We don't have a 'to' field, try to derive if this is a + // response to a message. If not, just fall back 'All' + // + // 'References' + // - https://www.rfc-editor.org/rfc/rfc5536#section-3.2.10 + // - https://www.rfc-editor.org/rfc/rfc5322.html + // + // 'In-Reply-To' + // - https://www.rfc-editor.org/rfc/rfc5322.html + // + // Both may contain 1:N, "optionally" separated by CFWS; by this + // point in the code, they should be space separated at most. + // + // Each entry is in msg-id format. That is: + // "<" id-left "@" id-right ">" + // + msgData.to = 'All'; // fallback + let parentMessageId = ( + msgData.parsed.header.get('in-reply-to') || + msgData.parsed.header.get('references') || + '' + ) + .split(' ')[0] + .replace(/[\<\>]/g, ''); + + if (parentMessageId) { + let m = /[0-9]+\.([0-9a-f\-]{36})@enigma-bbs/.exec( + parentMessageId + ); + if (m && m[1]) { + const filter = { + resultType: 'messageList', + uuids: m[1], + limit: 1, + }; + + return Message.findMessages(filter, (err, messageList) => { + if (err) { + return callback(err); + } + + // current message/article is a reply to this message: + msgData.to = messageList[0].fromUserName; + msgData.replyToMsgId = messageList[0].replyToMsgId; // may not be present + return callback(null, msgData); + }); + } + } + + return callback(null, msgData); + }, + (msgData, callback) => { + const message = new Message({ + toUserName: msgData.to, + fromUserName: msgData.from, + subject: msgData.subject, + replyToMsgId: msgData.replyToMsgId || 0, + modTimestamp: msgData.date, // moment can generally parse these + // :TODO: inspect Content-Type 'charset' if present & attempt to properly decode if not UTF-8 + message: msgData.parsed.body.slice(0, -1).join('\n'), // remove trailing blank + areaTag: msgData.newsgroups[0].areaTag, + }); + + message.meta.System[Message.SystemMetaNames.ExternalFlavor] = + Message.AddressFlavor.NNTP; + + // :TODO: investigate JAMNTTP clients/etc. + + persistMessage(message, err => { + return callback(err); + }); + }, + ], + err => { + return cb(err); + } + ); + } + + static _parseArticleLines(articleLines, cb) { + // + // Split articleLines into: + // - Header split into N:V pairs + // - Message Body lines + // - + const header = new Map(); + const body = []; + let inHeader = true; + let currentHeaderName; + forEachSeries( + articleLines, + (line, nextLine) => { + if (inHeader) { + if (line === '.' || line === '') { + inHeader = false; + return nextLine(null); + } + + const sep = line.indexOf(':'); + if (sep < 1) { + // at least a single char name + // entries can split across lines -- they will be prefixed with a single space. + if ( + currentHeaderName && + (line.startsWith(' ') || line.startsWith('\t')) + ) { + let v = header.get(currentHeaderName); + v += line + .replace(/^\t/, ' ') // if we're dealign with a legacy tab + .trimRight(); + header.set(currentHeaderName, v); + return nextLine(null); + } + + return nextLine( + Errors.Invalid( + `"${line}" is not a valid NNTP message header!` + ) + ); + } + + currentHeaderName = line.slice(0, sep).trim().toLowerCase(); + const value = line.slice(sep + 1).trim(); + header.set(currentHeaderName, value); + return nextLine(null); + } + + // body + if (line !== '.') { + // :TODO: de-escape lines that start with ".." -> "." + body.push(line); + } + return nextLine(null); + }, + err => { + return cb(err, { header, body }); + } + ); + } } +const EndOfPostMarker = ['', '.']; + exports.getModule = class NNTPServerModule extends ServerModule { constructor() { super(); @@ -985,9 +1258,85 @@ exports.getModule = class NNTPServerModule extends ServerModule { const config = Config(); + // add in some additional supported commands + const commands = Object.assign({}, NNTPServerBase.commands, { + POST: PostCommand, + }); + + // :TODO: nntp-server doesn't currently allow posting in a nice way, so this is kludged in. Fork+MR something cleaner at some point + class ProxySession extends NNTPServerBase.Session { + constructor(server, stream) { + super(server, stream); + this.articleLinesBuffer = []; + } + + parse(data) { + if (this.receivingPostArticle) { + return this.receivePostArticleData(data); + } + + super.parse(data); + } + + receivePostArticleData(data) { + this.articleLinesBuffer.push(...data.split(/r?\n/)); + + const endOfPost = _.isEqual( + this.articleLinesBuffer.slice(-2), + EndOfPostMarker + ); + if (endOfPost) { + this.receivingPostArticle = false; + + // Command is not exported currently; maybe submit a MR to allow posting in a nicer way... + function Command(runner, articleLines, session) { + this.state = 0; // CMD_WAIT + this.cmd_line = 'POST'; + this.resolved_value = null; + this.rejected_value = null; + this.run = runner; + this.articleLines = articleLines; + this.session = session; + } + + this.pipeline.push( + new Command( + this._processarticleLinesBuffer, + this.articleLinesBuffer, + this + ) + ); + this.articleLinesBuffer = []; + this.tick(); + } + } + + _processarticleLinesBuffer() { + return new Promise(resolve => { + NNTPServer._importMessage(this.session, this.articleLines, err => { + if (err) { + this.rejected_value = Responses.ArticlePostFailed; + this.state = 3; // CMD_REJECTED + } else { + this.resolved_value = Responses.ArticlePostedOk; + this.state = 2; // CMD_RESOLVED + } + + return resolve(); + }); + }); + } + + static create(server, stream) { + return new ProxySession(server, stream); + } + } + const commonOptions = { //requireAuth : true, // :TODO: re-enable! - // :TODO: override |session| - use our own debug to Bunyan, etc. + // :TODO: How to hook into debugging?! + commands, + session: ProxySession, // :TODO: only do this is config.postingAllowed is true, else '440 posting not allowed' even if authenticated }; if (this.enableNntp) {