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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 """
|
||||
|
||||
39
lib/mobilizon/instances/instance_actor.ex
Normal file
39
lib/mobilizon/instances/instance_actor.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user