diff options
Diffstat (limited to 'comm/mailnews/news/src/NntpChannel.jsm')
-rw-r--r-- | comm/mailnews/news/src/NntpChannel.jsm | 402 |
1 files changed, 402 insertions, 0 deletions
diff --git a/comm/mailnews/news/src/NntpChannel.jsm b/comm/mailnews/news/src/NntpChannel.jsm new file mode 100644 index 0000000000..4e20fca7bc --- /dev/null +++ b/comm/mailnews/news/src/NntpChannel.jsm @@ -0,0 +1,402 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["NntpChannel"]; + +const { MailChannel } = ChromeUtils.importESModule( + "resource:///modules/MailChannel.sys.mjs" +); +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + MailUtils: "resource:///modules/MailUtils.jsm", + NntpUtils: "resource:///modules/NntpUtils.jsm", +}); + +/** + * A channel to interact with NNTP server. + * + * @implements {nsIChannel} + * @implements {nsIRequest} + * @implements {nsICacheEntryOpenCallback} + */ +class NntpChannel extends MailChannel { + QueryInterface = ChromeUtils.generateQI([ + "nsIMailChannel", + "nsIChannel", + "nsIRequest", + "nsICacheEntryOpenCallback", + ]); + + _logger = lazy.NntpUtils.logger; + _status = Cr.NS_OK; + + /** + * @param {nsIURI} uri - The uri to construct the channel from. + * @param {nsILoadInfo} [loadInfo] - The loadInfo associated with the channel. + */ + constructor(uri, loadInfo) { + super(); + this._server = lazy.NntpUtils.findServer(uri.asciiHost); + if (!this._server) { + this._server = MailServices.accounts + .createIncomingServer("", uri.asciiHost, "nntp") + .QueryInterface(Ci.nsINntpIncomingServer); + this._server.port = uri.port; + } + + if (uri.port < 1) { + // Ensure the uri has a port so that memory cache works. + uri = uri.mutate().setPort(this._server.port).finalize(); + } + + // Two forms of the uri: + // - news://news.mozilla.org:119/mailman.30.1608649442.1056.accessibility%40lists.mozilla.org?group=mozilla.accessibility&key=378 + // - news://news.mozilla.org:119/id@mozilla.org + let url = new URL(uri.spec); + this._groupName = url.searchParams.get("group"); + if (this._groupName) { + this._newsFolder = this._server.rootFolder.getChildNamed( + decodeURIComponent(url.searchParams.get("group")) + ); + this._articleNumber = url.searchParams.get("key"); + } else { + this._messageId = decodeURIComponent(url.pathname.slice(1)); + if (!this._messageId.includes("@")) { + this._groupName = this._messageId; + this._messageId = null; + } + } + + // nsIChannel attributes. + this.originalURI = uri; + this.URI = uri.QueryInterface(Ci.nsIMsgMailNewsUrl); + this.loadInfo = loadInfo || { + QueryInterface: ChromeUtils.generateQI(["nsILoadInfo"]), + loadingPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + internalContentPolicy: Ci.nsIContentPolicy.TYPE_OTHER, + }; + this.contentLength = 0; + } + + /** + * @see nsIRequest + * @returns {string} + */ + get name() { + return this.URI?.spec; + } + + /** + * @see nsIRequest + * @returns {boolean} + */ + isPending() { + return !!this._pending; + } + + /** + * @see nsIRequest + * @returns {nsresult} + */ + get status() { + return this._status; + } + + /** + * @see nsICacheEntryOpenCallback + */ + onCacheEntryAvailable(entry, isNew, status) { + if (!Components.isSuccessCode(status)) { + // If memory cache doesn't work, read from the server. + this._readFromServer(); + return; + } + + if (isNew) { + if (Services.io.offline) { + this._status = Cr.NS_ERROR_OFFLINE; + return; + } + // It's a new entry, needs to read from the server. + let tee = Cc["@mozilla.org/network/stream-listener-tee;1"].createInstance( + Ci.nsIStreamListenerTee + ); + let outStream = entry.openOutputStream(0, -1); + // When the tee stream receives data from the server, it writes to both + // the original listener and outStream (memory cache). + tee.init(this._listener, outStream, null); + this._listener = tee; + this._cacheEntry = entry; + this._readFromServer(); + return; + } + + // It's an old entry, read from the memory cache. + this._readFromCacheStream(entry.openInputStream(0)); + } + + onCacheEntryCheck(entry) { + return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED; + } + + /** + * @see nsIChannel + */ + get contentType() { + return this._contentType || "message/rfc822"; + } + + set contentType(value) { + this._contentType = value; + } + + get isDocument() { + return true; + } + + open() { + throw Components.Exception( + `${this.constructor.name}.open not implemented`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + asyncOpen(listener) { + this._logger.debug("asyncOpen", this.URI.spec); + let url = new URL(this.URI.spec); + this._listener = listener; + if (url.searchParams.has("list-ids")) { + // Triggered by newsError.js. + this._removeExpired(decodeURIComponent(url.pathname.slice(1))); + return; + } + + if (this._groupName && !this._server.containsNewsgroup(this._groupName)) { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/news.properties" + ); + let win = Services.wm.getMostRecentWindow("mail:3pane"); + let result = Services.prompt.confirm( + win, + null, + bundle.formatStringFromName("autoSubscribeText", [this._groupName]) + ); + if (!result) { + return; + } + this._server.subscribeToNewsgroup(this._groupName); + let folder = this._server.findGroup(this._groupName); + lazy.MailUtils.displayFolderIn3Pane(folder.URI); + } + + if (this._groupName && !this._articleNumber && !this._messageId) { + let folder = this._server.findGroup(this._groupName); + lazy.MailUtils.displayFolderIn3Pane(folder.URI); + return; + } + + if (url.searchParams.has("part")) { + let converter = Cc["@mozilla.org/streamConverters;1"].getService( + Ci.nsIStreamConverterService + ); + this._listener = converter.asyncConvertData( + "message/rfc822", + "*/*", + listener, + this + ); + } + try { + // Attempt to get the message from the offline storage. + try { + if (this._readFromOfflineStorage()) { + return; + } + } catch (e) { + this._logger.warn(e); + } + + let uri = this.URI; + if (url.search) { + // A full news url may look like + // news://<host>:119/<Msg-ID>?group=<name>&key=<key>&header=quotebody. + // Remove any query strings to keep the cache key stable. + uri = uri.mutate().setQuery("").finalize(); + } + + // Check if a memory cache is available for the current URI. + MailServices.nntp.cacheStorage.asyncOpenURI( + uri, + "", + Ci.nsICacheStorage.OPEN_NORMALLY, + this + ); + } catch (e) { + this._logger.warn(e); + this._readFromServer(); + } + if (this._status == Cr.NS_ERROR_OFFLINE) { + throw new Components.Exception( + "The requested action could not be completed in the offline state", + Cr.NS_ERROR_OFFLINE + ); + } + } + + /** + * Try to read the article from the offline storage. + * + * @returns {boolean} True if successfully read from the offline storage. + */ + _readFromOfflineStorage() { + if (!this._newsFolder) { + return false; + } + if (!this._newsFolder.hasMsgOffline(this._articleNumber)) { + return false; + } + let hdr = this._newsFolder.GetMessageHeader(this._articleNumber); + let stream = this._newsFolder.getLocalMsgStream(hdr); + this._readFromCacheStream(stream); + return true; + } + + /** + * Read the article from the a stream. + * + * @param {nsIInputStream} cacheStream - The input stream to read. + */ + _readFromCacheStream(cacheStream) { + let pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance( + Ci.nsIInputStreamPump + ); + this.contentLength = 0; + this._contentType = ""; + pump.init(cacheStream, 0, 0, true); + pump.asyncRead({ + onStartRequest: () => { + this._listener.onStartRequest(this); + this._pending = true; + }, + onStopRequest: (request, status) => { + this._listener.onStopRequest(this, status); + try { + this.loadGroup?.removeRequest(this, null, Cr.NS_OK); + } catch (e) {} + this._pending = false; + }, + onDataAvailable: (request, stream, offset, count) => { + this.contentLength += count; + this._listener.onDataAvailable(this, stream, offset, count); + try { + if (!cacheStream.available()) { + cacheStream.close(); + } + } catch (e) {} + }, + }); + } + + /** + * Retrieve the article from the server. + */ + _readFromServer() { + this._logger.debug("Read from server"); + let pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe); + pipe.init(true, true, 0, 0); + let inputStream = pipe.inputStream; + let outputStream = pipe.outputStream; + if (this._newsFolder) { + this._newsFolder.QueryInterface(Ci.nsIMsgNewsFolder).saveArticleOffline = + this._newsFolder.shouldStoreMsgOffline(this._articleNumber); + } + + this._server.wrappedJSObject.withClient(client => { + let msgWindow; + try { + msgWindow = this.URI.msgWindow; + } catch (e) {} + client.startRunningUrl(null, msgWindow, this.URI); + client.channel = this; + this._listener.onStartRequest(this); + this._pending = true; + client.onOpen = () => { + if (this._messageId) { + client.getArticleByMessageId(this._messageId); + } else { + client.getArticleByArticleNumber( + this._groupName, + this._articleNumber + ); + } + }; + + client.onData = data => { + this.contentLength += data.length; + outputStream.write(data, data.length); + this._listener.onDataAvailable(this, inputStream, 0, data.length); + }; + + client.onDone = status => { + try { + this.loadGroup?.removeRequest(this, null, Cr.NS_OK); + } catch (e) {} + if (status != Cr.NS_OK) { + // Prevent marking a message as read. + this.URI.errorCode = status; + // Remove the invalid cache. + this._cacheEntry?.asyncDoom(null); + } + this._listener.onStopRequest(this, status); + this._newsFolder?.msgDatabase.commit( + Ci.nsMsgDBCommitType.kSessionCommit + ); + this._pending = false; + }; + }); + } + + /** + * Fetch all the article keys on the server, then remove expired keys from the + * local folder. + * + * @param {string} groupName - The group to check. + */ + _removeExpired(groupName) { + this._logger.debug("_removeExpired", groupName); + let newsFolder = this._server.findGroup(groupName); + let allKeys = new Set(newsFolder.msgDatabase.listAllKeys()); + this._server.wrappedJSObject.withClient(client => { + let msgWindow; + try { + msgWindow = this.URI.msgWindow; + } catch (e) {} + client.startRunningUrl(null, msgWindow, this.URI); + this._listener.onStartRequest(this); + this._pending = true; + client.onOpen = () => { + client.listgroup(groupName); + }; + + client.onData = data => { + allKeys.delete(+data); + }; + + client.onDone = status => { + newsFolder.removeMessages([...allKeys]); + this._listener.onStopRequest(this, status); + this._pending = false; + }; + }); + } +} |