logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://anongit.hacktivis.me/git/pleroma.git/
commit: 1a313fa30c5f96df56bc6f12329e13f50739c728
parent cdd6df062100a3982922b95d8e1f3123db6e4253
Author: lain <lain@soykaf.club>
Date:   Thu, 25 Dec 2025 10:22:53 +0000

Merge branch 'replies_collection' into 'develop'

Provide full replies collection in ActivityPub objects (ported from akkoma)

See merge request pleroma/pleroma!4370

Diffstat:

Achangelog.d/replies-collection.add2++
Mlib/pleroma/activity/queries.ex18+-----------------
Mlib/pleroma/object.ex22----------------------
Mlib/pleroma/pagination.ex46+++++++++++++++++++++++++++++-----------------
Mlib/pleroma/web/activity_pub/activity_pub.ex22++++++++++++++++++++++
Mlib/pleroma/web/activity_pub/activity_pub_controller.ex38++++++++++++++++++++++++++++++++++----
Mlib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex16++++++++++------
Mlib/pleroma/web/activity_pub/transmogrifier.ex57+++++++++++++++++----------------------------------------
Alib/pleroma/web/activity_pub/views/collection_view_helper.ex59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/web/activity_pub/views/object_view.ex89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/pleroma/web/activity_pub/views/user_view.ex61++++++++++++++++++++++++++++---------------------------------
Mlib/pleroma/web/controller_helper.ex49++++++++++++++++++++++++++++++++++++-------------
Mlib/pleroma/web/router.ex1+
Mtest/pleroma/web/activity_pub/activity_pub_controller_test.exs126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs15+++++++++++----
Mtest/pleroma/web/activity_pub/views/object_view_test.exs32+++++++++++++++++++++++++++++++-
Mtest/pleroma/web/activity_pub/views/user_view_test.exs12++++++++++++
17 files changed, 508 insertions(+), 157 deletions(-)

diff --git a/changelog.d/replies-collection.add b/changelog.d/replies-collection.add @@ -0,0 +1 @@ +Provide full replies collection in ActivityPub objects +\ No newline at end of file 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, where: 3] + import Ecto.Query, only: [from: 2] @type query :: Ecto.Queryable.t() | Pleroma.Activity.t() @@ -70,22 +70,6 @@ defmodule Pleroma.Activity.Queries do ) 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 def by_type(query \\ Activity, activity_type) do from( diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex @@ -398,28 +398,6 @@ defmodule Pleroma.Object 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: [] diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex @@ -95,13 +95,30 @@ defmodule Pleroma.Pagination do offset: :integer, limit: :integer, skip_extra_order: :boolean, - skip_order: :boolean + skip_order: :boolean, + order_asc: :boolean } changeset = cast({%{}, param_types}, params, Map.keys(param_types)) changeset.changes end + defp order_statement(query, table_binding, :asc) do + order_by( + query, + [{u, table_position(query, table_binding)}], + fragment("? asc nulls last", u.id) + ) + end + + defp order_statement(query, table_binding, :desc) do + order_by( + query, + [{u, table_position(query, table_binding)}], + fragment("? desc nulls last", u.id) + ) + end + defp restrict(query, :min_id, %{min_id: min_id}, table_binding) do where(query, [{q, table_position(query, table_binding)}], q.id > ^min_id) end @@ -119,19 +136,16 @@ defmodule Pleroma.Pagination do defp restrict(%{order_bys: [_ | _]} = query, :order, %{skip_extra_order: true}, _), do: query defp restrict(query, :order, %{min_id: _}, table_binding) do - order_by( - query, - [{u, table_position(query, table_binding)}], - fragment("? asc nulls last", u.id) - ) + order_statement(query, table_binding, :asc) end - defp restrict(query, :order, _options, table_binding) do - order_by( - query, - [{u, table_position(query, table_binding)}], - fragment("? desc nulls last", u.id) - ) + defp restrict(query, :order, %{max_id: _}, table_binding) do + order_statement(query, table_binding, :desc) + end + + defp restrict(query, :order, options, table_binding) do + dir = if options[:order_asc], do: :asc, else: :desc + order_statement(query, table_binding, dir) end defp restrict(query, :offset, %{offset: offset}, _table_binding) do @@ -151,11 +165,9 @@ defmodule Pleroma.Pagination do defp restrict(query, _, _, _), do: query - defp enforce_order(result, %{min_id: _}) do - result - |> Enum.reverse() - end - + defp enforce_order(result, %{min_id: _, order_asc: true}), do: result + defp enforce_order(result, %{min_id: _}), do: Enum.reverse(result) + defp enforce_order(result, %{max_id: _, order_asc: true}), do: Enum.reverse(result) defp enforce_order(result, _), do: result defp table_position(%Ecto.Query{} = query, binding_name) do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -499,6 +499,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub 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()) :: Ecto.UUID.t() | nil def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -31,6 +31,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do @federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers] + @object_replies_known_param_keys ["page", "min_id", "max_id", "since_id", "limit"] + plug(FederatingPlug when action in @federating_only_actions) plug( @@ -95,6 +97,36 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController 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.take(@object_replies_known_param_keys) + |> Enum.into(%{}, 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 @@ -257,8 +289,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities), - iri: "#{user.ap_id}/outbox" + pagination: ControllerHelper.get_pagination_fields(conn, activities) }) end end @@ -404,8 +435,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities), - iri: "#{user.ap_id}/inbox" + pagination: ControllerHelper.get_pagination_fields(conn, activities) }) end diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex @@ -56,20 +56,24 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator 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" => %{"items" => replies}}} = data) when is_list(replies), do: Map.put(data, "replies", replies) + defp fix_replies(%{"replies" => %{"first" => %{"orderedItems" => replies}}} = data) + when is_list(replies), + do: Map.put(data, "replies", replies) + defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies), do: Map.put(data, "replies", replies) - # TODO: Pleroma does not have any support for Collections at the moment. - # If the `replies` field is not something the ObjectID validator can handle, - # the activity/object would be rejected, which is bad behavior. - defp fix_replies(%{"replies" => replies} = data) when not is_list(replies), - do: Map.drop(data, ["replies"]) + defp fix_replies(%{"replies" => %{"orderedItems" => replies}} = data) when is_list(replies), + do: Map.put(data, "replies", replies) - defp fix_replies(data), do: data + defp fix_replies(data), do: Map.delete(data, "replies") def fix_attachments(%{"attachment" => attachment} = data) when is_map(attachment), do: Map.put(data, "attachment", [attachment]) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -23,7 +23,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator - import Ecto.Query import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] require Pleroma.Constants @@ -762,48 +761,26 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier 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}) - 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 + def set_replies(%{"type" => type} = obj_data) + when type in Pleroma.Constants.status_object_types() do + with obj_ap_id when is_binary(obj_ap_id) <- 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(_), do: [] + def set_replies(obj_data), do: obj_data # Prepares the object of an outgoing create activity. def prepare_object(object) do diff --git a/lib/pleroma/web/activity_pub/views/collection_view_helper.ex b/lib/pleroma/web/activity_pub/views/collection_view_helper.ex @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# Copyright © 2025 Akkoma Authors <https://akkoma.dev/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.CollectionViewHelper do + alias Pleroma.Web.ActivityPub.Utils + + def collection_page_offset(collection, iri, page, show_items \\ true, total \\ nil) do + offset = (page - 1) * 10 + items = Enum.slice(collection, offset, 10) + items = Enum.map(items, fn user -> user.ap_id end) + total = total || length(collection) + + map = %{ + "id" => "#{iri}?page=#{page}", + "type" => "OrderedCollectionPage", + "partOf" => iri, + "totalItems" => total, + "orderedItems" => if(show_items, do: items, else: []) + } + + if offset + 10 < total do + Map.put(map, "next", "#{iri}?page=#{page + 1}") + else + map + end + end + + defp maybe_omit_next(pagination, _items, nil), do: pagination + + 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 + + def collection_page_keyset( + display_items, + pagination, + limit \\ nil, + skip_ap_context \\ false + ) do + %{ + "type" => "OrderedCollectionPage", + "orderedItems" => display_items + } + |> Map.merge(pagination) + |> maybe_omit_next(display_items, limit) + |> then(fn m -> + if skip_ap_context, do: m, else: Map.merge(m, Utils.make_json_ld_header()) + end) + end +end diff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex @@ -6,7 +6,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do use Pleroma.Web, :view alias Pleroma.Activity alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.CollectionViewHelper alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ControllerHelper def render("object.json", %{object: %Object{} = object}) do base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(object.data) @@ -19,4 +22,90 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do {:ok, ap_data} = Transmogrifier.prepare_outgoing(activity.data) ap_data 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 + pagination = ControllerHelper.get_pagination_fields(conn, items, %{"page" => true}, :asc) + Map.put(pagination, "id", Phoenix.Controller.current_url(conn, %{"page" => true})) + 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 diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.ActivityPub.CollectionViewHelper alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils @@ -164,7 +165,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do 0 end - collection(following, "#{user.ap_id}/following", page, showing_items, total) + CollectionViewHelper.collection_page_offset( + following, + "#{user.ap_id}/following", + page, + showing_items, + total + ) |> Map.merge(Utils.make_json_ld_header()) end @@ -189,7 +196,12 @@ defmodule Pleroma.Web.ActivityPub.UserView do "totalItems" => total, "first" => if showing_items do - collection(following, "#{user.ap_id}/following", 1, !user.hide_follows) + CollectionViewHelper.collection_page_offset( + following, + "#{user.ap_id}/following", + 1, + !user.hide_follows + ) else "#{user.ap_id}/following?page=1" end @@ -212,7 +224,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do 0 end - collection(followers, "#{user.ap_id}/followers", page, showing_items, total) + CollectionViewHelper.collection_page_offset( + followers, + "#{user.ap_id}/followers", + page, + showing_items, + total + ) |> Map.merge(Utils.make_json_ld_header()) end @@ -236,7 +254,12 @@ defmodule Pleroma.Web.ActivityPub.UserView do "type" => "OrderedCollection", "first" => if showing_items do - collection(followers, "#{user.ap_id}/followers", 1, showing_items, total) + CollectionViewHelper.collection_page_offset( + followers, + "#{user.ap_id}/followers", + 1, + showing_items + ) else "#{user.ap_id}/followers?page=1" end @@ -256,7 +279,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do def render("activity_collection_page.json", %{ activities: activities, - iri: iri, pagination: pagination }) do collection = @@ -265,13 +287,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do data end) - %{ - "type" => "OrderedCollectionPage", - "partOf" => iri, - "orderedItems" => collection - } - |> Map.merge(Utils.make_json_ld_header()) - |> Map.merge(pagination) + CollectionViewHelper.collection_page_keyset(collection, pagination) end def render("featured.json", %{ @@ -299,27 +315,6 @@ defmodule Pleroma.Web.ActivityPub.UserView do Map.put(map, "totalItems", total) end - def collection(collection, iri, page, show_items \\ true, total \\ nil) do - offset = (page - 1) * 10 - items = Enum.slice(collection, offset, 10) - items = Enum.map(items, fn user -> user.ap_id end) - total = total || length(collection) - - map = %{ - "id" => "#{iri}?page=#{page}", - "type" => "OrderedCollectionPage", - "partOf" => iri, - "totalItems" => total, - "orderedItems" => if(show_items, do: items, else: []) - } - - if offset < total do - Map.put(map, "next", "#{iri}?page=#{page + 1}") - else - map - end - end - defp maybe_make_image(func, description, key, user) do if image = func.(user, no_default: true) do %{ diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex @@ -55,7 +55,7 @@ defmodule Pleroma.Web.ControllerHelper do # TODO: Only fetch the params from open_api_spex when everything is converted @id_keys Pagination.page_keys() -- ["limit", "order"] - defp build_pagination_fields(conn, min_id, max_id, extra_params) do + defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do params = if Map.has_key?(conn.private, :open_api_spex) do get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)]) @@ -66,27 +66,50 @@ defmodule Pleroma.Web.ControllerHelper do |> Map.merge(extra_params) |> Map.drop(@id_keys) + {{next_id, nid}, {prev_id, pid}} = + if order == :desc, + do: {{:max_id, max_id}, {:min_id, min_id}}, + else: {{:min_id, min_id}, {:max_id, max_id}} + + id = Phoenix.Controller.current_url(conn) + base_id = %{URI.parse(id) | query: nil} |> URI.to_string() + %{ - "next" => current_url(conn, Map.put(params, :max_id, max_id)), - "prev" => current_url(conn, Map.put(params, :min_id, min_id)), - "id" => current_url(conn) + "next" => current_url(conn, Map.put(params, next_id, nid)), + "prev" => current_url(conn, Map.put(params, prev_id, pid)), + "id" => id, + "partOf" => base_id } end - def get_pagination_fields(conn, entries, extra_params \\ %{}) do + defp get_first_last_pagination_id(entries) do case List.last(entries) do - %{pagination_id: max_id} when not is_nil(max_id) -> - %{pagination_id: min_id} = List.first(entries) + %{pagination_id: last_id} when not is_nil(last_id) -> + %{pagination_id: first_id} = List.first(entries) + {first_id, last_id} - build_pagination_fields(conn, min_id, max_id, extra_params) + %{id: last_id} -> + %{id: first_id} = List.first(entries) + {first_id, last_id} - %{id: max_id} -> - %{id: min_id} = List.first(entries) + _ -> + nil + end + end - build_pagination_fields(conn, min_id, max_id, extra_params) + def get_pagination_fields(conn, entries, extra_params \\ %{}, order \\ :desc) - _ -> - %{} + def get_pagination_fields(conn, entries, extra_params, :desc) do + case get_first_last_pagination_id(entries) do + nil -> %{} + {min_id, max_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :desc) + end + end + + def get_pagination_fields(conn, entries, extra_params, :asc) do + case get_first_last_pagination_id(entries) do + nil -> %{} + {max_id, min_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :asc) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex @@ -972,6 +972,7 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) get("/users/:nickname/collections/featured", ActivityPubController, :pinned) + get("/objects/:uuid/replies", ActivityPubController, :object_replies) end scope "/", Pleroma.Web.ActivityPub do diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -430,7 +430,133 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest 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 "first page id includes `?page=true`", %{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") + + %{"id" => collection_id, "first" => %{"id" => page_id, "partOf" => part_of}} = + json_response(conn, 200) + + assert part_of == collection_id + assert String.contains?(page_id, "page=true") + end + + test "unknown query params do not crash the endpoint", %{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?unknown_param=1") + + assert %{"type" => "OrderedCollection"} = 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 does not include a top-level replies collection on activities", %{conn: conn} do + clear_config([:activitypub, :note_replies_output_limit], 1) + + activity = insert(:note_activity) + activity = Activity.get_by_id_with_object(activity.id) + + uuid = String.split(activity.data["id"], "/") |> List.last() + + conn = + conn + |> put_req_header("accept", "application/activity+json") + |> get("/activities/#{uuid}") + + res = json_response(conn, 200) + + refute Map.has_key?(res, "replies") + assert get_in(res, ["object", "replies", "id"]) == activity.object.data["id"] <> "/replies" + end + test "it doesn't return a local-only activity", %{conn: conn} do user = insert(:user) {:ok, post} = CommonAPI.post(user, %{status: "test", visibility: "local"}) diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs @@ -696,12 +696,19 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest 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 + object = data["object"] |> Map.delete("replies") + modified = Transmogrifier.set_replies(object) + + refute object["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"}) @@ -730,7 +737,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest 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 diff --git a/test/pleroma/web/activity_pub/views/object_view_test.exs b/test/pleroma/web/activity_pub/views/object_view_test.exs @@ -49,9 +49,39 @@ defmodule Pleroma.Web.ActivityPub.ObjectViewTest 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 diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs @@ -169,6 +169,18 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do user = Map.merge(user, %{hide_followers_count: false, hide_followers: true}) assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user}) end + + test "does not hide follower items based on `hide_follows`" do + user = insert(:user) + follower = insert(:user) + {:ok, user, _follower, _activity} = CommonAPI.follow(user, follower) + + user = Map.merge(user, %{hide_followers: false, hide_follows: true}) + follower_ap_id = follower.ap_id + + assert %{"first" => %{"orderedItems" => [^follower_ap_id]}} = + UserView.render("followers.json", %{user: user}) + end end describe "following" do