mobilizon.chapril.org-mobil.../lib/mobilizon/actors/actors.ex
Thomas Citharel 49a5725da3
Improve and activate groups
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
2020-09-29 10:25:00 +02:00

1688 lines
48 KiB
Elixir

defmodule Mobilizon.Actors do
@moduledoc """
The Actors context.
"""
import Ecto.Query
import EctoEnum
import Geo.PostGIS, only: [st_dwithin_in_meters: 3]
import Mobilizon.Service.Guards
alias Ecto.Multi
alias Mobilizon.Actors.{Actor, Bot, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.{Crypto, Events}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Media.File
alias Mobilizon.Service.Workers
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users
alias Mobilizon.Web.Email.Group
alias Mobilizon.Web.Upload
require Logger
defenum(ActorType, :actor_type, [
:Person,
:Application,
:Group,
:Organization,
:Service
])
defenum(ActorOpenness, :actor_openness, [
:invite_only,
:moderated,
:open
])
defenum(ActorVisibility, :actor_visibility, [
:public,
:unlisted,
# Probably unused
:restricted,
:private
])
defenum(MemberRole, :member_role, [
:invited,
:not_approved,
:member,
:moderator,
:administrator,
:creator,
:rejected
])
@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.
"""
@spec get_actor(integer | String.t()) :: Actor.t() | nil
def get_actor(id), do: Repo.get(Actor, id)
@doc """
Gets a single actor.
Raises `Ecto.NoResultsError` if the actor does not exist.
"""
@spec get_actor!(integer | String.t()) :: Actor.t()
def get_actor!(id), do: Repo.get!(Actor, id)
@doc """
Gets an actor with preloaded relations.
"""
@spec get_actor_with_preload(integer | String.t()) :: Actor.t() | nil
def get_actor_with_preload(id, include_suspended \\ false) do
id
|> actor_with_preload_query(include_suspended)
|> Repo.one()
end
@doc """
Gets a local actor with preloaded relations.
"""
@spec get_local_actor_with_preload(integer | String.t()) :: Actor.t() | nil
def get_local_actor_with_preload(id) do
id
|> actor_with_preload_query()
|> filter_local()
|> Repo.one()
end
@spec get_remote_actor_with_preload(integer | String.t(), boolean()) :: Actor.t() | nil
def get_remote_actor_with_preload(id, include_suspended \\ false) do
id
|> actor_with_preload_query(include_suspended)
|> filter_external()
|> Repo.one()
end
@doc """
Gets an actor by its URL (ActivityPub ID). The `:preload` option allows to
preload the followers relation.
"""
@spec get_actor_by_url(String.t(), boolean) ::
{:ok, Actor.t()} | {:error, :actor_not_found}
def get_actor_by_url(url, preload \\ false)
def get_actor_by_url(nil, _preload), do: {:error, :actor_not_found}
def get_actor_by_url(url, preload) do
case Repo.get_by(Actor, url: url) do
nil ->
{:error, :actor_not_found}
actor ->
{:ok, preload_followers(actor, preload)}
end
end
@doc """
New function to replace `Mobilizon.Actors.get_actor_by_url/1` with
better signature
"""
@spec get_actor_by_url_2(String.t(), boolean) :: Actor.t() | nil
def get_actor_by_url_2(url, preload \\ false) do
Actor
|> Repo.get_by(url: url)
|> preload_followers(preload)
end
@doc """
Gets an actor by its URL (ActivityPub ID). The `:preload` option allows to
preload the followers relation.
Raises `Ecto.NoResultsError` if the actor does not exist.
"""
@spec get_actor_by_url!(String.t(), boolean) :: Actor.t()
def get_actor_by_url!(url, preload \\ false) do
Actor
|> Repo.get_by!(url: url)
|> preload_followers(preload)
end
@doc """
Gets an actor by name.
"""
@spec get_actor_by_name(String.t(), atom | nil) :: Actor.t() | nil
def get_actor_by_name(name, type \\ nil) do
query = from(a in Actor)
query
|> filter_by_type(type)
|> filter_by_name(String.split(name, "@"))
|> Repo.one()
end
@doc """
Gets a local actor by its preferred username.
"""
@spec get_local_actor_by_name(String.t()) :: Actor.t() | nil
def get_local_actor_by_name(name) do
query = from(a in Actor)
query
|> filter_by_name([name])
|> Repo.one()
end
@doc """
Gets a local actor by its preferred username and preloaded relations
(organized events, followers and followings).
"""
@spec get_local_actor_by_name_with_preload(String.t()) :: Actor.t() | nil
def get_local_actor_by_name_with_preload(name) do
name
|> get_local_actor_by_name()
|> Repo.preload([:organized_events, :followers, :followings])
end
@doc """
Gets an actor by name and preloads the organized events.
"""
@spec get_actor_by_name_with_preload(String.t(), atom() | nil) :: Actor.t() | nil
def get_actor_by_name_with_preload(name, type \\ nil) do
name
|> get_actor_by_name(type)
|> Repo.preload([:organized_events, :user, :physical_address])
end
@doc """
Creates an actor.
"""
@spec create_actor(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def create_actor(attrs \\ %{}) do
type = Map.get(attrs, :type, :Person)
case type do
:Person ->
%Actor{}
|> Actor.changeset(attrs)
|> Repo.insert()
:Group ->
create_group(attrs)
end
end
@doc """
Creates a new person actor.
"""
@spec new_person(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def new_person(args, default_actor \\ false) do
args = Map.put(args, :keys, Crypto.generate_rsa_2048_private_key())
with {:ok, %Actor{id: person_id} = person} <-
%Actor{}
|> Actor.registration_changeset(args)
|> Repo.insert() do
Events.create_feed_token(%{user_id: args.user_id, actor_id: person.id})
if default_actor do
user = Users.get_user!(args.user_id)
Users.update_user(user, %{default_actor_id: person_id})
end
{:ok, person}
end
end
@doc """
Updates an actor.
"""
@spec update_actor(Actor.t(), map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def update_actor(%Actor{} = actor, attrs) do
actor
|> Repo.preload([:physical_address])
|> Actor.update_changeset(attrs)
|> delete_files_if_media_changed()
|> Repo.update()
end
@doc """
Upserts an actor.
Conflicts on actor's URL/AP ID, replaces keys, avatar and banner, name and summary.
"""
@spec upsert_actor(map, boolean) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def upsert_actor(
data,
preload \\ false
) do
# data =
# data
# |> Map.put(:avatar, transform_media_file(data.avatar))
# |> Map.put(:banner, transform_media_file(data.banner))
insert =
data
|> Actor.remote_actor_creation_changeset()
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :url, :preferred_username, :domain]},
conflict_target: [:url]
)
case insert do
{:ok, actor} ->
actor = if preload, do: Repo.preload(actor, [:followers]), else: actor
{:ok, actor}
error ->
Logger.debug(inspect(error))
{:error, error}
end
end
# defp transform_media_file(nil), do: nil
# defp transform_media_file(file) do
# file = for({key, val} <- file, into: %{}, do: {String.to_atom(key), val})
# if is_nil(file) do
# nil
# else
# struct(Mobilizon.Media.File, file)
# end
# end
@delete_actor_default_options [reserve_username: true, suspension: false]
def delete_actor(%Actor{} = actor, options \\ @delete_actor_default_options) do
delete_actor_options = Keyword.merge(@delete_actor_default_options, options)
Workers.Background.enqueue("delete_actor", %{
"actor_id" => actor.id,
"author_id" => Keyword.get(delete_actor_options, :author_id),
"reserve_username" => Keyword.get(delete_actor_options, :reserve_username, true),
"suspension" => Keyword.get(delete_actor_options, :suspension, false)
})
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 =
if type == :Group do
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)
else
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")
Logger.error(inspect(error, pretty: true))
{:error, error}
err ->
Logger.error("Unknown error while deleting actor")
Logger.error(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
|> Actor.changeset(%{keys: Crypto.generate_rsa_2048_private_key()})
|> Repo.update()
end
@doc """
Returns the list of actors.
"""
@spec list_actors(
atom(),
String.t(),
String.t(),
String.t(),
boolean,
boolean,
integer,
integer
) :: Page.t()
def list_actors(
type \\ :Person,
preferred_username \\ "",
name \\ "",
domain \\ "",
local \\ true,
suspended \\ false,
page \\ nil,
limit \\ nil
)
def list_actors(
:Person,
preferred_username,
name,
domain,
local,
suspended,
page,
limit
) do
person_query()
|> filter_actors(preferred_username, name, domain, local, suspended)
|> Page.build_page(page, limit)
end
def list_actors(
:Group,
preferred_username,
name,
domain,
local,
suspended,
page,
limit
) do
group_query()
|> filter_actors(preferred_username, name, domain, local, suspended)
|> Page.build_page(page, limit)
end
@spec filter_actors(Ecto.Query.t(), String.t(), String.t(), String.t(), boolean(), boolean()) ::
Ecto.Query.t()
defp filter_actors(
query,
preferred_username,
name,
domain,
local,
suspended
) do
query
|> filter_suspended(suspended)
|> filter_preferred_username(preferred_username)
|> filter_name(name)
|> filter_domain(domain)
|> filter_remote(local)
end
defp filter_preferred_username(query, ""), do: query
defp filter_preferred_username(query, preferred_username),
do: where(query, [a], ilike(a.preferred_username, ^"%#{preferred_username}%"))
defp filter_name(query, ""), do: query
defp filter_name(query, name),
do: where(query, [a], ilike(a.name, ^"%#{name}%"))
defp filter_domain(query, ""), do: query
defp filter_domain(query, domain),
do: where(query, [a], ilike(a.domain, ^"%#{domain}%"))
defp filter_remote(query, true), do: filter_local(query)
defp filter_remote(query, false), do: filter_external(query)
@spec filter_suspended(Ecto.Query.t(), boolean()) :: Ecto.Query.t()
defp filter_suspended(query, true), do: where(query, [a], a.suspended)
defp filter_suspended(query, false), do: where(query, [a], not a.suspended)
@doc """
Returns the list of local actors by their username.
"""
@spec list_local_actor_by_username(String.t()) :: [Actor.t()]
def list_local_actor_by_username(username) do
username
|> actor_by_username_query()
|> filter_local()
|> Repo.all()
|> Repo.preload(:organized_events)
end
@doc """
Builds a page struct for actors by their name or displayed name.
"""
@spec build_actors_by_username_or_name_page(
map(),
[ActorType.t()],
integer | nil,
integer | nil
) :: Page.t()
def build_actors_by_username_or_name_page(
%{term: term} = args,
types,
page \\ nil,
limit \\ nil
) do
Actor
|> actor_by_username_or_name_query(term)
|> actors_for_location(args)
|> filter_by_types(types)
|> filter_suspended(false)
|> Page.build_page(page, limit)
end
@doc """
Gets a group by its title.
"""
@spec get_group_by_title(String.t()) :: Actor.t() | nil
def get_group_by_title(title) do
group_query()
|> filter_by_name(String.split(title, "@"))
|> Repo.one()
end
@doc """
Gets a local group by its title.
"""
@spec get_local_group_by_title(String.t()) :: Actor.t() | nil
def get_local_group_by_title(title) do
group_query()
|> filter_by_name([title])
|> Repo.one()
end
@doc """
Gets a group by its actor id.
"""
@spec get_group_by_actor_id(integer | String.t()) ::
{:ok, Actor.t()} | {:error, :group_not_found}
def get_group_by_actor_id(actor_id) do
case Repo.get_by(Actor, id: actor_id, type: :Group) do
nil ->
{:error, :group_not_found}
actor ->
{:ok, actor}
end
end
@spec get_local_group_by_url(String.t()) :: Actor.t()
def get_local_group_by_url(group_url) do
group_query()
|> where([q], q.url == ^group_url and is_nil(q.domain))
|> Repo.one()
end
@spec get_group_by_members_url(String.t()) :: Actor.t()
def get_group_by_members_url(members_url) do
group_query()
|> where([q], q.members_url == ^members_url)
|> Repo.one()
end
@spec get_actor_by_followers_url(String.t()) :: Actor.t()
def get_actor_by_followers_url(followers_url) do
Actor
|> where([q], q.followers_url == ^followers_url)
|> Repo.one()
end
@doc """
Creates a group.
If the group is local, creates an admin actor as well from `creator_actor_id`.
"""
@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}} <-
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}
end
else
%Actor{}
|> Actor.group_creation_changeset(attrs)
|> Repo.insert()
end
end
@doc """
Deletes a group.
"""
def delete_group!(%Actor{type: :Group} = group), do: Repo.delete!(group)
@doc """
Lists the groups.
"""
@spec list_groups(integer | nil, integer | nil) :: Page.t()
def list_groups(page \\ nil, limit \\ nil) do
groups_query()
|> Page.build_page(page, limit)
end
@doc """
Lists the groups.
"""
@spec list_groups_for_stream :: Enum.t()
def list_groups_for_stream do
groups_query()
|> Repo.stream()
end
@doc """
Returns the list of groups an actor is member of.
"""
@spec list_groups_member_of(Actor.t()) :: [Actor.t()]
def list_groups_member_of(%Actor{id: actor_id}) do
actor_id
|> groups_member_of_query()
|> Repo.all()
end
@doc """
Gets a single member.
"""
@spec get_member(integer | String.t()) :: {:ok, Member.t()} | nil
def get_member(id) do
Member
|> Repo.get(id)
|> Repo.preload([:actor, :parent, :invited_by])
end
@doc """
Gets a single member.
Raises `Ecto.NoResultsError` if the member does not exist.
"""
@spec get_member!(integer | String.t()) :: Member.t()
def get_member!(id), do: Repo.get!(Member, id)
@doc """
Gets a single member of an actor (for example a group).
"""
@spec get_member(integer | String.t(), integer | String.t()) ::
{:ok, Member.t()} | {:error, :member_not_found}
def get_member(actor_id, parent_id) do
case Repo.get_by(Member, actor_id: actor_id, parent_id: parent_id) do
nil ->
{:error, :member_not_found}
member ->
{:ok, member}
end
end
@spec get_member(integer | String.t(), integer | String.t(), list()) ::
{:ok, Member.t()} | {:error, :member_not_found}
def get_member(actor_id, parent_id, roles) do
case Member
|> where([m], m.actor_id == ^actor_id and m.parent_id == ^parent_id and m.role in ^roles)
|> Repo.one() do
nil ->
{:error, :member_not_found}
member ->
{:ok, member}
end
end
@spec is_member?(integer | String.t(), integer | String.t()) :: boolean()
def is_member?(actor_id, parent_id) do
match?(
{:ok, %Member{}},
get_member(actor_id, parent_id, @member_roles)
)
end
@doc """
Gets a single member of an actor (for example a group).
"""
@spec get_member_by_url(String.t()) :: Member.t() | nil
def get_member_by_url(url) do
Member
|> where(url: ^url)
|> preload([:actor, :parent, :invited_by])
|> Repo.one()
end
@spec get_single_group_member_actor(integer() | String.t()) :: Actor.t() | nil
def get_single_group_member_actor(group_id) do
Member
|> where(
[m],
m.parent_id == ^group_id and m.role in [^:member, ^:moderator, ^:administrator, ^:creator]
)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], is_nil(a.domain))
|> limit(1)
|> select([_m, a], a)
|> Repo.one()
end
@doc """
Creates a member.
"""
@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])}
end
end
@doc """
Updates a member.
"""
@spec update_member(Member.t(), map) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()}
def update_member(%Member{} = member, attrs) do
member
|> Member.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a member.
"""
@spec delete_member(Member.t()) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()}
def delete_member(%Member{} = member), do: Repo.delete(member)
@doc """
Returns the list of memberships for an user.
Default behaviour is to not return :not_approved memberships
## Examples
iex> list_event_participations_for_user(5)
%Page{total: 3, elements: [%Participant{}, ...]}
"""
@spec list_memberships_for_user(
integer,
integer | nil,
integer | nil
) :: Page.t()
def list_memberships_for_user(user_id, page, limit) do
user_id
|> list_members_for_user_query()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of members for an actor.
"""
@spec list_members_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_members_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> members_for_actor_query()
|> Page.build_page(page, limit)
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,
page \\ nil,
limit \\ nil
) do
group_id
|> group_internal_member_query()
|> Page.build_page(page, limit)
end
@spec list_remote_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_remote_members_for_group(
%Actor{id: group_id, type: :Group} = _group,
page \\ nil,
limit \\ nil
) do
group_id
|> group_external_member_query()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of members for a group.
"""
@spec list_members_for_group(Actor.t(), list(atom()), integer | nil, integer | nil) :: Page.t()
def list_members_for_group(
%Actor{id: group_id, type: :Group},
roles \\ [],
page \\ nil,
limit \\ nil
) do
group_id
|> members_for_group_query()
|> filter_member_role(roles)
|> Page.build_page(page, limit)
end
@spec list_external_actors_members_for_group(Actor.t()) :: list(Actor.t())
def list_external_actors_members_for_group(%Actor{id: group_id, type: :Group}) do
group_id
|> group_external_member_actor_query()
|> Repo.all()
end
@doc """
Returns the list of administrator members for a group.
"""
@spec list_administrator_members_for_group(integer | String.t(), integer | nil, integer | nil) ::
Page.t()
def list_administrator_members_for_group(id, page \\ nil, limit \\ nil) do
id
|> administrator_members_for_group_query()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of all group ids where the actor_id is the last administrator.
"""
@spec list_group_ids_where_last_administrator(integer | String.t()) :: [integer]
def list_group_ids_where_last_administrator(actor_id) do
actor_id
|> group_ids_where_last_administrator_query()
|> Repo.all()
end
@doc """
Returns whether the member is the last administrator for a group
"""
@spec is_only_administrator?(integer | String.t(), integer | String.t()) :: boolean()
def is_only_administrator?(member_id, group_id) do
Member
|> where(
[m],
m.parent_id == ^group_id and m.id != ^member_id and m.role in ^@administrator_roles
)
|> Repo.aggregate(:count)
|> (&(&1 == 0)).()
end
@doc """
Gets a single bot.
Raises `Ecto.NoResultsError` if the bot does not exist.
"""
def get_bot!(id), do: Repo.get!(Bot, id)
@doc """
Gets the bot associated to an actor.
"""
@spec get_bot_for_actor(Actor.t()) :: Bot.t()
def get_bot_for_actor(%Actor{id: actor_id}) do
Repo.get_by!(Bot, actor_id: actor_id)
end
@doc """
Creates a bot.
"""
@spec create_bot(map) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()}
def create_bot(attrs \\ %{}) do
%Bot{}
|> Bot.changeset(attrs)
|> Repo.insert()
end
@doc """
Registers a new bot.
"""
@spec register_bot(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def register_bot(%{name: name, summary: summary}) do
attrs = %{
preferred_username: name,
domain: nil,
keys: Crypto.generate_rsa_2048_private_key(),
summary: summary,
type: :Service
}
%Actor{}
|> Actor.registration_changeset(attrs)
|> Repo.insert()
end
@spec get_or_create_internal_actor(String.t()) :: {:ok, Actor.t()}
def get_or_create_internal_actor(username) do
case username |> Actor.build_url(:page) |> get_actor_by_url() do
{:ok, %Actor{} = actor} ->
{:ok, actor}
_ ->
case username do
"anonymous" ->
Actor.build_anonymous_actor_creation_attrs()
|> Repo.insert()
"relay" ->
Actor.build_relay_creation_attrs()
|> Repo.insert()
end
end
end
@doc """
Updates a bot.
"""
@spec update_bot(Bot.t(), map) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()}
def update_bot(%Bot{} = bot, attrs) do
bot
|> Bot.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a bot.
"""
@spec delete_bot(Bot.t()) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()}
def delete_bot(%Bot{} = bot), do: Repo.delete(bot)
@doc """
Returns the list of bots.
"""
@spec list_bots :: [Bot.t()]
def list_bots, do: Repo.all(Bot)
@doc """
Gets a single follower.
Raises `Ecto.NoResultsError` if the follower does not exist.
"""
@spec get_follower!(integer | String.t()) :: Follower.t()
def get_follower!(id) do
Follower
|> Repo.get!(id)
|> Repo.preload([:actor, :target_actor])
end
@doc """
Get a follower by the url.
"""
@spec get_follower_by_url(String.t()) :: Follower.t()
def get_follower_by_url(url) do
url
|> follower_by_url()
|> Repo.one()
end
@doc """
Gets a follower by the followed actor and following actor
"""
@spec get_follower_by_followed_and_following(Actor.t(), Actor.t()) :: Follower.t() | nil
def get_follower_by_followed_and_following(%Actor{id: followed_id}, %Actor{id: following_id}) do
followed_id
|> follower_by_followed_and_following_query(following_id)
|> Repo.one()
end
@doc """
Creates a follower.
"""
@spec create_follower(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])}
end
end
@doc """
Updates a follower.
"""
@spec update_follower(Follower.t(), map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()}
def update_follower(%Follower{} = follower, attrs) do
with {:ok, %Follower{} = follower} <-
follower
|> Follower.changeset(attrs)
|> Repo.update() do
{:ok, Repo.preload(follower, [:actor, :target_actor])}
end
end
@doc """
Deletes a follower.
"""
@spec delete_follower(Follower.t()) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()}
def delete_follower(%Follower{} = follower), do: Repo.delete(follower)
@doc """
Deletes a follower by followed and following actors.
"""
@spec delete_follower_by_followed_and_following(Actor.t(), Actor.t()) ::
{:ok, Follower.t()} | {:error, Ecto.Changeset.t()}
def delete_follower_by_followed_and_following(%Actor{} = followed, %Actor{} = following) do
followed
|> get_follower_by_followed_and_following(following)
|> Repo.delete()
end
@doc """
Returns the list of followers for an actor.
If actor A and C both follow actor B, actor B's followers are A and C.
"""
@spec list_followers_actors_for_actor(Actor.t()) :: [Actor.t()]
def list_followers_actors_for_actor(%Actor{id: actor_id}) do
actor_id
|> follower_actors_for_actor_query()
|> Repo.all()
end
@doc """
Returns the list of external followers for an actor.
"""
@spec list_external_followers_for_actor(Actor.t()) :: [Follower.t()]
def list_external_followers_for_actor(%Actor{id: actor_id}) do
actor_id
|> list_external_follower_actors_for_actor_query()
|> Repo.all()
end
@doc """
Returns the paginated list of external followers for an actor.
"""
@spec list_external_followers_for_actor_paginated(Actor.t(), integer | nil, integer | nil) ::
Page.t()
def list_external_followers_for_actor_paginated(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> list_external_followers_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
Build a page struct for followers of an actor.
"""
@spec build_followers_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def build_followers_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> follower_actors_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of followings for an actor.
If actor A follows actor B and C, actor A's followings are B and C.
"""
@spec list_followings_for_actor(Actor.t()) :: [Follower.t()]
def list_followings_for_actor(%Actor{id: actor_id}) do
actor_id
|> followings_actors_for_actor_query()
|> Repo.all()
end
@doc """
Returns the list of external followings for an actor.
"""
@spec list_external_followings_for_actor_paginated(Actor.t(), integer | nil, integer | nil) ::
Page.t()
def list_external_followings_for_actor_paginated(
%Actor{id: actor_id},
page \\ nil,
limit \\ nil
) do
actor_id
|> list_external_followings_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
Build a page struct for followings of an actor.
"""
@spec build_followings_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def build_followings_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> followings_actors_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
Makes an actor following another actor.
"""
@spec follow(Actor.t(), Actor.t(), String.t() | nil, boolean | nil) ::
{:ok, Follower.t()} | {:error, atom, String.t()}
def follow(%Actor{} = followed, %Actor{} = follower, url \\ nil, approved \\ true) do
with {:suspended, false} <- {:suspended, followed.suspended},
# Check if followed has blocked follower
{:already_following, nil} <- {:already_following, is_following(follower, followed)} do
Logger.info(
"Making #{Actor.preferred_username_and_domain(follower)} follow #{
Actor.preferred_username_and_domain(followed)
} " <>
"(approved: #{approved})"
)
create_follower(%{
"actor_id" => follower.id,
"target_actor_id" => followed.id,
"approved" => approved,
"url" => url
})
else
{:already_following, %Follower{}} ->
{:error, :already_following,
"Could not follow actor: you are already following #{
Actor.preferred_username_and_domain(followed)
}"}
{:suspended, _} ->
{:error, :suspended,
"Could not follow actor: #{Actor.preferred_username_and_domain(followed)} has been suspended"}
end
end
@doc """
Unfollows an actor (removes a Follower record).
"""
@spec unfollow(Actor.t(), Actor.t()) ::
{:ok, Follower.t()} | {:error, Ecto.Changeset.t() | String.t()}
def unfollow(%Actor{} = followed, %Actor{} = follower) do
case {:already_following, is_following(follower, followed)} do
{:already_following, %Follower{} = follow} ->
delete_follower(follow)
{:already_following, nil} ->
{:error, "Could not unfollow actor: you are not following #{followed.preferred_username}"}
end
end
@doc """
Checks whether an actor is following another actor.
"""
@spec is_following(Actor.t(), Actor.t()) :: Follower.t() | nil
def is_following(%Actor{} = follower_actor, %Actor{} = followed_actor) do
get_follower_by_followed_and_following(followed_actor, follower_actor)
end
@doc """
Whether the actor needs to be updated.
Local actors obviously don't need to be updated
"""
@spec needs_update?(Actor.t()) :: boolean
def needs_update?(%Actor{domain: nil}), do: false
def needs_update?(%Actor{last_refreshed_at: nil, domain: domain}) when not is_nil(domain),
do: true
def needs_update?(%Actor{domain: domain} = actor) when not is_nil(domain) do
DateTime.diff(DateTime.utc_now(), actor.last_refreshed_at) >=
Application.get_env(:mobilizon, :activitypub)[:actor_stale_period]
end
def needs_update?(_), do: true
@spec should_rotate_actor_key(Actor.t()) :: boolean
def should_rotate_actor_key(%Actor{id: actor_id}) do
with {:ok, value} when is_boolean(value) <- Cachex.exists?(:actor_key_rotation, actor_id) do
value
end
end
@spec schedule_key_rotation(Actor.t(), integer()) :: nil
def schedule_key_rotation(%Actor{id: actor_id} = actor, delay) do
Cachex.put(:actor_key_rotation, actor_id, true)
Workers.Background.enqueue("actor_key_rotation", %{"actor_id" => actor.id}, schedule_in: delay)
: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)
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)
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 ->
if Map.has_key?(changes, key) do
with %Ecto.Changeset{changes: %{url: new_url}} <- changes[key],
%{url: old_url} <- data |> Map.from_struct() |> Map.get(key),
false <- new_url == old_url do
Upload.remove(old_url)
end
end
end)
changeset
end
@spec actor_with_preload_query(integer | String.t(), boolean()) :: Ecto.Query.t()
defp actor_with_preload_query(actor_id, include_suspended \\ false)
defp actor_with_preload_query(actor_id, false) do
actor_id
|> actor_with_preload_query(true)
|> where([a], not a.suspended)
end
defp actor_with_preload_query(actor_id, true) do
Actor
|> where([a], a.id == ^actor_id)
|> preload([a], [:organized_events, :followers, :followings])
end
@spec actor_by_username_query(String.t()) :: Ecto.Query.t()
defp actor_by_username_query(username) do
from(
a in Actor,
where:
fragment(
"f_unaccent(?) <% f_unaccent(?) or f_unaccent(coalesce(?, '')) <% f_unaccent(?)",
a.preferred_username,
^username,
a.name,
^username
),
order_by:
fragment(
"word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc",
a.preferred_username,
^username,
a.name,
^username
)
)
end
@spec actor_by_username_or_name_query(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
defp actor_by_username_or_name_query(query, ""), do: query
defp actor_by_username_or_name_query(query, username) do
query
|> where(
[a],
fragment(
"f_unaccent(?) %> f_unaccent(?) or f_unaccent(coalesce(?, '')) %> f_unaccent(?)",
a.preferred_username,
^username,
a.name,
^username
)
)
|> order_by(
[a],
fragment(
"word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc",
a.preferred_username,
^username,
a.name,
^username
)
)
end
@spec actors_for_location(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp actors_for_location(query, %{radius: radius}) when is_nil(radius),
do: query
defp actors_for_location(query, %{location: location, radius: radius})
when is_valid_string?(location) and not is_nil(radius) do
with {lon, lat} <- Geohax.decode(location),
point <- Geo.WKT.decode!("SRID=4326;POINT(#{lon} #{lat})") do
query
|> join(:inner, [q], a in Address, on: a.id == q.physical_address_id, as: :address)
|> where(
[q],
st_dwithin_in_meters(^point, as(:address).geom, ^(radius * 1000))
)
else
_ -> query
end
end
defp actors_for_location(query, _args), do: query
@spec person_query :: Ecto.Query.t()
defp person_query do
from(a in Actor, where: a.type == ^:Person)
end
@spec group_query :: Ecto.Query.t()
defp group_query do
from(a in Actor, where: a.type == ^:Group)
end
@spec groups_member_of_query(integer | String.t()) :: Ecto.Query.t()
defp groups_member_of_query(actor_id) do
from(
a in Actor,
join: m in Member,
on: a.id == m.parent_id,
where: m.actor_id == ^actor_id
)
end
@spec groups_query :: Ecto.Query.t()
defp groups_query do
from(
a in Actor,
where: a.type == ^:Group,
where: a.visibility == ^:public
)
end
@spec list_members_for_user_query(integer()) :: Ecto.Query.t()
defp list_members_for_user_query(user_id) do
from(
m in Member,
join: a in Actor,
on: m.actor_id == a.id,
where: a.user_id == ^user_id and m.role != ^:not_approved,
preload: [:parent, :actor, :invited_by]
)
end
@spec members_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp members_for_actor_query(actor_id) do
from(
m in Member,
where: m.actor_id == ^actor_id,
preload: [:parent, :invited_by]
)
end
@spec members_for_group_query(integer | String.t()) :: Ecto.Query.t()
defp members_for_group_query(group_id) do
from(
m in Member,
where: m.parent_id == ^group_id,
preload: [:parent, :actor]
)
end
@spec group_external_member_actor_query(integer()) :: Ecto.Query.t()
defp group_external_member_actor_query(group_id) do
Member
|> where([m], m.parent_id == ^group_id)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], not is_nil(a.domain))
|> select([_m, a], a)
end
@spec group_external_member_query(integer()) :: Ecto.Query.t()
defp group_external_member_query(group_id) do
Member
|> where([m], m.parent_id == ^group_id)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], not is_nil(a.domain))
|> preload([m], [:parent, :actor])
|> select([m, _a], m)
end
@spec group_internal_member_query(integer()) :: Ecto.Query.t()
defp group_internal_member_query(group_id) do
Member
|> where([m], m.parent_id == ^group_id)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], is_nil(a.domain))
|> preload([m], [:parent, :actor])
|> select([m, _a], m)
end
@spec filter_member_role(Ecto.Query.t(), list(atom()) | atom()) :: Ecto.Query.t()
def filter_member_role(query, []), do: query
def filter_member_role(query, roles) when is_list(roles) do
where(query, [m], m.role in ^roles)
end
def filter_member_role(query, role) when is_atom(role) do
from(m in query, where: m.role == ^role)
end
@spec administrator_members_for_group_query(integer | String.t()) :: Ecto.Query.t()
defp administrator_members_for_group_query(group_id) do
from(
m in Member,
where: m.parent_id == ^group_id and m.role in ^@administrator_roles,
preload: [:actor]
)
end
@spec administrator_members_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp administrator_members_for_actor_query(actor_id) do
from(
m in Member,
where: m.actor_id == ^actor_id and m.role in ^@administrator_roles,
select: m.parent_id
)
end
@spec group_ids_where_last_administrator_query(integer | String.t()) :: Ecto.Query.t()
defp group_ids_where_last_administrator_query(actor_id) do
from(
m in Member,
where: m.role in ^@administrator_roles,
join: m2 in subquery(administrator_members_for_actor_query(actor_id)),
on: m.parent_id == m2.parent_id,
group_by: m.parent_id,
select: m.parent_id,
having: count(m.actor_id) == 1
)
end
@spec follower_by_url(String.t()) :: Ecto.Query.t()
defp follower_by_url(url) do
from(
f in Follower,
where: f.url == ^url,
preload: [:actor, :target_actor]
)
end
@spec follower_by_followed_and_following_query(integer | String.t(), integer | String.t()) ::
Ecto.Query.t()
defp follower_by_followed_and_following_query(followed_id, follower_id) do
from(
f in Follower,
where: f.target_actor_id == ^followed_id and f.actor_id == ^follower_id,
preload: [:actor, :target_actor]
)
end
@spec follower_actors_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp follower_actors_for_actor_query(actor_id) do
from(
a in Actor,
join: f in Follower,
on: a.id == f.actor_id,
where: f.target_actor_id == ^actor_id and f.approved == true
)
end
@spec follower_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp follower_for_actor_query(actor_id) do
from(
f in Follower,
join: a in Actor,
on: a.id == f.actor_id,
where: f.target_actor_id == ^actor_id
)
end
@spec followings_actors_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followings_actors_for_actor_query(actor_id) do
from(
a in Actor,
join: f in Follower,
on: a.id == f.target_actor_id,
where: f.actor_id == ^actor_id
)
end
@spec followings_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followings_for_actor_query(actor_id) do
from(
f in Follower,
join: a in Actor,
on: a.id == f.target_actor_id,
where: f.actor_id == ^actor_id
)
end
@spec list_external_follower_actors_for_actor_query(integer) :: Ecto.Query.t()
defp list_external_follower_actors_for_actor_query(actor_id) do
actor_id
|> follower_actors_for_actor_query()
|> filter_external()
end
@spec list_external_followers_for_actor_query(integer) :: Ecto.Query.t()
defp list_external_followers_for_actor_query(actor_id) do
actor_id
|> follower_for_actor_query()
|> filter_follower_actors_external()
end
@spec list_external_followings_for_actor_query(integer) :: Ecto.Query.t()
defp list_external_followings_for_actor_query(actor_id) do
actor_id
|> followings_for_actor_query()
|> filter_follower_actors_external()
|> order_by(desc: :updated_at)
end
@spec filter_local(Ecto.Query.t()) :: Ecto.Query.t()
defp filter_local(query) do
from(a in query, where: is_nil(a.domain))
end
@spec filter_external(Ecto.Query.t()) :: Ecto.Query.t()
defp filter_external(query) do
from(a in query, where: not is_nil(a.domain))
end
@spec filter_follower_actors_external(Ecto.Query.t()) :: Ecto.Query.t()
defp filter_follower_actors_external(query) do
query
|> where([_f, a], not is_nil(a.domain))
|> preload([f, a], [:target_actor, :actor])
end
@spec filter_by_type(Ecto.Query.t(), ActorType.t()) :: Ecto.Query.t()
defp filter_by_type(query, type)
when type in [:Person, :Group, :Application, :Service, :Organisation] do
from(a in query, where: a.type == ^type)
end
defp filter_by_type(query, _type), do: query
@spec filter_by_types(Ecto.Query.t(), [ActorType.t()]) :: Ecto.Query.t()
defp filter_by_types(query, types) do
from(a in query, where: a.type in ^types)
end
@spec filter_by_name(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))
end
defp filter_by_name(query, [name, domain]) do
from(a in query, where: a.preferred_username == ^name and a.domain == ^domain)
end
@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
])
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_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
defp accumulate_paginated_elements(
%Actor{} = actor,
method,
elements \\ [],
page \\ 1,
limit \\ 10
) do
Logger.debug("accumulate_paginated_elements")
%Page{total: total, elements: new_elements} = page = 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