lib/caddy/admin/request.ex

defmodule Caddy.Admin.Request do
  @moduledoc """

  Req, send request through socket

  """

  alias Caddy.Admin.Request
  alias Caddy.Config
  require Logger

  defstruct status: 0, headers: [], body: ""

  @doc """
  Send HTTP GET method to admin socket
  """
  def get(path) do
    unix_path = get_admin_sock()

    {:ok, socket} =
      :gen_tcp.connect({:local, unix_path}, 0, [
        :binary,
        {:active, false},
        {:packet, :http_bin}
      ])

    req_raw_header = gen_raw_header("get", path)
    :gen_tcp.send(socket, req_raw_header)
    do_recv(socket)
  end

  @doc """
  Send HTTP POST method to admin socket
  """
  def post(path, data, content_type \\ "application/json") do
    unix_path = get_admin_sock()

    {:ok, socket} =
      :gen_tcp.connect({:local, unix_path}, 0, [
        :binary,
        {:active, false},
        {:packet, :http_bin}
      ])

    req_raw_header = gen_raw_header("post", path, content_type)

    :gen_tcp.send(socket, req_raw_header)
    :gen_tcp.send(socket, data)
    do_recv(socket)
  end

  @doc """
  Send HTTP PATCH method to admin socket
  """
  def patch(path, data, content_type \\ "application/json") do
    unix_path = get_admin_sock()

    {:ok, socket} =
      :gen_tcp.connect({:local, unix_path}, 0, [
        :binary,
        {:active, false},
        {:packet, :http_bin}
      ])

    req_raw_header = gen_raw_header("patch", path, content_type)

    :gen_tcp.send(socket, req_raw_header)
    :gen_tcp.send(socket, data)
    do_recv(socket)
  end

  @doc """
  Send HTTP PUT method to admin socket
  """
  @spec put(binary(), binary(), binary()) ::
          {:ok, atom | %{:headers => list, optional(any) => any}, String.t() | Map.t()}
  def put(path, data, content_type \\ "application/json") do
    unix_path = get_admin_sock()

    {:ok, socket} =
      :gen_tcp.connect({:local, unix_path}, 0, [
        :binary,
        {:active, false},
        {:packet, :http_bin}
      ])

    req_raw_header = gen_raw_header("put", path, content_type)

    :gen_tcp.send(socket, req_raw_header)
    :gen_tcp.send(socket, data)
    do_recv(socket)
  end

  @doc """
  Send HTTP DELETE method to admin socket
  """
  @spec delete(binary(), binary(), binary()) ::
          {:ok, atom | %{:headers => list, optional(any) => any}, String.t() | Map.t()}
  def delete(path, data \\ "", content_type \\ "application/json") do
    unix_path = get_admin_sock()

    {:ok, socket} =
      :gen_tcp.connect({:local, unix_path}, 0, [
        :binary,
        {:active, false},
        {:packet, :http_bin}
      ])

    req_raw_header = gen_raw_header("delete", path, content_type)

    :gen_tcp.send(socket, req_raw_header)
    :gen_tcp.send(socket, data)
    do_recv(socket)
  end

  defp get_admin_sock() do
    Config.get(:config)
    |> get_in(["admin", "listen"])
    |> String.replace(~r/^unix\//, "")
  end

  defp gen_raw_header(method, path, content_type \\ nil) do
    host = Config.get(:config) |> get_in(["admin", "origins"]) |> Enum.at(0, "caddy-admin.local")

    """
    #{String.upcase(method)} #{path} HTTP/1.1
    Host: #{host}
    #{if(content_type == nil, do: "\r\n", else: "Content-Type: #{content_type}\r\n")}
    """
  end

  defp do_recv(socket), do: do_recv(socket, :gen_tcp.recv(socket, 0, 5000), %Request{})

  defp do_recv(socket, {:ok, {:http_response, {1, 1}, code, _}}, resp) do
    do_recv(socket, :gen_tcp.recv(socket, 0, 5000), %Request{resp | status: code})
  end

  defp do_recv(socket, {:ok, {:http_header, _, h, _, v}}, resp) do
    do_recv(socket, :gen_tcp.recv(socket, 0, 5000), %Request{
      resp
      | headers: [{h, v} | resp.headers]
    })
  end

  defp do_recv(socket, {:ok, :http_eoh}, resp) do
    # Now we only have body left.
    # # Depending on headers here you may want to do different things.
    # # The response might be chunked, or upgraded in case you have attached to the container
    # # Now I can receive the response. Because of `:active, false} I need to explicitly ask for data, otherwise it gets send to the process as messages.
    case :proplists.get_value(:"Content-Type", resp.headers) do
      "application/json" -> {:ok, resp, Jason.decode!(read_body(socket, resp))}
      _ -> {:ok, resp, read_body(socket, resp)}
    end
  end

  defp read_body(socket, resp) do
    case :proplists.get_value(:"Content-Length", resp.headers) do
      :undefined ->
        # No content length. Checked if chunked
        case :proplists.get_value(:"Transfer-Encoding", resp.headers) do
          "chunked" ->
            {:ok, resp_body} = read_chunked_body(socket, resp)
            resp_body

          # No body
          _ ->
            ""
        end

      content_length ->
        bytes_to_read = String.to_integer(content_length)
        # No longer line based http, just read data
        :inet.setopts(socket, [{:packet, :raw}])

        case :gen_tcp.recv(socket, bytes_to_read, 5000) do
          {:ok, data} ->
            data

          {:error, reason} ->
            {:error, reason}
        end
    end
  end

  defp read_chunked_body(socket, resp), do: read_chunked_body(socket, resp, [])

  defp read_chunked_body(socket, resp, acc) do
    Logger.debug("read_chunked_body: #{inspect(socket)} #{inspect(resp)} #{inspect(acc)}")
    :inet.setopts(socket, [{:packet, :line}])

    case :gen_tcp.recv(socket, 0, 5000) do
      {:ok, length} ->
        length = String.trim_trailing(length, "\r\n") |> String.to_integer(16)

        if length == 0 do
          {:ok, :erlang.iolist_to_binary(Enum.reverse(acc))}
        else
          :inet.setopts(socket, [{:packet, :raw}])
          {:ok, data} = :gen_tcp.recv(socket, length, 5000)
          :gen_tcp.recv(socket, 2, 5000)
          read_chunked_body(socket, resp, [data | acc])
        end

      other ->
        {:error, other}
    end
  end
end