gecko-dev/browser/modules/DirectoryLinksProvider.jsm

379 строки
12 KiB
JavaScript

/* 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/. */
"use strict";
this.EXPORTED_SYMBOLS = ["DirectoryLinksProvider"];
const Cu = Components.utils;
Cu.importGlobalProperties(["XMLHttpRequest"]);
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
"resource://gre/modules/PromiseUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils",
"resource://gre/modules/UpdateUtils.jsm");
XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => {
return new TextDecoder();
});
// The filename where directory links are stored locally
const DIRECTORY_LINKS_FILE = "directoryLinks.json";
const DIRECTORY_LINKS_TYPE = "application/json";
// The preference that tells where to obtain directory links
const PREF_DIRECTORY_SOURCE = "browser.newtabpage.directory.source";
// The preference that tells if newtab is enhanced
const PREF_NEWTAB_ENHANCED = "browser.newtabpage.enhanced";
// Only allow link urls that are http(s)
const ALLOWED_LINK_SCHEMES = new Set(["http", "https"]);
// Only allow link image urls that are https or data
const ALLOWED_IMAGE_SCHEMES = new Set(["https", "data"]);
// Only allow urls to Mozilla's CDN or empty (for data URIs)
const ALLOWED_URL_BASE = new Set(["mozilla.net", ""]);
// The frecency of a directory link
const DIRECTORY_FRECENCY = 1000;
/**
* Singleton that serves as the provider of directory links.
* Directory links are a hard-coded set of links shown if a user's link
* inventory is empty.
*/
var DirectoryLinksProvider = {
__linksURL: null,
_observers: new Set(),
// links download deferred, resolved upon download completion
_downloadDeferred: null,
// download default interval is 24 hours in milliseconds
_downloadIntervalMS: 86400000,
get _observedPrefs() {
return Object.freeze({
enhanced: PREF_NEWTAB_ENHANCED,
linksURL: PREF_DIRECTORY_SOURCE,
});
},
get _linksURL() {
if (!this.__linksURL) {
try {
this.__linksURL = Services.prefs.getCharPref(this._observedPrefs.linksURL);
this.__linksURLModified = Services.prefs.prefHasUserValue(this._observedPrefs.linksURL);
} catch (e) {
Cu.reportError("Error fetching directory links url from prefs: " + e);
}
}
return this.__linksURL;
},
/**
* Gets the currently selected locale for display.
* @return the selected locale or "en-US" if none is selected
*/
get locale() {
return Services.locale.getRequestedLocale() || "en-US";
},
/**
* Set appropriate default ping behavior controlled by enhanced pref
*/
_setDefaultEnhanced: function DirectoryLinksProvider_setDefaultEnhanced() {
if (!Services.prefs.prefHasUserValue(PREF_NEWTAB_ENHANCED)) {
let enhanced = Services.prefs.getBoolPref(PREF_NEWTAB_ENHANCED);
try {
// Default to not enhanced if DNT is set to tell websites to not track
if (Services.prefs.getBoolPref("privacy.donottrackheader.enabled")) {
enhanced = false;
}
} catch (ex) {}
Services.prefs.setBoolPref(PREF_NEWTAB_ENHANCED, enhanced);
}
},
observe: function DirectoryLinksProvider_observe(aSubject, aTopic, aData) {
if (aTopic == "nsPref:changed") {
switch (aData) {
// Re-set the default in case the user clears the pref
case this._observedPrefs.enhanced:
this._setDefaultEnhanced();
break;
case this._observedPrefs.linksURL:
delete this.__linksURL;
this._fetchAndCacheLinksIfNecessary(true);
break;
}
} else if (aTopic === "intl:requested-locales-changed") {
this._fetchAndCacheLinksIfNecessary(true);
}
},
_addPrefsObserver: function DirectoryLinksProvider_addObserver() {
for (let pref in this._observedPrefs) {
let prefName = this._observedPrefs[pref];
Services.prefs.addObserver(prefName, this);
}
},
_removePrefsObserver: function DirectoryLinksProvider_removeObserver() {
for (let pref in this._observedPrefs) {
let prefName = this._observedPrefs[pref];
Services.prefs.removeObserver(prefName, this);
}
},
_fetchAndCacheLinks: function DirectoryLinksProvider_fetchAndCacheLinks(uri) {
// Replace with the same display locale used for selecting links data
uri = uri.replace("%LOCALE%", this.locale);
uri = uri.replace("%CHANNEL%", UpdateUtils.UpdateChannel);
return this._downloadJsonData(uri).then(json => {
return OS.File.writeAtomic(this._directoryFilePath, json, {tmpPath: this._directoryFilePath + ".tmp"});
});
},
/**
* Downloads a links with json content
* @param download uri
* @return promise resolved to json string, "{}" returned if status != 200
*/
_downloadJsonData: function DirectoryLinksProvider__downloadJsonData(uri) {
return new Promise((resolve, reject) => {
let xmlHttp = this._newXHR();
xmlHttp.onload = function(aResponse) {
let json = this.responseText;
if (this.status && this.status != 200) {
json = "{}";
}
resolve(json);
};
xmlHttp.onerror = function(e) {
reject("Fetching " + uri + " results in error code: " + e.target.status);
};
try {
xmlHttp.open("GET", uri);
// Override the type so XHR doesn't complain about not well-formed XML
xmlHttp.overrideMimeType(DIRECTORY_LINKS_TYPE);
// Set the appropriate request type for servers that require correct types
xmlHttp.setRequestHeader("Content-Type", DIRECTORY_LINKS_TYPE);
xmlHttp.send();
} catch (e) {
reject("Error fetching " + uri);
Cu.reportError(e);
}
});
},
/**
* Downloads directory links if needed
* @return promise resolved immediately if no download needed, or upon completion
*/
_fetchAndCacheLinksIfNecessary: function DirectoryLinksProvider_fetchAndCacheLinksIfNecessary(forceDownload = false) {
if (this._downloadDeferred) {
// fetching links already - just return the promise
return this._downloadDeferred.promise;
}
if (forceDownload || this._needsDownload) {
this._downloadDeferred = PromiseUtils.defer();
this._fetchAndCacheLinks(this._linksURL).then(() => {
// the new file was successfully downloaded and cached, so update a timestamp
this._lastDownloadMS = Date.now();
this._downloadDeferred.resolve();
this._downloadDeferred = null;
this._callObservers("onManyLinksChanged");
},
error => {
this._downloadDeferred.resolve();
this._downloadDeferred = null;
this._callObservers("onDownloadFail");
});
return this._downloadDeferred.promise;
}
// download is not needed
return Promise.resolve();
},
/**
* @return true if download is needed, false otherwise
*/
get _needsDownload() {
// fail if last download occured less then 24 hours ago
if ((Date.now() - this._lastDownloadMS) > this._downloadIntervalMS) {
return true;
}
return false;
},
/**
* Create a new XMLHttpRequest that is anonymous, i.e., doesn't send cookies
*/
_newXHR() {
return new XMLHttpRequest({mozAnon: true});
},
/**
* Reads directory links file and parses its content
* @return a promise resolved to an object with keys 'directory' and 'suggested',
* each containing a valid list of links,
* or {'directory': [], 'suggested': []} if read or parse fails.
*/
_readDirectoryLinksFile: function DirectoryLinksProvider_readDirectoryLinksFile() {
let emptyOutput = {directory: []};
return OS.File.read(this._directoryFilePath).then(binaryData => {
let output;
try {
let json = gTextDecoder.decode(binaryData);
let linksObj = JSON.parse(json);
output = {directory: linksObj.directory || []};
} catch (e) {
Cu.reportError(e);
}
return output || emptyOutput;
},
error => {
Cu.reportError(error);
return emptyOutput;
});
},
/**
* Get the enhanced link object for a link (whether history or directory)
*/
getEnhancedLink: function DirectoryLinksProvider_getEnhancedLink(link) {
// Use the provided link if it's already enhanced
return link.enhancedImageURI && link;
},
/**
* Check if a url's scheme is in a Set of allowed schemes and if the base
* domain is allowed.
* @param url to check
* @param allowed Set of allowed schemes
* @param checkBase boolean to check the base domain
*/
isURLAllowed(url, allowed, checkBase) {
// Assume no url is an allowed url
if (!url) {
return true;
}
let scheme = "", base = "";
try {
// A malformed url will not be allowed
let uri = Services.io.newURI(url);
scheme = uri.scheme;
// URIs without base domains will be allowed
base = Services.eTLD.getBaseDomain(uri);
} catch (ex) {}
// Require a scheme match and the base only if desired
return allowed.has(scheme) && (!checkBase || ALLOWED_URL_BASE.has(base));
},
/**
* Gets the current set of directory links.
* @param aCallback The function that the array of links is passed to.
*/
getLinks: function DirectoryLinksProvider_getLinks(aCallback) {
this._readDirectoryLinksFile().then(rawLinks => {
// Only check base domain for images when using the default pref
let checkBase = !this.__linksURLModified;
let validityFilter = link => {
// Make sure the link url is allowed and images too if they exist
return this.isURLAllowed(link.url, ALLOWED_LINK_SCHEMES, false) &&
(!link.imageURI ||
this.isURLAllowed(link.imageURI, ALLOWED_IMAGE_SCHEMES, checkBase)) &&
(!link.enhancedImageURI ||
this.isURLAllowed(link.enhancedImageURI, ALLOWED_IMAGE_SCHEMES, checkBase));
};
let links = rawLinks.directory.filter(validityFilter).map((link, position) => {
link.lastVisitDate = rawLinks.directory.length - position;
link.frecency = DIRECTORY_FRECENCY;
return link;
});
return links;
}).catch(ex => {
Cu.reportError(ex);
return [];
}).then(links => {
aCallback(links);
});
},
init: function DirectoryLinksProvider_init() {
this._setDefaultEnhanced();
this._addPrefsObserver();
Services.obs.addObserver(this, "intl:requested-locales-changed");
// setup directory file path and last download timestamp
this._directoryFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, DIRECTORY_LINKS_FILE);
this._lastDownloadMS = 0;
return (async () => {
// get the last modified time of the links file if it exists
let doesFileExists = await OS.File.exists(this._directoryFilePath);
if (doesFileExists) {
let fileInfo = await OS.File.stat(this._directoryFilePath);
this._lastDownloadMS = Date.parse(fileInfo.lastModificationDate);
}
// fetch directory on startup without force
await this._fetchAndCacheLinksIfNecessary();
})();
},
/**
* Return the object to its pre-init state
*/
reset: function DirectoryLinksProvider_reset() {
delete this.__linksURL;
this._removePrefsObserver();
this._removeObservers();
Services.obs.removeObserver(this, "intl:requested-locales-changed");
},
addObserver: function DirectoryLinksProvider_addObserver(aObserver) {
this._observers.add(aObserver);
},
removeObserver: function DirectoryLinksProvider_removeObserver(aObserver) {
this._observers.delete(aObserver);
},
_callObservers(methodName, ...args) {
for (let obs of this._observers) {
if (typeof(obs[methodName]) == "function") {
try {
obs[methodName](this, ...args);
} catch (err) {
Cu.reportError(err);
}
}
}
},
_removeObservers() {
this._observers.clear();
}
};