Skip to main content

lib/attached_dashboard/web/router.ex

defmodule AttachedDashboard.Web.Router do
  @moduledoc """
  Phoenix router for the AttachedDashboard.

  Also provides the `attached_dashboard/1,2` macro that host apps use to mount
  the dashboard at a chosen prefix.

  ## Usage

      # router.ex
      import AttachedDashboard.Web.Router

      scope "/" do
        pipe_through :browser
        attached_dashboard "/admin/files"
      end

  ## Options

  * `:on_mount` — `on_mount` hooks added to the dashboard's `live_session`. Use this to
    enforce authentication from the host app.
  * `:as` — override the route helper name. Defaults to `:attached_dashboard`.
  * `:csp_nonce_assign_key` — CSP nonce key (atom or map with `:img`, `:style`, `:script` keys).
  * `:backlink` — path in the host app to return to from the dashboard sidebar
    (e.g. `"/admin"`). When set, the sidebar title becomes a link to this path.
    Use a full URL/path string; the target lives outside the dashboard's
    `live_session`, so navigation is a regular request (full page reload).
  """

  use Phoenix.Router
  import Phoenix.LiveView.Router

  # Actual routes — single source of truth. Used for:
  #   1. `~p` compile-time path validation via Phoenix.VerifiedRoutes
  #   2. The `attached_dashboard/1,2` mount macro below, which re-emits these
  #      routes scoped under the host app's chosen prefix.
  live "/", AttachedDashboard.Web.Live.OverviewLive, :index
  live "/originals", AttachedDashboard.Web.Live.Originals.IndexLive, :index
  live "/originals/:id", AttachedDashboard.Web.Live.Originals.ShowLive, :show
  live "/originals/:original_id/variants/:id", AttachedDashboard.Web.Live.Originals.Variants.ShowLive, :show
  live "/variants", AttachedDashboard.Web.Live.Variants.IndexLive, :index
  live "/orphans", AttachedDashboard.Web.Live.Orphans.IndexLive, :index
  live "/orphans/:owner_table/:owner_field", AttachedDashboard.Web.Live.Orphans.ShowLive, :show
  live "/owners", AttachedDashboard.Web.Live.Owners.IndexLive, :index
  live "/processors", AttachedDashboard.Web.Live.Processors.IndexLive, :index

  @doc """
  Mounts the AttachedDashboard at the given path in the host app's router.
  """
  defmacro attached_dashboard(path, opts \\ []) do
    opts =
      if Macro.quoted_literal?(opts) do
        Macro.prewalk(opts, &expand_alias(&1, __CALLER__))
      else
        opts
      end

    live_routes =
      for route <- __MODULE__.__routes__(),
          lv = route.metadata[:phoenix_live_view],
          match?({_, _, _, _}, lv) do
        {module, action, _opts, _live_session} = lv
        {route.path, module, action}
      end

    route_quotes =
      for {rpath, module, action} <- live_routes do
        quote do
          live unquote(rpath), unquote(module), unquote(action), route_opts
        end
      end

    quote do
      scope unquote(path), alias: false, as: false do
        import Phoenix.LiveView.Router, only: [live: 4, live_session: 3]

        {session_name, session_opts, route_opts} =
          AttachedDashboard.Web.Router.__options__(unquote(path), unquote(opts))

        live_session session_name, session_opts do
          (unquote_splicing(route_quotes))
        end

        get "/*filename", AttachedDashboard.Web.Controllers.AssetsController, :asset, as: :attached_dashboard_asset
      end
    end
  end

  defp expand_alias({:__aliases__, _, _} = alias, env) do
    Macro.expand(alias, %{env | function: {:attached_dashboard, 2}})
  end

  defp expand_alias(other, _env), do: other

  @doc false
  def __options__(prefix, opts) do
    as = Keyword.get(opts, :as, :attached_dashboard)
    csp_key = Keyword.get(opts, :csp_nonce_assign_key)
    backlink = Keyword.get(opts, :backlink)
    on_mount = Keyword.get(opts, :on_mount, []) ++ [AttachedDashboard.Web.Mounts.SessionMount]

    session_opts = [
      on_mount: on_mount,
      session: {__MODULE__, :__session__, [prefix, csp_key, backlink]},
      root_layout: {AttachedDashboard.Web.Components.Layouts, :root},
      layout: false
    ]

    {as, session_opts, [as: as]}
  end

  @doc false
  def __session__(conn, prefix, csp_key, backlink) do
    %{
      "prefix" => prefix,
      "backlink" => backlink,
      "csp_nonces" => expand_csp_nonces(conn, csp_key)
    }
  end

  defp expand_csp_nonces(_conn, nil), do: %{img: nil, style: nil, script: nil}

  defp expand_csp_nonces(conn, key) when is_atom(key) do
    nonce = conn.assigns[key]
    %{img: nonce, style: nonce, script: nonce}
  end

  defp expand_csp_nonces(conn, map) when is_map(map) do
    %{
      img: conn.assigns[map[:img]],
      style: conn.assigns[map[:style]],
      script: conn.assigns[map[:script]]
    }
  end
end