diff --git a/lib/mv_web/helpers/join_description_renderer.ex b/lib/mv_web/helpers/join_description_renderer.ex new file mode 100644 index 0000000..121b02b --- /dev/null +++ b/lib/mv_web/helpers/join_description_renderer.ex @@ -0,0 +1,70 @@ +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 `` 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 `` 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 + ["", escape(label), ""] + end + + defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string() +end diff --git a/test/mv_web/helpers/join_description_renderer_test.exs b/test/mv_web/helpers/join_description_renderer_test.exs new file mode 100644 index 0000000..62eb91d --- /dev/null +++ b/test/mv_web/helpers/join_description_renderer_test.exs @@ -0,0 +1,85 @@ +defmodule MvWeb.Helpers.JoinDescriptionRendererTest do + @moduledoc """ + Tests for the join-description renderer that auto-links raw URLs and Markdown + links while escaping all other content. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias MvWeb.Helpers.JoinDescriptionRenderer + + defp html(value) do + value + |> JoinDescriptionRenderer.render() + |> Phoenix.HTML.safe_to_string() + end + + describe "render/1" do + test "converts a raw URL to an anchor tag" do + result = html("Akzeptiere https://example.com/dsgvo") + + assert result =~ ~s(" + assert result =~ "Akzeptiere " + end + + test "converts Markdown [text](url) to an anchor tag with the link text" do + result = html("[Datenschutzerklärung](https://example.com/dsgvo)") + + assert result =~ ~s(Datenschutzerklärung" + end + + test "returns an empty safe string for nil input" do + assert JoinDescriptionRenderer.render(nil) == {:safe, ""} + end + + test "escapes arbitrary HTML in non-link text" do + result = html("") + + refute result =~ "