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
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4"use strict";
5
6const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
7const { XPCOMUtils } = ChromeUtils.import(
8  "resource://gre/modules/XPCOMUtils.jsm"
9);
10
11XPCOMUtils.defineLazyModuleGetters(this, {
12  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
13  EveryWindow: "resource:///modules/EveryWindow.jsm",
14  AboutReaderParent: "resource:///actors/AboutReaderParent.jsm",
15});
16
17const FEW_MINUTES = 15 * 60 * 1000; // 15 mins
18
19function isPrivateWindow(win) {
20  return (
21    !(win instanceof Ci.nsIDOMWindow) ||
22    win.closed ||
23    PrivateBrowsingUtils.isWindowPrivate(win)
24  );
25}
26
27/**
28 * Check current location against the list of allowed hosts
29 * Additionally verify for redirects and check original request URL against
30 * the list.
31 *
32 * @returns {object} - {host, url} pair that matched the list of allowed hosts
33 */
34function checkURLMatch(aLocationURI, { hosts, matchPatternSet }, aRequest) {
35  // If checks pass we return a match
36  let match;
37  try {
38    match = { host: aLocationURI.host, url: aLocationURI.spec };
39  } catch (e) {
40    // nsIURI.host can throw for non-nsStandardURL nsIURIs
41    return false;
42  }
43
44  // Check current location against allowed hosts
45  if (hosts.has(match.host)) {
46    return match;
47  }
48
49  if (matchPatternSet) {
50    if (matchPatternSet.matches(match.url)) {
51      return match;
52    }
53  }
54
55  // Nothing else to check, return early
56  if (!aRequest) {
57    return false;
58  }
59
60  // The original URL at the start of the request
61  const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI;
62  // We have been redirected
63  if (originalLocation.spec !== aLocationURI.spec) {
64    return (
65      hosts.has(originalLocation.host) && {
66        host: originalLocation.host,
67        url: originalLocation.spec,
68      }
69    );
70  }
71
72  return false;
73}
74
75function createMatchPatternSet(patterns, flags) {
76  try {
77    return new MatchPatternSet(new Set(patterns), flags);
78  } catch (e) {
79    Cu.reportError(e);
80  }
81  return new MatchPatternSet([]);
82}
83
84/**
85 * A Map from trigger IDs to singleton trigger listeners. Each listener must
86 * have idempotent `init` and `uninit` methods.
87 */
88this.ASRouterTriggerListeners = new Map([
89  [
90    "openArticleURL",
91    {
92      id: "openArticleURL",
93      _initialized: false,
94      _triggerHandler: null,
95      _hosts: new Set(),
96      _matchPatternSet: null,
97      readerModeEvent: "Reader:UpdateReaderButton",
98
99      init(triggerHandler, hosts, patterns) {
100        if (!this._initialized) {
101          this.receiveMessage = this.receiveMessage.bind(this);
102          AboutReaderParent.addMessageListener(this.readerModeEvent, this);
103          this._triggerHandler = triggerHandler;
104          this._initialized = true;
105        }
106        if (patterns) {
107          this._matchPatternSet = createMatchPatternSet([
108            ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
109            ...patterns,
110          ]);
111        }
112        if (hosts) {
113          hosts.forEach(h => this._hosts.add(h));
114        }
115      },
116
117      receiveMessage({ data, target }) {
118        if (data && data.isArticle) {
119          const match = checkURLMatch(target.currentURI, {
120            hosts: this._hosts,
121            matchPatternSet: this._matchPatternSet,
122          });
123          if (match) {
124            this._triggerHandler(target, { id: this.id, param: match });
125          }
126        }
127      },
128
129      uninit() {
130        if (this._initialized) {
131          AboutReaderParent.removeMessageListener(this.readerModeEvent, this);
132          this._initialized = false;
133          this._triggerHandler = null;
134          this._hosts = new Set();
135          this._matchPatternSet = null;
136        }
137      },
138    },
139  ],
140  [
141    "openBookmarkedURL",
142    {
143      id: "openBookmarkedURL",
144      _initialized: false,
145      _triggerHandler: null,
146      _hosts: new Set(),
147      bookmarkEvent: "bookmark-icon-updated",
148
149      init(triggerHandler) {
150        if (!this._initialized) {
151          Services.obs.addObserver(this, this.bookmarkEvent);
152          this._triggerHandler = triggerHandler;
153          this._initialized = true;
154        }
155      },
156
157      observe(subject, topic, data) {
158        if (topic === this.bookmarkEvent && data === "starred") {
159          const browser = Services.wm.getMostRecentBrowserWindow();
160          if (browser) {
161            this._triggerHandler(browser.gBrowser.selectedBrowser, {
162              id: this.id,
163            });
164          }
165        }
166      },
167
168      uninit() {
169        if (this._initialized) {
170          Services.obs.removeObserver(this, this.bookmarkEvent);
171          this._initialized = false;
172          this._triggerHandler = null;
173          this._hosts = new Set();
174        }
175      },
176    },
177  ],
178  [
179    "frequentVisits",
180    {
181      id: "frequentVisits",
182      _initialized: false,
183      _triggerHandler: null,
184      _hosts: null,
185      _matchPatternSet: null,
186      _visits: null,
187
188      init(triggerHandler, hosts = [], patterns) {
189        if (!this._initialized) {
190          this.onTabSwitch = this.onTabSwitch.bind(this);
191          EveryWindow.registerCallback(
192            this.id,
193            win => {
194              if (!isPrivateWindow(win)) {
195                win.addEventListener("TabSelect", this.onTabSwitch);
196                win.gBrowser.addTabsProgressListener(this);
197              }
198            },
199            win => {
200              if (!isPrivateWindow(win)) {
201                win.removeEventListener("TabSelect", this.onTabSwitch);
202                win.gBrowser.removeTabsProgressListener(this);
203              }
204            }
205          );
206          this._visits = new Map();
207          this._initialized = true;
208        }
209        this._triggerHandler = triggerHandler;
210        if (patterns) {
211          this._matchPatternSet = createMatchPatternSet([
212            ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
213            ...patterns,
214          ]);
215        }
216        if (this._hosts) {
217          hosts.forEach(h => this._hosts.add(h));
218        } else {
219          this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
220        }
221      },
222
223      /* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only
224       * if it's been more than FEW_MINUTES since the last visit.
225       * @param {string} host - Location host of current selected tab
226       * @returns {boolean} - If the new visit has been recorded
227       */
228      _updateVisits(host) {
229        const visits = this._visits.get(host);
230
231        if (visits && Date.now() - visits[0] > FEW_MINUTES) {
232          this._visits.set(host, [Date.now(), ...visits]);
233          return true;
234        }
235        if (!visits) {
236          this._visits.set(host, [Date.now()]);
237          return true;
238        }
239
240        return false;
241      },
242
243      onTabSwitch(event) {
244        if (!event.target.ownerGlobal.gBrowser) {
245          return;
246        }
247
248        const { gBrowser } = event.target.ownerGlobal;
249        const match = checkURLMatch(gBrowser.currentURI, {
250          hosts: this._hosts,
251          matchPatternSet: this._matchPatternSet,
252        });
253        if (match) {
254          this.triggerHandler(gBrowser.selectedBrowser, match);
255        }
256      },
257
258      triggerHandler(aBrowser, match) {
259        const updated = this._updateVisits(match.host);
260
261        // If the previous visit happend less than FEW_MINUTES ago
262        // no updates were made, no need to trigger the handler
263        if (!updated) {
264          return;
265        }
266
267        this._triggerHandler(aBrowser, {
268          id: this.id,
269          param: match,
270          context: {
271            // Remapped to {host, timestamp} because JEXL operators can only
272            // filter over collections (arrays of objects)
273            recentVisits: this._visits
274              .get(match.host)
275              .map(timestamp => ({ host: match.host, timestamp })),
276          },
277        });
278      },
279
280      onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
281        // Some websites trigger redirect events after they finish loading even
282        // though the location remains the same. This results in onLocationChange
283        // events to be fired twice.
284        const isSameDocument = !!(
285          aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
286        );
287        if (aWebProgress.isTopLevel && !isSameDocument) {
288          const match = checkURLMatch(
289            aLocationURI,
290            { hosts: this._hosts, matchPatternSet: this._matchPatternSet },
291            aRequest
292          );
293          if (match) {
294            this.triggerHandler(aBrowser, match);
295          }
296        }
297      },
298
299      uninit() {
300        if (this._initialized) {
301          EveryWindow.unregisterCallback(this.id);
302
303          this._initialized = false;
304          this._triggerHandler = null;
305          this._hosts = null;
306          this._matchPatternSet = null;
307          this._visits = null;
308        }
309      },
310    },
311  ],
312
313  /**
314   * Attach listeners to every browser window to detect location changes, and
315   * notify the trigger handler whenever we navigate to a URL with a hostname
316   * we're looking for.
317   */
318  [
319    "openURL",
320    {
321      id: "openURL",
322      _initialized: false,
323      _triggerHandler: null,
324      _hosts: null,
325      _matchPatternSet: null,
326      _visits: null,
327
328      /*
329       * If the listener is already initialised, `init` will replace the trigger
330       * handler and add any new hosts to `this._hosts`.
331       */
332      init(triggerHandler, hosts = [], patterns) {
333        if (!this._initialized) {
334          this.onLocationChange = this.onLocationChange.bind(this);
335          EveryWindow.registerCallback(
336            this.id,
337            win => {
338              if (!isPrivateWindow(win)) {
339                win.addEventListener("TabSelect", this.onTabSwitch);
340                win.gBrowser.addTabsProgressListener(this);
341              }
342            },
343            win => {
344              if (!isPrivateWindow(win)) {
345                win.removeEventListener("TabSelect", this.onTabSwitch);
346                win.gBrowser.removeTabsProgressListener(this);
347              }
348            }
349          );
350
351          this._visits = new Map();
352          this._initialized = true;
353        }
354        this._triggerHandler = triggerHandler;
355        if (patterns) {
356          this._matchPatternSet = createMatchPatternSet([
357            ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
358            ...patterns,
359          ]);
360        }
361        if (this._hosts) {
362          hosts.forEach(h => this._hosts.add(h));
363        } else {
364          this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
365        }
366      },
367
368      uninit() {
369        if (this._initialized) {
370          EveryWindow.unregisterCallback(this.id);
371
372          this._initialized = false;
373          this._triggerHandler = null;
374          this._hosts = null;
375          this._matchPatternSet = null;
376          this._visits = null;
377        }
378      },
379
380      onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
381        // Some websites trigger redirect events after they finish loading even
382        // though the location remains the same. This results in onLocationChange
383        // events to be fired twice.
384        const isSameDocument = !!(
385          aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
386        );
387        if (aWebProgress.isTopLevel && !isSameDocument) {
388          const match = checkURLMatch(
389            aLocationURI,
390            { hosts: this._hosts, matchPatternSet: this._matchPatternSet },
391            aRequest
392          );
393          if (match) {
394            let visitsCount = (this._visits.get(match.url) || 0) + 1;
395            this._visits.set(match.url, visitsCount);
396            this._triggerHandler(aBrowser, {
397              id: this.id,
398              param: match,
399              context: { visitsCount },
400            });
401          }
402        }
403      },
404    },
405  ],
406
407  /**
408   * Add an observer notification to notify the trigger handler whenever the user
409   * saves or updates a login via the login capture doorhanger.
410   */
411  [
412    "newSavedLogin",
413    {
414      _initialized: false,
415      _triggerHandler: null,
416
417      /**
418       * If the listener is already initialised, `init` will replace the trigger
419       * handler.
420       */
421      init(triggerHandler) {
422        if (!this._initialized) {
423          Services.obs.addObserver(this, "LoginStats:NewSavedPassword");
424          Services.obs.addObserver(this, "LoginStats:LoginUpdateSaved");
425          this._initialized = true;
426        }
427        this._triggerHandler = triggerHandler;
428      },
429
430      uninit() {
431        if (this._initialized) {
432          Services.obs.removeObserver(this, "LoginStats:NewSavedPassword");
433          Services.obs.removeObserver(this, "LoginStats:LoginUpdateSaved");
434
435          this._initialized = false;
436          this._triggerHandler = null;
437        }
438      },
439
440      observe(aSubject, aTopic, aData) {
441        if (aSubject.currentURI.asciiHost === "accounts.firefox.com") {
442          // Don't notify about saved logins on the FxA login origin since this
443          // trigger is used to promote login Sync and getting a recommendation
444          // to enable Sync during the sign up process is a bad UX.
445          return;
446        }
447
448        switch (aTopic) {
449          case "LoginStats:NewSavedPassword": {
450            this._triggerHandler(aSubject, {
451              id: "newSavedLogin",
452              context: { type: "save" },
453            });
454            break;
455          }
456          case "LoginStats:LoginUpdateSaved": {
457            this._triggerHandler(aSubject, {
458              id: "newSavedLogin",
459              context: { type: "update" },
460            });
461            break;
462          }
463          default: {
464            throw new Error(`Unexpected observer notification: ${aTopic}`);
465          }
466        }
467      },
468    },
469  ],
470
471  [
472    "contentBlocking",
473    {
474      _initialized: false,
475      _triggerHandler: null,
476      _events: [],
477      _sessionPageLoad: 0,
478      onLocationChange: null,
479
480      init(triggerHandler, params, patterns) {
481        params.forEach(p => this._events.push(p));
482
483        if (!this._initialized) {
484          Services.obs.addObserver(this, "SiteProtection:ContentBlockingEvent");
485          Services.obs.addObserver(
486            this,
487            "SiteProtection:ContentBlockingMilestone"
488          );
489          this.onLocationChange = this._onLocationChange.bind(this);
490          EveryWindow.registerCallback(
491            this.id,
492            win => {
493              if (!isPrivateWindow(win)) {
494                win.gBrowser.addTabsProgressListener(this);
495              }
496            },
497            win => {
498              if (!isPrivateWindow(win)) {
499                win.gBrowser.removeTabsProgressListener(this);
500              }
501            }
502          );
503
504          this._initialized = true;
505        }
506        this._triggerHandler = triggerHandler;
507      },
508
509      uninit() {
510        if (this._initialized) {
511          Services.obs.removeObserver(
512            this,
513            "SiteProtection:ContentBlockingEvent"
514          );
515          Services.obs.removeObserver(
516            this,
517            "SiteProtection:ContentBlockingMilestone"
518          );
519          EveryWindow.unregisterCallback(this.id);
520          this.onLocationChange = null;
521          this._initialized = false;
522        }
523        this._triggerHandler = null;
524        this._events = [];
525        this._sessionPageLoad = 0;
526      },
527
528      observe(aSubject, aTopic, aData) {
529        switch (aTopic) {
530          case "SiteProtection:ContentBlockingEvent":
531            const { browser, host, event } = aSubject.wrappedJSObject;
532            if (this._events.filter(e => (e & event) === e).length) {
533              this._triggerHandler(browser, {
534                id: "contentBlocking",
535                param: {
536                  host,
537                  type: event,
538                },
539                context: {
540                  pageLoad: this._sessionPageLoad,
541                },
542              });
543            }
544            break;
545          case "SiteProtection:ContentBlockingMilestone":
546            if (this._events.includes(aSubject.wrappedJSObject.event)) {
547              this._triggerHandler(
548                Services.wm.getMostRecentBrowserWindow().gBrowser
549                  .selectedBrowser,
550                {
551                  id: "contentBlocking",
552                  context: {
553                    pageLoad: this._sessionPageLoad,
554                  },
555                  param: {
556                    type: aSubject.wrappedJSObject.event,
557                  },
558                }
559              );
560            }
561            break;
562        }
563      },
564
565      _onLocationChange(
566        aBrowser,
567        aWebProgress,
568        aRequest,
569        aLocationURI,
570        aFlags
571      ) {
572        // Some websites trigger redirect events after they finish loading even
573        // though the location remains the same. This results in onLocationChange
574        // events to be fired twice.
575        const isSameDocument = !!(
576          aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
577        );
578        if (
579          ["http", "https"].includes(aLocationURI.scheme) &&
580          aWebProgress.isTopLevel &&
581          !isSameDocument
582        ) {
583          this._sessionPageLoad += 1;
584        }
585      },
586    },
587  ],
588]);
589
590const EXPORTED_SYMBOLS = ["ASRouterTriggerListeners"];
591