diff options
Diffstat (limited to 'toolkit/content/contentAreaUtils.js')
-rw-r--r-- | toolkit/content/contentAreaUtils.js | 1330 |
1 files changed, 1330 insertions, 0 deletions
diff --git a/toolkit/content/contentAreaUtils.js b/toolkit/content/contentAreaUtils.js new file mode 100644 index 000000000..2b7af30de --- /dev/null +++ b/toolkit/content/contentAreaUtils.js @@ -0,0 +1,1330 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadLastDir", + "resource://gre/modules/DownloadLastDir.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", + "resource://gre/modules/Deprecated.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +var ContentAreaUtils = { + + // this is for backwards compatibility. + get ioService() { + return Services.io; + }, + + get stringBundle() { + delete this.stringBundle; + return this.stringBundle = + Services.strings.createBundle("chrome://global/locale/contentAreaCommands.properties"); + } +} + +function urlSecurityCheck(aURL, aPrincipal, aFlags) +{ + return BrowserUtils.urlSecurityCheck(aURL, aPrincipal, aFlags); +} + +/** + * Determine whether or not a given focused DOMWindow is in the content area. + **/ +function isContentFrame(aFocusedWindow) +{ + if (!aFocusedWindow) + return false; + + return (aFocusedWindow.top == window.content); +} + +function forbidCPOW(arg, func, argname) +{ + if (arg && (typeof(arg) == "object" || typeof(arg) == "function") && + Components.utils.isCrossProcessWrapper(arg)) { + throw new Error(`no CPOWs allowed for argument ${argname} to ${func}`); + } +} + +// Clientele: (Make sure you don't break any of these) +// - File -> Save Page/Frame As... +// - Context -> Save Page/Frame As... +// - Context -> Save Link As... +// - Alt-Click links in web pages +// - Alt-Click links in the UI +// +// Try saving each of these types: +// - A complete webpage using File->Save Page As, and Context->Save Page As +// - A webpage as HTML only using the above methods +// - A webpage as Text only using the above methods +// - An image with an extension (e.g. .jpg) in its file name, using +// Context->Save Image As... +// - An image without an extension (e.g. a banner ad on cnn.com) using +// the above method. +// - A linked document using Save Link As... +// - A linked document using Alt-click Save Link As... +// +function saveURL(aURL, aFileName, aFilePickerTitleKey, aShouldBypassCache, + aSkipPrompt, aReferrer, aSourceDocument, aIsContentWindowPrivate) +{ + forbidCPOW(aURL, "saveURL", "aURL"); + forbidCPOW(aReferrer, "saveURL", "aReferrer"); + // Allow aSourceDocument to be a CPOW. + + internalSave(aURL, null, aFileName, null, null, aShouldBypassCache, + aFilePickerTitleKey, null, aReferrer, aSourceDocument, + aSkipPrompt, null, aIsContentWindowPrivate); +} + +// Just like saveURL, but will get some info off the image before +// calling internalSave +// Clientele: (Make sure you don't break any of these) +// - Context -> Save Image As... +const imgICache = Components.interfaces.imgICache; +const nsISupportsCString = Components.interfaces.nsISupportsCString; + +/** + * Offers to save an image URL to the file system. + * + * @param aURL (string) + * The URL of the image to be saved. + * @param aFileName (string) + * The suggested filename for the saved file. + * @param aFilePickerTitleKey (string, optional) + * Localized string key for an alternate title for the file + * picker. If set to null, this will default to something sensible. + * @param aShouldBypassCache (bool) + * If true, the image will always be retrieved from the server instead + * of the network or image caches. + * @param aSkipPrompt (bool) + * If true, we will attempt to save the file with the suggested + * filename to the default downloads folder without showing the + * file picker. + * @param aReferrer (nsIURI, optional) + * The referrer URI object (not a URL string) to use, or null + * if no referrer should be sent. + * @param aDoc (nsIDocument, deprecated, optional) + * The content document that the save is being initiated from. If this + * is omitted, then aIsContentWindowPrivate must be provided. + * @param aContentType (string, optional) + * The content type of the image. + * @param aContentDisp (string, optional) + * The content disposition of the image. + * @param aIsContentWindowPrivate (bool) + * Whether or not the containing window is in private browsing mode. + * Does not need to be provided is aDoc is passed. + */ +function saveImageURL(aURL, aFileName, aFilePickerTitleKey, aShouldBypassCache, + aSkipPrompt, aReferrer, aDoc, aContentType, aContentDisp, + aIsContentWindowPrivate) +{ + forbidCPOW(aURL, "saveImageURL", "aURL"); + forbidCPOW(aReferrer, "saveImageURL", "aReferrer"); + + if (aDoc && aIsContentWindowPrivate == undefined) { + if (Components.utils.isCrossProcessWrapper(aDoc)) { + Deprecated.warning("saveImageURL should not be passed document CPOWs. " + + "The caller should pass in the content type and " + + "disposition themselves", + "https://bugzilla.mozilla.org/show_bug.cgi?id=1243643"); + } + // This will definitely not work for in-browser code or multi-process compatible + // add-ons due to bug 1233497, which makes unsafe CPOW usage throw by default. + Deprecated.warning("saveImageURL should be passed the private state of " + + "the containing window.", + "https://bugzilla.mozilla.org/show_bug.cgi?id=1243643"); + aIsContentWindowPrivate = + PrivateBrowsingUtils.isContentWindowPrivate(aDoc.defaultView); + } + + // We'd better have the private state by now. + if (aIsContentWindowPrivate == undefined) { + throw new Error("saveImageURL couldn't compute private state of content window"); + } + + if (!aShouldBypassCache && (aDoc && !Components.utils.isCrossProcessWrapper(aDoc)) && + (!aContentType && !aContentDisp)) { + try { + var imageCache = Components.classes["@mozilla.org/image/tools;1"] + .getService(Components.interfaces.imgITools) + .getImgCacheForDocument(aDoc); + var props = + imageCache.findEntryProperties(makeURI(aURL, getCharsetforSave(null)), aDoc); + if (props) { + aContentType = props.get("type", nsISupportsCString); + aContentDisp = props.get("content-disposition", nsISupportsCString); + } + } catch (e) { + // Failure to get type and content-disposition off the image is non-fatal + } + } + + internalSave(aURL, null, aFileName, aContentDisp, aContentType, + aShouldBypassCache, aFilePickerTitleKey, null, aReferrer, + null, aSkipPrompt, null, aIsContentWindowPrivate); +} + +// This is like saveDocument, but takes any browser/frame-like element +// (nsIFrameLoaderOwner) and saves the current document inside it, +// whether in-process or out-of-process. +function saveBrowser(aBrowser, aSkipPrompt, aOuterWindowID=0) +{ + if (!aBrowser) { + throw "Must have a browser when calling saveBrowser"; + } + let persistable = aBrowser.QueryInterface(Ci.nsIFrameLoaderOwner) + .frameLoader + .QueryInterface(Ci.nsIWebBrowserPersistable); + let stack = Components.stack.caller; + persistable.startPersistence(aOuterWindowID, { + onDocumentReady: function (document) { + saveDocument(document, aSkipPrompt); + }, + onError: function (status) { + throw new Components.Exception("saveBrowser failed asynchronously in startPersistence", + status, stack); + } + }); +} + +// Saves a document; aDocument can be an nsIWebBrowserPersistDocument +// (see saveBrowser, above) or an nsIDOMDocument. +// +// aDocument can also be a CPOW for a remote nsIDOMDocument, in which +// case "save as" modes that serialize the document's DOM are +// unavailable. This is a temporary measure for the "Save Frame As" +// command (bug 1141337) and pre-e10s add-ons. +function saveDocument(aDocument, aSkipPrompt) +{ + const Ci = Components.interfaces; + + if (!aDocument) + throw "Must have a document when calling saveDocument"; + + let contentDisposition = null; + let cacheKeyInt = null; + + if (aDocument instanceof Ci.nsIWebBrowserPersistDocument) { + // nsIWebBrowserPersistDocument exposes these directly. + contentDisposition = aDocument.contentDisposition; + cacheKeyInt = aDocument.cacheKey; + } else if (aDocument instanceof Ci.nsIDOMDocument) { + // Otherwise it's an actual nsDocument (and possibly a CPOW). + // We want to use cached data because the document is currently visible. + let ifreq = + aDocument.defaultView + .QueryInterface(Ci.nsIInterfaceRequestor); + + try { + contentDisposition = + ifreq.getInterface(Ci.nsIDOMWindowUtils) + .getDocumentMetadata("content-disposition"); + } catch (ex) { + // Failure to get a content-disposition is ok + } + + try { + let shEntry = + ifreq.getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIWebPageDescriptor) + .currentDescriptor + .QueryInterface(Ci.nsISHEntry); + + let cacheKey = shEntry.cacheKey + .QueryInterface(Ci.nsISupportsPRUint32) + .data; + // cacheKey might be a CPOW, which can't be passed to native + // code, but the data attribute is just a number. + cacheKeyInt = cacheKey.data; + } catch (ex) { + // We might not find it in the cache. Oh, well. + } + } + + // Convert the cacheKey back into an XPCOM object. + let cacheKey = null; + if (cacheKeyInt) { + cacheKey = Cc["@mozilla.org/supports-PRUint32;1"] + .createInstance(Ci.nsISupportsPRUint32); + cacheKey.data = cacheKeyInt; + } + + internalSave(aDocument.documentURI, aDocument, null, contentDisposition, + aDocument.contentType, false, null, null, + aDocument.referrer ? makeURI(aDocument.referrer) : null, + aDocument, aSkipPrompt, cacheKey); +} + +function DownloadListener(win, transfer) { + function makeClosure(name) { + return function() { + transfer[name].apply(transfer, arguments); + } + } + + this.window = win; + + // Now... we need to forward all calls to our transfer + for (var i in transfer) { + if (i != "QueryInterface") + this[i] = makeClosure(i); + } +} + +DownloadListener.prototype = { + QueryInterface: function dl_qi(aIID) + { + if (aIID.equals(Components.interfaces.nsIInterfaceRequestor) || + aIID.equals(Components.interfaces.nsIWebProgressListener) || + aIID.equals(Components.interfaces.nsIWebProgressListener2) || + aIID.equals(Components.interfaces.nsISupports)) { + return this; + } + throw Components.results.NS_ERROR_NO_INTERFACE; + }, + + getInterface: function dl_gi(aIID) + { + if (aIID.equals(Components.interfaces.nsIAuthPrompt) || + aIID.equals(Components.interfaces.nsIAuthPrompt2)) { + var ww = + Components.classes["@mozilla.org/embedcomp/window-watcher;1"] + .getService(Components.interfaces.nsIPromptFactory); + return ww.getPrompt(this.window, aIID); + } + + throw Components.results.NS_ERROR_NO_INTERFACE; + } +} + +const kSaveAsType_Complete = 0; // Save document with attached objects. +XPCOMUtils.defineConstant(this, "kSaveAsType_Complete", 0); +// const kSaveAsType_URL = 1; // Save document or URL by itself. +const kSaveAsType_Text = 2; // Save document, converting to plain text. +XPCOMUtils.defineConstant(this, "kSaveAsType_Text", kSaveAsType_Text); + +/** + * internalSave: Used when saving a document or URL. + * + * If aChosenData is null, this method: + * - Determines a local target filename to use + * - Prompts the user to confirm the destination filename and save mode + * (aContentType affects this) + * - [Note] This process involves the parameters aURL, aReferrer (to determine + * how aURL was encoded), aDocument, aDefaultFileName, aFilePickerTitleKey, + * and aSkipPrompt. + * + * If aChosenData is non-null, this method: + * - Uses the provided source URI and save file name + * - Saves the document as complete DOM if possible (aDocument present and + * right aContentType) + * - [Note] The parameters aURL, aDefaultFileName, aFilePickerTitleKey, and + * aSkipPrompt are ignored. + * + * In any case, this method: + * - Creates a 'Persist' object (which will perform the saving in the + * background) and then starts it. + * - [Note] This part of the process only involves the parameters aDocument, + * aShouldBypassCache and aReferrer. The source, the save name and the save + * mode are the ones determined previously. + * + * @param aURL + * The String representation of the URL of the document being saved + * @param aDocument + * The document to be saved + * @param aDefaultFileName + * The caller-provided suggested filename if we don't + * find a better one + * @param aContentDisposition + * The caller-provided content-disposition header to use. + * @param aContentType + * The caller-provided content-type to use + * @param aShouldBypassCache + * If true, the document will always be refetched from the server + * @param aFilePickerTitleKey + * Alternate title for the file picker + * @param aChosenData + * If non-null this contains an instance of object AutoChosen (see below) + * which holds pre-determined data so that the user does not need to be + * prompted for a target filename. + * @param aReferrer + * the referrer URI object (not URL string) to use, or null + * if no referrer should be sent. + * @param aInitiatingDocument [optional] + * The document from which the save was initiated. + * If this is omitted then aIsContentWindowPrivate has to be provided. + * @param aSkipPrompt [optional] + * If set to true, we will attempt to save the file to the + * default downloads folder without prompting. + * @param aCacheKey [optional] + * If set will be passed to saveURI. See nsIWebBrowserPersist for + * allowed values. + * @param aIsContentWindowPrivate [optional] + * This parameter is provided when the aInitiatingDocument is not a + * real document object. Stores whether aInitiatingDocument.defaultView + * was private or not. + */ +function internalSave(aURL, aDocument, aDefaultFileName, aContentDisposition, + aContentType, aShouldBypassCache, aFilePickerTitleKey, + aChosenData, aReferrer, aInitiatingDocument, aSkipPrompt, + aCacheKey, aIsContentWindowPrivate) +{ + forbidCPOW(aURL, "internalSave", "aURL"); + forbidCPOW(aReferrer, "internalSave", "aReferrer"); + forbidCPOW(aCacheKey, "internalSave", "aCacheKey"); + // Allow aInitiatingDocument to be a CPOW. + + if (aSkipPrompt == undefined) + aSkipPrompt = false; + + if (aCacheKey == undefined) + aCacheKey = null; + + // Note: aDocument == null when this code is used by save-link-as... + var saveMode = GetSaveModeForContentType(aContentType, aDocument); + + var file, sourceURI, saveAsType; + // Find the URI object for aURL and the FileName/Extension to use when saving. + // FileName/Extension will be ignored if aChosenData supplied. + if (aChosenData) { + file = aChosenData.file; + sourceURI = aChosenData.uri; + saveAsType = kSaveAsType_Complete; + + continueSave(); + } else { + var charset = null; + if (aDocument) + charset = aDocument.characterSet; + else if (aReferrer) + charset = aReferrer.originCharset; + var fileInfo = new FileInfo(aDefaultFileName); + initFileInfo(fileInfo, aURL, charset, aDocument, + aContentType, aContentDisposition); + sourceURI = fileInfo.uri; + + var fpParams = { + fpTitleKey: aFilePickerTitleKey, + fileInfo: fileInfo, + contentType: aContentType, + saveMode: saveMode, + saveAsType: kSaveAsType_Complete, + file: file + }; + + // Find a URI to use for determining last-downloaded-to directory + let relatedURI = aReferrer || sourceURI; + + promiseTargetFile(fpParams, aSkipPrompt, relatedURI).then(aDialogAccepted => { + if (!aDialogAccepted) + return; + + saveAsType = fpParams.saveAsType; + file = fpParams.file; + + continueSave(); + }).then(null, Components.utils.reportError); + } + + function continueSave() { + // XXX We depend on the following holding true in appendFiltersForContentType(): + // If we should save as a complete page, the saveAsType is kSaveAsType_Complete. + // If we should save as text, the saveAsType is kSaveAsType_Text. + var useSaveDocument = aDocument && + (((saveMode & SAVEMODE_COMPLETE_DOM) && (saveAsType == kSaveAsType_Complete)) || + ((saveMode & SAVEMODE_COMPLETE_TEXT) && (saveAsType == kSaveAsType_Text))); + // If we're saving a document, and are saving either in complete mode or + // as converted text, pass the document to the web browser persist component. + // If we're just saving the HTML (second option in the list), send only the URI. + let nonCPOWDocument = + aDocument && !Components.utils.isCrossProcessWrapper(aDocument); + + let isPrivate = aIsContentWindowPrivate; + if (isPrivate === undefined) { + isPrivate = aInitiatingDocument instanceof Components.interfaces.nsIDOMDocument + ? PrivateBrowsingUtils.isContentWindowPrivate(aInitiatingDocument.defaultView) + : aInitiatingDocument.isPrivate; + } + + var persistArgs = { + sourceURI : sourceURI, + sourceReferrer : aReferrer, + sourceDocument : useSaveDocument ? aDocument : null, + targetContentType : (saveAsType == kSaveAsType_Text) ? "text/plain" : null, + targetFile : file, + sourceCacheKey : aCacheKey, + sourcePostData : nonCPOWDocument ? getPostData(aDocument) : null, + bypassCache : aShouldBypassCache, + isPrivate : isPrivate, + }; + + // Start the actual save process + internalPersist(persistArgs); + } +} + +/** + * internalPersist: Creates a 'Persist' object (which will perform the saving + * in the background) and then starts it. + * + * @param persistArgs.sourceURI + * The nsIURI of the document being saved + * @param persistArgs.sourceCacheKey [optional] + * If set will be passed to saveURI + * @param persistArgs.sourceDocument [optional] + * The document to be saved, or null if not saving a complete document + * @param persistArgs.sourceReferrer + * Required and used only when persistArgs.sourceDocument is NOT present, + * the nsIURI of the referrer to use, or null if no referrer should be + * sent. + * @param persistArgs.sourcePostData + * Required and used only when persistArgs.sourceDocument is NOT present, + * represents the POST data to be sent along with the HTTP request, and + * must be null if no POST data should be sent. + * @param persistArgs.targetFile + * The nsIFile of the file to create + * @param persistArgs.targetContentType + * Required and used only when persistArgs.sourceDocument is present, + * determines the final content type of the saved file, or null to use + * the same content type as the source document. Currently only + * "text/plain" is meaningful. + * @param persistArgs.bypassCache + * If true, the document will always be refetched from the server + * @param persistArgs.isPrivate + * Indicates whether this is taking place in a private browsing context. + */ +function internalPersist(persistArgs) +{ + var persist = makeWebBrowserPersist(); + + // Calculate persist flags. + const nsIWBP = Components.interfaces.nsIWebBrowserPersist; + const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES | + nsIWBP.PERSIST_FLAGS_FORCE_ALLOW_COOKIES; + if (persistArgs.bypassCache) + persist.persistFlags = flags | nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; + else + persist.persistFlags = flags | nsIWBP.PERSIST_FLAGS_FROM_CACHE; + + // Leave it to WebBrowserPersist to discover the encoding type (or lack thereof): + persist.persistFlags |= nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION; + + // Find the URI associated with the target file + var targetFileURL = makeFileURI(persistArgs.targetFile); + + // Create download and initiate it (below) + var tr = Components.classes["@mozilla.org/transfer;1"].createInstance(Components.interfaces.nsITransfer); + tr.init(persistArgs.sourceURI, + targetFileURL, "", null, null, null, persist, persistArgs.isPrivate); + persist.progressListener = new DownloadListener(window, tr); + + if (persistArgs.sourceDocument) { + // Saving a Document, not a URI: + var filesFolder = null; + if (persistArgs.targetContentType != "text/plain") { + // Create the local directory into which to save associated files. + filesFolder = persistArgs.targetFile.clone(); + + var nameWithoutExtension = getFileBaseName(filesFolder.leafName); + var filesFolderLeafName = + ContentAreaUtils.stringBundle + .formatStringFromName("filesFolder", [nameWithoutExtension], 1); + + filesFolder.leafName = filesFolderLeafName; + } + + var encodingFlags = 0; + if (persistArgs.targetContentType == "text/plain") { + encodingFlags |= nsIWBP.ENCODE_FLAGS_FORMATTED; + encodingFlags |= nsIWBP.ENCODE_FLAGS_ABSOLUTE_LINKS; + encodingFlags |= nsIWBP.ENCODE_FLAGS_NOFRAMES_CONTENT; + } + else { + encodingFlags |= nsIWBP.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES; + } + + const kWrapColumn = 80; + persist.saveDocument(persistArgs.sourceDocument, targetFileURL, filesFolder, + persistArgs.targetContentType, encodingFlags, kWrapColumn); + } else { + persist.savePrivacyAwareURI(persistArgs.sourceURI, + persistArgs.sourceCacheKey, + persistArgs.sourceReferrer, + Components.interfaces.nsIHttpChannel.REFERRER_POLICY_NO_REFERRER_WHEN_DOWNGRADE, + persistArgs.sourcePostData, + null, + targetFileURL, + persistArgs.isPrivate); + } +} + +/** + * Structure for holding info about automatically supplied parameters for + * internalSave(...). This allows parameters to be supplied so the user does not + * need to be prompted for file info. + * @param aFileAutoChosen This is an nsIFile object that has been + * pre-determined as the filename for the target to save to + * @param aUriAutoChosen This is the nsIURI object for the target + */ +function AutoChosen(aFileAutoChosen, aUriAutoChosen) { + this.file = aFileAutoChosen; + this.uri = aUriAutoChosen; +} + +/** + * Structure for holding info about a URL and the target filename it should be + * saved to. This structure is populated by initFileInfo(...). + * @param aSuggestedFileName This is used by initFileInfo(...) when it + * cannot 'discover' the filename from the url + * @param aFileName The target filename + * @param aFileBaseName The filename without the file extension + * @param aFileExt The extension of the filename + * @param aUri An nsIURI object for the url that is being saved + */ +function FileInfo(aSuggestedFileName, aFileName, aFileBaseName, aFileExt, aUri) { + this.suggestedFileName = aSuggestedFileName; + this.fileName = aFileName; + this.fileBaseName = aFileBaseName; + this.fileExt = aFileExt; + this.uri = aUri; +} + +/** + * Determine what the 'default' filename string is, its file extension and the + * filename without the extension. This filename is used when prompting the user + * for confirmation in the file picker dialog. + * @param aFI A FileInfo structure into which we'll put the results of this method. + * @param aURL The String representation of the URL of the document being saved + * @param aURLCharset The charset of aURL. + * @param aDocument The document to be saved + * @param aContentType The content type we're saving, if it could be + * determined by the caller. + * @param aContentDisposition The content-disposition header for the object + * we're saving, if it could be determined by the caller. + */ +function initFileInfo(aFI, aURL, aURLCharset, aDocument, + aContentType, aContentDisposition) +{ + try { + // Get an nsIURI object from aURL if possible: + try { + aFI.uri = makeURI(aURL, aURLCharset); + // Assuming nsiUri is valid, calling QueryInterface(...) on it will + // populate extra object fields (eg filename and file extension). + var url = aFI.uri.QueryInterface(Components.interfaces.nsIURL); + aFI.fileExt = url.fileExtension; + } catch (e) { + } + + // Get the default filename: + aFI.fileName = getDefaultFileName((aFI.suggestedFileName || aFI.fileName), + aFI.uri, aDocument, aContentDisposition); + // If aFI.fileExt is still blank, consider: aFI.suggestedFileName is supplied + // if saveURL(...) was the original caller (hence both aContentType and + // aDocument are blank). If they were saving a link to a website then make + // the extension .htm . + if (!aFI.fileExt && !aDocument && !aContentType && (/^http(s?):\/\//i.test(aURL))) { + aFI.fileExt = "htm"; + aFI.fileBaseName = aFI.fileName; + } else { + aFI.fileExt = getDefaultExtension(aFI.fileName, aFI.uri, aContentType); + aFI.fileBaseName = getFileBaseName(aFI.fileName); + } + } catch (e) { + } +} + +/** + * Given the Filepicker Parameters (aFpP), show the file picker dialog, + * prompting the user to confirm (or change) the fileName. + * @param aFpP + * A structure (see definition in internalSave(...) method) + * containing all the data used within this method. + * @param aSkipPrompt + * If true, attempt to save the file automatically to the user's default + * download directory, thus skipping the explicit prompt for a file name, + * but only if the associated preference is set. + * If false, don't save the file automatically to the user's + * default download directory, even if the associated preference + * is set, but ask for the target explicitly. + * @param aRelatedURI + * An nsIURI associated with the download. The last used + * directory of the picker is retrieved from/stored in the + * Content Pref Service using this URI. + * @return Promise + * @resolve a boolean. When true, it indicates that the file picker dialog + * is accepted. + */ +function promiseTargetFile(aFpP, /* optional */ aSkipPrompt, /* optional */ aRelatedURI) +{ + return Task.spawn(function*() { + let downloadLastDir = new DownloadLastDir(window); + let prefBranch = Services.prefs.getBranch("browser.download."); + let useDownloadDir = prefBranch.getBoolPref("useDownloadDir"); + + if (!aSkipPrompt) + useDownloadDir = false; + + // Default to the user's default downloads directory configured + // through download prefs. + let dirPath = yield Downloads.getPreferredDownloadsDirectory(); + let dirExists = yield OS.File.exists(dirPath); + let dir = new FileUtils.File(dirPath); + + if (useDownloadDir && dirExists) { + dir.append(getNormalizedLeafName(aFpP.fileInfo.fileName, + aFpP.fileInfo.fileExt)); + aFpP.file = uniqueFile(dir); + return true; + } + + // We must prompt for the file name explicitly. + // If we must prompt because we were asked to... + let deferred = Promise.defer(); + if (useDownloadDir) { + // Keep async behavior in both branches + Services.tm.mainThread.dispatch(function() { + deferred.resolve(null); + }, Components.interfaces.nsIThread.DISPATCH_NORMAL); + } else { + downloadLastDir.getFileAsync(aRelatedURI, function getFileAsyncCB(aFile) { + deferred.resolve(aFile); + }); + } + let file = yield deferred.promise; + if (file && (yield OS.File.exists(file.path))) { + dir = file; + dirExists = true; + } + + if (!dirExists) { + // Default to desktop. + dir = Services.dirsvc.get("Desk", Components.interfaces.nsIFile); + } + + let fp = makeFilePicker(); + let titleKey = aFpP.fpTitleKey || "SaveLinkTitle"; + fp.init(window, ContentAreaUtils.stringBundle.GetStringFromName(titleKey), + Components.interfaces.nsIFilePicker.modeSave); + + fp.displayDirectory = dir; + fp.defaultExtension = aFpP.fileInfo.fileExt; + fp.defaultString = getNormalizedLeafName(aFpP.fileInfo.fileName, + aFpP.fileInfo.fileExt); + appendFiltersForContentType(fp, aFpP.contentType, aFpP.fileInfo.fileExt, + aFpP.saveMode); + + // The index of the selected filter is only preserved and restored if there's + // more than one filter in addition to "All Files". + if (aFpP.saveMode != SAVEMODE_FILEONLY) { + try { + fp.filterIndex = prefBranch.getIntPref("save_converter_index"); + } + catch (e) { + } + } + + let deferComplete = Promise.defer(); + fp.open(function(aResult) { + deferComplete.resolve(aResult); + }); + let result = yield deferComplete.promise; + if (result == Components.interfaces.nsIFilePicker.returnCancel || !fp.file) { + return false; + } + + if (aFpP.saveMode != SAVEMODE_FILEONLY) + prefBranch.setIntPref("save_converter_index", fp.filterIndex); + + // Do not store the last save directory as a pref inside the private browsing mode + downloadLastDir.setFile(aRelatedURI, fp.file.parent); + + fp.file.leafName = validateFileName(fp.file.leafName); + + aFpP.saveAsType = fp.filterIndex; + aFpP.file = fp.file; + aFpP.fileURL = fp.fileURL; + + return true; + }); +} + +// Since we're automatically downloading, we don't get the file picker's +// logic to check for existing files, so we need to do that here. +// +// Note - this code is identical to that in +// mozilla/toolkit/mozapps/downloads/src/nsHelperAppDlg.js.in +// If you are updating this code, update that code too! We can't share code +// here since that code is called in a js component. +function uniqueFile(aLocalFile) +{ + var collisionCount = 0; + while (aLocalFile.exists()) { + collisionCount++; + if (collisionCount == 1) { + // Append "(2)" before the last dot in (or at the end of) the filename + // special case .ext.gz etc files so we don't wind up with .tar(2).gz + if (aLocalFile.leafName.match(/\.[^\.]{1,3}\.(gz|bz2|Z)$/i)) + aLocalFile.leafName = aLocalFile.leafName.replace(/\.[^\.]{1,3}\.(gz|bz2|Z)$/i, "(2)$&"); + else + aLocalFile.leafName = aLocalFile.leafName.replace(/(\.[^\.]*)?$/, "(2)$&"); + } + else { + // replace the last (n) in the filename with (n+1) + aLocalFile.leafName = aLocalFile.leafName.replace(/^(.*\()\d+\)/, "$1" + (collisionCount + 1) + ")"); + } + } + return aLocalFile; +} + +/** + * Download a URL using the new jsdownloads API. + * + * @param aURL + * the url to download + * @param [optional] aFileName + * the destination file name, if omitted will be obtained from the url. + * @param aInitiatingDocument + * The document from which the download was initiated. + */ +function DownloadURL(aURL, aFileName, aInitiatingDocument) { + // For private browsing, try to get document out of the most recent browser + // window, or provide our own if there's no browser window. + let isPrivate = aInitiatingDocument.defaultView + .QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIWebNavigation) + .QueryInterface(Components.interfaces.nsILoadContext) + .usePrivateBrowsing; + + let fileInfo = new FileInfo(aFileName); + initFileInfo(fileInfo, aURL, null, null, null, null); + + let filepickerParams = { + fileInfo: fileInfo, + saveMode: SAVEMODE_FILEONLY + }; + + Task.spawn(function* () { + let accepted = yield promiseTargetFile(filepickerParams, true, fileInfo.uri); + if (!accepted) + return; + + let file = filepickerParams.file; + let download = yield Downloads.createDownload({ + source: { url: aURL, isPrivate: isPrivate }, + target: { path: file.path, partFilePath: file.path + ".part" } + }); + download.tryToKeepPartialData = true; + + // Ignore errors because failures are reported through the download list. + download.start().catch(() => {}); + + // Add the download to the list, allowing it to be managed. + let list = yield Downloads.getList(Downloads.ALL); + list.add(download); + }).then(null, Components.utils.reportError); +} + +// We have no DOM, and can only save the URL as is. +const SAVEMODE_FILEONLY = 0x00; +XPCOMUtils.defineConstant(this, "SAVEMODE_FILEONLY", SAVEMODE_FILEONLY); +// We have a DOM and can save as complete. +const SAVEMODE_COMPLETE_DOM = 0x01; +XPCOMUtils.defineConstant(this, "SAVEMODE_COMPLETE_DOM", SAVEMODE_COMPLETE_DOM); +// We have a DOM which we can serialize as text. +const SAVEMODE_COMPLETE_TEXT = 0x02; +XPCOMUtils.defineConstant(this, "SAVEMODE_COMPLETE_TEXT", SAVEMODE_COMPLETE_TEXT); + +// If we are able to save a complete DOM, the 'save as complete' filter +// must be the first filter appended. The 'save page only' counterpart +// must be the second filter appended. And the 'save as complete text' +// filter must be the third filter appended. +function appendFiltersForContentType(aFilePicker, aContentType, aFileExtension, aSaveMode) +{ + // The bundle name for saving only a specific content type. + var bundleName; + // The corresponding filter string for a specific content type. + var filterString; + + // Every case where GetSaveModeForContentType can return non-FILEONLY + // modes must be handled here. + if (aSaveMode != SAVEMODE_FILEONLY) { + switch (aContentType) { + case "text/html": + bundleName = "WebPageHTMLOnlyFilter"; + filterString = "*.htm; *.html"; + break; + + case "application/xhtml+xml": + bundleName = "WebPageXHTMLOnlyFilter"; + filterString = "*.xht; *.xhtml"; + break; + + case "image/svg+xml": + bundleName = "WebPageSVGOnlyFilter"; + filterString = "*.svg; *.svgz"; + break; + + case "text/xml": + case "application/xml": + bundleName = "WebPageXMLOnlyFilter"; + filterString = "*.xml"; + break; + } + } + + if (!bundleName) { + if (aSaveMode != SAVEMODE_FILEONLY) + throw "Invalid save mode for type '" + aContentType + "'"; + + var mimeInfo = getMIMEInfoForType(aContentType, aFileExtension); + if (mimeInfo) { + + var extEnumerator = mimeInfo.getFileExtensions(); + + var extString = ""; + while (extEnumerator.hasMore()) { + var extension = extEnumerator.getNext(); + if (extString) + extString += "; "; // If adding more than one extension, + // separate by semi-colon + extString += "*." + extension; + } + + if (extString) + aFilePicker.appendFilter(mimeInfo.description, extString); + } + } + + if (aSaveMode & SAVEMODE_COMPLETE_DOM) { + aFilePicker.appendFilter(ContentAreaUtils.stringBundle.GetStringFromName("WebPageCompleteFilter"), + filterString); + // We should always offer a choice to save document only if + // we allow saving as complete. + aFilePicker.appendFilter(ContentAreaUtils.stringBundle.GetStringFromName(bundleName), + filterString); + } + + if (aSaveMode & SAVEMODE_COMPLETE_TEXT) + aFilePicker.appendFilters(Components.interfaces.nsIFilePicker.filterText); + + // Always append the all files (*) filter + aFilePicker.appendFilters(Components.interfaces.nsIFilePicker.filterAll); +} + +function getPostData(aDocument) +{ + const Ci = Components.interfaces; + + if (aDocument instanceof Ci.nsIWebBrowserPersistDocument) { + return aDocument.postData; + } + try { + // Find the session history entry corresponding to the given document. In + // the current implementation, nsIWebPageDescriptor.currentDescriptor always + // returns a session history entry. + let sessionHistoryEntry = + aDocument.defaultView + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIWebPageDescriptor) + .currentDescriptor + .QueryInterface(Ci.nsISHEntry); + return sessionHistoryEntry.postData; + } + catch (e) { + } + return null; +} + +function makeWebBrowserPersist() +{ + const persistContractID = "@mozilla.org/embedding/browser/nsWebBrowserPersist;1"; + const persistIID = Components.interfaces.nsIWebBrowserPersist; + return Components.classes[persistContractID].createInstance(persistIID); +} + +function makeURI(aURL, aOriginCharset, aBaseURI) +{ + return BrowserUtils.makeURI(aURL, aOriginCharset, aBaseURI); +} + +function makeFileURI(aFile) +{ + return BrowserUtils.makeFileURI(aFile); +} + +function makeFilePicker() +{ + const fpContractID = "@mozilla.org/filepicker;1"; + const fpIID = Components.interfaces.nsIFilePicker; + return Components.classes[fpContractID].createInstance(fpIID); +} + +function getMIMEService() +{ + const mimeSvcContractID = "@mozilla.org/mime;1"; + const mimeSvcIID = Components.interfaces.nsIMIMEService; + const mimeSvc = Components.classes[mimeSvcContractID].getService(mimeSvcIID); + return mimeSvc; +} + +// Given aFileName, find the fileName without the extension on the end. +function getFileBaseName(aFileName) +{ + // Remove the file extension from aFileName: + return aFileName.replace(/\.[^.]*$/, ""); +} + +function getMIMETypeForURI(aURI) +{ + try { + return getMIMEService().getTypeFromURI(aURI); + } + catch (e) { + } + return null; +} + +function getMIMEInfoForType(aMIMEType, aExtension) +{ + if (aMIMEType || aExtension) { + try { + return getMIMEService().getFromTypeAndExtension(aMIMEType, aExtension); + } + catch (e) { + } + } + return null; +} + +function getDefaultFileName(aDefaultFileName, aURI, aDocument, + aContentDisposition) +{ + // 1) look for a filename in the content-disposition header, if any + if (aContentDisposition) { + const mhpContractID = "@mozilla.org/network/mime-hdrparam;1"; + const mhpIID = Components.interfaces.nsIMIMEHeaderParam; + const mhp = Components.classes[mhpContractID].getService(mhpIID); + var dummy = { value: null }; // Need an out param... + var charset = getCharsetforSave(aDocument); + + var fileName = null; + try { + fileName = mhp.getParameter(aContentDisposition, "filename", charset, + true, dummy); + } + catch (e) { + try { + fileName = mhp.getParameter(aContentDisposition, "name", charset, true, + dummy); + } + catch (e) { + } + } + if (fileName) + return fileName; + } + + let docTitle; + if (aDocument) { + // If the document looks like HTML or XML, try to use its original title. + docTitle = validateFileName(aDocument.title).trim(); + if (docTitle) { + let contentType = aDocument.contentType; + if (contentType == "application/xhtml+xml" || + contentType == "application/xml" || + contentType == "image/svg+xml" || + contentType == "text/html" || + contentType == "text/xml") { + // 2) Use the document title + return docTitle; + } + } + } + + try { + var url = aURI.QueryInterface(Components.interfaces.nsIURL); + if (url.fileName != "") { + // 3) Use the actual file name, if present + var textToSubURI = Components.classes["@mozilla.org/intl/texttosuburi;1"] + .getService(Components.interfaces.nsITextToSubURI); + return validateFileName(textToSubURI.unEscapeURIForUI(url.originCharset || "UTF-8", url.fileName)); + } + } catch (e) { + // This is something like a data: and so forth URI... no filename here. + } + + if (docTitle) + // 4) Use the document title + return docTitle; + + if (aDefaultFileName) + // 5) Use the caller-provided name, if any + return validateFileName(aDefaultFileName); + + // 6) If this is a directory, use the last directory name + var path = aURI.path.match(/\/([^\/]+)\/$/); + if (path && path.length > 1) + return validateFileName(path[1]); + + try { + if (aURI.host) + // 7) Use the host. + return aURI.host; + } catch (e) { + // Some files have no information at all, like Javascript generated pages + } + try { + // 8) Use the default file name + return ContentAreaUtils.stringBundle.GetStringFromName("DefaultSaveFileName"); + } catch (e) { + // in case localized string cannot be found + } + // 9) If all else fails, use "index" + return "index"; +} + +function validateFileName(aFileName) +{ + var re = /[\/]+/g; + if (navigator.appVersion.indexOf("Windows") != -1) { + re = /[\\\/\|]+/g; + aFileName = aFileName.replace(/[\"]+/g, "'"); + aFileName = aFileName.replace(/[\*\:\?]+/g, " "); + aFileName = aFileName.replace(/[\<]+/g, "("); + aFileName = aFileName.replace(/[\>]+/g, ")"); + } + else if (navigator.appVersion.indexOf("Macintosh") != -1) + re = /[\:\/]+/g; + else if (navigator.appVersion.indexOf("Android") != -1) { + // On mobile devices, the filesystem may be very limited in what + // it considers valid characters. To avoid errors, we sanitize + // conservatively. + const dangerousChars = "*?<>|\":/\\[];,+="; + var processed = ""; + for (var i = 0; i < aFileName.length; i++) + processed += aFileName.charCodeAt(i) >= 32 && + !(dangerousChars.indexOf(aFileName[i]) >= 0) ? aFileName[i] + : "_"; + + // Last character should not be a space + processed = processed.trim(); + + // If a large part of the filename has been sanitized, then we + // will use a default filename instead + if (processed.replace(/_/g, "").length <= processed.length/2) { + // We purposefully do not use a localized default filename, + // which we could have done using + // ContentAreaUtils.stringBundle.GetStringFromName("DefaultSaveFileName") + // since it may contain invalid characters. + var original = processed; + processed = "download"; + + // Preserve a suffix, if there is one + if (original.indexOf(".") >= 0) { + var suffix = original.split(".").slice(-1)[0]; + if (suffix && suffix.indexOf("_") < 0) + processed += "." + suffix; + } + } + return processed; + } + + return aFileName.replace(re, "_"); +} + +function getNormalizedLeafName(aFile, aDefaultExtension) +{ + if (!aDefaultExtension) + return aFile; + + if (AppConstants.platform == "win") { + // Remove trailing dots and spaces on windows + aFile = aFile.replace(/[\s.]+$/, ""); + } + + // Remove leading dots + aFile = aFile.replace(/^\.+/, ""); + + // Fix up the file name we're saving to to include the default extension + var i = aFile.lastIndexOf("."); + if (aFile.substr(i + 1) != aDefaultExtension) + return aFile + "." + aDefaultExtension; + + return aFile; +} + +function getDefaultExtension(aFilename, aURI, aContentType) +{ + if (aContentType == "text/plain" || aContentType == "application/octet-stream" || aURI.scheme == "ftp") + return ""; // temporary fix for bug 120327 + + // First try the extension from the filename + const stdURLContractID = "@mozilla.org/network/standard-url;1"; + const stdURLIID = Components.interfaces.nsIURL; + var url = Components.classes[stdURLContractID].createInstance(stdURLIID); + url.filePath = aFilename; + + var ext = url.fileExtension; + + // This mirrors some code in nsExternalHelperAppService::DoContent + // Use the filename first and then the URI if that fails + + var mimeInfo = getMIMEInfoForType(aContentType, ext); + + if (ext && mimeInfo && mimeInfo.extensionExists(ext)) + return ext; + + // Well, that failed. Now try the extension from the URI + var urlext; + try { + url = aURI.QueryInterface(Components.interfaces.nsIURL); + urlext = url.fileExtension; + } catch (e) { + } + + if (urlext && mimeInfo && mimeInfo.extensionExists(urlext)) { + return urlext; + } + try { + if (mimeInfo) + return mimeInfo.primaryExtension; + } + catch (e) { + } + // Fall back on the extensions in the filename and URI for lack + // of anything better. + return ext || urlext; +} + +function GetSaveModeForContentType(aContentType, aDocument) +{ + // We can only save a complete page if we have a loaded document, + // and it's not a CPOW -- nsWebBrowserPersist needs a real document. + if (!aDocument || Components.utils.isCrossProcessWrapper(aDocument)) + return SAVEMODE_FILEONLY; + + // Find the possible save modes using the provided content type + var saveMode = SAVEMODE_FILEONLY; + switch (aContentType) { + case "text/html": + case "application/xhtml+xml": + case "image/svg+xml": + saveMode |= SAVEMODE_COMPLETE_TEXT; + // Fall through + case "text/xml": + case "application/xml": + saveMode |= SAVEMODE_COMPLETE_DOM; + break; + } + + return saveMode; +} + +function getCharsetforSave(aDocument) +{ + if (aDocument) + return aDocument.characterSet; + + if (document.commandDispatcher.focusedWindow) + return document.commandDispatcher.focusedWindow.document.characterSet; + + return window.content.document.characterSet; +} + +/** + * Open a URL from chrome, determining if we can handle it internally or need to + * launch an external application to handle it. + * @param aURL The URL to be opened + * + * WARNING: Please note that openURL() does not perform any content security checks!!! + */ +function openURL(aURL) +{ + var uri = makeURI(aURL); + + var protocolSvc = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Components.interfaces.nsIExternalProtocolService); + + if (!protocolSvc.isExposedProtocol(uri.scheme)) { + // If we're not a browser, use the external protocol service to load the URI. + protocolSvc.loadUrl(uri); + } + else { + var recentWindow = Services.wm.getMostRecentWindow("navigator:browser"); + if (recentWindow) { + recentWindow.openUILinkIn(uri.spec, "tab"); + return; + } + + var loadgroup = Components.classes["@mozilla.org/network/load-group;1"] + .createInstance(Components.interfaces.nsILoadGroup); + var appstartup = Services.startup; + + var loadListener = { + onStartRequest: function ll_start(aRequest, aContext) { + appstartup.enterLastWindowClosingSurvivalArea(); + }, + onStopRequest: function ll_stop(aRequest, aContext, aStatusCode) { + appstartup.exitLastWindowClosingSurvivalArea(); + }, + QueryInterface: function ll_QI(iid) { + if (iid.equals(Components.interfaces.nsISupports) || + iid.equals(Components.interfaces.nsIRequestObserver) || + iid.equals(Components.interfaces.nsISupportsWeakReference)) + return this; + throw Components.results.NS_ERROR_NO_INTERFACE; + } + } + loadgroup.groupObserver = loadListener; + + var uriListener = { + onStartURIOpen: function(uri) { return false; }, + doContent: function(ctype, preferred, request, handler) { return false; }, + isPreferred: function(ctype, desired) { return false; }, + canHandleContent: function(ctype, preferred, desired) { return false; }, + loadCookie: null, + parentContentListener: null, + getInterface: function(iid) { + if (iid.equals(Components.interfaces.nsIURIContentListener)) + return this; + if (iid.equals(Components.interfaces.nsILoadGroup)) + return loadgroup; + throw Components.results.NS_ERROR_NO_INTERFACE; + } + } + + var channel = NetUtil.newChannel({ + uri: uri, + loadUsingSystemPrincipal: true + }); + + var uriLoader = Components.classes["@mozilla.org/uriloader;1"] + .getService(Components.interfaces.nsIURILoader); + uriLoader.openURI(channel, + Components.interfaces.nsIURILoader.IS_CONTENT_PREFERRED, + uriListener); + } +} |