From ed03eaade9093f341392c2e99f8b9f522a710da8 Mon Sep 17 00:00:00 2001 From: Oneric Date: Sat, 26 Apr 2025 16:34:56 +0200 Subject: [PATCH] web/metadata: provide alternate link for ActivityPub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows discovering a page represents an ActivityPub object and also where to find the underlying representation. Other servers already implement this and some tools came to rely or profit from it. The alternate link is provided both with the "application/activity+json" format as used by Mastodon and the standard-compliant media type. Just like the feed provider, ActivityPub links are always enabled unless access to local posts is restricted and not configurable. The commit is based on earlier work by Charlotte 🦝 Deleńkec but with fixes and some tweaks. Co-authored-by: Charlotte 🦝 Deleńkec --- lib/pleroma/web/metadata.ex | 6 +++- lib/pleroma/web/metadata/providers/ap_url.ex | 35 +++++++++++++++++++ .../web/metadata/providers/ap_url_test.exs | 31 ++++++++++++++++ .../static_fe/static_fe_controller_test.exs | 20 +++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/web/metadata/providers/ap_url.ex create mode 100644 test/pleroma/web/metadata/providers/ap_url_test.exs diff --git a/lib/pleroma/web/metadata.ex b/lib/pleroma/web/metadata.ex index 48801b588..f232fbb0e 100644 --- a/lib/pleroma/web/metadata.ex +++ b/lib/pleroma/web/metadata.ex @@ -64,7 +64,11 @@ def activity_nsfw?(_) do defp activated_providers do unless Pleroma.Config.restrict_unauthenticated_access?(:activities, :local) do - [Pleroma.Web.Metadata.Providers.Feed | Pleroma.Config.get([__MODULE__, :providers], [])] + [ + Pleroma.Web.Metadata.Providers.Feed, + Pleroma.Web.Metadata.Providers.ApUrl + | Pleroma.Config.get([__MODULE__, :providers], []) + ] else [] end diff --git a/lib/pleroma/web/metadata/providers/ap_url.ex b/lib/pleroma/web/metadata/providers/ap_url.ex new file mode 100644 index 000000000..cff581713 --- /dev/null +++ b/lib/pleroma/web/metadata/providers/ap_url.ex @@ -0,0 +1,35 @@ +# Akkoma: Magically expressive social media +# Copyright © 2025 Akkoma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.ApUrl do + alias Pleroma.Web.Metadata.Providers.Provider + + @behaviour Provider + + defp alt_link(uri, type) do + { + :link, + [rel: "alternate", href: uri, type: type], + [] + } + end + + defp ap_alt_links(uri) do + [ + alt_link(uri, "application/activity+json"), + alt_link(uri, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") + ] + end + + @impl Provider + def build_tags(%{object: %{data: %{"id" => ap_id}}}) when is_binary(ap_id) do + ap_alt_links(ap_id) + end + + def build_tags(%{user: %{ap_id: ap_id}}) when is_binary(ap_id) do + ap_alt_links(ap_id) + end + + def build_tags(_), do: [] +end diff --git a/test/pleroma/web/metadata/providers/ap_url_test.exs b/test/pleroma/web/metadata/providers/ap_url_test.exs new file mode 100644 index 000000000..b01c58fff --- /dev/null +++ b/test/pleroma/web/metadata/providers/ap_url_test.exs @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.ApUrlTest do + use Pleroma.DataCase, async: true + import Pleroma.Factory + alias Pleroma.Web.Metadata.Providers.ApUrl + + @ap_type_compliant "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + @ap_type_mastodon "application/activity+json" + + test "it preferentially renders a link to a post" do + user = insert(:user) + note = insert(:note, user: user) + + assert ApUrl.build_tags(%{object: note, user: user}) == [ + {:link, [rel: "alternate", href: note.data["id"], type: @ap_type_mastodon], []}, + {:link, [rel: "alternate", href: note.data["id"], type: @ap_type_compliant], []} + ] + end + + test "it renders a link to a user" do + user = insert(:user) + + assert ApUrl.build_tags(%{user: user}) == [ + {:link, [rel: "alternate", href: user.ap_id, type: @ap_type_mastodon], []}, + {:link, [rel: "alternate", href: user.ap_id, type: @ap_type_compliant], []} + ] + end +end diff --git a/test/pleroma/web/static_fe/static_fe_controller_test.exs b/test/pleroma/web/static_fe/static_fe_controller_test.exs index 79d4d6261..4d128af73 100644 --- a/test/pleroma/web/static_fe/static_fe_controller_test.exs +++ b/test/pleroma/web/static_fe/static_fe_controller_test.exs @@ -321,6 +321,16 @@ defp meta_find_twitter(document, name) do Floki.find(document, "head>meta[name=\"twitter:" <> name <> "\"]") end + defp meta_find_alt_links(document) do + Floki.find(document, "head>link[rel=\"alternate\"]") + |> Enum.map(fn {_, attr, _} -> + { + :proplists.get_value("type", attr), + :proplists.get_value("href", attr) + } + end) + end + # Detailed metadata tests are already done for each builder individually, so just # one check per type of content should suffice to ensure we're calling the providers correctly describe "metadata tags for" do @@ -350,6 +360,8 @@ test "user profile", %{conn: conn, user: user, user_avatar_url: user_avatar_url} [{"meta", tw_desc, _}] = meta_find_twitter(document, "description") [{"meta", tw_img, _}] = meta_find_twitter(document, "image") + alt_links = meta_find_alt_links(document) + assert meta_content(og_type) == "article" assert meta_content(og_title) == Pleroma.Web.Metadata.Utils.user_name_string(user) assert meta_content(og_url) == user.ap_id @@ -362,6 +374,8 @@ test "user profile", %{conn: conn, user: user, user_avatar_url: user_avatar_url} assert meta_content(tw_title) == meta_content(og_title) assert meta_content(tw_desc) == meta_content(og_desc) assert meta_content(tw_img) == meta_content(og_img) + + assert Enum.any?(alt_links, fn e -> e == {"application/activity+json", user.ap_id} end) end test "text-only post", %{conn: conn, user: user, user_avatar_url: user_avatar_url} do @@ -386,6 +400,8 @@ test "text-only post", %{conn: conn, user: user, user_avatar_url: user_avatar_ur [{"meta", tw_desc, _}] = meta_find_twitter(document, "description") [{"meta", tw_img, _}] = meta_find_twitter(document, "image") + alt_links = meta_find_alt_links(document) + assert meta_content(og_type) == "article" assert meta_content(og_title) == Pleroma.Web.Metadata.Utils.user_name_string(user) assert meta_content(og_url) == activity.data["id"] @@ -398,6 +414,10 @@ test "text-only post", %{conn: conn, user: user, user_avatar_url: user_avatar_ur assert meta_content(tw_title) == meta_content(og_title) assert meta_content(tw_desc) == meta_content(og_desc) assert meta_content(tw_img) == meta_content(og_img) + + assert Enum.any?(alt_links, fn e -> + e == {"application/activity+json", activity.object.data["id"]} + end) end test "post with attachments", %{conn: conn, user: user} do