defmodule Sentry.PlugContext do
  @moduledoc """
  A **Plug** for adding request context to Sentry events.

  This module adds Sentry context metadata during the request in a Plug
  application. It includes defaults for scrubbing sensitive data, and options for
  customizing such behavior.

  ## Usage

  You can use this module in a Plug pipeline to add Sentry metadata:

      plug Sentry.PlugContext

  This plug will add context metadata to the request, which will be added to
  reported errors that happen during plug execution. For Cowboy
  applications, you will also need to use `Sentry.PlugCapture`.

  ### Scrubbing `POST` Body Params

  In order to send `POST` body parameters you should first scrub them of sensitive
  information. By default, they will be scrubbed with `default_body_scrubber/1`. This
  can be overridden by passing the `:body_scrubber` option, which accepts a `Plug.Conn`
  and returns a map to send.  Setting `:body_scrubber` to `nil` will not send any data
  back. If you would like to make use of Sentry's default scrubber behavior in a custom
  scrubber, it can be called directly. An example configuration may look like
  the following:

      defmodule MySentryScrubber do
        def scrub_params(conn) do
          # Makes use of the default body_scrubber to avoid sending password
          # and credit card information in plain text. To also prevent sending
          # our sensitive "my_secret_field" and "other_sensitive_data" fields,
          # we simply drop those keys.
          conn
          |> Sentry.PlugContext.default_body_scrubber()
          |> Map.drop(["my_secret_field", "other_sensitive_data"])
        end
      end

  Then pass it into `Sentry.PlugContext`:

      plug Sentry.PlugContext, body_scrubber: &MySentryScrubber.scrub_params/1

  You can also pass it in as a `{module, fun}`, like so:

      plug Sentry.PlugContext, body_scrubber: {MySentryScrubber, :scrub_params}

  > #### Large Files {: .tip}
  >
  > If you are sending large files in `POST` requests, we recommend you
  > scrub them out through the `:body_scrubber` mechanism.

  ### Scrubbing Headers

  By default, Sentry uses `default_header_scrubber/1` to scrub headers. This can be
  configured similarly to body params, through the `:header_scrubber` configuration
  option:

      defmodule MySentryScrubber do
        def scrub_headers(conn) do
          # In this example, we do not want to include Content-Type or User-Agent
          # in reported headers, so we drop them.
          conn.req_headers
          |> Map.new()
          |> Sentry.PlugContext.default_header_scrubber()
          |> Map.drop(["content-type", "user-agent"])
        end
      end

  Then, pass it into `Sentry.PlugContext`:

      plug Sentry.PlugContext, header_scrubber: &MySentryScrubber.scrub_headers/1

  It can also be passed in as a `{module, fun}` like so:

      plug Sentry.PlugContext, header_scrubber: {MySentryScrubber, :scrub_headers}

  ### Scrubbing Cookies

  By default Sentry will scrub all cookies before sending events
  (see `scrub_cookies/1`). It can be configured similarly to the headers
  and body scrubbers, but is configured via the `:cookie_scrubber` key.

  For example:

      plug Sentry.PlugContext, cookie_scrubber: &MySentryScrubber.scrub_cookies/1

  ### Scrubbing URLs

  *Available since v10.2.0.*

  If any of your URLs contain sensitive tokens or other data, you should scrub them
  to remove the sensitive data. This can be configured similarly to body params,
  through the `:url_scrubber` configuration option. It should return a string:

      defmodule MySentryScrubber do
        def scrub_url(conn) do
          conn
          |> Plug.Conn.request_url()
          |> String.replace(~r/secret-token\/\w+/, "secret-token/****")
        end
      end

  Then pass it into `Sentry.PlugContext`:

      plug Sentry.PlugContext, url_scrubber: &MySentryScrubber.scrub_url/1

  You can also pass it in as a `{module, fun}`, like so:

      plug Sentry.PlugContext, url_scrubber: {MySentryScrubber, :scrub_url}

  ## Including Request Identifiers

  If you're using Phoenix, `Plug.RequestId`, or any other method to set a *request ID*
  response header, and would like to include that information with errors
  reported by `Sentry.PlugContext`, use the `:request_id_header` option. It allows you to set
  which header key Sentry should check. It defaults to `x-request-id`,
  which `Plug.RequestId` (and therefore Phoenix) also default to.

      plug Sentry.PlugContext, request_id_header: "application-request-id"

  ## Remote Address Reader

  `Sentry.PlugContext` includes a request's originating IP address under the `REMOTE_ADDR`
  environment key in Sentry. By default, we read it from the `x-forwarded-for` HTTP header,
  and if this header is not present, from `conn.remote_ip`.

  If you wish to read this value differently (for example, from a different HTTP header),
  or modify it in some other way (such as by masking it), you can configure this behavior
  by passing the `:remote_address_reader` option:

      plug Sentry.PlugContext, remote_address_reader: &MyModule.read_ip/1

  The `:remote_address_reader` option must be a function that accepts a `Plug.Conn`
  returns a `t:String.t/0` IP, or a `{module, function}` tuple, where `module.function/1`
  takes a `Plug.Conn` and returns a `t:String.t/0` IP.
  """

  if Code.ensure_loaded?(Plug) do
    @behaviour Plug

    @impl Plug
    def init(opts) do
      opts
    end

    @impl Plug
    def call(conn, opts) do
      request = build_request_interface_data(conn, opts)
      Sentry.Context.set_request_context(request)
      conn
    end
  end

  @default_scrubbed_param_keys ["password", "passwd", "secret"]
  @default_scrubbed_header_keys ["authorization", "authentication", "cookie"]
  @scrubbed_value "*********"
  @default_plug_request_id_header "x-request-id"

  @doc false
  @spec build_request_interface_data(Plug.Conn.t(), keyword()) :: Sentry.Context.request_context()
  def build_request_interface_data(conn, opts) do
    body_scrubber = Keyword.get(opts, :body_scrubber, {__MODULE__, :default_body_scrubber})
    header_scrubber = Keyword.get(opts, :header_scrubber, {__MODULE__, :default_header_scrubber})
    cookie_scrubber = Keyword.get(opts, :cookie_scrubber, {__MODULE__, :default_cookie_scrubber})
    url_scrubber = Keyword.get(opts, :url_scrubber, {__MODULE__, :default_url_scrubber})

    remote_address_reader =
      Keyword.get(opts, :remote_address_reader, {__MODULE__, :default_remote_address_reader})

    request_id_header = Keyword.get(opts, :request_id_header, @default_plug_request_id_header)

    conn =
      Plug.Conn.fetch_cookies(conn)
      |> Plug.Conn.fetch_query_params()

    %{
      url: apply_fun_with_conn(conn, url_scrubber, Plug.Conn.request_url(conn)),
      method: conn.method,
      data: apply_fun_with_conn(conn, body_scrubber, %{}),
      query_string: conn.query_string,
      cookies: apply_fun_with_conn(conn, cookie_scrubber, %{}),
      headers: apply_fun_with_conn(conn, header_scrubber, %{}),
      env: %{
        "REMOTE_ADDR" => apply_fun_with_conn(conn, remote_address_reader, %{}),
        "REMOTE_PORT" => remote_port(conn),
        "SERVER_NAME" => conn.host,
        "SERVER_PORT" => conn.port,
        "REQUEST_ID" => conn |> Plug.Conn.get_resp_header(request_id_header) |> List.first()
      }
    }
  end

  @doc false
  @spec default_remote_address_reader(Plug.Conn.t()) :: String.t()
  def default_remote_address_reader(conn) do
    case Plug.Conn.get_req_header(conn, "x-forwarded-for") do
      [header_value | _rest] ->
        [address | _rest] = String.split(header_value, ",", parts: 2)
        String.trim(address)

      [] ->
        case :inet.ntoa(conn.remote_ip) do
          {:error, _} -> ""
          address -> to_string(address)
        end
    end
  end

  defp remote_port(conn) do
    case Plug.Conn.get_req_header(conn, "x-forwarded-for") do
      [] ->
        nil

      [_value | _rest] ->
        # As of bandit v1.5.7 during client disconnection it will raise "Unable to obtain transport_info: [reason]"
        # everytime it tries to get the transport info. Let's handle that exception by returning nil instead of bubbling the exception.
        # Refer to: https://github.com/mtrudel/bandit/blob/b06e43dcee93c5c044b18ed8347838fc2a1728f0/lib/bandit/transport_info.ex#L26
        try do
          Plug.Conn.get_peer_data(conn).port
        rescue
          _ -> nil
        end
    end
  end

  defp apply_fun_with_conn(_conn, _function = nil, default), do: default
  defp apply_fun_with_conn(conn, {module, fun}, _default), do: apply(module, fun, [conn])
  defp apply_fun_with_conn(conn, fun, _default) when is_function(fun, 1), do: fun.(conn)

  @doc """
  Scrubs **all** cookies off of the request.
  """
  @spec default_cookie_scrubber(Plug.Conn.t()) :: map()
  def default_cookie_scrubber(_conn) do
    %{}
  end

  @doc """
  Returns the request URL without modifying it.
  """
  @spec default_url_scrubber(Plug.Conn.t()) :: String.t()
  def default_url_scrubber(conn) do
    Plug.Conn.request_url(conn)
  end

  @doc """
  Scrubs the headers of a request.

  The default scrubbed headers are:

  #{Enum.map_join(@default_scrubbed_header_keys, "\n", &"*  `#{&1}`")}
  """
  @spec default_header_scrubber(Plug.Conn.t()) :: map()
  def default_header_scrubber(conn) do
    conn.req_headers
    |> Map.new()
    |> Map.drop(@default_scrubbed_header_keys)
  end

  @doc """
  Scrubs the body of a request.

  The default scrubbed keys are:

  #{Enum.map_join(@default_scrubbed_param_keys, "\n", &"*  `#{&1}`")}
  """
  @spec default_body_scrubber(Plug.Conn.t()) :: map()
  def default_body_scrubber(conn) do
    scrub_map(conn.params, @default_scrubbed_param_keys)
  end

  defp scrub_map(map, scrubbed_keys) do
    Map.new(map, fn {key, value} ->
      value =
        cond do
          key in scrubbed_keys -> @scrubbed_value
          is_binary(value) and value =~ credit_card_regex() -> @scrubbed_value
          is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys)
          is_map(value) -> scrub_map(value, scrubbed_keys)
          is_list(value) -> scrub_list(value, scrubbed_keys)
          true -> value
        end

      {key, value}
    end)
  end

  defp scrub_list(list, scrubbed_keys) do
    Enum.map(list, fn value ->
      cond do
        is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys)
        is_map(value) -> scrub_map(value, scrubbed_keys)
        is_list(value) -> scrub_list(value, scrubbed_keys)
        true -> value
      end
    end)
  end

  defp credit_card_regex, do: ~r/^(?:\d[ -]*?){13,16}$/
end
