defmodule Mobilizon.Federation.ActivityPub.Fetcher do @moduledoc """ Module to handle direct URL ActivityPub fetches to remote content If you need to first get cached data, see `Mobilizon.Federation.ActivityPub.fetch_object_from_url/2` """ require Logger alias Mobilizon.Federation.HTTPSignatures.Signature alias Mobilizon.Federation.ActivityPub.{Relay, Transmogrifier} alias Mobilizon.Federation.ActivityStream.Converter.Actor, as: ActorConverter alias Mobilizon.Service.ErrorReporting.Sentry alias Mobilizon.Service.HTTP.ActivityPub, as: ActivityPubClient import Mobilizon.Federation.ActivityPub.Utils, only: [maybe_date_fetch: 2, sign_fetch: 4, origin_check?: 2] import Mobilizon.Service.Guards, only: [is_valid_string: 1] @spec fetch(String.t(), Keyword.t()) :: {:ok, map()} | {:error, :invalid_url | :http_gone | :http_error | :http_not_found | :content_not_json} def fetch(url, options \\ []) do on_behalf_of = Keyword.get(options, :on_behalf_of, Relay.get_actor()) date = Signature.generate_date_header() headers = [{:Accept, "application/activity+json"}] |> maybe_date_fetch(date) |> sign_fetch(on_behalf_of, url, date) client = ActivityPubClient.client(headers: headers) if address_valid?(url) do case ActivityPubClient.get(client, url) do {:ok, %Tesla.Env{body: data, status: code}} when code in 200..299 and is_map(data) -> {:ok, data} {:ok, %Tesla.Env{status: 410}} -> Logger.debug("Resource at #{url} is 410 Gone") {:error, :http_gone} {:ok, %Tesla.Env{status: 404}} -> Logger.debug("Resource at #{url} is 404 Gone") {:error, :http_not_found} {:ok, %Tesla.Env{body: data}} when is_binary(data) -> {:error, :content_not_json} {:ok, %Tesla.Env{} = res} -> Logger.debug("Resource returned bad HTTP code inspect #{res}") {:error, :http_error} end else {:error, :invalid_url} end end @spec fetch_and_create(String.t(), Keyword.t()) :: {:ok, map(), struct()} | {:error, atom()} | :error def fetch_and_create(url, options \\ []) do case fetch(url, options) do {:ok, data} when is_map(data) -> if origin_check?(url, data) do case Transmogrifier.handle_incoming(%{ "type" => "Create", "to" => data["to"], "cc" => data["cc"], "actor" => data["actor"] || data["attributedTo"], "attributedTo" => data["attributedTo"] || data["actor"], "object" => data }) do {:ok, entity, structure} -> {:ok, entity, structure} {:error, error} when is_atom(error) -> {:error, error} :error -> {:error, :transmogrifier_error} end else Logger.warn("Object origin check failed") {:error, :object_origin_check_failed} end {:error, err} -> {:error, err} end end @spec fetch_and_update(String.t(), Keyword.t()) :: {:ok, map(), struct()} | {:error, atom()} def fetch_and_update(url, options \\ []) do case fetch(url, options) do {:ok, data} when is_map(data) -> if origin_check(url, data) do Transmogrifier.handle_incoming(%{ "type" => "Update", "to" => data["to"], "cc" => data["cc"], "actor" => data["actor"] || data["attributedTo"], "attributedTo" => data["attributedTo"] || data["actor"], "object" => data }) else Logger.warn("Object origin check failed") {:error, :object_origin_check_failed} end {:error, err} -> {:error, err} end end @type fetch_actor_errors :: :json_decode_error | :actor_deleted | :http_error | :actor_not_allowed_type @doc """ Fetching a remote actor's information through its AP ID """ @spec fetch_and_prepare_actor_from_url(String.t()) :: {:ok, map()} | {:error, fetch_actor_errors} def fetch_and_prepare_actor_from_url(url) do Logger.debug("Fetching and preparing actor from url") Logger.debug(inspect(url)) case Tesla.get(url, headers: [{"Accept", "application/activity+json"}], follow_redirect: true ) do {:ok, %{status: 200, body: body}} -> Logger.debug("response okay, now decoding json") case Jason.decode(body) do {:ok, data} when is_map(data) -> Logger.debug("Got activity+json response at actor's endpoint, now converting data") case ActorConverter.as_to_model_data(data) do {:error, :actor_not_allowed_type} -> {:error, :actor_not_allowed_type} map when is_map(map) -> {:ok, map} end {:error, %Jason.DecodeError{} = e} -> Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}") {:error, :json_decode_error} end {:ok, %{status: 410}} -> Logger.info("Response HTTP 410") {:error, :actor_deleted} {:ok, %Tesla.Env{}} -> Logger.info("Non 200 HTTP Code") {:error, :http_error} {:error, error} -> Logger.warn("Could not fetch actor at fetch #{url}, #{inspect(error)}") {:error, :http_error} end end @spec origin_check(String.t(), map()) :: boolean() defp origin_check(url, data) do if origin_check?(url, data) do true else Sentry.capture_message("Object origin check failed", extra: %{url: url, data: data}) Logger.debug("Object origin check failed between #{inspect(url)} and #{inspect(data)}") false end end @spec address_valid?(String.t()) :: boolean defp address_valid?(address) do %URI{host: host, scheme: scheme} = URI.parse(address) is_valid_string(host) and is_valid_string(scheme) end end