lib/web/conn.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma
alias Antikythera.Http

defmodule Antikythera.Request do
  @moduledoc """
  Definition of `Antikythera.Request` struct.
  """

  defmodule PathMatches do
    use Croma.SubtypeOfMap, key_module: Croma.Atom, value_module: Croma.String
  end

  defmodule Sender do
    alias Antikythera.GearName
    @type sender_ip :: String.t()
    @type t :: {:web, sender_ip} | {:gear, GearName.t()}

    defun valid?(v :: term) :: boolean do
      {:web, s} when is_binary(s) -> true
      {:gear, n} -> GearName.valid?(n)
      _ -> false
    end
  end

  use Croma.Struct,
    recursive_new?: true,
    fields: [
      method: Http.Method,
      path_info: Antikythera.PathInfo,
      path_matches: PathMatches,
      query_params: Http.QueryParams,
      headers: Http.Headers,
      cookies: Http.ReqCookiesMap,
      # can be used to e.g. check HMAC of body
      raw_body: Http.RawBody,
      body: Http.Body,
      sender: Sender
    ]
end

defmodule Antikythera.Conn do
  @moduledoc """
  Definition of `Antikythera.Conn` struct, which represents a client-server connection.

  This module also defines many functions to work with `Antikythera.Conn`.
  """

  alias Antikythera.{Request, Context}
  alias Antikythera.Session
  alias Antikythera.FastJasonEncoder

  defmodule BeforeSend do
    use Croma.SubtypeOfList, elem_module: Croma.Function, default: []
  end

  defmodule Assigns do
    use Croma.SubtypeOfMap, key_module: Croma.Atom, value_module: Croma.Any, default: %{}
  end

  use Croma.Struct,
    recursive_new?: true,
    fields: [
      request: Request,
      context: Context,
      status: Croma.TypeGen.nilable(Http.Status.Int),
      resp_headers: Http.Headers,
      resp_cookies: Http.SetCookiesMap,
      resp_body: Http.RawBody,
      before_send: BeforeSend,
      assigns: Assigns
    ]

  #
  # Lower-level interfaces to manipulate `Conn.t`.
  #
  defun get_req_header(%__MODULE__{request: request}, key :: v[String.t()]) :: nil | String.t() do
    request.headers[key]
  end

  defun get_req_query(%__MODULE__{request: request}, key :: v[String.t()]) :: nil | String.t() do
    request.query_params[key]
  end

  defun put_status(conn :: v[t], status :: v[Http.Status.t()]) :: t do
    %__MODULE__{conn | status: Http.Status.code(status)}
  end

  defun put_resp_header(
          %__MODULE__{resp_headers: resp_headers} = conn,
          key :: v[String.t()],
          value :: v[String.t()]
        ) :: t do
    %__MODULE__{conn | resp_headers: Map.put(resp_headers, key, value)}
  end

  defun put_resp_headers(
          %__MODULE__{resp_headers: resp_headers} = conn,
          headers :: v[%{String.t() => String.t()}]
        ) :: t do
    %__MODULE__{conn | resp_headers: Map.merge(resp_headers, headers)}
  end

  defun put_resp_body(conn :: v[t], body :: v[String.t()]) :: t do
    %__MODULE__{conn | resp_body: body}
  end

  @doc """
  Returns all request cookies.
  """
  defun get_req_cookies(%__MODULE__{request: %Request{cookies: cookies}}) ::
          Http.ReqCookiesMap.t() do
    cookies
  end

  @doc """
  Returns a request cookie specified by `name`.
  """
  defun get_req_cookie(conn :: v[t], name :: v[String.t()]) :: nil | String.t() do
    get_req_cookies(conn)[name]
  end

  @default_cookie_opts if Antikythera.Env.compiling_for_cloud?(),
                         do: %{path: "/", secure: true},
                         else: %{path: "/"}

  @doc """
  Adds a `set-cookie` response header to the given `Antikythera.Conn.t`.

  `path` directive of `set-cookie` header is automatically filled with `"/"` if not explicitly given.
  Also `secure` directive is filled by default in the cloud environments (assuming that it's serving with HTTPS).

  Note that response cookies are stored separately from the other response headers,
  as cookies require special treatment according to the HTTP specs.
  """
  defun put_resp_cookie(
          %__MODULE__{resp_cookies: resp_cookies} = conn,
          name :: v[String.t()],
          value :: v[String.t()],
          opts0 :: Http.SetCookie.options_t() \\ %{}
        ) :: t do
    opts = Map.merge(@default_cookie_opts, opts0)
    set_cookie = %Http.SetCookie{value: value} |> Http.SetCookie.update!(opts)
    %__MODULE__{conn | resp_cookies: Map.put(resp_cookies, name, set_cookie)}
  end

  @doc """
  Tells the client to delete an existing cookie specified by `name`.

  This is a wrapper around `put_resp_cookie/4` that sets an immediately expiring cookie (whose value is an empty string).
  """
  defun put_resp_cookie_to_revoke(conn :: v[t], name :: v[String.t()]) :: t do
    put_resp_cookie(conn, name, "", %{max_age: 0})
  end

  defun register_before_send(%__MODULE__{before_send: before_send} = conn, callback :: (t -> t)) ::
          t do
    %__MODULE__{conn | before_send: [callback | before_send]}
  end

  # These session-related functions assume that the `conn` is processed by `Antikythera.Plug.Session`
  # and thus it contains `:session` field in `:assigns`.
  defun get_session(%__MODULE__{assigns: %{session: session}}, key :: v[String.t()]) :: any do
    Session.get(session, key)
  end

  defun put_session(
          %__MODULE__{assigns: %{session: session}} = conn,
          key :: v[String.t()],
          value :: any
        ) :: t do
    assign(conn, :session, Session.put(session, key, value))
  end

  defun delete_session(%__MODULE__{assigns: %{session: session}} = conn, key :: v[String.t()]) ::
          t do
    assign(conn, :session, Session.delete(session, key))
  end

  defun clear_session(%__MODULE__{assigns: %{session: session}} = conn) :: t do
    assign(conn, :session, Session.clear(session))
  end

  defun renew_session(%__MODULE__{assigns: %{session: session}} = conn) :: t do
    assign(conn, :session, Session.renew(session))
  end

  defun destroy_session(%__MODULE__{assigns: %{session: session}} = conn) :: t do
    assign(conn, :session, Session.destroy(session))
  end

  defun assign(%__MODULE__{assigns: assigns} = conn, key :: v[atom], value :: any) :: t do
    %__MODULE__{conn | assigns: Map.put(assigns, key, value)}
  end

  #
  # Higher-level interfaces: Conveniences for common operations on `Conn.t`,
  # (implemented using the lower-level interfaces defined above).
  #
  @doc """
  Put `cache-control` response header for responses that must not be cached.

  The actual header value to be set is: `"private, no-cache, no-store, max-age=0"`.
  """
  defun no_cache(conn :: v[t]) :: t do
    put_resp_header(conn, "cache-control", "private, no-cache, no-store, max-age=0")
  end

  @doc """
  Returns an HTTP response that make the client redirect to the specified `url`.
  """
  defun redirect(conn :: v[t], url :: v[String.t()], status :: v[Http.Status.t()] \\ 302) :: t do
    conn
    |> put_resp_header("location", url)
    |> put_status(status)
  end

  @doc """
  Returns a JSON response.
  """
  defun json(
          %__MODULE__{resp_headers: resp_headers} = conn,
          status :: v[Http.Status.t()],
          body :: v[%{(atom | String.t()) => any} | [any]]
        ) :: t do
    %__MODULE__{
      conn
      | status: Http.Status.code(status),
        resp_headers: Map.put(resp_headers, "content-type", "application/json"),
        resp_body: FastJasonEncoder.encode!(body)
    }
  end

  @doc """
  Renders a HAML template file and returns the dynamic content as an HTML response.
  """
  defun render(
          %__MODULE__{context: context, resp_headers: resp_headers, assigns: assigns} = conn,
          status :: v[Http.Status.t()],
          template_name :: v[String.t()],
          render_params :: Keyword.t(any),
          opts :: Keyword.t(atom) \\ [layout: :application]
        ) :: t do
    flash = Map.get(assigns, :flash, %{})
    template_module = AntikytheraCore.GearModule.template_module_from_context(context)

    %__MODULE__{
      conn
      | status: Http.Status.code(status),
        resp_headers: Map.put(resp_headers, "content-type", "text/html; charset=utf-8"),
        resp_body:
          html_content(
            template_module,
            template_name,
            [flash: flash] ++ render_params,
            opts[:layout]
          )
    }
  end

  defunp html_content(
           template_module :: v[module],
           template_name :: v[String.t()],
           render_params :: Keyword.t(any),
           layout_name :: v[nil | atom]
         ) :: String.t() do
    content = template_module.content_for(template_name, render_params)

    {:safe, str} =
      case layout_name do
        nil ->
          content

        layout ->
          params_with_internal_content = [yield: content] ++ render_params
          template_module.content_for("layout/#{layout}", params_with_internal_content)
      end

    str
  end

  @doc """
  Sends a file which resides in `priv/` directory as a response.

  `path` must be a file path relative to the `priv/` directory.
  content-type header is inferred from the file's extension.

  Don't use this function for sending large files; you should use CDN for large files (see `Antikythera.Asset`).
  Also, if all you need to do is just to return a file (i.e. you don't need any authentication),
  you should not use this function; just placing the file under `priv/static/` directory should suffice.
  """
  defun send_priv_file(
          %__MODULE__{context: context, resp_headers: resp_headers} = conn,
          status :: v[Http.Status.t()],
          path :: Path.t()
        ) :: t do
    # Protect from directory traversal attack
    if String.contains?(path, "..") do
      raise "path must not contain `..`"
    end

    %__MODULE__{
      conn
      | status: Http.Status.code(status),
        resp_headers: Map.put(resp_headers, "content-type", mimetype(path)),
        resp_body: File.read!(filepath(context, path))
    }
  end

  defunp mimetype(path :: Path.t()) :: String.t() do
    {top, sub, _} = :cow_mimetypes.all(path)
    "#{top}/#{sub}"
  end

  defunp filepath(%Context{gear_entry_point: {mod, _}}, path :: Path.t()) :: Path.t() do
    gear_name = Module.split(mod) |> hd() |> Macro.underscore() |> String.to_existing_atom()
    Path.join(:code.priv_dir(gear_name), path)
  end

  @doc """
  Gets a flash message stored in the given `t:Antikythera.Conn.t/0`.
  """
  defun get_flash(%__MODULE__{assigns: assigns}, key :: v[String.t()]) :: nil | String.t() do
    assigns.flash[key]
  end

  @doc """
  Stores the flash message into the current `t:Antikythera.Conn.t/0`.
  """
  defun put_flash(
          %__MODULE__{assigns: assigns} = conn,
          key :: v[String.t()],
          value :: v[String.t()]
        ) :: t do
    assign(conn, :flash, Map.put(assigns.flash, key, value))
  end
end