2025-04-16 10:03:13 -03:00

579 lines
16 KiB
Elixir

defmodule Phoenix.LiveView.Utils do
# Shared helpers used mostly by Channel and Diff,
# but also Static, and LiveViewTest.
@moduledoc false
alias Phoenix.LiveView.{Socket, Lifecycle}
# All available mount options
@mount_opts [:temporary_assigns, :layout]
@max_flash_age :timer.seconds(60)
@valid_uri_schemes [
"http:",
"https:",
"ftp:",
"ftps:",
"mailto:",
"news:",
"irc:",
"gopher:",
"nntp:",
"feed:",
"telnet:",
"mms:",
"rtsp:",
"svn:",
"tel:",
"fax:",
"xmpp:"
]
@doc """
Assigns a value if it changed.
"""
def assign(%Socket{} = socket, key, value) do
case socket do
%{assigns: %{^key => ^value}} -> socket
%{} -> force_assign(socket, key, value)
end
end
@doc """
Assigns the given `key` with value from `fun` into `socket_or_assigns` if one does not yet exist.
"""
def assign_new(%Socket{} = socket, key, fun) when is_function(fun, 1) do
case socket do
%{assigns: %{^key => _}} ->
socket
%{private: %{assign_new: {assigns, keys}}} ->
# It is important to store the keys even if they are not in assigns
# because maybe the controller doesn't have it but the view does.
socket = put_in(socket.private.assign_new, {assigns, [key | keys]})
Phoenix.LiveView.Utils.force_assign(
socket,
key,
case assigns do
%{^key => value} -> value
%{} -> fun.(socket.assigns)
end
)
%{assigns: assigns} ->
Phoenix.LiveView.Utils.force_assign(socket, key, fun.(assigns))
end
end
def assign_new(%Socket{} = socket, key, fun) when is_function(fun, 0) do
case socket do
%{assigns: %{^key => _}} ->
socket
%{private: %{assign_new: {assigns, keys}}} ->
# It is important to store the keys even if they are not in assigns
# because maybe the controller doesn't have it but the view does.
socket = put_in(socket.private.assign_new, {assigns, [key | keys]})
Phoenix.LiveView.Utils.force_assign(socket, key, Map.get_lazy(assigns, key, fun))
%{} ->
Phoenix.LiveView.Utils.force_assign(socket, key, fun.())
end
end
@doc """
Forces an assign on a socket.
"""
def force_assign(%Socket{assigns: assigns} = socket, key, val) do
%{socket | assigns: force_assign(assigns, assigns.__changed__, key, val)}
end
@doc """
Forces an assign with the given changed map.
"""
def force_assign(assigns, nil, key, val), do: Map.put(assigns, key, val)
def force_assign(assigns, changed, key, val) do
# If the current value is a map, we store it in changed so
# we can perform nested change tracking. Also note the use
# of put_new is important. We want to keep the original value
# from assigns and not any intermediate ones that may appear.
current_val = Map.get(assigns, key)
changed_val = if is_map(current_val), do: current_val, else: true
changed = Map.put_new(changed, key, changed_val)
Map.put(%{assigns | __changed__: changed}, key, val)
end
@doc """
Clears the changes from the socket assigns.
"""
def clear_changed(%Socket{private: private, assigns: assigns} = socket) do
temporary = Map.get(private, :temporary_assigns, %{})
%{socket | assigns: assigns |> Map.merge(temporary) |> Map.put(:__changed__, %{})}
end
@doc """
Clears temporary data (flash, pushes, etc) from the socket privates.
"""
def clear_temp(socket) do
put_in(socket.private.live_temp, %{})
end
@doc """
Checks if the socket changed.
"""
def changed?(%Socket{assigns: %{__changed__: changed}}), do: changed != %{}
@doc """
Checks if the given assign changed.
"""
def changed?(%Socket{} = socket, assign), do: changed?(socket.assigns, assign)
def changed?(%{__changed__: nil}, _assign), do: true
def changed?(%{__changed__: changed}, assign), do: Map.has_key?(changed, assign)
@doc """
Returns the CID of the given socket.
"""
def cid(%Socket{assigns: %{myself: %Phoenix.LiveComponent.CID{} = cid}}), do: cid
def cid(%Socket{}), do: nil
@doc """
Configures the socket for use.
"""
def configure_socket(%Socket{id: nil} = socket, private, action, flash, host_uri) do
%{
socket
| id: random_id(),
private: private,
assigns: configure_assigns(socket.assigns, action, flash),
host_uri: prune_uri(host_uri)
}
end
def configure_socket(%Socket{} = socket, private, action, flash, host_uri) do
assigns = configure_assigns(socket.assigns, action, flash)
%{socket | host_uri: prune_uri(host_uri), private: private, assigns: assigns}
end
defp configure_assigns(assigns, action, flash) do
Map.merge(assigns, %{live_action: action, flash: flash})
end
defp prune_uri(:not_mounted_at_router), do: :not_mounted_at_router
defp prune_uri(url) do
%URI{host: host, port: port, scheme: scheme} = url
if host == nil do
raise "client did not send full URL, missing host in #{url}"
end
%URI{host: host, port: port, scheme: scheme}
end
@doc """
Returns a random ID with valid DOM tokens
"""
def random_id do
"phx-"
|> Kernel.<>(random_encoded_bytes())
|> String.replace(["/", "+"], "-")
end
@doc """
Prunes any data no longer needed after mount.
"""
def post_mount_prune(%Socket{} = socket) do
socket
|> clear_changed()
|> clear_temp()
|> drop_private([:connect_info, :connect_params, :assign_new])
end
@doc """
Validate and normalizes the layout.
"""
def normalize_layout(false), do: false
def normalize_layout({mod, layout}) when is_atom(mod) and is_atom(layout) do
{mod, Atom.to_string(layout)}
end
def normalize_layout(other) do
raise ArgumentError,
":layout expects a tuple of the form {MyLayouts, :my_template} or false, " <>
"got: #{inspect(other)}"
end
@doc """
Returns the socket's flash messages.
"""
def get_flash(%Socket{assigns: assigns}), do: assigns.flash
def get_flash(%{} = flash, key), do: flash[key]
@doc """
Puts a new flash with the socket's flash messages.
"""
def replace_flash(%Socket{} = socket, %{} = new_flash) do
assign(socket, :flash, new_flash)
end
@doc """
Clears the flash.
"""
def clear_flash(%Socket{} = socket) do
assign(socket, :flash, %{})
end
@doc """
Clears the key from the flash.
"""
def clear_flash(%Socket{} = socket, key) do
key = flash_key(key)
new_flash = Map.delete(socket.assigns.flash, key)
socket = assign(socket, :flash, new_flash)
update_in(socket.private.live_temp[:flash], &Map.delete(&1 || %{}, key))
end
@doc """
Puts a flash message in the socket.
"""
def put_flash(%Socket{assigns: assigns} = socket, key, msg) do
key = flash_key(key)
new_flash = Map.put(assigns.flash, key, msg)
socket = assign(socket, :flash, new_flash)
update_in(socket.private.live_temp[:flash], &Map.put(&1 || %{}, key, msg))
end
@doc """
Returns a map of the flash messages which have changed.
"""
def changed_flash(%Socket{} = socket) do
socket.private.live_temp[:flash] || %{}
end
defp flash_key(binary) when is_binary(binary), do: binary
defp flash_key(atom) when is_atom(atom), do: Atom.to_string(atom)
@doc """
Annotates the changes with the event to be pushed.
Events are dispatched on the JavaScript side only after
the current patch is invoked. Therefore, if the LiveView
redirects, the events won't be invoked.
"""
def push_event(%Socket{} = socket, event, %{} = payload) do
update_in(socket.private.live_temp[:push_events], &[[event, payload] | &1 || []])
end
@doc """
Annotates the reply in the socket changes.
"""
def put_reply(%Socket{} = socket, %{} = payload) do
put_in(socket.private.live_temp[:push_reply], payload)
end
@doc """
Returns the push events in the socket.
"""
def get_push_events(%Socket{} = socket) do
Enum.reverse(socket.private.live_temp[:push_events] || [])
end
@doc """
Returns the reply in the socket.
"""
def get_reply(%Socket{} = socket) do
socket.private.live_temp[:push_reply]
end
@doc """
Returns the configured signing salt for the endpoint.
"""
def salt!(endpoint) when is_atom(endpoint) do
salt = endpoint.config(:live_view)[:signing_salt]
if is_binary(salt) and byte_size(salt) >= 8 do
salt
else
raise ArgumentError, """
the signing salt for #{inspect(endpoint)} is missing or too short.
Add the following LiveView configuration to your config/runtime.exs
or config/config.exs:
config :my_app, MyAppWeb.Endpoint,
...,
live_view: [signing_salt: "#{random_encoded_bytes()}"]
"""
end
end
@doc """
Raises error message for bad live patch on mount.
"""
def raise_bad_mount_and_live_patch!() do
raise RuntimeError, """
attempted to live patch while mounting.
a LiveView cannot be mounted while issuing a live patch to the client. \
Use push_navigate/2 or redirect/2 instead if you wish to mount and redirect.
"""
end
@doc """
Calls the `c:Phoenix.LiveView.mount/3` callback, otherwise returns the socket as is.
"""
def maybe_call_live_view_mount!(%Socket{} = socket, view, params, session, uri \\ nil) do
%{any?: any?, exported?: exported?} = Lifecycle.stage_info(socket, view, :mount, 3)
if any? do
:telemetry.span(
[:phoenix, :live_view, :mount],
%{socket: socket, params: params, session: session, uri: uri},
fn ->
socket =
case Lifecycle.mount(params, session, socket) do
{:cont, %Socket{} = socket} when exported? ->
view.mount(params, session, socket)
{_, %Socket{} = socket} ->
{:ok, socket}
end
|> handle_mount_result!({view, :mount, 3})
{socket, %{socket: socket, params: params, session: session, uri: uri}}
end
)
else
socket
end
end
@doc """
Calls the `c:Phoenix.LiveComponent.mount/1` callback, otherwise returns the socket as is.
"""
def maybe_call_live_component_mount!(%Socket{} = socket, component) do
if Code.ensure_loaded?(component) and function_exported?(component, :mount, 1) do
socket
|> component.mount()
|> handle_mount_result!({component, :mount, 1})
else
socket
end
end
defp handle_mount_result!({:ok, %Socket{} = socket, opts}, context)
when is_list(opts) do
validate_mount_redirect!(socket.redirected)
handle_mount_options!(socket, opts, context)
end
defp handle_mount_result!({:ok, %Socket{} = socket}, _context) do
validate_mount_redirect!(socket.redirected)
socket
end
defp handle_mount_result!(response, {mod, fun, arity}) do
raise ArgumentError, """
invalid result returned from #{inspect(mod)}.#{fun}/#{arity}.
Expected {:ok, socket} | {:ok, socket, opts}, got: #{inspect(response)}
"""
end
defp validate_mount_redirect!({:live, :patch, _}), do: raise_bad_mount_and_live_patch!()
defp validate_mount_redirect!(_), do: :ok
@doc """
Handle all valid options on mount/on_mount.
"""
def handle_mount_options!(%Socket{} = socket, opts, {mod, fun, arity}) do
Enum.reduce(opts, socket, fn
{key, val}, socket when key in @mount_opts ->
handle_mount_option(socket, key, val)
{key, val}, _socket ->
raise ArgumentError, """
invalid option returned from #{inspect(mod)}.#{fun}/#{arity}.
Expected keys to be one of #{inspect(@mount_opts)}
got: #{inspect(key)}: #{inspect(val)}
"""
end)
end
defp handle_mount_option(socket, :layout, layout) do
put_in(socket.private[:live_layout], normalize_layout(layout))
end
defp handle_mount_option(socket, :temporary_assigns, temp_assigns) do
unless Keyword.keyword?(temp_assigns) do
raise "the :temporary_assigns mount option must be keyword list"
end
temp_assigns = Map.new(temp_assigns)
%Socket{
socket
| assigns: Map.merge(temp_assigns, socket.assigns),
private:
Map.update(
socket.private,
:temporary_assigns,
temp_assigns,
&Map.merge(&1, temp_assigns)
)
}
end
@doc """
Calls the `handle_params/3` callback, and returns the result.
This function expects the calling code has checked to see if this function has
been exported, otherwise it assumes the function has been exported.
Raises an `ArgumentError` on unexpected return types.
"""
def call_handle_params!(%Socket{} = socket, view, exported? \\ true, params, uri)
when is_boolean(exported?) do
:telemetry.span(
[:phoenix, :live_view, :handle_params],
%{socket: socket, params: params, uri: uri},
fn ->
case Lifecycle.handle_params(params, uri, socket) do
{:cont, %Socket{} = socket} when exported? ->
case view.handle_params(params, uri, socket) do
{:noreply, %Socket{} = socket} ->
{{:noreply, socket}, %{socket: socket, params: params, uri: uri}}
other ->
raise ArgumentError, """
invalid result returned from #{inspect(view)}.handle_params/3.
Expected {:noreply, socket}, got: #{inspect(other)}
"""
end
{_, %Socket{} = socket} ->
{{:noreply, socket}, %{socket: socket, params: params, uri: uri}}
end
end
)
end
@doc """
Calls the optional `update/2` or `update_many/1` callback, otherwise update the socket(s) directly.
"""
def maybe_call_update!(socket, component, assigns) do
cond do
function_exported?(component, :update_many, 1) ->
case component.update_many([{assigns, socket}]) do
[%Socket{} = socket] ->
socket
other ->
raise "#{inspect(component)}.update_many/1 must return a list of Phoenix.LiveView.Socket " <>
"of the same length as the input list, got: #{inspect(other)}"
end
function_exported?(component, :update, 2) ->
socket =
case component.update(assigns, socket) do
{:ok, %Socket{} = socket} ->
socket
other ->
raise ArgumentError, """
invalid result returned from #{inspect(component)}.update/2.
Expected {:ok, socket}, got: #{inspect(other)}
"""
end
if socket.redirected do
raise "cannot redirect socket on update. Redirect before `update/2` is called" <>
" or use `send/2` and redirect in the `handle_info/2` response"
end
socket
true ->
Enum.reduce(assigns, socket, fn {k, v}, acc -> assign(acc, k, v) end)
end
end
@doc """
Signs the socket's flash into a token if it has been set.
"""
def sign_flash(endpoint_mod, %{} = flash) do
Phoenix.Token.sign(endpoint_mod, flash_salt(endpoint_mod), flash)
end
@doc """
Verifies the socket's flash token.
"""
def verify_flash(endpoint_mod, flash_token) do
salt = flash_salt(endpoint_mod)
case Phoenix.Token.verify(endpoint_mod, salt, flash_token, max_age: @max_flash_age) do
{:ok, flash} -> flash
{:error, _reason} -> %{}
end
end
defp random_encoded_bytes do
binary = <<
System.system_time(:nanosecond)::64,
:erlang.phash2({node(), self()})::16,
:erlang.unique_integer()::16
>>
Base.url_encode64(binary)
end
defp drop_private(%Socket{private: private} = socket, keys) do
%Socket{socket | private: Map.drop(private, keys)}
end
defp flash_salt(endpoint_mod) when is_atom(endpoint_mod) do
"flash:" <> salt!(endpoint_mod)
end
def valid_destination!(%URI{} = uri, context) do
valid_destination!(URI.to_string(uri), context)
end
def valid_destination!({:safe, to}, context) do
{:safe, valid_string_destination!(IO.iodata_to_binary(to), context)}
end
def valid_destination!({other, to}, _context) when is_atom(other) do
[Atom.to_string(other), ?:, to]
end
def valid_destination!(to, context) do
valid_string_destination!(IO.iodata_to_binary(to), context)
end
for scheme <- @valid_uri_schemes do
def valid_string_destination!(unquote(scheme) <> _ = string, _context), do: string
end
def valid_string_destination!(to, context) do
if not match?("/" <> _, to) and String.contains?(to, ":") do
raise ArgumentError, """
unsupported scheme given to #{context}. In case you want to link to an
unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest}
"""
else
to
end
end
end