Scrape instance nodeinfo (#251)
authorfloatingghost <hannah@coffee-and-dreams.uk>
Sun, 6 Nov 2022 22:49:39 +0000 (22:49 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Sun, 6 Nov 2022 22:49:39 +0000 (22:49 +0000)
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/251

14 files changed:
config/config.exs
config/description.exs
config/test.exs
lib/pleroma/application.ex
lib/pleroma/instances/instance.ex
lib/pleroma/web/activity_pub/side_effects.ex
lib/pleroma/web/mastodon_api/views/account_view.ex
lib/pleroma/workers/nodeinfo_fetcher_worker.ex [new file with mode: 0644]
priv/repo/migrations/20221020135943_add_nodeinfo.exs [new file with mode: 0644]
test/pleroma/instances/instance_test.exs
test/pleroma/web/activity_pub/side_effects_test.exs
test/pleroma/web/mastodon_api/views/account_view_test.exs
test/pleroma/web/streamer_test.exs
test/support/factory.ex

index 1af0370519225e7299a1673000b6a781a587f9f8..644155aeb2b17af6c34614f1cd308cef0af38824 100644 (file)
@@ -567,7 +567,8 @@ config :pleroma, Oban,
     attachments_cleanup: 1,
     new_users_digest: 1,
     mute_expire: 5,
-    search_indexing: 10
+    search_indexing: 10,
+    nodeinfo_fetcher: 1
   ],
   plugins: [
     Oban.Plugins.Pruner,
@@ -806,7 +807,8 @@ config :ex_aws, http_client: Pleroma.HTTP.ExAws
 
 config :web_push_encryption, http_client: Pleroma.HTTP.WebPush
 
-config :pleroma, :instances_favicons, enabled: false
+config :pleroma, :instances_favicons, enabled: true
+config :pleroma, :instances_nodeinfo, enabled: true
 
 config :floki, :html_parser, Floki.HTMLParser.FastHtml
 
index 1ff0a582bd6438524d0c773925c7f98f605c76cd..4843c0aaeeb905decdc9d591adb03e92044eecb9 100644 (file)
@@ -3047,6 +3047,19 @@ config :pleroma, :config_description, [
       }
     ]
   },
+  %{
+    group: :pleroma,
+    key: :instances_nodeinfo,
+    type: :group,
+    description: "Control favicons for instances",
+    children: [
+      %{
+        key: :enabled,
+        type: :boolean,
+        description: "Allow/disallow getting instance nodeinfo"
+      }
+    ]
+  },
   %{
     group: :ex_aws,
     key: :s3,
index a5edb11499a7043b6bbec61cd229de78f1904941..3056dbd0319f26f56d16755907cc6b63c714dad5 100644 (file)
@@ -139,6 +139,8 @@ config :pleroma, Pleroma.Search.Meilisearch, url: "http://127.0.0.1:7700/", priv
 # Reduce recompilation time
 # https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects
 config :phoenix, :plug_init_mode, :runtime
+config :pleroma, :instances_favicons, enabled: false
+config :pleroma, :instances_nodeinfo, enabled: false
 
 if File.exists?("./config/test.secret.exs") do
   import_config "test.secret.exs"
index adccd7c5dfbc087fbec54e90b287b031c597c03b..a78924dfabd48838c6bf25186bb39be1bdf7cde3 100644 (file)
@@ -156,7 +156,8 @@ defmodule Pleroma.Application do
       build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
       build_cachex("failed_proxy_url", limit: 2500),
       build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000),
-      build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500)
+      build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500),
+      build_cachex("instances", default_ttl: :timer.hours(24), limit: 2500)
     ]
   end
 
index 533dbbb825789ef72936b0131b821a54a4529a24..fcf3181bf530550cb18120a6b873b0772f6b67db 100644 (file)
@@ -5,6 +5,8 @@
 defmodule Pleroma.Instances.Instance do
   @moduledoc "Instance."
 
+  @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
   alias Pleroma.Instances
   alias Pleroma.Instances.Instance
   alias Pleroma.Repo
@@ -22,7 +24,8 @@ defmodule Pleroma.Instances.Instance do
     field(:host, :string)
     field(:unreachable_since, :naive_datetime_usec)
     field(:favicon, :string)
-    field(:favicon_updated_at, :naive_datetime)
+    field(:metadata_updated_at, :naive_datetime)
+    field(:nodeinfo, :map, default: %{})
 
     timestamps()
   end
@@ -31,7 +34,7 @@ defmodule Pleroma.Instances.Instance do
 
   def changeset(struct, params \\ %{}) do
     struct
-    |> cast(params, [:host, :unreachable_since, :favicon, :favicon_updated_at])
+    |> cast(params, [:host, :unreachable_since, :favicon, :nodeinfo, :metadata_updated_at])
     |> validate_required([:host])
     |> unique_constraint(:host)
   end
@@ -138,63 +141,144 @@ defmodule Pleroma.Instances.Instance do
 
   defp parse_datetime(datetime), do: datetime
 
-  def get_or_update_favicon(%URI{host: host} = instance_uri) do
-    existing_record = Repo.get_by(Instance, %{host: host})
+  def needs_update(nil), do: true
+
+  def needs_update(%Instance{metadata_updated_at: nil}), do: true
+
+  def needs_update(%Instance{metadata_updated_at: metadata_updated_at}) do
     now = NaiveDateTime.utc_now()
+    NaiveDateTime.diff(now, metadata_updated_at) > 86_400
+  end
 
-    if existing_record && existing_record.favicon_updated_at &&
-         NaiveDateTime.diff(now, existing_record.favicon_updated_at) < 86_400 do
-      existing_record.favicon
+  def local do
+    %Instance{
+      host: Pleroma.Web.Endpoint.host(),
+      favicon: Pleroma.Web.Endpoint.url() <> "/favicon.png",
+      nodeinfo: Pleroma.Web.Nodeinfo.NodeinfoController.raw_nodeinfo()
+    }
+  end
+
+  def update_metadata(%URI{host: host} = uri) do
+    Logger.info("Checking metadata for #{host}")
+    existing_record = Repo.get_by(Instance, %{host: host})
+
+    if reachable?(host) do
+      do_update_metadata(uri, existing_record)
     else
-      favicon = scrape_favicon(instance_uri)
+      {:discard, :unreachable}
+    end
+  end
 
-      if existing_record do
-        existing_record
-        |> changeset(%{favicon: favicon, favicon_updated_at: now})
-        |> Repo.update()
+  defp do_update_metadata(%URI{host: host} = uri, existing_record) do
+    if existing_record do
+      if needs_update(existing_record) do
+        Logger.info("Updating metadata for #{host}")
+        favicon = scrape_favicon(uri)
+        nodeinfo = scrape_nodeinfo(uri)
+
+        {:ok, instance} =
+          existing_record
+          |> changeset(%{
+            host: host,
+            favicon: favicon,
+            nodeinfo: nodeinfo,
+            metadata_updated_at: NaiveDateTime.utc_now()
+          })
+          |> Repo.update()
+
+        @cachex.put(:instances_cache, "instances:#{host}", instance)
       else
+        {:discard, "Does not require update"}
+      end
+    else
+      favicon = scrape_favicon(uri)
+      nodeinfo = scrape_nodeinfo(uri)
+
+      Logger.info("Creating metadata for #{host}")
+
+      {:ok, instance} =
         %Instance{}
-        |> changeset(%{host: host, favicon: favicon, favicon_updated_at: now})
+        |> changeset(%{
+          host: host,
+          favicon: favicon,
+          nodeinfo: nodeinfo,
+          metadata_updated_at: NaiveDateTime.utc_now()
+        })
         |> Repo.insert()
-      end
 
-      favicon
+      @cachex.put(:instances_cache, "instances:#{host}", instance)
     end
-  rescue
-    e ->
-      Logger.warn("Instance.get_or_update_favicon(\"#{host}\") error: #{inspect(e)}")
+  end
+
+  def get_favicon(%URI{host: host}) do
+    existing_record = Repo.get_by(Instance, %{host: host})
+
+    if existing_record do
+      existing_record.favicon
+    else
       nil
+    end
   end
 
-  defp scrape_favicon(%URI{} = instance_uri) do
-    try do
-      with {_, true} <- {:reachable, reachable?(instance_uri.host)},
-           {:ok, %Tesla.Env{body: html}} <-
-             Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], []),
-           {_, [favicon_rel | _]} when is_binary(favicon_rel) <-
-             {:parse,
-              html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")},
-           {_, favicon} when is_binary(favicon) <-
-             {:merge, URI.merge(instance_uri, favicon_rel) |> to_string()} do
-        favicon
-      else
-        {:reachable, false} ->
-          Logger.debug(
-            "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") ignored unreachable host"
-          )
+  defp scrape_nodeinfo(%URI{} = instance_uri) do
+    with true <- Pleroma.Config.get([:instances_nodeinfo, :enabled]),
+         {_, true} <- {:reachable, reachable?(instance_uri.host)},
+         {:ok, %Tesla.Env{status: 200, body: body}} <-
+           Tesla.get(
+             "https://#{instance_uri.host}/.well-known/nodeinfo",
+             headers: [{"Accept", "application/json"}]
+           ),
+         {:ok, json} <- Jason.decode(body),
+         {:ok, %{"links" => links}} <- {:ok, json},
+         {:ok, %{"href" => href}} <-
+           {:ok,
+            Enum.find(links, &(&1["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0"))},
+         {:ok, %Tesla.Env{body: data}} <-
+           Pleroma.HTTP.get(href, [{"accept", "application/json"}], []),
+         {:length, true} <- {:length, String.length(data) < 50_000},
+         {:ok, nodeinfo} <- Jason.decode(data) do
+      nodeinfo
+    else
+      {:reachable, false} ->
+        Logger.debug(
+          "Instance.scrape_nodeinfo(\"#{to_string(instance_uri)}\") ignored unreachable host"
+        )
 
-          nil
+        nil
 
-        _ ->
-          nil
-      end
-    rescue
-      e ->
-        Logger.warn(
-          "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") error: #{inspect(e)}"
+      {:length, false} ->
+        Logger.debug(
+          "Instance.scrape_nodeinfo(\"#{to_string(instance_uri)}\") ignored too long body"
         )
 
         nil
+
+      _ ->
+        nil
+    end
+  end
+
+  defp scrape_favicon(%URI{} = instance_uri) do
+    with true <- Pleroma.Config.get([:instances_favicons, :enabled]),
+         {_, true} <- {:reachable, reachable?(instance_uri.host)},
+         {:ok, %Tesla.Env{body: html}} <-
+           Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], []),
+         {_, [favicon_rel | _]} when is_binary(favicon_rel) <-
+           {:parse, html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")},
+         {_, favicon} when is_binary(favicon) <-
+           {:merge, URI.merge(instance_uri, favicon_rel) |> to_string()},
+         {:length, true} <- {:length, String.length(favicon) < 255} do
+      favicon
+    else
+      {:reachable, false} ->
+        Logger.debug(
+          "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") ignored unreachable host"
+        )
+
+        nil
+
+      _ ->
+        nil
     end
   end
 
@@ -217,4 +301,25 @@ defmodule Pleroma.Instances.Instance do
     end)
     |> Stream.run()
   end
+
+  def get_by_url(url_or_host) do
+    url = host(url_or_host)
+    Repo.get_by(Instance, host: url)
+  end
+
+  def get_cached_by_url(url_or_host) do
+    url = host(url_or_host)
+
+    if url == Pleroma.Web.Endpoint.host() do
+      {:ok, local()}
+    else
+      @cachex.fetch!(:instances_cache, "instances:#{url}", fn _ ->
+        with %Instance{} = instance <- get_by_url(url) do
+          {:commit, {:ok, instance}}
+        else
+          _ -> {:ignore, nil}
+        end
+      end)
+    end
+  end
 end
index c3258c75ba7be9db7e9a53ffefdf108342b75857..18643662ebcdd1ea1af63d15cb8dda088d04dd47 100644 (file)
@@ -192,6 +192,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   # - Increase the user note count
   # - Increase the reply count
   # - Increase replies count
+  # - Ask for scraping of nodeinfo
   # - Set up ActivityExpiration
   # - Set up notifications
   # - Index incoming posts for search (if needed)
@@ -209,6 +210,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
 
       reply_depth = (meta[:depth] || 0) + 1
 
+      Pleroma.Workers.NodeInfoFetcherWorker.enqueue("process", %{
+        "source_url" => activity.data["actor"]
+      })
+
       # FIXME: Force inReplyTo to replies
       if Pleroma.Web.Federator.allowed_thread_distance?(reply_depth) and
            object.data["replies"] != nil do
@@ -234,7 +239,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
 
       {:ok, activity, meta}
     else
-      e -> Repo.rollback(e)
+      e ->
+        Logger.error(inspect(e))
+        Repo.rollback(e)
     end
   end
 
index 06acf0a2689992c61a9f90cb33a050a565fc206d..cbb57aee663219e475ccaaeeeac48bd4cc97fedf 100644 (file)
@@ -186,6 +186,16 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do
     render_many(targets, AccountView, "relationship.json", render_opts)
   end
 
+  def render("instance.json", %{instance: %Pleroma.Instances.Instance{} = instance}) do
+    %{
+      name: instance.host,
+      favicon: instance.favicon |> MediaProxy.url(),
+      nodeinfo: instance.nodeinfo
+    }
+  end
+
+  def render("instance.json", _), do: nil
+
   defp do_render("show.json", %{user: user} = opts) do
     user = User.sanitize_html(user, User.html_filter_policy(opts[:for]))
     display_name = user.name || user.nickname
@@ -230,16 +240,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do
         %{}
       end
 
-    favicon =
-      if Pleroma.Config.get([:instances_favicons, :enabled]) do
-        user
-        |> Map.get(:ap_id, "")
-        |> URI.parse()
-        |> URI.merge("/")
-        |> Pleroma.Instances.Instance.get_or_update_favicon()
-        |> MediaProxy.url()
+    instance =
+      with {:ok, instance} <- Pleroma.Instances.Instance.get_cached_by_url(user.ap_id) do
+        instance
       else
+        _ ->
+          nil
+      end
+
+    favicon =
+      if is_nil(instance) do
         nil
+      else
+        instance.favicon
+        |> MediaProxy.url()
       end
 
     %{
@@ -271,7 +285,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do
         }
       },
       last_status_at: user.last_status_at,
-
+      akkoma: %{
+        instance: render("instance.json", %{instance: instance})
+      },
       # Pleroma extensions
       # Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub
       fqn: User.full_nickname(user),
diff --git a/lib/pleroma/workers/nodeinfo_fetcher_worker.ex b/lib/pleroma/workers/nodeinfo_fetcher_worker.ex
new file mode 100644 (file)
index 0000000..27492e1
--- /dev/null
@@ -0,0 +1,18 @@
+defmodule Pleroma.Workers.NodeInfoFetcherWorker do
+  use Pleroma.Workers.WorkerHelper, queue: "nodeinfo_fetcher"
+
+  alias Oban.Job
+  alias Pleroma.Instances.Instance
+
+  @impl Oban.Worker
+  def perform(%Job{
+        args: %{"op" => "process", "source_url" => domain}
+      }) do
+    uri =
+      domain
+      |> URI.parse()
+      |> URI.merge("/")
+
+    Instance.update_metadata(uri)
+  end
+end
diff --git a/priv/repo/migrations/20221020135943_add_nodeinfo.exs b/priv/repo/migrations/20221020135943_add_nodeinfo.exs
new file mode 100644 (file)
index 0000000..17707f3
--- /dev/null
@@ -0,0 +1,17 @@
+defmodule Pleroma.Repo.Migrations.AddNodeinfo do
+  use Ecto.Migration
+
+  def up do
+    alter table(:instances) do
+      add_if_not_exists(:nodeinfo, :map, default: %{})
+      add_if_not_exists(:metadata_updated_at, :naive_datetime)
+    end
+  end
+
+  def down do
+    alter table(:instances) do
+      remove_if_exists(:nodeinfo, :map)
+      remove_if_exists(:metadata_updated_at, :naive_datetime)
+    end
+  end
+end
index e49922724e12fc9890c2221e9dfc2f7f7ac043d2..adc847da5bcc791513755d753da087a7d1623f24 100644 (file)
@@ -9,12 +9,16 @@ defmodule Pleroma.Instances.InstanceTest do
   alias Pleroma.Tests.ObanHelpers
   alias Pleroma.Web.CommonAPI
 
-  use Pleroma.DataCase
+  use Pleroma.DataCase, async: true
 
   import ExUnit.CaptureLog
   import Pleroma.Factory
 
-  setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
+  setup_all do
+    clear_config([:instance, :federation_reachability_timeout_days], 1)
+    clear_config([:instances_nodeinfo, :enabled], true)
+    clear_config([:instances_favicons, :enabled], true)
+  end
 
   describe "set_reachable/1" do
     test "clears `unreachable_since` of existing matching Instance record having non-nil `unreachable_since`" do
@@ -102,62 +106,220 @@ defmodule Pleroma.Instances.InstanceTest do
     end
   end
 
-  describe "get_or_update_favicon/1" do
-    test "Scrapes favicon URLs" do
-      Tesla.Mock.mock(fn %{url: "https://favicon.example.org/"} ->
-        %Tesla.Env{
-          status: 200,
-          body: ~s[<html><head><link rel="icon" href="/favicon.png"></head></html>]
-        }
+  describe "update_metadata/1" do
+    test "Scrapes favicon URLs and nodeinfo" do
+      Tesla.Mock.mock(fn
+        %{url: "https://favicon.example.org/"} ->
+          %Tesla.Env{
+            status: 200,
+            body: ~s[<html><head><link rel="icon" href="/favicon.png"></head></html>]
+          }
+
+        %{url: "https://favicon.example.org/.well-known/nodeinfo"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                links: [
+                  %{
+                    rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
+                    href: "https://favicon.example.org/nodeinfo/2.0"
+                  }
+                ]
+              })
+          }
+
+        %{url: "https://favicon.example.org/nodeinfo/2.0"} ->
+          %Tesla.Env{
+            status: 200,
+            body: Jason.encode!(%{version: "2.0", software: %{name: "Akkoma"}})
+          }
       end)
 
-      assert "https://favicon.example.org/favicon.png" ==
-               Instance.get_or_update_favicon(URI.parse("https://favicon.example.org/"))
+      assert {:ok, true} ==
+               Instance.update_metadata(URI.parse("https://favicon.example.org/"))
+
+      {:ok, instance} = Instance.get_cached_by_url("https://favicon.example.org/")
+      assert instance.favicon == "https://favicon.example.org/favicon.png"
+      assert instance.nodeinfo == %{"version" => "2.0", "software" => %{"name" => "Akkoma"}}
     end
 
-    test "Returns nil on too long favicon URLs" do
+    test "Does not retain favicons that are too long" do
       long_favicon_url =
         "https://Lorem.ipsum.dolor.sit.amet/consecteturadipiscingelit/Praesentpharetrapurusutaliquamtempus/Mauriseulaoreetarcu/atfacilisisorci/Nullamporttitor/nequesedfeugiatmollis/dolormagnaefficiturlorem/nonpretiumsapienorcieurisus/Nullamveleratsem/Maecenassedaccumsanexnam/favicon.png"
 
-      Tesla.Mock.mock(fn %{url: "https://long-favicon.example.org/"} ->
-        %Tesla.Env{
-          status: 200,
-          body:
-            ~s[<html><head><link rel="icon" href="] <> long_favicon_url <> ~s["></head></html>]
-        }
+      Tesla.Mock.mock(fn
+        %{url: "https://long-favicon.example.org/"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              ~s[<html><head><link rel="icon" href="] <> long_favicon_url <> ~s["></head></html>]
+          }
+
+        %{url: "https://long-favicon.example.org/.well-known/nodeinfo"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                links: [
+                  %{
+                    rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
+                    href: "https://long-favicon.example.org/nodeinfo/2.0"
+                  }
+                ]
+              })
+          }
+
+        %{url: "https://long-favicon.example.org/nodeinfo/2.0"} ->
+          %Tesla.Env{
+            status: 200,
+            body: Jason.encode!(%{version: "2.0", software: %{name: "Akkoma"}})
+          }
       end)
 
-      assert capture_log(fn ->
-               assert nil ==
-                        Instance.get_or_update_favicon(
-                          URI.parse("https://long-favicon.example.org/")
-                        )
-             end) =~
-               "Instance.get_or_update_favicon(\"long-favicon.example.org\") error: %Postgrex.Error{"
+      assert {:ok, true} ==
+               Instance.update_metadata(URI.parse("https://long-favicon.example.org/"))
+
+      {:ok, instance} = Instance.get_cached_by_url("https://long-favicon.example.org/")
+      assert instance.favicon == nil
     end
 
     test "Handles not getting a favicon URL properly" do
-      Tesla.Mock.mock(fn %{url: "https://no-favicon.example.org/"} ->
-        %Tesla.Env{
-          status: 200,
-          body: ~s[<html><head><h1>I wil look down and whisper "GNO.."</h1></head></html>]
-        }
+      Tesla.Mock.mock(fn
+        %{url: "https://no-favicon.example.org/"} ->
+          %Tesla.Env{
+            status: 200,
+            body: ~s[<html><head><h1>I wil look down and whisper "GNO.."</h1></head></html>]
+          }
+
+        %{url: "https://no-favicon.example.org/.well-known/nodeinfo"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                links: [
+                  %{
+                    rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
+                    href: "https://no-favicon.example.org/nodeinfo/2.0"
+                  }
+                ]
+              })
+          }
+
+        %{url: "https://no-favicon.example.org/nodeinfo/2.0"} ->
+          %Tesla.Env{
+            status: 200,
+            body: Jason.encode!(%{version: "2.0", software: %{name: "Akkoma"}})
+          }
       end)
 
       refute capture_log(fn ->
-               assert nil ==
-                        Instance.get_or_update_favicon(
-                          URI.parse("https://no-favicon.example.org/")
-                        )
-             end) =~ "Instance.scrape_favicon(\"https://no-favicon.example.org/\") error: "
+               assert {:ok, true} =
+                        Instance.update_metadata(URI.parse("https://no-favicon.example.org/"))
+             end) =~ "Instance.update_metadata(\"https://no-favicon.example.org/\") error: "
     end
 
-    test "Doesn't scrapes unreachable instances" do
+    test "Doesn't scrape unreachable instances" do
       instance = insert(:instance, unreachable_since: Instances.reachability_datetime_threshold())
       url = "https://" <> instance.host
 
-      assert capture_log(fn -> assert nil == Instance.get_or_update_favicon(URI.parse(url)) end) =~
-               "Instance.scrape_favicon(\"#{url}\") ignored unreachable host"
+      assert {:discard, :unreachable} == Instance.update_metadata(URI.parse(url))
+    end
+
+    test "doesn't continue scraping nodeinfo if we can't find a link" do
+      Tesla.Mock.mock(fn
+        %{url: "https://bad-nodeinfo.example.org/"} ->
+          %Tesla.Env{
+            status: 200,
+            body: ~s[<html><head><h1>I wil look down and whisper "GNO.."</h1></head></html>]
+          }
+
+        %{url: "https://bad-nodeinfo.example.org/.well-known/nodeinfo"} ->
+          %Tesla.Env{
+            status: 200,
+            body: "oepsie woepsie de nodeinfo is kapotie uwu"
+          }
+      end)
+
+      assert {:ok, true} ==
+               Instance.update_metadata(URI.parse("https://bad-nodeinfo.example.org/"))
+
+      {:ok, instance} = Instance.get_cached_by_url("https://bad-nodeinfo.example.org/")
+      assert instance.nodeinfo == nil
+    end
+
+    test "doesn't store bad json in the nodeinfo" do
+      Tesla.Mock.mock(fn
+        %{url: "https://bad-nodeinfo.example.org/"} ->
+          %Tesla.Env{
+            status: 200,
+            body: ~s[<html><head><h1>I wil look down and whisper "GNO.."</h1></head></html>]
+          }
+
+        %{url: "https://bad-nodeinfo.example.org/.well-known/nodeinfo"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                links: [
+                  %{
+                    rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
+                    href: "https://bad-nodeinfo.example.org/nodeinfo/2.0"
+                  }
+                ]
+              })
+          }
+
+        %{url: "https://bad-nodeinfo.example.org/nodeinfo/2.0"} ->
+          %Tesla.Env{
+            status: 200,
+            body: "oepsie woepsie de json might be bad uwu"
+          }
+      end)
+
+      assert {:ok, true} ==
+               Instance.update_metadata(URI.parse("https://bad-nodeinfo.example.org/"))
+
+      {:ok, instance} = Instance.get_cached_by_url("https://bad-nodeinfo.example.org/")
+      assert instance.nodeinfo == nil
+    end
+
+    test "doesn't store incredibly long json nodeinfo" do
+      too_long = String.duplicate("a", 50_000)
+
+      Tesla.Mock.mock(fn
+        %{url: "https://bad-nodeinfo.example.org/"} ->
+          %Tesla.Env{
+            status: 200,
+            body: ~s[<html><head><h1>I wil look down and whisper "GNO.."</h1></head></html>]
+          }
+
+        %{url: "https://bad-nodeinfo.example.org/.well-known/nodeinfo"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                links: [
+                  %{
+                    rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
+                    href: "https://bad-nodeinfo.example.org/nodeinfo/2.0"
+                  }
+                ]
+              })
+          }
+
+        %{url: "https://bad-nodeinfo.example.org/nodeinfo/2.0"} ->
+          %Tesla.Env{
+            status: 200,
+            body: Jason.encode!(%{version: "2.0", software: %{name: too_long}})
+          }
+      end)
+
+      assert {:ok, true} ==
+               Instance.update_metadata(URI.parse("https://bad-nodeinfo.example.org/"))
+
+      {:ok, instance} = Instance.get_cached_by_url("https://bad-nodeinfo.example.org/")
+      assert instance.nodeinfo == nil
     end
   end
 
index fa8171eabc4f5495a865ce72bd15993571138d3b..ee664bb8f42b4db90d57a36905dc0583a3da462f 100644 (file)
@@ -21,6 +21,35 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
   import Mock
   import Pleroma.Factory
 
+  describe "handle" do
+    test "it queues a fetch of instance information" do
+      author = insert(:user, local: false, ap_id: "https://wowee.example.com/users/1")
+      recipient = insert(:user, local: true)
+
+      {:ok, note_data, _meta} =
+        Builder.note(%Pleroma.Web.CommonAPI.ActivityDraft{
+          user: author,
+          to: [recipient.ap_id],
+          mentions: [recipient],
+          content_html: "hey",
+          extra: %{"id" => "https://wowee.example.com/notes/1"}
+        })
+
+      {:ok, create_activity_data, _meta} =
+        Builder.create(author, note_data["id"], [recipient.ap_id])
+
+      {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false)
+
+      {:ok, _create_activity, _meta} =
+        SideEffects.handle(create_activity, local: false, object_data: note_data)
+
+      assert_enqueued(
+        worker: Pleroma.Workers.NodeInfoFetcherWorker,
+        args: %{"op" => "process", "source_url" => "https://wowee.example.com/users/1"}
+      )
+    end
+  end
+
   describe "handle_after_transaction" do
     test "it streams out notifications and streams" do
       author = insert(:user, local: true)
index 8db88713765dd5c8892fe30f73e41ce8a8704ea7..d1903af801676402676091185f0aa9dafc18aba5 100644 (file)
@@ -3,7 +3,7 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
-  use Pleroma.DataCase
+  use Pleroma.DataCase, async: false
 
   alias Pleroma.User
   alias Pleroma.UserRelationship
@@ -12,6 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
 
   import Pleroma.Factory
   import Tesla.Mock
+  import Mock
 
   setup do
     mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
@@ -25,6 +26,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
 
     user =
       insert(:user, %{
+        ap_id: "https://example.com/users/chikichikibanban",
         follower_count: 3,
         note_count: 5,
         background: background_image,
@@ -38,6 +40,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         also_known_as: ["https://shitposter.zone/users/shp"]
       })
 
+    insert(:instance, %{host: "example.com", nodeinfo: %{version: "2.1"}})
+
     expected = %{
       id: to_string(user.id),
       username: "shp",
@@ -50,6 +54,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
       statuses_count: 5,
       note: "<span>valid html</span>. a<br/>b<br/>c<br/>d<br/>f &#39;&amp;&lt;&gt;&quot;",
       url: user.ap_id,
+      akkoma: %{
+        instance: %{
+          name: "example.com",
+          nodeinfo: %{
+            "version" => "2.1"
+          },
+          favicon: nil
+        }
+      },
       avatar: "http://localhost:4001/images/avi.png",
       avatar_static: "http://localhost:4001/images/avi.png",
       header: "http://localhost:4001/images/banner.png",
@@ -98,9 +111,57 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
     assert expected == AccountView.render("show.json", %{user: user, skip_visibility_check: true})
   end
 
+  describe "nodeinfo" do
+    setup do
+      [
+        user: insert(:user, ap_id: "https://somewhere.example.com/users/chikichikibanban"),
+        instance:
+          insert(:instance, %{
+            host: "somewhere.example.com",
+            favicon: "https://example.com/favicon.ico"
+          })
+      ]
+    end
+
+    test "is embedded in the account view", %{user: user} do
+      assert %{
+               akkoma: %{
+                 instance: %{
+                   name: "somewhere.example.com",
+                   nodeinfo: %{
+                     "version" => "2.0"
+                   },
+                   favicon: "https://example.com/favicon.ico"
+                 }
+               }
+             } = AccountView.render("show.json", %{user: user, skip_visibility_check: true})
+    end
+
+    test "uses local nodeinfo for local users" do
+      user = insert(:user)
+
+      assert %{
+               akkoma: %{
+                 instance: %{
+                   name: "localhost",
+                   nodeinfo: %{
+                     software: %{
+                       name: "akkoma"
+                     }
+                   }
+                 }
+               }
+             } = AccountView.render("show.json", %{user: user, skip_visibility_check: true})
+    end
+  end
+
   describe "favicon" do
     setup do
-      [user: insert(:user)]
+      [
+        user: insert(:user, ap_id: "https://example.com/users/chikichikibanban"),
+        instance:
+          insert(:instance, %{host: "example.com", favicon: "https://example.com/favicon.ico"})
+      ]
     end
 
     test "is parsed when :instance_favicons is enabled", %{user: user} do
@@ -108,13 +169,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
 
       assert %{
                pleroma: %{
-                 favicon:
-                   "https://shitposter.club/plugins/Qvitter/img/gnusocial-favicons/favicon-16x16.png"
+                 favicon: "https://example.com/favicon.ico"
                }
              } = AccountView.render("show.json", %{user: user, skip_visibility_check: true})
     end
 
-    test "is nil when :instances_favicons is disabled", %{user: user} do
+    test "is nil when we have no instance", %{user: user} do
+      user = %{user | ap_id: "https://wowee.example.com/users/2"}
+
       assert %{pleroma: %{favicon: nil}} =
                AccountView.render("show.json", %{user: user, skip_visibility_check: true})
     end
@@ -176,11 +238,18 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
       },
       fqn: "shp@shitposter.club",
       last_status_at: nil,
+      akkoma: %{
+        instance: %{
+          name: "localhost",
+          favicon: "http://localhost:4001/favicon.png",
+          nodeinfo: %{version: "2.0"}
+        }
+      },
       pleroma: %{
         ap_id: user.ap_id,
         also_known_as: [],
         background_image: nil,
-        favicon: nil,
+        favicon: "http://localhost:4001/favicon.png",
         is_confirmed: true,
         tags: [],
         is_admin: false,
@@ -196,7 +265,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
       }
     }
 
-    assert expected == AccountView.render("show.json", %{user: user, skip_visibility_check: true})
+    with_mock(
+      Pleroma.Web.Nodeinfo.NodeinfoController,
+      raw_nodeinfo: fn -> %{version: "2.0"} end
+    ) do
+      assert expected ==
+               AccountView.render("show.json", %{user: user, skip_visibility_check: true})
+    end
   end
 
   test "Represent a Funkwhale channel" do
@@ -578,6 +653,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         emoji: %{"joker_smile" => "https://evil.website/society.png"}
       )
 
+    insert(:instance, %{host: "localhost", favicon: "https://evil.website/favicon.png"})
+
     with media_preview_enabled <- [false, true] do
       clear_config([:media_preview_proxy, :enabled], media_preview_enabled)
 
@@ -586,6 +663,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
         {key, url} when key in [:avatar, :avatar_static, :header, :header_static] ->
           String.starts_with?(url, Pleroma.Web.Endpoint.url())
 
+        {:akkoma, %{instance: %{favicon: favicon_url}}} ->
+          String.starts_with?(favicon_url, Pleroma.Web.Endpoint.url())
+
         {:emojis, emojis} ->
           Enum.all?(emojis, fn %{url: url, static_url: static_url} ->
             String.starts_with?(url, Pleroma.Web.Endpoint.url()) &&
@@ -598,4 +678,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do
       |> assert()
     end
   end
+
+  test "returns nil in the instance field when no instance is held locally" do
+    user = insert(:user, ap_id: "https://example.com/users/1")
+    view = AccountView.render("show.json", %{user: user, skip_visibility_check: true})
+    assert view[:akkoma][:instance] == nil
+  end
 end
index 8e2ab5016cf4ebb19e8b708f46b7619d1de5771a..a9db5a0158162e6f196480147dfc2c9dc55c69de 100644 (file)
@@ -3,7 +3,7 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.StreamerTest do
-  use Pleroma.DataCase
+  use Pleroma.DataCase, async: false
 
   import Pleroma.Factory
 
index 54d385bc44f46278ce34d04232c9c2ee4f9cea88..bd9d7fe4251209124d29ecbdf64cc8e845cd7aab 100644 (file)
@@ -36,6 +36,15 @@ defmodule Pleroma.Factory do
     }
   end
 
+  def instance_factory(attrs \\ %{}) do
+    %Pleroma.Instances.Instance{
+      host: attrs[:domain] || "example.com",
+      nodeinfo: %{version: "2.0", openRegistrations: true},
+      unreachable_since: nil
+    }
+    |> Map.merge(attrs)
+  end
+
   def user_factory(attrs \\ %{}) do
     pem = Enum.random(@rsa_keys)
 
@@ -522,13 +531,6 @@ defmodule Pleroma.Factory do
     }
   end
 
-  def instance_factory do
-    %Pleroma.Instances.Instance{
-      host: "domain.com",
-      unreachable_since: nil
-    }
-  end
-
   def oauth_token_factory(attrs \\ %{}) do
     scopes = Map.get(attrs, :scopes, ["read"])
     oauth_app = Map.get_lazy(attrs, :app, fn -> insert(:oauth_app, scopes: scopes) end)