Actor suspension refactoring

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-09-10 11:36:05 +02:00
parent e9fecc4d24
commit 75e254d8b4
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
23 changed files with 816 additions and 603 deletions

View file

@ -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()]

View file

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

View file

@ -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
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
case Actors.get_actor_by_url(url, preload) do
{:ok, %Actor{} = cached_actor} ->
unless 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}
__MODULE__.make_actor_from_url(url, preload)
end
{:error, err} ->
Logger.debug("Could not fetch by AP id")
Logger.debug(inspect(err))
{:error, "Could not fetch by AP id"}
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

View file

@ -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 <-
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
|> 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}
else
{:ok, %Tesla.Env{status: 410}} ->
Logger.debug("Resource at #{url} is 410 Gone")
{:error, "Gone"}
{:error, :http_gone}
{:ok, %Tesla.Env{status: 404}} ->
Logger.debug("Resource at #{url} is 404 Gone")
{:error, "Not found"}
{:error, :http_not_found}
{:ok, %Tesla.Env{} = res} ->
{:error, res}
{:error, err} ->
{:error, err}
end
else
{: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,26 +109,34 @@ 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,
case 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
) 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")
{:ok, ActorConverter.as_to_model_data(data)}
else
# Actor is gone, probably deleted
{: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}
@ -124,16 +145,10 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
Logger.info("Non 200 HTTP Code")
{:error, :http_error}
{:error, e} ->
Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}")
{:error, e}
e ->
Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}")
{:error, e}
{:error, error} ->
Logger.warn("Could not fetch actor at fetch #{url}, #{inspect(error)}")
{:error, :http_error}
end
res
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

View file

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

View file

@ -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,16 +32,24 @@ 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,
case ActivityPubActor.make_actor_from_url(group_url) do
{:ok,
%Actor{
outbox_url: outbox_url,
resources_url: resources_url,
@ -51,9 +58,10 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
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),
}} ->
Logger.debug("Fetched group OK, now doing collections")
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),
@ -62,45 +70,46 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
:ok <- fetch_collection(events_url, on_behalf_of) do
:ok
else
{:error, :actor_deleted} ->
{:error, :actor_deleted}
{:error, :http_error} ->
{:error, :http_error}
{: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 [:error, :process_error, :fetch_error, :collection_url_nil] ->
Logger.debug("Error while fetching actor collection")
{:error, err}
end
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
{:error, err}
when err in [:actor_deleted, :http_error, :json_decode_error, :actor_is_local] ->
Logger.debug("Error while making actor")
{:error, 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
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"])

View file

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

View file

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

View file

@ -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:)?(?<name>\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
case validate_endpoint(actor) do
{:ok, address} ->
case fetch_webfinger_data(address) do
{:ok, %{"url" => url}} ->
{:ok, url}
else
e ->
Logger.debug("Couldn't finger #{actor}")
Logger.debug(inspect(e))
{:error, e}
{: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}

View file

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

View file

@ -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,10 +514,8 @@ 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 local do
with {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} <-
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} ->
@ -623,8 +525,14 @@ defmodule Mobilizon.Actors do
role: :administrator
})
end)
|> Repo.transaction() do
|> Repo.transaction()
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,8 +726,7 @@ 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{}
case %Member{}
|> Member.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :url, :actor_id, :parent_id]},
@ -829,7 +736,11 @@ defmodule Mobilizon.Actors do
# 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{}
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -119,18 +119,39 @@ 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
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(
@ -139,7 +160,7 @@ defmodule Mobilizon.Web.Email.Group do
instance: instance
)
Email.base_email(to: email, subject: subject)
Email.base_email(to: Keyword.get(options, :email), subject: subject)
|> assign(:locale, locale)
|> assign(:group, group)
|> assign(:role, member_role)
@ -149,11 +170,6 @@ defmodule Mobilizon.Web.Email.Group do
|> render(:group_deletion)
|> Email.Mailer.send_email_later()
:ok
else
# Skip if it's the author itself
{:member_not_author, _} ->
:ok
end
end
end

View file

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

View file

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

View file

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