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
5"use strict";
6
7/**
8 * This module exports a provider that offers search engine suggestions.
9 */
10
11var EXPORTED_SYMBOLS = ["UrlbarProviderSearchSuggestions"];
12
13const { XPCOMUtils } = ChromeUtils.import(
14  "resource://gre/modules/XPCOMUtils.jsm"
15);
16XPCOMUtils.defineLazyModuleGetters(this, {
17  Log: "resource://gre/modules/Log.jsm",
18  PlacesSearchAutocompleteProvider:
19    "resource://gre/modules/PlacesSearchAutocompleteProvider.jsm",
20  SearchSuggestionController:
21    "resource://gre/modules/SearchSuggestionController.jsm",
22  Services: "resource://gre/modules/Services.jsm",
23  SkippableTimer: "resource:///modules/UrlbarUtils.jsm",
24  UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
25  UrlbarProvider: "resource:///modules/UrlbarUtils.jsm",
26  UrlbarResult: "resource:///modules/UrlbarResult.jsm",
27  UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm",
28  UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
29});
30
31XPCOMUtils.defineLazyGetter(this, "logger", () =>
32  Log.repository.getLogger("Urlbar.Provider.SearchSuggestions")
33);
34
35/**
36 * Returns whether the passed in string looks like a url.
37 * @param {string} str
38 * @param {boolean} [ignoreAlphanumericHosts]
39 * @returns {boolean}
40 *   True if the query looks like a URL.
41 */
42function looksLikeUrl(str, ignoreAlphanumericHosts = false) {
43  // Single word including special chars.
44  return (
45    !UrlbarTokenizer.REGEXP_SPACES.test(str) &&
46    (["/", "@", ":", "["].some(c => str.includes(c)) ||
47      (ignoreAlphanumericHosts
48        ? /^([\[\]A-Z0-9-]+\.){3,}[^.]+$/i.test(str)
49        : str.includes(".")))
50  );
51}
52
53/**
54 * Returns the portion of a string starting at the index where another string
55 * begins.
56 *
57 * @param   {string} sourceStr
58 *          The string to search within.
59 * @param   {string} targetStr
60 *          The string to search for.
61 * @returns {string} The substring within sourceStr starting at targetStr, or
62 *          the empty string if targetStr does not occur in sourceStr.
63 */
64function substringAt(sourceStr, targetStr) {
65  let index = sourceStr.indexOf(targetStr);
66  return index < 0 ? "" : sourceStr.substr(index);
67}
68
69/**
70 * Returns the portion of a string starting at the index where another string
71 * ends.
72 *
73 * @param   {string} sourceStr
74 *          The string to search within.
75 * @param   {string} targetStr
76 *          The string to search for.
77 * @returns {string} The substring within sourceStr where targetStr ends, or the
78 *          empty string if targetStr does not occur in sourceStr.
79 */
80function substringAfter(sourceStr, targetStr) {
81  let index = sourceStr.indexOf(targetStr);
82  return index < 0 ? "" : sourceStr.substr(index + targetStr.length);
83}
84
85/**
86 * Class used to create the provider.
87 */
88class ProviderSearchSuggestions extends UrlbarProvider {
89  constructor() {
90    super();
91    // Maps the running queries by queryContext.
92    this.queries = new Map();
93  }
94
95  /**
96   * Returns the name of this provider.
97   * @returns {string} the name of this provider.
98   */
99  get name() {
100    return "SearchSuggestions";
101  }
102
103  /**
104   * Returns the type of this provider.
105   * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.*
106   */
107  get type() {
108    return UrlbarUtils.PROVIDER_TYPE.NETWORK;
109  }
110
111  /**
112   * Whether this provider should be invoked for the given context.
113   * If this method returns false, the providers manager won't start a query
114   * with this provider, to save on resources.
115   * @param {UrlbarQueryContext} queryContext The query context object
116   * @returns {boolean} Whether this provider should be invoked for the search.
117   */
118  isActive(queryContext) {
119    // If the sources don't include search or the user used a restriction
120    // character other than search, don't allow any suggestions.
121    if (
122      !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) ||
123      (queryContext.restrictSource &&
124        queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH)
125    ) {
126      return false;
127    }
128
129    // No suggestions for empty search strings.
130    if (!queryContext.searchString.trim()) {
131      return false;
132    }
133
134    return (
135      this._getFormHistoryCount(queryContext) ||
136      this._allowRemoteSuggestions(queryContext)
137    );
138  }
139
140  /**
141   * Returns whether the user typed a token alias or a restriction token. We use
142   * this value to override the pref to disable search suggestions in the
143   * Urlbar.
144   * @param {UrlbarQueryContext} queryContext  The query context object.
145   * @returns {boolean} True if the user typed a token alias or search
146   *   restriction token.
147   */
148  _isTokenOrRestrictionPresent(queryContext) {
149    return (
150      queryContext.searchString.startsWith("@") ||
151      (queryContext.restrictSource &&
152        queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) ||
153      queryContext.tokens.some(
154        t => t.type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH
155      )
156    );
157  }
158
159  /**
160   * Returns whether suggestions in general are allowed for a given query
161   * context.  If this returns false, then we shouldn't fetch either form
162   * history or remote suggestions.  Otherwise further checks are necessary to
163   * determine whether to allow either form history or remote suggestions; see
164   * _getFormHistoryCount and _allowRemoteSuggestions.
165   *
166   * @param {object} queryContext The query context object
167   * @returns {boolean} True if suggestions in general are allowed and false if
168   *   not.
169   */
170  _allowSuggestions(queryContext) {
171    if (
172      !queryContext.allowSearchSuggestions ||
173      // If the user typed a restriction token or token alias, we ignore the
174      // pref to disable suggestions in the Urlbar.
175      (!UrlbarPrefs.get("suggest.searches") &&
176        !this._isTokenOrRestrictionPresent(queryContext)) ||
177      !UrlbarPrefs.get("browser.search.suggest.enabled") ||
178      (queryContext.isPrivate &&
179        !UrlbarPrefs.get("browser.search.suggest.enabled.private"))
180    ) {
181      return false;
182    }
183    return true;
184  }
185
186  /**
187   * Returns whether remote suggestions are allowed for a given query context.
188   *
189   * @param {object} queryContext The query context object
190   * @returns {boolean} True if remote suggestions are allowed and false if not.
191   */
192  _allowRemoteSuggestions(queryContext) {
193    // Check whether suggestions in general are allowed.
194    if (!this._allowSuggestions(queryContext)) {
195      return false;
196    }
197
198    // Skip all remaining checks and allow remote suggestions at this point if
199    // the user used a token alias or restriction token. We want "@engine query"
200    // to return suggestions from the engine. We'll return early from startQuery
201    // if the query doesn't match an alias.
202    if (this._isTokenOrRestrictionPresent(queryContext)) {
203      return true;
204    }
205
206    // If the user is just adding on to a query that previously didn't return
207    // many remote suggestions, we are unlikely to get any more results.
208    if (
209      !!this._lastLowResultsSearchSuggestion &&
210      queryContext.searchString.length >
211        this._lastLowResultsSearchSuggestion.length &&
212      queryContext.searchString.startsWith(this._lastLowResultsSearchSuggestion)
213    ) {
214      return false;
215    }
216
217    // We're unlikely to get useful remote suggestions for single characters.
218    if (queryContext.searchString.length < 2) {
219      return false;
220    }
221
222    // Disallow remote suggestions if only an origin is typed to avoid
223    // disclosing information about sites the user visits.
224    if (
225      queryContext.tokens.length == 1 &&
226      queryContext.tokens[0].type == UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN
227    ) {
228      return false;
229    }
230
231    // Disallow remote suggestions for strings containing tokens that look like
232    // URLs or non-alphanumeric origins, to avoid disclosing information about
233    // networks or passwords.
234    if (
235      queryContext.tokens.some(
236        t =>
237          t.type == UrlbarTokenizer.TYPE.POSSIBLE_URL ||
238          (t.type == UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN &&
239            !UrlbarTokenizer.REGEXP_SINGLE_WORD_HOST.test(t.value))
240      )
241    ) {
242      return false;
243    }
244
245    // Allow remote suggestions.
246    return true;
247  }
248
249  /**
250   * Starts querying.
251   * @param {object} queryContext The query context object
252   * @param {function} addCallback Callback invoked by the provider to add a new
253   *        result.
254   * @returns {Promise} resolved when the query stops.
255   */
256  async startQuery(queryContext, addCallback) {
257    logger.info(`Starting query for ${queryContext.searchString}`);
258    let instance = {};
259    this.queries.set(queryContext, instance);
260
261    let trimmedOriginalSearchString = queryContext.searchString.trim();
262
263    let aliasEngine = await this._maybeGetAlias(queryContext);
264    if (!aliasEngine) {
265      // Autofill matches queries starting with "@" to token alias engines.
266      // If the string starts with "@", but an alias engine is not yet
267      // matched, then autofill might still be filtering token alias
268      // engine results. We don't want to mix search suggestions with those
269      // engine results, so we return early. See bug 1551049 comment 1 for
270      // discussion on how to improve this behavior.
271      if (queryContext.searchString.startsWith("@")) {
272        return;
273      }
274    }
275
276    let query = aliasEngine
277      ? aliasEngine.query
278      : substringAt(queryContext.searchString, queryContext.tokens[0].value);
279    if (!query) {
280      return;
281    }
282
283    let leadingRestrictionToken = null;
284    if (
285      UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) &&
286      (queryContext.tokens.length > 1 ||
287        queryContext.tokens[0].type == UrlbarTokenizer.TYPE.RESTRICT_SEARCH)
288    ) {
289      leadingRestrictionToken = queryContext.tokens[0].value;
290    }
291
292    // If the heuristic result is a search engine result with an empty query
293    // and we have either a token alias or the search restriction char, then
294    // we're done.
295    // For the restriction character case, also consider a single char query
296    // or just the char itself, anyway we don't return search suggestions
297    // unless at least 2 chars have been typed. Thus "?__" and "? a" should
298    // finish here, while "?aa" should continue.
299    let emptyQueryTokenAlias =
300      aliasEngine && aliasEngine.isTokenAlias && !aliasEngine.query;
301    let emptySearchRestriction =
302      trimmedOriginalSearchString.length <= 3 &&
303      leadingRestrictionToken == UrlbarTokenizer.RESTRICT.SEARCH &&
304      /\s*\S?$/.test(trimmedOriginalSearchString);
305    if (emptySearchRestriction || emptyQueryTokenAlias) {
306      return;
307    }
308
309    // Strip a leading search restriction char, because we prepend it to text
310    // when the search shortcut is used and it's not user typed. Don't strip
311    // other restriction chars, so that it's possible to search for things
312    // including one of those (e.g. "c#").
313    if (leadingRestrictionToken === UrlbarTokenizer.RESTRICT.SEARCH) {
314      query = substringAfter(query, leadingRestrictionToken).trim();
315    }
316
317    // Find our search engine. It may have already been set with an alias.
318    let engine;
319    if (aliasEngine) {
320      engine = aliasEngine.engine;
321    } else {
322      engine = queryContext.engineName
323        ? Services.search.getEngineByName(queryContext.engineName)
324        : await PlacesSearchAutocompleteProvider.currentEngine(
325            queryContext.isPrivate
326          );
327      if (!engine) {
328        return;
329      }
330    }
331
332    let alias = (aliasEngine && aliasEngine.alias) || "";
333    let results = await this._fetchSearchSuggestions(
334      queryContext,
335      engine,
336      query,
337      alias
338    );
339
340    if (!results || !this.queries.has(queryContext)) {
341      return;
342    }
343
344    for (let result of results) {
345      addCallback(this, result);
346    }
347
348    this.queries.delete(queryContext);
349  }
350
351  /**
352   * Gets the provider's priority.
353   * @param {UrlbarQueryContext} queryContext The query context object
354   * @returns {number} The provider's priority for the given query.
355   */
356  getPriority(queryContext) {
357    return 0;
358  }
359
360  /**
361   * Cancels a running query.
362   * @param {object} queryContext The query context object
363   */
364  cancelQuery(queryContext) {
365    logger.info(`Canceling query for ${queryContext.searchString}`);
366
367    if (this._suggestionsController) {
368      this._suggestionsController.stop();
369      this._suggestionsController = null;
370    }
371
372    this.queries.delete(queryContext);
373  }
374
375  /**
376   * Returns the number of form history entries we should fetch from the
377   * suggestions controller for a given query context.
378   *
379   * @param {object} queryContext The query context object
380   * @returns {number} The number of form history results we should fetch.
381   */
382  _getFormHistoryCount(queryContext) {
383    if (!this._allowSuggestions(queryContext)) {
384      return 0;
385    }
386
387    let count = UrlbarPrefs.get("maxHistoricalSearchSuggestions");
388    if (!count) {
389      return 0;
390    }
391
392    // If there's a form history entry that equals the search string, the search
393    // suggestions controller will include it, and we'll make a result for it.
394    // If the heuristic result ends up being a search result, the muxer will
395    // exclude the form history result since it dupes the heuristic, and the
396    // final list of results would be left with `count` - 1 form history results
397    // instead of `count`.  Therefore we request `count` + 1 entries.  The muxer
398    // will dedupe and limit the final form history count as appropriate.
399    return count + 1;
400  }
401
402  async _fetchSearchSuggestions(queryContext, engine, searchString, alias) {
403    if (!engine || !searchString) {
404      return null;
405    }
406
407    this._suggestionsController = new SearchSuggestionController();
408    this._suggestionsController.formHistoryParam = queryContext.formHistoryName;
409    this._suggestionsController.maxLocalResults = this._getFormHistoryCount(
410      queryContext
411    );
412
413    let allowRemote = this._allowRemoteSuggestions(queryContext);
414
415    // Request maxResults + 1 remote suggestions for the same reason we request
416    // maxHistoricalSearchSuggestions + 1 form history entries; see
417    // _getFormHistoryCount.  We allow for the possibility that the engine may
418    // return a suggestion that's the same as the search string.
419    this._suggestionsController.maxRemoteResults = allowRemote
420      ? queryContext.maxResults + 1
421      : 0;
422
423    this._suggestionsFetchCompletePromise = this._suggestionsController.fetch(
424      searchString,
425      queryContext.isPrivate,
426      engine,
427      queryContext.userContextId
428    );
429
430    // See `SearchSuggestionsController.fetch` documentation for a description
431    // of `fetchData`.
432    let fetchData = await this._suggestionsFetchCompletePromise;
433    // The fetch was canceled.
434    if (!fetchData) {
435      return null;
436    }
437
438    let results = [];
439
440    for (let entry of fetchData.local) {
441      results.push(
442        new UrlbarResult(
443          UrlbarUtils.RESULT_TYPE.SEARCH,
444          UrlbarUtils.RESULT_SOURCE.HISTORY,
445          ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
446            engine: engine.name,
447            suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED],
448            lowerCaseSuggestion: entry.value.toLocaleLowerCase(),
449          })
450        )
451      );
452    }
453
454    // If we don't return many results, then keep track of the query. If the
455    // user just adds on to the query, we won't fetch more suggestions if the
456    // query is very long since we are unlikely to get any.
457    if (
458      allowRemote &&
459      !fetchData.remote.length &&
460      searchString.length > UrlbarPrefs.get("maxCharsForSearchSuggestions")
461    ) {
462      this._lastLowResultsSearchSuggestion = searchString;
463    }
464
465    // If we have only tail suggestions, we only show them if we have no other
466    // results. We need to wait for other results to arrive to avoid flickering.
467    // We will wait for this timer unless we have suggestions that don't have a
468    // tail.
469    let tailTimer = new SkippableTimer({
470      name: "ProviderSearchSuggestions",
471      time: 100,
472      logger,
473    });
474
475    for (let entry of fetchData.remote) {
476      if (looksLikeUrl(entry.value)) {
477        continue;
478      }
479
480      if (entry.tail && entry.tailOffsetIndex < 0) {
481        Cu.reportError(
482          `Error in tail suggestion parsing. Value: ${entry.value}, tail: ${entry.tail}.`
483        );
484        continue;
485      }
486
487      let tail = entry.tail;
488      let tailPrefix = entry.matchPrefix;
489
490      // Skip tail suggestions if the pref is disabled.
491      if (tail && !UrlbarPrefs.get("richSuggestions.tail")) {
492        continue;
493      }
494
495      if (!tail) {
496        await tailTimer.fire().catch(Cu.reportError);
497      }
498
499      try {
500        results.push(
501          new UrlbarResult(
502            UrlbarUtils.RESULT_TYPE.SEARCH,
503            UrlbarUtils.RESULT_SOURCE.SEARCH,
504            ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
505              engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED],
506              suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED],
507              lowerCaseSuggestion: entry.value.toLocaleLowerCase(),
508              tailPrefix,
509              tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED],
510              tailOffsetIndex: entry.tailOffsetIndex,
511              keyword: [alias ? alias : undefined, UrlbarUtils.HIGHLIGHT.TYPED],
512              query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE],
513              isSearchHistory: false,
514              icon: [engine.iconURI && !entry.value ? engine.iconURI.spec : ""],
515              keywordOffer: UrlbarUtils.KEYWORD_OFFER.NONE,
516            })
517          )
518        );
519      } catch (err) {
520        Cu.reportError(err);
521        continue;
522      }
523    }
524
525    await tailTimer.promise;
526    return results;
527  }
528
529  /**
530   * Searches for an engine alias given the queryContext.
531   * @param {UrlbarQueryContext} queryContext
532   * @returns {object} aliasEngine
533   *   A representation of the aliased engine. Null if there's no match.
534   * @returns {nsISearchEngine} aliasEngine.engine
535   * @returns {string} aliasEngine.alias
536   * @returns {string} aliasEngine.query
537   * @returns {boolean} aliasEngine.isTokenAlias
538   *
539   */
540  async _maybeGetAlias(queryContext) {
541    if (
542      queryContext.restrictSource &&
543      queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH &&
544      queryContext.engineName &&
545      !queryContext.searchString.startsWith("@")
546    ) {
547      // If an engineName was passed in from the queryContext in restrict mode,
548      // we'll set our engine in startQuery based on engineName.
549      return null;
550    }
551
552    let possibleAlias = queryContext.tokens[0]?.value.trim();
553    // The "@" character on its own is handled by UnifiedComplete and returns a
554    // list of every available token alias.
555    if (!possibleAlias || possibleAlias == "@") {
556      return null;
557    }
558
559    // Check if the user entered an engine alias directly.
560    let engineMatch = await PlacesSearchAutocompleteProvider.engineForAlias(
561      possibleAlias
562    );
563    if (engineMatch) {
564      return {
565        engine: engineMatch,
566        alias: possibleAlias,
567        query: substringAfter(queryContext.searchString, possibleAlias).trim(),
568        isTokenAlias: possibleAlias.startsWith("@"),
569      };
570    }
571
572    // Check if the user is matching a token alias.
573    let engines = await PlacesSearchAutocompleteProvider.tokenAliasEngines();
574    if (!engines || !engines.length) {
575      return null;
576    }
577
578    for (let { engine, tokenAliases } of engines) {
579      if (tokenAliases.includes(possibleAlias)) {
580        return {
581          engine,
582          alias: possibleAlias,
583          query: substringAfter(
584            queryContext.searchString,
585            possibleAlias
586          ).trim(),
587          isTokenAlias: true,
588        };
589      }
590    }
591    return null;
592  }
593}
594
595var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions();
596