From f975db87191fae2d1e40d8736d49434352a71344 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:28:52 +0400 Subject: [PATCH 1/2] 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 From 422a1d1475bd3db41a05b1a36036f8ae0c27b3c9 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 27 Oct 2026 12:50:04 +0400 Subject: [PATCH 2/2] fix(filters): honour whole_word The whole_word field was stored and returned by the API, but the regex always used word boundaries. As in Mastodon, a filter with whole_word false now also matches inside words. --- changelog.d/filter-whole-word.fix | 1 + lib/pleroma/filter.ex | 11 +++++++---- test/pleroma/filter_test.exs | 11 +++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 changelog.d/filter-whole-word.fix diff --git a/changelog.d/filter-whole-word.fix b/changelog.d/filter-whole-word.fix new file mode 100644 index 000000000..a831a74cb --- /dev/null +++ b/changelog.d/filter-whole-word.fix @@ -0,0 +1 @@ +Filters with whole_word set to false now also match inside words diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 3ab44efb0..fe3fb1c27 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -212,7 +212,7 @@ defmodule Pleroma.Filter do phrases = filters - |> Enum.map(&phrase_regex(&1.phrase, boundary)) + |> Enum.map(&phrase_regex(&1, boundary)) |> Enum.join("|") case format do @@ -223,9 +223,12 @@ defmodule Pleroma.Filter do 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 + # Phrases are literal text. Like Mastodon, whole-word filters only get a + # word boundary where the phrase starts or ends with a word character. + defp phrase_regex(%__MODULE__{phrase: phrase, whole_word: false}, _boundary), + do: Regex.escape(phrase) + + defp phrase_regex(%__MODULE__{phrase: 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: "" diff --git a/test/pleroma/filter_test.exs b/test/pleroma/filter_test.exs index 5018b30bc..2fc72d9f5 100644 --- a/test/pleroma/filter_test.exs +++ b/test/pleroma/filter_test.exs @@ -233,6 +233,17 @@ defmodule Pleroma.FilterTest do end end + test "matches inside words when whole_word is false" do + filters = [%Filter{phrase: "cat", whole_word: false}, %Filter{phrase: "dog"}] + pg = Filter.compose_regex(filters) + re = Filter.compose_regex(filters, :re) + + for {text, expected} <- [{"a catalog", true}, {"a hotdog", false}, {"a dog", true}] 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)