4.2 KiB · text History 92c48b1
defmodule DiogramosWeb.PublicUrl do
  @moduledoc """
  Derives the public-facing origin (`scheme://host[:port]`) a native
  client should dial back to.

  Resolution, highest precedence first:

  1. `DIOGRAMOS_PUBLIC_URL` (explicit override for reverse-proxy setups).
  2. Composed from forwarded headers with conn/socket fallbacks:
     * scheme: `X-Forwarded-Proto` if present, else the request's scheme
     * host: `X-Forwarded-Host` if present (it may already carry `:port`),
       else the request's host, with the port appended when non-default
  3. The endpoint's configured URL, as a last resort.

  Scheme and host are sourced independently, which is the whole point: a
  proxy that sets only `X-Forwarded-Proto: https` and passes Host through
  unchanged — the common Traefik/nginx default — must still yield an
  `https` origin. Treating the two as a unit encodes `http://` into the
  pairing QR behind a TLS-terminating proxy, and the phone then fails its
  WebSocket upgrade.

  The result never has a trailing slash, so callers can concatenate
  `"\#{base}/api/v1/..."` without producing a double slash.
  """

  import Plug.Conn, only: [get_req_header: 2]

  @spec derive(Plug.Conn.t() | Phoenix.LiveView.Socket.t()) :: String.t()
  def derive(%Plug.Conn{} = conn) do
    env_override() || compose(scheme_from_conn(conn), host_from_conn(conn)) || endpoint_url()
  end

  @doc """
  LiveView flavour. Must be called from `mount/3` — LiveView discards
  connect_info afterwards — and the endpoint must expose `:uri` and
  `:x_headers` in the socket's `connect_info`.

  On the dead render there is no connect_info at all, so this falls
  through to the endpoint URL; callers that need the real origin should
  derive it once the socket is connected.
  """
  def derive(%Phoenix.LiveView.Socket{} = socket) do
    env_override() || compose(scheme_from_socket(socket), host_from_socket(socket)) ||
      endpoint_url()
  end

  ## Scheme and host extraction

  defp scheme_from_conn(conn) do
    forwarded_proto(get_req_header(conn, "x-forwarded-proto")) || to_string(conn.scheme)
  end

  defp host_from_conn(conn) do
    case get_req_header(conn, "x-forwarded-host") do
      [host | _] -> first_value(host)
      _ -> host_with_port(conn.host, to_string(conn.scheme), conn.port)
    end
  end

  defp scheme_from_socket(socket) do
    forwarded_proto([header(socket, "x-forwarded-proto")]) ||
      case connect_uri(socket) do
        %URI{scheme: scheme} when is_binary(scheme) -> scheme
        _ -> nil
      end
  end

  defp host_from_socket(socket) do
    case header(socket, "x-forwarded-host") do
      nil ->
        case connect_uri(socket) do
          %URI{host: host, scheme: scheme, port: port} when is_binary(host) ->
            host_with_port(host, scheme, port)

          _ ->
            nil
        end

      host ->
        first_value(host)
    end
  end

  defp connect_uri(socket), do: Phoenix.LiveView.get_connect_info(socket, :uri)

  defp header(socket, name) do
    socket
    |> Phoenix.LiveView.get_connect_info(:x_headers)
    |> Kernel.||([])
    |> Enum.find_value(fn
      {^name, value} -> value
      _ -> nil
    end)
  end

  ## Helpers

  defp compose(nil, _host), do: nil
  defp compose(_scheme, nil), do: nil
  defp compose(scheme, host), do: strip_trailing_slash("#{scheme}://#{host}")

  defp forwarded_proto([proto | _]) when is_binary(proto), do: first_value(proto)
  defp forwarded_proto(_), do: nil

  # Chained proxies append rather than replace, so these headers can be a
  # comma-separated list. The first value is the one closest to the client.
  defp first_value(value), do: value |> String.split(",") |> List.first() |> String.trim()

  defp host_with_port(host, _scheme, nil), do: host

  defp host_with_port(host, scheme, port) do
    if default_port?(scheme, port), do: host, else: "#{host}:#{port}"
  end

  defp default_port?("http", 80), do: true
  defp default_port?("https", 443), do: true
  defp default_port?(_scheme, _port), do: false

  defp env_override do
    case System.get_env("DIOGRAMOS_PUBLIC_URL") do
      nil -> nil
      "" -> nil
      url -> strip_trailing_slash(url)
    end
  end

  defp endpoint_url, do: strip_trailing_slash(DiogramosWeb.Endpoint.url())

  defp strip_trailing_slash(url), do: String.replace_suffix(url, "/", "")
end