Merge remote-tracking branch 'remotes/origin/develop' into 1505-threads-federation
authorIvan Tashkinov <ivantashkinov@gmail.com>
Tue, 18 Feb 2020 14:46:09 +0000 (17:46 +0300)
committerIvan Tashkinov <ivantashkinov@gmail.com>
Tue, 18 Feb 2020 14:46:09 +0000 (17:46 +0300)
15 files changed:
CHANGELOG.md
config/config.exs
config/description.exs
docs/introduction.md
lib/pleroma/activity/queries.ex
lib/pleroma/object.ex
lib/pleroma/object/fetcher.ex
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/federator/federator.ex
lib/pleroma/workers/remote_fetcher_worker.ex [new file with mode: 0644]
test/fixtures/mastodon-post-activity.json
test/object/fetcher_test.exs
test/support/oban_helpers.ex
test/web/activity_pub/transmogrifier_test.exs
test/web/activity_pub/views/object_view_test.exs

index 3e838983b4b4393e26f0ae8ae27a90e685c7acbb..bdd6ec432382832d0863be8a4d143b8c3a7944cd 100644 (file)
@@ -73,6 +73,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Support for custom Elixir modules (such as MRF policies)
 - User settings: Add _This account is a_ option.
 - OAuth: admin scopes support (relevant setting: `[:auth, :enforce_oauth_admin_scope_usage]`).
+- ActivityPub: support for `replies` collection (output for outgoing federation & fetching on incoming federation).
 <details>
   <summary>API Changes</summary>
 
@@ -114,6 +115,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Configuration: `feed.logo` option for tag feed.
 - Tag feed: `/tags/:tag.rss` - list public statuses by hashtag.
 - Mastodon API: Add `reacted` property to `emoji_reactions`
+- ActivityPub: `[:activitypub, :note_replies_output_limit]` setting sets the number of note self-replies to output on outgoing federation.
 </details>
 
 ### Fixed
index ccc0c4e525b99b79a08a2c64e8fd6761ac8ff59f..cb1551c2ad8347b4c7bb79fa413ff8ba895c1bba 100644 (file)
@@ -326,6 +326,7 @@ config :pleroma, :activitypub,
   unfollow_blocked: true,
   outgoing_blocks: true,
   follow_handshake_timeout: 500,
+  note_replies_output_limit: 5,
   sign_object_fetches: true
 
 config :pleroma, :streamer,
@@ -480,6 +481,7 @@ config :pleroma, Oban,
     transmogrifier: 20,
     scheduled_activities: 10,
     background: 5,
+    remote_fetcher: 2,
     attachments_cleanup: 5
   ],
   crontab: [
index efea7c137728dd2cbf2a1d7674ce97792ded7a4b..10f266555a5d8a447e89509ab491bad43d03a16f 100644 (file)
@@ -661,7 +661,7 @@ config :pleroma, :config_description, [
         label: "Fed. incoming replies max depth",
         type: :integer,
         description:
-          "Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while" <>
+          "Max. depth of reply-to and reply activities fetching on incoming federation, to prevent out-of-memory situations while" <>
             " fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes.",
         suggestions: [
           100
@@ -1789,6 +1789,12 @@ config :pleroma, :config_description, [
         type: :boolean,
         description: "Sign object fetches with HTTP signatures"
       },
+      %{
+        key: :note_replies_output_limit,
+        type: :integer,
+        description:
+          "The number of Note replies' URIs to be included with outgoing federation (`5` to match Mastodon hardcoded value, `0` to disable the output)."
+      },
       %{
         key: :follow_handshake_timeout,
         type: :integer,
index 823e14f53ce6fb4ab24e1b9fb031e541850f69c9..a915c143c717188393e313f20f6fde371ef9a418 100644 (file)
@@ -41,7 +41,7 @@ On the top right you will also see a wrench icon. This opens your personal setti
 This is where the interesting stuff happens!
 Depending on the timeline you will see different statuses, but each status has a standard structure:
 
-- Profile pic, name and link to profile. An optional left-arrow if it's a reply to another status (hovering will reveal the replied-to status). Clicking on the profile pic will uncollapse the user's profile.
+- Profile pic, name and link to profile. An optional left-arrow if it's a reply to another status (hovering will reveal the reply-to status). Clicking on the profile pic will uncollapse the user's profile.
 - A `+` button on the right allows you to Expand/Collapse an entire discussion thread. It also updates in realtime!
 - An arrow icon allows you to open the status on the instance where it's originating from.
 - The text of the status, including mentions and attachements. If you click on a mention, it will automatically open the profile page of that person.
index 79f3052016b5180c309ffc803f64c888214884ba..c17affec98501b973f346b259793b57d8ce73d9c 100644 (file)
@@ -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(
index 52556bf31546a3ff74f02663d6d2a39a6d2261f9..f316f8b36c062018892a343a846747a035541df4 100644 (file)
@@ -301,4 +301,26 @@ defmodule Pleroma.Object do
   def local?(%Object{data: %{"id" => id}}) do
     String.starts_with?(id, Pleroma.Web.base_url() <> "/")
   end
+
+  def replies(object, opts \\ []) do
+    object = Object.normalize(object)
+
+    query =
+      Object
+      |> where(
+        [o],
+        fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
+      )
+      |> order_by([o], asc: o.id)
+
+    if opts[:self_only] do
+      actor = object.data["actor"]
+      where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
+    else
+      query
+    end
+  end
+
+  def self_replies(object, opts \\ []),
+    do: replies(object, Keyword.put(opts, :self_only, true))
 end
index 037c423395184a223cccd987881d727de451cc27..23ecd9e15ef61a23fe187c7dd67ca65db7e6a7d2 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Object.Fetcher do
   alias Pleroma.Signature
   alias Pleroma.Web.ActivityPub.InternalFetchActor
   alias Pleroma.Web.ActivityPub.Transmogrifier
+  alias Pleroma.Web.Federator
 
   require Logger
   require Pleroma.Constants
@@ -59,20 +60,23 @@ defmodule Pleroma.Object.Fetcher do
     end
   end
 
-  # TODO:
-  # This will create a Create activity, which we need internally at the moment.
+  # Note: will create a Create activity, which we need internally at the moment.
   def fetch_object_from_id(id, options \\ []) do
-    with {:fetch_object, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)},
-         {:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)},
-         {:normalize, nil} <- {:normalize, Object.normalize(data, false)},
+    with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)},
+         {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])},
+         {_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)},
+         {_, nil} <- {:normalize, Object.normalize(data, false)},
          params <- prepare_activity_params(data),
-         {:containment, :ok} <- {:containment, Containment.contain_origin(id, params)},
-         {:transmogrifier, {:ok, activity}} <-
+         {_, :ok} <- {:containment, Containment.contain_origin(id, params)},
+         {_, {:ok, activity}} <-
            {:transmogrifier, Transmogrifier.handle_incoming(params, options)},
-         {:object, _data, %Object{} = object} <-
+         {_, _data, %Object{} = object} <-
            {:object, data, Object.normalize(activity, false)} do
       {:ok, object}
     else
+      {:allowed_depth, false} ->
+        {:error, "Max thread distance exceeded."}
+
       {:containment, _} ->
         {:error, "Object containment failed."}
 
index a72d8430f6e3b6ab4d73ad69702fc2ee2a63c1f9..5bd2baca48fb669a8a1d2e42cfc1f8c2fc562a2c 100644 (file)
@@ -156,8 +156,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
       when not is_nil(in_reply_to) do
     in_reply_to_id = prepare_in_reply_to(in_reply_to)
     object = Map.put(object, "inReplyToAtomUri", in_reply_to_id)
+    depth = (options[:depth] || 0) + 1
 
-    if Federator.allowed_incoming_reply_depth?(options[:depth]) do
+    if Federator.allowed_thread_distance?(depth) do
       with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options),
            %Activity{} = _ <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
         object
@@ -312,7 +313,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
 
   def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options)
       when is_binary(reply_id) do
-    with true <- Federator.allowed_incoming_reply_depth?(options[:depth]),
+    with true <- Federator.allowed_thread_distance?(options[:depth]),
          {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do
       Map.put(object, "type", "Answer")
     else
@@ -406,8 +407,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
 
     with nil <- Activity.get_create_by_object_ap_id(object["id"]),
          {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
-      options = Keyword.put(options, :depth, (options[:depth] || 0) + 1)
-      object = fix_object(data["object"], options)
+      object = fix_object(object, options)
 
       params = %{
         to: data["to"],
@@ -424,7 +424,20 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
           ])
       }
 
-      ActivityPub.create(params)
+      with {:ok, created_activity} <- ActivityPub.create(params) do
+        reply_depth = (options[:depth] || 0) + 1
+
+        if Federator.allowed_thread_distance?(reply_depth) do
+          for reply_id <- replies(object) do
+            Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{
+              "id" => reply_id,
+              "depth" => reply_depth
+            })
+          end
+        end
+
+        {:ok, created_activity}
+      end
     else
       %Activity{} = activity -> {:ok, activity}
       _e -> :error
@@ -442,7 +455,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
       |> fix_addressing
 
     with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
-      options = Keyword.put(options, :depth, (options[:depth] || 0) + 1)
+      reply_depth = (options[:depth] || 0) + 1
+      options = Keyword.put(options, :depth, reply_depth)
       object = fix_object(object, options)
 
       params = %{
@@ -903,6 +917,50 @@ 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_data) do
+    replies_uris =
+      with limit when limit > 0 <-
+             Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
+           %Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do
+        object
+        |> Object.self_replies()
+        |> select([o], fragment("?->>'id'", o.data))
+        |> limit(^limit)
+        |> Repo.all()
+      else
+        _ -> []
+      end
+
+    set_replies(obj_data, replies_uris)
+  end
+
+  defp set_replies(obj, []) do
+    obj
+  end
+
+  defp set_replies(obj, replies_uris) do
+    replies_collection = %{
+      "type" => "Collection",
+      "items" => replies_uris
+    }
+
+    Map.merge(obj, %{"replies" => replies_collection})
+  end
+
+  def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
+    items
+  end
+
+  def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do
+    items
+  end
+
+  def replies(_), do: []
+
   # Prepares the object of an outgoing create activity.
   def prepare_object(object) do
     object
@@ -914,6 +972,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
index f506a7d245eace7c06bff5bcb2dde6a22e6a84b0..013fb5b70d29e349ef8bd7b484076a9df10b7d3e 100644 (file)
@@ -15,13 +15,19 @@ defmodule Pleroma.Web.Federator do
 
   require Logger
 
-  @doc "Addresses [memory leaks on recursive replies fetching](https://git.pleroma.social/pleroma/pleroma/issues/161)"
+  @doc """
+  Returns `true` if the distance to target object does not exceed max configured value.
+  Serves to prevent fetching of very long threads, especially useful on smaller instances.
+  Addresses [memory leaks on recursive replies fetching](https://git.pleroma.social/pleroma/pleroma/issues/161).
+  Applies to fetching of both ancestor (reply-to) and child (reply) objects.
+  """
   # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength
-  def allowed_incoming_reply_depth?(depth) do
-    max_replies_depth = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth])
+  def allowed_thread_distance?(distance) do
+    max_distance = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth])
 
-    if max_replies_depth do
-      (depth || 1) <= max_replies_depth
+    if max_distance && max_distance >= 0 do
+      # Default depth is 0 (an object has zero distance from itself in its thread)
+      (distance || 0) <= max_distance
     else
       true
     end
diff --git a/lib/pleroma/workers/remote_fetcher_worker.ex b/lib/pleroma/workers/remote_fetcher_worker.ex
new file mode 100644 (file)
index 0000000..e860ca8
--- /dev/null
@@ -0,0 +1,20 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Workers.RemoteFetcherWorker do
+  alias Pleroma.Object.Fetcher
+
+  use Pleroma.Workers.WorkerHelper, queue: "remote_fetcher"
+
+  @impl Oban.Worker
+  def perform(
+        %{
+          "op" => "fetch_remote",
+          "id" => id
+        } = args,
+        _job
+      ) do
+    {:ok, _object} = Fetcher.fetch_object_from_id(id, depth: args["depth"])
+  end
+end
index b91263431844d5346079a62d1decbd32555d3ff1..5c3d2272215caf29c2e137645c78c8548a2217df 100644 (file)
         "inReplyTo": null,
         "inReplyToAtomUri": null,
         "published": "2018-02-12T14:08:20Z",
+        "replies": {
+            "id": "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies",
+            "type": "Collection",
+            "first": {
+                "type":        "CollectionPage",
+                "next": "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies?min_id=99512778738411824&page=true",
+                "partOf": "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies",
+                "items": [
+                    "http://mastodon.example.org/users/admin/statuses/99512778738411823",
+                    "http://mastodon.example.org/users/admin/statuses/99512778738411824"
+                ]
+            }
+        },
         "sensitive": true,
         "summary": "cw",
         "tag": [
index 2aad7a5883b3811726d05e5f666c547019030171..3afd35648b20bff089fe4c5400176da66858779e 100644 (file)
@@ -26,6 +26,31 @@ defmodule Pleroma.Object.FetcherTest do
     :ok
   end
 
+  describe "max thread distance restriction" do
+    @ap_id "http://mastodon.example.org/@admin/99541947525187367"
+
+    clear_config([:instance, :federation_incoming_replies_max_depth])
+
+    test "it returns thread depth exceeded error if thread depth is exceeded" do
+      Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+      assert {:error, "Max thread distance exceeded."} =
+               Fetcher.fetch_object_from_id(@ap_id, depth: 1)
+    end
+
+    test "it fetches object if max thread depth is restricted to 0 and depth is not specified" do
+      Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+      assert {:ok, _} = Fetcher.fetch_object_from_id(@ap_id)
+    end
+
+    test "it fetches object if requested depth does not exceed max thread depth" do
+      Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 10)
+
+      assert {:ok, _} = Fetcher.fetch_object_from_id(@ap_id, depth: 10)
+    end
+  end
+
   describe "actor origin containment" do
     test "it rejects objects with a bogus origin" do
       {:error, _} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity.json")
index 72792c0648a89a7f5d9a8df2199dfe74665cf4c5..0e3b654df304fad1632d0a4a808951ceea17978c 100644 (file)
@@ -9,6 +9,10 @@ defmodule Pleroma.Tests.ObanHelpers do
 
   alias Pleroma.Repo
 
+  def wipe_all do
+    Repo.delete_all(Oban.Job)
+  end
+
   def perform_all do
     Oban.Job
     |> Repo.all()
index 1b12ee3a9dfd118072cdf1eef234b0863b8da98c..937f78cbeddcaf6adadf43dee00d335bd8f3e1da 100644 (file)
@@ -3,7 +3,9 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
+  use Oban.Testing, repo: Pleroma.Repo
   use Pleroma.DataCase
+
   alias Pleroma.Activity
   alias Pleroma.Object
   alias Pleroma.Object.Fetcher
@@ -40,7 +42,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
     end
 
     @tag capture_log: true
-    test "it fetches replied-to activities if we don't have them" do
+    test "it fetches reply-to activities if we don't have them" do
       data =
         File.read!("test/fixtures/mastodon-post-activity.json")
         |> Poison.decode!()
@@ -61,7 +63,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       assert returned_object.data["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873"
     end
 
-    test "it does not fetch replied-to activities beyond max_replies_depth" do
+    test "it does not fetch reply-to activities beyond max replies depth limit" do
       data =
         File.read!("test/fixtures/mastodon-post-activity.json")
         |> Poison.decode!()
@@ -73,7 +75,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       data = Map.put(data, "object", object)
 
       with_mock Pleroma.Web.Federator,
-        allowed_incoming_reply_depth?: fn _ -> false end do
+        allowed_thread_distance?: fn _ -> false end do
         {:ok, returned_activity} = Transmogrifier.handle_incoming(data)
 
         returned_object = Object.normalize(returned_activity, false)
@@ -1348,6 +1350,101 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
     end
   end
 
+  describe "`handle_incoming/2`, Mastodon format `replies` handling" do
+    clear_config([:activitypub, :note_replies_output_limit]) do
+      Pleroma.Config.put([:activitypub, :note_replies_output_limit], 5)
+    end
+
+    clear_config([:instance, :federation_incoming_replies_max_depth])
+
+    setup do
+      data =
+        "test/fixtures/mastodon-post-activity.json"
+        |> File.read!()
+        |> Poison.decode!()
+
+      items = get_in(data, ["object", "replies", "first", "items"])
+      assert length(items) > 0
+
+      %{data: data, items: items}
+    end
+
+    test "schedules background fetching of `replies` items if max thread depth limit allows", %{
+      data: data,
+      items: items
+    } do
+      Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 10)
+
+      {:ok, _activity} = Transmogrifier.handle_incoming(data)
+
+      for id <- items do
+        job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1}
+        assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args)
+      end
+    end
+
+    test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows",
+         %{data: data} do
+      Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+      {:ok, _activity} = Transmogrifier.handle_incoming(data)
+
+      assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == []
+    end
+  end
+
+  describe "`handle_incoming/2`, Pleroma format `replies` handling" do
+    clear_config([:activitypub, :note_replies_output_limit]) do
+      Pleroma.Config.put([:activitypub, :note_replies_output_limit], 5)
+    end
+
+    clear_config([:instance, :federation_incoming_replies_max_depth])
+
+    setup do
+      user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(user, %{"status" => "post1"})
+
+      {:ok, reply1} =
+        CommonAPI.post(user, %{"status" => "reply1", "in_reply_to_status_id" => activity.id})
+
+      {:ok, reply2} =
+        CommonAPI.post(user, %{"status" => "reply2", "in_reply_to_status_id" => activity.id})
+
+      replies_uris = Enum.map([reply1, reply2], fn a -> a.object.data["id"] end)
+
+      {:ok, federation_output} = Transmogrifier.prepare_outgoing(activity.data)
+
+      Repo.delete(activity.object)
+      Repo.delete(activity)
+
+      %{federation_output: federation_output, replies_uris: replies_uris}
+    end
+
+    test "schedules background fetching of `replies` items if max thread depth limit allows", %{
+      federation_output: federation_output,
+      replies_uris: replies_uris
+    } do
+      Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 1)
+
+      {:ok, _activity} = Transmogrifier.handle_incoming(federation_output)
+
+      for id <- replies_uris do
+        job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1}
+        assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args)
+      end
+    end
+
+    test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows",
+         %{federation_output: federation_output} do
+      Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0)
+
+      {:ok, _activity} = Transmogrifier.handle_incoming(federation_output)
+
+      assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == []
+    end
+  end
+
   describe "prepare outgoing" do
     test "it inlines private announced objects" do
       user = insert(:user)
@@ -2046,4 +2143,49 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
              }
     end
   end
+
+  describe "set_replies/1" do
+    clear_config([:activitypub, :note_replies_output_limit]) do
+      Pleroma.Config.put([:activitypub, :note_replies_output_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 :note_replies_output_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.object.data["id"] end)
+
+      assert %{"type" => "Collection", "items" => ^replies_uris} =
+               Transmogrifier.set_replies(object.data)["replies"]
+    end
+  end
 end
index 13447dc297172731211ebd936186732b3eb43188..acc855b98e57a75e6e5cd63638d7cf2471acb65d 100644 (file)
@@ -36,6 +36,26 @@ defmodule Pleroma.Web.ActivityPub.ObjectViewTest do
     assert result["@context"]
   end
 
+  describe "note activity's `replies` collection rendering" do
+    clear_config([:activitypub, :note_replies_output_limit]) do
+      Pleroma.Config.put([:activitypub, :note_replies_output_limit], 5)
+    end
+
+    test "renders `replies` collection for a note activity" do
+      user = insert(:user)
+      activity = insert(:note_activity, user: user)
+
+      {:ok, self_reply1} =
+        CommonAPI.post(user, %{"status" => "self-reply 1", "in_reply_to_status_id" => activity.id})
+
+      replies_uris = [self_reply1.object.data["id"]]
+      result = ObjectView.render("object.json", %{object: refresh_record(activity)})
+
+      assert %{"type" => "Collection", "items" => ^replies_uris} =
+               get_in(result, ["object", "replies"])
+    end
+  end
+
   test "renders a like activity" do
     note = insert(:note_activity)
     object = Object.normalize(note)