/* 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/. */

/*
 * This module implements the policy to block websites from being visited,
 * or to only allow certain websites to be visited.
 *
 * The blocklist takes as input an array of MatchPattern strings, as documented
 * at https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Match_patterns.
 *
 * The exceptions list takes the same as input. This list opens up
 * exceptions for rules on the blocklist that might be too strict.
 *
 * In addition to that, this allows the user to create an allowlist approach,
 * by using the special "<all_urls>" pattern for the blocklist, and then
 * adding all allowlisted websites on the exceptions list.
 *
 * Note that this module only blocks top-level website navigations and embeds.
 * It does not block any other accesses to these urls: image tags, scripts, XHR, etc.,
 * because that could cause unexpected breakage. This is a policy to block
 * users from visiting certain websites, and not from blocking any network
 * connections to those websites. If the admin is looking for that, the recommended
 * way is to configure that with extensions or through a company firewall.
 */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const LIST_LENGTH_LIMIT = 1000;

const PREF_LOGLEVEL = "browser.policies.loglevel";

const lazy = {};

XPCOMUtils.defineLazyGetter(lazy, "log", () => {
  let { ConsoleAPI } = ChromeUtils.importESModule(
    "resource://gre/modules/Console.sys.mjs"
  );
  return new ConsoleAPI({
    prefix: "WebsiteFilter Policy",
    // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
    // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
    maxLogLevel: "error",
    maxLogLevelPref: PREF_LOGLEVEL,
  });
});

export let WebsiteFilter = {
  init(blocklist, exceptionlist) {
    let blockArray = [],
      exceptionArray = [];

    for (let i = 0; i < blocklist.length && i < LIST_LENGTH_LIMIT; i++) {
      try {
        let pattern = new MatchPattern(blocklist[i].toLowerCase());
        blockArray.push(pattern);
        lazy.log.debug(
          `Pattern added to WebsiteFilter. Block: ${blocklist[i]}`
        );
      } catch (e) {
        lazy.log.error(
          `Invalid pattern on WebsiteFilter. Block: ${blocklist[i]}`
        );
      }
    }

    this._blockPatterns = new MatchPatternSet(blockArray);

    for (let i = 0; i < exceptionlist.length && i < LIST_LENGTH_LIMIT; i++) {
      try {
        let pattern = new MatchPattern(exceptionlist[i].toLowerCase());
        exceptionArray.push(pattern);
        lazy.log.debug(
          `Pattern added to WebsiteFilter. Exception: ${exceptionlist[i]}`
        );
      } catch (e) {
        lazy.log.error(
          `Invalid pattern on WebsiteFilter. Exception: ${exceptionlist[i]}`
        );
      }
    }

    if (exceptionArray.length) {
      this._exceptionsPatterns = new MatchPatternSet(exceptionArray);
    }

    let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);

    if (!registrar.isContractIDRegistered(this.contractID)) {
      registrar.registerFactory(
        this.classID,
        this.classDescription,
        this.contractID,
        this
      );

      Services.catMan.addCategoryEntry(
        "content-policy",
        this.contractID,
        this.contractID,
        false,
        true
      );
    }
    // We have to do this to catch 30X redirects.
    // See bug 456957.
    Services.obs.addObserver(this, "http-on-examine-response", true);
  },

  shouldLoad(contentLocation, loadInfo, mimeTypeGuess) {
    let contentType = loadInfo.externalContentPolicyType;
    let url = contentLocation.spec;
    if (contentLocation.scheme == "view-source") {
      url = contentLocation.pathQueryRef;
    } else if (url.toLowerCase().startsWith("about:reader")) {
      url = decodeURIComponent(
        url.toLowerCase().substr("about:reader?url=".length)
      );
    }
    if (
      contentType == Ci.nsIContentPolicy.TYPE_DOCUMENT ||
      contentType == Ci.nsIContentPolicy.TYPE_SUBDOCUMENT
    ) {
      if (this._blockPatterns.matches(url.toLowerCase())) {
        if (
          !this._exceptionsPatterns ||
          !this._exceptionsPatterns.matches(url.toLowerCase())
        ) {
          return Ci.nsIContentPolicy.REJECT_POLICY;
        }
      }
    }
    return Ci.nsIContentPolicy.ACCEPT;
  },
  shouldProcess(contentLocation, loadInfo, mimeTypeGuess) {
    return Ci.nsIContentPolicy.ACCEPT;
  },
  observe(subject, topic, data) {
    try {
      let channel = subject.QueryInterface(Ci.nsIHttpChannel);
      if (
        !channel.isDocument ||
        channel.responseStatus < 300 ||
        channel.responseStatus >= 400
      ) {
        return;
      }
      let location = channel.getResponseHeader("location");
      // location might not be a fully qualified URL
      let url;
      try {
        url = new URL(location);
      } catch (e) {
        url = new URL(location, channel.URI.spec);
      }
      if (this._blockPatterns.matches(url.href.toLowerCase())) {
        if (
          !this._exceptionsPatterns ||
          !this._exceptionsPatterns.matches(url.href.toLowerCase())
        ) {
          channel.cancel(Cr.NS_ERROR_BLOCKED_BY_POLICY);
        }
      }
    } catch (e) {}
  },
  classDescription: "Policy Engine File Content Policy",
  contractID: "@mozilla-org/policy-engine-file-content-policy-service;1",
  classID: Components.ID("{c0bbb557-813e-4e25-809d-b46a531a258f}"),
  QueryInterface: ChromeUtils.generateQI([
    "nsIContentPolicy",
    "nsIObserver",
    "nsISupportsWeakReference",
  ]),
  createInstance(iid) {
    return this.QueryInterface(iid);
  },
  isAllowed(url) {
    if (this._blockPatterns?.matches(url.toLowerCase())) {
      if (
        !this._exceptionsPatterns ||
        !this._exceptionsPatterns.matches(url.toLowerCase())
      ) {
        return false;
      }
    }
    return true;
  },
};
