mitgliederverwaltung/lib/mv_web/helpers/join_description_renderer.ex

70 lines
2.4 KiB
Elixir

defmodule MvWeb.Helpers.JoinDescriptionRenderer do
@moduledoc """
Renders a custom field's `join_description` into Phoenix-safe HTML for the
public join form.
The renderer auto-links two patterns into `<a href="...">` tags:
- Markdown links of the form `[text](url)` (processed first)
- bare `http(s)://` URLs in the remaining text
All other content is HTML-escaped: only `<a href="...">` tags are ever
emitted, so arbitrary HTML in the input is rendered as inert text. This is a
defense-in-depth measure — `join_description` is admin-set content, never
end-user input — but the renderer must not become a vector for injecting
arbitrary markup.
Markdown links are matched before bare URLs and their matched region is
consumed, so a Markdown link whose URL also looks like a bare URL is linked
exactly once (no nested anchors).
"""
@markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
@bare_url ~r/(https?:\/\/[^\s<]+)/
@bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
@doc """
Converts `value` to a Phoenix-safe HTML iolist.
Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
links rendered and all other text HTML-escaped.
"""
@spec render(String.t() | nil) :: Phoenix.HTML.safe()
def render(nil), do: {:safe, ""}
def render(value) when is_binary(value) do
{:safe, render_segments(value)}
end
# Split on Markdown links first; for each non-Markdown segment, link bare URLs;
# everything that is not a link is HTML-escaped.
defp render_segments(text) do
Regex.split(@markdown_link, text, include_captures: true)
|> Enum.map(&render_markdown_or_plain/1)
end
defp render_markdown_or_plain(segment) do
case Regex.run(@markdown_link, segment) do
[^segment, label, url] -> anchor(url, label)
_ -> render_plain(segment)
end
end
# Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
defp render_plain(segment) do
Regex.split(@bare_url, segment, include_captures: true)
|> Enum.map(fn part ->
if Regex.match?(@bare_url_anchored, part) do
anchor(part, part)
else
escape(part)
end
end)
end
defp anchor(url, label) do
["<a href=\"", escape(url), "\" class=\"link link-primary\">", escape(label), "</a>"]
end
defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
end