Bumping manifests a=b2g-bump
[gecko.git] / browser / modules / DirectoryLinksProvider.jsm
blob9385c0921c9e767502675616d666042f9cd367fe
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/. */
5 "use strict";
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();
29 });
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"];
65 /**
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
68  * inventory is empty.
69  */
70 let DirectoryLinksProvider = {
72   __linksURL: null,
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,
82   /**
83    * A mapping from eTLD+1 to an enhanced link objects
84    */
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,
92   }),
94   get _linksURL() {
95     if (!this.__linksURL) {
96       try {
97         this.__linksURL = Services.prefs.getCharPref(this._observedPrefs["linksURL"]);
98       }
99       catch (e) {
100         Cu.reportError("Error fetching directory links url from prefs: " + e);
101       }
102     }
103     return this.__linksURL;
104   },
106   /**
107    * Gets the currently selected locale for display.
108    * @return  the selected locale or "en-US" if none is selected
109    */
110   get locale() {
111     let matchOS;
112     try {
113       matchOS = Services.prefs.getBoolPref(PREF_MATCH_OS_LOCALE);
114     }
115     catch (e) {}
117     if (matchOS) {
118       return Services.locale.getLocaleComponentForUserAgent();
119     }
121     try {
122       let locale = Services.prefs.getComplexValue(PREF_SELECTED_LOCALE,
123                                                   Ci.nsIPrefLocalizedString);
124       if (locale) {
125         return locale.data;
126       }
127     }
128     catch (e) {}
130     try {
131       return Services.prefs.getCharPref(PREF_SELECTED_LOCALE);
132     }
133     catch (e) {}
135     return "en-US";
136   },
138   /**
139    * Set appropriate default ping behavior controlled by enhanced pref
140    */
141   _setDefaultEnhanced: function DirectoryLinksProvider_setDefaultEnhanced() {
142     if (!Services.prefs.prefHasUserValue(PREF_NEWTAB_ENHANCED)) {
143       let enhanced = true;
144       try {
145         // Default to not enhanced if DNT is set to tell websites to not track
146         if (Services.prefs.getBoolPref("privacy.donottrackheader.enabled")) {
147           enhanced = false;
148         }
149       }
150       catch(ex) {}
151       Services.prefs.setBoolPref(PREF_NEWTAB_ENHANCED, enhanced);
152     }
153   },
155   observe: function DirectoryLinksProvider_observe(aSubject, aTopic, aData) {
156     if (aTopic == "nsPref:changed") {
157       switch (aData) {
158         // Re-set the default in case the user clears the pref
159         case this._observedPrefs.enhanced:
160           this._setDefaultEnhanced();
161           break;
163         case this._observedPrefs.linksURL:
164           delete this.__linksURL;
165           // fallthrough
167         // Force directory download on changes to fetch related prefs
168         case this._observedPrefs.matchOSLocale:
169         case this._observedPrefs.prefSelectedLocale:
170           this._fetchAndCacheLinksIfNecessary(true);
171           break;
172       }
173     }
174   },
176   _addPrefsObserver: function DirectoryLinksProvider_addObserver() {
177     for (let pref in this._observedPrefs) {
178       let prefName = this._observedPrefs[pref];
179       Services.prefs.addObserver(prefName, this, false);
180     }
181   },
183   _removePrefsObserver: function DirectoryLinksProvider_removeObserver() {
184     for (let pref in this._observedPrefs) {
185       let prefName = this._observedPrefs[pref];
186       Services.prefs.removeObserver(prefName, this);
187     }
188   },
190   _fetchAndCacheLinks: function DirectoryLinksProvider_fetchAndCacheLinks(uri) {
191     // Replace with the same display locale used for selecting links data
192     uri = uri.replace("%LOCALE%", this.locale);
194     let deferred = Promise.defer();
195     let xmlHttp = new XMLHttpRequest();
197     let self = this;
198     xmlHttp.onload = function(aResponse) {
199       let json = this.responseText;
200       if (this.status && this.status != 200) {
201         json = "{}";
202       }
203       OS.File.writeAtomic(self._directoryFilePath, json, {tmpPath: self._directoryFilePath + ".tmp"})
204         .then(() => {
205           deferred.resolve();
206         },
207         () => {
208           deferred.reject("Error writing uri data in profD.");
209         });
210     };
212     xmlHttp.onerror = function(e) {
213       deferred.reject("Fetching " + uri + " results in error code: " + e.target.status);
214     };
216     try {
217       xmlHttp.open("GET", uri);
218       // Override the type so XHR doesn't complain about not well-formed XML
219       xmlHttp.overrideMimeType(DIRECTORY_LINKS_TYPE);
220       // Set the appropriate request type for servers that require correct types
221       xmlHttp.setRequestHeader("Content-Type", DIRECTORY_LINKS_TYPE);
222       xmlHttp.send();
223     } catch (e) {
224       deferred.reject("Error fetching " + uri);
225       Cu.reportError(e);
226     }
227     return deferred.promise;
228   },
230   /**
231    * Downloads directory links if needed
232    * @return promise resolved immediately if no download needed, or upon completion
233    */
234   _fetchAndCacheLinksIfNecessary: function DirectoryLinksProvider_fetchAndCacheLinksIfNecessary(forceDownload=false) {
235     if (this._downloadDeferred) {
236       // fetching links already - just return the promise
237       return this._downloadDeferred.promise;
238     }
240     if (forceDownload || this._needsDownload) {
241       this._downloadDeferred = Promise.defer();
242       this._fetchAndCacheLinks(this._linksURL).then(() => {
243         // the new file was successfully downloaded and cached, so update a timestamp
244         this._lastDownloadMS = Date.now();
245         this._downloadDeferred.resolve();
246         this._downloadDeferred = null;
247         this._callObservers("onManyLinksChanged")
248       },
249       error => {
250         this._downloadDeferred.resolve();
251         this._downloadDeferred = null;
252         this._callObservers("onDownloadFail");
253       });
254       return this._downloadDeferred.promise;
255     }
257     // download is not needed
258     return Promise.resolve();
259   },
261   /**
262    * @return true if download is needed, false otherwise
263    */
264   get _needsDownload () {
265     // fail if last download occured less then 24 hours ago
266     if ((Date.now() - this._lastDownloadMS) > this._downloadIntervalMS) {
267       return true;
268     }
269     return false;
270   },
272   /**
273    * Reads directory links file and parses its content
274    * @return a promise resolved to valid list of links or [] if read or parse fails
275    */
276   _readDirectoryLinksFile: function DirectoryLinksProvider_readDirectoryLinksFile() {
277     return OS.File.read(this._directoryFilePath).then(binaryData => {
278       let output;
279       try {
280         let locale = this.locale;
281         let json = gTextDecoder.decode(binaryData);
282         let list = JSON.parse(json);
283         output = list[locale];
284       }
285       catch (e) {
286         Cu.reportError(e);
287       }
288       return output || [];
289     },
290     error => {
291       Cu.reportError(error);
292       return [];
293     });
294   },
296   /**
297    * Report some action on a newtab page (view, click)
298    * @param sites Array of sites shown on newtab page
299    * @param action String of the behavior to report
300    * @param triggeringSiteIndex optional Int index of the site triggering action
301    * @return download promise
302    */
303   reportSitesAction: function DirectoryLinksProvider_reportSitesAction(sites, action, triggeringSiteIndex) {
304     let newtabEnhanced = false;
305     let pingEndPoint = "";
306     try {
307       newtabEnhanced = Services.prefs.getBoolPref(PREF_NEWTAB_ENHANCED);
308       pingEndPoint = Services.prefs.getCharPref(PREF_DIRECTORY_PING);
309     }
310     catch (ex) {}
312     // Only send pings when enhancing tiles with an endpoint and valid action
313     let invalidAction = PING_ACTIONS.indexOf(action) == -1;
314     if (!newtabEnhanced || pingEndPoint == "" || invalidAction) {
315       return Promise.resolve();
316     }
318     let actionIndex;
319     let data = {
320       locale: this.locale,
321       tiles: sites.reduce((tiles, site, pos) => {
322         // Only add data for non-empty tiles
323         if (site) {
324           // Remember which tiles data triggered the action
325           let {link} = site;
326           let tilesIndex = tiles.length;
327           if (triggeringSiteIndex == pos) {
328             actionIndex = tilesIndex;
329           }
331           // Make the payload in a way so keys can be excluded when stringified
332           let id = link.directoryId;
333           tiles.push({
334             id: id || site.enhancedId,
335             pin: site.isPinned() ? 1 : undefined,
336             pos: pos != tilesIndex ? pos : undefined,
337             score: Math.round(link.frecency / PING_SCORE_DIVISOR) || undefined,
338             url: site.enhancedId && "",
339           });
340         }
341         return tiles;
342       }, []),
343     };
345     // Provide a direct index to the tile triggering the action
346     if (actionIndex !== undefined) {
347       data[action] = actionIndex;
348     }
350     // Package the data to be sent with the ping
351     let ping = new XMLHttpRequest();
352     ping.open("POST", pingEndPoint + (action == "view" ? "view" : "click"));
353     ping.send(JSON.stringify(data));
355     // Use this as an opportunity to potentially fetch new links
356     return this._fetchAndCacheLinksIfNecessary();
357   },
359   /**
360    * Get the enhanced link object for a link (whether history or directory)
361    */
362   getEnhancedLink: function DirectoryLinksProvider_getEnhancedLink(link) {
363     // Use the provided link if it's already enhanced
364     return link.enhancedImageURI && link ||
365            this._enhancedLinks.get(NewTabUtils.extractSite(link.url));
366   },
368   /**
369    * Check if a url's scheme is in a Set of allowed schemes
370    */
371   isURLAllowed: function DirectoryLinksProvider_isURLAllowed(url, allowed) {
372     // Assume no url is an allowed url
373     if (!url) {
374       return true;
375     }
377     let scheme = "";
378     try {
379       // A malformed url will not be allowed
380       scheme = Services.io.newURI(url, null, null).scheme;
381     }
382     catch(ex) {}
383     return allowed.has(scheme);
384   },
386   /**
387    * Gets the current set of directory links.
388    * @param aCallback The function that the array of links is passed to.
389    */
390   getLinks: function DirectoryLinksProvider_getLinks(aCallback) {
391     this._readDirectoryLinksFile().then(rawLinks => {
392       // Reset the cache of enhanced images for this new set of links
393       this._enhancedLinks.clear();
395       return rawLinks.filter(link => {
396         // Make sure the link url is allowed and images too if they exist
397         return this.isURLAllowed(link.url, ALLOWED_LINK_SCHEMES) &&
398                this.isURLAllowed(link.imageURI, ALLOWED_IMAGE_SCHEMES) &&
399                this.isURLAllowed(link.enhancedImageURI, ALLOWED_IMAGE_SCHEMES);
400       }).map((link, position) => {
401         // Stash the enhanced image for the site
402         if (link.enhancedImageURI) {
403           this._enhancedLinks.set(NewTabUtils.extractSite(link.url), link);
404         }
406         link.frecency = DIRECTORY_FRECENCY;
407         link.lastVisitDate = rawLinks.length - position;
408         return link;
409       });
410     }).catch(ex => {
411       Cu.reportError(ex);
412       return [];
413     }).then(aCallback);
414   },
416   init: function DirectoryLinksProvider_init() {
417     this._setDefaultEnhanced();
418     this._addPrefsObserver();
419     // setup directory file path and last download timestamp
420     this._directoryFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, DIRECTORY_LINKS_FILE);
421     this._lastDownloadMS = 0;
422     return Task.spawn(function() {
423       // get the last modified time of the links file if it exists
424       let doesFileExists = yield OS.File.exists(this._directoryFilePath);
425       if (doesFileExists) {
426         let fileInfo = yield OS.File.stat(this._directoryFilePath);
427         this._lastDownloadMS = Date.parse(fileInfo.lastModificationDate);
428       }
429       // fetch directory on startup without force
430       yield this._fetchAndCacheLinksIfNecessary();
431     }.bind(this));
432   },
434   /**
435    * Return the object to its pre-init state
436    */
437   reset: function DirectoryLinksProvider_reset() {
438     delete this.__linksURL;
439     this._removePrefsObserver();
440     this._removeObservers();
441   },
443   addObserver: function DirectoryLinksProvider_addObserver(aObserver) {
444     this._observers.add(aObserver);
445   },
447   removeObserver: function DirectoryLinksProvider_removeObserver(aObserver) {
448     this._observers.delete(aObserver);
449   },
451   _callObservers: function DirectoryLinksProvider__callObservers(aMethodName, aArg) {
452     for (let obs of this._observers) {
453       if (typeof(obs[aMethodName]) == "function") {
454         try {
455           obs[aMethodName](this, aArg);
456         } catch (err) {
457           Cu.reportError(err);
458         }
459       }
460     }
461   },
463   _removeObservers: function() {
464     this._observers.clear();
465   }