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
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
25 changed files with 775 additions and 167 deletions

View file

@ -7,22 +7,43 @@ defmodule Mobilizon.Federation.NodeInfo do
require Logger require Logger
@application_uri "https://www.w3.org/ns/activitystreams#Application" @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) @env Application.compile_env(:mobilizon, :env)
@spec application_actor(String.t()) :: String.t() | nil @spec application_actor(String.t()) :: String.t() | nil
def application_actor(host) do 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 case fetch_nodeinfo_endpoint(host) do
{:ok, %{body: body, status: code}} when code in 200..299 -> {:ok, body} ->
extract_application_actor(body) extract_application_actor(body)
err -> {:error, :node_info_meta_http_error} ->
Logger.debug("Failed to fetch NodeInfo data #{inspect(err)}")
nil nil
end end
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 defp extract_application_actor(body) do
body body
|> Map.get("links", []) |> Map.get("links", [])
@ -31,4 +52,54 @@ defmodule Mobilizon.Federation.NodeInfo do
end) end)
|> Map.get("href") |> Map.get("href")
end 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 end

View file

@ -490,19 +490,35 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
context: %{current_user: %User{role: role}} context: %{current_user: %User{role: role}}
}) })
when is_admin(role) do when is_admin(role) do
remote_relay = Actors.get_relay(domain) remote_relay = Instances.get_instance_actor(domain)
local_relay = Relay.get_actor() local_relay = Relay.get_actor()
result = %{ result =
has_relay: !is_nil(remote_relay), if is_nil(remote_relay) do
relay_address: %{
if(is_nil(remote_relay), has_relay: false,
do: nil, relay_address: nil,
else: "#{remote_relay.preferred_username}@#{remote_relay.domain}" follower_status: nil,
), followed_status: nil,
follower_status: follow_status(remote_relay, local_relay), software: nil,
followed_status: follow_status(local_relay, remote_relay) 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 case Instances.instance(domain) do
nil -> {:error, :not_found} nil -> {:error, :not_found}

View file

@ -227,6 +227,16 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
field(:relay_address, :string, field(:relay_address, :string,
description: "If this instance has a relay, it's federated username" 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 end
@desc """ @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 Ecto.Adapters.SQL
alias Mobilizon.Actors.{Actor, Follower} alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Instances.Instance alias Mobilizon.Instances.{Instance, InstanceActor}
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
import Ecto.Query import Ecto.Query
@ -12,13 +12,13 @@ defmodule Mobilizon.Instances do
@spec instances(Keyword.t()) :: Page.t(Instance.t()) @spec instances(Keyword.t()) :: Page.t(Instance.t())
def instances(options) do def instances(options) do
page = Keyword.get(options, :page) page = Keyword.get(options, :page, 1)
limit = Keyword.get(options, :limit) limit = Keyword.get(options, :limit, 10)
order_by = Keyword.get(options, :order_by) order_by = Keyword.get(options, :order_by, :event_count)
direction = Keyword.get(options, :direction) direction = Keyword.get(options, :direction, :desc)
filter_domain = Keyword.get(options, :filter_domain) filter_domain = Keyword.get(options, :filter_domain)
# suspend_status = Keyword.get(options, :filter_suspend_status) # suspend_status = Keyword.get(options, :filter_suspend_status, :all)
follow_status = Keyword.get(options, :filter_follow_status) follow_status = Keyword.get(options, :filter_follow_status, :all)
order_by_options = Keyword.new([{direction, order_by}]) order_by_options = Keyword.new([{direction, order_by}])
@ -42,7 +42,9 @@ defmodule Mobilizon.Instances do
query = query =
Instance Instance
|> join(:left, [i], s in subquery(subquery), on: i.domain == s.domain) |> 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) |> order_by(^order_by_options)
query = query =
@ -100,16 +102,72 @@ defmodule Mobilizon.Instances do
following: following, following: following,
following_approved: following_approved, following_approved: following_approved,
has_relay: has_relay has_relay: has_relay
}} }, instance_meta, instance_actor}
) do ) do
instance instance
|> Map.put(:follower_status, follow_status(following, following_approved)) |> Map.put(:follower_status, follow_status(following, following_approved))
|> Map.put(:followed_status, follow_status(follower, follower_approved)) |> Map.put(:followed_status, follow_status(follower, follower_approved))
|> Map.put(:has_relay, has_relay) |> 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 end
defp follow_status(true, true), do: :approved defp follow_status(true, true), do: :approved
defp follow_status(true, false), do: :pending defp follow_status(true, false), do: :pending
defp follow_status(false, _), do: :none defp follow_status(false, _), do: :none
defp follow_status(nil, _), 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 end

View file

@ -3,14 +3,16 @@ defmodule Mobilizon.Service.Workers.RefreshInstances do
Worker to refresh the instances materialized view and the relay actors 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.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Federation.ActivityPub.Relay alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Federation.NodeInfo
alias Mobilizon.Instances alias Mobilizon.Instances
alias Mobilizon.Instances.Instance alias Mobilizon.Instances.{Instance, InstanceActor}
alias Oban.Job alias Oban.Job
require Logger
@impl Oban.Worker @impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok @spec perform(Oban.Job.t()) :: :ok
@ -30,6 +32,8 @@ defmodule Mobilizon.Service.Workers.RefreshInstances do
{:error, :not_remote_instance} {:error, :not_remote_instance}
end end
@spec refresh_instance_actor(Instance.t()) ::
{:ok, InstanceActor.t()} | {:error, Ecto.Changeset.t()} | {:error, atom}
def refresh_instance_actor(%Instance{domain: domain}) do def refresh_instance_actor(%Instance{domain: domain}) do
%Actor{url: url} = Relay.get_actor() %Actor{url: url} = Relay.get_actor()
%URI{host: host} = URI.new!(url) %URI{host: host} = URI.new!(url)
@ -37,7 +41,67 @@ defmodule Mobilizon.Service.Workers.RefreshInstances do
if host == domain do if host == domain do
{:error, :not_remote_instance} {:error, :not_remote_instance}
else 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 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

View file

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

BIN
public/img/gancio.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 96.24 96.24" xml:space="preserve"
>
<g>
<path d="M48.122,0C21.587,0,0.001,21.585,0.001,48.118c0,26.535,21.587,48.122,48.12,48.122c26.532,0,48.117-21.587,48.117-48.122
C96.239,21.586,74.654,0,48.122,0z M4.857,48.118c0-6.271,1.345-12.227,3.746-17.606l20.638,56.544
C14.81,80.042,4.857,65.243,4.857,48.118z M48.122,91.385c-4.247,0-8.346-0.623-12.222-1.763L48.88,51.903l13.301,36.433
c0.086,0.215,0.191,0.411,0.308,0.596C57.992,90.514,53.16,91.385,48.122,91.385z M54.083,27.834
c2.604-0.137,4.953-0.412,4.953-0.412c2.33-0.276,2.057-3.701-0.277-3.564c0,0-7.007,0.549-11.532,0.549
c-4.25,0-11.396-0.549-11.396-0.549c-2.332-0.137-2.604,3.427-0.273,3.564c0,0,2.208,0.275,4.537,0.412l6.74,18.469l-9.468,28.395
L21.615,27.835c2.608-0.136,4.952-0.412,4.952-0.412c2.33-0.275,2.055-3.702-0.278-3.562c0,0-7.004,0.549-11.53,0.549
c-0.813,0-1.77-0.021-2.784-0.052C19.709,12.611,33.008,4.856,48.122,4.856c11.265,0,21.519,4.306,29.215,11.357
c-0.187-0.01-0.368-0.035-0.562-0.035c-4.248,0-7.264,3.702-7.264,7.679c0,3.564,2.055,6.582,4.248,10.146
c1.647,2.882,3.567,6.585,3.567,11.932c0,3.704-1.422,8-3.293,13.986l-4.315,14.421L54.083,27.834z M69.871,85.516l13.215-38.208
c2.471-6.171,3.29-11.106,3.29-15.497c0-1.591-0.104-3.07-0.292-4.449c3.38,6.163,5.303,13.236,5.301,20.758
C91.384,64.08,82.732,78.016,69.871,85.516z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -293,7 +293,7 @@ button.menubar__button {
@apply px-3 dark:text-black; @apply px-3 dark:text-black;
} }
.pagination-link-current { .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 { .pagination-ellipsis {
@apply text-center m-1 text-gray-300; @apply text-center m-1 text-gray-300;

View file

@ -2,8 +2,8 @@
<li <li
class="setting-menu-item" class="setting-menu-item"
:class="{ :class="{
'cursor-pointer bg-mbz-yellow-alt-500 dark:bg-mbz-purple-500': isActive, 'cursor-pointer bg-mbz-yellow-alt-500 dark:bg-mbz-purple-600': isActive,
'bg-mbz-yellow-alt-100 hover:bg-mbz-yellow-alt-200 dark:bg-mbz-purple-300 dark:hover:bg-mbz-purple-400 dark:text-white': 'bg-mbz-yellow-alt-100 hover:bg-mbz-yellow-alt-200 dark:bg-mbz-purple-500 dark:hover:bg-mbz-purple-600 dark:text-white':
!isActive, !isActive,
}" }"
> >

View file

@ -1,6 +1,6 @@
<template> <template>
<li <li
class="bg-mbz-yellow-alt-300 text-violet-2 dark:bg-mbz-purple-500 dark:text-zinc-100 text-xl" class="bg-mbz-yellow-alt-300 text-violet-2 dark:bg-mbz-purple-700 dark:text-zinc-100 text-xl"
> >
<router-link <router-link
class="cursor-pointer my-2 mx-0 py-2 px-3 font-medium block no-underline" class="cursor-pointer my-2 mx-0 py-2 px-3 font-medium block no-underline"

View file

@ -81,6 +81,10 @@ export const INSTANCE_FRAGMENT = gql`
fragment InstanceFragment on Instance { fragment InstanceFragment on Instance {
domain domain
hasRelay hasRelay
instanceName
instanceDescription
software
softwareVersion
relayAddress relayAddress
followerStatus followerStatus
followedStatus followedStatus

View file

@ -1638,5 +1638,8 @@
"Return to the event page": "Return to the event page", "Return to the event page": "Return to the event page",
"Cancel participation": "Cancel participation", "Cancel participation": "Cancel participation",
"Add a recipient": "Add a recipient", "Add a recipient": "Add a recipient",
"Announcements for {eventTitle}": "Announcements for {eventTitle}" "Announcements for {eventTitle}": "Announcements for {eventTitle}",
"Visit {instance_domain}": "Visit {instance_domain}",
"Software details: {software_details}": "Software details: {software_details}",
"Only instances with an application actor can be followed": "Only instances with an application actor can be followed"
} }

View file

@ -1632,5 +1632,8 @@
"Return to the event page": "Retourner à la page de l'événement", "Return to the event page": "Retourner à la page de l'événement",
"Cancel participation": "Annuler la participation", "Cancel participation": "Annuler la participation",
"Add a recipient": "Ajouter un·e destinataire", "Add a recipient": "Ajouter un·e destinataire",
"Announcements for {eventTitle}": "Annonces pour {eventTitle}" "Announcements for {eventTitle}": "Annonces pour {eventTitle}",
"Visit {instance_domain}": "Visiter {instance_domain}",
"Software details: {software_details}": "Détails du logiciel : {software_details}",
"Only instances with an application actor can be followed": "Seules les instances avec un acteur application peuvent être suivies"
} }

View file

@ -3,6 +3,10 @@ import { InstanceFollowStatus } from "./enums";
export interface IInstance { export interface IInstance {
domain: string; domain: string;
hasRelay: boolean; hasRelay: boolean;
instanceName: string | null;
instanceDescription: string | null;
software: string | null;
softwareVersion: string | null;
relayAddress: string | null; relayAddress: string | null;
followerStatus: InstanceFollowStatus; followerStatus: InstanceFollowStatus;
followedStatus: InstanceFollowStatus; followedStatus: InstanceFollowStatus;

View file

@ -2,12 +2,50 @@
<div v-if="instance"> <div v-if="instance">
<breadcrumbs-nav <breadcrumbs-nav
:links="[ :links="[
{ name: RouteName.ADMIN, text: $t('Admin') }, { name: RouteName.ADMIN, text: t('Admin') },
{ name: RouteName.INSTANCES, text: $t('Instances') }, { name: RouteName.INSTANCES, text: t('Instances') },
{ text: instance.domain }, { text: instance.domain },
]" ]"
/> />
<h1 class="text-2xl">{{ instance.domain }}</h1> <section
class="flex flex-wrap md:flex-nowrap items-center justify-between gap-4"
>
<div>
<h2 class="text-4xl font-bold" v-if="instance.instanceName">
{{ instance.instanceName }}
</h2>
<h2 class="text-4xl font-bold" v-else>{{ instance.domain }}</h2>
<p
v-if="instance.instanceDescription"
class="text-slate-700 dark:text-slate-400 my-4"
>
{{ instance.instanceDescription }}
</p>
<i18n-t
v-if="instance.software && instance.softwareVersion"
keypath="Software details: {software_details}"
class="my-4"
>
<template #software_details>
<span class="capitalize">
{{ instance.software }} - {{ instance.softwareVersion }}
</span>
</template>
</i18n-t>
</div>
<o-button
tag="a"
:href="`https://${instance.domain}`"
target="_blank"
rel="noopener noreferrer"
icon-right="open-in-new"
class="mx-auto md:mx-0"
>{{
t("Visit {instance_domain}", { instance_domain: instance.domain })
}}
</o-button>
</section>
<section>
<div <div
class="grid md:grid-cols-2 xl:grid-cols-4 gap-2 content-center text-center mt-2" class="grid md:grid-cols-2 xl:grid-cols-4 gap-2 content-center text-center mt-2"
> >
@ -21,7 +59,7 @@
<span class="mb-4 text-xl font-semibold block">{{ <span class="mb-4 text-xl font-semibold block">{{
instance.personCount instance.personCount
}}</span> }}</span>
<span class="text-sm block">{{ $t("Profiles") }}</span> <span class="text-sm block">{{ t("Profiles") }}</span>
</router-link> </router-link>
</div> </div>
<div class="bg-gray-50 dark:bg-mbz-purple-500 rounded-xl p-8"> <div class="bg-gray-50 dark:bg-mbz-purple-500 rounded-xl p-8">
@ -34,42 +72,52 @@
<span class="mb-4 text-xl font-semibold block">{{ <span class="mb-4 text-xl font-semibold block">{{
instance.groupCount instance.groupCount
}}</span> }}</span>
<span class="text-sm block">{{ $t("Groups") }}</span> <span class="text-sm block">{{ t("Groups") }}</span>
</router-link> </router-link>
</div> </div>
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8"> <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
<span class="mb-4 text-xl font-semibold block">{{ <span class="mb-4 text-xl font-semibold block">{{
instance.followingsCount instance.followingsCount
}}</span> }}</span>
<span class="text-sm block">{{ $t("Followings") }}</span> <span class="text-sm block">{{ t("Followings") }}</span>
</div> </div>
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8"> <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
<span class="mb-4 text-xl font-semibold block">{{ <span class="mb-4 text-xl font-semibold block">{{
instance.followersCount instance.followersCount
}}</span> }}</span>
<span class="text-sm block">{{ $t("Followers") }}</span> <span class="text-sm block">{{ t("Followers") }}</span>
</div> </div>
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8"> <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
<router-link <router-link
:to="{ name: RouteName.REPORTS, query: { domain: instance.domain } }" :to="{
name: RouteName.REPORTS,
query: { domain: instance.domain },
}"
> >
<span class="mb-4 text-xl font-semibold block">{{ <span class="mb-4 text-xl font-semibold block">{{
instance.reportsCount instance.reportsCount
}}</span> }}</span>
<span class="text-sm block">{{ $t("Reports") }}</span> <span class="text-sm block">{{ t("Reports") }}</span>
</router-link> </router-link>
</div> </div>
<div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8"> <div class="bg-zinc-50 dark:bg-mbz-purple-500 rounded-xl p-8">
<span class="mb-4 font-semibold block">{{ <span class="mb-4 font-semibold block">{{
formatBytes(instance.mediaSize) formatBytes(instance.mediaSize)
}}</span> }}</span>
<span class="text-sm block">{{ $t("Uploaded media size") }}</span> <span class="text-sm block">{{ t("Uploaded media size") }}</span>
</div> </div>
</div> </div>
</section>
<section>
<div class="mt-3 grid xl:grid-cols-2 gap-4"> <div class="mt-3 grid xl:grid-cols-2 gap-4">
<div <div
class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md" class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md"
v-if="instance.hasRelay" v-if="
instance.hasRelay &&
!['mastodon', 'peertube'].includes(
instance.software?.toLowerCase() ?? ''
)
"
> >
<button <button
@click=" @click="
@ -80,7 +128,7 @@
v-if="instance.followedStatus == InstanceFollowStatus.APPROVED" v-if="instance.followedStatus == InstanceFollowStatus.APPROVED"
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto" class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
> >
{{ $t("Stop following instance") }} {{ t("Stop following instance") }}
</button> </button>
<button <button
@click=" @click="
@ -91,18 +139,18 @@
v-else-if="instance.followedStatus == InstanceFollowStatus.PENDING" v-else-if="instance.followedStatus == InstanceFollowStatus.PENDING"
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto" class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
> >
{{ $t("Cancel follow request") }} {{ t("Cancel follow request") }}
</button> </button>
<button <button
@click="followInstance" @click="followInstance"
v-else v-else
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto" class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
> >
{{ $t("Follow instance") }} {{ t("Follow instance") }}
</button> </button>
</div> </div>
<div v-else class="md:h-48 py-16 text-center opacity-50"> <div v-else class="md:h-48 py-16 text-center opacity-50">
{{ $t("Only Mobilizon instances can be followed") }} {{ t("Only instances with an application actor can be followed") }}
</div> </div>
<div <div
class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md flex flex-col gap-2" class="border bg-white dark:bg-mbz-purple-500 dark:border-mbz-purple-700 p-6 shadow-md rounded-md flex flex-col gap-2"
@ -116,7 +164,7 @@
v-if="instance.followerStatus == InstanceFollowStatus.PENDING" v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
class="bg-green-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto" class="bg-green-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
> >
{{ $t("Accept follow") }} {{ t("Accept follow") }}
</button> </button>
<button <button
@click=" @click="
@ -127,13 +175,14 @@
v-if="instance.followerStatus != InstanceFollowStatus.NONE" v-if="instance.followerStatus != InstanceFollowStatus.NONE"
class="bg-red-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto" class="bg-red-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
> >
{{ $t("Reject follow") }} {{ t("Reject follow") }}
</button> </button>
<p v-if="instance.followerStatus == InstanceFollowStatus.NONE"> <p v-if="instance.followerStatus == InstanceFollowStatus.NONE">
{{ $t("This instance doesn't follow yours.") }} {{ t("This instance doesn't follow yours.") }}
</p> </p>
</div> </div>
</div> </div>
</section>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -152,6 +201,7 @@ import { InstanceFollowStatus } from "@/types/enums";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject } from "vue"; import { computed, inject } from "vue";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import { useI18n } from "vue-i18n";
const props = defineProps<{ domain: string }>(); const props = defineProps<{ domain: string }>();
@ -164,6 +214,8 @@ const instance = computed(() => instanceResult.value?.instance);
const notifier = inject<Notifier>("notifier"); const notifier = inject<Notifier>("notifier");
const { t } = useI18n({ useScope: "global" });
const { mutate: acceptInstance, onError: onAcceptInstanceError } = useMutation( const { mutate: acceptInstance, onError: onAcceptInstanceError } = useMutation(
ACCEPT_RELAY, ACCEPT_RELAY,
() => ({ () => ({

View file

@ -72,21 +72,65 @@
name: RouteName.INSTANCE, name: RouteName.INSTANCE,
params: { domain: instance.domain }, params: { domain: instance.domain },
}" }"
class="flex items-center mb-2 rounded bg-mbz-yellow-alt-300 dark:bg-mbz-purple-400 p-4 flex-wrap justify-center gap-x-2 gap-y-3" class="flex items-center mb-2 rounded bg-mbz-yellow-alt-300 hover:bg-mbz-yellow-alt-200 dark:bg-mbz-purple-600 dark:hover:bg-mbz-purple-700 p-4 flex-wrap justify-center gap-x-2 gap-y-3"
v-for="instance in instances.elements" v-for="instance in instances.elements"
:key="instance.domain" :key="instance.domain"
> >
<div class="grow overflow-hidden flex items-center gap-1"> <div class="grow overflow-hidden flex items-center gap-1">
<img <img
class="w-12" class="w-12"
v-if="instance.hasRelay" v-if="instance.software === 'Mobilizon'"
src="/img/logo.svg" src="/img/logo.svg"
alt="" alt=""
/> />
<CloudQuestion v-else :size="36" /> <mastodon-logo
class="w-8 mx-2"
alt=""
v-else-if="instance.software?.toLowerCase() === 'mastodon'"
/>
<img
class="w-8 mx-2"
v-else-if="instance.software?.toLowerCase() === 'gancio'"
src="/img/gancio.png"
alt=""
/>
<img
class="w-8 mx-2"
v-else-if="instance.software?.toLowerCase() === 'wordpress'"
src="/img/wordpress-logo.svg"
alt=""
/>
<CloudQuestion class="mx-1.5" v-else :size="36" />
<div class=""> <div class="">
<h3 class="text-lg truncate">{{ instance.domain }}</h3> <h3
class="text-lg truncate font-bold text-slate-800 dark:text-slate-100"
v-if="instance.instanceName"
>
{{ instance.instanceName }}
</h3>
<h3
class="text-lg truncate font-bold text-slate-800 dark:text-slate-100"
v-else
>
{{ instance.domain }}
</h3>
<p
v-if="instance.instanceName"
class="inline-flex gap-2 text-slate-700 dark:text-slate-300"
>
<span class="capitalize" v-if="instance.software">{{
instance.software
}}</span>
-
<span>{{ instance.domain }}</span>
</p>
<p
v-else-if="instance.software"
class="capitalize text-slate-700 dark:text-slate-300"
>
{{ instance.software }}
</p>
<span <span
class="text-sm" class="text-sm"
v-if="instance.followedStatus === InstanceFollowStatus.APPROVED" v-if="instance.followedStatus === InstanceFollowStatus.APPROVED"
@ -186,6 +230,7 @@ import { useRouter } from "vue-router";
import { useHead } from "@unhead/vue"; import { useHead } from "@unhead/vue";
import CloudQuestion from "../../../node_modules/vue-material-design-icons/CloudQuestion.vue"; import CloudQuestion from "../../../node_modules/vue-material-design-icons/CloudQuestion.vue";
import { Notifier } from "@/plugins/notifier"; import { Notifier } from "@/plugins/notifier";
import MastodonLogo from "@/components/Share/MastodonLogo.vue";
const INSTANCES_PAGE_LIMIT = 10; const INSTANCES_PAGE_LIMIT = 10;

View file

@ -8,6 +8,10 @@ defmodule Mobilizon.Federation.NodeInfoTest do
@instance_domain "event-federation.eu" @instance_domain "event-federation.eu"
@nodeinfo_fixture_path "test/fixtures/nodeinfo/wp-event-federation.json" @nodeinfo_fixture_path "test/fixtures/nodeinfo/wp-event-federation.json"
@nodeinfo_regular_fixture_path "test/fixtures/nodeinfo/regular.json" @nodeinfo_regular_fixture_path "test/fixtures/nodeinfo/regular.json"
@nodeinfo_both_versions_fixture_path "test/fixtures/nodeinfo/both_versions.json"
@nodeinfo_older_versions_fixture_path "test/fixtures/nodeinfo/older_versions.json"
@nodeinfo_data_fixture_path "test/fixtures/nodeinfo/data.json"
@nodeinfo_wp_data_fixture_path "test/fixtures/nodeinfo/wp-data.json"
describe "getting application actor" do describe "getting application actor" do
test "from wordpress event federation" do test "from wordpress event federation" do
@ -57,4 +61,128 @@ defmodule Mobilizon.Federation.NodeInfoTest do
assert nil == NodeInfo.application_actor(@instance_domain) assert nil == NodeInfo.application_actor(@instance_domain)
end end
end end
describe "getting informations" do
test "with both 2.0 and 2.1 endpoints" do
nodeinfo_end_point_data =
@nodeinfo_both_versions_fixture_path |> File.read!() |> Jason.decode!()
nodeinfo_data = @nodeinfo_data_fixture_path |> File.read!() |> Jason.decode!()
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://mobilizon.fr/.well-known/nodeinfo"
},
_opts ->
{:ok, %Tesla.Env{status: 200, body: nodeinfo_end_point_data}}
end)
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://mobilizon.fr/.well-known/nodeinfo/2.1"
},
_opts ->
{:ok, %Tesla.Env{status: 200, body: nodeinfo_data}}
end)
assert {:ok, data} = NodeInfo.nodeinfo("mobilizon.fr")
assert data["version"] == "2.1"
assert data["software"]["name"] == "Mobilizon"
# Added in 2.1
refute is_nil(data["software"]["repository"])
end
test "with only 2.0 endpoint" do
nodeinfo_end_point_data = @nodeinfo_regular_fixture_path |> File.read!() |> Jason.decode!()
nodeinfo_wp_data = @nodeinfo_wp_data_fixture_path |> File.read!() |> Jason.decode!()
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://event-federation.eu/.well-known/nodeinfo"
},
_opts ->
{:ok, %Tesla.Env{status: 200, body: nodeinfo_end_point_data}}
end)
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://event-federation.eu/wp-json/activitypub/1.0/nodeinfo"
},
_opts ->
{:ok, %Tesla.Env{status: 200, body: nodeinfo_wp_data}}
end)
assert {:ok, data} = NodeInfo.nodeinfo("event-federation.eu")
assert data["version"] == "2.0"
assert data["software"]["name"] == "wordpress"
# Added in 2.1
assert is_nil(data["software"]["repository"])
end
test "with no valid endpoint" do
nodeinfo_end_point_data =
@nodeinfo_older_versions_fixture_path |> File.read!() |> Jason.decode!()
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://somewhere.tld/.well-known/nodeinfo"
},
_opts ->
{:ok, %Tesla.Env{status: 200, body: nodeinfo_end_point_data}}
end)
assert {:error, :no_node_info_endpoint_found} = NodeInfo.nodeinfo("somewhere.tld")
end
test "with no answer from well-known endpoint" do
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://somewhere.tld/.well-known/nodeinfo"
},
_opts ->
{:ok, %Tesla.Env{status: 404}}
end)
assert {:error, :node_info_meta_http_error} = NodeInfo.nodeinfo("somewhere.tld")
end
test "with no answer from version endpoint" do
nodeinfo_end_point_data =
@nodeinfo_both_versions_fixture_path |> File.read!() |> Jason.decode!()
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://mobilizon.fr/.well-known/nodeinfo"
},
_opts ->
{:ok, %Tesla.Env{status: 200, body: nodeinfo_end_point_data}}
end)
WebfingerClientMock
|> expect(:call, fn
%{
method: :get,
url: "https://mobilizon.fr/.well-known/nodeinfo/2.1"
},
_opts ->
{:ok, %Tesla.Env{status: 404}}
end)
assert {:error, :node_info_endpoint_http_error} = NodeInfo.nodeinfo("mobilizon.fr")
end
end
end end

View file

@ -0,0 +1,12 @@
{
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": "https://mobilizon.fr/.well-known/nodeinfo/2.0"
},
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
"href": "https://mobilizon.fr/.well-known/nodeinfo/2.1"
}
]
}

29
test/fixtures/nodeinfo/data.json vendored Normal file
View file

@ -0,0 +1,29 @@
{
"version": "2.1",
"protocols": [
"activitypub"
],
"metadata": {
"nodeDescription": "Mobilizon.fr est l'instance Mobilizon de Framasoft.",
"nodeName": "Mobilizon"
},
"usage": {
"users": {
"total": 9204
},
"localComments": 3253,
"localPosts": 7545
},
"services": {
"outbound": [
"atom1.0"
],
"inbound": []
},
"software": {
"name": "Mobilizon",
"version": "4.0.2",
"repository": "https://framagit.org/framasoft/mobilizon"
},
"openRegistrations": true
}

View file

@ -0,0 +1,8 @@
{
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/1.1",
"href": "https://mobilizon.fr/.well-known/nodeinfo/1.1"
}
]
}

View file

@ -1,8 +1,8 @@
{ {
"links": [ "links": [
{ {
"rel": "http:\/\/nodeinfo.diaspora.software\/ns\/schema\/2.0", "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": "https:\/\/event-federation.eu\/wp-json\/activitypub\/1.0\/nodeinfo" "href": "https://event-federation.eu/wp-json/activitypub/1.0/nodeinfo"
} }
] ]
} }

24
test/fixtures/nodeinfo/wp-data.json vendored Normal file
View file

@ -0,0 +1,24 @@
{
"version": "2.0",
"software": {
"name": "wordpress",
"version": "6.4.2"
},
"usage": {
"users": {
"total": 1,
"activeMonth": 1,
"activeHalfyear": 1
},
"localPosts": 3,
"localComments": 3
},
"openRegistrations": false,
"protocols": [
"activitypub"
],
"services": {
"inbound": [],
"outbound": []
}
}

View file

@ -1,8 +1,8 @@
{ {
"links": [ "links": [
{ {
"rel": "http:\/\/nodeinfo.diaspora.software\/ns\/schema\/2.0", "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": "https:\/\/event-federation.eu\/wp-json\/activitypub\/1.0\/nodeinfo" "href": "https://event-federation.eu/wp-json/activitypub/1.0/nodeinfo"
}, },
{ {
"rel": "https://www.w3.org/ns/activitystreams#Application", "rel": "https://www.w3.org/ns/activitystreams#Application",