web/metadata: provide alternate link for ActivityPub

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 <lotte@chir.rs>
This commit is contained in:
Oneric 2025-04-26 16:34:56 +02:00
parent 6a6d4254d5
commit ed03eaade9
4 changed files with 91 additions and 1 deletions

View file

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

View file

@ -0,0 +1,35 @@
# Akkoma: Magically expressive social media
# Copyright © 2025 Akkoma Authors <https://akkoma.dev/>
# 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

View file

@ -0,0 +1,31 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# 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

View file

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