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).
This commit is contained in:
Lain Soykaf 2026-10-27 12:28:52 +04:00
commit f975db8719
No known key found for this signature in database
3 changed files with 66 additions and 6 deletions

View file

@ -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

View file

@ -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

View file

@ -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