From 99befcaf1bf2326d2196e5640a8834009a720290 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sat, 25 Jul 2026 10:51:09 +0400 Subject: [PATCH] fix(activitypub): deduplicate account favourites --- lib/pleroma/web/activity_pub/activity_pub.ex | 16 +++++++ ...00_add_activity_favorites_lookup_index.exs | 20 ++++++++ .../controllers/account_controller_test.exs | 46 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index a44c5b1ff..10d3fe063 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1025,6 +1025,20 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_assigned_account(query, _), do: query defp restrict_favorited_by(query, %{favorited_by: ap_id}) do + newer_favorite = + from(newer_favorite in Activity, + where: newer_favorite.actor == ^ap_id, + where: newer_favorite.id > parent_as(:favorited_activity).id, + where: fragment("?->>'type' = ?", newer_favorite.data, "Like"), + where: + fragment( + "associated_object_id(?) = associated_object_id(?)", + newer_favorite.data, + parent_as(:favorited_activity).data + ), + select: 1 + ) + from( [activity, object: object] in query, join: favorited_activity in Activity, @@ -1033,6 +1047,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do favorited_activity.actor == ^ap_id and fragment("?->>'type' = ?", favorited_activity.data, "Like") and fragment("associated_object_id(?) = (?)->>'id'", favorited_activity.data, object.data), + where: fragment("(?)->'likes' \\? (?)", object.data, ^ap_id), + where: not exists(subquery(newer_favorite)), select: %Activity{activity | pagination_id: favorited_activity.id} ) end diff --git a/priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs b/priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs new file mode 100644 index 000000000..34bc04555 --- /dev/null +++ b/priv/repo/migrations/20260725000000_add_activity_favorites_lookup_index.exs @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.AddActivityFavoritesLookupIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + create_if_not_exists( + index(:activities, [:actor, "associated_object_id(data)", "id DESC"], + name: :activities_favorites_lookup_index, + where: "data->>'type' = 'Like'", + concurrently: true + ) + ) + end +end diff --git a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs index e93b277cc..bdf9f03c1 100644 --- a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs @@ -5,9 +5,12 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do use Pleroma.Web.ConnCase + alias Pleroma.Activity alias Pleroma.Config + alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -127,6 +130,49 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do assert Enum.map(response, & &1["id"]) == [second_post.id, first_post.id, third_post.id] end + test "returns a status once when duplicate favorite activities exist", %{ + conn: conn, + user: user + } do + older_activity = insert(:note_activity) + {:ok, _} = CommonAPI.favorite(older_activity.id, user) + + activity = insert(:note_activity) + {:ok, favorite} = CommonAPI.favorite(activity.id, user) + + duplicate = + Repo.insert!(%Activity{ + actor: favorite.actor, + data: Map.put(favorite.data, "id", Utils.generate_activity_id()), + local: favorite.local, + recipients: favorite.recipients + }) + + conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/favourites?limit=1") + response = json_response_and_validate_schema(conn, :ok) + + assert Enum.map(response, & &1["id"]) == [activity.id] + assert [link_header] = get_resp_header(conn, "link") + assert link_header =~ "max_id=#{duplicate.id}" + assert link_header =~ "min_id=#{duplicate.id}" + + response = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites?max_id=#{duplicate.id}&limit=1") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(response, & &1["id"]) == [older_activity.id] + + {:ok, _} = CommonAPI.unfavorite(activity.id, user) + + response = + conn + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response_and_validate_schema(:ok) + + assert Enum.map(response, & &1["id"]) == [older_activity.id] + end + test "returns favorited DM only when user is logged in and he is one of recipients", %{ current_user: current_user, user: user