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

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  ProxyChannelFilter: "resource://gre/modules/ProxyChannelFilter.sys.mjs",
});

// Delayed wakeup is tied to ExtensionParent.browserPaintedPromise, which is
// when the first browser window has been painted. On Android, parts of the
// browser can trigger requests without browser "window" (geckoview.xhtml).
// Therefore we allow such proxy events to trigger wakeup.
// On desktop, we do not wake up early, to minimize the amount of work before
// a browser window is painted.
XPCOMUtils.defineLazyPreferenceGetter(
  this,
  "isEarlyWakeupOnRequestEnabled",
  "extensions.webextensions.early_background_wakeup_on_request",
  false
);
var { ExtensionPreferencesManager } = ChromeUtils.importESModule(
  "resource://gre/modules/ExtensionPreferencesManager.sys.mjs"
);

var { ExtensionError } = ExtensionUtils;
var { getSettingsAPI } = ExtensionPreferencesManager;

const proxySvc = Ci.nsIProtocolProxyService;

const PROXY_TYPES_MAP = new Map([
  ["none", proxySvc.PROXYCONFIG_DIRECT],
  ["autoDetect", proxySvc.PROXYCONFIG_WPAD],
  ["system", proxySvc.PROXYCONFIG_SYSTEM],
  ["manual", proxySvc.PROXYCONFIG_MANUAL],
  ["autoConfig", proxySvc.PROXYCONFIG_PAC],
]);

const DEFAULT_PORTS = new Map([
  ["http", 80],
  ["ssl", 443],
  ["socks", 1080],
]);

ExtensionPreferencesManager.addSetting("proxy.settings", {
  permission: "proxy",
  prefNames: [
    "network.proxy.type",
    "network.proxy.http",
    "network.proxy.http_port",
    "network.proxy.share_proxy_settings",
    "network.proxy.ssl",
    "network.proxy.ssl_port",
    "network.proxy.socks",
    "network.proxy.socks_port",
    "network.proxy.socks_version",
    "network.proxy.socks_remote_dns",
    "network.proxy.socks5_remote_dns",
    "network.proxy.no_proxies_on",
    "network.proxy.autoconfig_url",
    "signon.autologin.proxy",
    "network.http.proxy.respect-be-conservative",
  ],

  setCallback(value) {
    let prefs = {
      "network.proxy.type": PROXY_TYPES_MAP.get(value.proxyType),
      "signon.autologin.proxy": value.autoLogin,
      "network.proxy.socks_remote_dns": value.proxyDNS,
      "network.proxy.socks5_remote_dns": value.proxyDNS,
      "network.proxy.autoconfig_url": value.autoConfigUrl,
      "network.proxy.share_proxy_settings": value.httpProxyAll,
      "network.proxy.socks_version": value.socksVersion,
      "network.proxy.no_proxies_on": value.passthrough,
      "network.http.proxy.respect-be-conservative": value.respectBeConservative,
    };

    for (let prop of ["http", "ssl", "socks"]) {
      if (value[prop]) {
        let url = new URL(`http://${value[prop]}`);
        prefs[`network.proxy.${prop}`] = url.hostname;
        // Only fall back to defaults if no port provided.
        let [, rawPort] = value[prop].split(":");
        let port = parseInt(rawPort, 10) || DEFAULT_PORTS.get(prop);
        prefs[`network.proxy.${prop}_port`] = port;
      }
    }

    return prefs;
  },
});

function registerProxyFilterEvent(
  context,
  extension,
  fire,
  filterProps,
  extraInfoSpec = []
) {
  let listener = data => {
    if (isEarlyWakeupOnRequestEnabled && fire.wakeup) {
      // Starts the background script if it has not started, no-op otherwise.
      // The default behavior of waking up via fire.sync() is insufficient
      // because that awaits ExtensionParent.browserPaintedPromise, but it is
      // possible to intercept system requests before that (bug 1870760).
      extension.emit("start-background-script");
    }
    return fire.sync(data);
  };

  let filter = { ...filterProps };
  if (filter.urls) {
    let perms = new MatchPatternSet([
      ...extension.allowedOrigins.patterns,
      ...extension.optionalOrigins.patterns,
    ]);
    filter.urls = new MatchPatternSet(filter.urls);

    if (!perms.overlapsAll(filter.urls)) {
      Cu.reportError(
        "The proxy.onRequest filter doesn't overlap with host permissions."
      );
    }
  }

  let proxyFilter = new ProxyChannelFilter(
    context,
    extension,
    listener,
    filter,
    extraInfoSpec
  );
  return {
    unregister: () => {
      proxyFilter.destroy();
    },
    convert(_fire, _context) {
      fire = _fire;
      proxyFilter.context = _context;
    },
  };
}

this.proxy = class extends ExtensionAPIPersistent {
  PERSISTENT_EVENTS = {
    onRequest({ fire, context }, params) {
      return registerProxyFilterEvent(context, this.extension, fire, ...params);
    },
  };

  getAPI(context) {
    let { extension } = context;
    let self = this;

    return {
      proxy: {
        onRequest: new EventManager({
          context,
          module: "proxy",
          event: "onRequest",
          extensionApi: self,
        }).api(),

        // Leaving as non-persistent. By itself it's not useful since proxy-error
        // is emitted from the proxy filter.
        onError: new EventManager({
          context,
          name: "proxy.onError",
          register: fire => {
            let listener = (name, error) => {
              fire.async(error);
            };
            extension.on("proxy-error", listener);
            return () => {
              extension.off("proxy-error", listener);
            };
          },
        }).api(),

        settings: Object.assign(
          getSettingsAPI({
            context,
            name: "proxy.settings",
            callback() {
              let prefValue = Services.prefs.getIntPref("network.proxy.type");
              let socksVersion = Services.prefs.getIntPref(
                "network.proxy.socks_version"
              );
              let proxyDNS;
              if (socksVersion == 4) {
                proxyDNS = Services.prefs.getBoolPref(
                  "network.proxy.socks_remote_dns"
                );
              } else {
                proxyDNS = Services.prefs.getBoolPref(
                  "network.proxy.socks5_remote_dns"
                );
              }

              let proxyConfig = {
                proxyType: Array.from(PROXY_TYPES_MAP.entries()).find(
                  entry => entry[1] === prefValue
                )[0],
                autoConfigUrl: Services.prefs.getCharPref(
                  "network.proxy.autoconfig_url"
                ),
                autoLogin: Services.prefs.getBoolPref("signon.autologin.proxy"),
                proxyDNS,
                httpProxyAll: Services.prefs.getBoolPref(
                  "network.proxy.share_proxy_settings"
                ),
                socksVersion,
                passthrough: Services.prefs.getCharPref(
                  "network.proxy.no_proxies_on"
                ),
              };

              if (extension.isPrivileged) {
                proxyConfig.respectBeConservative = Services.prefs.getBoolPref(
                  "network.http.proxy.respect-be-conservative"
                );
              }

              for (let prop of ["http", "ssl", "socks"]) {
                let host = Services.prefs.getCharPref(`network.proxy.${prop}`);
                let port = Services.prefs.getIntPref(
                  `network.proxy.${prop}_port`
                );
                proxyConfig[prop] = port ? `${host}:${port}` : host;
              }

              return proxyConfig;
            },
            // proxy.settings is unsupported on android.
            validate() {
              if (AppConstants.platform == "android") {
                throw new ExtensionError(
                  `proxy.settings is not supported on android.`
                );
              }
            },
          }),
          {
            set: details => {
              if (AppConstants.platform === "android") {
                throw new ExtensionError(
                  "proxy.settings is not supported on android."
                );
              }

              if (!extension.privateBrowsingAllowed) {
                throw new ExtensionError(
                  "proxy.settings requires private browsing permission."
                );
              }

              if (!Services.policies.isAllowed("changeProxySettings")) {
                throw new ExtensionError(
                  "Proxy settings are being managed by the Policies manager."
                );
              }

              let value = details.value;

              // proxyType is optional and it should default to "system" when missing.
              if (value.proxyType == null) {
                value.proxyType = "system";
              }

              if (!PROXY_TYPES_MAP.has(value.proxyType)) {
                throw new ExtensionError(
                  `${value.proxyType} is not a valid value for proxyType.`
                );
              }

              if (value.httpProxyAll) {
                // Match what about:preferences does with proxy settings
                // since the proxy service does not check the value
                // of share_proxy_settings.
                value.ssl = value.http;
              }

              for (let prop of ["http", "ssl", "socks"]) {
                let host = value[prop];
                if (host) {
                  let valid = true;
                  // Fixup in case a full url is passed.
                  if (host.includes("://")) {
                    host = URL.parse(host)?.host;
                    if (host) {
                      value[prop] = host;
                    } else {
                      valid = false;
                    }
                  } else {
                    // Validate the host value.
                    valid = URL.canParse(`http://${host}`);
                  }

                  if (!valid) {
                    throw new ExtensionError(
                      `${value[prop]} is not a valid value for ${prop}.`
                    );
                  }
                }
              }

              if (value.proxyType === "autoConfig" || value.autoConfigUrl) {
                if (!URL.canParse(value.autoConfigUrl)) {
                  throw new ExtensionError(
                    `${value.autoConfigUrl} is not a valid value for autoConfigUrl.`
                  );
                }
              }

              if (value.socksVersion !== undefined) {
                if (
                  !Number.isInteger(value.socksVersion) ||
                  value.socksVersion < 4 ||
                  value.socksVersion > 5
                ) {
                  throw new ExtensionError(
                    `${value.socksVersion} is not a valid value for socksVersion.`
                  );
                }
              }

              if (
                value.respectBeConservative !== undefined &&
                !extension.isPrivileged &&
                Services.prefs.getBoolPref(
                  "network.http.proxy.respect-be-conservative"
                ) != value.respectBeConservative
              ) {
                throw new ExtensionError(
                  `respectBeConservative can be set by privileged extensions only.`
                );
              }

              return ExtensionPreferencesManager.setSetting(
                extension.id,
                "proxy.settings",
                value
              );
            },
          }
        ),
      },
    };
  }
};
