/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // This file implements test NNTP servers const { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm"); var EXPORTED_SYMBOLS = [ "NntpDaemon", "NewsArticle", "NNTP_POSTABLE", "NNTP_REAL_LENGTH", "NNTP_RFC977_handler", "NNTP_RFC2980_handler", "NNTP_RFC3977_handler", "NNTP_Giganews_handler", "NNTP_RFC4643_extension", ]; class NntpDaemon { constructor(flags) { this._groups = {}; this._messages = {}; this._flags = flags; } addGroup(group, postable) { var flags = 0; if (postable) { flags |= NNTP_POSTABLE; } this._groups[group] = { keys: [], flags, nextKey: 1 }; } addArticle(article) { this._messages[article.messageID] = article; for (let group of article.groups) { if (group in this._groups) { var key = this._groups[group].nextKey++; this._groups[group][key] = article; this._groups[group].keys.push(key); } } } addArticleToGroup(article, group, key) { this._groups[group][key] = article; this._messages[article.messageID] = article; this._groups[group].keys.push(key); if (this._groups[group].nextKey <= key) { this._groups[group].nextKey = key + 1; } } removeArticleFromGroup(groupName, key) { let group = this._groups[groupName]; delete group[key]; group.keys = group.keys.filter(x => x != key); } getGroup(group) { if (this._groups.hasOwnProperty(group)) { return this._groups[group]; } return null; } getGroupStats(group) { if (group.keys.length == 0) { return [0, 0, 0]; } var min = 1 << 30; var max = 0; group.keys.forEach(function (key) { if (key < min) { min = key; } if (key > max) { max = key; } }); var length; if (hasFlag(this._flags, NNTP_REAL_LENGTH)) { length = group.keys.length; } else { length = max - min + 1; } return [length, min, max]; } getArticle(msgid) { if (msgid in this._messages) { return this._messages[msgid]; } return null; } } function NewsArticle(text) { this.headers = new Map(); this.body = ""; this.messageID = ""; this.fullText = text; var headerMap; [headerMap, this.body] = MimeParser.extractHeadersAndBody(text); for (var [header, values] of headerMap._rawHeaders) { var value = values[0]; this.headers.set(header, value); if (header == "message-id") { var start = value.indexOf("<"); var end = value.indexOf(">", start); this.messageID = value.substring(start, end + 1); } else if (header == "newsgroups") { this.groups = value.split(/[ \t]*,[ \t]*/); } } // Add in non-existent fields if (!this.headers.has("lines")) { let lines = this.body.split("\n").length; this.headers.set("lines", lines); } } /** * This function converts an NNTP wildmat into a regular expression. * * I don't know how accurate it is wrt i18n characters, but its primary usage * right now is just XPAT, where i18n effects are utterly unspecified, so I am * not too concerned. * * This also neglects cases where special characters are in [] blocks. */ function wildmat2regex(wildmat) { // Special characters in regex that aren't special in wildmat wildmat = wildmat.replace(/[$+.()|{}^]/, function (str) { return "\\" + str; }); wildmat = wildmat.replace(/(\\*)([*?])/, function (str, p1, p2) { // TODO: This function appears to be wrong on closer inspection. if (p1.length % 2 == 0) { return p2 == "*" ? ".*" : "."; } return str; }); return new RegExp(wildmat); } // NNTP FLAGS var NNTP_POSTABLE = 0x0001; var NNTP_REAL_LENGTH = 0x0100; function hasFlag(flags, flag) { return (flags & flag) == flag; } // NNTP TEST SERVERS // ----------------- // To be comprehensive about testing and fallback, we define these varying // levels of RFC-compliance: // * RFC 977 solely (there's not a lot there!) // * RFC 977 + 2980 (note that there are varying levels of this impl) // * RFC 3977 bare bones // * RFC 3977 full // * RFC 3977 + post-3977 extensions // * Giganews (Common newsserver for ISP stuff; highest importance) // * INN 2.4 (Gold standard common implementation; second highest importance) // Note too that we want various levels of brokenness: // * Perm errors that require login // * "I can't handle that" (e.g., news.mozilla.org only supports XOVER for // searching with XHDR) // * Naive group counts, missing articles // * Limitations on what can be posted // This handler implements the bare minimum required by RFC 977. Actually, not // even that much: IHAVE and SLAVE are not implemented, as those two are // explicitly server implementations. class NNTP_RFC977_handler { constructor(daemon) { this._daemon = daemon; this.closing = false; this.resetTest(); } resetTest() { this.extraCommands = ""; this.articleKey = null; this.group = null; } ARTICLE(args) { var info = this._selectArticle(args, 220); if (info[0] == null) { return info[1]; } var response = info[1] + "\n"; response += info[0].fullText.replace(/^\./gm, ".."); response += "."; return response; } BODY(args) { var info = this._selectArticle(args, 222); if (info[0] == null) { return info[1]; } var response = info[1] + "\n"; response += info[0].body.replace(/^\./gm, ".."); response += "."; return response; } GROUP(args) { var group = this._daemon.getGroup(args); if (group == null) { return "411 no such news group"; } this.group = group; this.articleKey = 0 in this.group.keys ? this.group.keys[0] : null; var stats = this._daemon.getGroupStats(group); return ( "211 " + stats[0] + " " + stats[1] + " " + stats[2] + " " + args + " group selected" ); } HEAD(args) { var info = this._selectArticle(args, 221); if (info[0] == null) { return info[1]; } var response = info[1] + "\n"; for (let [header, value] of info[0].headers) { response += header + ": " + value + "\n"; } response += "."; return response; } HELP(args) { var response = "100 Why certainly, here is my help:\n"; response += "Mozilla fake NNTP RFC 977 testing server"; response += "Commands supported:\n"; response += "\tARTICLE | [nnn]\n"; response += "\tBODY\n"; response += "\tGROUP group\n"; response += "\tHEAD\n"; response += "\tHELP\n"; response += "\tLAST\n"; response += "\tLIST\n"; response += "\tNEWGROUPS\n"; response += "\tNEWNEWS\n"; response += "\tNEXT\n"; response += "\tPOST\n"; response += "\tQUIT\n"; response += "\tSTAT\n"; response += this.extraCommands; response += "."; return response; } LAST(args) { if (this.group == null) { return "412 no newsgroup selected"; } if (this.articleKey == null) { return "420 no current article has been selected"; } return "502 Command not implemented"; } LIST(args) { var response = "215 list of newsgroup follows\n"; for (let groupname in this._daemon._groups) { let group = this._daemon._groups[groupname]; let stats = this._daemon.getGroupStats(group); response += groupname + " " + stats[1] + " " + stats[0] + " " + (hasFlag(group.flags, NNTP_POSTABLE) ? "y" : "n") + "\n"; } response += "."; return response; } NEWGROUPS(args) { return "502 Command not implemented"; } NEWNEWS(args) { return "502 Command not implemented"; } NEXT(args) { if (this.group == null) { return "412 no newsgroup selected"; } if (this.articleKey == null) { return "420 no current article has been selected"; } return "502 Command not implemented"; } POST(args) { this.posting = true; this.post = ""; return "340 Please continue"; } QUIT(args) { this.closing = true; return "205 closing connection - goodbye!"; } STAT(args) { var info = this._selectArticle(args, 223); return info[1]; } LISTGROUP(args) { // Yes, I know this isn't RFC 977, but I doubt that mailnews will ever drop // its requirement for this, so I'll stuff it in here anyways... var group = args == "" ? this.group : this._daemon.getGroup(args); if (group == null) { return "411 This newsgroup does not exist"; } var response = "211 Articles follow:\n"; for (let key of group.keys) { response += key + "\n"; } response += ".\n"; return response; } onError(command, args) { return "500 command not recognized"; } onServerFault(e) { return "500 internal server error: " + e; } onStartup() { this.closing = false; this.group = null; this.article = null; this.posting = false; return "200 posting allowed"; } onMultiline(line) { if (line == ".") { if (this.posting) { var article = new NewsArticle(this.post); this._daemon.addArticle(article); this.posting = false; return "240 Wonderful article, your style is gorgeous!"; } } if (this.posting) { if (line.startsWith(".")) { line = line.substring(1); } this.post += line + "\n"; } return undefined; } postCommand(reader) { if (this.closing) { reader.closeSocket(); } reader.setMultiline(this.posting); } /** * Selects an article based on args. * * Returns an array of objects consisting of: * # The selected article (or null if non was selected * # The first line response */ _selectArticle(args, responseCode) { var art, key; if (args == "") { if (this.group == null) { return [null, "412 no newsgroup has been selected"]; } if (this.articleKey == null) { return [null, "420 no current article has been selected"]; } art = this.group[this.articleKey]; key = this.articleKey; } else if (args.startsWith("<")) { art = this._daemon.getArticle(args); key = 0; if (art == null) { return [null, "430 no such article found"]; } } else { if (this.group == null) { return [null, "412 no newsgroup has been selected"]; } key = parseInt(args); if (key in this.group) { this.articleKey = key; art = this.group[key]; } else { return [null, "423 no such article number in this group"]; } } var respCode = responseCode + " " + key + " " + art.messageID + " article selected"; return [art, respCode]; } } class NNTP_RFC2980_handler extends NNTP_RFC977_handler { DATE(args) { return "502 Command not implemented"; } LIST(args) { var index = args.indexOf(" "); var command = index == -1 ? args : args.substring(0, index); args = index == -1 ? "" : args.substring(index + 1); command = command.toUpperCase(); if ("LIST_" + command in this) { return this["LIST_" + command](args); } return super.LIST(command + " " + args); } LIST_ACTIVE(args) { return super.LIST(args); } MODE(args) { if (args == "READER") { return this.onStartup(); } return "500 What do you think you're trying to pull here?"; } XHDR(args) { if (!this.group) { return "412 No group selected"; } args = args.split(" "); var header = args[0].toLowerCase(); var found = false; var response = "221 Headers abound\n"; for (let key of this._filterRange(args[1], this.group.keys)) { if (!this.group[key].headers.has(header)) { continue; } found = true; response += key + " " + this.group[key].headers.get(header) + "\n"; } if (!found) { return "420 No such article"; } response += "."; return response; } XOVER(args) { if (!this.group) { return "412 No group selected"; } args = args.split(/ +/, 3); var response = "224 List of articles\n"; for (let key of this._filterRange(args[0], this.group.keys)) { response += key + "\t"; var article = this.group[key]; response += article.headers.get("subject") + "\t" + article.headers.get("from") + "\t" + article.headers.get("date") + "\t" + article.headers.get("message-id") + "\t" + (article.headers.get("references") || "") + "\t" + article.fullText.replace(/\r?\n/, "\r\n").length + "\t" + article.body.split(/\r?\n/).length + "\t" + (article.headers.get("xref") || "") + "\n"; } response += ".\n"; return response; } XPAT(args) { if (!this.group) { return "412 No group selected"; } /* XPAT header range ... */ args = args.split(/ +/, 3); let header = args[0].toLowerCase(); let regex = wildmat2regex(args[2]); let response = "221 Results follow\n"; for (let key of this._filterRange(args[1], this.group.keys)) { let article = this.group[key]; if ( article.headers.has(header) && regex.test(article.headers.get(header)) ) { response += key + " " + article.headers.get(header) + "\n"; } } return response + "."; } _filterRange(range, keys) { let dash = range.indexOf("-"); let low, high; if (dash < 0) { low = high = parseInt(range); } else { low = parseInt(range.substring(0, dash)); if (dash < range.length - 1) { high = range.substring(dash + 1); } else { // Everything is less than this. high = 1.0 / 0.0; } } return keys.filter(function (e) { return low <= e && e <= high; }); } } class NNTP_Giganews_handler extends NNTP_RFC2980_handler { XHDR(args) { var header = args.split(" ")[0].toLowerCase(); if ( header in ["subject", "from", "xref", "date", "message-id", "references"] ) { return super.XHDR(args); } return "503 unsupported header field"; } } class NNTP_RFC4643_extension extends NNTP_RFC2980_handler { constructor(daemon) { super(daemon); this.extraCommands += "\tAUTHINFO USER\n"; this.extraCommands += "\tAUTHINFO PASS\n"; this.expectedUsername = "testnews"; this.expectedPassword = "newstest"; this.requireBoth = true; this.authenticated = false; this.usernameReceived = false; } AUTHINFO(args) { if (this.authenticated) { return "502 Command unavailable"; } var argSplit = args.split(" "); var action = argSplit[0]; var param = argSplit[1]; if (action == "user") { if (this.usernameReceived) { return "502 Command unavailable"; } var expectUsername = this.lastGroupTried ? this._daemon.groupCredentials[this.lastGroupTried][0] : this.expectedUsername; if (param != expectUsername) { return "481 Authentication failed"; } this.usernameReceived = true; if (this.requireBoth) { return "381 Password required"; } this.authenticated = this.lastGroupTried ? this.lastGroupTried : true; return "281 Authentication Accepted"; } else if (action == "pass") { if (!this.requireBoth || !this.usernameReceived) { return "482 Authentication commands issued out of sequence"; } this.usernameReceived = false; var expectPassword = this.lastGroupTried ? this._daemon.groupCredentials[this.lastGroupTried][1] : this.expectedPassword; if (param != expectPassword) { return "481 Authentication failed"; } this.authenticated = this.lastGroupTried ? this.lastGroupTried : true; return "281 Authentication Accepted"; } return "502 Invalid Command"; } LIST(args) { if (this.authenticated) { return args ? super.LIST(args) : "502 Invalid command: LIST"; } return "480 Authentication required"; } GROUP(args) { if ( (this._daemon.groupCredentials != null && this.authenticated == args) || (this._daemon.groupCredentials == null && this.authenticated) ) { return super.GROUP(args); } if (this._daemon.groupCredentials != null) { this.lastGroupTried = args; } return "480 Authentication required"; } }