PhoenixKit.Utils.Routes (phoenix_kit v2.1.0)

Copy Markdown View Source

Utility functions for working with PhoenixKit routes and URLs.

This module provides helpers for constructing URLs with the correct PhoenixKit prefix configured in the application.

Summary

Types

The request a redirect is being built for. The application's router is read from it, which is how routable?/2 works. nil is accepted — it simply makes every candidate fail closed, so the chain lands on a core-owned page.

Functions

Returns a locale-aware admin path. For non-primary locales the locale segment is always emitted. For the primary locale the shape follows the site-wide default_language_no_prefix setting (Languages.default_language_no_prefix?/0): prefixless when the setting is on, prefixed when off.

Whether a path lands on one of the sign-in pages (or /users/log-out).

The bare base URL (scheme + host, no trailing slash), same source url/1 uses.

Returns the default locale (base code) from the Languages module.

Returns true when path is safe to use as a local redirect / return_to target: a binary that begins with a single / but not // or /\ — both of which browsers resolve as protocol-relative, host-switching URLs. Use this to guard user-supplied redirect params against open-redirect attacks.

Returns a locale-aware path using locale from assigns.

The configured site main page, or nil when unset or unusable.

Builds a PhoenixKit path: the host's mount prefix, plus a locale segment when the site is multilingual.

Gets the base module name for the parent application.

Resolves where to send a user once they are signed in and confirmed.

Renders ?return_to=<path> for a link, or "" when there is nothing safe to carry. Lets the auth pages hand the pending destination to each other instead of dropping it the moment a visitor switches to another sign-in method.

Whether path actually resolves to a GET route in the application's router.

Resolves where to send a visitor that core is allowed to send them.

Returns a full url with preconfigured prefix.

Returns the configured PhoenixKit URL prefix.

Types

request_context()

@type request_context() :: Plug.Conn.t() | Phoenix.LiveView.Socket.t() | nil

The request a redirect is being built for. The application's router is read from it, which is how routable?/2 works. nil is accepted — it simply makes every candidate fail closed, so the chain lands on a core-owned page.

Functions

admin_path(url_path, locale)

Returns a locale-aware admin path. For non-primary locales the locale segment is always emitted. For the primary locale the shape follows the site-wide default_language_no_prefix setting (Languages.default_language_no_prefix?/0): prefixless when the setting is on, prefixed when off.

Both URL shapes resolve at the router level — the admin route macros declare /:locale/admin/* AND /admin/* scopes — so either shape is routable. The two shapes share one live_session :phoenix_kit_admin, so locale switching across them stays on the WebSocket (push_navigate) without a full-page reload.

Examples

iex> Routes.admin_path("/admin/users", "uk")
"/phoenix_kit/uk/admin/users"

iex> Routes.admin_path("/admin/users", nil)
"/phoenix_kit/admin/users"

Primary-locale shape depends on the default_language_no_prefix setting (not shown as doctests because the result varies with runtime state):

# setting OFF (default)
Routes.admin_path("/admin/users", "en") #=> "/phoenix_kit/en/admin/users"

# setting ON
Routes.admin_path("/admin/users", "en") #=> "/phoenix_kit/admin/users"

auth_page?(path)

@spec auth_page?(term()) :: boolean()

Whether a path lands on one of the sign-in pages (or /users/log-out).

Public because the after_login_path / after_registration_path changeset applies the same rule when the setting is saved — one list, one predicate, so a new auth route can't be guarded on read and forgotten on write.

Suffix-matched, since the real URL carries the host's mount prefix and an optional locale segment (/app/et/users/log-in). A host page whose own path happens to end in one of these segments is refused too — over-strict rather than allowing a redirect loop.

Examples

iex> PhoenixKit.Utils.Routes.auth_page?("/users/log-in")
true
iex> PhoenixKit.Utils.Routes.auth_page?("/et/users/log-out/")
true
iex> PhoenixKit.Utils.Routes.auth_page?("/dashboard")
false

base_url()

@spec base_url() :: String.t()

The bare base URL (scheme + host, no trailing slash), same source url/1 uses.

For absolutizing a path that is already url-prefixed and locale-prefixed (e.g. a notification's link, built via path/1) — concatenate it onto this directly. Do NOT pass such a path to url/1, which re-applies path/1 and would double-prefix it.

get_default_admin_locale()

Returns the default locale (base code) from the Languages module.

Extracts the base code from the default language (e.g., "en-US" becomes "en"). Falls back to "en" if no default language is configured.

Examples

iex> Routes.get_default_admin_locale()
"en"

local_path?(path)

@spec local_path?(term()) :: boolean()

Returns true when path is safe to use as a local redirect / return_to target: a binary that begins with a single / but not // or /\ — both of which browsers resolve as protocol-relative, host-switching URLs. Use this to guard user-supplied redirect params against open-redirect attacks.

ASCII control characters are rejected too. Browsers strip tab/CR/LF while parsing a URL, so "/\t/evil.example" (from ?return_to=%2F%09%2Fevil.example) becomes //evil.example — a cross-origin navigation — once it reaches window.location. Phoenix.Controller.redirect/2 blocks those itself, but LiveView's validate_local_url! only rejects \\ and a leading //, so a LiveView redirect(socket, to: ...) would otherwise pass it through.

Examples

iex> PhoenixKit.Utils.Routes.local_path?("/admin/dashboard")
true
iex> PhoenixKit.Utils.Routes.local_path?("//evil.com")
false
iex> PhoenixKit.Utils.Routes.local_path?("https://evil.com")
false
iex> PhoenixKit.Utils.Routes.local_path?("/\t/evil.com")
false

locale_aware_path(assigns, url_path)

Returns a locale-aware path using locale from assigns.

This function is specifically designed for use in component templates where the locale needs to be passed explicitly via assigns.

Prefers base locale code for URL generation (current_locale_base), falls back to extracting base from full dialect code (current_locale).

main_page_path()

@spec main_page_path() :: String.t() | nil

The configured site main page, or nil when unset or unusable.

nil rather than "/" is deliberate: an unset setting means "nobody chose", and safe_destination/2 probes "/" on its own as the last candidate. A "/" default here would instead assert it as the administrator's first-priority choice — ahead of everything, on a host that may not route it.

The setting is validated as a local path when saved and re-guarded here on read, so a hand-edited DB row can't turn it into an open redirect.

path(url_path, opts \\ [])

Builds a PhoenixKit path: the host's mount prefix, plus a locale segment when the site is multilingual.

The :locale option

This is the contract, and it is what a bilingual site needs when auth pages come out in the wrong language. Without it the locale is determined — from the process's Gettext locale — which is right for a link rendered inside a request and wrong for a link built outside one (an email, a background job, a script).

  • locale: "et" — force this locale.
  • locale: :none — emit no locale segment at all. For anything that is not a page: assets, webhooks, sitemap.xml.
  • locale: nil or omitted — determine it from the current process.

Examples

Routes.path("/users/log-in")                 # current locale
Routes.path("/users/log-in", locale: "et")   # /et/users/log-in
Routes.path("/sitemap.xml", locale: :none)   # never localized

Note that the primary language is emitted prefixlessly when the site is configured that way, so locale: "en" on an English-primary site yields a path with no /en segment — that is deliberate, not a dropped option.

See the multilang guide for how locales are resolved and switched; the symptom of getting this wrong (English auth pages on a translated site) reads like an i18n bug rather than a routing one.

phoenix_kit_app_base()

@spec phoenix_kit_app_base() :: String.t()

Gets the base module name for the parent application.

Reads from :phoenix_kit, :layouts_module config (e.g., MprojectWeb.Layouts -> MprojectWeb).

Examples

iex> PhoenixKit.Utils.Routes.phoenix_kit_app_base()
"MprojectWeb"

post_auth_path(candidates \\ [], opts \\ [])

@spec post_auth_path(
  [term()],
  keyword()
) :: String.t()

Resolves where to send a user once they are signed in and confirmed.

Takes candidate destinations in priority order (e.g. a ?return_to= param, then the session's user_return_to) and returns the first that passes local_path?/1. Falls back to the after_login_path setting, then to core's own guaranteed landing, /admin.

The setting is validated as a local path when saved, but is re-guarded here so a hand-edited DB row can't turn a post-auth redirect into an open redirect. Single source of truth for the post-auth landing page — used by the login flow (signed_in_path/1) and by both confirmation LiveViews.

Options

  • :context — the conn/socket the redirect is being built for. Pass it wherever one is at hand. "/" is the host's home page and core declares no route for it; with a context the tail of the chain is probed with routable?/2 and, when the host really has no /, handed to safe_destination/2, which can then pick the best destination for this subject instead of merely a safe one. Without a context the tail is path("/admin") — still safe, just less specific.
  • :scope — the subject, forwarded to safe_destination/2 for that last step. Only consulted when a :context is present.

The context-less tail is path("/admin") and deliberately not safe_destination(nil, opts): with no scope to go on that call runs the ANONYMOUS chain and terminates on /users/log-in, whose on_mount bounces an authenticated visitor straight back through here — the "core-owned destination" guarantee would survive exactly one hop. /admin is scope-blind on purpose: core declares it unconditionally and admits every authenticated visitor, so it cannot bounce anyone.

Examples

iex> PhoenixKit.Utils.Routes.post_auth_path(["/checkout"])
"/checkout"

With nothing usable to go on the chain lands on core's guaranteed landing. Its exact shape depends on the host's mount prefix and the language settings, so these read as comparisons rather than literals — they asserted "/" back when the tail was the host's unowned home page:

iex> alias PhoenixKit.Utils.Routes
iex> Routes.post_auth_path(["https://evil.com", nil]) == Routes.path("/admin")
true

iex> alias PhoenixKit.Utils.Routes
iex> Routes.post_auth_path(["/users/log-out"]) == Routes.path("/admin")
true

return_to_query(path)

@spec return_to_query(term()) :: String.t()

Renders ?return_to=<path> for a link, or "" when there is nothing safe to carry. Lets the auth pages hand the pending destination to each other instead of dropping it the moment a visitor switches to another sign-in method.

routable?(context, path)

@spec routable?(request_context(), term()) :: boolean()

Whether path actually resolves to a GET route in the application's router.

The router is taken from the request: conn.private[:phoenix_router], set by the generated router before dispatch, or socket.router, set when the LiveView is mounted at the router.

When the router cannot be determined this returns false. The two failure modes are asymmetric: emitting an unverified path is the 404 this whole mechanism exists to eliminate, while skipping the candidate merely falls through to a core-owned page that is guaranteed to exist.

safe_destination(context, opts \\ [])

@spec safe_destination(
  request_context(),
  keyword()
) :: String.t()

Resolves where to send a visitor that core is allowed to send them.

Replaces every hardcoded "/" / Routes.path("/") destination in core. Routes.path("/") emits a locale-prefixed root (/en); the route that would serve it belongs to the host application, which core cannot declare, so on a host that never declared one every such redirect 404s.

The chain

Authenticated (opts[:scope] passes Scope.authenticated?/1):

  1. :return_to — the untrusted explicit destination
  2. /admin, when Scope.can_access_admin_area?/1 and :skip_admin is not set
  3. /dashboard
  4. the after_login_path setting
  5. the host's home page — path("/"), then "/"

Anonymous:

  1. the main_page_path setting, when set and still resolvable
  2. the host's home page — path("/"), then "/"

Every candidate must be a local path (local_path?/1), not an auth page (auth_page?/1), and actually routable (routable?/2). Under :skip_admin it must additionally not be an admin-area path (admin_area_path?/1) — see the option below.

The home page is a candidate like any other, and only like any other. It is the one destination in this whole mechanism that core cannot declare, so it is used exactly where the host proves it declared it, and skipped silently everywhere else. Dropping it entirely would have been a silent regression for every already-working install, where logging out has always landed on the site home.

Both shapes are offered, locale-prefixed first, because a multilingual host may declare either or both — see home_candidates/0. The prefixed form is what the eleven original call sites emitted; the defect was that they emitted it unprobed, not that they named it.

When nothing survives, the terminal is core's own: /admin for any authenticated visitor, /users/log-in for an anonymous one. The terminal follows authentication rather than being a single page, because /users/log-in bounces a signed-in visitor straight back out again — terminating an authenticated chain there would hand the decision to post_auth_path/2, one hop later.

Options

  • :scope%PhoenixKit.Users.Auth.Scope{} or nil. Pass it explicitly. Several call sites run on pipelines that never assign a scope, and full logout still carries the just-logged-out user in conn.assigns after the session has been cleared, so inferring it here would answer "authenticated" about someone who no longer is.

  • :return_to — a candidate path, or a list of them in priority order. Honoured on the authenticated chain only: an anonymous pending destination belongs in the user_return_to session key, not in a redirect.

  • :skip_admin — the caller is rejecting this visitor from the admin area. It suppresses step 2 and drops every remaining candidate that resolves into the admin area (admin_area_path?/1), whichever step produced it — a :return_to, or an after_login_path an operator pointed at /admin/users. Suppressing only step 2 was not enough: on a host with user_dashboard_enabled: false the setting was the first candidate left standing, it is routable, and handing it back re-entered the same gate that had just refused the visitor — a candidate always won, the terminal was never reached, and the browser gave up with ERR_TOO_MANY_REDIRECTS.

    The terminal is deliberately NOT filtered: it is the /admin index, which the gate admits every authenticated visitor to, so arriving there is a render rather than a second bounce. That asymmetry is the whole point — the chain has somewhere to end.

The invariant

Every value returned is either a path the caller supplied, an administrator configured, or one of the two shapes of the host's own home page — each proven to resolve in the router — or one of the two landings core declares itself. There is no third branch, and nothing is ever returned unprobed except those two landings, which core declares and permits unconditionally.

path("/"), the locale-prefixed root that started all this, is therefore still a candidate — it simply may no longer be synthesized. That distinction is the fix: the eleven original call sites returned it without asking whether it resolved, which is why it 404'd.

The invariant used to be "every candidate was probed, terminals included", because core could promise nothing about its own pages: /dashboard is compiled out by user_dashboard_enabled: false, and /admin used to reject an authenticated visitor who held no admin rights. Neither is true of the terminals any more:

  • /admin is declared unconditionally by the admin index route and, since :phoenix_kit_ensure_admin exempts that one view from its permission checks (PhoenixKitWeb.Users.Auth.landing_view?/1), admits every authenticated visitor — one who holds no rights is greeted and shown nothing else. So it can neither 404 nor bounce.
  • /users/log-in is declared unconditionally too, by the public auth surface. That is a separate fact from the /admin decision: it rests on generate_public_live_routes/1 in PhoenixKitWeb.Integration, not on anything the admin area does, and it holds independently of it.

So the invariant is now: the chain ends at a path core declares unconditionally and permits unconditionally. The terminal is still handed to routable?/2, but only as a diagnostic — the arm is returned either way, and the probe exists to name a misconfigured install in the log instead of letting it surface as a mystery 404. See terminal/2.

That invariant is about the TERMINAL, and stating it was not enough to make the chain terminate. A candidate that wins is returned instead of the terminal, so under :skip_admin — the rejection path — the candidates are held to the weaker fact the caller actually needs: no candidate may be an admin-area path. Otherwise the resolver can answer with the very kind of page the visitor was just refused, the gate refuses it again, and the identical computation runs forever without ever reaching the terminal it was promised. With the filter in place a skip_admin resolution is either a non-admin path proven routable in this router, or the /admin index — and the index admits everyone, so the next hop renders.

The authenticated terminal is not an auth page: /users/log-in redirects an authenticated visitor through post_auth_path/2, which re-enters this function, so using it there is an infinite redirect rather than a fallback.

url(url_path)

Returns a full url with preconfigured prefix.

This function first checks for a configured site URL in Settings, then automatically detects the correct URL from the running Phoenix application endpoint when possible, falling back to static configuration. This ensures that magic links and other email links work correctly in both development and production environments, with full control over the base URL through the Settings admin panel.

url_prefix()

@spec url_prefix() :: String.t()

Returns the configured PhoenixKit URL prefix.

Examples

iex> PhoenixKit.Utils.Routes.url_prefix()
"/phoenix_kit"