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 000000000..904c556d0 Binary files /dev/null and b/public/img/gancio.png differ 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 @@
- {{ - instance.personCount - }} - {{ $t("Profiles") }} -
- {{ $t("This instance doesn't follow yours.") }} + {{ instance.instanceDescription }}
++ {{ t("This instance doesn't follow yours.") }} +
+