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 <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2023-12-20 17:52:27 +01:00
parent 2fba6379f1
commit 99b2339424
25 changed files with 775 additions and 167 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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