1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
7 this.EXPORTED_SYMBOLS = ["DirectoryLinksProvider"];
9 const Ci = Components.interfaces;
10 const Cc = Components.classes;
11 const Cu = Components.utils;
12 const XMLHttpRequest =
13 Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1", "nsIXMLHttpRequest");
15 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
16 Cu.import("resource://gre/modules/Services.jsm");
17 Cu.import("resource://gre/modules/Task.jsm");
19 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
20 "resource://gre/modules/NetUtil.jsm");
21 XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
22 "resource://gre/modules/NewTabUtils.jsm");
23 XPCOMUtils.defineLazyModuleGetter(this, "OS",
24 "resource://gre/modules/osfile.jsm")
25 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
26 "resource://gre/modules/Promise.jsm");
27 XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => {
28 return new TextDecoder();
31 // The filename where directory links are stored locally
32 const DIRECTORY_LINKS_FILE = "directoryLinks.json";
33 const DIRECTORY_LINKS_TYPE = "application/json";
35 // The preference that tells whether to match the OS locale
36 const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS";
38 // The preference that tells what locale the user selected
39 const PREF_SELECTED_LOCALE = "general.useragent.locale";
41 // The preference that tells where to obtain directory links
42 const PREF_DIRECTORY_SOURCE = "browser.newtabpage.directory.source";
44 // The preference that tells where to send click/view pings
45 const PREF_DIRECTORY_PING = "browser.newtabpage.directory.ping";
47 // The preference that tells if newtab is enhanced
48 const PREF_NEWTAB_ENHANCED = "browser.newtabpage.enhanced";
50 // Only allow link urls that are http(s)
51 const ALLOWED_LINK_SCHEMES = new Set(["http", "https"]);
53 // Only allow link image urls that are https or data
54 const ALLOWED_IMAGE_SCHEMES = new Set(["https", "data"]);
56 // The frecency of a directory link
57 const DIRECTORY_FRECENCY = 1000;
59 // Divide frecency by this amount for pings
60 const PING_SCORE_DIVISOR = 10000;
62 // Allowed ping actions remotely stored as columns: case-insensitive [a-z0-9_]
63 const PING_ACTIONS = ["block", "click", "pin", "sponsored", "sponsored_link", "unpin", "view"];
66 * Singleton that serves as the provider of directory links.
67 * Directory links are a hard-coded set of links shown if a user's link
70 let DirectoryLinksProvider = {
74 _observers: new Set(),
76 // links download deferred, resolved upon download completion
77 _downloadDeferred: null,
79 // download default interval is 24 hours in milliseconds
80 _downloadIntervalMS: 86400000,
83 * A mapping from eTLD+1 to an enhanced link objects
85 _enhancedLinks: new Map(),
87 get _observedPrefs() Object.freeze({
88 enhanced: PREF_NEWTAB_ENHANCED,
89 linksURL: PREF_DIRECTORY_SOURCE,
90 matchOSLocale: PREF_MATCH_OS_LOCALE,
91 prefSelectedLocale: PREF_SELECTED_LOCALE,
95 if (!this.__linksURL) {
97 this.__linksURL = Services.prefs.getCharPref(this._observedPrefs["linksURL"]);
100 Cu.reportError("Error fetching directory links url from prefs: " + e);
103 return this.__linksURL;
107 * Gets the currently selected locale for display.
108 * @return the selected locale or "en-US" if none is selected
113 matchOS = Services.prefs.getBoolPref(PREF_MATCH_OS_LOCALE);
118 return Services.locale.getLocaleComponentForUserAgent();
122 let locale = Services.prefs.getComplexValue(PREF_SELECTED_LOCALE,
123 Ci.nsIPrefLocalizedString);
131 return Services.prefs.getCharPref(PREF_SELECTED_LOCALE);
139 * Set appropriate default ping behavior controlled by enhanced pref
141 _setDefaultEnhanced: function DirectoryLinksProvider_setDefaultEnhanced() {
142 if (!Services.prefs.prefHasUserValue(PREF_NEWTAB_ENHANCED)) {
145 // Default to not enhanced if DNT is set to tell websites to not track
146 if (Services.prefs.getBoolPref("privacy.donottrackheader.enabled") &&
147 Services.prefs.getIntPref("privacy.donottrackheader.value") == 1) {
152 Services.prefs.setBoolPref(PREF_NEWTAB_ENHANCED, enhanced);
156 observe: function DirectoryLinksProvider_observe(aSubject, aTopic, aData) {
157 if (aTopic == "nsPref:changed") {
159 // Re-set the default in case the user clears the pref
160 case this._observedPrefs.enhanced:
161 this._setDefaultEnhanced();
164 case this._observedPrefs.linksURL:
165 delete this.__linksURL;
168 // Force directory download on changes to fetch related prefs
169 case this._observedPrefs.matchOSLocale:
170 case this._observedPrefs.prefSelectedLocale:
171 this._fetchAndCacheLinksIfNecessary(true);
177 _addPrefsObserver: function DirectoryLinksProvider_addObserver() {
178 for (let pref in this._observedPrefs) {
179 let prefName = this._observedPrefs[pref];
180 Services.prefs.addObserver(prefName, this, false);
184 _removePrefsObserver: function DirectoryLinksProvider_removeObserver() {
185 for (let pref in this._observedPrefs) {
186 let prefName = this._observedPrefs[pref];
187 Services.prefs.removeObserver(prefName, this);
191 _fetchAndCacheLinks: function DirectoryLinksProvider_fetchAndCacheLinks(uri) {
192 // Replace with the same display locale used for selecting links data
193 uri = uri.replace("%LOCALE%", this.locale);
195 let deferred = Promise.defer();
196 let xmlHttp = new XMLHttpRequest();
199 xmlHttp.onload = function(aResponse) {
200 let json = this.responseText;
201 if (this.status && this.status != 200) {
204 OS.File.writeAtomic(self._directoryFilePath, json, {tmpPath: self._directoryFilePath + ".tmp"})
209 deferred.reject("Error writing uri data in profD.");
213 xmlHttp.onerror = function(e) {
214 deferred.reject("Fetching " + uri + " results in error code: " + e.target.status);
218 xmlHttp.open("GET", uri);
219 // Override the type so XHR doesn't complain about not well-formed XML
220 xmlHttp.overrideMimeType(DIRECTORY_LINKS_TYPE);
221 // Set the appropriate request type for servers that require correct types
222 xmlHttp.setRequestHeader("Content-Type", DIRECTORY_LINKS_TYPE);
225 deferred.reject("Error fetching " + uri);
228 return deferred.promise;
232 * Downloads directory links if needed
233 * @return promise resolved immediately if no download needed, or upon completion
235 _fetchAndCacheLinksIfNecessary: function DirectoryLinksProvider_fetchAndCacheLinksIfNecessary(forceDownload=false) {
236 if (this._downloadDeferred) {
237 // fetching links already - just return the promise
238 return this._downloadDeferred.promise;
241 if (forceDownload || this._needsDownload) {
242 this._downloadDeferred = Promise.defer();
243 this._fetchAndCacheLinks(this._linksURL).then(() => {
244 // the new file was successfully downloaded and cached, so update a timestamp
245 this._lastDownloadMS = Date.now();
246 this._downloadDeferred.resolve();
247 this._downloadDeferred = null;
248 this._callObservers("onManyLinksChanged")
251 this._downloadDeferred.resolve();
252 this._downloadDeferred = null;
253 this._callObservers("onDownloadFail");
255 return this._downloadDeferred.promise;
258 // download is not needed
259 return Promise.resolve();
263 * @return true if download is needed, false otherwise
265 get _needsDownload () {
266 // fail if last download occured less then 24 hours ago
267 if ((Date.now() - this._lastDownloadMS) > this._downloadIntervalMS) {
274 * Reads directory links file and parses its content
275 * @return a promise resolved to valid list of links or [] if read or parse fails
277 _readDirectoryLinksFile: function DirectoryLinksProvider_readDirectoryLinksFile() {
278 return OS.File.read(this._directoryFilePath).then(binaryData => {
281 let locale = this.locale;
282 let json = gTextDecoder.decode(binaryData);
283 let list = JSON.parse(json);
284 output = list[locale];
292 Cu.reportError(error);
298 * Report some action on a newtab page (view, click)
299 * @param sites Array of sites shown on newtab page
300 * @param action String of the behavior to report
301 * @param triggeringSiteIndex optional Int index of the site triggering action
302 * @return download promise
304 reportSitesAction: function DirectoryLinksProvider_reportSitesAction(sites, action, triggeringSiteIndex) {
305 let newtabEnhanced = false;
306 let pingEndPoint = "";
308 newtabEnhanced = Services.prefs.getBoolPref(PREF_NEWTAB_ENHANCED);
309 pingEndPoint = Services.prefs.getCharPref(PREF_DIRECTORY_PING);
313 // Only send pings when enhancing tiles with an endpoint and valid action
314 let invalidAction = PING_ACTIONS.indexOf(action) == -1;
315 if (!newtabEnhanced || pingEndPoint == "" || invalidAction) {
316 return Promise.resolve();
322 tiles: sites.reduce((tiles, site, pos) => {
323 // Only add data for non-empty tiles
325 // Remember which tiles data triggered the action
327 let tilesIndex = tiles.length;
328 if (triggeringSiteIndex == pos) {
329 actionIndex = tilesIndex;
332 // Make the payload in a way so keys can be excluded when stringified
333 let id = link.directoryId;
335 id: id || site.enhancedId,
336 pin: site.isPinned() ? 1 : undefined,
337 pos: pos != tilesIndex ? pos : undefined,
338 score: Math.round(link.frecency / PING_SCORE_DIVISOR) || undefined,
339 url: site.enhancedId && "",
346 // Provide a direct index to the tile triggering the action
347 if (actionIndex !== undefined) {
348 data[action] = actionIndex;
351 // Package the data to be sent with the ping
352 let ping = new XMLHttpRequest();
353 ping.open("POST", pingEndPoint + (action == "view" ? "view" : "click"));
354 ping.send(JSON.stringify(data));
356 // Use this as an opportunity to potentially fetch new links
357 return this._fetchAndCacheLinksIfNecessary();
361 * Get the enhanced link object for a link (whether history or directory)
363 getEnhancedLink: function DirectoryLinksProvider_getEnhancedLink(link) {
364 // Use the provided link if it's already enhanced
365 return link.enhancedImageURI && link ||
366 this._enhancedLinks.get(NewTabUtils.extractSite(link.url));
370 * Check if a url's scheme is in a Set of allowed schemes
372 isURLAllowed: function DirectoryLinksProvider_isURLAllowed(url, allowed) {
373 // Assume no url is an allowed url
380 // A malformed url will not be allowed
381 scheme = Services.io.newURI(url, null, null).scheme;
384 return allowed.has(scheme);
388 * Gets the current set of directory links.
389 * @param aCallback The function that the array of links is passed to.
391 getLinks: function DirectoryLinksProvider_getLinks(aCallback) {
392 this._readDirectoryLinksFile().then(rawLinks => {
393 // Reset the cache of enhanced images for this new set of links
394 this._enhancedLinks.clear();
396 return rawLinks.filter(link => {
397 // Make sure the link url is allowed and images too if they exist
398 return this.isURLAllowed(link.url, ALLOWED_LINK_SCHEMES) &&
399 this.isURLAllowed(link.imageURI, ALLOWED_IMAGE_SCHEMES) &&
400 this.isURLAllowed(link.enhancedImageURI, ALLOWED_IMAGE_SCHEMES);
401 }).map((link, position) => {
402 // Stash the enhanced image for the site
403 if (link.enhancedImageURI) {
404 this._enhancedLinks.set(NewTabUtils.extractSite(link.url), link);
407 link.frecency = DIRECTORY_FRECENCY;
408 link.lastVisitDate = rawLinks.length - position;
417 init: function DirectoryLinksProvider_init() {
418 this._setDefaultEnhanced();
419 this._addPrefsObserver();
420 // setup directory file path and last download timestamp
421 this._directoryFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, DIRECTORY_LINKS_FILE);
422 this._lastDownloadMS = 0;
423 return Task.spawn(function() {
424 // get the last modified time of the links file if it exists
425 let doesFileExists = yield OS.File.exists(this._directoryFilePath);
426 if (doesFileExists) {
427 let fileInfo = yield OS.File.stat(this._directoryFilePath);
428 this._lastDownloadMS = Date.parse(fileInfo.lastModificationDate);
430 // fetch directory on startup without force
431 yield this._fetchAndCacheLinksIfNecessary();
436 * Return the object to its pre-init state
438 reset: function DirectoryLinksProvider_reset() {
439 delete this.__linksURL;
440 this._removePrefsObserver();
441 this._removeObservers();
444 addObserver: function DirectoryLinksProvider_addObserver(aObserver) {
445 this._observers.add(aObserver);
448 removeObserver: function DirectoryLinksProvider_removeObserver(aObserver) {
449 this._observers.delete(aObserver);
452 _callObservers: function DirectoryLinksProvider__callObservers(aMethodName, aArg) {
453 for (let obs of this._observers) {
454 if (typeof(obs[aMethodName]) == "function") {
456 obs[aMethodName](this, aArg);
464 _removeObservers: function() {
465 this._observers.clear();