lib/cookie_jar/cookie.ex

defmodule CookieJar.Cookie do
  @moduledoc """
  Model individual Cookie as specified by the
  [MDN doc](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)
  Some of the functionalities are irrelevant so only the following attributes are kept:
  domain, include_subdomain, path, secure, expires, name and value
  """

  defstruct domain: "",
            include_subdomain: false,
            path: "",
            secure: false,
            expires: 0,
            name: "",
            value: ""

  @type t :: %__MODULE__{
          domain: String.t(),
          include_subdomain: boolean(),
          path: String.t(),
          secure: boolean(),
          expires: integer(),
          name: String.t(),
          value: String.t()
        }

  @doc """
  simple constructor
  """
  @spec new(String.t(), String.t()) :: t()
  def new(name, value), do: %__MODULE__{name: name, value: value}

  @doc """
  Return true if cookie2 is superceding cookie1. Only compare domain, path and name. 
  """
  @spec equal?(t(), t()) :: boolean()
  def equal?(cookie1, cookie2) do
    cond do
      cookie2.name != cookie1.name -> false
      cookie2.domain != cookie1.domain -> false
      cookie2.path != cookie1.path -> false
      true -> true
    end
  end

  @doc """
  parse a cookie from the Set-Cookie: value. return nil if no valid cookie found
  """
  @spec parse(String.t()) :: nil | t()
  def parse(set_cookie), do: parse(set_cookie, nil)

  @doc """
  parse a cookie from the Set-Cookie: value. return nil if no valid cookie found
  taking additional infomatio ffrom the requesting URI
  """
  @spec parse(String.t(), nil | URI.t()) :: nil | t()
  def parse(set_cookie, uri) do
    # sensible default
    {host, path, secure} =
      case uri do
        nil -> {"", "", false}
        _ -> {uri.host || "", uri.path || "", uri.scheme == "https"}
      end

    cookie =
      parse_segments(
        String.split(set_cookie, ~r";\s*"),
        %__MODULE__{domain: host, path: path}
      )

    # security check
    cond do
      cookie == nil -> nil
      # attempted to set secure cookie from http
      cookie.secure && !secure -> nil
      # attempted to set cross site cookie
      uri != nil && cookie.domain != host && cookie.domain != parent_domain(host) -> nil
      true -> cookie
    end
  end

  @doc """
  Return true if the cookie shall be sent to the uri
  """
  @spec matched?(t(), URI.t()) :: boolean()
  def matched?(cookie, uri) do
    cond do
      cookie.secure && uri.scheme != "https" -> false
      cookie.expires > 0 && cookie.expires < DateTime.to_unix(DateTime.utc_now()) -> false
      !domain_match(cookie, uri.host) -> false
      !path_match(cookie, uri.path) -> false
      true -> true
    end
  end

  @doc """
  return name=value as string
  """
  @spec to_string(t()) :: String.t()
  def to_string(cookie), do: "#{cookie.name}=#{cookie.value}"

  defp domain_match(cookie, host) do
    cond do
      cookie.domain == host ->
        true

      !cookie.include_subdomain ->
        false

      true ->
        # We stored domain without leading dot
        String.slice(host, (0 - String.length(cookie.domain) - 1)..-1) ==
          "." <> cookie.domain
    end
  end

  defp path_match(cookie, path) do
    path = path || "/"

    cond do
      cookie.path == "" ->
        true

      cookie.path == path ->
        true

      true ->
        # we store path without trailing /
        String.slice(path, 0..String.length(cookie.path)) ==
          cookie.path <> "/"
    end
  end

  defp parse_segments([], %__MODULE__{name: "", value: ""}), do: nil
  defp parse_segments([], cookie), do: cookie

  # the first segment is name=value
  defp parse_segments([head | tail], cookie = %__MODULE__{name: "", value: ""}) do
    case String.split(head, "=", parts: 2) do
      [name, value] ->
        parse_segments(tail, %{cookie | name: name, value: value})

      _ ->
        nil
    end
  end

  defp parse_segments([head | tail], cookie) do
    case String.split(head, "=", parts: 2) do
      [name, value] ->
        parse_segments(tail, update_cookie(cookie, String.downcase(name), value))

      [name] ->
        parse_segments(tail, update_cookie(cookie, String.downcase(name)))
    end
  end

  defp update_cookie(cookie, "path", path) do
    %{cookie | path: String.trim_trailing(path, "/")}
  end

  defp update_cookie(cookie, "domain", domain) do
    %{cookie | domain: String.trim_leading(domain, "."), include_subdomain: true}
  end

  defp update_cookie(cookie, "max-age", age) do
    case Integer.parse(age) do
      {seconds, ""} ->
        %{
          cookie
          | expires:
              DateTime.utc_now()
              |> DateTime.add(seconds)
              |> DateTime.to_unix()
        }

      _ ->
        cookie
    end
  end

  defp update_cookie(cookie, _, _), do: cookie

  defp update_cookie(cookie, "secure"), do: %{cookie | secure: true}
  defp update_cookie(cookie, _), do: cookie

  defp parent_domain(host) do
    case String.split(host, ".", parts: 2) do
      [_head, parent] -> parent
      _ -> nil
    end
  end
end