Initial NNTP write access support
This commit is contained in:
parent
e61be537aa
commit
d2dafc4dbc
|
@ -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 = {
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue