From f975db87191fae2d1e40d8736d49434352a71344 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:28:52 +0400 Subject: [PATCH] fix(filters): treat phrases as literal text in the regex Phrases went into the Postgres and Elixir regexes as they were, so a phrase like "(" broke the timeline and notification queries. The :re form also put the word boundaries only around the whole alternation. Escape each phrase and, as Mastodon does, add a word boundary only where the phrase starts or ends with a word character. Reported in Akkoma (d6209837b). --- changelog.d/filter-regex-escape.fix | 1 + lib/pleroma/filter.ex | 20 +++++++---- test/pleroma/filter_test.exs | 51 +++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 changelog.d/filter-regex-escape.fix diff --git a/changelog.d/filter-regex-escape.fix b/changelog.d/filter-regex-escape.fix new file mode 100644 index 000000000..e6e011e3a --- /dev/null +++ b/changelog.d/filter-regex-escape.fix @@ -0,0 +1 @@ +Treat filter phrases as literal text; a phrase like "(" no longer breaks timeline and notification queries, and phrases that start or end with punctuation (like "#tag") now match diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 77ed64d4f..3ab44efb0 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -208,19 +208,27 @@ defmodule Pleroma.Filter do end def compose_regex([_ | _] = filters, format) do + boundary = if format == :postgres, do: "\\y", else: "\\b" + phrases = filters - |> Enum.map(& &1.phrase) + |> Enum.map(&phrase_regex(&1.phrase, boundary)) |> Enum.join("|") case format do - :postgres -> - "\\y(#{phrases})\\y" - - :re -> - ~r/\b#{phrases}\b/i + :postgres -> "(#{phrases})" + :re -> Regex.compile!("(?:#{phrases})", "iu") end end def compose_regex(_, _), do: nil + + # Phrases are literal text. Like Mastodon, only add a word boundary + # where the phrase starts or ends with a word character. + defp phrase_regex(phrase, boundary) do + prefix = if String.match?(phrase, ~r/^\w/u), do: boundary, else: "" + suffix = if String.match?(phrase, ~r/\w$/u), do: boundary, else: "" + + prefix <> Regex.escape(phrase) <> suffix + end end diff --git a/test/pleroma/filter_test.exs b/test/pleroma/filter_test.exs index 6d33cd22b..5018b30bc 100644 --- a/test/pleroma/filter_test.exs +++ b/test/pleroma/filter_test.exs @@ -190,4 +190,55 @@ defmodule Pleroma.FilterTest do {:error, _} = Filter.update(filter, changes) end + + describe "compose_regex/2" do + defp filters(phrases), do: Enum.map(phrases, &%Filter{phrase: &1}) + + defp pg_match?(regex, text) do + %{rows: [[match]]} = Repo.query!("SELECT $1::text ~* $2", [text, regex]) + match + end + + test "treats phrases as literal text in Postgres" do + regex = Filter.compose_regex(filters(["a.b", "(c"])) + + assert pg_match?(regex, "see a.b here") + refute pg_match?(regex, "see axb here") + assert pg_match?(regex, "see (c here") + end + + test "treats phrases as literal text in Elixir" do + regex = Filter.compose_regex(filters(["a.b", "(c"]), :re) + + assert Regex.match?(regex, "see a.b here") + refute Regex.match?(regex, "see axb here") + assert Regex.match?(regex, "see (c here") + end + + test "keeps word boundaries for every phrase in Postgres" do + regex = Filter.compose_regex(filters(["cat", "dog"])) + + assert pg_match?(regex, "a dog barks") + refute pg_match?(regex, "a catalog") + refute pg_match?(regex, "a hotdog") + end + + test "only adds word boundaries next to word characters" do + pg = Filter.compose_regex(filters(["#tag"])) + re = Filter.compose_regex(filters(["#tag"]), :re) + + for {text, expected} <- [{"a #tag here", true}, {"a#tag", true}, {"some #tags", false}] do + assert pg_match?(pg, text) == expected + assert Regex.match?(re, text) == expected + end + end + + test "keeps word boundaries for every phrase in Elixir" do + regex = Filter.compose_regex(filters(["cat", "dog"]), :re) + + assert Regex.match?(regex, "a dog barks") + refute Regex.match?(regex, "a catalog") + refute Regex.match?(regex, "a hotdog") + end + end end