defmodule AshDispatch.SafeRender do @moduledoc """ HTML-safe rendering helpers for `AshDispatch.TemplateResolver`. When `TemplateResolver` renders an `email.html.heex` template it preprocesses HEEx-style `{@var}` markers into `<%= @var %>` and evaluates the result via `EEx.eval_string/2`. Plain EEx does **not** HTML-escape interpolated values, so any user-controlled string flowing through `prepare_template_assigns/2` (lead name, contract recipient, customer comment, etc.) would land raw in the rendered email — a real injection vector. The preprocessor wraps every auto-converted `{@var}` expansion in `AshDispatch.SafeRender.escape/1` for HTML formats so escaping is the default, matching Phoenix HEEx semantics. ## Opt-out (when you genuinely have safe markup)

{AshDispatch.SafeRender.raw @already_safe_html}

`raw/1` returns its argument unchanged and is recognized as a "not a bare `@var`" expression by the preprocessor, so it's emitted as plain `<%= raw(@x) %>` without the escape wrapper. ## Phoenix.HTML interop If an assign value is a `{:safe, iodata}` tuple (Phoenix's `Phoenix.HTML.Safe` output) `escape/1` returns the iodata as-is — the value already promises it is escaped. """ @doc """ Escape a value for safe inclusion in HTML output. - `nil` → `""` (so `{@missing_field}` doesn't render the string "nil") - `binary` → HTML-escaped - `integer` / `float` / `atom` → string-converted (no escape needed — these can't contain markup characters that change meaning) - `{:safe, iodata}` → returned unchanged (already escaped by caller) - Anything else → `to_string/1` then escape """ @spec escape(any()) :: iodata() def escape(nil), do: "" def escape({:safe, iodata}), do: iodata def escape(value) when is_integer(value) or is_float(value), do: to_string(value) def escape(value) when is_atom(value), do: Atom.to_string(value) def escape(value) when is_binary(value) do # In-line escape so we don't take a new dependency on Plug.HTML or # phoenix_html — the substitution table matches both. do_escape(value, []) end # Anything else — fall back to String.Chars if implemented. This catches # Date, NaiveDateTime, Decimal, custom structs, etc. Raises if the type # doesn't have a String.Chars impl (e.g. plain maps, lists, tuples) — # better to fail loud than silently render `<<1,2,3>>` from an iolist. def escape(value), do: value |> to_string() |> escape() @doc """ Mark a value as already-safe HTML so the preprocessor's auto-escape is bypassed. In templates:

{AshDispatch.SafeRender.raw @trusted_html}

Returns its argument unchanged — the wrapper is only meaningful as a signal to the HEEx preprocessor, which sees `raw(@x)` is not a bare `@var` and emits `<%= raw(@x) %>` without escape. """ @spec raw(value) :: value when value: any() def raw(value), do: value # ── internals ────────────────────────────────────────────────── # Single-pass HTML escape — same substitutions as Plug.HTML.html_escape/1. defp do_escape(<<>>, acc), do: IO.iodata_to_binary(Enum.reverse(acc)) defp do_escape(<>, acc), do: do_escape(rest, ["<" | acc]) defp do_escape(<, rest::binary>>, acc), do: do_escape(rest, [">" | acc]) defp do_escape(<>, acc), do: do_escape(rest, ["&" | acc]) defp do_escape(<>, acc), do: do_escape(rest, [""" | acc]) defp do_escape(<>, acc), do: do_escape(rest, ["'" | acc]) defp do_escape(<>, acc), do: do_escape(rest, [<> | acc]) end