federation/in: drop remote part from received emoji reactions

The remote part is included in federated emoji names by e.g.
Iceshrimp.NET ever since remote emoji support was added in
4d21aa1670
and as of writing it still continues to do so.
It adds no value for us though; we add the remote part automatically
based on the URL and it makes it more difficult to correctly coalesce
the original reaction (from a user for whom the moji was local)
and the subsequent reactions with the identical emoji from users of
other instances. Additionally the remote part can cause issues when
later used with our REST API.

For non-reactions this is unproblematic and thus
there’s no need to change anything there.

Use a migration to fix up existing activities.
This will cause some (further) desync from the inlined reactions
array, but will be fixable with the resync mix task and avoids
issues when running the resync without first fixing existing activities.
This commit is contained in:
Oneric 2025-08-06 00:00:00 +00:00
commit 89801abad5
3 changed files with 214 additions and 0 deletions

View file

@ -57,6 +57,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do
|> fix_emoji_qualification()
|> CommonFixes.fix_actor()
|> CommonFixes.fix_activity_addressing()
|> prune_tags()
|> drop_remote_indicator()
data =
if Map.has_key?(data, "tag") do
@ -133,4 +135,54 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do
|> validate_emoji()
|> maybe_validate_tag_presence()
end
# All tags but the single emoji tag corresponding to the used custom emoji (if any)
# are ignored anyway. Having a known single-element array makes further processing easier.
# Also ensures the Emoji tag uses a pre-stripped name
defp prune_tags(%{"content" => emoji, "tag" => tags} = data) do
clean_emoji = Emoji.stripped_name(emoji)
pruned_tags =
Enum.reduce_while(tags, [], fn
%{"type" => "Emoji", "name" => name} = tag, res ->
clean_name = Emoji.stripped_name(name)
if clean_name == clean_emoji do
{:halt, [%{tag | "name" => clean_name}]}
else
{:cont, res}
end
_, res ->
{:cont, res}
end)
%{data | "tag" => pruned_tags}
end
defp prune_tags(data), do: data
# some software, like Iceshrimp.NET, federates emoji reaction with (from its POV) remote emoji
# with the source instance added to the name in AP as an @ postfix, similar to how its handled
# in Akkomas REST API.
# However, this leads to duplicated remote indicators being presented to our clients an can cause
# issues when trying to split the values we receive from REST API. Thus just drop them here.
defp drop_remote_indicator(%{"content" => emoji, "tag" => tag} = data) when is_list(tag) do
if String.contains?(emoji, "@") do
stripped_emoji = Emoji.stripped_name(emoji)
[clean_emoji | _] = String.split(stripped_emoji, "@", parts: 2)
clean_tag =
Enum.map(tag, fn
%{"name" => ^stripped_emoji} = t -> %{t | "name" => clean_emoji}
t -> t
end)
%{data | "content" => ":" <> clean_emoji <> ":", "tag" => clean_tag}
else
data
end
end
defp drop_remote_indicator(data), do: data
end

View file

@ -0,0 +1,102 @@
defmodule Pleroma.Repo.Migrations.EmojiReactDropRemotePartFromName do
use Ecto.Migration
import Ecto.Query
defp drop_remote_indicator(%{"content" => emoji, "tag" => _tag} = data) do
if String.contains?(emoji, "@") do
do_drop_remote_indicator(data)
else
data
end
end
defp do_drop_remote_indicator(%{"content" => emoji, "tag" => tag} = data) do
stripped_emoji = Pleroma.Emoji.stripped_name(emoji)
[clean_emoji | _] = String.split(stripped_emoji, "@", parts: 2)
clean_tag =
Enum.map(tag, fn
%{"name" => ^stripped_emoji} = t -> %{t | "name" => clean_emoji}
t -> t
end)
%{data | "content" => ":" <> clean_emoji <> ":", "tag" => clean_tag}
end
defp prune_and_strip_tags(%{"content" => emoji, "tag" => tags} = data) do
clean_emoji = Pleroma.Emoji.stripped_name(emoji)
pruned_tags =
Enum.reduce_while(tags, [], fn
%{"type" => "Emoji", "name" => name} = tag, res ->
clean_name = Pleroma.Emoji.stripped_name(name)
if clean_name == clean_emoji do
{:halt, [%{tag | "name" => clean_name}]}
else
{:cont, res}
end
_, res ->
{:cont, res}
end)
%{data | "tag" => pruned_tags}
end
def up() do
Pleroma.Activity
|> where(
[a],
fragment("?->>'type' = 'EmojiReact'", a.data) and
fragment("jsonb_typeof(?->'content') = 'string'", a.data) and
fragment("jsonb_typeof(?->'tag') = 'array'", a.data)
)
|> Pleroma.Repo.chunk_stream(600, :batches, timeout: :infinity)
|> Stream.each(fn chunk ->
Enum.reduce(chunk, {[], []}, fn %{id: id, data: data}, {ids, newdat} ->
new_data =
data
|> prune_and_strip_tags()
|> drop_remote_indicator()
if new_data == data do
{ids, newdat}
else
# not sure why we get a string back from the db here and need to explicit convert it back
{[FlakeId.from_string(id) | ids], [new_data | newdat]}
end
end)
|> then(fn
{[], []} ->
IO.puts("Nothing in current batch")
:ok
{ids, newdat} ->
{upcnt, _} =
Pleroma.Activity
|> join(
:inner,
[a],
news in fragment(
"SELECT * FROM unnest(?::uuid[], ?::jsonb[]) AS news(id, new_data)",
^ids,
^newdat
),
on: a.id == news.id
)
|> update([_a, news], set: [data: news.new_data])
|> Pleroma.Repo.update_all([], timeout: :infinity)
IO.puts("Fixed #{upcnt} reacts in current batch")
end)
end)
|> Stream.run()
end
def down() do
# not reversible, but also shouldnt cause any problems
:ok
end
end

View file

@ -212,6 +212,66 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do
assert {:error, _} = Transmogrifier.handle_incoming(data)
end
test "it strips colons from emoji object in tag" do
shortcode = ":blobcatgoogly:"
imgurl = "https://example.org/emoji/a.png"
{data, _, _} = prepare_react(shortcode, imgurl)
coloned_tag = Enum.map(data["tag"], fn tag -> %{tag | "name" => shortcode} end)
data = %{data | "tag" => coloned_tag}
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["type"] == "EmojiReact"
assert data["content"] == shortcode
[%{"name" => tag_name}] = data["tag"]
assert ":" <> tag_name <> ":" == shortcode
end
test "it prunes tag to only the relevant emoji object" do
shortcode = "blobcatgoogly"
imgurl = "https://example.org/emoji/a.png"
{data, _, _} = prepare_react(shortcode, imgurl)
ext_tag = [
%{
"type" => "Hashtag",
"name" => "#cat",
"href" => "https://example.org/hastags/cat"
},
emoji_object("evilcat", "https://example.org/evilcat.avif")
| data["tag"]
]
data = %{data | "tag" => ext_tag}
refute match?([%{"type" => "Emoji"}], data["tag"])
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert match?([%{"type" => "Emoji", "name" => ^shortcode}], data["tag"])
end
test "it strips pre-existing remote indicators" do
shortcode = "blobcatgoogly@fedi.example.org"
imgurl = "https://example.org/emoji/a.png"
{data, _, _} = prepare_react(shortcode, imgurl)
[clean_shortcode, _] = String.split(shortcode, "@", parts: 2)
{:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data)
assert data["content"] == ":" <> clean_shortcode <> ":"
assert match?([%{"name" => ^clean_shortcode}], data["tag"])
end
defp prepare_react(shortcode, imgurl, emoji_id \\ nil) do
user = insert(:user)
other_user = insert(:user, local: false)
{:ok, activity} = CommonAPI.post(user, %{status: "hello"})
emoji = emoji_object(shortcode, emoji_id, imgurl)
data = react_with_custom(activity.data["object"], other_user.ap_id, emoji)
{data, activity, other_user}
end
defp emoji_object(shortcode, id \\ nil, url \\ "https://example.org/emoji.png") do
%{
"type" => "Emoji",