579 lines
16 KiB
Elixir
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
|