commit: 7620640f3c12d8621b50d63ca4b4ad074b5e2832
parent: 3982963bebd071027382dc59c876e4bcab79b78f
Author: Haelwenn (lanodan) Monnier <contact@hacktivis.me>
Date: Thu, 13 Feb 2020 20:17:04 +0100
Merge branch 'threads-federation' into dev-lanodan2
Diffstat:
11 files changed, 255 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -71,6 +71,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>
@@ -112,6 +113,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
diff --git a/config/config.exs b/config/config.exs
@@ -340,6 +340,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,
@@ -494,6 +495,7 @@ config :pleroma, Oban,
transmogrifier: 20,
scheduled_activities: 10,
background: 5,
+ remote_fetcher: 2,
attachments_cleanup: 5
]
diff --git a/config/description.exs b/config/description.exs
@@ -1791,6 +1791,12 @@ config :pleroma, :config_description, [
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,
description: "Following handshake timeout",
diff --git 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/object.ex b/lib/pleroma/object.ex
@@ -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
diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex
@@ -407,7 +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,13 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
])
}
- ActivityPub.create(params)
+ with {:ok, created_activity} <- ActivityPub.create(params) do
+ for reply_id <- replies(object) do
+ Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{"id" => reply_id})
+ end
+
+ {:ok, created_activity}
+ end
else
%Activity{} = activity -> {:ok, activity}
_e -> :error
@@ -903,6 +909,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 +964,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/lib/pleroma/workers/remote_fetcher_worker.ex b/lib/pleroma/workers/remote_fetcher_worker.ex
@@ -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
+ },
+ _job
+ ) do
+ {:ok, _object} = Fetcher.fetch_object_from_id(id)
+ end
+end
diff --git a/test/fixtures/mastodon-post-activity.json b/test/fixtures/mastodon-post-activity.json
@@ -35,6 +35,19 @@
"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": [
diff --git a/test/support/oban_helpers.ex b/test/support/oban_helpers.ex
@@ -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()
diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs
@@ -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
@@ -1348,6 +1350,55 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
end
end
+ describe "handle_incoming/2: `replies` handling:" do
+ clear_config([:activitypub, :note_replies_output_limit]) do
+ Pleroma.Config.put([:activitypub, :note_replies_output_limit], 5)
+ end
+
+ test "with Mastodon-formatted `replies` collection, it schedules background fetching of items" do
+ data =
+ "test/fixtures/mastodon-post-activity.json"
+ |> File.read!()
+ |> Poison.decode!()
+
+ items = get_in(data, ["object", "replies", "first", "items"])
+ assert length(items) > 0
+
+ {:ok, _activity} = Transmogrifier.handle_incoming(data)
+
+ for id <- items do
+ job_args = %{"op" => "fetch_remote", "id" => id}
+ assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args)
+ end
+ end
+
+ test "with Pleroma-formatted `replies` collection, it schedules background fetching of items" 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)
+
+ {:ok, _activity} = Transmogrifier.handle_incoming(federation_output)
+
+ for id <- replies_uris do
+ job_args = %{"op" => "fetch_remote", "id" => id}
+ assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args)
+ end
+ end
+ end
+
describe "prepare outgoing" do
test "it inlines private announced objects" do
user = insert(:user)
@@ -2046,4 +2097,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
diff --git a/test/web/activity_pub/views/object_view_test.exs b/test/web/activity_pub/views/object_view_test.exs
@@ -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)