federation/out: add full replies collection to objects
Until now only a limited number of self-replies were inlined as an anonymous, unordered ActivityPub collection. Notably the advertised replies might be private posts. However, providing all (non-private) replies allows for better thread consistency across instances if the remote server cooperates. The collection existing as a stndalone object has two advantages for this. For one, if it was still anonymous, _all_ replies would need to be inlined, which might be too bloated in pathological cases. Secondly, it allows remote servers to update the thread by traversing the reply collection independent of the original post. (If the remote part knows about chronological ordering, it can in theory even efficiently resume from where it previously stopped)
This commit is contained in:
parent
c55654876f
commit
b41a13df56
11 changed files with 273 additions and 61 deletions
|
|
@ -365,28 +365,6 @@ def local?(%Object{data: %{"id" => id}}) do
|
|||
String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/")
|
||||
end
|
||||
|
||||
def replies(object, opts \\ []) do
|
||||
object = Object.normalize(object, fetch: false)
|
||||
|
||||
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))
|
||||
|
||||
def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
|
||||
|
||||
def tags(_), do: []
|
||||
|
|
|
|||
|
|
@ -509,6 +509,28 @@ def fetch_activities_for_context(context, opts \\ %{}) do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
def fetch_objects_for_replies_collection(parent_ap_id, opts \\ %{}) do
|
||||
opts =
|
||||
opts
|
||||
|> Map.put(:order_asc, true)
|
||||
|> Map.put(:id_type, :integer)
|
||||
|
||||
from(o in Object,
|
||||
where:
|
||||
fragment("?->>'inReplyTo' = ?", o.data, ^parent_ap_id) and
|
||||
fragment(
|
||||
"(?->'to' \\? ?::text OR ?->'cc' \\? ?::text)",
|
||||
o.data,
|
||||
^Pleroma.Constants.as_public(),
|
||||
o.data,
|
||||
^Pleroma.Constants.as_public()
|
||||
) and
|
||||
fragment("?->>'type' <> 'Answer'", o.data),
|
||||
select: %{id: o.id, ap_id: fragment("?->>'id'", o.data)}
|
||||
)
|
||||
|> Pagination.fetch_paginated(opts, :keyset)
|
||||
end
|
||||
|
||||
@spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) ::
|
||||
FlakeId.Ecto.CompatType.t() | nil
|
||||
def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|
|||
alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug
|
||||
alias Pleroma.Web.Plugs.FederatingPlug
|
||||
|
||||
require Logger
|
||||
|
||||
action_fallback(:errors)
|
||||
|
||||
@federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers]
|
||||
|
|
@ -121,6 +119,35 @@ def object(%{assigns: assigns} = conn, _) do
|
|||
end
|
||||
end
|
||||
|
||||
def object_replies(%{assigns: assigns, query_params: params} = conn, _all_params) do
|
||||
object_ap_id = conn.path_info |> Enum.reverse() |> tl() |> Enum.reverse()
|
||||
object_ap_id = Endpoint.url() <> "/" <> Enum.join(object_ap_id, "/")
|
||||
|
||||
# Most other API params are converted to atoms by OpenAPISpex 3.x
|
||||
# and therefore helper functions assume atoms. For consistency,
|
||||
# also convert our params to atoms here.
|
||||
params =
|
||||
params
|
||||
|> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end)
|
||||
|> Map.put(:object_ap_id, object_ap_id)
|
||||
|> Map.put(:order_asc, true)
|
||||
|> Map.put(:conn, conn)
|
||||
|
||||
with %Object{} = object <- Object.get_cached_by_ap_id(object_ap_id),
|
||||
user <- Map.get(assigns, :user, nil),
|
||||
{_, true} <- {:visible?, Visibility.visible_for_user?(object, user)} do
|
||||
conn
|
||||
|> maybe_skip_cache(user)
|
||||
|> set_cache_ttl_for(object)
|
||||
|> put_resp_content_type("application/activity+json")
|
||||
|> put_view(ObjectView)
|
||||
|> render("object_replies.json", render_params: params)
|
||||
else
|
||||
{:visible?, false} -> {:error, :not_found}
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def track_object_fetch(conn, nil), do: conn
|
||||
|
||||
def track_object_fetch(conn, object_id) do
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do
|
|||
defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
|
||||
defp fix_tag(data), do: Map.drop(data, ["tag"])
|
||||
|
||||
# legacy internal *oma format
|
||||
defp fix_replies(%{"replies" => replies} = data) when is_list(replies), do: data
|
||||
|
||||
defp fix_replies(%{"replies" => %{"first" => first}} = data) when is_binary(first) do
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
|
|||
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
|
||||
alias Pleroma.Web.Federator
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
require Pleroma.Constants
|
||||
require Logger
|
||||
|
||||
|
|
@ -790,37 +788,22 @@ def set_quote_url(%{"quoteUri" => quote} = object) when is_binary(quote) do
|
|||
def set_quote_url(obj), do: obj
|
||||
|
||||
@doc """
|
||||
Serialized Mastodon-compatible `replies` collection containing _self-replies_.
|
||||
Based on Mastodon's ActivityPub::NoteSerializer#replies.
|
||||
Inline first page of the `replies` collection,
|
||||
containing any replies in chronological order.
|
||||
"""
|
||||
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})
|
||||
with obj_ap_id when obj_ap_id != nil <- obj_data["id"],
|
||||
limit when limit > 0 <-
|
||||
Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
|
||||
collection <-
|
||||
Pleroma.Web.ActivityPub.ObjectView.render("object_replies.json", %{
|
||||
render_params: %{object_ap_id: obj_data["id"], limit: limit, skip_ap_ctx: true}
|
||||
}) do
|
||||
Map.put(obj_data, "replies", collection)
|
||||
else
|
||||
0 -> Map.put(obj_data, "replies", obj_data["id"] <> "/replies")
|
||||
_ -> obj_data
|
||||
end
|
||||
end
|
||||
|
||||
def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
|
||||
|
|
|
|||
|
|
@ -29,7 +29,14 @@ def collection_page_offset(collection, iri, page, show_items \\ true, total \\ n
|
|||
|
||||
defp maybe_omit_next(pagination, _items, nil), do: pagination
|
||||
|
||||
defp maybe_omit_next(pagination, items, limit) do
|
||||
defp maybe_omit_next(pagination, items, limit) when is_binary(limit) do
|
||||
case Integer.parse(limit) do
|
||||
{limit, ""} -> maybe_omit_next(pagination, items, limit)
|
||||
_ -> maybe_omit_next(pagination, items, nil)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_omit_next(pagination, items, limit) when is_number(limit) do
|
||||
if Enum.count(items) < limit, do: Map.delete(pagination, "next"), else: pagination
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do
|
|||
use Pleroma.Web, :view
|
||||
alias Pleroma.Activity
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Web.ActivityPub.CollectionViewHelper
|
||||
alias Pleroma.Web.ControllerHelper
|
||||
alias Pleroma.Web.ActivityPub.Transmogrifier
|
||||
alias Pleroma.Web.ActivityPub.ActivityPub
|
||||
|
||||
def render("object.json", %{object: %Object{} = object}) do
|
||||
base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header()
|
||||
|
|
@ -37,4 +40,89 @@ def render("object.json", %{object: %Activity{} = activity}) do
|
|||
|
||||
Map.merge(base, additional)
|
||||
end
|
||||
|
||||
def render("object_replies.json", %{
|
||||
conn: conn,
|
||||
render_params: %{object_ap_id: object_ap_id, page: "true"} = params
|
||||
}) do
|
||||
params = Map.put_new(params, :limit, 40)
|
||||
|
||||
items = ActivityPub.fetch_objects_for_replies_collection(object_ap_id, params)
|
||||
display_items = map_reply_collection_items(items)
|
||||
|
||||
pagination = ControllerHelper.get_pagination_fields(conn, items, %{}, :asc)
|
||||
|
||||
CollectionViewHelper.collection_page_keyset(display_items, pagination, params[:limit])
|
||||
end
|
||||
|
||||
def render(
|
||||
"object_replies.json",
|
||||
%{
|
||||
render_params: %{object_ap_id: object_ap_id} = params
|
||||
} = opts
|
||||
) do
|
||||
params =
|
||||
params
|
||||
|> Map.drop([:max_id, :min_id, :since_id, :object_ap_id])
|
||||
|> Map.put_new(:limit, 40)
|
||||
|> Map.put(:total, true)
|
||||
|
||||
%{total: total, items: items} =
|
||||
ActivityPub.fetch_objects_for_replies_collection(object_ap_id, params)
|
||||
|
||||
display_items = map_reply_collection_items(items)
|
||||
|
||||
first_pagination = reply_collection_first_pagination(items, opts)
|
||||
|
||||
col_ap =
|
||||
%{
|
||||
"id" => object_ap_id <> "/replies",
|
||||
"type" => "OrderedCollection",
|
||||
"totalItems" => total
|
||||
}
|
||||
|
||||
col_ap =
|
||||
if total > 0 do
|
||||
first_page =
|
||||
CollectionViewHelper.collection_page_keyset(
|
||||
display_items,
|
||||
first_pagination,
|
||||
params[:limit],
|
||||
true
|
||||
)
|
||||
|
||||
Map.put(col_ap, "first", first_page)
|
||||
else
|
||||
col_ap
|
||||
end
|
||||
|
||||
if params[:skip_ap_ctx] do
|
||||
col_ap
|
||||
else
|
||||
Map.merge(col_ap, Pleroma.Web.ActivityPub.Utils.make_json_ld_header())
|
||||
end
|
||||
end
|
||||
|
||||
defp map_reply_collection_items(items), do: Enum.map(items, fn %{ap_id: ap_id} -> ap_id end)
|
||||
|
||||
defp reply_collection_first_pagination(items, %{conn: %Plug.Conn{} = conn}) do
|
||||
ControllerHelper.get_pagination_fields(conn, items, %{"page" => true}, :asc)
|
||||
end
|
||||
|
||||
defp reply_collection_first_pagination(items, %{render_params: %{object_ap_id: object_ap_id}}) do
|
||||
%{
|
||||
"id" => object_ap_id <> "/replies?page=true",
|
||||
"partOf" => object_ap_id <> "/replies"
|
||||
}
|
||||
|> then(fn m ->
|
||||
case items do
|
||||
[] ->
|
||||
m
|
||||
|
||||
i ->
|
||||
next_id = object_ap_id <> "/replies?page=true&min_id=#{List.last(i)[:id]}"
|
||||
Map.put(m, "next", next_id)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -824,6 +824,7 @@ defmodule Pleroma.Web.Router do
|
|||
get("/users/:nickname/outbox", ActivityPubController, :outbox)
|
||||
post("/users/:nickname/inbox", ActivityPubController, :inbox)
|
||||
get("/users/:nickname/collections/featured", ActivityPubController, :pinned)
|
||||
get("/objects/:uuid/replies", ActivityPubController, :object_replies)
|
||||
end
|
||||
|
||||
scope "/relay", Pleroma.Web.ActivityPub do
|
||||
|
|
|
|||
|
|
@ -426,6 +426,75 @@ test "cached purged after object deletion", %{conn: conn} do
|
|||
end
|
||||
end
|
||||
|
||||
describe "/objects/:uuid/replies" do
|
||||
test "it renders the top-level collection", %{
|
||||
conn: conn
|
||||
} do
|
||||
user = insert(:user)
|
||||
note = insert(:note_activity)
|
||||
note = Pleroma.Activity.get_by_id_with_object(note.id)
|
||||
uuid = String.split(note.object.data["id"], "/") |> List.last()
|
||||
|
||||
{:ok, _} =
|
||||
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("accept", "application/activity+json")
|
||||
|> get("/objects/#{uuid}/replies")
|
||||
|
||||
assert match?(
|
||||
%{
|
||||
"id" => _,
|
||||
"type" => "OrderedCollection",
|
||||
"totalItems" => 1,
|
||||
"first" => %{
|
||||
"id" => _,
|
||||
"type" => "OrderedCollectionPage",
|
||||
"orderedItems" => [_]
|
||||
}
|
||||
},
|
||||
json_response(conn, 200)
|
||||
)
|
||||
end
|
||||
|
||||
test "it renders a collection page", %{
|
||||
conn: conn
|
||||
} do
|
||||
user = insert(:user)
|
||||
note = insert(:note_activity)
|
||||
note = Pleroma.Activity.get_by_id_with_object(note.id)
|
||||
uuid = String.split(note.object.data["id"], "/") |> List.last()
|
||||
|
||||
{:ok, r1} =
|
||||
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: note.id})
|
||||
|
||||
{:ok, r2} =
|
||||
CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: note.id})
|
||||
|
||||
{:ok, _} =
|
||||
CommonAPI.post(user, %{status: "reply3", in_reply_to_status_id: note.id})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("accept", "application/activity+json")
|
||||
|> get("/objects/#{uuid}/replies?page=true&min_id=#{r1.object.id}&limit=1")
|
||||
|
||||
expected_uris = [r2.object.data["id"]]
|
||||
|
||||
assert match?(
|
||||
%{
|
||||
"id" => _,
|
||||
"type" => "OrderedCollectionPage",
|
||||
"prev" => _,
|
||||
"next" => _,
|
||||
"orderedItems" => ^expected_uris
|
||||
},
|
||||
json_response(conn, 200)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "/activities/:uuid" do
|
||||
test "it doesn't return a local-only activity", %{conn: conn} do
|
||||
user = insert(:user)
|
||||
|
|
|
|||
|
|
@ -681,12 +681,18 @@ test "returns object with emoji when object contains map tag" do
|
|||
describe "set_replies/1" do
|
||||
setup do: clear_config([:activitypub, :note_replies_output_limit], 2)
|
||||
|
||||
test "returns unmodified object if activity doesn't have self-replies" do
|
||||
test "still provides reply collection id even if activity doesn't have replies yet" do
|
||||
data = Jason.decode!(File.read!("test/fixtures/mastodon-post-activity.json"))
|
||||
assert Transmogrifier.set_replies(data) == data
|
||||
modified = Transmogrifier.set_replies(data)
|
||||
|
||||
refute data["replies"]
|
||||
assert modified["replies"]
|
||||
assert match?(%{"id" => "http" <> _, "totalItems" => 0}, modified["replies"])
|
||||
# first page should be omitted if there are no entries anyway
|
||||
refute modified["replies"]["first"]
|
||||
end
|
||||
|
||||
test "sets `replies` collection with a limited number of self-replies" do
|
||||
test "sets `replies` collection with a limited number of replies, preferring oldest" do
|
||||
[user, another_user] = insert_list(2, :user)
|
||||
|
||||
{:ok, %{id: id1} = activity} = CommonAPI.post(user, %{status: "1"})
|
||||
|
|
@ -715,7 +721,7 @@ test "sets `replies` collection with a limited number of self-replies" do
|
|||
object = Object.normalize(activity, fetch: false)
|
||||
replies_uris = Enum.map([self_reply1, self_reply2], fn a -> a.object.data["id"] end)
|
||||
|
||||
assert %{"type" => "Collection", "items" => ^replies_uris} =
|
||||
assert %{"type" => "OrderedCollection", "first" => %{"orderedItems" => ^replies_uris}} =
|
||||
Transmogrifier.set_replies(object.data)["replies"]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,9 +49,39 @@ test "renders `replies` collection for a note activity" do
|
|||
replies_uris = [self_reply1.object.data["id"]]
|
||||
result = ObjectView.render("object.json", %{object: refresh_record(activity)})
|
||||
|
||||
assert %{"type" => "Collection", "items" => ^replies_uris} =
|
||||
assert %{
|
||||
"type" => "OrderedCollection",
|
||||
"id" => _,
|
||||
"first" => %{"orderedItems" => ^replies_uris}
|
||||
} =
|
||||
get_in(result, ["object", "replies"])
|
||||
end
|
||||
|
||||
test "renders a replies collection on its own" do
|
||||
user = insert(:user)
|
||||
activity = insert(:note_activity, user: user)
|
||||
activity = Pleroma.Activity.get_by_id_with_object(activity.id)
|
||||
|
||||
{:ok, r1} =
|
||||
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id})
|
||||
|
||||
{:ok, r2} =
|
||||
CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id})
|
||||
|
||||
replies_uris = [r1.object.data["id"], r2.object.data["id"]]
|
||||
|
||||
result =
|
||||
ObjectView.render("object_replies.json", %{
|
||||
render_params: %{object_ap_id: activity.object.data["id"]}
|
||||
})
|
||||
|
||||
%{
|
||||
"type" => "OrderedCollection",
|
||||
"id" => _,
|
||||
"totalItems" => 2,
|
||||
"first" => %{"orderedItems" => ^replies_uris}
|
||||
} = result
|
||||
end
|
||||
end
|
||||
|
||||
test "renders a like activity" do
|
||||
|
|
|
|||
Loading…
Reference in a new issue