lib/membrane_rtsp/session/logic.ex

defmodule Membrane.RTSP.Logic do
  @moduledoc """
  Logic for RTSP session
  """
  alias Membrane.RTSP.{Request, Response}
  @user_agent "MembraneRTSP/#{Mix.Project.config()[:version]} (Membrane Framework RTSP Client)"

  defmodule State do
    @moduledoc "Struct representing the state of RTSP session"
    @enforce_keys [:transport, :uri, :transport_module]
    defstruct @enforce_keys ++
                [
                  :session_id,
                  cseq: 0,
                  execution_options: [],
                  auth: nil
                ]

    @type digest_opts() :: %{
            realm: String.t() | nil,
            nonce: String.t() | nil
          }

    @type auth_t() :: nil | :basic | {:digest, digest_opts()}

    @type t :: %__MODULE__{
            transport: any(),
            cseq: non_neg_integer(),
            uri: URI.t(),
            session_id: binary() | nil,
            auth: auth_t(),
            execution_options: Keyword.t()
          }
  end

  @spec user_agent() :: binary()
  def user_agent(), do: @user_agent

  @spec execute(Request.t(), State.t()) :: {:ok, binary()} | {:error, reason :: any()}
  def execute(request, state) do
    %State{
      cseq: cseq,
      transport: transport,
      transport_module: transport_module,
      uri: uri,
      session_id: session_id
    } = state

    request
    |> inject_session_header(session_id)
    |> Request.with_header("CSeq", cseq |> to_string())
    |> Request.with_header("User-Agent", @user_agent)
    |> apply_credentials(uri, state.auth)
    |> Request.stringify(uri)
    |> transport_module.execute(transport, state.execution_options)
  end

  @spec inject_session_header(Request.t(), binary()) :: Request.t()
  def inject_session_header(request, session_id) do
    case session_id do
      nil -> request
      session -> Request.with_header(request, "Session", session)
    end
  end

  @spec apply_credentials(Request.t(), URI.t(), State.auth_t()) :: Request.t()
  def apply_credentials(request, %URI{userinfo: nil}, _auth_options), do: request

  def apply_credentials(%Request{headers: headers} = request, uri, auth) do
    case List.keyfind(headers, "Authorization", 0) do
      {"Authorization", _value} ->
        request

      _else ->
        do_apply_credentials(request, uri, auth)
    end
  end

  defp do_apply_credentials(request, %URI{userinfo: info}, :basic) do
    encoded = Base.encode64(info)
    Request.with_header(request, "Authorization", "Basic " <> encoded)
  end

  defp do_apply_credentials(request, %URI{} = uri, {:digest, options}) do
    encoded = encode_digest(request, uri, options)
    Request.with_header(request, "Authorization", encoded)
  end

  defp do_apply_credentials(request, _url, _options) do
    request
  end

  @spec encode_digest(Request.t(), URI.t(), State.digest_opts()) :: String.t()
  def encode_digest(request, %URI{userinfo: userinfo} = uri, options) do
    [username, password] = String.split(userinfo, ":", parts: 2)
    encoded_uri = Request.process_uri(request, uri)
    ha1 = md5([username, options.realm, password])
    ha2 = md5([request.method, encoded_uri])
    response = md5([ha1, options.nonce, ha2])

    Enum.join(
      [
        "Digest",
        ~s(username="#{username}",),
        ~s(realm="#{options.realm}",),
        ~s(nonce="#{options.nonce}",),
        ~s(uri="#{encoded_uri}",),
        ~s(response="#{response}")
      ],
      " "
    )
  end

  @spec md5([String.t()]) :: String.t()
  def md5(value) do
    value
    |> Enum.join(":")
    |> IO.iodata_to_binary()
    |> :erlang.md5()
    |> Base.encode16(case: :lower)
  end

  # Some responses do not have to return the Session ID
  # If it does return one, it needs to match one stored in the state.
  @spec handle_session_id(Response.t(), State.t()) :: {:ok, State.t()} | {:error, reason :: any()}
  def handle_session_id(%Response{} = response, state) do
    with {:ok, session_value} <- Response.get_header(response, "Session") do
      [session_id | _rest] = String.split(session_value, ";")

      case state do
        %State{session_id: nil} -> {:ok, %State{state | session_id: session_id}}
        %State{session_id: ^session_id} -> {:ok, state}
        _else -> {:error, :invalid_session_id}
      end
    else
      {:error, :no_such_header} -> {:ok, state}
    end
  end

  # Checks for the `nonce` and `realm` values in the `WWW-Authenticate` header.
  # if they exist, sets `type` to `{:digest, opts}`
  @spec detect_authentication_type(Response.t(), State.t()) :: {:ok, State.t()}
  def detect_authentication_type(%Response{} = response, state) do
    with {:ok, "Digest " <> digest} <- Response.get_header(response, "WWW-Authenticate") do
      [_match, nonce] = Regex.run(~r/nonce=\"(?<nonce>.*)\"/U, digest)
      [_match, realm] = Regex.run(~r/realm=\"(?<realm>.*)\"/U, digest)
      auth_options = {:digest, %{nonce: nonce, realm: realm}}
      {:ok, %{state | auth: auth_options}}
    else
      # non digest auth?
      {:ok, _non_digest} ->
        {:ok, %{state | auth: :basic}}

      {:error, :no_such_header} ->
        {:ok, state}
    end
  end
end