Merge branch '1505-threads-federation' into 'develop'

[#1505] Threads / replies federation

Closes #1505

See merge request pleroma/pleroma!2129
This commit is contained in:
Haelwenn 2020-02-24 07:28:35 +00:00
commit 1e1156b645
15 changed files with 366 additions and 25 deletions

View file

@ -75,6 +75,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- A new users admin digest email - A new users admin digest email
- OAuth: admin scopes support (relevant setting: `[:auth, :enforce_oauth_admin_scope_usage]`). - OAuth: admin scopes support (relevant setting: `[:auth, :enforce_oauth_admin_scope_usage]`).
- Add an option `authorized_fetch_mode` to require HTTP signatures for AP fetches. - Add an option `authorized_fetch_mode` to require HTTP signatures for AP fetches.
- ActivityPub: support for `replies` collection (output for outgoing federation & fetching on incoming federation).
<details> <details>
<summary>API Changes</summary> <summary>API Changes</summary>
@ -117,6 +118,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Tag feed: `/tags/:tag.rss` - list public statuses by hashtag. - Tag feed: `/tags/:tag.rss` - list public statuses by hashtag.
- Mastodon API: Add `reacted` property to `emoji_reactions` - Mastodon API: Add `reacted` property to `emoji_reactions`
- Pleroma API: Add reactions for a single emoji. - Pleroma API: Add reactions for a single emoji.
- ActivityPub: `[:activitypub, :note_replies_output_limit]` setting sets the number of note self-replies to output on outgoing federation.
</details> </details>
### Fixed ### Fixed

View file

@ -328,6 +328,7 @@
unfollow_blocked: true, unfollow_blocked: true,
outgoing_blocks: true, outgoing_blocks: true,
follow_handshake_timeout: 500, follow_handshake_timeout: 500,
note_replies_output_limit: 5,
sign_object_fetches: true, sign_object_fetches: true,
authorized_fetch_mode: false authorized_fetch_mode: false
@ -483,6 +484,7 @@
transmogrifier: 20, transmogrifier: 20,
scheduled_activities: 10, scheduled_activities: 10,
background: 5, background: 5,
remote_fetcher: 2,
attachments_cleanup: 5, attachments_cleanup: 5,
new_users_digest: 1 new_users_digest: 1
], ],

View file

@ -662,7 +662,7 @@
label: "Fed. incoming replies max depth", label: "Fed. incoming replies max depth",
type: :integer, type: :integer,
description: 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.", " 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: [ suggestions: [
100 100
@ -1790,6 +1790,12 @@
type: :boolean, type: :boolean,
description: "Sign object fetches with HTTP signatures" 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, key: :follow_handshake_timeout,
type: :integer, type: :integer,

View 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! This is where the interesting stuff happens!
Depending on the timeline you will see different statuses, but each status has a standard structure: 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! - 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. - 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. - 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.

View file

@ -7,7 +7,7 @@ defmodule Pleroma.Activity.Queries do
Contains queries for Activity. 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() @type query :: Ecto.Queryable.t() | Activity.t()
@ -63,6 +63,22 @@ def by_object_id(query, object_id) when is_binary(object_id) do
) )
end end
@spec by_object_in_reply_to_id(query, String.t(), keyword()) :: 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 @spec by_type(query, String.t()) :: query
def by_type(query \\ Activity, activity_type) do def by_type(query \\ Activity, activity_type) do
from( from(

View file

@ -301,4 +301,26 @@ def update_data(%Object{data: data} = object, attrs \\ %{}) do
def local?(%Object{data: %{"id" => id}}) do def local?(%Object{data: %{"id" => id}}) do
String.starts_with?(id, Pleroma.Web.base_url() <> "/") String.starts_with?(id, Pleroma.Web.base_url() <> "/")
end 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 end

View file

@ -10,6 +10,7 @@ defmodule Pleroma.Object.Fetcher do
alias Pleroma.Signature alias Pleroma.Signature
alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.InternalFetchActor
alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.Federator
require Logger require Logger
require Pleroma.Constants require Pleroma.Constants
@ -59,20 +60,23 @@ def refetch_object(%Object{data: %{"id" => id}} = object) do
end end
end end
# TODO: # Note: will create a Create activity, which we need internally at the moment.
# This will create a Create activity, which we need internally at the moment.
def fetch_object_from_id(id, options \\ []) do def fetch_object_from_id(id, options \\ []) do
with {:fetch_object, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)},
{:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])},
{:normalize, nil} <- {:normalize, Object.normalize(data, false)}, {_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)},
{_, nil} <- {:normalize, Object.normalize(data, false)},
params <- prepare_activity_params(data), params <- prepare_activity_params(data),
{:containment, :ok} <- {:containment, Containment.contain_origin(id, params)}, {_, :ok} <- {:containment, Containment.contain_origin(id, params)},
{:transmogrifier, {:ok, activity}} <- {_, {:ok, activity}} <-
{:transmogrifier, Transmogrifier.handle_incoming(params, options)}, {:transmogrifier, Transmogrifier.handle_incoming(params, options)},
{:object, _data, %Object{} = object} <- {_, _data, %Object{} = object} <-
{:object, data, Object.normalize(activity, false)} do {:object, data, Object.normalize(activity, false)} do
{:ok, object} {:ok, object}
else else
{:allowed_depth, false} ->
{:error, "Max thread distance exceeded."}
{:containment, _} -> {:containment, _} ->
{:error, "Object containment failed."} {:error, "Object containment failed."}

View file

@ -156,8 +156,9 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options)
when not is_nil(in_reply_to) do when not is_nil(in_reply_to) do
in_reply_to_id = prepare_in_reply_to(in_reply_to) in_reply_to_id = prepare_in_reply_to(in_reply_to)
object = Map.put(object, "inReplyToAtomUri", in_reply_to_id) 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), 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 %Activity{} = _ <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
object object
@ -312,7 +313,7 @@ def fix_type(object, options \\ [])
def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options)
when is_binary(reply_id) do 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 {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do
Map.put(object, "type", "Answer") Map.put(object, "type", "Answer")
else else
@ -406,8 +407,7 @@ def handle_incoming(
with nil <- Activity.get_create_by_object_ap_id(object["id"]), with nil <- Activity.get_create_by_object_ap_id(object["id"]),
{:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do {: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(object, options)
object = fix_object(data["object"], options)
params = %{ params = %{
to: data["to"], to: data["to"],
@ -424,7 +424,20 @@ def handle_incoming(
]) ])
} }
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 else
%Activity{} = activity -> {:ok, activity} %Activity{} = activity -> {:ok, activity}
_e -> :error _e -> :error
@ -442,7 +455,8 @@ def handle_incoming(
|> fix_addressing |> fix_addressing
with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do 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) object = fix_object(object, options)
params = %{ params = %{
@ -903,6 +917,50 @@ def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_r
def set_reply_to_uri(obj), do: obj 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. # Prepares the object of an outgoing create activity.
def prepare_object(object) do def prepare_object(object) do
object object
@ -914,6 +972,7 @@ def prepare_object(object) do
|> prepare_attachments |> prepare_attachments
|> set_conversation |> set_conversation
|> set_reply_to_uri |> set_reply_to_uri
|> set_replies
|> strip_internal_fields |> strip_internal_fields
|> strip_internal_tags |> strip_internal_tags
|> set_type |> set_type

View file

@ -15,13 +15,19 @@ defmodule Pleroma.Web.Federator do
require Logger 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 # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength
def allowed_incoming_reply_depth?(depth) do def allowed_thread_distance?(distance) do
max_replies_depth = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth]) max_distance = Pleroma.Config.get([:instance, :federation_incoming_replies_max_depth])
if max_replies_depth do if max_distance && max_distance >= 0 do
(depth || 1) <= max_replies_depth # Default depth is 0 (an object has zero distance from itself in its thread)
(distance || 0) <= max_distance
else else
true true
end end

View file

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

View file

@ -35,6 +35,19 @@
"inReplyTo": null, "inReplyTo": null,
"inReplyToAtomUri": null, "inReplyToAtomUri": null,
"published": "2018-02-12T14:08:20Z", "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, "sensitive": true,
"summary": "cw", "summary": "cw",
"tag": [ "tag": [

View file

@ -26,6 +26,31 @@ defmodule Pleroma.Object.FetcherTest do
:ok :ok
end 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 describe "actor origin containment" do
test "it rejects objects with a bogus origin" do test "it rejects objects with a bogus origin" do
{:error, _} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity.json") {:error, _} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity.json")

View file

@ -9,6 +9,10 @@ defmodule Pleroma.Tests.ObanHelpers do
alias Pleroma.Repo alias Pleroma.Repo
def wipe_all do
Repo.delete_all(Oban.Job)
end
def perform_all do def perform_all do
Oban.Job Oban.Job
|> Repo.all() |> Repo.all()

View file

@ -3,7 +3,9 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase use Pleroma.DataCase
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Object.Fetcher alias Pleroma.Object.Fetcher
@ -40,7 +42,7 @@ test "it ignores an incoming notice if we already have it" do
end end
@tag capture_log: true @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 = data =
File.read!("test/fixtures/mastodon-post-activity.json") File.read!("test/fixtures/mastodon-post-activity.json")
|> Poison.decode!() |> Poison.decode!()
@ -61,7 +63,7 @@ test "it fetches replied-to activities if we don't have them" do
assert returned_object.data["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" assert returned_object.data["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873"
end 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 = data =
File.read!("test/fixtures/mastodon-post-activity.json") File.read!("test/fixtures/mastodon-post-activity.json")
|> Poison.decode!() |> Poison.decode!()
@ -73,7 +75,7 @@ test "it does not fetch replied-to activities beyond max_replies_depth" do
data = Map.put(data, "object", object) data = Map.put(data, "object", object)
with_mock Pleroma.Web.Federator, 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) {:ok, returned_activity} = Transmogrifier.handle_incoming(data)
returned_object = Object.normalize(returned_activity, false) returned_object = Object.normalize(returned_activity, false)
@ -1348,6 +1350,101 @@ test "it accepts Move activities" do
end end
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 describe "prepare outgoing" do
test "it inlines private announced objects" do test "it inlines private announced objects" do
user = insert(:user) user = insert(:user)
@ -2046,4 +2143,49 @@ test "returns object with emoji when object contains map tag" do
} }
end end
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 end

View file

@ -36,6 +36,26 @@ test "renders a note activity" do
assert result["@context"] assert result["@context"]
end 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 test "renders a like activity" do
note = insert(:note_activity) note = insert(:note_activity)
object = Object.normalize(note) object = Object.normalize(note)