diff --git a/config/config.exs b/config/config.exs index 2c154eb45..60642c467 100644 --- a/config/config.exs +++ b/config/config.exs @@ -620,6 +620,10 @@ config :pleroma, :modules, runtime_dir: "instance/modules" config :pleroma, configurable_from_database: false +config :pleroma, :mastodon_compatibility, + # https://git.pleroma.social/pleroma/pleroma/issues/1505 + federated_note_replies_limit: 5 + config :swarm, node_blacklist: [~r/myhtml_.*$/] # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/config/description.exs b/config/description.exs index f941349d5..a0675ec30 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3089,6 +3089,20 @@ config :pleroma, :config_description, [ } ] }, + %{ + group: :pleroma, + key: :mastodon_compatibility, + type: :group, + description: "Mastodon compatibility-related settings.", + children: [ + %{ + key: :federated_note_replies_limit, + type: :integer, + description: + "The number of Note self-reply URIs to be included with outgoing federation (`5` to mimic Mastodon hardcoded value, `0` to disable)." + } + ] + }, %{ group: :pleroma, type: :group, diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 896cbb3c5..b7be7a800 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -329,4 +329,23 @@ defmodule Pleroma.Activity do _ -> nil end end + + def replies(activity, opts \\ []) do + object = Object.normalize(activity) + + query = + Activity + |> Queries.by_type("Create") + |> Queries.by_object_in_reply_to_id(object.data["id"], skip_preloading: true) + |> order_by([activity], asc: activity.id) + + if opts[:self_only] do + where(query, [a], a.actor == ^activity.actor) + else + query + end + end + + def self_replies(activity, opts \\ []), + do: replies(activity, Keyword.put(opts, :self_only, true)) end diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index 79f305201..c17affec9 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Activity.Queries do Contains queries for Activity. """ - import Ecto.Query, only: [from: 2] + import Ecto.Query, only: [from: 2, where: 3] @type query :: Ecto.Queryable.t() | Activity.t() @@ -63,6 +63,22 @@ defmodule Pleroma.Activity.Queries do ) end + @spec by_object_id(query, String.t()) :: query + def by_object_in_reply_to_id(query, in_reply_to_id, opts \\ []) do + query = + if opts[:skip_preloading] do + Activity.with_joined_object(query) + else + Activity.with_preloaded_object(query) + end + + where( + query, + [activity, object: o], + fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(in_reply_to_id)) + ) + end + @spec by_type(query, String.t()) :: query def by_type(query \\ Activity, activity_type) do from( diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 2b8bfc3bd..9e712ab75 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -903,6 +903,49 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def set_reply_to_uri(obj), do: obj + @doc """ + Serialized Mastodon-compatible `replies` collection containing _self-replies_. + Based on Mastodon's ActivityPub::NoteSerializer#replies. + """ + def set_replies(obj) do + limit = Pleroma.Config.get([:mastodon_compatibility, :federated_note_replies_limit], 0) + + replies_uris = + with true <- limit > 0 || nil, + %Activity{} = activity <- Activity.get_create_by_object_ap_id(obj["id"]) do + activity + |> Activity.self_replies() + |> select([a], fragment("?->>'id'", a.data)) + |> limit(^limit) + |> Repo.all() + end + + set_replies(obj, replies_uris || []) + end + + defp set_replies(obj, replies_uris) when replies_uris in [nil, []] do + obj + end + + defp set_replies(obj, replies_uris) do + # Note: stubs (Mastodon doesn't make separate requests via those URIs in FetchRepliesService) + masto_replies_uri = nil + masto_replies_next_page_uri = nil + + replies_collection = %{ + "type" => "Collection", + "id" => masto_replies_uri, + "first" => %{ + "type" => "Collection", + "part_of" => masto_replies_uri, + "items" => replies_uris, + "next" => masto_replies_next_page_uri + } + } + + Map.merge(obj, %{"replies" => replies_collection}) + end + # Prepares the object of an outgoing create activity. def prepare_object(object) do object @@ -914,6 +957,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> prepare_attachments |> set_conversation |> set_reply_to_uri + |> set_replies |> strip_internal_fields |> strip_internal_tags |> set_type diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 5da358c43..418b8a1ca 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -2027,4 +2027,51 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do } end end + + describe "set_replies/1" do + clear_config([:mastodon_compatibility, :federated_note_replies_limit]) do + Pleroma.Config.put([:mastodon_compatibility, :federated_note_replies_limit], 2) + end + + test "returns unmodified object if activity doesn't have self-replies" do + data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) + assert Transmogrifier.set_replies(data) == data + end + + test "sets `replies` collection with a limited number of self-replies" do + [user, another_user] = insert_list(2, :user) + + {:ok, %{id: id1} = activity} = CommonAPI.post(user, %{"status" => "1"}) + + {:ok, %{id: id2} = self_reply1} = + CommonAPI.post(user, %{"status" => "self-reply 1", "in_reply_to_status_id" => id1}) + + {:ok, self_reply2} = + CommonAPI.post(user, %{"status" => "self-reply 2", "in_reply_to_status_id" => id1}) + + # Assuming to _not_ be present in `replies` due to :federated_note_replies_limit is set to 2 + {:ok, _} = + CommonAPI.post(user, %{"status" => "self-reply 3", "in_reply_to_status_id" => id1}) + + {:ok, _} = + CommonAPI.post(user, %{ + "status" => "self-reply to self-reply", + "in_reply_to_status_id" => id2 + }) + + {:ok, _} = + CommonAPI.post(another_user, %{ + "status" => "another user's reply", + "in_reply_to_status_id" => id1 + }) + + object = Object.normalize(activity) + replies_uris = Enum.map([self_reply1, self_reply2], fn a -> a.data["id"] end) + + assert %{ + "type" => "Collection", + "first" => %{"type" => "Collection", "items" => ^replies_uris} + } = Transmogrifier.set_replies(object.data)["replies"] + end + end end