Initial NNTP write access support

This commit is contained in:
Bryan Ashby 2022-09-22 21:03:53 -06:00
parent e61be537aa
commit d2dafc4dbc
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
2 changed files with 359 additions and 9 deletions

View File

@ -55,6 +55,7 @@ const ADDRESS_FLAVOR = {
FTN: 'ftn', // FTN style FTN: 'ftn', // FTN style
Email: 'email', // From email Email: 'email', // From email
QWK: 'qwk', // QWK packet QWK: 'qwk', // QWK packet
NNTP: 'nntp', // NNTP article POST; often a email address
}; };
const STATE_FLAGS0 = { const STATE_FLAGS0 = {

View File

@ -9,6 +9,7 @@ const { getTransactionDatabase, getModDatabasePath } = require('../../database.j
const { const {
getMessageAreaByTag, getMessageAreaByTag,
getMessageConferenceByTag, getMessageConferenceByTag,
persistMessage,
} = require('../../message_area.js'); } = require('../../message_area.js');
const User = require('../../user.js'); const User = require('../../user.js');
const Errors = require('../../enig_error.js').Errors; const Errors = require('../../enig_error.js').Errors;
@ -21,6 +22,7 @@ const {
} = require('../../string_util.js'); } = require('../../string_util.js');
const AnsiPrep = require('../../ansi_prep.js'); const AnsiPrep = require('../../ansi_prep.js');
const { stripMciColorCodes } = require('../../color_codes.js'); const { stripMciColorCodes } = require('../../color_codes.js');
const ACS = require('../../acs');
// deps // deps
const NNTPServerBase = require('nntp-server'); const NNTPServerBase = require('nntp-server');
@ -111,6 +113,37 @@ class NNTPDatabase {
let 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 { class NNTPServer extends NNTPServerBase {
constructor(options, serverName) { constructor(options, serverName) {
super(options); super(options);
@ -125,6 +158,10 @@ class NNTPServer extends NNTPServerBase {
} }
_needAuth(session, command) { _needAuth(session, command) {
if (AuthCommands.includes(command)) {
return !session.authenticated && !session.authUser;
}
return super._needAuth(session, command); return super._needAuth(session, command);
} }
@ -132,7 +169,8 @@ class NNTPServer extends NNTPServerBase {
const username = session.authinfo_user; const username = session.authinfo_user;
const password = session.authinfo_pass; 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 => { return new Promise(resolve => {
const user = new User(); const user = new User();
@ -140,17 +178,19 @@ class NNTPServer extends NNTPServerBase {
{ type: User.AuthFactor1Types.Password, username, password }, { type: User.AuthFactor1Types.Password, username, password },
err => { err => {
if (err) { if (err) {
// :TODO: Log IP address this.log.warn(
this.log.debug(
{ username, reason: err.message }, { username, reason: err.message },
'Authentication failure' `NNTP authentication failure for "${username}"`
); );
return resolve(false); return resolve(false);
} }
session.authUser = user; session.authUser = user;
this.log.debug({ username }, 'User authenticated successfully'); this.log.info(
{ username },
`NTTP authentication success for "${username}"`
);
return resolve(true); return resolve(true);
} }
); );
@ -592,17 +632,35 @@ class NNTPServer extends NNTPServerBase {
if (!conf) { if (!conf) {
return false; return false;
} }
// :TODO: validate ACS
const area = getMessageAreaByTag(areaTag, confTag); const area = getMessageAreaByTag(areaTag, confTag);
if (!area) { if (!area) {
return false; return false;
} }
// :TODO: validate ACS
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; 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) { getGroup(session, groupName, cb) {
let group = this.groupCache.get(groupName); let group = this.groupCache.get(groupName);
if (group) { if (group) {
@ -919,8 +977,223 @@ class NNTPServer extends NNTPServerBase {
areaTag areaTag
).replace(/\./g, '_')}`; ).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 { exports.getModule = class NNTPServerModule extends ServerModule {
constructor() { constructor() {
super(); super();
@ -985,9 +1258,85 @@ exports.getModule = class NNTPServerModule extends ServerModule {
const config = Config(); 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 = { const commonOptions = {
//requireAuth : true, // :TODO: re-enable! //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) { if (this.enableNntp) {