Packages

phoenix_kit

2.60.2
2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules sitemap route_resolver.ex
Raw

lib/modules/sitemap/route_resolver.ex

defmodule PhoenixKit.Modules.Sitemap.RouteResolver do
  @moduledoc """
  Resolves actual routes from parent application router.

  Uses router introspection to automatically detect URL patterns,
  falling back to Settings configuration when introspection fails.

  ## Resolution Priority

  1. Router Introspection - automatic detection from parent app router
  2. Settings override - manual configuration in PhoenixKit Settings
  3. Hardcoded fallback - default values

  ## Usage

      # Find path for specific plug module
      RouteResolver.find_route(PhoenixKitWeb.Users.Registration)
      # => "/users/register"

      # Find content route by type
      RouteResolver.find_content_route(:pages)
      # => "/pages/:slug"

      RouteResolver.find_content_route(:entity, "product")
      # => "/products/:slug"
  """

  require Logger

  @doc """
  Gets router module with automatic discovery.

  Resolution order:
  1. `config :phoenix_kit, router: MyAppWeb.Router`
  2. Via endpoint from `config :phoenix_kit, endpoint: MyAppWeb.Endpoint`
  3. Auto-discover from OTP applications (finds *Web.Router modules)
  """
  def get_router do
    # 1. Explicit router config
    case PhoenixKit.Config.get(:router) do
      {:ok, router} when not is_nil(router) ->
        if valid_router?(router), do: router, else: try_endpoint_router()

      _ ->
        try_endpoint_router()
    end
  end

  defp try_endpoint_router do
    # 2. Get router through endpoint
    case PhoenixKit.Config.get(:endpoint) do
      {:ok, endpoint} when not is_nil(endpoint) ->
        router = get_router_from_endpoint(endpoint)
        if router, do: router, else: try_auto_discover()

      _ ->
        try_auto_discover()
    end
  end

  defp get_router_from_endpoint(endpoint) do
    if Code.ensure_loaded?(endpoint) do
      try do
        router = endpoint.config(:router)
        if valid_router?(router), do: router, else: nil
      rescue
        _ -> nil
      end
    else
      nil
    end
  end

  # Auto-discover router from loaded OTP applications
  defp try_auto_discover do
    # Get all loaded applications except known system ones
    excluded_apps = ~w(
      elixir stdlib kernel compiler phoenix phoenix_live_view phoenix_html
      plug ecto ecto_sql postgrex jason swoosh oban hammer bcrypt_elixir
      argon2_elixir telemetry logger gettext phoenix_pubsub castore
      mint finch req nimble_options phoenix_ecto floki html_entities
      ex_doc makeup makeup_elixir makeup_erlang earmark_parser mdex
      file_system esbuild tailwind heroicons
      phoenix_kit ueberauth ueberauth_google ueberauth_github
      ueberauth_apple ueberauth_facebook assent jose igniter
      owl sourceror spitfire rewrite glob_ex infer inflex
      observer_cli bandit thousand_island
    )a

    :application.loaded_applications()
    |> Enum.map(fn {app, _, _} -> app end)
    |> Enum.reject(&(&1 in excluded_apps))
    |> Enum.find_value(&find_router_in_app/1)
  end

  defp find_router_in_app(app) do
    # Try common router module naming patterns
    app_name = app |> to_string() |> Macro.camelize()

    patterns = [
      "#{app_name}Web.Router",
      "#{app_name}.Router",
      "#{app_name}Web.Web.Router"
    ]

    Enum.find_value(patterns, fn pattern ->
      module = String.to_atom("Elixir.#{pattern}")

      if valid_router?(module) do
        Logger.debug("RouteResolver: Auto-discovered router #{inspect(module)}")
        module
      else
        nil
      end
    end)
  end

  defp valid_router?(nil), do: false

  defp valid_router?(router) do
    Code.ensure_loaded?(router) and
      function_exported?(router, :__routes__, 0)
  end

  @doc """
  Returns all routes from the parent router.

  Returns empty list if router is not available.
  """
  @spec get_routes() :: [map()]
  def get_routes do
    case get_router() do
      nil ->
        []

      router ->
        try do
          router.__routes__()
        rescue
          error ->
            Logger.debug("RouteResolver: Failed to get routes: #{inspect(error)}")
            []
        end
    end
  end

  @doc """
  Finds path for a specific plug module.

  ## Options

  - `:verb` - HTTP verb to match (default: `:get`)

  ## Examples

      find_route(PhoenixKitWeb.Users.Registration)
      # => "/users/register"

      find_route(MyApp.SomeController, verb: :post)
      # => "/some/path"
  """
  @spec find_route(module(), keyword()) :: String.t() | nil
  def find_route(plug_module, opts \\ []) do
    verb = Keyword.get(opts, :verb, :get)

    # A route that opted out of the sitemap (`metadata: %{sitemap: false}`)
    # is not found here either, so a configured static entry naming its plug
    # cannot bring it back.
    get_routes()
    |> Enum.find(fn route ->
      route.plug == plug_module and route.verb == verb and
        not match?(%{metadata: %{sitemap: false}}, route)
    end)
    |> case do
      nil -> nil
      route -> route.path
    end
  end

  @doc """
  Finds route pattern by content type.

  ## Types

  - `:pages` - Finds routes that look like page routes (contain :slug and plug name contains "page")
  - `:posts` - Finds routes that look like post routes (contain :slug and plug name contains "post")
  - `:entity` - Finds routes matching entity name (singular or plural form)

  ## Examples

      find_content_route(:pages)
      # => "/pages/:slug"

      find_content_route(:posts)
      # => "/posts/:slug"

      find_content_route(:entity, "product")
      # => "/products/:slug"

      find_content_route(:entity, "page")
      # => "/pages/:slug"
  """
  @spec find_content_route(atom(), String.t() | nil) :: String.t() | nil
  def find_content_route(type, name \\ nil)

  def find_content_route(:pages, _name) do
    get_routes()
    |> Enum.filter(fn route ->
      route.verb == :get and
        (String.contains?(route.path, ":slug") or
           String.contains?(route.path, "*path"))
    end)
    |> Enum.find(fn route ->
      plug_name = to_string(route.plug) |> String.downcase()
      String.contains?(plug_name, "page") or String.contains?(plug_name, "content")
    end)
    |> extract_path()
  end

  def find_content_route(:posts, _name) do
    get_routes()
    |> Enum.filter(fn route ->
      route.verb == :get and
        (String.contains?(route.path, ":slug") or String.contains?(route.path, ":id"))
    end)
    |> Enum.find(fn route ->
      path_lower = String.downcase(route.path)
      plug_name = to_string(route.plug) |> String.downcase()

      # Match routes with /posts/ in path or plug name containing "post"
      String.contains?(path_lower, "/posts/") or
        String.starts_with?(path_lower, "/posts/") or
        (String.contains?(plug_name, "post") and not String.contains?(plug_name, "page"))
    end)
    |> extract_path()
  end

  def find_content_route(:entity, entity_name) when is_binary(entity_name) do
    entity_lower = String.downcase(entity_name)

    routes =
      get_routes()
      |> Enum.filter(fn route ->
        route.verb == :get and
          (String.contains?(route.path, ":slug") or String.contains?(route.path, ":id"))
      end)

    # First try exact entity name match
    exact_match =
      Enum.find(routes, fn route ->
        path_lower = String.downcase(route.path)
        # Match both singular and plural forms in path
        String.contains?(path_lower, "/#{entity_lower}/") or
          String.contains?(path_lower, "/#{entity_lower}s/") or
          String.starts_with?(path_lower, "/#{entity_lower}/") or
          String.starts_with?(path_lower, "/#{entity_lower}s/")
      end)

    if exact_match do
      extract_path(exact_match)
    else
      # Try catch-all pattern like /:entity_name/:slug or /:name/:slug
      find_catchall_entity_route(routes, entity_name)
    end
  end

  def find_content_route(_, _), do: nil

  @doc """
  Finds index route for content type (list page without :slug).

  ## Examples

      find_index_route(:posts)
      # => "/posts"

      find_index_route(:entity, "page")
      # => "/page" or "/pages"

      find_index_route(:entity, "product")
      # => "/products"
  """
  @spec find_index_route(atom(), String.t() | nil) :: String.t() | nil
  def find_index_route(type, name \\ nil)

  def find_index_route(:posts, _name) do
    get_routes()
    |> Enum.filter(fn route ->
      route.verb == :get and
        not String.contains?(route.path, ":") and
        not String.contains?(route.path, "*")
    end)
    |> Enum.find(fn route ->
      path_lower = String.downcase(route.path)
      plug_name = to_string(route.plug) |> String.downcase()

      # Match /posts path or plug name containing "post"
      path_lower == "/posts" or
        String.ends_with?(path_lower, "/posts") or
        (String.contains?(plug_name, "post") and not String.contains?(plug_name, "page") and
           not String.contains?(path_lower, ":"))
    end)
    |> extract_path()
  end

  def find_index_route(:entity, entity_name) when is_binary(entity_name) do
    entity_lower = String.downcase(entity_name)

    # First try static routes (without params)
    static_routes =
      get_routes()
      |> Enum.filter(fn route ->
        route.verb == :get and
          not String.contains?(route.path, ":") and
          not String.contains?(route.path, "*")
      end)

    exact_match =
      Enum.find(static_routes, fn route ->
        path_lower = String.downcase(route.path)
        # Match exact entity path or plural form
        path_lower == "/#{entity_lower}" or
          path_lower == "/#{entity_lower}s" or
          String.ends_with?(path_lower, "/#{entity_lower}") or
          String.ends_with?(path_lower, "/#{entity_lower}s")
      end)

    if exact_match do
      extract_path(exact_match)
    else
      # Try catch-all pattern like /:entity_name
      param_routes =
        get_routes()
        |> Enum.filter(fn route ->
          route.verb == :get and
            String.contains?(route.path, ":") and
            not String.contains?(route.path, "*")
        end)

      find_catchall_index_route(param_routes, entity_name)
    end
  end

  def find_index_route(_, _), do: nil

  @doc """
  Extracts URL prefix from a route pattern.

  ## Examples

      extract_prefix("/pages/:slug")
      # => "/pages"

      extract_prefix("/content/*path")
      # => "/content"

      extract_prefix("/blog/posts/:id")
      # => "/blog/posts"
  """
  @spec extract_prefix(String.t() | nil) :: String.t() | nil
  def extract_prefix(nil), do: nil

  def extract_prefix(pattern) when is_binary(pattern) do
    pattern
    |> String.split("/:")
    |> List.first()
    |> String.split("/*")
    |> List.first()
    |> case do
      "" -> "/"
      prefix -> prefix
    end
  end

  # Private helpers

  # Find catch-all entity routes like /:entity_name/:slug
  # Returns the pattern with :entity_name replaced by actual entity name
  defp find_catchall_entity_route(routes, entity_name) do
    catchall_patterns = [
      ~r{^/:entity_name/:slug$},
      ~r{^/:entity/:slug$},
      ~r{^/:name/:slug$},
      ~r{^/:type/:slug$},
      ~r{^/:[a-z_]+/:slug$}
    ]

    Enum.find_value(routes, fn route ->
      if Enum.any?(catchall_patterns, &Regex.match?(&1, route.path)) do
        # Replace the first param with entity name
        route.path
        |> String.replace(~r{^/:[a-z_]+/}, "/#{entity_name}/")
      else
        nil
      end
    end)
  end

  # Find catch-all index routes like /:entity_name
  defp find_catchall_index_route(routes, entity_name) do
    catchall_patterns = [
      ~r{^/:entity_name$},
      ~r{^/:entity$},
      ~r{^/:name$},
      ~r{^/:type$},
      ~r{^/:[a-z_]+$}
    ]

    Enum.find_value(routes, fn route ->
      if Enum.any?(catchall_patterns, &Regex.match?(&1, route.path)) do
        # Replace with entity name
        "/#{entity_name}"
      else
        nil
      end
    end)
  end

  defp extract_path(nil), do: nil
  defp extract_path(route), do: route.path

  @doc """
  Checks if a route requires authentication based on its on_mount hooks.

  Returns true if the route uses authentication-requiring on_mount hooks:
  - `:phoenix_kit_ensure_authenticated_scope`
  - `:phoenix_kit_ensure_admin`

  ## Examples

      route_requires_auth?(%{metadata: %{...}})
      # => true/false

      # Check by path pattern
      route_requires_auth?("/posts")
      # => true/false
  """
  @spec route_requires_auth?(map() | String.t()) :: boolean()
  def route_requires_auth?(route) when is_map(route) do
    on_mount_hooks = extract_on_mount_hooks(route)

    Enum.any?(on_mount_hooks, fn hook ->
      hook in [
        :phoenix_kit_ensure_authenticated_scope,
        :phoenix_kit_ensure_admin,
        :ensure_authenticated,
        :require_authenticated_user
      ]
    end)
  end

  def route_requires_auth?(path) when is_binary(path) do
    get_routes()
    |> Enum.find(fn route ->
      route.verb == :get and routes_match?(route.path, path)
    end)
    |> case do
      nil -> false
      route -> route_requires_auth?(route)
    end
  end

  @doc """
  Checks if a content route (posts, entities, etc.) requires authentication.

  ## Examples

      content_route_requires_auth?(:posts)
      # => false

      content_route_requires_auth?(:entity, "product")
      # => false
  """
  @spec content_route_requires_auth?(atom(), String.t() | nil) :: boolean()
  def content_route_requires_auth?(type, name \\ nil)

  def content_route_requires_auth?(:posts, _name) do
    get_routes()
    |> Enum.filter(fn route ->
      route.verb == :get and
        (String.contains?(route.path, ":slug") or String.contains?(route.path, ":id"))
    end)
    |> Enum.find(fn route ->
      path_lower = String.downcase(route.path)
      plug_name = to_string(route.plug) |> String.downcase()

      String.contains?(path_lower, "/posts/") or
        String.starts_with?(path_lower, "/posts/") or
        (String.contains?(plug_name, "post") and not String.contains?(plug_name, "page"))
    end)
    |> case do
      nil -> false
      route -> route_requires_auth?(route)
    end
  end

  def content_route_requires_auth?(:entity, entity_name) when is_binary(entity_name) do
    case find_content_route(:entity, entity_name) do
      nil ->
        false

      path ->
        route_requires_auth?(path)
    end
  end

  def content_route_requires_auth?(_, _), do: false

  # Extract on_mount hooks from route metadata
  defp extract_on_mount_hooks(route) do
    case get_in(route.metadata, [:phoenix_live_view]) do
      {_, _, _, %{extra: %{on_mount: on_mount}}} ->
        Enum.map(on_mount, fn
          %{id: {_mod, id}} -> id
          {_mod, id} -> id
          _ -> nil
        end)
        |> Enum.reject(&is_nil/1)

      _ ->
        []
    end
  rescue
    _ -> []
  end

  # Check if route patterns match (handles :params and *wildcards)
  defp routes_match?(pattern, path) do
    pattern_parts = String.split(pattern, "/")
    path_parts = String.split(path, "/")

    if length(pattern_parts) != length(path_parts) do
      false
    else
      Enum.zip(pattern_parts, path_parts)
      |> Enum.all?(fn
        {":" <> _, _} -> true
        {"*" <> _, _} -> true
        {same, same} -> true
        _ -> false
      end)
    end
  end
end