diff --git a/lib/federation/activity_pub/activity.ex b/lib/federation/activity_pub/activity.ex index 59a20ec82..258b72dd1 100644 --- a/lib/federation/activity_pub/activity.ex +++ b/lib/federation/activity_pub/activity.ex @@ -4,7 +4,7 @@ defmodule Mobilizon.Federation.ActivityPub.Activity do """ @type t :: %__MODULE__{ - data: String.t(), + data: map(), local: boolean, actor: Actor.t(), recipients: [String.t()] diff --git a/lib/federation/activity_pub/activity_pub.ex b/lib/federation/activity_pub/activity_pub.ex index caf110f81..a8b4203bc 100644 --- a/lib/federation/activity_pub/activity_pub.ex +++ b/lib/federation/activity_pub/activity_pub.ex @@ -143,11 +143,11 @@ defmodule Mobilizon.Federation.ActivityPub do {:ok, _activity, entity} -> {:ok, entity} - {:error, "Gone"} -> - {:error, "Gone", entity} + {:error, :http_gone} -> + {:error, :http_gone, entity} - {:error, "Not found"} -> - {:error, "Not found", entity} + {:error, :http_not_found} -> + {:error, :http_not_found, entity} {:error, "Object origin check failed"} -> {:error, "Object origin check failed"} diff --git a/lib/federation/activity_pub/actor.ex b/lib/federation/activity_pub/actor.ex index cd02fa4d2..9cdf851ef 100644 --- a/lib/federation/activity_pub/actor.ex +++ b/lib/federation/activity_pub/actor.ex @@ -14,65 +14,62 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do @doc """ Getting an actor from url, eventually creating it if we don't have it locally or if it needs an update """ - @spec get_or_fetch_actor_by_url(String.t(), boolean) :: {:ok, Actor.t()} | {:error, String.t()} + @spec get_or_fetch_actor_by_url(url :: String.t(), preload :: boolean()) :: + {:ok, Actor.t()} + | {:error, make_actor_errors} + | {:error, :no_internal_relay_actor} + | {:error, :url_nil} def get_or_fetch_actor_by_url(url, preload \\ false) - def get_or_fetch_actor_by_url(nil, _preload), do: {:error, "Can't fetch a nil url"} + def get_or_fetch_actor_by_url(nil, _preload), do: {:error, :url_nil} def get_or_fetch_actor_by_url("https://www.w3.org/ns/activitystreams#Public", _preload) do - with %Actor{url: url} <- Relay.get_actor() do - get_or_fetch_actor_by_url(url) + case Relay.get_actor() do + %Actor{url: url} -> + get_or_fetch_actor_by_url(url) + + {:error, %Ecto.Changeset{}} -> + {:error, :no_internal_relay_actor} end end - @spec get_or_fetch_actor_by_url(String.t(), boolean()) :: {:ok, Actor.t()} | {:error, any()} def get_or_fetch_actor_by_url(url, preload) do - with {:ok, %Actor{} = cached_actor} <- Actors.get_actor_by_url(url, preload), - false <- Actors.needs_update?(cached_actor) do - {:ok, cached_actor} - else - _ -> - # For tests, see https://github.com/jjh42/mock#not-supported---mocking-internal-function-calls and Mobilizon.Federation.ActivityPubTest - case __MODULE__.make_actor_from_url(url, preload) do - {:ok, %Actor{} = actor} -> - {:ok, actor} - - {:error, err} -> - Logger.debug("Could not fetch by AP id") - Logger.debug(inspect(err)) - {:error, "Could not fetch by AP id"} + case Actors.get_actor_by_url(url, preload) do + {:ok, %Actor{} = cached_actor} -> + unless Actors.needs_update?(cached_actor) do + {:ok, cached_actor} + else + __MODULE__.make_actor_from_url(url, preload) end + + {:error, :actor_not_found} -> + # For tests, see https://github.com/jjh42/mock#not-supported---mocking-internal-function-calls and Mobilizon.Federation.ActivityPubTest + __MODULE__.make_actor_from_url(url, preload) end end + @type make_actor_errors :: Fetcher.fetch_actor_errors() | :actor_is_local + @doc """ Create an actor locally by its URL (AP ID) """ - @spec make_actor_from_url(String.t(), boolean()) :: - {:ok, %Actor{}} | {:error, :actor_deleted} | {:error, :http_error} | {:error, any()} + @spec make_actor_from_url(url :: String.t(), preload :: boolean()) :: + {:ok, Actor.t()} | {:error, make_actor_errors} def make_actor_from_url(url, preload \\ false) do if are_same_origin?(url, Endpoint.url()) do - {:error, "Can't make a local actor from URL"} + {:error, :actor_is_local} else case Fetcher.fetch_and_prepare_actor_from_url(url) do - # Just in case - {:ok, {:error, _e}} -> - raise ArgumentError, message: "Failed to make actor from url #{url}" - - {:ok, data} -> + {:ok, data} when is_map(data) -> Actors.upsert_actor(data, preload) # Request returned 410 {:error, :actor_deleted} -> - Logger.info("Actor was deleted") + Logger.info("Actor #{url} was deleted") {:error, :actor_deleted} - {:error, :http_error} -> - {:error, :http_error} - - {:error, e} -> - Logger.warn("Failed to make actor from url #{url}") - {:error, e} + {:error, err} when err in [:http_error, :json_decode_error] -> + {:error, err} end end end @@ -80,8 +77,8 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do @doc """ Find an actor in our local database or call WebFinger to find what's its AP ID is and then fetch it """ - @spec find_or_make_actor_from_nickname(String.t(), atom() | nil) :: - {:ok, Actor.t()} | {:error, any()} + @spec find_or_make_actor_from_nickname(nickname :: String.t(), type :: atom() | nil) :: + {:ok, Actor.t()} | {:error, make_actor_errors | WebFinger.finger_errors()} def find_or_make_actor_from_nickname(nickname, type \\ nil) do case Actors.get_actor_by_name_with_preload(nickname, type) do %Actor{url: actor_url} = actor -> @@ -96,20 +93,22 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do end end - @spec find_or_make_group_from_nickname(String.t()) :: tuple() + @spec find_or_make_group_from_nickname(nick :: String.t()) :: + {:error, make_actor_errors | WebFinger.finger_errors()} def find_or_make_group_from_nickname(nick), do: find_or_make_actor_from_nickname(nick, :Group) @doc """ Create an actor inside our database from username, using WebFinger to find out its AP ID and then fetch it """ - @spec make_actor_from_nickname(String.t()) :: {:ok, %Actor{}} | {:error, any()} + @spec make_actor_from_nickname(nickname :: String.t(), preload :: boolean) :: + {:ok, Actor.t()} | {:error, make_actor_errors | WebFinger.finger_errors()} def make_actor_from_nickname(nickname, preload \\ false) do case WebFinger.finger(nickname) do {:ok, url} when is_binary(url) -> make_actor_from_url(url, preload) - {:error, _e} -> - {:error, "No ActivityPub URL found in WebFinger"} + {:error, e} -> + {:error, e} end end end diff --git a/lib/federation/activity_pub/fetcher.ex b/lib/federation/activity_pub/fetcher.ex index 71a7cfc1c..7a9f4a610 100644 --- a/lib/federation/activity_pub/fetcher.ex +++ b/lib/federation/activity_pub/fetcher.ex @@ -15,39 +15,48 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do import Mobilizon.Federation.ActivityPub.Utils, only: [maybe_date_fetch: 2, sign_fetch: 4, origin_check?: 2] - @spec fetch(String.t(), Keyword.t()) :: {:ok, map()} + import Mobilizon.Service.Guards, only: [is_valid_string: 1] + + @spec fetch(String.t(), Keyword.t()) :: + {:ok, map()} + | {:ok, Tesla.Env.t()} + | {:error, String.t()} + | {:error, any()} + | {:error, :invalid_url} def fetch(url, options \\ []) do on_behalf_of = Keyword.get(options, :on_behalf_of, Relay.get_actor()) + date = Signature.generate_date_header() - with false <- address_invalid(url), - 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), - {:ok, %Tesla.Env{body: data, status: code}} when code in 200..299 <- - ActivityPubClient.get(client, url) do - {:ok, data} + 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 -> + {: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{} = res} -> + {:error, res} + end else - {:ok, %Tesla.Env{status: 410}} -> - Logger.debug("Resource at #{url} is 410 Gone") - {:error, "Gone"} - - {:ok, %Tesla.Env{status: 404}} -> - Logger.debug("Resource at #{url} is 404 Gone") - {:error, "Not found"} - - {:ok, %Tesla.Env{} = res} -> - {:error, res} - - {:error, err} -> - {:error, err} + {:error, :invalid_url} end end - @spec fetch_and_create(String.t(), Keyword.t()) :: {:ok, map(), struct()} + @spec fetch_and_create(String.t(), Keyword.t()) :: + {:ok, map(), struct()} | {:error, :invalid_url} | {:error, String.t()} | {:error, any} def fetch_and_create(url, options \\ []) do with {:ok, data} when is_map(data) <- fetch(url, options), {:origin_check, true} <- {:origin_check, origin_check?(url, data)}, @@ -69,12 +78,16 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do {:ok, data} when is_binary(data) -> {:error, "Failed to parse content as JSON"} + {:error, :invalid_url} -> + {:error, :invalid_url} + {:error, err} -> {:error, err} end end - @spec fetch_and_update(String.t(), Keyword.t()) :: {:ok, map(), struct()} + @spec fetch_and_update(String.t(), Keyword.t()) :: + {:ok, map(), struct()} | {:error, String.t()} | :error | {:error, any} def fetch_and_update(url, options \\ []) do with {:ok, data} when is_map(data) <- fetch(url, options), {:origin_check, true} <- {:origin_check, origin_check(url, data)}, @@ -96,44 +109,46 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do end end + @type fetch_actor_errors :: :json_decode_error | :actor_deleted | :http_error + @doc """ Fetching a remote actor's information through its AP ID """ - @spec fetch_and_prepare_actor_from_url(String.t()) :: {:ok, map()} | {:error, atom()} | any() + @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)) - res = - with {:ok, %{status: 200, body: body}} <- - Tesla.get(url, - headers: [{"Accept", "application/activity+json"}], - follow_redirect: true - ), - :ok <- Logger.debug("response okay, now decoding json"), - {:ok, data} <- Jason.decode(body) do - Logger.debug("Got activity+json response at actor's endpoint, now converting data") - {:ok, ActorConverter.as_to_model_data(data)} - else - # Actor is gone, probably deleted - {:ok, %{status: 410}} -> - Logger.info("Response HTTP 410") - {:error, :actor_deleted} + 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") - {:ok, %Tesla.Env{}} -> - Logger.info("Non 200 HTTP Code") - {:error, :http_error} + case Jason.decode(body) do + {:ok, data} when is_map(data) -> + Logger.debug("Got activity+json response at actor's endpoint, now converting data") + {:ok, ActorConverter.as_to_model_data(data)} - {:error, e} -> - Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}") - {:error, e} + {:error, %Jason.DecodeError{} = e} -> + Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}") + {:error, :json_decode_error} + end - e -> - Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}") - {:error, e} - end + {:ok, %{status: 410}} -> + Logger.info("Response HTTP 410") + {:error, :actor_deleted} - res + {: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() @@ -147,11 +162,14 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do end end - @spec address_invalid(String.t()) :: false | {:error, :invalid_url} - defp address_invalid(address) do - with %URI{host: host, scheme: scheme} <- URI.parse(address), - true <- is_nil(host) or is_nil(scheme) do - {:error, :invalid_url} + @spec address_valid?(String.t()) :: boolean + defp address_valid?(address) do + case URI.parse(address) do + %URI{host: host, scheme: scheme} -> + is_valid_string(host) and is_valid_string(scheme) + + _ -> + false end end end diff --git a/lib/federation/activity_pub/permission.ex b/lib/federation/activity_pub/permission.ex index 2d43c6ebb..9a14c1671 100644 --- a/lib/federation/activity_pub/permission.ex +++ b/lib/federation/activity_pub/permission.ex @@ -15,6 +15,15 @@ defmodule Mobilizon.Federation.ActivityPub.Permission do @type object :: %{id: String.t(), url: String.t()} + @type permissions_member_role :: nil | :member | :moderator | :administrator + + @type t :: %__MODULE__{ + access: permissions_member_role, + create: permissions_member_role, + update: permissions_member_role, + delete: permissions_member_role + } + @doc """ Check that actor can access the object """ diff --git a/lib/federation/activity_pub/refresher.ex b/lib/federation/activity_pub/refresher.ex index 7385da96e..9f3f0a3b8 100644 --- a/lib/federation/activity_pub/refresher.ex +++ b/lib/federation/activity_pub/refresher.ex @@ -8,13 +8,12 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor alias Mobilizon.Federation.ActivityPub.{Fetcher, Relay, Transmogrifier, Utils} - alias Mobilizon.Service.ErrorReporting.Sentry require Logger @doc """ Refresh a remote profile """ - @spec refresh_profile(Actor.t()) :: {:ok, Actor.t()} + @spec refresh_profile(Actor.t()) :: {:ok, Actor.t()} | {:error, fetch_actor_errors()} | {:error} def refresh_profile(%Actor{domain: nil}), do: {:error, "Can only refresh remote actors"} def refresh_profile(%Actor{type: :Group, url: url, id: group_id} = group) do @@ -33,74 +32,84 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do end def refresh_profile(%Actor{type: type, url: url}) when type in [:Person, :Application] do - with {:ok, %Actor{outbox_url: outbox_url} = actor} <- - ActivityPubActor.make_actor_from_url(url), - :ok <- fetch_collection(outbox_url, Relay.get_actor()) do - {:ok, actor} + case ActivityPubActor.make_actor_from_url(url) do + {:ok, %Actor{outbox_url: outbox_url} = actor} -> + case fetch_collection(outbox_url, Relay.get_actor()) do + :ok -> {:ok, actor} + {:error, error} -> {:error, error} + end + + {:error, error} -> + {:error, error} end end - @spec fetch_group(String.t(), Actor.t()) :: :ok + @type fetch_actor_errors :: ActivityPubActor.make_actor_errors() | fetch_collection_errors() + + @spec fetch_group(String.t(), Actor.t()) :: :ok | {:error, fetch_actor_errors} def fetch_group(group_url, %Actor{} = on_behalf_of) do - with {:ok, - %Actor{ - outbox_url: outbox_url, - resources_url: resources_url, - members_url: members_url, - posts_url: posts_url, - todos_url: todos_url, - discussions_url: discussions_url, - events_url: events_url - }} <- - ActivityPubActor.make_actor_from_url(group_url), - :ok <- fetch_collection(outbox_url, on_behalf_of), - :ok <- fetch_collection(members_url, on_behalf_of), - :ok <- fetch_collection(resources_url, on_behalf_of), - :ok <- fetch_collection(posts_url, on_behalf_of), - :ok <- fetch_collection(todos_url, on_behalf_of), - :ok <- fetch_collection(discussions_url, on_behalf_of), - :ok <- fetch_collection(events_url, on_behalf_of) do - :ok - else - {:error, :actor_deleted} -> - {:error, :actor_deleted} + case ActivityPubActor.make_actor_from_url(group_url) do + {:ok, + %Actor{ + outbox_url: outbox_url, + resources_url: resources_url, + members_url: members_url, + posts_url: posts_url, + todos_url: todos_url, + discussions_url: discussions_url, + events_url: events_url + }} -> + Logger.debug("Fetched group OK, now doing collections") - {:error, :http_error} -> - {:error, :http_error} + with :ok <- fetch_collection(outbox_url, on_behalf_of), + :ok <- fetch_collection(members_url, on_behalf_of), + :ok <- fetch_collection(resources_url, on_behalf_of), + :ok <- fetch_collection(posts_url, on_behalf_of), + :ok <- fetch_collection(todos_url, on_behalf_of), + :ok <- fetch_collection(discussions_url, on_behalf_of), + :ok <- fetch_collection(events_url, on_behalf_of) do + :ok + else + {:error, err} + when err in [:error, :process_error, :fetch_error, :collection_url_nil] -> + Logger.debug("Error while fetching actor collection") + {:error, err} + end - {:error, err} -> - Logger.error("Error while refreshing a group") - - Sentry.capture_message("Error while refreshing a group", - extra: %{group_url: group_url} - ) - - Logger.debug(inspect(err)) + {:error, err} + when err in [:actor_deleted, :http_error, :json_decode_error, :actor_is_local] -> + Logger.debug("Error while making actor") {:error, err} - - err -> - Logger.error("Error while refreshing a group") - - Sentry.capture_message("Error while refreshing a group", - extra: %{group_url: group_url} - ) - - Logger.debug(inspect(err)) - err end end - def fetch_collection(nil, _on_behalf_of), do: :error + @typep fetch_collection_errors :: :process_error | :fetch_error | :collection_url_nil + + @spec fetch_collection(String.t() | nil, any) :: + :ok | {:error, fetch_collection_errors} + def fetch_collection(nil, _on_behalf_of), do: {:error, :collection_url_nil} def fetch_collection(collection_url, on_behalf_of) do Logger.debug("Fetching and preparing collection from url") Logger.debug(inspect(collection_url)) - with {:ok, data} <- Fetcher.fetch(collection_url, on_behalf_of: on_behalf_of), - :ok <- Logger.debug("Fetch ok, passing to process_collection"), - :ok <- process_collection(data, on_behalf_of) do - Logger.debug("Finished processing a collection") - :ok + case Fetcher.fetch(collection_url, on_behalf_of: on_behalf_of) do + {:ok, data} when is_map(data) -> + Logger.debug("Fetch ok, passing to process_collection") + + case process_collection(data, on_behalf_of) do + :ok -> + Logger.debug("Finished processing a collection") + :ok + + :error -> + Logger.debug("Failed to process collection #{collection_url}") + {:error, :process_error} + end + + {:error, _err} -> + Logger.debug("Failed to fetch collection #{collection_url}") + {:error, :fetch_error} end end @@ -127,6 +136,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do |> Enum.each(&refresh_profile/1) end + @spec process_collection(map(), any()) :: :ok | :error defp process_collection(%{"type" => type, "orderedItems" => items}, _on_behalf_of) when type in ["OrderedCollection", "OrderedCollectionPage"] do Logger.debug( @@ -168,6 +178,8 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do defp process_collection(_, _), do: :error # If we're handling an activity + @spec handling_element(map()) :: {:ok, any, struct} | :error + @spec handling_element(String.t()) :: {:ok, struct} | {:error, any()} defp handling_element(%{"type" => activity_type} = data) when activity_type in ["Create", "Update", "Delete"] do object = get_in(data, ["object"]) diff --git a/lib/federation/activity_pub/relay.ex b/lib/federation/activity_pub/relay.ex index 90eb4cea2..37f4811f0 100644 --- a/lib/federation/activity_pub/relay.ex +++ b/lib/federation/activity_pub/relay.ex @@ -138,7 +138,8 @@ defmodule Mobilizon.Federation.ActivityPub.Relay do defp fetch_object(object) when is_binary(object), do: {object, object} - @spec fetch_actor(String.t()) :: {:ok, String.t()} | {:error, String.t()} + @spec fetch_actor(String.t()) :: + {:ok, String.t()} | {:error, WebFinger.finger_errors() | :bad_url} # Dirty hack defp fetch_actor("https://" <> address), do: fetch_actor(address) defp fetch_actor("http://" <> address), do: fetch_actor(address) @@ -154,26 +155,15 @@ defmodule Mobilizon.Federation.ActivityPub.Relay do check_actor("relay@#{host}") true -> - {:error, "Bad URL"} + {:error, :bad_url} end end - @spec check_actor(String.t()) :: {:ok, String.t()} | {:error, String.t()} + @spec check_actor(String.t()) :: {:ok, String.t()} | {:error, WebFinger.finger_errors()} defp check_actor(username_and_domain) do case Actors.get_actor_by_name(username_and_domain) do %Actor{url: url} -> {:ok, url} - nil -> finger_actor(username_and_domain) - end - end - - @spec finger_actor(String.t()) :: {:ok, String.t()} | {:error, String.t()} - defp finger_actor(nickname) do - case WebFinger.finger(nickname) do - {:ok, url} when is_binary(url) -> - {:ok, url} - - _e -> - {:error, "No ActivityPub URL found in WebFinger"} + nil -> WebFinger.finger(username_and_domain) end end end diff --git a/lib/federation/activity_pub/transmogrifier.ex b/lib/federation/activity_pub/transmogrifier.ex index cd2c96f7a..ba7fa6478 100644 --- a/lib/federation/activity_pub/transmogrifier.ex +++ b/lib/federation/activity_pub/transmogrifier.ex @@ -32,6 +32,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do @doc """ Handle incoming activities """ + @spec handle_incoming(map()) :: :error | {:ok, any(), struct()} def handle_incoming(%{"id" => nil}), do: :error def handle_incoming(%{"id" => ""}), do: :error @@ -1107,7 +1108,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do defp is_group_object_gone(object_id) do case ActivityPub.fetch_object_from_url(object_id, force: true) do - {:error, error_message, object} when error_message in ["Gone", "Not found"] -> + {:error, error_message, object} when error_message in [:http_gone, :http_not_found] -> {:ok, object} # comments are just emptied diff --git a/lib/federation/web_finger/web_finger.ex b/lib/federation/web_finger/web_finger.ex index 872cd5892..9066d135e 100644 --- a/lib/federation/web_finger/web_finger.ex +++ b/lib/federation/web_finger/web_finger.ex @@ -19,6 +19,10 @@ defmodule Mobilizon.Federation.WebFinger do require Logger import SweetXml + @doc """ + Returns the Web Host Metadata (for `/.well-known/host-meta`) representation for the instance, following RFC6414. + """ + @spec host_meta :: String.t() def host_meta do base_url = Endpoint.url() %URI{host: host} = URI.parse(base_url) @@ -47,6 +51,10 @@ defmodule Mobilizon.Federation.WebFinger do |> XmlBuilder.to_doc() end + @doc """ + Returns the Webfinger representation for the instance, following RFC7033. + """ + @spec webfinger(String.t(), String.t()) :: {:ok, map} | {:error, :actor_not_found} def webfinger(resource, "JSON") do host = Endpoint.host() regex = ~r/(acct:)?(?\w+)@#{host}/ @@ -61,11 +69,14 @@ defmodule Mobilizon.Federation.WebFinger do {:ok, represent_actor(actor, "JSON")} _e -> - {:error, "Couldn't find actor"} + {:error, :actor_not_found} end end end + @doc """ + Return an `Mobilizon.Actors.Actor` Webfinger representation (as JSON) + """ @spec represent_actor(Actor.t()) :: map() @spec represent_actor(Actor.t(), String.t()) :: map() def represent_actor(%Actor{} = actor), do: represent_actor(actor, "JSON") @@ -89,6 +100,7 @@ defmodule Mobilizon.Federation.WebFinger do } end + @spec maybe_add_avatar(list(map()), Actor.t()) :: list(map()) defp maybe_add_avatar(data, %Actor{avatar: avatar}) when not is_nil(avatar) do data ++ [ @@ -102,6 +114,7 @@ defmodule Mobilizon.Federation.WebFinger do defp maybe_add_avatar(data, _actor), do: data + @spec maybe_add_profile_page(list(map()), Actor.t()) :: list(map()) defp maybe_add_profile_page(data, %Actor{type: :Group, url: url}) do data ++ [ @@ -115,35 +128,69 @@ defmodule Mobilizon.Federation.WebFinger do defp maybe_add_profile_page(data, _actor), do: data + @type finger_errors :: + :host_not_found | :address_invalid | :http_error | :webfinger_information_not_json + @doc """ Finger an actor to retreive it's ActivityPub ID/URL - Fetches the Extensible Resource Descriptor endpoint `/.well-known/host-meta` to find the Webfinger endpoint (usually `/.well-known/webfinger?resource=`) with `find_webfinger_endpoint/1` and then performs a Webfinger query to get the ActivityPub ID associated to an actor. + Fetches the Extensible Resource Descriptor endpoint `/.well-known/host-meta` to find the Webfinger endpoint (usually `/.well-known/webfinger?resource=`) and then performs a Webfinger query to get the ActivityPub ID associated to an actor. """ - @spec finger(String.t()) :: {:ok, String.t()} | {:error, atom()} + @spec finger(String.t()) :: + {:ok, String.t()} + | {:error, finger_errors} def finger(actor) do actor = String.trim_leading(actor, "@") - with address when is_binary(address) <- apply_webfinger_endpoint(actor), - false <- address_invalid(address), - {:ok, %{body: body, status: code}} when code in 200..299 <- - WebfingerClient.get(address), - {:ok, %{"url" => url}} <- webfinger_from_json(body) do - {:ok, url} - else - e -> - Logger.debug("Couldn't finger #{actor}") - Logger.debug(inspect(e)) - {:error, e} + case validate_endpoint(actor) do + {:ok, address} -> + case fetch_webfinger_data(address) do + {:ok, %{"url" => url}} -> + {:ok, url} + + {:error, err} -> + Logger.debug("Couldn't process webfinger data for #{actor}") + err + end + + {:error, err} -> + Logger.debug("Couldn't find webfinger endpoint for #{actor}") + {:error, err} end end - @doc """ - Fetches the Extensible Resource Descriptor endpoint `/.well-known/host-meta` to find the Webfinger endpoint (usually `/.well-known/webfinger?resource=`) - """ + @spec fetch_webfinger_data(String.t()) :: + {:ok, map()} | {:error, :webfinger_information_not_json | :http_error} + defp fetch_webfinger_data(address) do + case WebfingerClient.get(address) do + {:ok, %{body: body, status: code}} when code in 200..299 -> + webfinger_from_json(body) + + _ -> + {:error, :http_error} + end + end + + @spec validate_endpoint(String.t()) :: + {:ok, String.t()} | {:error, :address_invalid | :host_not_found} + defp validate_endpoint(actor) do + case apply_webfinger_endpoint(actor) do + address when is_binary(address) -> + if address_invalid(address) do + {:error, :address_invalid} + else + {:ok, address} + end + + _ -> + {:error, :host_not_found} + end + end + + # Fetches the Extensible Resource Descriptor endpoint `/.well-known/host-meta` to find the Webfinger endpoint (usually `/.well-known/webfinger?resource=`) @spec find_webfinger_endpoint(String.t()) :: {:ok, String.t()} | {:error, :link_not_found} | {:error, any()} - def find_webfinger_endpoint(domain) when is_binary(domain) do + defp find_webfinger_endpoint(domain) when is_binary(domain) do with {:ok, %{body: body}} <- fetch_document("http://#{domain}/.well-known/host-meta"), link_template when is_binary(link_template) <- find_link_from_template(body) do {:ok, link_template} diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex index 1bfa41c31..8de075b6b 100644 --- a/lib/mobilizon/actors/actor.ex +++ b/lib/mobilizon/actors/actor.ex @@ -324,7 +324,7 @@ defmodule Mobilizon.Actors.Actor do Changeset for group creation """ @spec group_creation_changeset(t, map) :: Ecto.Changeset.t() - def group_creation_changeset(%__MODULE__{} = actor, params) do + def group_creation_changeset(actor, params) do actor |> cast(params, @group_creation_attrs) |> build_urls(:Group) diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 66d4f0c37..7811b2202 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -12,16 +12,12 @@ defmodule Mobilizon.Actors do alias Mobilizon.Actors.{Actor, Bot, Follower, Member} alias Mobilizon.Addresses.Address - alias Mobilizon.{Crypto, Events} + alias Mobilizon.Crypto alias Mobilizon.Events.FeedToken - alias Mobilizon.Federation.ActivityPub - alias Mobilizon.Medias.File - alias Mobilizon.Service.ErrorReporting.Sentry alias Mobilizon.Service.Workers alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Users alias Mobilizon.Users.User - alias Mobilizon.Web.Email.Group alias Mobilizon.Web.Upload require Logger @@ -61,7 +57,6 @@ defmodule Mobilizon.Actors do @administrator_roles [:creator, :administrator] @moderator_roles [:moderator] ++ @administrator_roles @member_roles [:member] ++ @moderator_roles - @actor_preloads [:user, :organized_events, :comments] @doc """ Gets a single actor. @@ -151,7 +146,7 @@ defmodule Mobilizon.Actors do @doc """ Gets an actor by name. """ - @spec get_actor_by_name(String.t(), atom | nil) :: Actor.t() | nil + @spec get_actor_by_name(String.t(), ActorType.t() | nil) :: Actor.t() | nil def get_actor_by_name(name, type \\ nil) do query = from(a in Actor) @@ -311,98 +306,6 @@ defmodule Mobilizon.Actors do }) end - @doc """ - Deletes an actor. - """ - @spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} - def perform(:delete_actor, %Actor{type: type} = actor, options \\ @delete_actor_default_options) do - Logger.info("Going to delete actor #{actor.url}") - actor = Repo.preload(actor, @actor_preloads) - - delete_actor_options = Keyword.merge(@delete_actor_default_options, options) - Logger.debug(inspect(delete_actor_options)) - - if type == :Group do - delete_eventual_local_members(actor, delete_actor_options) - end - - multi = - Multi.new() - |> Multi.run(:delete_organized_events, fn _, _ -> delete_actor_organized_events(actor) end) - |> Multi.run(:empty_comments, fn _, _ -> delete_actor_empty_comments(actor) end) - |> Multi.run(:remove_banner, fn _, _ -> remove_banner(actor) end) - |> Multi.run(:remove_avatar, fn _, _ -> remove_avatar(actor) end) - - multi = - case type do - :Group -> - multi - |> Multi.run(:delete_remote_members, fn _, _ -> - delete_group_elements(actor, :remote_members) - end) - |> Multi.run(:delete_group_organized_events, fn _, _ -> - delete_group_elements(actor, :events) - end) - |> Multi.run(:delete_group_posts, fn _, _ -> - delete_group_elements(actor, :posts) - end) - |> Multi.run(:delete_group_resources, fn _, _ -> - delete_group_elements(actor, :resources) - end) - |> Multi.run(:delete_group_todo_lists, fn _, _ -> - delete_group_elements(actor, :todo_lists) - end) - |> Multi.run(:delete_group_discussions, fn _, _ -> - delete_group_elements(actor, :discussions) - end) - - :Person -> - # When deleting a profile, reset default_actor_id - Multi.run(multi, :reset_default_actor_id, fn _, _ -> - reset_default_actor_id(actor) - end) - - _ -> - multi - end - - multi = - if Keyword.get(delete_actor_options, :reserve_username, true) do - Multi.update(multi, :actor, Actor.delete_changeset(actor)) - else - Multi.delete(multi, :actor, actor) - end - - Logger.debug("Going to run the transaction") - - case Repo.transaction(multi) do - {:ok, %{actor: %Actor{} = actor}} -> - {:ok, true} = Cachex.del(:activity_pub, "actor_#{actor.preferred_username}") - Logger.info("Deleted actor #{actor.url}") - {:ok, actor} - - {:error, remove, error, _} when remove in [:remove_banner, :remove_avatar] -> - Logger.error("Error while deleting actor's banner or avatar") - - Sentry.capture_message("Error while deleting actor's banner or avatar", - extra: %{err: error} - ) - - Logger.debug(inspect(error, pretty: true)) - {:error, error} - - err -> - Logger.error("Unknown error while deleting actor") - - Sentry.capture_message("Error while deleting actor's banner or avatar", - extra: %{err: err} - ) - - Logger.debug(inspect(err, pretty: true)) - {:error, err} - end - end - @spec actor_key_rotation(Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def actor_key_rotation(%Actor{} = actor) do actor @@ -536,8 +439,9 @@ defmodule Mobilizon.Actors do limit \\ nil ) do anonymous_actor_id = Mobilizon.Config.anonymous_actor_id() + query = from(a in Actor) - Actor + query |> actor_by_username_or_name_query(term) |> actors_for_location(Keyword.get(options, :location), Keyword.get(options, :radius)) |> filter_by_types(Keyword.get(options, :actor_type, :Group)) @@ -610,21 +514,25 @@ defmodule Mobilizon.Actors do """ @spec create_group(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def create_group(attrs \\ %{}) do - local = Map.get(attrs, :local, true) + if Map.get(attrs, :local, true) do + multi = + Multi.new() + |> Multi.insert(:insert_group, Actor.group_creation_changeset(%Actor{}, attrs)) + |> Multi.insert(:add_admin_member, fn %{insert_group: group} -> + Member.changeset(%Member{}, %{ + parent_id: group.id, + actor_id: attrs.creator_actor_id, + role: :administrator + }) + end) + |> Repo.transaction() - if local do - with {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} <- - Multi.new() - |> Multi.insert(:insert_group, Actor.group_creation_changeset(%Actor{}, attrs)) - |> Multi.insert(:add_admin_member, fn %{insert_group: group} -> - Member.changeset(%Member{}, %{ - parent_id: group.id, - actor_id: attrs.creator_actor_id, - role: :administrator - }) - end) - |> Repo.transaction() do - {:ok, group} + case multi do + {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} -> + {:ok, group} + + {:error, %Ecto.Changeset{} = err} -> + {:error, err} end else %Actor{} @@ -818,18 +726,21 @@ defmodule Mobilizon.Actors do """ @spec create_member(map) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()} def create_member(attrs \\ %{}) do - with {:ok, %Member{} = member} <- - %Member{} - |> Member.changeset(attrs) - |> Repo.insert( - on_conflict: {:replace_all_except, [:id, :url, :actor_id, :parent_id]}, - conflict_target: [:actor_id, :parent_id], - # See https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert/2-upserts, - # when doing an upsert with on_conflict, PG doesn't return whether it's an insert or upsert - # so we need to refresh the fields - returning: true - ) do - {:ok, Repo.preload(member, [:actor, :parent, :invited_by])} + case %Member{} + |> Member.changeset(attrs) + |> Repo.insert( + on_conflict: {:replace_all_except, [:id, :url, :actor_id, :parent_id]}, + conflict_target: [:actor_id, :parent_id], + # See https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert/2-upserts, + # when doing an upsert with on_conflict, PG doesn't return whether it's an insert or upsert + # so we need to refresh the fields + returning: true + ) do + {:ok, %Member{} = member} -> + {:ok, Repo.preload(member, [:actor, :parent, :invited_by])} + + {:error, %Ecto.Changeset{} = err} -> + {:error, err} end end @@ -881,6 +792,13 @@ defmodule Mobilizon.Actors do |> Page.build_page(page, limit) end + @spec list_all_local_members_for_group(Actor.t()) :: Member.t() + def list_all_local_members_for_group(%Actor{id: group_id, type: :Group} = _group) do + group_id + |> group_internal_member_query() + |> Repo.all() + end + @spec list_local_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t() def list_local_members_for_group( %Actor{id: group_id, type: :Group} = _group, @@ -995,7 +913,7 @@ defmodule Mobilizon.Actors do @doc """ Creates a bot. """ - @spec create_bot(map) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()} + @spec create_bot(attrs :: map) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()} def create_bot(attrs \\ %{}) do %Bot{} |> Bot.changeset(attrs) @@ -1005,7 +923,8 @@ defmodule Mobilizon.Actors do @doc """ Registers a new bot. """ - @spec register_bot(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} + @spec register_bot(%{name: String.t(), summary: String.t()}) :: + {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def register_bot(%{name: name, summary: summary}) do attrs = %{ preferred_username: name, @@ -1020,7 +939,8 @@ defmodule Mobilizon.Actors do |> Repo.insert() end - @spec get_or_create_internal_actor(String.t()) :: {:ok, Actor.t()} + @spec get_or_create_internal_actor(String.t()) :: + {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def get_or_create_internal_actor(username) do case username |> Actor.build_url(:page) |> get_actor_by_url() do {:ok, %Actor{} = actor} -> @@ -1105,13 +1025,16 @@ defmodule Mobilizon.Actors do @doc """ Creates a follower. """ - @spec create_follower(map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} + @spec create_follower(attrs :: map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} def create_follower(attrs \\ %{}) do - with {:ok, %Follower{} = follower} <- - %Follower{} - |> Follower.changeset(attrs) - |> Repo.insert() do - {:ok, Repo.preload(follower, [:actor, :target_actor])} + case %Follower{} + |> Follower.changeset(attrs) + |> Repo.insert() do + {:ok, %Follower{} = follower} -> + {:ok, Repo.preload(follower, [:actor, :target_actor])} + + {:error, %Ecto.Changeset{} = err} -> + {:error, err} end end @@ -1345,7 +1268,8 @@ defmodule Mobilizon.Actors do end end - @spec schedule_key_rotation(Actor.t(), integer()) :: nil + # TODO: Move me otherwhere + @spec schedule_key_rotation(Actor.t(), integer()) :: :ok def schedule_key_rotation(%Actor{id: actor_id} = actor, delay) do Cachex.put(:actor_key_rotation, actor_id, true) @@ -1354,36 +1278,6 @@ defmodule Mobilizon.Actors do :ok end - @spec remove_banner(Actor.t()) :: {:ok, Actor.t()} - defp remove_banner(%Actor{banner: nil} = actor), do: {:ok, actor} - - defp remove_banner(%Actor{banner: %File{url: url}} = actor) do - safe_remove_file(url, actor) - {:ok, actor} - end - - @spec remove_avatar(Actor.t()) :: {:ok, Actor.t()} - defp remove_avatar(%Actor{avatar: nil} = actor), do: {:ok, actor} - - defp remove_avatar(%Actor{avatar: %File{url: url}} = actor) do - safe_remove_file(url, actor) - {:ok, actor} - end - - @spec safe_remove_file(String.t(), Actor.t()) :: {:ok, Actor.t()} - defp safe_remove_file(url, %Actor{} = actor) do - case Upload.remove(url) do - {:ok, _value} -> - {:ok, actor} - - {:error, error} -> - Logger.error("Error while removing an upload file") - Logger.debug(inspect(error)) - - {:ok, actor} - end - end - @spec delete_files_if_media_changed(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do Enum.each([:avatar, :banner], fn key -> @@ -1748,9 +1642,9 @@ defmodule Mobilizon.Actors do from(a in query, where: a.visibility == ^:public) end - @spec filter_by_name(Ecto.Query.t(), [String.t()]) :: Ecto.Query.t() + @spec filter_by_name(query :: Ecto.Query.t(), [String.t()]) :: Ecto.Query.t() defp filter_by_name(query, [name]) do - from(a in query, where: a.preferred_username == ^name and is_nil(a.domain)) + where(query, [a], a.preferred_username == ^name and is_nil(a.domain)) end defp filter_by_name(query, [name, domain]) do @@ -1761,6 +1655,7 @@ defmodule Mobilizon.Actors do end end + @spec filter_followed_by_approved_status(Ecto.Query.t(), boolean() | nil) :: Ecto.Query.t() defp filter_followed_by_approved_status(query, nil), do: query defp filter_followed_by_approved_status(query, approved) do @@ -1770,141 +1665,4 @@ defmodule Mobilizon.Actors do @spec preload_followers(Actor.t(), boolean) :: Actor.t() defp preload_followers(actor, true), do: Repo.preload(actor, [:followers]) defp preload_followers(actor, false), do: actor - - defp delete_actor_organized_events(%Actor{organized_events: organized_events} = actor) do - res = - Enum.map(organized_events, fn event -> - event = - Repo.preload(event, [ - :organizer_actor, - :participants, - :picture, - :mentions, - :comments, - :attributed_to, - :tags, - :physical_address, - :contacts, - :media - ]) - - ActivityPub.delete(event, actor, false) - end) - - if Enum.all?(res, fn {status, _, _} -> status == :ok end) do - {:ok, res} - else - {:error, res} - end - end - - defp delete_actor_empty_comments(%Actor{comments: comments} = actor) do - res = - Enum.map(comments, fn comment -> - comment = - Repo.preload(comment, [ - :actor, - :mentions, - :event, - :in_reply_to_comment, - :origin_comment, - :attributed_to, - :tags - ]) - - ActivityPub.delete(comment, actor, false) - end) - - if Enum.all?(res, fn {status, _, _} -> status == :ok end) do - {:ok, res} - else - {:error, res} - end - end - - defp delete_group_elements(%Actor{type: :Group} = actor, type) do - Logger.debug("delete_group_elements #{inspect(type)}") - - method = - case type do - :remote_members -> &list_remote_members_for_group/3 - :events -> &Events.list_simple_organized_events_for_group/3 - :posts -> &Mobilizon.Posts.get_posts_for_group/3 - :resources -> &Mobilizon.Resources.get_resources_for_group/3 - :todo_lists -> &Mobilizon.Todos.get_todo_lists_for_group/3 - :discussions -> &Mobilizon.Discussions.find_discussions_for_actor/3 - end - - res = - actor - |> accumulate_paginated_elements(method) - |> Enum.map(fn element -> ActivityPub.delete(element, actor, false) end) - - if Enum.all?(res, fn {status, _, _} -> status == :ok end) do - Logger.debug("Return OK for all #{to_string(type)}") - {:ok, res} - else - Logger.debug("Something failed #{inspect(res)}") - {:error, res} - end - end - - @spec reset_default_actor_id(Actor.t()) :: {:ok, User.t()} | {:error, :user_not_found} - defp reset_default_actor_id(%Actor{type: :Person, user: %User{id: user_id} = user, id: actor_id}) do - Logger.debug("reset_default_actor_id") - - new_actor_id = - user - |> Users.get_actors_for_user() - |> Enum.map(& &1.id) - |> Enum.find(&(&1 !== actor_id)) - - {:ok, Users.update_user_default_actor(user_id, new_actor_id)} - rescue - _e in Ecto.NoResultsError -> - {:error, :user_not_found} - end - - defp reset_default_actor_id(%Actor{type: :Person, user: nil}), do: {:ok, nil} - - defp accumulate_paginated_elements( - %Actor{} = actor, - method, - elements \\ [], - page \\ 1, - limit \\ 10 - ) do - Logger.debug("accumulate_paginated_elements") - %Page{total: total, elements: new_elements} = method.(actor, page, limit) - elements = elements ++ new_elements - count = length(elements) - - if count < total do - accumulate_paginated_elements(actor, method, elements, page + 1, limit) - else - Logger.debug("Found #{count} group elements to delete") - elements - end - end - - # This one is not in the Multi transaction because it sends activities - defp delete_eventual_local_members(%Actor{} = group, options) do - suspended? = Keyword.get(options, :suspension, false) - - group - |> accumulate_paginated_elements(&list_local_members_for_group/3) - |> Enum.map(fn member -> - if suspended? do - Group.send_group_suspension_notification(member) - else - with author_id when not is_nil(author_id) <- Keyword.get(options, :author_id), - %Actor{} = author <- get_actor(author_id) do - Group.send_group_deletion_notification(member, author) - end - end - - member - end) - |> Enum.map(fn member -> ActivityPub.delete(member, group, false) end) - end end diff --git a/lib/mobilizon/actors/follower.ex b/lib/mobilizon/actors/follower.ex index e77bc0154..7bc0ad92b 100644 --- a/lib/mobilizon/actors/follower.ex +++ b/lib/mobilizon/actors/follower.ex @@ -12,10 +12,13 @@ defmodule Mobilizon.Actors.Follower do alias Mobilizon.Web.Endpoint @type t :: %__MODULE__{ + id: String.t(), approved: boolean, url: String.t(), target_actor: Actor.t(), - actor: Actor.t() + actor: Actor.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() } @required_attrs [:url, :approved, :target_actor_id, :actor_id] @@ -35,7 +38,7 @@ defmodule Mobilizon.Actors.Follower do end @doc false - @spec changeset(t, map) :: Ecto.Changeset.t() + @spec changeset(follower :: t, attrs :: map) :: Ecto.Changeset.t() def changeset(follower, attrs) do follower |> cast(attrs, @attrs) diff --git a/lib/mobilizon/config.ex b/lib/mobilizon/config.ex index bf0ce8c65..704fa63af 100644 --- a/lib/mobilizon/config.ex +++ b/lib/mobilizon/config.ex @@ -5,6 +5,7 @@ defmodule Mobilizon.Config do alias Mobilizon.Actors alias Mobilizon.Service.GitStatus + require Logger @spec instance_config :: keyword def instance_config, do: Application.get_env(:mobilizon, :instance) @@ -35,10 +36,10 @@ defmodule Mobilizon.Config do "instance_long_description" ) - @spec instance_slogan :: String.t() + @spec instance_slogan :: String.t() | nil def instance_slogan, do: Mobilizon.Admin.get_admin_setting_value("instance", "instance_slogan") - @spec contact :: String.t() + @spec contact :: String.t() | nil def contact do Mobilizon.Admin.get_admin_setting_value("instance", "contact") end @@ -53,7 +54,7 @@ defmodule Mobilizon.Config do Mobilizon.Admin.get_admin_setting_value("instance", "instance_terms_type", "DEFAULT") end - @spec instance_terms_url :: String.t() + @spec instance_terms_url :: String.t() | nil def instance_terms_url do Mobilizon.Admin.get_admin_setting_value("instance", "instance_terms_url") end @@ -225,6 +226,7 @@ defmodule Mobilizon.Config do @spec ldap_enabled? :: boolean() def ldap_enabled?, do: get([:ldap, :enabled], false) + @spec instance_resource_providers :: list(%{type: atom, software: atom, endpoint: String.t()}) def instance_resource_providers do types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types]) @@ -248,20 +250,25 @@ defmodule Mobilizon.Config do end end + @spec instance_group_feature_enabled? :: boolean def instance_group_feature_enabled?, do: :mobilizon |> Application.get_env(:groups) |> Keyword.get(:enabled) + @spec instance_event_creation_enabled? :: boolean def instance_event_creation_enabled?, do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:creation) + @spec anonymous_actor_id :: binary | integer def anonymous_actor_id, do: get_cached_value(:anonymous_actor_id) + @spec relay_actor_id :: binary | integer def relay_actor_id, do: get_cached_value(:relay_actor_id) + @spec admin_settings :: map def admin_settings, do: get_cached_value(:admin_config) - @spec get(module | atom) :: any + @spec get(key :: module | atom) :: any def get(key), do: get(key, nil) - @spec get([module | atom]) :: any + @spec get(keys :: [module | atom], default :: any) :: any def get([key], default), do: get(key, default) def get([parent_key | keys], default) do @@ -271,10 +278,10 @@ defmodule Mobilizon.Config do end end - @spec get(module | atom, any) :: any + @spec get(key :: module | atom, default :: any) :: any def get(key, default), do: Application.get_env(:mobilizon, key, default) - @spec get!(module | atom) :: any + @spec get!(key :: module | atom) :: any def get!(key) do value = get(key, nil) @@ -285,6 +292,7 @@ defmodule Mobilizon.Config do end end + @spec put(keys :: [module | atom], value :: any) :: :ok def put([key], value), do: put(key, value) def put([parent_key | keys], value) do @@ -295,6 +303,7 @@ defmodule Mobilizon.Config do Application.put_env(:mobilizon, parent_key, parent) end + @spec put(keys :: module | atom, value :: any) :: :ok def put(key, value) do Application.put_env(:mobilizon, key, value) end @@ -302,11 +311,16 @@ defmodule Mobilizon.Config do @spec to_boolean(boolean | String.t()) :: boolean defp to_boolean(boolean), do: "true" == String.downcase("#{boolean}") + @spec get_cached_value(atom) :: String.t() | integer | map defp get_cached_value(key) do case Cachex.fetch(:config, key, fn key -> case create_cache(key) do - value when not is_nil(value) -> {:commit, value} - err -> {:ignore, err} + {:ok, value} when not is_nil(value) -> + {:commit, value} + + {:error, err} -> + Logger.debug("Failed to cache config value, returned: #{inspect(err)}") + {:ignore, err} end end) do {status, value} when status in [:ok, :commit] -> value @@ -314,23 +328,29 @@ defmodule Mobilizon.Config do end end - @spec create_cache(atom()) :: integer() + @spec create_cache(atom()) :: {:ok, integer() | map()} | {:error, Ecto.Changeset.t()} defp create_cache(:anonymous_actor_id) do - with {:ok, %{id: actor_id}} <- Actors.get_or_create_internal_actor("anonymous") do - actor_id + case Actors.get_or_create_internal_actor("anonymous") do + {:ok, %{id: actor_id}} -> + {:ok, actor_id} + + {:error, err} -> + {:error, err} end end - @spec create_cache(atom()) :: integer() defp create_cache(:relay_actor_id) do - with {:ok, %{id: actor_id}} <- Actors.get_or_create_internal_actor("relay") do - actor_id + case Actors.get_or_create_internal_actor("relay") do + {:ok, %{id: actor_id}} -> + {:ok, actor_id} + + {:error, err} -> + {:error, err} end end - @spec create_cache(atom()) :: map() defp create_cache(:admin_config) do - %{ + data = %{ instance_description: instance_description(), instance_long_description: instance_long_description(), instance_name: instance_name(), @@ -346,12 +366,16 @@ defmodule Mobilizon.Config do instance_rules: instance_rules(), instance_languages: instance_languages() } + + {:ok, data} end + @spec clear_config_cache :: {:ok | :error, integer} def clear_config_cache do Cachex.clear(:config) end + @spec generate_terms(String.t()) :: String.t() def generate_terms(locale) do Gettext.put_locale(locale) @@ -366,6 +390,7 @@ defmodule Mobilizon.Config do ) end + @spec generate_privacy(String.t()) :: String.t() def generate_privacy(locale) do Gettext.put_locale(locale) @@ -376,6 +401,7 @@ defmodule Mobilizon.Config do ) end + @spec instance_contact_html :: String.t() defp instance_contact_html do contact = contact() diff --git a/lib/mobilizon/users/push_subscription.ex b/lib/mobilizon/users/push_subscription.ex index 737139f2f..be94336e3 100644 --- a/lib/mobilizon/users/push_subscription.ex +++ b/lib/mobilizon/users/push_subscription.ex @@ -6,6 +6,14 @@ defmodule Mobilizon.Users.PushSubscription do alias Mobilizon.Users.User import Ecto.Changeset + @type t :: %__MODULE__{ + digest: String.t(), + user: User.t(), + endpoint: String.t(), + auth: String.t(), + p256dh: String.t() + } + @primary_key {:id, :binary_id, autogenerate: true} schema "user_push_subscriptions" do field(:digest, :string) diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index 271c962de..aee2742ef 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -42,7 +42,7 @@ defmodule Mobilizon.Users do end end - @spec create_external(String.t(), String.t(), Map.t()) :: + @spec create_external(String.t(), String.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def create_external(email, provider, args \\ %{}) do with {:ok, %User{} = user} <- diff --git a/lib/service/actor_suspension.ex b/lib/service/actor_suspension.ex new file mode 100644 index 000000000..104981824 --- /dev/null +++ b/lib/service/actor_suspension.ex @@ -0,0 +1,256 @@ +defmodule Mobilizon.Service.ActorSuspension do + @moduledoc """ + Handle actor suspensions + """ + + alias Ecto.Multi + alias Mobilizon.{Actors, Events, Users} + alias Mobilizon.Actors.{Actor, Member} + alias Mobilizon.Discussions.{Comment, Discussion} + alias Mobilizon.Events.{Event, Participant} + alias Mobilizon.Medias.File + alias Mobilizon.Posts.Post + alias Mobilizon.Resources.Resource + alias Mobilizon.Storage.Repo + alias Mobilizon.Users.User + alias Mobilizon.Web.Email.Actor, as: ActorEmail + alias Mobilizon.Web.Email.Group + alias Mobilizon.Web.Upload + require Logger + import Ecto.Query + + @actor_preloads [:user, :organized_events, :comments] + @delete_actor_default_options [reserve_username: true, suspension: false] + + @doc """ + Deletes an actor. + """ + @spec suspend_actor(Actor.t(), Keyword.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} + def suspend_actor(%Actor{} = actor, options \\ @delete_actor_default_options) do + Logger.info("Going to delete actor #{actor.url}") + actor = Repo.preload(actor, @actor_preloads) + + delete_actor_options = Keyword.merge(@delete_actor_default_options, options) + Logger.debug(inspect(delete_actor_options)) + + send_suspension_notification(actor) + notify_event_participants_from_suspension(actor) + delete_participations(actor) + + multi = + Multi.new() + |> maybe_reset_actor_id(actor) + |> delete_actor_empty_comments(actor) + |> Multi.run(:remove_banner, fn _, _ -> remove_banner(actor) end) + |> Multi.run(:remove_avatar, fn _, _ -> remove_avatar(actor) end) + + multi = + if Keyword.get(delete_actor_options, :reserve_username, true) do + multi + |> delete_actor_events(actor) + |> delete_posts(actor) + |> delete_ressources(actor) + |> delete_discussions(actor) + |> delete_members(actor) + |> Multi.update(:actor, Actor.delete_changeset(actor)) + else + Multi.delete(multi, :actor, actor) + end + + Logger.debug("Going to run the transaction") + + case Repo.transaction(multi) do + {:ok, %{actor: %Actor{} = actor}} -> + {:ok, true} = Cachex.del(:activity_pub, "actor_#{actor.preferred_username}") + Logger.info("Deleted actor #{actor.url}") + {:ok, actor} + + {:error, remove, error, _} when remove in [:remove_banner, :remove_avatar] -> + Logger.error("Error while deleting actor's banner or avatar") + + Sentry.capture_message("Error while deleting actor's banner or avatar", + extra: %{err: error} + ) + + Logger.debug(inspect(error, pretty: true)) + {:error, error} + + err -> + Logger.error("Unknown error while deleting actor") + + Sentry.capture_message("Error while deleting actor's banner or avatar", + extra: %{err: err} + ) + + Logger.debug(inspect(err, pretty: true)) + {:error, err} + end + end + + # When deleting a profile, reset default_actor_id + @spec maybe_reset_actor_id(Multi.t(), Actor.t()) :: Multi.t() + defp maybe_reset_actor_id(%Multi{} = multi, %Actor{type: :Person} = actor) do + Multi.run(multi, :reset_default_actor_id, fn _, _ -> + reset_default_actor_id(actor) + end) + end + + defp maybe_reset_actor_id(%Multi{} = multi, %Actor{type: :Group} = _actor) do + multi + end + + defp delete_actor_empty_comments(%Multi{} = multi, %Actor{id: actor_id}) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Multi.update_all(multi, :empty_comments, where(Comment, [c], c.actor_id == ^actor_id), + set: [ + text: nil, + actor_id: nil, + deleted_at: now + ] + ) + end + + @spec notify_event_participants_from_suspension(Actor.t()) :: :ok + defp notify_event_participants_from_suspension(%Actor{id: actor_id} = actor) do + actor + |> get_actor_organizer_events_participations() + |> preload([:actor, :event]) + |> Repo.all() + |> Enum.filter(fn %Participant{actor: %Actor{id: participant_actor_id}} -> + participant_actor_id != actor_id + end) + |> Enum.each(&ActorEmail.send_notification_event_participants_from_suspension(&1, actor)) + end + + @spec get_actor_organizer_events_participations(Actor.t()) :: Ecto.Query.t() + defp get_actor_organizer_events_participations(%Actor{type: :Person, id: actor_id}) do + do_get_actor_organizer_events_participations() + |> where([_p, e], e.organizer_actor_id == ^actor_id) + end + + defp get_actor_organizer_events_participations(%Actor{type: :Group, id: actor_id}) do + do_get_actor_organizer_events_participations() + |> where([_p, e], e.attributed_to_id == ^actor_id) + end + + @spec do_get_actor_organizer_events_participations :: Ecto.Query.t() + defp do_get_actor_organizer_events_participations do + Participant + |> join(:inner, [p], e in Event, on: p.event_id == e.id) + |> where([_p, e], e.begins_on > ^DateTime.utc_now()) + |> where([p, _e], p.role in [:participant, :moderator, :administrator]) + end + + @spec delete_actor_events(Ecto.Multi.t(), Actor.t()) :: Ecto.Multi.t() + defp delete_actor_events(%Multi{} = multi, %Actor{type: :Person, id: actor_id}) do + Multi.delete_all(multi, :delete_events, where(Event, [e], e.organizer_actor_id == ^actor_id)) + end + + defp delete_actor_events(%Multi{} = multi, %Actor{type: :Group, id: actor_id}) do + Multi.delete_all(multi, :delete_events, where(Event, [e], e.attributed_to_id == ^actor_id)) + end + + defp delete_posts(%Multi{} = multi, %Actor{type: :Person, id: actor_id}) do + Multi.delete_all(multi, :delete_posts, where(Post, [e], e.author_id == ^actor_id)) + end + + defp delete_posts(%Multi{} = multi, %Actor{type: :Group, id: actor_id}) do + Multi.delete_all(multi, :delete_posts, where(Post, [e], e.attributed_to_id == ^actor_id)) + end + + defp delete_ressources(%Multi{} = multi, %Actor{type: :Person, id: actor_id}) do + Multi.delete_all(multi, :delete_resources, where(Resource, [e], e.creator_id == ^actor_id)) + end + + defp delete_ressources(%Multi{} = multi, %Actor{type: :Group, id: actor_id}) do + Multi.delete_all(multi, :delete_resources, where(Resource, [e], e.actor_id == ^actor_id)) + end + + # Keep discussions just in case, comments are already emptied + defp delete_discussions(%Multi{} = multi, %Actor{type: :Person}) do + multi + end + + defp delete_discussions(%Multi{} = multi, %Actor{type: :Group, id: actor_id}) do + Multi.delete_all(multi, :delete_discussions, where(Discussion, [e], e.actor_id == ^actor_id)) + end + + @spec delete_participations(Actor.t()) :: :ok + defp delete_participations(%Actor{type: :Person} = actor) do + %Actor{participations: participations} = Repo.preload(actor, [:participations]) + Enum.each(participations, &Events.delete_participant/1) + end + + defp delete_participations(%Actor{type: :Group}), do: :ok + + @spec delete_members(Multi.t(), Actor.t()) :: Multi.t() + defp delete_members(%Multi{} = multi, %Actor{type: :Person, id: actor_id}) do + Multi.delete_all(multi, :delete_members, where(Member, [e], e.actor_id == ^actor_id)) + end + + defp delete_members(%Multi{} = multi, %Actor{type: :Group, id: actor_id}) do + Multi.delete_all(multi, :delete_members, where(Member, [e], e.parent_id == ^actor_id)) + end + + @spec reset_default_actor_id(Actor.t()) :: {:ok, User.t() | nil} | {:error, :user_not_found} + defp reset_default_actor_id(%Actor{type: :Person, user: %User{id: user_id} = user, id: actor_id}) do + Logger.debug("reset_default_actor_id") + + new_actor_id = + user + |> Users.get_actors_for_user() + |> Enum.map(& &1.id) + |> Enum.find(&(&1 !== actor_id)) + + {:ok, Users.update_user_default_actor(user_id, new_actor_id)} + rescue + _e in Ecto.NoResultsError -> + {:error, :user_not_found} + end + + defp reset_default_actor_id(%Actor{type: :Person, user: nil}), do: {:ok, nil} + + @spec remove_banner(Actor.t()) :: {:ok, Actor.t()} + defp remove_banner(%Actor{banner: nil} = actor), do: {:ok, actor} + + defp remove_banner(%Actor{banner: %File{url: url}} = actor) do + safe_remove_file(url, actor) + {:ok, actor} + end + + @spec remove_avatar(Actor.t()) :: {:ok, Actor.t()} + defp remove_avatar(%Actor{avatar: avatar} = actor) do + case avatar do + %File{url: url} -> + safe_remove_file(url, actor) + {:ok, actor} + + nil -> + {:ok, actor} + end + end + + @spec safe_remove_file(String.t(), Actor.t()) :: {:ok, Actor.t()} + defp safe_remove_file(url, %Actor{} = actor) do + case Upload.remove(url) do + {:ok, _value} -> + {:ok, actor} + + {:error, error} -> + Logger.error("Error while removing an upload file") + Logger.debug(inspect(error)) + + {:ok, actor} + end + end + + @spec send_suspension_notification(Actor.t()) :: :ok + defp send_suspension_notification(%Actor{type: :Group} = group) do + group + |> Actors.list_all_local_members_for_group() + |> Enum.each(&Group.send_group_suspension_notification/1) + end + + defp send_suspension_notification(%Actor{} = _actor), do: :ok +end diff --git a/lib/service/workers/background.ex b/lib/service/workers/background.ex index a9e93504e..07eb9c91a 100644 --- a/lib/service/workers/background.ex +++ b/lib/service/workers/background.ex @@ -6,6 +6,7 @@ defmodule Mobilizon.Service.Workers.Background do alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Federation.ActivityPub.Refresher + alias Mobilizon.Service.ActorSuspension use Mobilizon.Service.Workers.Helper, queue: "background" @@ -14,7 +15,7 @@ defmodule Mobilizon.Service.Workers.Background do with reserve_username when is_boolean(reserve_username) <- Map.get(args, "reserve_username", true), %Actor{} = actor <- Actors.get_actor(actor_id) do - Actors.perform(:delete_actor, actor, reserve_username: reserve_username) + ActorSuspension.suspend_actor(actor, reserve_username: reserve_username) end end diff --git a/lib/service/workers/clean_suspended_actors.ex b/lib/service/workers/clean_suspended_actors.ex new file mode 100644 index 000000000..277f0f1be --- /dev/null +++ b/lib/service/workers/clean_suspended_actors.ex @@ -0,0 +1,18 @@ +defmodule Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker do + @moduledoc """ + Worker to clean unattached media + """ + + use Oban.Worker, queue: "background" + alias Mobilizon.Actors + alias Mobilizon.Service.ActorSuspension + + @suspention_days 30 + + @impl Oban.Worker + def perform(%Job{}) do + [suspension: @suspention_days] + |> Actors.list_suspended_actors_to_purge() + |> Enum.each(&ActorSuspension.suspend_actor(&1, reserve_username: true, suspension: true)) + end +end diff --git a/lib/web/cache/activity_pub.ex b/lib/web/cache/activity_pub.ex index d31b8cc2e..976191210 100644 --- a/lib/web/cache/activity_pub.ex +++ b/lib/web/cache/activity_pub.ex @@ -21,7 +21,7 @@ defmodule Mobilizon.Web.Cache.ActivityPub do Gets a actor by username and eventually domain. """ @spec get_actor_by_name(String.t()) :: - {:commit, Actor.t()} | {:ignore, nil} + {:commit, ActorModel.t()} | {:ignore, nil} def get_actor_by_name(name) do Cachex.fetch(@cache, "actor_" <> name, fn "actor_" <> name -> case Actor.find_or_make_actor_from_nickname(name) do @@ -38,7 +38,7 @@ defmodule Mobilizon.Web.Cache.ActivityPub do Gets a local actor by username. """ @spec get_local_actor_by_name(String.t()) :: - {:commit, Actor.t()} | {:ignore, nil} + {:commit, ActorModel.t()} | {:ignore, nil} def get_local_actor_by_name(name) do Cachex.fetch(@cache, "local_actor_" <> name, fn "local_actor_" <> name -> case Actors.get_local_actor_by_name(name) do @@ -195,7 +195,7 @@ defmodule Mobilizon.Web.Cache.ActivityPub do @doc """ Gets a relay. """ - @spec get_relay :: {:commit, Actor.t()} | {:ignore, nil} + @spec get_relay :: {:commit, ActorModel.t()} | {:ignore, nil} def get_relay do Cachex.fetch(@cache, "relay_actor", &Relay.get_actor/0) end diff --git a/lib/web/email/group.ex b/lib/web/email/group.ex index 8e1e938ec..ea4a0ee01 100644 --- a/lib/web/email/group.ex +++ b/lib/web/email/group.ex @@ -119,41 +119,57 @@ defmodule Mobilizon.Web.Email.Group do when role not in @member_roles, do: :ok + @spec send_group_deletion_notification(Member.t(), Actor.t()) :: :ok def send_group_deletion_notification( %Member{ - actor: %Actor{user_id: user_id, id: actor_id}, - parent: %Actor{domain: nil} = group, - role: member_role + actor: %Actor{user_id: user_id, id: actor_id} = member }, %Actor{id: author_id} = author ) do with %User{email: email, locale: locale} <- Users.get_user!(user_id), {:member_not_author, true} <- {:member_not_author, author_id !== actor_id} do - Gettext.put_locale(locale) - instance = Config.instance_name() - - subject = - gettext( - "The group %{group} has been deleted on %{instance}", - group: group.name, - instance: instance - ) - - Email.base_email(to: email, subject: subject) - |> assign(:locale, locale) - |> assign(:group, group) - |> assign(:role, member_role) - |> assign(:subject, subject) - |> assign(:instance, instance) - |> assign(:author, author) - |> render(:group_deletion) - |> Email.Mailer.send_email_later() - - :ok + do_send_group_deletion_notification(member, author: author, email: email, locale: locale) else # Skip if it's the author itself {:member_not_author, _} -> :ok end end + + @spec send_group_deletion_notification(Member.t()) :: :ok + def send_group_deletion_notification(%Member{actor: %Actor{user_id: user_id}} = member) do + case Users.get_user!(user_id) do + %User{email: email, locale: locale} -> + do_send_group_deletion_notification(member, email: email, locale: locale) + end + end + + defp do_send_group_deletion_notification( + %Member{role: member_role, parent: %Actor{domain: nil} = group}, + options + ) do + locale = Keyword.get(options, :locale) + Gettext.put_locale(locale) + instance = Config.instance_name() + author = Keyword.get(options, :author) + + subject = + gettext( + "The group %{group} has been deleted on %{instance}", + group: group.name, + instance: instance + ) + + Email.base_email(to: Keyword.get(options, :email), subject: subject) + |> assign(:locale, locale) + |> assign(:group, group) + |> assign(:role, member_role) + |> assign(:subject, subject) + |> assign(:instance, instance) + |> assign(:author, author) + |> render(:group_deletion) + |> Email.Mailer.send_email_later() + + :ok + end end diff --git a/test/federation/activity_pub/transmogrifier_test.exs b/test/federation/activity_pub/transmogrifier_test.exs index d344b5633..dc59f71e3 100644 --- a/test/federation/activity_pub/transmogrifier_test.exs +++ b/test/federation/activity_pub/transmogrifier_test.exs @@ -202,7 +202,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do with_mock ActivityPubActor, [:passthrough], get_or_fetch_actor_by_url: fn url -> case url do - @mobilizon_group_url -> {:error, "Not found"} + @mobilizon_group_url -> {:error, :http_not_found} ^actor_url -> {:ok, actor} end end do @@ -308,7 +308,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do with_mock ActivityPubActor, [:passthrough], get_or_fetch_actor_by_url: fn url -> case url do - @mobilizon_group_url -> {:error, "Not found"} + @mobilizon_group_url -> {:error, :http_not_found} ^actor_url -> {:ok, actor} end end do diff --git a/test/mobilizon/actors/actors_test.exs b/test/mobilizon/actors/actors_test.exs index 94abd067e..bf51bff32 100644 --- a/test/mobilizon/actors/actors_test.exs +++ b/test/mobilizon/actors/actors_test.exs @@ -5,7 +5,7 @@ defmodule Mobilizon.ActorsTest do import Mobilizon.Factory - alias Mobilizon.{Actors, Config, Discussions, Events, Tombstone, Users} + alias Mobilizon.{Actors, Config, Discussions, Events, Users} alias Mobilizon.Actors.{Actor, Bot, Follower, Member} alias Mobilizon.Discussions.Comment alias Mobilizon.Events.Event @@ -292,57 +292,7 @@ defmodule Mobilizon.ActorsTest do test "update_actor/2 with invalid data returns error changeset", %{actor: actor} do assert {:error, %Ecto.Changeset{}} = Actors.update_actor(actor, @invalid_attrs) actor_fetched = Actors.get_actor!(actor.id) - assert actor = actor_fetched - end - - test "perform delete the actor actually deletes the actor", %{ - actor: %Actor{avatar: %{url: avatar_url}, banner: %{url: banner_url}, id: actor_id} = actor - } do - %Event{url: event1_url} = event1 = insert(:event, organizer_actor: actor) - insert(:event, organizer_actor: actor) - - %Comment{url: comment1_url} = comment1 = insert(:comment, actor: actor) - insert(:comment, actor: actor) - - %URI{path: "/media/" <> avatar_path} = URI.parse(avatar_url) - %URI{path: "/media/" <> banner_path} = URI.parse(banner_url) - - assert File.exists?( - Config.get!([Uploader.Local, :uploads]) <> - "/" <> avatar_path - ) - - assert File.exists?( - Config.get!([Uploader.Local, :uploads]) <> - "/" <> banner_path - ) - - assert {:ok, %Actor{}} = Actors.perform(:delete_actor, actor) - - assert %Actor{ - name: nil, - summary: nil, - suspended: true, - avatar: nil, - banner: nil, - user_id: nil - } = Actors.get_actor(actor_id) - - assert {:error, :event_not_found} = Events.get_event(event1.id) - assert %Tombstone{} = Tombstone.find_tombstone(event1_url) - assert %Comment{deleted_at: deleted_at} = Discussions.get_comment(comment1.id) - refute is_nil(deleted_at) - assert %Tombstone{} = Tombstone.find_tombstone(comment1_url) - - refute File.exists?( - Config.get!([Uploader.Local, :uploads]) <> - "/" <> avatar_path - ) - - refute File.exists?( - Config.get!([Uploader.Local, :uploads]) <> - "/" <> banner_path - ) + assert actor.id == actor_fetched.id end test "delete_actor/1 deletes the actor", %{ @@ -392,10 +342,8 @@ defmodule Mobilizon.ActorsTest do } = Actors.get_actor(actor_id) assert {:error, :event_not_found} = Events.get_event(event1.id) - assert %Tombstone{} = Tombstone.find_tombstone(event1_url) assert %Comment{deleted_at: deleted_at} = Discussions.get_comment(comment1.id) refute is_nil(deleted_at) - assert %Tombstone{} = Tombstone.find_tombstone(comment1_url) refute File.exists?( Config.get!([Uploader.Local, :uploads]) <> diff --git a/test/service/actor_suspension_test.exs b/test/service/actor_suspension_test.exs new file mode 100644 index 000000000..6c5f0d203 --- /dev/null +++ b/test/service/actor_suspension_test.exs @@ -0,0 +1,103 @@ +defmodule Mobilizon.Service.ActorSuspensionTest do + use Mobilizon.DataCase + + import Mobilizon.Factory + + alias Mobilizon.{Actors, Config, Discussions, Events} + alias Mobilizon.Actors.Actor + alias Mobilizon.Discussions.Comment + alias Mobilizon.Events.{Event, Participant} + alias Mobilizon.Service.ActorSuspension + alias Mobilizon.Web.Upload.Uploader + + describe "suspend a person" do + setup do + %Actor{} = actor = insert(:actor) + + %Comment{} = comment = insert(:comment, actor: actor) + %Event{} = event = insert(:event, organizer_actor: actor) + %Event{} = insert(:event) + insert(:participant, event: event) + + %Participant{} = + participant = insert(:participant, actor: actor, event: event, role: :participant) + + {:ok, actor: actor, comment: comment, event: event, participant: participant} + end + + test "local", %{actor: actor, comment: comment, event: event, participant: participant} do + assert actor + |> media_paths() + |> media_exists?() + + assert {:ok, %Actor{}} = ActorSuspension.suspend_actor(actor) + assert %Actor{suspended: true} = Actors.get_actor(actor.id) + assert %Comment{deleted_at: %DateTime{}} = Discussions.get_comment(comment.id) + assert {:error, :event_not_found} = Events.get_event(event.id) + assert nil == Events.get_participant(participant.id) + + refute actor + |> media_paths() + |> media_exists?() + end + end + + describe "delete a person" do + setup do + %Actor{} = actor = insert(:actor) + + %Comment{} = comment = insert(:comment, actor: actor) + %Event{} = event = insert(:event, organizer_actor: actor) + + {:ok, actor: actor, comment: comment, event: event} + end + + test "local", %{actor: actor, comment: comment, event: event} do + assert actor + |> media_paths() + |> media_exists?() + + assert {:ok, %Actor{}} = ActorSuspension.suspend_actor(actor, reserve_username: false) + assert nil == Actors.get_actor(actor.id) + assert %Comment{deleted_at: %DateTime{}} = Discussions.get_comment(comment.id) + assert {:error, :event_not_found} = Events.get_event(event.id) + + refute actor + |> media_paths() + |> media_exists?() + end + end + + describe "suspend a group" do + setup do + %Actor{} = group = insert(:group) + + %Event{} = event = insert(:event, attributed_to: group) + + {:ok, group: group, event: event} + end + + test "local", %{group: group, event: event} do + assert {:ok, %Actor{}} = ActorSuspension.suspend_actor(group) + assert %Actor{suspended: true} = Actors.get_actor(group.id) + assert {:error, :event_not_found} = Events.get_event(event.id) + end + end + + defp media_paths(%Actor{avatar: %{url: avatar_url}, banner: %{url: banner_url}}) do + %URI{path: "/media/" <> avatar_path} = URI.parse(avatar_url) + %URI{path: "/media/" <> banner_path} = URI.parse(banner_url) + %{avatar: avatar_path, banner: banner_path} + end + + defp media_exists?(%{avatar: avatar_path, banner: banner_path}) do + File.exists?( + Config.get!([Uploader.Local, :uploads]) <> + "/" <> avatar_path + ) && + File.exists?( + Config.get!([Uploader.Local, :uploads]) <> + "/" <> banner_path + ) + end +end