diff --git a/CHANGELOG.md b/CHANGELOG.md
index f819fb0e8..4c18d2690 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
+### Added
+
+### Fixed
+- Internal actors no longer pretend to have unresolvable follow(er|ing) collections
+
+### Changed
+- Internal and relay actors are now again represented with type "Application"
+
## 2025.03
## Added
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
index 5f3ddf64a..f7a9c82f8 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -2022,12 +2022,13 @@ def get_or_fetch_by_ap_id(ap_id, options \\ []) do
Creates an internal service actor by URI if missing.
Optionally takes nickname for addressing.
"""
- @spec get_or_create_service_actor_by_ap_id(String.t(), String.t()) :: User.t() | nil
- def get_or_create_service_actor_by_ap_id(uri, nickname) do
+ @spec get_or_create_service_actor_by_ap_id(String.t(), String.t(), Keyword.t()) ::
+ User.t() | nil
+ def get_or_create_service_actor_by_ap_id(uri, nickname, create_opts \\ []) do
{_, user} =
case get_cached_by_ap_id(uri) do
nil ->
- with {:error, %{errors: errors}} <- create_service_actor(uri, nickname) do
+ with {:error, %{errors: errors}} <- create_service_actor(uri, nickname, create_opts) do
Logger.error("Cannot create service actor: #{uri}/.\n#{inspect(errors)}")
{:error, nil}
end
@@ -2049,15 +2050,27 @@ defp set_invisible(user) do
|> update_and_set_cache()
end
- @spec create_service_actor(String.t(), String.t()) ::
+ @spec create_service_actor(String.t(), String.t(), Keyword.t()) ::
{:ok, User.t()} | {:error, Ecto.Changeset.t()}
- defp create_service_actor(uri, nickname) do
+ defp create_service_actor(uri, nickname, opts) do
%User{
invisible: true,
local: true,
ap_id: uri,
nickname: nickname,
- follower_address: uri <> "/followers"
+ actor_type: "Application",
+ follower_address:
+ if Keyword.get(opts, :followable, false) do
+ uri <> "/followers"
+ else
+ nil
+ end,
+ following_address:
+ if Keyword.get(opts, :following, false) do
+ uri <> "/following"
+ else
+ nil
+ end
}
|> change
|> put_private_key()
diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex
index 6d60a074f..4d6413736 100644
--- a/lib/pleroma/web/activity_pub/relay.ex
+++ b/lib/pleroma/web/activity_pub/relay.ex
@@ -16,7 +16,12 @@ defmodule Pleroma.Web.ActivityPub.Relay do
def ap_id, do: "#{Pleroma.Web.Endpoint.url()}/#{@nickname}"
@spec get_actor() :: User.t() | nil
- def get_actor, do: User.get_or_create_service_actor_by_ap_id(ap_id(), @nickname)
+ def get_actor,
+ do:
+ User.get_or_create_service_actor_by_ap_id(ap_id(), @nickname,
+ followable: true,
+ following: true
+ )
@spec follow(String.t()) :: {:ok, Activity.t()} | {:error, any()}
def follow(target_instance) do
diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex
index 2ca31fc3c..2a8c6df21 100644
--- a/lib/pleroma/web/activity_pub/views/user_view.ex
+++ b/lib/pleroma/web/activity_pub/views/user_view.ex
@@ -16,9 +16,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do
import Ecto.Query
- def render("endpoints.json", %{user: %User{nickname: nil, local: true} = _user}) do
- %{"sharedInbox" => url(~p"/inbox")}
- end
+ defp maybe_put(map, _, nil), do: map
+ defp maybe_put(map, k, v), do: Map.put(map, k, v)
def render("endpoints.json", %{user: %User{local: true} = _user}) do
%{
@@ -39,13 +38,11 @@ def render("service.json", %{user: user}) do
%{
"id" => user.ap_id,
"type" => "Application",
- "following" => "#{user.ap_id}/following",
- "followers" => "#{user.ap_id}/followers",
"inbox" => "#{user.ap_id}/inbox",
"outbox" => "#{user.ap_id}/outbox",
- "name" => "Pleroma",
+ "name" => "Akkoma",
"summary" =>
- "An internal service actor for this Pleroma instance. No user-serviceable parts inside.",
+ "An internal service actor for this Akkoma instance. No user-serviceable parts inside.",
"url" => user.ap_id,
"manuallyApprovesFollowers" => false,
"publicKey" => %{
@@ -56,16 +53,15 @@ def render("service.json", %{user: user}) do
"endpoints" => endpoints,
"invisible" => User.invisible?(user)
}
+ |> maybe_put("following", user.following_address)
+ |> maybe_put("followers", user.follower_address)
+ |> maybe_put("preferredUsername", user.nickname)
|> Map.merge(Utils.make_json_ld_header())
end
- # the instance itself is not a Person, but instead an Application
- def render("user.json", %{user: %User{nickname: nil} = user}),
+ def render("user.json", %{user: %User{actor_type: "Application"} = user}),
do: render("service.json", %{user: user})
- def render("user.json", %{user: %User{nickname: "internal." <> _} = user}),
- do: render("service.json", %{user: user}) |> Map.put("preferredUsername", user.nickname)
-
def render("user.json", %{user: user}) do
public_key =
case User.SigningKey.public_key_pem(user) do
diff --git a/priv/repo/migrations/20250326000000_instance_actor_tweaks.exs b/priv/repo/migrations/20250326000000_instance_actor_tweaks.exs
new file mode 100644
index 000000000..86fe9f41d
--- /dev/null
+++ b/priv/repo/migrations/20250326000000_instance_actor_tweaks.exs
@@ -0,0 +1,41 @@
+defmodule Pleroma.Repo.Migrations.InstanceActorsTweaks do
+ use Ecto.Migration
+
+ import Ecto.Query
+
+ def up() do
+ # since Akkoma isn’t up and running at this point, Web.endpoint()
+ # isn’t available and we can't use the functions from Relay and InternalFetchActor,
+ # thus the AP ID suffix are hardcoded here and used together with a check for locality
+ # (e.g. custom ports make it hard to hardcode the full base url)
+ relay_ap_id = "%/relay"
+ fetch_ap_id = "%/internal/fetch"
+
+ # Convert to Application type
+ Pleroma.User
+ |> where([u], u.local and (like(u.ap_id, ^fetch_ap_id) or like(u.ap_id, ^relay_ap_id)))
+ |> Pleroma.Repo.update_all(set: [actor_type: "Application"])
+
+ # Drop bogus follow* addresses
+ Pleroma.User
+ |> where([u], u.local and like(u.ap_id, ^fetch_ap_id))
+ |> Pleroma.Repo.update_all(set: [follower_address: nil, following_address: nil])
+
+ # Add required follow* addresses
+ Pleroma.User
+ |> where([u], u.local and like(u.ap_id, ^relay_ap_id))
+ |> update([u],
+ set: [
+ follower_address: fragment("CONCAT(?, '/followers')", u.ap_id),
+ following_address: fragment("CONCAT(?, '/following')", u.ap_id)
+ ]
+ )
+ |> Pleroma.Repo.update_all([])
+ end
+
+ def down do
+ # We don't know if the type was Person or Application before and
+ # without this or the lost patch it didn't matter, so just do nothing
+ :ok
+ end
+end
diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs
index be8e21dcb..890109a4a 100644
--- a/test/pleroma/user_test.exs
+++ b/test/pleroma/user_test.exs
@@ -58,7 +58,11 @@ test "returns relay user" do
local: true,
ap_id: ^uri,
follower_address: ^followers_uri
- } = User.get_or_create_service_actor_by_ap_id(uri, "relay")
+ } =
+ User.get_or_create_service_actor_by_ap_id(uri, "relay",
+ followable: true,
+ following: true
+ )
assert capture_log(fn ->
refute User.get_or_create_service_actor_by_ap_id("/relay", "relay")
@@ -67,7 +71,6 @@ test "returns relay user" do
test "returns invisible actor" do
uri = "#{Pleroma.Web.Endpoint.url()}/internal/fetch-test"
- followers_uri = "#{uri}/followers"
user = User.get_or_create_service_actor_by_ap_id(uri, "internal.fetch-test")
assert %User{
@@ -75,7 +78,7 @@ test "returns invisible actor" do
invisible: true,
local: true,
ap_id: ^uri,
- follower_address: ^followers_uri
+ follower_address: nil
} = user
user2 = User.get_or_create_service_actor_by_ap_id(uri, "internal.fetch-test")
diff --git a/test/pleroma/web/activity_pub/internal_fetch_actor_tests.exs b/test/pleroma/web/activity_pub/internal_fetch_actor_tests.exs
new file mode 100644
index 000000000..ae1828f79
--- /dev/null
+++ b/test/pleroma/web/activity_pub/internal_fetch_actor_tests.exs
@@ -0,0 +1,32 @@
+# Akkoma: Magically expressive social media
+# Copyright © 2025 Akkoma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.InternalFetchActorTests do
+ use Pleroma.DataCase, async: true
+
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.InternalFetchActor
+
+ test "creates a fetch actor if needed" do
+ user = InternalFetchActor.get_actor()
+ assert user
+ assert user.ap_id == "#{Pleroma.Web.Endpoint.url()}/internal/fetch"
+ end
+
+ test "fetch actor is an application" do
+ user = InternalFetchActor.get_actor()
+ assert user.actor_type == "Application"
+ end
+
+ test "fetch actor doesn't expose follow* collections" do
+ user = InternalFetchActor.get_actor()
+ refute user.follower_address
+ refute user.following_address
+ end
+
+ test "fetch actor is invisible" do
+ user = InternalFetchActor.get_actor()
+ assert User.invisible?(user)
+ end
+end
diff --git a/test/pleroma/web/activity_pub/relay_test.exs b/test/pleroma/web/activity_pub/relay_test.exs
index b1c927b49..18407ac09 100644
--- a/test/pleroma/web/activity_pub/relay_test.exs
+++ b/test/pleroma/web/activity_pub/relay_test.exs
@@ -20,6 +20,18 @@ test "gets an actor for the relay" do
assert user.ap_id == "#{Pleroma.Web.Endpoint.url()}/relay"
end
+ test "relay actor is an application" do
+ # See
+ user = Relay.get_actor()
+ assert user.actor_type == "Application"
+ end
+
+ test "relay actor has follow* collections" do
+ user = Relay.get_actor()
+ assert user.follower_address
+ assert user.following_address
+ end
+
test "relay actor is invisible" do
user = Relay.get_actor()
assert User.invisible?(user)
diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs
index 4283fb0c8..e2c8f5865 100644
--- a/test/pleroma/web/activity_pub/views/user_view_test.exs
+++ b/test/pleroma/web/activity_pub/views/user_view_test.exs
@@ -126,18 +126,6 @@ test "remote users have an empty endpoints structure" do
assert result["id"] == user.ap_id
assert result["endpoints"] == %{}
end
-
- test "instance users do not expose oAuth endpoints" do
- user =
- insert(:user, nickname: nil, local: true)
- |> with_signing_key()
-
- result = UserView.render("user.json", %{user: user})
-
- refute result["endpoints"]["oauthAuthorizationEndpoint"]
- refute result["endpoints"]["oauthRegistrationEndpoint"]
- refute result["endpoints"]["oauthTokenEndpoint"]
- end
end
describe "followers" do