"use strict";

const {interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://gre/modules/ExtensionUtils.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService",
                                  "resource://gre/modules/ContextualIdentityService.jsm");

var {
  EventManager,
} = ExtensionUtils;

var DEFAULT_STORE = "firefox-default";
var PRIVATE_STORE = "firefox-private";
var CONTAINER_STORE = "firefox-container-";

global.getCookieStoreIdForTab = function(data, tab) {
  if (data.incognito) {
    return PRIVATE_STORE;
  }

  if (tab.userContextId) {
    return CONTAINER_STORE + tab.userContextId;
  }

  return DEFAULT_STORE;
};

global.isPrivateCookieStoreId = function(storeId) {
  return storeId == PRIVATE_STORE;
};

global.isDefaultCookieStoreId = function(storeId) {
  return storeId == DEFAULT_STORE;
};

global.isContainerCookieStoreId = function(storeId) {
  return storeId !== null && storeId.startsWith(CONTAINER_STORE);
};

global.getContainerForCookieStoreId = function(storeId) {
  if (!global.isContainerCookieStoreId(storeId)) {
    return null;
  }

  let containerId = storeId.substring(CONTAINER_STORE.length);
  if (ContextualIdentityService.getIdentityFromId(containerId)) {
    return parseInt(containerId, 10);
  }

  return null;
};

global.isValidCookieStoreId = function(storeId) {
  return global.isDefaultCookieStoreId(storeId) ||
         global.isPrivateCookieStoreId(storeId) ||
         global.isContainerCookieStoreId(storeId);
};

function convert({cookie, isPrivate}) {
  let result = {
    name: cookie.name,
    value: cookie.value,
    domain: cookie.host,
    hostOnly: !cookie.isDomain,
    path: cookie.path,
    secure: cookie.isSecure,
    httpOnly: cookie.isHttpOnly,
    session: cookie.isSession,
  };

  if (!cookie.isSession) {
    result.expirationDate = cookie.expiry;
  }

  if (cookie.originAttributes.userContextId) {
    result.storeId = CONTAINER_STORE + cookie.originAttributes.userContextId;
  } else if (cookie.originAttributes.privateBrowsingId || isPrivate) {
    result.storeId = PRIVATE_STORE;
  } else {
    result.storeId = DEFAULT_STORE;
  }

  return result;
}

function isSubdomain(otherDomain, baseDomain) {
  return otherDomain == baseDomain || otherDomain.endsWith("." + baseDomain);
}

// Checks that the given extension has permission to set the given cookie for
// the given URI.
function checkSetCookiePermissions(extension, uri, cookie) {
  // Permission checks:
  //
  //  - If the extension does not have permissions for the specified
  //    URL, it cannot set cookies for it.
  //
  //  - If the specified URL could not set the given cookie, neither can
  //    the extension.
  //
  // Ideally, we would just have the cookie service make the latter
  // determination, but that turns out to be quite complicated. At the
  // moment, it requires constructing a cookie string and creating a
  // dummy channel, both of which can be problematic. It also triggers
  // a whole set of additional permission and preference checks, which
  // may or may not be desirable.
  //
  // So instead, we do a similar set of checks here. Exactly what
  // cookies a given URL should be able to set is not well-documented,
  // and is not standardized in any standard that anyone actually
  // follows. So instead, we follow the rules used by the cookie
  // service.
  //
  // See source/netwerk/cookie/nsCookieService.cpp, in particular
  // CheckDomain() and SetCookieInternal().

  if (uri.scheme != "http" && uri.scheme != "https") {
    return false;
  }

  if (!extension.whiteListedHosts.matchesIgnoringPath(uri)) {
    return false;
  }

  if (!cookie.host) {
    // If no explicit host is specified, this becomes a host-only cookie.
    cookie.host = uri.host;
    return true;
  }

  // A leading "." is not expected, but is tolerated if it's not the only
  // character in the host. If there is one, start by stripping it off. We'll
  // add a new one on success.
  if (cookie.host.length > 1) {
    cookie.host = cookie.host.replace(/^\./, "");
  }
  cookie.host = cookie.host.toLowerCase();

  if (cookie.host != uri.host) {
    // Not an exact match, so check for a valid subdomain.
    let baseDomain;
    try {
      baseDomain = Services.eTLD.getBaseDomain(uri);
    } catch (e) {
      if (e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS ||
          e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS) {
        // The cookie service uses these to determine whether the domain
        // requires an exact match. We already know we don't have an exact
        // match, so return false. In all other cases, re-raise the error.
        return false;
      }
      throw e;
    }

    // The cookie domain must be a subdomain of the base domain. This prevents
    // us from setting cookies for domains like ".co.uk".
    // The domain of the requesting URL must likewise be a subdomain of the
    // cookie domain. This prevents us from setting cookies for entirely
    // unrelated domains.
    if (!isSubdomain(cookie.host, baseDomain) ||
        !isSubdomain(uri.host, cookie.host)) {
      return false;
    }

    // RFC2109 suggests that we may only add cookies for sub-domains 1-level
    // below us, but enforcing that would break the web, so we don't.
  }

  // An explicit domain was passed, so add a leading "." to make this a
  // domain cookie.
  cookie.host = "." + cookie.host;

  // We don't do any significant checking of path permissions. RFC2109
  // suggests we only allow sites to add cookies for sub-paths, similar to
  // same origin policy enforcement, but no-one implements this.

  return true;
}

function* query(detailsIn, props, context) {
  // Different callers want to filter on different properties. |props|
  // tells us which ones they're interested in.
  let details = {};
  props.forEach(property => {
    if (detailsIn[property] !== null) {
      details[property] = detailsIn[property];
    }
  });

  if ("domain" in details) {
    details.domain = details.domain.toLowerCase().replace(/^\./, "");
  }

  let userContextId = 0;
  let isPrivate = context.incognito;
  if (details.storeId) {
    if (!global.isValidCookieStoreId(details.storeId)) {
      return;
    }

    if (global.isDefaultCookieStoreId(details.storeId)) {
      isPrivate = false;
    } else if (global.isPrivateCookieStoreId(details.storeId)) {
      isPrivate = true;
    } else if (global.isContainerCookieStoreId(details.storeId)) {
      isPrivate = false;
      userContextId = global.getContainerForCookieStoreId(details.storeId);
      if (!userContextId) {
        return;
      }
    }
  }

  let storeId = DEFAULT_STORE;
  if (isPrivate) {
    storeId = PRIVATE_STORE;
  } else if ("storeId" in details) {
    storeId = details.storeId;
  }

  // We can use getCookiesFromHost for faster searching.
  let enumerator;
  let uri;
  if ("url" in details) {
    try {
      uri = NetUtil.newURI(details.url).QueryInterface(Ci.nsIURL);
      Services.cookies.usePrivateMode(isPrivate, () => {
        enumerator = Services.cookies.getCookiesFromHost(uri.host, {userContextId});
      });
    } catch (ex) {
      // This often happens for about: URLs
      return;
    }
  } else if ("domain" in details) {
    Services.cookies.usePrivateMode(isPrivate, () => {
      enumerator = Services.cookies.getCookiesFromHost(details.domain, {userContextId});
    });
  } else {
    Services.cookies.usePrivateMode(isPrivate, () => {
      enumerator = Services.cookies.enumerator;
    });
  }

  // Based on nsCookieService::GetCookieStringInternal
  function matches(cookie) {
    function domainMatches(host) {
      return cookie.rawHost == host || (cookie.isDomain && host.endsWith(cookie.host));
    }

    function pathMatches(path) {
      let cookiePath = cookie.path.replace(/\/$/, "");

      if (!path.startsWith(cookiePath)) {
        return false;
      }

      // path == cookiePath, but without the redundant string compare.
      if (path.length == cookiePath.length) {
        return true;
      }

      // URL path is a substring of the cookie path, so it matches if, and
      // only if, the next character is a path delimiter.
      let pathDelimiters = ["/", "?", "#", ";"];
      return pathDelimiters.includes(path[cookiePath.length]);
    }

    // "Restricts the retrieved cookies to those that would match the given URL."
    if (uri) {
      if (!domainMatches(uri.host)) {
        return false;
      }

      if (cookie.isSecure && uri.scheme != "https") {
        return false;
      }

      if (!pathMatches(uri.path)) {
        return false;
      }
    }

    if ("name" in details && details.name != cookie.name) {
      return false;
    }

    if (userContextId != cookie.originAttributes.userContextId) {
      return false;
    }

    // "Restricts the retrieved cookies to those whose domains match or are subdomains of this one."
    if ("domain" in details && !isSubdomain(cookie.rawHost, details.domain)) {
      return false;
    }

    // "Restricts the retrieved cookies to those whose path exactly matches this string.""
    if ("path" in details && details.path != cookie.path) {
      return false;
    }

    if ("secure" in details && details.secure != cookie.isSecure) {
      return false;
    }

    if ("session" in details && details.session != cookie.isSession) {
      return false;
    }

    // Check that the extension has permissions for this host.
    if (!context.extension.whiteListedHosts.matchesCookie(cookie)) {
      return false;
    }

    return true;
  }

  while (enumerator.hasMoreElements()) {
    let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2);
    if (matches(cookie)) {
      yield {cookie, isPrivate, storeId};
    }
  }
}

extensions.registerSchemaAPI("cookies", "addon_parent", context => {
  let {extension} = context;
  let self = {
    cookies: {
      get: function(details) {
        // FIXME: We don't sort by length of path and creation time.
        for (let cookie of query(details, ["url", "name", "storeId"], context)) {
          return Promise.resolve(convert(cookie));
        }

        // Found no match.
        return Promise.resolve(null);
      },

      getAll: function(details) {
        let allowed = ["url", "name", "domain", "path", "secure", "session", "storeId"];
        let result = Array.from(query(details, allowed, context), convert);

        return Promise.resolve(result);
      },

      set: function(details) {
        let uri = NetUtil.newURI(details.url).QueryInterface(Ci.nsIURL);

        let path;
        if (details.path !== null) {
          path = details.path;
        } else {
          // This interface essentially emulates the behavior of the
          // Set-Cookie header. In the case of an omitted path, the cookie
          // service uses the directory path of the requesting URL, ignoring
          // any filename or query parameters.
          path = uri.directory;
        }

        let name = details.name !== null ? details.name : "";
        let value = details.value !== null ? details.value : "";
        let secure = details.secure !== null ? details.secure : false;
        let httpOnly = details.httpOnly !== null ? details.httpOnly : false;
        let isSession = details.expirationDate === null;
        let expiry = isSession ? Number.MAX_SAFE_INTEGER : details.expirationDate;
        let isPrivate = context.incognito;
        let userContextId = 0;
        if (global.isDefaultCookieStoreId(details.storeId)) {
          isPrivate = false;
        } else if (global.isPrivateCookieStoreId(details.storeId)) {
          isPrivate = true;
        } else if (global.isContainerCookieStoreId(details.storeId)) {
          let containerId = global.getContainerForCookieStoreId(details.storeId);
          if (containerId === null) {
            return Promise.reject({message: `Illegal storeId: ${details.storeId}`});
          }
          isPrivate = false;
          userContextId = containerId;
        } else if (details.storeId !== null) {
          return Promise.reject({message: "Unknown storeId"});
        }

        let cookieAttrs = {host: details.domain, path: path, isSecure: secure};
        if (!checkSetCookiePermissions(extension, uri, cookieAttrs)) {
          return Promise.reject({message: `Permission denied to set cookie ${JSON.stringify(details)}`});
        }

        // The permission check may have modified the domain, so use
        // the new value instead.
        Services.cookies.usePrivateMode(isPrivate, () => {
          Services.cookies.add(cookieAttrs.host, path, name, value,
                               secure, httpOnly, isSession, expiry, {userContextId});
        });

        return self.cookies.get(details);
      },

      remove: function(details) {
        for (let {cookie, isPrivate, storeId} of query(details, ["url", "name", "storeId"], context)) {
          Services.cookies.usePrivateMode(isPrivate, () => {
            Services.cookies.remove(cookie.host, cookie.name, cookie.path, false, cookie.originAttributes);
          });

          // Todo: could there be multiple per subdomain?
          return Promise.resolve({
            url: details.url,
            name: details.name,
            storeId,
          });
        }

        return Promise.resolve(null);
      },

      getAllCookieStores: function() {
        let data = {};
        for (let window of WindowListManager.browserWindows()) {
          let tabs = TabManager.for(extension).getTabs(window);
          for (let tab of tabs) {
            if (!(tab.cookieStoreId in data)) {
              data[tab.cookieStoreId] = [];
            }
            data[tab.cookieStoreId].push(tab);
          }
        }

        let result = [];
        for (let key in data) {
          result.push({id: key, tabIds: data[key], incognito: key == PRIVATE_STORE});
        }
        return Promise.resolve(result);
      },

      onChanged: new EventManager(context, "cookies.onChanged", fire => {
        let observer = (subject, topic, data) => {
          let notify = (removed, cookie, cause) => {
            cookie.QueryInterface(Ci.nsICookie2);

            if (extension.whiteListedHosts.matchesCookie(cookie)) {
              fire({removed, cookie: convert({cookie, isPrivate: topic == "private-cookie-changed"}), cause});
            }
          };

          // We do our best effort here to map the incompatible states.
          switch (data) {
            case "deleted":
              notify(true, subject, "explicit");
              break;
            case "added":
              notify(false, subject, "explicit");
              break;
            case "changed":
              notify(true, subject, "overwrite");
              notify(false, subject, "explicit");
              break;
            case "batch-deleted":
              subject.QueryInterface(Ci.nsIArray);
              for (let i = 0; i < subject.length; i++) {
                let cookie = subject.queryElementAt(i, Ci.nsICookie2);
                if (!cookie.isSession && cookie.expiry * 1000 <= Date.now()) {
                  notify(true, cookie, "expired");
                } else {
                  notify(true, cookie, "evicted");
                }
              }
              break;
          }
        };

        Services.obs.addObserver(observer, "cookie-changed", false);
        Services.obs.addObserver(observer, "private-cookie-changed", false);
        return () => {
          Services.obs.removeObserver(observer, "cookie-changed");
          Services.obs.removeObserver(observer, "private-cookie-changed");
        };
      }).api(),
    },
  };

  return self;
});
