From 99b2339424edb5b0c514581fbd6a42e4f0fcc5e1 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Wed, 20 Dec 2023 17:52:27 +0100 Subject: [PATCH] feat(nodeinfo): extract and save NodeInfo information from instances to display it on instances list We also try to detect the application actor if it's not given by NodeInfo metadata (FEP-2677) (guessing for Mobilizon, PeerTube & Mastodon). Closes #1392 Signed-off-by: Thomas Citharel --- lib/federation/node_info.ex | 81 ++++- lib/graphql/resolvers/admin.ex | 38 ++- lib/graphql/schema/admin.ex | 10 + lib/mobilizon/instances/instance_actor.ex | 39 +++ lib/mobilizon/instances/instances.ex | 76 ++++- lib/service/workers/refresh_instances.ex | 70 +++- .../20231220092536_add_actor_instances.exs | 17 + public/img/gancio.png | Bin 0 -> 10166 bytes public/img/wordpress-logo.svg | 21 ++ src/assets/oruga-tailwindcss.css | 2 +- src/components/Settings/SettingMenuItem.vue | 4 +- .../Settings/SettingMenuSection.vue | 2 +- src/graphql/admin.ts | 4 + src/i18n/en_US.json | 5 +- src/i18n/fr_FR.json | 5 +- src/types/instance.model.ts | 4 + src/views/Admin/InstanceView.vue | 302 ++++++++++-------- src/views/Admin/InstancesView.vue | 53 ++- test/federation/node_info_test.exs | 128 ++++++++ test/fixtures/nodeinfo/both_versions.json | 12 + test/fixtures/nodeinfo/data.json | 29 ++ test/fixtures/nodeinfo/older_versions.json | 8 + test/fixtures/nodeinfo/regular.json | 4 +- test/fixtures/nodeinfo/wp-data.json | 24 ++ .../nodeinfo/wp-event-federation.json | 4 +- 25 files changed, 775 insertions(+), 167 deletions(-) create mode 100644 lib/mobilizon/instances/instance_actor.ex create mode 100644 priv/repo/migrations/20231220092536_add_actor_instances.exs create mode 100644 public/img/gancio.png create mode 100644 public/img/wordpress-logo.svg create mode 100644 test/fixtures/nodeinfo/both_versions.json create mode 100644 test/fixtures/nodeinfo/data.json create mode 100644 test/fixtures/nodeinfo/older_versions.json create mode 100644 test/fixtures/nodeinfo/wp-data.json diff --git a/lib/federation/node_info.ex b/lib/federation/node_info.ex index 66e1bc43a..b37bd3e96 100644 --- a/lib/federation/node_info.ex +++ b/lib/federation/node_info.ex @@ -7,22 +7,43 @@ defmodule Mobilizon.Federation.NodeInfo do require Logger @application_uri "https://www.w3.org/ns/activitystreams#Application" + @nodeinfo_rel_2_0 "http://nodeinfo.diaspora.software/ns/schema/2.0" + @nodeinfo_rel_2_1 "http://nodeinfo.diaspora.software/ns/schema/2.1" + @env Application.compile_env(:mobilizon, :env) @spec application_actor(String.t()) :: String.t() | nil def application_actor(host) do - prefix = if @env !== :dev, do: "https", else: "http" + Logger.debug("Fetching application actor from NodeInfo data for domain #{host}") - case WebfingerClient.get("#{prefix}://#{host}/.well-known/nodeinfo") do - {:ok, %{body: body, status: code}} when code in 200..299 -> + case fetch_nodeinfo_endpoint(host) do + {:ok, body} -> extract_application_actor(body) - err -> - Logger.debug("Failed to fetch NodeInfo data #{inspect(err)}") + {:error, :node_info_meta_http_error} -> nil end end + @spec nodeinfo(String.t()) :: {:ok, map()} | {:error, atom()} + def nodeinfo(host) do + Logger.debug("Fetching NodeInfo details for domain #{host}") + + with {:ok, endpoint} when is_binary(endpoint) <- fetch_nodeinfo_details(host), + :ok <- Logger.debug("Going to get NodeInfo information from URL #{endpoint}"), + {:ok, %{body: body, status: code}} when code in 200..299 <- WebfingerClient.get(endpoint) do + Logger.debug("Found nodeinfo information for domain #{host}") + {:ok, body} + else + {:error, err} -> + {:error, err} + + err -> + Logger.debug("Failed to fetch NodeInfo data from endpoint #{inspect(err)}") + {:error, :node_info_endpoint_http_error} + end + end + defp extract_application_actor(body) do body |> Map.get("links", []) @@ -31,4 +52,54 @@ defmodule Mobilizon.Federation.NodeInfo do end) |> Map.get("href") end + + @spec fetch_nodeinfo_endpoint(String.t()) :: {:ok, map()} | {:error, atom()} + defp fetch_nodeinfo_endpoint(host) do + prefix = if @env !== :dev, do: "https", else: "http" + + case WebfingerClient.get("#{prefix}://#{host}/.well-known/nodeinfo") do + {:ok, %{body: body, status: code}} when code in 200..299 -> + {:ok, body} + + err -> + Logger.debug("Failed to fetch NodeInfo data #{inspect(err)}") + {:error, :node_info_meta_http_error} + end + end + + @spec fetch_nodeinfo_details(String.t()) :: {:ok, String.t()} | {:error, atom()} + defp fetch_nodeinfo_details(host) do + with {:ok, body} <- fetch_nodeinfo_endpoint(host) do + extract_nodeinfo_endpoint(body) + end + end + + @spec extract_nodeinfo_endpoint(map()) :: + {:ok, String.t()} + | {:error, :no_node_info_endpoint_found | :no_valid_node_info_endpoint_found} + defp extract_nodeinfo_endpoint(body) do + links = Map.get(body, "links", []) + + relation = + find_nodeinfo_relation(links, @nodeinfo_rel_2_1) || + find_nodeinfo_relation(links, @nodeinfo_rel_2_0) + + if is_nil(relation) do + {:error, :no_node_info_endpoint_found} + else + endpoint = Map.get(relation, "href") + + if is_nil(endpoint) do + {:error, :no_valid_node_info_endpoint_found} + else + {:ok, endpoint} + end + end + end + + defp find_nodeinfo_relation(links, relation) do + Enum.find(links, fn %{"rel" => rel, "href" => href} -> + rel == relation and is_binary(href) + end) + end end diff --git a/lib/graphql/resolvers/admin.ex b/lib/graphql/resolvers/admin.ex index b5d6fa1ee..d052d8b09 100644 --- a/lib/graphql/resolvers/admin.ex +++ b/lib/graphql/resolvers/admin.ex @@ -490,19 +490,35 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do context: %{current_user: %User{role: role}} }) when is_admin(role) do - remote_relay = Actors.get_relay(domain) + remote_relay = Instances.get_instance_actor(domain) local_relay = Relay.get_actor() - result = %{ - has_relay: !is_nil(remote_relay), - relay_address: - if(is_nil(remote_relay), - do: nil, - else: "#{remote_relay.preferred_username}@#{remote_relay.domain}" - ), - follower_status: follow_status(remote_relay, local_relay), - followed_status: follow_status(local_relay, remote_relay) - } + result = + if is_nil(remote_relay) do + %{ + has_relay: false, + relay_address: nil, + follower_status: nil, + followed_status: nil, + software: nil, + software_version: nil + } + else + %{ + has_relay: !is_nil(remote_relay.actor), + relay_address: + if(is_nil(remote_relay.actor), + do: nil, + else: Actor.preferred_username_and_domain(remote_relay.actor) + ), + follower_status: follow_status(remote_relay.actor, local_relay), + followed_status: follow_status(local_relay, remote_relay.actor), + instance_name: remote_relay.instance_name, + instance_description: remote_relay.instance_description, + software: remote_relay.software, + software_version: remote_relay.software_version + } + end case Instances.instance(domain) do nil -> {:error, :not_found} diff --git a/lib/graphql/schema/admin.ex b/lib/graphql/schema/admin.ex index 97e428ffc..59c6b810b 100644 --- a/lib/graphql/schema/admin.ex +++ b/lib/graphql/schema/admin.ex @@ -227,6 +227,16 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do field(:relay_address, :string, description: "If this instance has a relay, it's federated username" ) + + field(:instance_name, :string, description: "This instance's name") + + field(:instance_description, :string, description: "This instance's description") + + field(:software, :string, description: "The software this instance declares running") + + field(:software_version, :string, + description: "The software version this instance declares running" + ) end @desc """ diff --git a/lib/mobilizon/instances/instance_actor.ex b/lib/mobilizon/instances/instance_actor.ex new file mode 100644 index 000000000..376498ad9 --- /dev/null +++ b/lib/mobilizon/instances/instance_actor.ex @@ -0,0 +1,39 @@ +defmodule Mobilizon.Instances.InstanceActor do + @moduledoc """ + An instance actor + """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Actors.Actor + + @type t :: %__MODULE__{ + domain: String.t(), + actor: Actor.t(), + instance_name: String.t(), + instance_description: String.t(), + software: String.t(), + software_version: String.t() + } + + schema "instance_actors" do + field(:domain, :string) + field(:instance_name, :string) + field(:instance_description, :string) + field(:software, :string) + field(:software_version, :string) + belongs_to(:actor, Actor) + + timestamps() + end + + @required_attrs [:domain] + @optional_attrs [:actor_id, :instance_name, :instance_description, :software, :software_version] + @attrs @required_attrs ++ @optional_attrs + + def changeset(%__MODULE__{} = instance_actor, attrs) do + instance_actor + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) + |> unique_constraint(:domain) + end +end diff --git a/lib/mobilizon/instances/instances.ex b/lib/mobilizon/instances/instances.ex index 94a293e7d..9fa759907 100644 --- a/lib/mobilizon/instances/instances.ex +++ b/lib/mobilizon/instances/instances.ex @@ -4,7 +4,7 @@ defmodule Mobilizon.Instances do """ alias Ecto.Adapters.SQL alias Mobilizon.Actors.{Actor, Follower} - alias Mobilizon.Instances.Instance + alias Mobilizon.Instances.{Instance, InstanceActor} alias Mobilizon.Storage.{Page, Repo} import Ecto.Query @@ -12,13 +12,13 @@ defmodule Mobilizon.Instances do @spec instances(Keyword.t()) :: Page.t(Instance.t()) def instances(options) do - page = Keyword.get(options, :page) - limit = Keyword.get(options, :limit) - order_by = Keyword.get(options, :order_by) - direction = Keyword.get(options, :direction) + page = Keyword.get(options, :page, 1) + limit = Keyword.get(options, :limit, 10) + order_by = Keyword.get(options, :order_by, :event_count) + direction = Keyword.get(options, :direction, :desc) filter_domain = Keyword.get(options, :filter_domain) - # suspend_status = Keyword.get(options, :filter_suspend_status) - follow_status = Keyword.get(options, :filter_follow_status) + # suspend_status = Keyword.get(options, :filter_suspend_status, :all) + follow_status = Keyword.get(options, :filter_follow_status, :all) order_by_options = Keyword.new([{direction, order_by}]) @@ -42,7 +42,9 @@ defmodule Mobilizon.Instances do query = Instance |> join(:left, [i], s in subquery(subquery), on: i.domain == s.domain) - |> select([i, s], {i, s}) + |> join(:left, [i], ia in InstanceActor, on: i.domain == ia.domain) + |> join(:left, [_i, _s, ia], a in Actor, on: ia.actor_id == a.id) + |> select([i, s, ia, a], {i, s, ia, a}) |> order_by(^order_by_options) query = @@ -100,16 +102,72 @@ defmodule Mobilizon.Instances do following: following, following_approved: following_approved, has_relay: has_relay - }} + }, instance_meta, instance_actor} ) do instance |> Map.put(:follower_status, follow_status(following, following_approved)) |> Map.put(:followed_status, follow_status(follower, follower_approved)) |> Map.put(:has_relay, has_relay) + |> Map.put(:instance_actor, instance_actor) + |> add_metadata_details(instance_meta) + end + + @spec add_metadata_details(map(), InstanceActor.t() | nil) :: map() + defp add_metadata_details(instance, nil), do: instance + + defp add_metadata_details(instance, instance_meta) do + instance + |> Map.put(:instance_name, instance_meta.instance_name) + |> Map.put(:instance_description, instance_meta.instance_description) + |> Map.put(:software, instance_meta.software) + |> Map.put(:software_version, instance_meta.software_version) end defp follow_status(true, true), do: :approved defp follow_status(true, false), do: :pending defp follow_status(false, _), do: :none defp follow_status(nil, _), do: :none + + @spec get_instance_actor(String.t()) :: InstanceActor.t() | nil + def get_instance_actor(domain) do + InstanceActor + |> Repo.get_by(domain: domain) + |> Repo.preload(:actor) + end + + @doc """ + Creates an instance actor. + """ + @spec create_instance_actor(map) :: {:ok, InstanceActor.t()} | {:error, Ecto.Changeset.t()} + def create_instance_actor(attrs \\ %{}) do + with {:ok, %InstanceActor{} = instance_actor} <- + %InstanceActor{} + |> InstanceActor.changeset(attrs) + |> Repo.insert(on_conflict: :replace_all, conflict_target: :domain) do + {:ok, Repo.preload(instance_actor, :actor)} + end + end + + @doc """ + Updates an instance actor. + """ + @spec update_instance_actor(InstanceActor.t(), map) :: + {:ok, InstanceActor.t()} | {:error, Ecto.Changeset.t()} + def update_instance_actor(%InstanceActor{} = instance_actor, attrs) do + with {:ok, %InstanceActor{} = instance_actor} <- + instance_actor + |> Repo.preload(:actor) + |> InstanceActor.changeset(attrs) + |> Repo.update() do + {:ok, Repo.preload(instance_actor, :actor)} + end + end + + @doc """ + Deletes a post + """ + @spec delete_instance_actor(InstanceActor.t()) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()} + def delete_instance_actor(%InstanceActor{} = instance_actor) do + Repo.delete(instance_actor) + end end diff --git a/lib/service/workers/refresh_instances.ex b/lib/service/workers/refresh_instances.ex index efc67a15a..5381b1916 100644 --- a/lib/service/workers/refresh_instances.ex +++ b/lib/service/workers/refresh_instances.ex @@ -3,14 +3,16 @@ defmodule Mobilizon.Service.Workers.RefreshInstances do Worker to refresh the instances materialized view and the relay actors """ - use Oban.Worker, unique: [period: :infinity, keys: [:event_uuid, :action]] + use Oban.Worker alias Mobilizon.Actors.Actor alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor alias Mobilizon.Federation.ActivityPub.Relay + alias Mobilizon.Federation.NodeInfo alias Mobilizon.Instances - alias Mobilizon.Instances.Instance + alias Mobilizon.Instances.{Instance, InstanceActor} alias Oban.Job + require Logger @impl Oban.Worker @spec perform(Oban.Job.t()) :: :ok @@ -30,6 +32,8 @@ defmodule Mobilizon.Service.Workers.RefreshInstances do {:error, :not_remote_instance} end + @spec refresh_instance_actor(Instance.t()) :: + {:ok, InstanceActor.t()} | {:error, Ecto.Changeset.t()} | {:error, atom} def refresh_instance_actor(%Instance{domain: domain}) do %Actor{url: url} = Relay.get_actor() %URI{host: host} = URI.new!(url) @@ -37,7 +41,67 @@ defmodule Mobilizon.Service.Workers.RefreshInstances do if host == domain do {:error, :not_remote_instance} else - ActivityPubActor.find_or_make_actor_from_nickname("relay@#{domain}") + actor_id = + case fetch_actor(domain) do + {:ok, %Actor{id: actor_id}} -> actor_id + _ -> nil + end + + with instance_metadata <- fetch_instance_metadata(domain), + :ok <- Logger.debug("Ready to save instance actor details"), + {:ok, %InstanceActor{}} <- + Instances.create_instance_actor(%{ + domain: domain, + actor_id: actor_id, + instance_name: get_in(instance_metadata, ["metadata", "nodeName"]), + instance_description: get_in(instance_metadata, ["metadata", "nodeDescription"]), + software: get_in(instance_metadata, ["software", "name"]), + software_version: get_in(instance_metadata, ["software", "version"]) + }) do + Logger.info("Saved instance actor details for domain #{host}") + else + err -> + Logger.error(inspect(err)) + end end end + + defp mobilizon(domain), do: "relay@#{domain}" + defp peertube(domain), do: "peertube@#{domain}" + defp mastodon(domain), do: "#{domain}@#{domain}" + + defp fetch_actor(domain) do + case NodeInfo.application_actor(domain) do + nil -> guess_application_actor(domain) + url -> ActivityPubActor.get_or_fetch_actor_by_url(url) + end + end + + defp fetch_instance_metadata(domain) do + case NodeInfo.nodeinfo(domain) do + {:error, _} -> + %{} + + {:ok, metadata} -> + metadata + end + end + + defp guess_application_actor(domain) do + Enum.find_value( + [ + &mobilizon/1, + &peertube/1, + &mastodon/1 + ], + {:error, :no_application_actor_found}, + fn username_pattern -> + case ActivityPubActor.find_or_make_actor_from_nickname(username_pattern.(domain)) do + {:ok, %Actor{type: :Application} = actor} -> {:ok, actor} + {:error, _err} -> false + {:ok, _actor} -> false + end + end + ) + end end diff --git a/priv/repo/migrations/20231220092536_add_actor_instances.exs b/priv/repo/migrations/20231220092536_add_actor_instances.exs new file mode 100644 index 000000000..2a90a3437 --- /dev/null +++ b/priv/repo/migrations/20231220092536_add_actor_instances.exs @@ -0,0 +1,17 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddActorInstances do + use Ecto.Migration + + def change do + create table(:instance_actors) do + add(:domain, :string) + add(:instance_name, :string) + add(:instance_description, :string) + add(:software, :string) + add(:software_version, :string) + add(:actor_id, references(:actors, on_delete: :delete_all)) + timestamps() + end + + create(unique_index(:instance_actors, [:domain])) + end +end diff --git a/public/img/gancio.png b/public/img/gancio.png new file mode 100644 index 0000000000000000000000000000000000000000..904c556d0c92299fa9e5d17549fd6ca6ecfc4fac GIT binary patch literal 10166 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 JPCGF+{s)4j?EL@$ literal 0 HcmV?d00001 diff --git a/public/img/wordpress-logo.svg b/public/img/wordpress-logo.svg new file mode 100644 index 000000000..96dde54dc --- /dev/null +++ b/public/img/wordpress-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/oruga-tailwindcss.css b/src/assets/oruga-tailwindcss.css index 41a417086..0b8f80320 100644 --- a/src/assets/oruga-tailwindcss.css +++ b/src/assets/oruga-tailwindcss.css @@ -293,7 +293,7 @@ button.menubar__button { @apply px-3 dark:text-black; } .pagination-link-current { - @apply bg-primary dark:bg-primary cursor-not-allowed pointer-events-none border-primary text-white dark:text-zinc-900; + @apply bg-primary dark:bg-primary cursor-not-allowed pointer-events-none border-primary text-white; } .pagination-ellipsis { @apply text-center m-1 text-gray-300; diff --git a/src/components/Settings/SettingMenuItem.vue b/src/components/Settings/SettingMenuItem.vue index a52570001..2322c48b0 100644 --- a/src/components/Settings/SettingMenuItem.vue +++ b/src/components/Settings/SettingMenuItem.vue @@ -2,8 +2,8 @@
  • diff --git a/src/components/Settings/SettingMenuSection.vue b/src/components/Settings/SettingMenuSection.vue index 173839567..06eb63896 100644 --- a/src/components/Settings/SettingMenuSection.vue +++ b/src/components/Settings/SettingMenuSection.vue @@ -1,6 +1,6 @@