diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex index e83d9a308..7820af547 100644 --- a/lib/mobilizon/actors/actor.ex +++ b/lib/mobilizon/actors/actor.ex @@ -1,44 +1,17 @@ -import EctoEnum - -defenum(Mobilizon.Actors.ActorTypeEnum, :actor_type, [ - :Person, - :Application, - :Group, - :Organization, - :Service -]) - -defenum(Mobilizon.Actors.ActorOpennessEnum, :actor_openness, [ - :invite_only, - :moderated, - :open -]) - -defenum(Mobilizon.Actors.ActorVisibilityEnum, :actor_visibility_type, [ - :public, - :unlisted, - # Probably unused - :restricted, - :private -]) - defmodule Mobilizon.Actors.Actor do @moduledoc """ - Represents an actor (local and remote actors) + Represents an actor (local and remote). """ use Ecto.Schema import Ecto.Changeset - import Ecto.Query - alias Mobilizon.Actors - alias Mobilizon.Actors.{Actor, Follower, Member} - alias Mobilizon.Config + alias Mobilizon.{Actors, Config, Crypto} + alias Mobilizon.Actors.{Actor, ActorOpenness, ActorType, ActorVisibility, Follower, Member} alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Media.File alias Mobilizon.Reports.{Report, Note} - alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Users.User alias MobilizonWeb.Router.Helpers, as: Routes @@ -46,7 +19,97 @@ defmodule Mobilizon.Actors.Actor do require Logger - # @type t :: %Actor{description: String.t, id: integer(), inserted_at: DateTime.t, updated_at: DateTime.t, display_name: String.t, domain: String.t, keys: String.t, suspended: boolean(), url: String.t, username: String.t, organized_events: list(), groups: list(), group_request: list(), user: User.t, field: ActorTypeEnum.t} + @type t :: %__MODULE__{ + url: String.t(), + outbox_url: String.t(), + inbox_url: String.t(), + following_url: String.t(), + followers_url: String.t(), + shared_inbox_url: String.t(), + type: ActorType.t(), + name: String.t(), + domain: String.t(), + summary: String.t(), + preferred_username: String.t(), + keys: String.t(), + manually_approves_followers: boolean, + openness: ActorOpenness.t(), + visibility: ActorVisibility.t(), + suspended: boolean, + avatar: File.t(), + banner: File.t(), + user: User.t(), + followers: [Follower.t()], + followings: [Follower.t()], + organized_events: [Event.t()], + feed_tokens: [FeedToken.t()], + created_reports: [Report.t()], + subject_reports: [Report.t()], + report_notes: [Note.t()], + memberships: [Actor.t()] + } + + @required_attrs [:preferred_username, :keys, :suspended, :url] + @optional_attrs [ + :outbox_url, + :inbox_url, + :shared_inbox_url, + :following_url, + :followers_url, + :type, + :name, + :domain, + :summary, + :manually_approves_followers, + :user_id + ] + @attrs @required_attrs ++ @optional_attrs + + @update_required_attrs @required_attrs + @update_optional_attrs [:name, :summary, :manually_approves_followers, :user_id] + @update_attrs @update_required_attrs ++ @update_optional_attrs + + @registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type] + @registration_optional_attrs [:domain, :name, :summary, :user_id] + @registration_attrs @registration_required_attrs ++ @registration_optional_attrs + + @remote_actor_creation_required_attrs [ + :url, + :inbox_url, + :type, + :domain, + :preferred_username, + :keys + ] + @remote_actor_creation_optional_attrs [ + :outbox_url, + :shared_inbox_url, + :following_url, + :followers_url, + :name, + :summary, + :manually_approves_followers + ] + @remote_actor_creation_attrs @remote_actor_creation_required_attrs ++ + @remote_actor_creation_optional_attrs + + @relay_creation_attrs [ + :type, + :name, + :summary, + :url, + :keys, + :preferred_username, + :domain, + :inbox_url, + :followers_url, + :following_url, + :shared_inbox_url + ] + + @group_creation_required_attrs [:url, :outbox_url, :inbox_url, :type, :preferred_username] + @group_creation_optional_attrs [:shared_inbox_url, :name, :domain, :summary] + @group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs schema "actors" do field(:url, :string) @@ -55,187 +118,156 @@ defmodule Mobilizon.Actors.Actor do field(:following_url, :string) field(:followers_url, :string) field(:shared_inbox_url, :string) - field(:type, Mobilizon.Actors.ActorTypeEnum, default: :Person) + field(:type, ActorType, default: :Person) field(:name, :string) field(:domain, :string, default: nil) field(:summary, :string) field(:preferred_username, :string) field(:keys, :string) field(:manually_approves_followers, :boolean, default: false) - field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated) - field(:visibility, Mobilizon.Actors.ActorVisibilityEnum, default: :private) + field(:openness, ActorOpenness, default: :moderated) + field(:visibility, ActorVisibility, default: :private) field(:suspended, :boolean, default: false) - # field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated) + + embeds_one(:avatar, File, on_replace: :update) + embeds_one(:banner, File, on_replace: :update) + belongs_to(:user, User) has_many(:followers, Follower, foreign_key: :target_actor_id) has_many(:followings, Follower, foreign_key: :actor_id) has_many(:organized_events, Event, foreign_key: :organizer_actor_id) - many_to_many(:memberships, Actor, join_through: Member) - belongs_to(:user, User) has_many(:feed_tokens, FeedToken, foreign_key: :actor_id) - embeds_one(:avatar, File, on_replace: :update) - embeds_one(:banner, File, on_replace: :update) has_many(:created_reports, Report, foreign_key: :reporter_id) has_many(:subject_reports, Report, foreign_key: :reported_id) has_many(:report_notes, Note, foreign_key: :moderator_id) + many_to_many(:memberships, Actor, join_through: Member) timestamps() end + @doc """ + Checks whether actor visibility is public. + """ + @spec is_public_visibility(Actor.t()) :: boolean + def is_public_visibility(%Actor{visibility: visibility}) do + visibility in [:public, :unlisted] + end + + @doc """ + Returns the display name if available, or the preferred username + (with the eventual @domain suffix if it's a distant actor). + """ + @spec display_name(Actor.t()) :: String.t() + def display_name(%Actor{name: name} = actor) when name in [nil, ""] do + preferred_username_and_domain(actor) + end + + def display_name(%Actor{name: name}), do: name + + @doc """ + Returns display name and username. + """ + @spec display_name_and_username(Actor.t()) :: String.t() + def display_name_and_username(%Actor{name: name} = actor) when name in [nil, ""] do + preferred_username_and_domain(actor) + end + + def display_name_and_username(%Actor{name: name} = actor) do + "#{name} (#{preferred_username_and_domain(actor)})" + end + + @doc """ + Returns the preferred username with the eventual @domain suffix if it's + a distant actor. + """ + @spec preferred_username_and_domain(Actor.t()) :: String.t() + def preferred_username_and_domain(%Actor{preferred_username: preferred_username, domain: nil}) do + preferred_username + end + + def preferred_username_and_domain(%Actor{preferred_username: preferred_username, domain: domain}) do + "#{preferred_username}@#{domain}" + end + @doc false + @spec changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t() def changeset(%Actor{} = actor, attrs) do actor - |> Ecto.Changeset.cast(attrs, [ - :url, - :outbox_url, - :inbox_url, - :shared_inbox_url, - :following_url, - :followers_url, - :type, - :name, - :domain, - :summary, - :preferred_username, - :keys, - :manually_approves_followers, - :suspended, - :user_id - ]) + |> cast(attrs, @attrs) |> build_urls() |> cast_embed(:avatar) |> cast_embed(:banner) |> unique_username_validator() - |> validate_required([:preferred_username, :keys, :suspended, :url]) - |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) + |> validate_required(@required_attrs) + |> unique_constraint(:preferred_username, + name: :actors_preferred_username_domain_type_index + ) |> unique_constraint(:url, name: :actors_url_index) end @doc false + @spec update_changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t() def update_changeset(%Actor{} = actor, attrs) do actor - |> Ecto.Changeset.cast(attrs, [ - :name, - :summary, - :keys, - :manually_approves_followers, - :suspended, - :user_id - ]) + |> cast(attrs, @update_attrs) |> cast_embed(:avatar) |> cast_embed(:banner) - |> validate_required([:preferred_username, :keys, :suspended, :url]) - |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) + |> validate_required(@update_required_attrs) + |> unique_constraint(:preferred_username, + name: :actors_preferred_username_domain_type_index + ) |> unique_constraint(:url, name: :actors_url_index) end @doc """ - Changeset for person registration + Changeset for person registration. """ - @spec registration_changeset(struct(), map()) :: Ecto.Changeset.t() + @spec registration_changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t() def registration_changeset(%Actor{} = actor, attrs) do actor - |> Ecto.Changeset.cast(attrs, [ - :preferred_username, - :domain, - :name, - :summary, - :keys, - :suspended, - :url, - :type, - :user_id - ]) + |> cast(attrs, @registration_attrs) |> build_urls() |> cast_embed(:avatar) |> cast_embed(:banner) - # Needed because following constraint can't work for domain null values (local) |> unique_username_validator() - |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) + |> unique_constraint(:preferred_username, + name: :actors_preferred_username_domain_type_index + ) |> unique_constraint(:url, name: :actors_url_index) - |> validate_required([:preferred_username, :keys, :suspended, :url, :type]) + |> validate_required(@registration_required_attrs) end - # TODO : Use me ! - # @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ @doc """ - Changeset for remote actor creation + Changeset for remote actor creation. """ - @spec remote_actor_creation(map()) :: Ecto.Changeset.t() - def remote_actor_creation(params) do - changes = + @spec remote_actor_creation_changeset(map) :: Ecto.Changeset.t() + def remote_actor_creation_changeset(attrs) do + changeset = %Actor{} - |> Ecto.Changeset.cast(params, [ - :url, - :outbox_url, - :inbox_url, - :shared_inbox_url, - :following_url, - :followers_url, - :type, - :name, - :domain, - :summary, - :preferred_username, - :keys, - :manually_approves_followers - ]) - |> validate_required([ - :url, - :inbox_url, - :type, - :domain, - :preferred_username, - :keys - ]) + |> cast(attrs, @remote_actor_creation_attrs) + |> validate_required(@remote_actor_creation_required_attrs) |> cast_embed(:avatar) |> cast_embed(:banner) - # Needed because following constraint can't work for domain null values (local) |> unique_username_validator() - |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) + |> unique_constraint(:preferred_username, + name: :actors_preferred_username_domain_type_index + ) |> unique_constraint(:url, name: :actors_url_index) |> validate_length(:summary, max: 5000) |> validate_length(:preferred_username, max: 100) - Logger.debug("Remote actor creation") - Logger.debug(inspect(changes)) - changes + Logger.debug("Remote actor creation: #{inspect(changeset)}") + + changeset end - def relay_creation(%{url: url, preferred_username: preferred_username} = _params) do - key = :public_key.generate_key({:rsa, 2048, 65_537}) - entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) - pem = [entry] |> :public_key.pem_encode() |> String.trim_trailing() + @doc """ + Changeset for relay creation. + """ + @spec relay_creation_changeset(map) :: Ecto.Changeset.t() + def relay_creation_changeset(attrs) do + relay_creation_attrs = build_relay_creation_attrs(attrs) - vars = %{ - "name" => Config.get([:instance, :name], "Mobilizon"), - "summary" => Config.get( - [:instance, :description], - "An internal service actor for this Mobilizon instance" - ), - "url" => url, - "keys" => pem, - "preferred_username" => preferred_username, - "domain" => nil, - "inbox_url" => "#{MobilizonWeb.Endpoint.url()}/inbox", - "followers_url" => "#{url}/followers", - "following_url" => "#{url}/following", - "shared_inbox_url" => "#{MobilizonWeb.Endpoint.url()}/inbox", - "type" => :Application - } - - cast(%Actor{}, vars, [ - :type, - :name, - :summary, - :url, - :keys, - :preferred_username, - :domain, - :inbox_url, - :followers_url, - :following_url, - :shared_inbox_url - ]) + cast(%Actor{}, relay_creation_attrs, @relay_creation_attrs) end @doc """ @@ -244,68 +276,48 @@ defmodule Mobilizon.Actors.Actor do @spec group_creation(struct(), map()) :: Ecto.Changeset.t() def group_creation(%Actor{} = actor, params) do actor - |> Ecto.Changeset.cast(params, [ - :url, - :outbox_url, - :inbox_url, - :shared_inbox_url, - :type, - :name, - :domain, - :summary, - :preferred_username - ]) + |> cast(params, @group_creation_attrs) |> cast_embed(:avatar) |> cast_embed(:banner) |> build_urls(:Group) |> put_change(:domain, nil) - |> put_change(:keys, Actors.create_keys()) + |> put_change(:keys, Crypto.generate_rsa_2048_private_key()) |> put_change(:type, :Group) |> unique_username_validator() - |> validate_required([:url, :outbox_url, :inbox_url, :type, :preferred_username]) - |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) + |> validate_required(@group_creation_required_attrs) + |> unique_constraint(:preferred_username, + name: :actors_preferred_username_domain_type_index + ) |> unique_constraint(:url, name: :actors_url_index) |> validate_length(:summary, max: 5000) |> validate_length(:preferred_username, max: 100) end + # Needed because following constraint can't work for domain null values (local) + @spec unique_username_validator(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp unique_username_validator( %Ecto.Changeset{changes: %{preferred_username: username} = changes} = changeset ) do with nil <- Map.get(changes, :domain, nil), - %Actor{preferred_username: _username} <- Actors.get_local_actor_by_name(username) do - changeset |> add_error(:preferred_username, "Username is already taken") + %Actor{preferred_username: _} <- Actors.get_local_actor_by_name(username) do + add_error(changeset, :preferred_username, "Username is already taken") else _ -> changeset end end # When we don't even have any preferred_username, don't even try validating preferred_username - defp unique_username_validator(changeset) do - changeset - end + defp unique_username_validator(changeset), do: changeset - @spec build_urls(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t() + @spec build_urls(Ecto.Changeset.t(), ActorType.t()) :: Ecto.Changeset.t() defp build_urls(changeset, type \\ :Person) defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, _type) do changeset - |> put_change( - :outbox_url, - build_url(username, :outbox) - ) - |> put_change( - :followers_url, - build_url(username, :followers) - ) - |> put_change( - :following_url, - build_url(username, :following) - ) - |> put_change( - :inbox_url, - build_url(username, :inbox) - ) + |> put_change(:outbox_url, build_url(username, :outbox)) + |> put_change(:followers_url, build_url(username, :followers)) + |> put_change(:following_url, build_url(username, :following)) + |> put_change(:inbox_url, build_url(username, :inbox)) |> put_change(:shared_inbox_url, "#{MobilizonWeb.Endpoint.url()}/inbox") |> put_change(:url, build_url(username, :page)) end @@ -313,19 +325,19 @@ defmodule Mobilizon.Actors.Actor do defp build_urls(%Ecto.Changeset{} = changeset, _type), do: changeset @doc """ - Build an AP URL for an actor + Builds an AP URL for an actor. """ - @spec build_url(String.t(), atom()) :: String.t() + @spec build_url(String.t(), atom, keyword) :: String.t() def build_url(preferred_username, endpoint, args \\ []) + def build_url(username, :inbox, _args), do: "#{build_url(username, :page)}/inbox" + def build_url(preferred_username, :page, args) do Endpoint |> Routes.page_url(:actor, preferred_username, args) |> URI.decode() end - def build_url(username, :inbox, _args), do: "#{build_url(username, :page)}/inbox" - def build_url(preferred_username, endpoint, args) when endpoint in [:outbox, :following, :followers] do Endpoint @@ -333,267 +345,35 @@ defmodule Mobilizon.Actors.Actor do |> URI.decode() end - @doc """ - Get a public key for a given ActivityPub actor ID (url) - """ - @spec get_public_key_for_url(String.t()) :: {:ok, String.t()} | {:error, atom()} - def get_public_key_for_url(url) do - with {:ok, %Actor{keys: keys}} <- Actors.get_or_fetch_by_url(url), - {:ok, public_key} <- prepare_public_key(keys) do - {:ok, public_key} - else - {:error, :pem_decode_error} -> - Logger.error("Error while decoding PEM") - {:error, :pem_decode_error} - - _ -> - Logger.error("Unable to fetch actor, so no keys for you") - {:error, :actor_fetch_error} - end - end - - @doc """ - Convert internal PEM encoded keys to public key format - """ - @spec prepare_public_key(String.t()) :: {:ok, tuple()} | {:error, :pem_decode_error} - def prepare_public_key(public_key_code) do - case :public_key.pem_decode(public_key_code) do - [public_key_entry] -> - {:ok, :public_key.pem_entry_decode(public_key_entry)} - - _err -> - {:error, :pem_decode_error} - end - end - - @doc """ - Get followers from an actor - - If actor A and C both follow actor B, actor B's followers are A and C - """ - @spec get_followers(struct(), number(), number()) :: map() - def get_followers(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do - query = - from( - a in Actor, - join: f in Follower, - on: a.id == f.actor_id, - where: f.target_actor_id == ^actor_id - ) - - total = Task.async(fn -> Repo.aggregate(query, :count, :id) end) - elements = Task.async(fn -> Repo.all(Page.paginate(query, page, limit)) end) - - %{total: Task.await(total), elements: Task.await(elements)} - end - - defp get_full_followers_query(%Actor{id: actor_id} = _actor) do - from( - a in Actor, - join: f in Follower, - on: a.id == f.actor_id, - where: f.target_actor_id == ^actor_id - ) - end - - @spec get_full_followers(struct()) :: list() - def get_full_followers(%Actor{} = actor) do - actor - |> get_full_followers_query() - |> Repo.all() - end - - @spec get_full_external_followers(struct()) :: list() - def get_full_external_followers(%Actor{} = actor) do - actor - |> get_full_followers_query() - |> where([a], not is_nil(a.domain)) - |> Repo.all() - end - - @doc """ - Get followings from an actor - - If actor A follows actor B and C, actor A's followings are B and B - """ - @spec get_followings(struct(), number(), number()) :: list() - def get_followings(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do - query = - from( - a in Actor, - join: f in Follower, - on: a.id == f.target_actor_id, - where: f.actor_id == ^actor_id - ) - - total = Task.async(fn -> Repo.aggregate(query, :count, :id) end) - elements = Task.async(fn -> Repo.all(Page.paginate(query, page, limit)) end) - - %{total: Task.await(total), elements: Task.await(elements)} - end - - @spec get_full_followings(struct()) :: list() - def get_full_followings(%Actor{id: actor_id} = _actor) do - Repo.all( - from( - a in Actor, - join: f in Follower, - on: a.id == f.target_actor_id, - where: f.actor_id == ^actor_id - ) - ) - end - - @doc """ - Returns the groups an actor is member of - """ - @spec get_groups_member_of(struct()) :: list() - def get_groups_member_of(%Actor{id: actor_id}) do - Repo.all( - from( - a in Actor, - join: m in Member, - on: a.id == m.parent_id, - where: m.actor_id == ^actor_id - ) - ) - end - - @doc """ - Returns the members for a group actor - """ - @spec get_members_for_group(struct()) :: list() - def get_members_for_group(%Actor{id: actor_id}) do - Repo.all( - from( - a in Actor, - join: m in Member, - on: a.id == m.actor_id, - where: m.parent_id == ^actor_id - ) - ) - end - - @doc """ - Make an actor follow another - """ - @spec follow(struct(), struct(), boolean()) :: Follower.t() | {:error, 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, false} <- {:already_following, following?(follower, followed)} do - do_follow(follower, followed, approved, url) - else - {:already_following, %Follower{}} -> - {:error, :already_following, - "Could not follow actor: you are already following #{followed.preferred_username}"} - - {:suspended, _} -> - {:error, :suspended, - "Could not follow actor: #{followed.preferred_username} has been suspended"} - end - end - - @doc """ - Unfollow an actor (remove a `Mobilizon.Actors.Follower`) - """ - @spec unfollow(struct(), struct()) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} - def unfollow(%Actor{} = followed, %Actor{} = follower) do - case {:already_following, following?(follower, followed)} do - {:already_following, %Follower{} = follow} -> - Actors.delete_follower(follow) - - {:already_following, false} -> - {:error, "Could not unfollow actor: you are not following #{followed.preferred_username}"} - end - end - - @spec do_follow(struct(), struct(), boolean(), String.t()) :: - {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} - defp do_follow(%Actor{} = follower, %Actor{} = followed, approved, url) do - Logger.info( - "Making #{follower.preferred_username} follow #{followed.preferred_username} (approved: #{ - approved - })" - ) - - Actors.create_follower(%{ - "actor_id" => follower.id, - "target_actor_id" => followed.id, - "approved" => approved, - "url" => url - }) - end - - @doc """ - Returns whether an actor is following another - """ - @spec following?(struct(), struct()) :: Follower.t() | false - def following?( - %Actor{} = follower_actor, - %Actor{} = followed_actor - ) do - case Actors.get_follower(followed_actor, follower_actor) do - nil -> false - %Follower{} = follow -> follow - end - end - - @spec public_visibility?(struct()) :: boolean() - def public_visibility?(%Actor{visibility: visibility}), do: visibility in [:public, :unlisted] - - @doc """ - Return the preferred_username with the eventual @domain suffix if it's a distant actor - """ - @spec actor_acct_from_actor(struct()) :: String.t() - def actor_acct_from_actor(%Actor{preferred_username: preferred_username, domain: domain}) do - if is_nil(domain) do - preferred_username - else - "#{preferred_username}@#{domain}" - end - end - - @doc """ - Returns the display name if available, or the preferred_username (with the eventual @domain suffix if it's a distant actor). - """ - @spec display_name(struct()) :: String.t() - def display_name(%Actor{name: name} = actor) do - case name do - nil -> actor_acct_from_actor(actor) - "" -> actor_acct_from_actor(actor) - name -> name - end - end - - @doc """ - Return display name and username - - ## Examples - iex> display_name_and_username(%Actor{name: "Thomas C", preferred_username: "tcit", domain: nil}) - "Thomas (tcit)" - - iex> display_name_and_username(%Actor{name: "Thomas C", preferred_username: "tcit", domain: "framapiaf.org"}) - "Thomas (tcit@framapiaf.org)" - - iex> display_name_and_username(%Actor{name: nil, preferred_username: "tcit", domain: "framapiaf.org"}) - "tcit@framapiaf.org" - - """ - @spec display_name_and_username(struct()) :: String.t() - def display_name_and_username(%Actor{name: nil} = actor), do: actor_acct_from_actor(actor) - def display_name_and_username(%Actor{name: ""} = actor), do: actor_acct_from_actor(actor) - - def display_name_and_username(%Actor{name: name} = actor), - do: name <> " (" <> actor_acct_from_actor(actor) <> ")" - @doc """ Clear multiple caches for an actor """ + # TODO: move to MobilizonWeb @spec clear_cache(struct()) :: {:ok, true} def clear_cache(%Actor{preferred_username: preferred_username, domain: nil}) do Cachex.del(:activity_pub, "actor_" <> preferred_username) Cachex.del(:feed, "actor_" <> preferred_username) Cachex.del(:ics, "actor_" <> preferred_username) end + + @spec build_relay_creation_attrs(map) :: map + defp build_relay_creation_attrs(%{url: url, preferred_username: preferred_username}) do + %{ + "name" => Config.get([:instance, :name], "Mobilizon"), + "summary" => + Config.get( + [:instance, :description], + "An internal service actor for this Mobilizon instance" + ), + "url" => url, + "keys" => Crypto.generate_rsa_2048_private_key(), + "preferred_username" => preferred_username, + "domain" => nil, + "inbox_url" => "#{MobilizonWeb.Endpoint.url()}/inbox", + "followers_url" => "#{url}/followers", + "following_url" => "#{url}/following", + "shared_inbox_url" => "#{MobilizonWeb.Endpoint.url()}/inbox", + "type" => :Application + } + end end diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 3a3617b9d..eb4da0fbb 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -4,105 +4,206 @@ defmodule Mobilizon.Actors do """ import Ecto.Query + import EctoEnum alias Ecto.Multi - alias Mobilizon.Actors.{Actor, Bot, Member, Follower} + alias Mobilizon.Actors.{Actor, Bot, Follower, Member} + alias Mobilizon.Crypto alias Mobilizon.Media.File - alias Mobilizon.Service.ActivityPub alias Mobilizon.Storage.{Page, Repo} require Logger - @doc false - def data() do - Dataloader.Ecto.new(Repo, query: &query/2) - end + 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, [ + :not_approved, + :member, + :moderator, + :administrator, + :creator + ]) @doc false - def query(queryable, _params) do - queryable - end + @spec data :: Dataloader.Ecto.t() + def data, do: Dataloader.Ecto.new(Repo, query: &query/2) - @doc """ - Returns the list of actors. - - ## Examples - - iex> Mobilizon.Actors.list_actors() - [%Mobilizon.Actors.Actor{}] - - """ - @spec list_actors() :: list() - def list_actors do - Repo.all(Actor) - end + @doc false + @spec query(Ecto.Query.t(), map) :: Ecto.Query.t() + def query(queryable, _params), do: queryable @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. - - ## Examples - - iex> get_actor!(123) - %Mobilizon.Actors.Actor{} - - iex> get_actor!(456) - ** (Ecto.NoResultsError) - """ - @spec get_actor!(integer()) :: Actor.t() - def get_actor!(id) do - Repo.get!(Actor, id) - end - - def get_actor(id) do - Repo.get(Actor, id) - end - - # Get actor by ID and preload organized events, followers and followings - @spec get_actor_with_everything(integer()) :: Ecto.Query.t() - defp do_get_actor_with_everything(id) do - from(a in Actor, - where: a.id == ^id, - preload: [:organized_events, :followers, :followings] - ) - end + @spec get_actor!(integer | String.t()) :: Actor.t() + def get_actor!(id), do: Repo.get!(Actor, id) @doc """ - Returns an actor with every relation + Gets an actor with preloaded relations. """ - @spec get_actor_with_everything(integer()) :: Mobilizon.Actors.Actor.t() - def get_actor_with_everything(id) do + @spec get_actor_with_preload(integer | String.t()) :: Actor.t() | nil + def get_actor_with_preload(id) do id - |> do_get_actor_with_everything + |> actor_with_preload_query() |> Repo.one() end @doc """ - Returns an actor with every relation + Gets a local actor with preloaded relations. """ - @spec get_local_actor_with_everything(integer()) :: Mobilizon.Actors.Actor.t() - def get_local_actor_with_everything(id) do + @spec get_local_actor_with_preload(integer | String.t()) :: Actor.t() | nil + def get_local_actor_with_preload(id) do id - |> do_get_actor_with_everything - |> where([a], is_nil(a.domain)) + |> actor_with_preload_query() + |> filter_local() |> Repo.one() end @doc """ - Creates a actor. - - ## Examples - - iex> create_actor(%{preferred_username: "test"}) - {:ok, %Mobilizon.Actors.Actor{preferred_username: "test"}} - - iex> create_actor(%{preferred_username: nil}) - {:error, %Ecto.Changeset{}} - + 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) do + case Repo.get_by(Actor, url: url) do + nil -> + {:error, :actor_not_found} + + actor -> + {:ok, preload_followers(actor, preload)} + end + 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 + from(a in Actor) + |> 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 + from(a in Actor) + |> 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) + end + + @doc """ + Gets a cached local actor by username. + #TODO: move to MobilizonWeb layer + """ + @spec get_cached_local_actor_by_name(String.t()) :: + {:commit, Actor.t()} | {:ignore, any()} + def get_cached_local_actor_by_name(name) do + Cachex.fetch(:activity_pub, "actor_" <> name, fn "actor_" <> name -> + case get_local_actor_by_name(name) do + nil -> {:ignore, nil} + %Actor{} = actor -> {:commit, actor} + end + end) + end + + @doc """ + Gets local actors by their username. + """ + @spec get_local_actor_by_username(String.t()) :: [Actor.t()] + def get_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( + String.t(), + [ActorType.t()], + integer | nil, + integer | nil + ) :: Page.t() + def build_actors_by_username_or_name_page(username, types, page \\ nil, limit \\ nil) do + username + |> actor_by_username_or_name_query() + |> filter_by_types(types) + |> Page.build_page(page, limit) + end + + @doc """ + Creates an actor. + """ + @spec create_actor(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def create_actor(attrs \\ %{}) do %Actor{} |> Actor.changeset(attrs) @@ -110,17 +211,9 @@ defmodule Mobilizon.Actors do end @doc """ - Updates a actor. - - ## Examples - - iex> update_actor(%Actor{preferred_username: "toto"}, %{preferred_username: "tata"}) - {:ok, %Mobilizon.Actors.Actor{preferred_username: "tata"}} - - iex> update_actor(%Actor{preferred_username: "toto"}, %{preferred_username: nil}) - {:error, %Ecto.Changeset{}} - + Updates an actor. """ + @spec update_actor(Actor.t(), map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def update_actor(%Actor{} = actor, attrs) do actor |> Actor.update_changeset(attrs) @@ -128,43 +221,46 @@ defmodule Mobilizon.Actors do |> Repo.update() end - defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes} = 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} = _old_key <- Map.from_struct(changeset.data) |> Map.get(key), - false <- new_url == old_url do - MobilizonWeb.Upload.remove(old_url) - end - end - 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(%{keys: keys, name: name, summary: summary} = data, preload \\ false) do + insert = + data + |> Actor.remote_actor_creation_changeset() + |> Repo.insert( + on_conflict: [set: [keys: keys, name: name, summary: summary]], + conflict_target: [:url] + ) - changeset + 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 @doc """ - Deletes a Actor. - - ## Examples - - iex> delete_actor(%Actor{}) - {:ok, %Mobilizon.Actors.Actor{}} - - iex> delete_actor(nil) - {:error, %Ecto.Changeset{}} - + Deletes an actor. """ @spec delete_actor(Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def delete_actor(%Actor{domain: nil} = actor) do - case Multi.new() - |> Multi.delete(:actor, actor) - |> Multi.run(:remove_banner, fn _repo, %{actor: %Actor{}} = _picture -> - remove_banner(actor) - end) - |> Multi.run(:remove_avatar, fn _repo, %{actor: %Actor{}} = _picture -> - remove_avatar(actor) - end) - |> Repo.transaction() do + transaction = + Multi.new() + |> Multi.delete(:actor, actor) + |> Multi.run(:remove_banner, fn _, %{actor: %Actor{}} -> remove_banner(actor) end) + |> Multi.run(:remove_avatar, fn _, %{actor: %Actor{}} -> remove_avatar(actor) end) + |> Repo.transaction() + + case transaction do {:ok, %{actor: %Actor{} = actor}} -> {:ok, actor} @@ -173,78 +269,153 @@ defmodule Mobilizon.Actors do end end - def delete_actor(%Actor{} = actor) do - Repo.delete(actor) + def delete_actor(%Actor{} = actor), do: Repo.delete(actor) + + @doc """ + Returns the list of actors. + """ + @spec list_actors :: [Actor.t()] + def list_actors, do: Repo.all(Actor) + + @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 """ - Returns an `%Ecto.Changeset{}` for tracking actor changes. - - ## Examples - - iex> change_actor(%Actor{}) - %Ecto.Changeset{data: %Mobilizon.Actors.Actor{}} - + Gets a local group by its title. """ - @spec change_actor(Actor.t()) :: Ecto.Changeset.t() - def change_actor(%Actor{} = actor) do - Actor.changeset(actor, %{}) + @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 + @spec actor_with_preload_query(integer | String.t()) :: Ecto.Query.t() + defp actor_with_preload_query(id) do + from( + a in Actor, + where: a.id == ^id, + preload: [: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(String.t()) :: Ecto.Query.t() + defp actor_by_username_or_name_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 group_query :: Ecto.Query.t() + defp group_query do + from(a in Actor, where: a.type == "Group") + 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_by_type(Ecto.Query.t(), ActorType.t()) :: Ecto.Query.t() + defp filter_by_type(query, type) when type in [:Person, :Group] 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 + + ##### TODO: continue refactoring from here ##### + @doc """ - List the groups + Returns the groups an actor is member of """ - @spec list_groups(number(), number()) :: list(Actor.t()) - def list_groups(page \\ nil, limit \\ nil) do + @spec get_groups_member_of(struct()) :: list() + def get_groups_member_of(%Actor{id: actor_id}) do Repo.all( from( a in Actor, - where: a.type == ^:Group, - where: a.visibility in [^:public, ^:unlisted] + join: m in Member, + on: a.id == m.parent_id, + where: m.actor_id == ^actor_id ) - |> Page.paginate(page, limit) ) end @doc """ - Get the default member role depending on the actor openness + Returns the members for a group actor """ - @spec get_default_member_role(Actor.t()) :: atom() - def get_default_member_role(%Actor{openness: :open}), do: :member - def get_default_member_role(%Actor{}), do: :not_approved - - @doc """ - Get a group by it's title - """ - @spec get_group_by_title(String.t()) :: Actor.t() | nil - def get_group_by_title(title) do - case String.split(title, "@") do - [title] -> - get_local_group_by_title(title) - - [title, domain] -> - Repo.one( - from(a in Actor, - where: a.preferred_username == ^title and a.type == "Group" and a.domain == ^domain - ) - ) - end - end - - @doc """ - Get a local group by it's title - """ - @spec get_local_group_by_title(String.t()) :: Actor.t() | nil - def get_local_group_by_title(title) do - title - |> do_get_local_group_by_title - |> Repo.one() - end - - @spec do_get_local_group_by_title(String.t()) :: Ecto.Query.t() - defp do_get_local_group_by_title(title) do - from(a in Actor, - where: a.preferred_username == ^title and a.type == "Group" and is_nil(a.domain) + @spec get_members_for_group(struct()) :: list() + def get_members_for_group(%Actor{id: actor_id}) do + Repo.all( + from( + a in Actor, + join: m in Member, + on: a.id == m.actor_id, + where: m.parent_id == ^actor_id + ) ) end @@ -274,323 +445,18 @@ defmodule Mobilizon.Actors do end @doc """ - Upsert an actor. - - Conflicts on actor's URL/AP ID. Replaces keys, avatar and banner, name and summary. + List the groups """ - @spec insert_or_update_actor(map(), boolean()) :: {:ok, Actor.t()} - def insert_or_update_actor(data, preload \\ false) do - cs = - data - |> Actor.remote_actor_creation() - - case Repo.insert( - cs, - on_conflict: [ - set: [ - keys: data.keys, - name: data.name, - summary: data.summary - ] - ], - conflict_target: [:url] - ) do - {:ok, actor} -> - actor = if preload, do: Repo.preload(actor, [:followers]), else: actor - {:ok, actor} - - err -> - Logger.debug(inspect(err)) - {:error, err} - end - end - - # def increase_event_count(%Actor{} = actor) do - # event_count = (actor.info["event_count"] || 0) + 1 - # new_info = Map.put(actor.info, "note_count", note_count) - # - # cs = info_changeset(actor, %{info: new_info}) - # - # update_and_set_cache(cs) - # end - - @doc """ - Get an actor by it's URL (ActivityPub ID). The `:preload` option allows preloading the Followers relation. - - Raises `Ecto.NoResultsError` if the Actor does not exist. - - ## Examples - iex> get_actor_by_url("https://mastodon.server.tld/users/user") - {:ok, %Mobilizon.Actors.Actor{preferred_username: "user"}} - - iex> get_actor_by_url("https://mastodon.server.tld/users/user", true) - {:ok, %Mobilizon.Actors.Actor{preferred_username: "user", followers: []}} - - iex> get_actor_by_url("non existent") - {:error, :actor_not_found} - - """ - @spec get_actor_by_url(String.t(), boolean()) :: {:ok, Actor.t()} | {:error, :actor_not_found} - def get_actor_by_url(url, preload \\ false) do - case Repo.get_by(Actor, url: url) do - nil -> - {:error, :actor_not_found} - - actor -> - actor = if preload, do: Repo.preload(actor, [:followers]), else: actor - {:ok, actor} - end - end - - @doc """ - Get an actor by it's URL (ActivityPub ID). The `:preload` option allows preloading the Followers relation. - - Raises `Ecto.NoResultsError` if the Actor does not exist. - - ## Examples - iex> get_actor_by_url!("https://mastodon.server.tld/users/user") - %Mobilizon.Actors.Actor{} - - iex> get_actor_by_url!("https://mastodon.server.tld/users/user", true) - {:ok, %Mobilizon.Actors.Actor{preferred_username: "user", followers: []}} - - iex> get_actor_by_url!("non existent") - ** (Ecto.NoResultsError) - - """ - @spec get_actor_by_url!(String.t(), boolean()) :: struct() - def get_actor_by_url!(url, preload \\ false) do - actor = Repo.get_by!(Actor, url: url) - if preload, do: Repo.preload(actor, [:followers]), else: actor - end - - @doc """ - Get an actor by name - - ## Examples - iex> get_actor_by_name("tcit") - %Mobilizon.Actors.Actor{preferred_username: "tcit", domain: nil} - - iex> get_actor_by_name("tcit@social.tcit.fr") - %Mobilizon.Actors.Actor{preferred_username: "tcit", domain: "social.tcit.fr"} - - iex> get_actor_by_name("tcit", :Group) - nil - - """ - @spec get_actor_by_name(String.t(), atom() | nil) :: Actor.t() - def get_actor_by_name(name, type \\ nil) do - # Base query - query = from(a in Actor) - - # If we have Person / Group information - query = - if type in [:Person, :Group] do - from(a in query, where: a.type == ^type) - else - query - end - - # If the name is a remote actor - query = - case String.split(name, "@") do - [name] -> do_get_actor_by_name(query, name) - [name, domain] -> do_get_actor_by_name(query, name, domain) - end - - Repo.one(query) - end - - # Get actor by username and domain is nil - @spec do_get_actor_by_name(Ecto.Queryable.t(), String.t()) :: Ecto.Queryable.t() - defp do_get_actor_by_name(query, name) do - from(a in query, where: a.preferred_username == ^name and is_nil(a.domain)) - end - - # Get actor by username and domain - @spec do_get_actor_by_name(Ecto.Queryable.t(), String.t(), String.t()) :: Ecto.Queryable.t() - defp do_get_actor_by_name(query, name, domain) do - from(a in query, where: a.preferred_username == ^name and a.domain == ^domain) - end - - @doc """ - Return a local actor by it's preferred username - """ - @spec get_local_actor_by_name(String.t()) :: Actor.t() | nil - def get_local_actor_by_name(name) do - Repo.one( - from(a in Actor, - where: a.preferred_username == ^name and is_nil(a.domain) - ) - ) - end - - @doc """ - Return a local actor by it's preferred username and preload associations - - Preloads organized_events, followers and followings - """ - @spec get_local_actor_by_name_with_everything(String.t()) :: Actor.t() | nil - def get_local_actor_by_name_with_everything(name) do - actor = Repo.one(from(a in Actor, where: a.preferred_username == ^name and is_nil(a.domain))) - Repo.preload(actor, [:organized_events, :followers, :followings]) - end - - @doc """ - Returns actor by name and preloads the organized events - - ## Examples - iex> get_actor_by_name_with_everything("tcit") - %Mobilizon.Actors.Actor{preferred_username: "tcit", domain: nil, organized_events: []} - - iex> get_actor_by_name_with_everything("tcit@social.tcit.fr") - %Mobilizon.Actors.Actor{preferred_username: "tcit", domain: "social.tcit.fr", organized_events: []} - - iex> get_actor_by_name_with_everything("tcit", :Group) - nil - - """ - @spec get_actor_by_name_with_everything(String.t(), atom() | nil) :: Actor.t() - def get_actor_by_name_with_everything(name, type \\ nil) do - name - |> get_actor_by_name(type) - |> Repo.preload(:organized_events) - end - - @doc """ - Returns a cached local actor by username - """ - @spec get_cached_local_actor_by_name(String.t()) :: - {:ok, Actor.t()} | {:commit, Actor.t()} | {:ignore, any()} - def get_cached_local_actor_by_name(name) do - Cachex.fetch(:activity_pub, "actor_" <> name, fn "actor_" <> name -> - case get_local_actor_by_name(name) do - nil -> {:ignore, nil} - %Actor{} = actor -> {:commit, actor} - end - end) - end - - @doc """ - Getting an actor from url, eventually creating it - """ - # TODO: Move this to Mobilizon.Service.ActivityPub - @spec get_or_fetch_by_url(String.t(), bool()) :: {:ok, Actor.t()} | {:error, String.t()} - def get_or_fetch_by_url(url, preload \\ false) do - case get_actor_by_url(url, preload) do - {:ok, %Actor{} = actor} -> - {:ok, actor} - - _ -> - case ActivityPub.make_actor_from_url(url, preload) do - {:ok, %Actor{} = actor} -> - {:ok, actor} - - _ -> - Logger.warn("Could not fetch by AP id") - {:error, "Could not fetch by AP id"} - end - end - end - - @doc """ - Getting an actor from url, eventually creating it - - Returns an error if fetch failed - """ - # TODO: Move this to Mobilizon.Service.ActivityPub - @spec get_or_fetch_by_url!(String.t(), bool()) :: Actor.t() - def get_or_fetch_by_url!(url, preload \\ false) do - case get_actor_by_url(url, preload) do - {:ok, actor} -> - actor - - _ -> - case ActivityPub.make_actor_from_url(url, preload) do - {:ok, actor} -> - actor - - _ -> - raise "Could not fetch by AP id" - end - end - end - - @doc """ - Find local users by their username - """ - # TODO: This doesn't seem to be used anyway - @spec find_local_by_username(String.t()) :: list(Actor.t()) - def find_local_by_username(username) do - actors = - Repo.all( - from( - a in Actor, - where: - fragment( - "f_unaccent(?) <% f_unaccent(?) or - f_unaccent(coalesce(?, '')) <% f_unaccent(?)", - a.preferred_username, - ^username, - a.name, - ^username - ), - where: is_nil(a.domain), - order_by: - fragment( - "word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc", - a.preferred_username, - ^username, - a.name, - ^username - ) - ) - ) - - Repo.preload(actors, :organized_events) - end - - @doc """ - Find actors by their name or displayed name - """ - @spec find_and_count_actors_by_username_or_name( - String.t(), - [ActorTypeEnum.t()], - integer() | nil, - integer() | nil - ) :: - %{total: integer(), elements: list(Actor.t())} - def find_and_count_actors_by_username_or_name(username, _types, page \\ nil, limit \\ nil) - - def find_and_count_actors_by_username_or_name(username, types, page, limit) do - query = + @spec list_groups(number(), number()) :: list(Actor.t()) + def list_groups(page \\ nil, limit \\ nil) do + Repo.all( from( a in Actor, - where: - fragment( - "f_unaccent(?) %> f_unaccent(?) or - f_unaccent(coalesce(?, '')) %> f_unaccent(?)", - a.preferred_username, - ^username, - a.name, - ^username - ), - where: a.type in ^types, - order_by: - fragment( - "word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc", - a.preferred_username, - ^username, - a.name, - ^username - ) + where: a.type == ^:Group, + where: a.visibility in [^:public, ^:unlisted] ) |> Page.paginate(page, limit) - - total = Task.async(fn -> Repo.aggregate(query, :count, :id) end) - elements = Task.async(fn -> Repo.all(query) end) - - %{total: Task.await(total), elements: Task.await(elements)} + ) end @doc """ @@ -603,25 +469,12 @@ defmodule Mobilizon.Actors do end end - @doc """ - Create a new RSA key - """ - @spec create_keys() :: String.t() - def create_keys() do - key = :public_key.generate_key({:rsa, 2048, 65_537}) - entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) - [entry] |> :public_key.pem_encode() |> String.trim_trailing() - end - @doc """ Create a new person actor """ @spec new_person(map()) :: {:ok, Actor.t()} | any def new_person(args) do - key = :public_key.generate_key({:rsa, 2048, 65_537}) - entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) - pem = [entry] |> :public_key.pem_encode() |> String.trim_trailing() - args = Map.put(args, :keys, pem) + args = Map.put(args, :keys, Crypto.generate_rsa_2048_private_key()) with {:ok, %Actor{} = person} <- %Actor{} @@ -637,15 +490,11 @@ defmodule Mobilizon.Actors do """ @spec register_bot_account(map()) :: Actor.t() def register_bot_account(%{name: name, summary: summary}) do - key = :public_key.generate_key({:rsa, 2048, 65_537}) - entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) - pem = [entry] |> :public_key.pem_encode() |> String.trim_trailing() - actor = Mobilizon.Actors.Actor.registration_changeset(%Mobilizon.Actors.Actor{}, %{ preferred_username: name, domain: nil, - keys: pem, + keys: Crypto.generate_rsa_2048_private_key(), summary: summary, type: :Service }) @@ -665,12 +514,20 @@ defmodule Mobilizon.Actors do _ -> %{url: url, preferred_username: preferred_username} - |> Actor.relay_creation() + |> Actor.relay_creation_changeset() |> Repo.insert() end end - alias Mobilizon.Actors.Member + @doc """ + Gets a single member of an actor (for example a group) + """ + 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 @doc """ Gets a single member. @@ -744,16 +601,41 @@ defmodule Mobilizon.Actors do end @doc """ - Returns an `%Ecto.Changeset{}` for tracking member changes. - - ## Examples - - iex> change_member(%Member{}) - %Ecto.Changeset{data: %Mobilizon.Actors.Member{}} - + Returns the list of administrator members for a group. """ - def change_member(%Member{} = member) do - Member.changeset(member, %{}) + def list_administrator_members_for_group(id, page \\ nil, limit \\ nil) do + Repo.all( + from( + m in Member, + where: m.parent_id == ^id and (m.role == ^:creator or m.role == ^:administrator), + preload: [:actor] + ) + |> Page.paginate(page, limit) + ) + end + + @doc """ + Get all group ids where the actor_id is the last administrator + """ + def list_group_id_where_last_administrator(actor_id) do + in_query = + from( + m in Member, + where: m.actor_id == ^actor_id and (m.role == ^:creator or m.role == ^:administrator), + select: m.parent_id + ) + + Repo.all( + from( + m in Member, + where: m.role == ^:creator or m.role == ^:administrator, + join: m2 in subquery(in_query), + on: m.parent_id == m2.parent_id, + group_by: m.parent_id, + select: m.parent_id, + having: count(m.actor_id) == 1 + ) + ) end @doc """ @@ -888,8 +770,6 @@ defmodule Mobilizon.Actors do Bot.changeset(bot, %{}) end - alias Mobilizon.Actors.Follower - @doc """ Gets a single follower. @@ -932,6 +812,84 @@ defmodule Mobilizon.Actors do ) end + @doc """ + Get followers from an actor + + If actor A and C both follow actor B, actor B's followers are A and C + """ + @spec get_followers(struct(), number(), number()) :: map() + def get_followers(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do + query = + from( + a in Actor, + join: f in Follower, + on: a.id == f.actor_id, + where: f.target_actor_id == ^actor_id + ) + + total = Task.async(fn -> Repo.aggregate(query, :count, :id) end) + elements = Task.async(fn -> Repo.all(Page.paginate(query, page, limit)) end) + + %{total: Task.await(total), elements: Task.await(elements)} + end + + @spec get_full_followers(struct()) :: list() + def get_full_followers(%Actor{} = actor) do + actor + |> get_full_followers_query() + |> Repo.all() + end + + @spec get_full_external_followers(struct()) :: list() + def get_full_external_followers(%Actor{} = actor) do + actor + |> get_full_followers_query() + |> where([a], not is_nil(a.domain)) + |> Repo.all() + end + + @doc """ + Get followings from an actor + + If actor A follows actor B and C, actor A's followings are B and B + """ + @spec get_followings(struct(), number(), number()) :: list() + def get_followings(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do + query = + from( + a in Actor, + join: f in Follower, + on: a.id == f.target_actor_id, + where: f.actor_id == ^actor_id + ) + + total = Task.async(fn -> Repo.aggregate(query, :count, :id) end) + elements = Task.async(fn -> Repo.all(Page.paginate(query, page, limit)) end) + + %{total: Task.await(total), elements: Task.await(elements)} + end + + @spec get_full_followings(struct()) :: list() + def get_full_followings(%Actor{id: actor_id} = _actor) do + Repo.all( + from( + a in Actor, + join: f in Follower, + on: a.id == f.target_actor_id, + where: f.actor_id == ^actor_id + ) + ) + end + + defp get_full_followers_query(%Actor{id: actor_id} = _actor) do + from( + a in Actor, + join: f in Follower, + on: a.id == f.actor_id, + where: f.target_actor_id == ^actor_id + ) + end + @doc """ Creates a follower. @@ -1006,16 +964,68 @@ defmodule Mobilizon.Actors do end @doc """ - Returns an `%Ecto.Changeset{}` for tracking follower changes. - - ## Examples - - iex> change_follower(Follower{}) - %Ecto.Changeset{data: %Mobilizon.Actors.Follower{}} - + Make an actor follow another """ - def change_follower(%Follower{} = follower) do - Follower.changeset(follower, %{}) + @spec follow(struct(), struct(), boolean()) :: Follower.t() | {:error, 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, false} <- {:already_following, following?(follower, followed)} do + do_follow(follower, followed, approved, url) + else + {:already_following, %Follower{}} -> + {:error, :already_following, + "Could not follow actor: you are already following #{followed.preferred_username}"} + + {:suspended, _} -> + {:error, :suspended, + "Could not follow actor: #{followed.preferred_username} has been suspended"} + end + end + + @doc """ + Unfollow an actor (remove a `Mobilizon.Actors.Follower`) + """ + @spec unfollow(struct(), struct()) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} + def unfollow(%Actor{} = followed, %Actor{} = follower) do + case {:already_following, following?(follower, followed)} do + {:already_following, %Follower{} = follow} -> + delete_follower(follow) + + {:already_following, false} -> + {:error, "Could not unfollow actor: you are not following #{followed.preferred_username}"} + end + end + + @spec do_follow(struct(), struct(), boolean(), String.t()) :: + {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} + defp do_follow(%Actor{} = follower, %Actor{} = followed, approved, url) do + Logger.info( + "Making #{follower.preferred_username} follow #{followed.preferred_username} (approved: #{ + approved + })" + ) + + create_follower(%{ + "actor_id" => follower.id, + "target_actor_id" => followed.id, + "approved" => approved, + "url" => url + }) + end + + @doc """ + Returns whether an actor is following another + """ + @spec following?(struct(), struct()) :: Follower.t() | false + def following?( + %Actor{} = follower_actor, + %Actor{} = followed_actor + ) do + case get_follower(followed_actor, follower_actor) do + nil -> false + %Follower{} = follow -> follow + end end defp remove_banner(%Actor{banner: nil} = actor), do: {:ok, actor} @@ -1041,4 +1051,19 @@ defmodule Mobilizon.Actors do {: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 + MobilizonWeb.Upload.remove(old_url) + end + end + end) + + changeset + end end diff --git a/lib/mobilizon/actors/bot.ex b/lib/mobilizon/actors/bot.ex index cde23e939..46d3c4b87 100644 --- a/lib/mobilizon/actors/bot.ex +++ b/lib/mobilizon/actors/bot.ex @@ -1,15 +1,30 @@ defmodule Mobilizon.Actors.Bot do @moduledoc """ - Represents a local bot + Represents a local bot. """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Actors.Actor alias Mobilizon.Users.User + @type t :: %__MODULE__{ + source: String.t(), + type: String.t(), + actor: Actor.t(), + user: User.t() + } + + @required_attrs [:source] + @optional_attrs [:type, :actor_id, :user_id] + @attrs @required_attrs ++ @optional_attrs + schema "bots" do field(:source, :string) field(:type, :string, default: :ics) + belongs_to(:actor, Actor) belongs_to(:user, User) @@ -17,9 +32,10 @@ defmodule Mobilizon.Actors.Bot do end @doc false + @spec changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t() def changeset(bot, attrs) do bot - |> cast(attrs, [:source, :type, :actor_id, :user_id]) - |> validate_required([:source]) + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) end end diff --git a/lib/mobilizon/actors/follower.ex b/lib/mobilizon/actors/follower.ex index 426706737..b8497e966 100644 --- a/lib/mobilizon/actors/follower.ex +++ b/lib/mobilizon/actors/follower.ex @@ -1,52 +1,63 @@ defmodule Mobilizon.Actors.Follower do @moduledoc """ - Represents the following of an actor to another actor + Represents the following of an actor to another actor. """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Actors.Follower + alias Mobilizon.Actors.Actor - @primary_key {:id, :binary_id, autogenerate: true} + @type t :: %__MODULE__{ + approved: boolean, + url: String.t(), + target_actor: Actor.t(), + actor: Actor.t() + } + @required_attrs [:url, :approved, :target_actor_id, :actor_id] + @attrs @required_attrs + + @primary_key {:id, :binary_id, autogenerate: true} schema "followers" do field(:approved, :boolean, default: false) field(:url, :string) + belongs_to(:target_actor, Actor) belongs_to(:actor, Actor) end @doc false - def changeset(%Follower{} = member, attrs) do - member - |> cast(attrs, [:url, :approved, :target_actor_id, :actor_id]) - |> generate_url() - |> validate_required([:url, :approved, :target_actor_id, :actor_id]) - |> unique_constraint(:target_actor_id, name: :followers_actor_target_actor_unique_index) + @spec changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t() + def changeset(follower, attrs) do + follower + |> cast(attrs, @attrs) + |> ensure_url() + |> validate_required(@required_attrs) + |> unique_constraint(:target_actor_id, + name: :followers_actor_target_actor_unique_index + ) end # If there's a blank URL that's because we're doing the first insert - defp generate_url(%Ecto.Changeset{data: %Follower{url: nil}} = changeset) do + @spec ensure_url(Ecto.Changeset.t()) :: Ecto.Changeset.t() + defp ensure_url(%Ecto.Changeset{data: %__MODULE__{url: nil}} = changeset) do case fetch_change(changeset, :url) do {:ok, _url} -> changeset - :error -> do_generate_url(changeset) + :error -> generate_url(changeset) end end # Most time just go with the given URL - defp generate_url(%Ecto.Changeset{} = changeset), do: changeset + defp ensure_url(%Ecto.Changeset{} = changeset), do: changeset - defp do_generate_url(%Ecto.Changeset{} = changeset) do + @spec generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t() + defp generate_url(%Ecto.Changeset{} = changeset) do uuid = Ecto.UUID.generate() changeset - |> put_change( - :url, - "#{MobilizonWeb.Endpoint.url()}/follow/#{uuid}" - ) - |> put_change( - :id, - uuid - ) + |> put_change(:id, uuid) + |> put_change(:url, "#{MobilizonWeb.Endpoint.url()}/follow/#{uuid}") end end diff --git a/lib/mobilizon/actors/member.ex b/lib/mobilizon/actors/member.ex index 02d70f1bf..98951e6b3 100644 --- a/lib/mobilizon/actors/member.ex +++ b/lib/mobilizon/actors/member.ex @@ -1,100 +1,59 @@ -import EctoEnum - -defenum(Mobilizon.Actors.MemberRoleEnum, :member_role_type, [ - :not_approved, - :member, - :moderator, - :administrator, - :creator -]) - defmodule Mobilizon.Actors.Member do @moduledoc """ - Represents the membership of an actor to a group + Represents the membership of an actor to a group. """ use Ecto.Schema import Ecto.Changeset - import Ecto.Query - alias Mobilizon.Actors.{Actor, Member} - alias Mobilizon.Storage.{Page, Repo} + alias Mobilizon.Actors.{Actor, Member, MemberRole} + + @type t :: %__MODULE__{ + role: MemberRole.t(), + parent: Actor.t(), + actor: Actor.t() + } + + @required_attrs [:parent_id, :actor_id] + @optional_attrs [:role] + @attrs @required_attrs ++ @optional_attrs schema "members" do - field(:role, Mobilizon.Actors.MemberRoleEnum, default: :member) + field(:role, MemberRole, default: :member) + belongs_to(:parent, Actor) belongs_to(:actor, Actor) timestamps() end - @doc false - def changeset(%Member{} = member, attrs) do - member - |> cast(attrs, [:role, :parent_id, :actor_id]) - |> validate_required([:parent_id, :actor_id]) - |> unique_constraint(:parent_id, name: :members_actor_parent_unique_index) - end - @doc """ - Gets a single member of an actor (for example a group) + Gets the default member role depending on the actor openness. """ - 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_default_member_role(Actor.t()) :: atom + def get_default_member_role(%Actor{openness: :open}), do: :member + def get_default_member_role(%Actor{}), do: :not_approved @doc """ - Gets a single member of an actor (for example a group) + Checks whether the actor can be joined to the group. """ def can_be_joined(%Actor{type: :Group, openness: :invite_only}), do: false def can_be_joined(%Actor{type: :Group}), do: true @doc """ - Returns the list of administrator members for a group. - """ - def list_administrator_members_for_group(id, page \\ nil, limit \\ nil) do - Repo.all( - from( - m in Member, - where: m.parent_id == ^id and (m.role == ^:creator or m.role == ^:administrator), - preload: [:actor] - ) - |> Page.paginate(page, limit) - ) - end - - @doc """ - Get all group ids where the actor_id is the last administrator - """ - def list_group_id_where_last_administrator(actor_id) do - in_query = - from( - m in Member, - where: m.actor_id == ^actor_id and (m.role == ^:creator or m.role == ^:administrator), - select: m.parent_id - ) - - Repo.all( - from( - m in Member, - where: m.role == ^:creator or m.role == ^:administrator, - join: m2 in subquery(in_query), - on: m.parent_id == m2.parent_id, - group_by: m.parent_id, - select: m.parent_id, - having: count(m.actor_id) == 1 - ) - ) - end - - @doc """ - Returns true if the member is an administrator (admin or creator) of the group + Checks whether the member is an administrator (admin or creator) of the group. """ def is_administrator(%Member{role: :administrator}), do: {:is_admin, true} def is_administrator(%Member{role: :creator}), do: {:is_admin, true} def is_administrator(%Member{}), do: {:is_admin, false} + + @doc false + @spec changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t() + def changeset(member, attrs) do + member + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) + |> unique_constraint(:parent_id, name: :members_actor_parent_unique_index) + end end diff --git a/lib/mobilizon/addresses/addresses.ex b/lib/mobilizon/addresses/addresses.ex index d1dc3b99c..7c8ac7b42 100644 --- a/lib/mobilizon/addresses/addresses.ex +++ b/lib/mobilizon/addresses/addresses.ex @@ -18,12 +18,6 @@ defmodule Mobilizon.Addresses do @spec query(Ecto.Query.t(), map) :: Ecto.Query.t() def query(queryable, _params), do: queryable - @doc """ - Returns the list of addresses. - """ - @spec list_addresses :: [Address.t()] - def list_addresses, do: Repo.all(Address) - @doc """ Gets a single address. """ @@ -72,6 +66,12 @@ defmodule Mobilizon.Addresses do @spec delete_address(Address.t()) :: {:ok, Address.t()} | {:error, Ecto.Changeset.t()} def delete_address(%Address{} = address), do: Repo.delete(address) + @doc """ + Returns the list of addresses. + """ + @spec list_addresses :: [Address.t()] + def list_addresses, do: Repo.all(Address) + @doc """ Searches addresses. diff --git a/lib/mobilizon/admin/admin.ex b/lib/mobilizon/admin/admin.ex index aa348cfa8..f660ab080 100644 --- a/lib/mobilizon/admin/admin.ex +++ b/lib/mobilizon/admin/admin.ex @@ -8,16 +8,6 @@ defmodule Mobilizon.Admin do alias Mobilizon.Admin.ActionLog alias Mobilizon.Storage.{Page, Repo} - @doc """ - Returns the list of action logs. - """ - @spec list_action_logs(integer | nil, integer | nil) :: [ActionLog.t()] - def list_action_logs(page \\ nil, limit \\ nil) do - list_action_logs_query() - |> Page.paginate(page, limit) - |> Repo.all() - end - @doc """ Creates a action_log. """ @@ -28,6 +18,16 @@ defmodule Mobilizon.Admin do |> Repo.insert() end + @doc """ + Returns the list of action logs. + """ + @spec list_action_logs(integer | nil, integer | nil) :: [ActionLog.t()] + def list_action_logs(page \\ nil, limit \\ nil) do + list_action_logs_query() + |> Page.paginate(page, limit) + |> Repo.all() + end + @spec list_action_logs_query :: Ecto.Query.t() defp list_action_logs_query do from(r in ActionLog, preload: [:actor]) diff --git a/lib/mobilizon/crypto.ex b/lib/mobilizon/crypto.ex index 9068c8787..05ced94db 100644 --- a/lib/mobilizon/crypto.ex +++ b/lib/mobilizon/crypto.ex @@ -12,4 +12,17 @@ defmodule Mobilizon.Crypto do |> :crypto.strong_rand_bytes() |> Base.url_encode64() end + + @doc """ + Generate RSA 2048-bit private key. + """ + @spec generate_rsa_2048_private_key :: String.t() + def generate_rsa_2048_private_key do + key = :public_key.generate_key({:rsa, 2048, 65_537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) + + [entry] + |> :public_key.pem_encode() + |> String.trim_trailing() + end end diff --git a/lib/mobilizon/reports/reports.ex b/lib/mobilizon/reports/reports.ex index 055e2ee91..95c96f73f 100644 --- a/lib/mobilizon/reports/reports.ex +++ b/lib/mobilizon/reports/reports.ex @@ -21,17 +21,6 @@ defmodule Mobilizon.Reports do @spec query(Ecto.Query.t(), map) :: Ecto.Query.t() def query(queryable, _params), do: queryable - @doc """ - Returns the list of reports. - """ - @spec list_reports(integer | nil, integer | nil, atom, atom) :: [Report.t()] - def list_reports(page \\ nil, limit \\ nil, sort \\ :updated_at, direction \\ :asc) do - list_reports_query() - |> Page.paginate(page, limit) - |> sort(sort, direction) - |> Repo.all() - end - @doc """ Gets a single report. """ @@ -90,17 +79,16 @@ defmodule Mobilizon.Reports do Deletes a report. """ @spec delete_report(Report.t()) :: {:ok, Report.t()} | {:error, Ecto.Changeset.t()} - def delete_report(%Report{} = report) do - Repo.delete(report) - end + def delete_report(%Report{} = report), do: Repo.delete(report) @doc """ - Returns the list of notes for a report. + Returns the list of reports. """ - @spec list_notes_for_report(Report.t()) :: [Note.t()] - def list_notes_for_report(%Report{id: report_id}) do - report_id - |> list_notes_for_report_query() + @spec list_reports(integer | nil, integer | nil, atom, atom) :: [Report.t()] + def list_reports(page \\ nil, limit \\ nil, sort \\ :updated_at, direction \\ :asc) do + list_reports_query() + |> Page.paginate(page, limit) + |> sort(sort, direction) |> Repo.all() end @@ -134,8 +122,21 @@ defmodule Mobilizon.Reports do Deletes a note. """ @spec delete_note(Note.t()) :: {:ok, Note.t()} | {:error, Ecto.Changeset.t()} - def delete_note(%Note{} = note) do - Repo.delete(note) + def delete_note(%Note{} = note), do: Repo.delete(note) + + @doc """ + Returns the list of notes for a report. + """ + @spec list_notes_for_report(Report.t()) :: [Note.t()] + def list_notes_for_report(%Report{id: report_id}) do + report_id + |> list_notes_for_report_query() + |> Repo.all() + end + + @spec report_by_url_query(String.t()) :: Ecto.Query.t() + defp report_by_url_query(url) do + from(r in Report, where: r.uri == ^url) end @spec list_reports_query :: Ecto.Query.t() @@ -146,11 +147,6 @@ defmodule Mobilizon.Reports do ) end - @spec report_by_url_query(String.t()) :: Ecto.Query.t() - defp report_by_url_query(url) do - from(r in Report, where: r.uri == ^url) - end - @spec list_notes_for_report_query(integer | String.t()) :: Ecto.Query.t() defp list_notes_for_report_query(report_id) do from( diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index c03fe3588..4fe5e8068 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -101,9 +101,7 @@ defmodule Mobilizon.Users do Deletes an user. """ @spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} - def delete_user(%User{} = user) do - Repo.delete(user) - end + def delete_user(%User{} = user), do: Repo.delete(user) @doc """ Get an user with its actors @@ -219,9 +217,7 @@ defmodule Mobilizon.Users do Counts users. """ @spec count_users :: integer - def count_users do - Repo.one(from(u in User, select: count(u.id))) - end + def count_users, do: Repo.one(from(u in User, select: count(u.id))) @doc """ Authenticate an user. diff --git a/lib/mobilizon_web/api/follows.ex b/lib/mobilizon_web/api/follows.ex index 626c710b9..a96b5c609 100644 --- a/lib/mobilizon_web/api/follows.ex +++ b/lib/mobilizon_web/api/follows.ex @@ -32,7 +32,7 @@ defmodule MobilizonWeb.API.Follows do def accept(%Actor{} = follower, %Actor{} = followed) do with %Follower{approved: false, id: follow_id, url: follow_url} = follow <- - Actor.following?(follower, followed), + Actors.following?(follower, followed), activity_follow_url <- "#{MobilizonWeb.Endpoint.url()}/accept/follow/#{follow_id}", data <- ActivityPub.Utils.make_follow_data(followed, follower, follow_url), diff --git a/lib/mobilizon_web/api/search.ex b/lib/mobilizon_web/api/search.ex index 0e1bfabe5..67e9a2726 100644 --- a/lib/mobilizon_web/api/search.ex +++ b/lib/mobilizon_web/api/search.ex @@ -1,20 +1,21 @@ defmodule MobilizonWeb.API.Search do @moduledoc """ - API for Search + API for search. """ - alias Mobilizon.Service.ActivityPub + alias Mobilizon.Actors - alias Mobilizon.Actors.Actor + alias Mobilizon.Actors.ActorType alias Mobilizon.Events - alias Mobilizon.Events.{Event, Comment} + alias Mobilizon.Service.ActivityPub + alias Mobilizon.Storage.Page require Logger @doc """ - Search actors + Searches actors. """ - @spec search_actors(String.t(), integer(), integer(), String.t()) :: - {:ok, %{total: integer(), elements: list(Actor.t())}} | {:error, any()} + @spec search_actors(String.t(), integer | nil, integer | nil, ActorType.t()) :: + {:ok, Page.t()} | {:error, String.t()} def search_actors(search, page \\ 1, limit \\ 10, result_type) do search = String.trim(search) @@ -22,31 +23,33 @@ defmodule MobilizonWeb.API.Search do search == "" -> {:error, "Search can't be empty"} - # Some URLs could be domain.tld/@username, so keep this condition above handle_search? function - url_search?(search) -> - # If this is not an actor, skip + # Some URLs could be domain.tld/@username, so keep this condition above + # the `is_handle` function + is_url(search) -> + # skip, if it's not an actor case process_from_url(search) do - %{:total => total, :elements => [%Actor{}] = elements} -> - {:ok, %{total: total, elements: elements}} + %Page{total: _total, elements: _elements} = page -> + {:ok, page} _ -> {:ok, %{total: 0, elements: []}} end - handle_search?(search) -> + is_handle(search) -> {:ok, process_from_username(search)} true -> - {:ok, - Actors.find_and_count_actors_by_username_or_name(search, [result_type], page, limit)} + page = Actors.build_actors_by_username_or_name_page(search, [result_type], page, limit) + + {:ok, page} end end @doc """ Search events """ - @spec search_events(String.t(), integer(), integer()) :: - {:ok, %{total: integer(), elements: list(Event.t())}} | {:error, any()} + @spec search_events(String.t(), integer | nil, integer | nil) :: + {:ok, Page.t()} | {:error, String.t()} def search_events(search, page \\ 1, limit \\ 10) do search = String.trim(search) @@ -54,11 +57,11 @@ defmodule MobilizonWeb.API.Search do search == "" -> {:error, "Search can't be empty"} - url_search?(search) -> - # If this is not an event, skip + is_url(search) -> + # skip, if it's w not an actor case process_from_url(search) do - {total = total, [%Event{} = elements]} -> - {:ok, %{total: total, elements: elements}} + %Page{total: _total, elements: _elements} = page -> + {:ok, page} _ -> {:ok, %{total: 0, elements: []}} @@ -70,43 +73,36 @@ defmodule MobilizonWeb.API.Search do end # If the search string is an username - @spec process_from_username(String.t()) :: %{total: integer(), elements: [Actor.t()]} + @spec process_from_username(String.t()) :: Page.t() defp process_from_username(search) do case ActivityPub.find_or_make_actor_from_nickname(search) do {:ok, actor} -> - %{total: 1, elements: [actor]} + %Page{total: 1, elements: [actor]} {:error, _err} -> Logger.debug(fn -> "Unable to find or make actor '#{search}'" end) - %{total: 0, elements: []} + + %Page{total: 0, elements: []} end end # If the search string is an URL - @spec process_from_url(String.t()) :: %{ - total: integer(), - elements: [Actor.t() | Event.t() | Comment.t()] - } + @spec process_from_url(String.t()) :: Page.t() defp process_from_url(search) do case ActivityPub.fetch_object_from_url(search) do {:ok, object} -> - %{total: 1, elements: [object]} + %Page{total: 1, elements: [object]} {:error, _err} -> Logger.debug(fn -> "Unable to find or make object from URL '#{search}'" end) - %{total: 0, elements: []} + + %Page{total: 0, elements: []} end end - # Is the search an URL search? - @spec url_search?(String.t()) :: boolean - defp url_search?(search) do - String.starts_with?(search, "https://") or String.starts_with?(search, "http://") - end + @spec is_url(String.t()) :: boolean + defp is_url(search), do: String.starts_with?(search, ["http://", "https://"]) - # Is the search an handle search? - @spec handle_search?(String.t()) :: boolean - defp handle_search?(search) do - String.match?(search, ~r/@/) - end + @spec is_handle(String.t()) :: boolean + defp is_handle(search), do: String.match?(search, ~r/@/) end diff --git a/lib/mobilizon_web/controllers/activity_pub_controller.ex b/lib/mobilizon_web/controllers/activity_pub_controller.ex index 1bdbbf49a..6fa703f59 100644 --- a/lib/mobilizon_web/controllers/activity_pub_controller.ex +++ b/lib/mobilizon_web/controllers/activity_pub_controller.ex @@ -31,7 +31,7 @@ defmodule MobilizonWeb.ActivityPubController do def following(conn, %{"name" => name, "page" => page}) do with {page, ""} <- Integer.parse(page), - %Actor{} = actor <- Actors.get_local_actor_by_name_with_everything(name) do + %Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do conn |> put_resp_header("content-type", "application/activity+json") |> json(ActorView.render("following.json", %{actor: actor, page: page})) @@ -39,7 +39,7 @@ defmodule MobilizonWeb.ActivityPubController do end def following(conn, %{"name" => name}) do - with %Actor{} = actor <- Actors.get_local_actor_by_name_with_everything(name) do + with %Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do conn |> put_resp_header("content-type", "application/activity+json") |> json(ActorView.render("following.json", %{actor: actor})) @@ -48,7 +48,7 @@ defmodule MobilizonWeb.ActivityPubController do def followers(conn, %{"name" => name, "page" => page}) do with {page, ""} <- Integer.parse(page), - %Actor{} = actor <- Actors.get_local_actor_by_name_with_everything(name) do + %Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do conn |> put_resp_header("content-type", "application/activity+json") |> json(ActorView.render("followers.json", %{actor: actor, page: page})) @@ -56,7 +56,7 @@ defmodule MobilizonWeb.ActivityPubController do end def followers(conn, %{"name" => name}) do - with %Actor{} = actor <- Actors.get_local_actor_by_name_with_everything(name) do + with %Actor{} = actor <- Actors.get_local_actor_by_name_with_preload(name) do conn |> put_resp_header("content-type", "application/activity+json") |> json(ActorView.render("followers.json", %{actor: actor})) diff --git a/lib/mobilizon_web/resolvers/group.ex b/lib/mobilizon_web/resolvers/group.ex index a8e378a2f..5aa35e861 100644 --- a/lib/mobilizon_web/resolvers/group.ex +++ b/lib/mobilizon_web/resolvers/group.ex @@ -76,7 +76,7 @@ defmodule MobilizonWeb.Resolvers.Group do ) do with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), {:is_owned, %Actor{}} <- User.owns_actor(user, actor_id), - {:ok, %Member{} = member} <- Member.get_member(actor_id, group.id), + {:ok, %Member{} = member} <- Actors.get_member(actor_id, group.id), {:is_admin, true} <- Member.is_administrator(member), group <- Actors.delete_group!(group) do {:ok, %{id: group.id}} @@ -109,9 +109,9 @@ defmodule MobilizonWeb.Resolvers.Group do ) do with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id), {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), - {:error, :member_not_found} <- Member.get_member(actor.id, group.id), + {:error, :member_not_found} <- Actors.get_member(actor.id, group.id), {:is_able_to_join, true} <- {:is_able_to_join, Member.can_be_joined(group)}, - role <- Mobilizon.Actors.get_default_member_role(group), + role <- Member.get_default_member_role(group), {:ok, _} <- Actors.create_member(%{parent_id: group.id, actor_id: actor.id, role: role}) do { :ok, @@ -149,7 +149,7 @@ defmodule MobilizonWeb.Resolvers.Group do %{context: %{current_user: user}} ) do with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id), - {:ok, %Member{} = member} <- Member.get_member(actor.id, group_id), + {:ok, %Member{} = member} <- Actors.get_member(actor.id, group_id), {:only_administrator, false} <- {:only_administrator, check_that_member_is_not_last_administrator(group_id, actor_id)}, {:ok, _} <- @@ -176,7 +176,7 @@ defmodule MobilizonWeb.Resolvers.Group do # and that it's the actor requesting leaving the group we return true @spec check_that_member_is_not_last_administrator(integer(), integer()) :: boolean() defp check_that_member_is_not_last_administrator(group_id, actor_id) do - case Member.list_administrator_members_for_group(group_id) do + case Actors.list_administrator_members_for_group(group_id) do [%Member{actor: %Actor{id: member_actor_id}}] -> actor_id == member_actor_id diff --git a/lib/mobilizon_web/resolvers/person.ex b/lib/mobilizon_web/resolvers/person.ex index 347b32131..8f751b7e9 100644 --- a/lib/mobilizon_web/resolvers/person.ex +++ b/lib/mobilizon_web/resolvers/person.ex @@ -2,12 +2,13 @@ defmodule MobilizonWeb.Resolvers.Person do @moduledoc """ Handles the person-related GraphQL calls """ + alias Mobilizon.Actors - alias Mobilizon.Actors.{Actor, Member} - alias Mobilizon.Users.User - alias Mobilizon.Users + alias Mobilizon.Actors.Actor alias Mobilizon.Events alias Mobilizon.Service.ActivityPub + alias Mobilizon.Users + alias Mobilizon.Users.User @doc """ Find a person @@ -206,7 +207,7 @@ defmodule MobilizonWeb.Resolvers.Person do # We check that the actor is not the last administrator/creator of a group @spec last_admin_of_a_group?(integer()) :: boolean() defp last_admin_of_a_group?(actor_id) do - length(Member.list_group_id_where_last_administrator(actor_id)) > 0 + length(Actors.list_group_id_where_last_administrator(actor_id)) > 0 end @spec proxify_avatar(Actor.t()) :: Actor.t() diff --git a/lib/mobilizon_web/resolvers/report.ex b/lib/mobilizon_web/resolvers/report.ex index 85796e158..472b60c6c 100644 --- a/lib/mobilizon_web/resolvers/report.ex +++ b/lib/mobilizon_web/resolvers/report.ex @@ -91,7 +91,7 @@ defmodule MobilizonWeb.Resolvers.Report do when is_moderator(role) do with {:is_owned, %Actor{}} <- User.owns_actor(user, moderator_id), %Report{} = report <- Reports.get_report(report_id), - %Actor{} = moderator <- Actors.get_local_actor_with_everything(moderator_id), + %Actor{} = moderator <- Actors.get_local_actor_with_preload(moderator_id), {:ok, %Note{} = note} <- MobilizonWeb.API.Reports.create_report_note(report, moderator, content) do {:ok, note} @@ -106,7 +106,7 @@ defmodule MobilizonWeb.Resolvers.Report do when is_moderator(role) do with {:is_owned, %Actor{}} <- User.owns_actor(user, moderator_id), %Note{} = note <- Reports.get_note(note_id), - %Actor{} = moderator <- Actors.get_local_actor_with_everything(moderator_id), + %Actor{} = moderator <- Actors.get_local_actor_with_preload(moderator_id), {:ok, %Note{} = note} <- MobilizonWeb.API.Reports.delete_report_note(note, moderator) do {:ok, %{id: note.id}} diff --git a/lib/mobilizon_web/views/activity_pub/actor_view.ex b/lib/mobilizon_web/views/activity_pub/actor_view.ex index 2d57cc278..00dbd0416 100644 --- a/lib/mobilizon_web/views/activity_pub/actor_view.ex +++ b/lib/mobilizon_web/views/activity_pub/actor_view.ex @@ -1,6 +1,7 @@ defmodule MobilizonWeb.ActivityPub.ActorView do use MobilizonWeb, :view + alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils @@ -47,8 +48,8 @@ defmodule MobilizonWeb.ActivityPub.ActorView do def render("following.json", %{actor: actor, page: page}) do %{total: total, elements: following} = - if Actor.public_visibility?(actor), - do: Actor.get_followings(actor, page), + if Actor.is_public_visibility(actor), + do: Actors.get_followings(actor, page), else: @private_visibility_empty_collection following @@ -58,8 +59,8 @@ defmodule MobilizonWeb.ActivityPub.ActorView do def render("following.json", %{actor: actor}) do %{total: total, elements: following} = - if Actor.public_visibility?(actor), - do: Actor.get_followings(actor), + if Actor.is_public_visibility(actor), + do: Actors.get_followings(actor), else: @private_visibility_empty_collection %{ @@ -73,8 +74,8 @@ defmodule MobilizonWeb.ActivityPub.ActorView do def render("followers.json", %{actor: actor, page: page}) do %{total: total, elements: followers} = - if Actor.public_visibility?(actor), - do: Actor.get_followers(actor, page), + if Actor.is_public_visibility(actor), + do: Actors.get_followers(actor, page), else: @private_visibility_empty_collection followers @@ -84,8 +85,8 @@ defmodule MobilizonWeb.ActivityPub.ActorView do def render("followers.json", %{actor: actor}) do %{total: total, elements: followers} = - if Actor.public_visibility?(actor), - do: Actor.get_followers(actor), + if Actor.is_public_visibility(actor), + do: Actors.get_followers(actor), else: @private_visibility_empty_collection %{ @@ -99,7 +100,7 @@ defmodule MobilizonWeb.ActivityPub.ActorView do def render("outbox.json", %{actor: actor, page: page}) do %{total: total, elements: followers} = - if Actor.public_visibility?(actor), + if Actor.is_public_visibility(actor), do: ActivityPub.fetch_public_activities_for_actor(actor, page), else: @private_visibility_empty_collection @@ -110,7 +111,7 @@ defmodule MobilizonWeb.ActivityPub.ActorView do def render("outbox.json", %{actor: actor}) do %{total: total, elements: followers} = - if Actor.public_visibility?(actor), + if Actor.is_public_visibility(actor), do: ActivityPub.fetch_public_activities_for_actor(actor), else: @private_visibility_empty_collection diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index c0d98a0ae..cccdae211 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -113,6 +113,29 @@ defmodule Mobilizon.Service.ActivityPub do end end + + @doc """ + Getting an actor from url, eventually creating it + """ + @spec get_or_fetch_by_url(String.t(), boolean) :: {:ok, Actor.t()} | {:error, String.t()} + def get_or_fetch_by_url(url, preload \\ false) do + case Actors.get_actor_by_url(url, preload) do + {:ok, %Actor{} = actor} -> + {:ok, actor} + + _ -> + case make_actor_from_url(url, preload) do + {:ok, %Actor{} = actor} -> + {:ok, actor} + + _ -> + Logger.warn("Could not fetch by AP id") + + {:error, "Could not fetch by AP id"} + end + end + end + @doc """ Create an activity of type "Create" """ @@ -279,7 +302,7 @@ defmodule Mobilizon.Service.ActivityPub do """ def follow(%Actor{} = follower, %Actor{} = followed, activity_id \\ nil, local \\ true) do with {:ok, %Follower{url: follow_url}} <- - Actor.follow(followed, follower, activity_id, false), + Actors.follow(followed, follower, activity_id, false), activity_follow_id <- activity_id || follow_url, data <- make_follow_data(followed, follower, activity_follow_id), @@ -298,7 +321,7 @@ defmodule Mobilizon.Service.ActivityPub do """ @spec unfollow(Actor.t(), Actor.t(), String.t(), boolean()) :: {:ok, map()} | any() def unfollow(%Actor{} = follower, %Actor{} = followed, activity_id \\ nil, local \\ true) do - with {:ok, %Follower{id: follow_id}} <- Actor.unfollow(followed, follower), + with {:ok, %Follower{id: follow_id}} <- Actors.unfollow(followed, follower), # We recreate the follow activity data <- make_follow_data( @@ -466,7 +489,7 @@ defmodule Mobilizon.Service.ActivityPub do def make_actor_from_url(url, preload \\ false) do case fetch_and_prepare_actor_from_url(url) do {:ok, data} -> - Actors.insert_or_update_actor(data, preload) + Actors.upsert_actor(data, preload) # Request returned 410 {:error, :actor_deleted} -> @@ -529,7 +552,7 @@ defmodule Mobilizon.Service.ActivityPub do followers = if actor.followers_url in activity.recipients do - Actor.get_full_external_followers(actor) + Actors.get_full_external_followers(actor) else [] end diff --git a/lib/service/activity_pub/converters/comment.ex b/lib/service/activity_pub/converters/comment.ex index d412ad457..920d699e8 100644 --- a/lib/service/activity_pub/converters/comment.ex +++ b/lib/service/activity_pub/converters/comment.ex @@ -4,7 +4,6 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Comment do This module allows to convert events from ActivityStream format to our own internal one, and back """ - alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Events.Comment, as: CommentModel alias Mobilizon.Events.Event @@ -20,7 +19,7 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Comment do @impl Converter @spec as_to_model_data(map()) :: map() def as_to_model_data(object) do - {:ok, %Actor{id: actor_id}} = Actors.get_or_fetch_by_url(object["actor"]) + {:ok, %Actor{id: actor_id}} = ActivityPub.get_or_fetch_by_url(object["actor"]) Logger.debug("Inserting full comment") Logger.debug(inspect(object)) diff --git a/lib/service/activity_pub/relay.ex b/lib/service/activity_pub/relay.ex index 933644c68..96e2bedb6 100644 --- a/lib/service/activity_pub/relay.ex +++ b/lib/service/activity_pub/relay.ex @@ -24,7 +24,7 @@ defmodule Mobilizon.Service.ActivityPub.Relay do def follow(target_instance) do with %Actor{} = local_actor <- get_actor(), - {:ok, %Actor{} = target_actor} <- Actors.get_or_fetch_by_url(target_instance), + {:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_by_url(target_instance), {:ok, activity} <- Follows.follow(local_actor, target_actor) do Logger.info("Relay: followed instance #{target_instance}; id=#{activity.data["id"]}") {:ok, activity} @@ -37,7 +37,7 @@ defmodule Mobilizon.Service.ActivityPub.Relay do def unfollow(target_instance) do with %Actor{} = local_actor <- get_actor(), - {:ok, %Actor{} = target_actor} <- Actors.get_or_fetch_by_url(target_instance), + {:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_by_url(target_instance), {:ok, activity} <- Follows.unfollow(local_actor, target_actor) do Logger.info("Relay: unfollowed instance #{target_instance}: id=#{activity.data["id"]}") {:ok, activity} @@ -50,7 +50,7 @@ defmodule Mobilizon.Service.ActivityPub.Relay do def accept(target_instance) do with %Actor{} = local_actor <- get_actor(), - {:ok, %Actor{} = target_actor} <- Actors.get_or_fetch_by_url(target_instance), + {:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_by_url(target_instance), {:ok, activity} <- Follows.accept(target_actor, local_actor) do {:ok, activity} end @@ -58,7 +58,7 @@ defmodule Mobilizon.Service.ActivityPub.Relay do # def reject(target_instance) do # with %Actor{} = local_actor <- get_actor(), - # {:ok, %Actor{} = target_actor} <- Actors.get_or_fetch_by_url(target_instance), + # {:ok, %Actor{} = target_actor} <- Activity.get_or_fetch_by_url(target_instance), # {:ok, activity} <- Follows.reject(target_actor, local_actor) do # {:ok, activity} # end diff --git a/lib/service/activity_pub/transmogrifier.ex b/lib/service/activity_pub/transmogrifier.ex index 2d5de4370..a94548aa5 100644 --- a/lib/service/activity_pub/transmogrifier.ex +++ b/lib/service/activity_pub/transmogrifier.ex @@ -139,7 +139,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object} = data) do Logger.info("Handle incoming to create notes") - with {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(data["actor"]) do + with {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_by_url(data["actor"]) do Logger.debug("found actor") Logger.debug(inspect(actor)) @@ -163,7 +163,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Event"} = object} = data) do Logger.info("Handle incoming to create event") - with {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(data["actor"]) do + with {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_by_url(data["actor"]) do Logger.debug("found actor") Logger.debug(inspect(actor)) @@ -187,8 +187,8 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do def handle_incoming( %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = _data ) do - with {:ok, %Actor{} = followed} <- Actors.get_or_fetch_by_url(followed, true), - {:ok, %Actor{} = follower} <- Actors.get_or_fetch_by_url(follower), + with {:ok, %Actor{} = followed} <- ActivityPub.get_or_fetch_by_url(followed, true), + {:ok, %Actor{} = follower} <- ActivityPub.get_or_fetch_by_url(follower), {:ok, activity, object} <- ActivityPub.follow(follower, followed, id, false) do {:ok, activity, object} else @@ -207,7 +207,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do } = data ) do with actor_url <- get_actor(data), - {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(actor_url), + {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_by_url(actor_url), {:object_not_found, {:ok, activity, object}} <- {:object_not_found, do_handle_incoming_accept_following(accepted_object, actor) || @@ -236,7 +236,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do %{"type" => "Reject", "object" => rejected_object, "actor" => _actor, "id" => id} = data ) do with actor_url <- get_actor(data), - {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(actor_url), + {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_by_url(actor_url), {:object_not_found, {:ok, activity, object}} <- {:object_not_found, do_handle_incoming_reject_following(rejected_object, actor) || @@ -279,7 +279,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data ) do with actor <- get_actor(data), - {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(actor), + {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_by_url(actor), {:ok, object} <- fetch_obj_helper_as_activity_streams(object_id), public <- Visibility.is_public?(data), {:ok, activity, object} <- ActivityPub.announce(actor, object, id, false, public) do @@ -347,7 +347,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do } = data ) do with actor <- get_actor(data), - {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(actor), + {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_by_url(actor), {:ok, object} <- fetch_obj_helper_as_activity_streams(object_id), {:ok, activity, object} <- ActivityPub.unannounce(actor, object, id, cancelled_activity_id, false) do @@ -451,7 +451,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do # } = data # ) do # with actor <- get_actor(data), - # %Actor{} = actor <- Actors.get_or_fetch_by_url(actor), + # %Actor{} = actor <- ActivityPub.get_or_fetch_by_url(actor), # {:ok, object} <- fetch_obj_helper(object_id) || fetch_obj_helper(object_id), # {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do # {:ok, activity} diff --git a/lib/service/export/feed.ex b/lib/service/export/feed.ex index e58f05e3e..39fe709d2 100644 --- a/lib/service/export/feed.ex +++ b/lib/service/export/feed.ex @@ -44,7 +44,7 @@ defmodule Mobilizon.Service.Export.Feed do @spec fetch_actor_event_feed(String.t()) :: String.t() defp fetch_actor_event_feed(name) do with %Actor{} = actor <- Actors.get_local_actor_by_name(name), - {:visibility, true} <- {:visibility, Actor.public_visibility?(actor)}, + {:visibility, true} <- {:visibility, Actor.is_public_visibility(actor)}, {:ok, events, _count} <- Events.get_public_events_for_actor(actor) do {:ok, build_actor_feed(actor, events)} else diff --git a/lib/service/export/icalendar.ex b/lib/service/export/icalendar.ex index 866685b33..6a67a3072 100644 --- a/lib/service/export/icalendar.ex +++ b/lib/service/export/icalendar.ex @@ -44,7 +44,7 @@ defmodule Mobilizon.Service.Export.ICalendar do """ @spec export_public_actor(Actor.t()) :: String.t() def export_public_actor(%Actor{} = actor) do - with true <- Actor.public_visibility?(actor), + with true <- Actor.is_public_visibility(actor), {:ok, events, _} <- Events.get_public_events_for_actor(actor) do {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} end diff --git a/lib/service/http_signatures/signature.ex b/lib/service/http_signatures/signature.ex index 4fc6dcfb3..0012ea02c 100644 --- a/lib/service/http_signatures/signature.ex +++ b/lib/service/http_signatures/signature.ex @@ -7,20 +7,23 @@ defmodule Mobilizon.Service.HTTPSignatures.Signature do @moduledoc """ Adapter for the `HTTPSignatures` lib that handles signing and providing public keys to verify HTTPSignatures """ + @behaviour HTTPSignatures.Adapter alias Mobilizon.Actors.Actor alias Mobilizon.Service.ActivityPub + require Logger def key_id_to_actor_url(key_id) do - uri = - URI.parse(key_id) + %{path: path} = uri = + key_id + |> URI.parse() |> Map.put(:fragment, nil) uri = - if not is_nil(uri.path) and String.ends_with?(uri.path, "/publickey") do - Map.put(uri, :path, String.replace(uri.path, "/publickey", "")) + if not is_nil(path) do + Map.put(uri, :path, String.trim_trailing(path, "/publickey")) else uri end @@ -28,11 +31,47 @@ defmodule Mobilizon.Service.HTTPSignatures.Signature do URI.to_string(uri) end + @doc """ + Convert internal PEM encoded keys to public key format. + """ + @spec prepare_public_key(String.t()) :: {:ok, tuple} | {:error, :pem_decode_error} + def prepare_public_key(public_key_code) do + case :public_key.pem_decode(public_key_code) do + [public_key_entry] -> + {:ok, :public_key.pem_entry_decode(public_key_entry)} + + _ -> + {:error, :pem_decode_error} + end + end + + @doc """ + Gets a public key for a given ActivityPub actor ID (url). + """ + @spec get_public_key_for_url(String.t()) :: + {:ok, String.t()} | {:error, :actor_fetch_error | :pem_decode_error} + def get_public_key_for_url(url) do + with {:ok, %Actor{keys: keys}} <- ActivityPub.get_or_fetch_by_url(url), + {:ok, public_key} <- prepare_public_key(keys) do + {:ok, public_key} + else + {:error, :pem_decode_error} -> + Logger.error("Error while decoding PEM") + + {:error, :pem_decode_error} + + _ -> + Logger.error("Unable to fetch actor, so no keys for you") + + {:error, :actor_fetch_error} + end + end + def fetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), actor_id <- key_id_to_actor_url(kid), :ok <- Logger.debug("Fetching public key for #{actor_id}"), - {:ok, public_key} <- Actor.get_public_key_for_url(actor_id) do + {:ok, public_key} <- get_public_key_for_url(actor_id) do {:ok, public_key} else e -> @@ -45,7 +84,7 @@ defmodule Mobilizon.Service.HTTPSignatures.Signature do actor_id <- key_id_to_actor_url(kid), :ok <- Logger.debug("Refetching public key for #{actor_id}"), {:ok, _actor} <- ActivityPub.make_actor_from_url(actor_id), - {:ok, public_key} <- Actor.get_public_key_for_url(actor_id) do + {:ok, public_key} <- get_public_key_for_url(actor_id) do {:ok, public_key} else e -> @@ -53,12 +92,12 @@ defmodule Mobilizon.Service.HTTPSignatures.Signature do end end - def sign(%Actor{} = actor, headers) do + def sign(%Actor{keys: keys} = actor, headers) do Logger.debug("Signing on behalf of #{actor.url}") Logger.debug("headers") Logger.debug(inspect(headers)) - with {:ok, key} <- actor.keys |> Actor.prepare_public_key() do + with {:ok, key} <- prepare_public_key(keys) do HTTPSignatures.sign(key, actor.url <> "#main-key", headers) end end diff --git a/mix.exs b/mix.exs index 311f0b954..1435bc9d9 100644 --- a/mix.exs +++ b/mix.exs @@ -184,9 +184,9 @@ defmodule Mobilizon.Mixfile do Models: [ Mobilizon.Actors, Mobilizon.Actors.Actor, - Mobilizon.Actors.ActorOpennessEnum, - Mobilizon.Actors.ActorTypeEnum, - Mobilizon.Actors.MemberRoleEnum, + Mobilizon.Actors.ActorOpenness, + Mobilizon.Actors.ActorType, + Mobilizon.Actors.MemberRole, Mobilizon.Actors.Bot, Mobilizon.Actors.Follower, Mobilizon.Actors.Member, diff --git a/priv/repo/migrations/20180517100700_move_from_account_to_actor.exs b/priv/repo/migrations/20180517100700_move_from_account_to_actor.exs index 3ba343e97..8c6b678c7 100644 --- a/priv/repo/migrations/20180517100700_move_from_account_to_actor.exs +++ b/priv/repo/migrations/20180517100700_move_from_account_to_actor.exs @@ -18,7 +18,7 @@ defmodule Mobilizon.Repo.Migrations.MoveFromAccountToActor do drop(table("groups")) rename(table("accounts"), to: table("actors")) - Mobilizon.Actors.ActorTypeEnum.create_type() + Mobilizon.Actors.ActorType.create_type() rename(table("actors"), :username, to: :name) rename(table("actors"), :description, to: :summary) rename(table("actors"), :display_name, to: :preferred_username) @@ -86,7 +86,7 @@ defmodule Mobilizon.Repo.Migrations.MoveFromAccountToActor do modify(:display_name, :string, null: true) end - Mobilizon.Actors.ActorTypeEnum.drop_type() + Mobilizon.Actors.ActorType.drop_type() rename(table("events"), :organizer_actor_id, to: :organizer_account_id) diff --git a/priv/repo/migrations/20190301141830_move_member_role_to_enum.exs b/priv/repo/migrations/20190301141830_move_member_role_to_enum.exs index b0e8dfea1..30a1ca745 100644 --- a/priv/repo/migrations/20190301141830_move_member_role_to_enum.exs +++ b/priv/repo/migrations/20190301141830_move_member_role_to_enum.exs @@ -1,12 +1,12 @@ defmodule Mobilizon.Repo.Migrations.MoveMemberRoleToEnum do use Ecto.Migration - alias Mobilizon.Actors.MemberRoleEnum + alias Mobilizon.Actors.MemberRole def up do - MemberRoleEnum.create_type() + MemberRole.create_type() alter table(:members) do - add(:role_tmp, MemberRoleEnum.type(), default: "member") + add(:role_tmp, MemberRole.type(), default: "member") end execute("UPDATE members set role_tmp = 'member' where role = 0") @@ -39,7 +39,7 @@ defmodule Mobilizon.Repo.Migrations.MoveMemberRoleToEnum do remove(:role) end - MemberRoleEnum.drop_type() + MemberRole.drop_type() rename(table(:members), :role_tmp, to: :role) end diff --git a/priv/repo/migrations/20190301143831_actor_group_openness.exs b/priv/repo/migrations/20190301143831_actor_group_openness.exs index 34c551734..75542a26b 100644 --- a/priv/repo/migrations/20190301143831_actor_group_openness.exs +++ b/priv/repo/migrations/20190301143831_actor_group_openness.exs @@ -1,12 +1,12 @@ defmodule Mobilizon.Repo.Migrations.ActorGroupOpenness do use Ecto.Migration - alias Mobilizon.Actors.ActorOpennessEnum + alias Mobilizon.Actors.ActorOpenness def up do - ActorOpennessEnum.create_type() + ActorOpenness.create_type() alter table(:actors) do - add(:openness, ActorOpennessEnum.type(), default: "moderated") + add(:openness, ActorOpenness.type(), default: "moderated") end end diff --git a/priv/repo/migrations/20190425075451_add_visibility_to_actor.exs b/priv/repo/migrations/20190425075451_add_visibility_to_actor.exs index d100b8191..381d9b8e8 100644 --- a/priv/repo/migrations/20190425075451_add_visibility_to_actor.exs +++ b/priv/repo/migrations/20190425075451_add_visibility_to_actor.exs @@ -1,13 +1,13 @@ defmodule Mobilizon.Repo.Migrations.AddVisibilityToActor do use Ecto.Migration - alias Mobilizon.Actors.ActorVisibilityEnum + alias Mobilizon.Actors.ActorVisibility def up do - ActorVisibilityEnum.create_type() + ActorVisibility.create_type() alter table(:actors) do - add(:visibility, ActorVisibilityEnum.type(), default: "private") + add(:visibility, ActorVisibility.type(), default: "private") end end @@ -16,6 +16,6 @@ defmodule Mobilizon.Repo.Migrations.AddVisibilityToActor do remove(:visibility) end - ActorVisibilityEnum.drop_type() + ActorVisibility.drop_type() end end diff --git a/test/mobilizon/actors/actors_test.exs b/test/mobilizon/actors/actors_test.exs index 2c589a0da..7147cda41 100644 --- a/test/mobilizon/actors/actors_test.exs +++ b/test/mobilizon/actors/actors_test.exs @@ -1,11 +1,15 @@ defmodule Mobilizon.ActorsTest do + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + use Mobilizon.DataCase + import Mobilizon.Factory + alias Mobilizon.{Actors, Config, Users} alias Mobilizon.Actors.{Actor, Member, Follower, Bot} alias Mobilizon.Media.File, as: FileModel - import Mobilizon.Factory - use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + alias Mobilizon.Service.ActivityPub + alias Mobilizon.Storage.Page describe "actors" do @valid_attrs %{ @@ -40,8 +44,6 @@ defmodule Mobilizon.ActorsTest do } @remote_account_url "https://social.tcit.fr/users/tcit" - @remote_account_username "tcit" - @remote_account_domain "social.tcit.fr" setup do user = insert(:user) @@ -70,14 +72,14 @@ defmodule Mobilizon.ActorsTest do assert actor_id == Users.get_actor_for_user(user).id end - test "get_actor_with_everything/1 returns the actor with it's organized events", %{ + test "get_actor_with_preload/1 returns the actor with it's organized events", %{ actor: actor } do - assert Actors.get_actor_with_everything(actor.id).organized_events == [] + assert Actors.get_actor_with_preload(actor.id).organized_events == [] event = insert(:event, organizer_actor: actor) event_found_id = - Actors.get_actor_with_everything(actor.id).organized_events |> hd |> Map.get(:id) + Actors.get_actor_with_preload(actor.id).organized_events |> hd |> Map.get(:id) assert event_found_id == event.id end @@ -97,7 +99,7 @@ defmodule Mobilizon.ActorsTest do preferred_username: preferred_username, domain: domain, avatar: %FileModel{name: picture_name} = _picture - } = _actor} = Actors.get_or_fetch_by_url(@remote_account_url) + } = _actor} = ActivityPub.get_or_fetch_by_url(@remote_account_url) assert picture_name == "avatar" @@ -111,51 +113,51 @@ defmodule Mobilizon.ActorsTest do end end - test "get_local_actor_by_name_with_everything!/1 returns the local actor with it's organized events", + test "get_local_actor_by_name_with_preload!/1 returns the local actor with it's organized events", %{ actor: actor } do - assert Actors.get_local_actor_by_name_with_everything(actor.preferred_username).organized_events == + assert Actors.get_local_actor_by_name_with_preload(actor.preferred_username).organized_events == [] event = insert(:event, organizer_actor: actor) event_found_id = - Actors.get_local_actor_by_name_with_everything(actor.preferred_username).organized_events + Actors.get_local_actor_by_name_with_preload(actor.preferred_username).organized_events |> hd |> Map.get(:id) assert event_found_id == event.id end - test "get_actor_by_name_with_everything!/1 returns the local actor with it's organized events", + test "get_actor_by_name_with_preload!/1 returns the local actor with it's organized events", %{ actor: actor } do - assert Actors.get_actor_by_name_with_everything(actor.preferred_username).organized_events == + assert Actors.get_actor_by_name_with_preload(actor.preferred_username).organized_events == [] event = insert(:event, organizer_actor: actor) event_found_id = - Actors.get_actor_by_name_with_everything(actor.preferred_username).organized_events + Actors.get_actor_by_name_with_preload(actor.preferred_username).organized_events |> hd |> Map.get(:id) assert event_found_id == event.id end - test "get_actor_by_name_with_everything!/1 returns the remote actor with it's organized events" do + test "get_actor_by_name_with_preload!/1 returns the remote actor with it's organized events" do use_cassette "actors/remote_actor_mastodon_tcit" do - with {:ok, %Actor{} = actor} <- Actors.get_or_fetch_by_url(@remote_account_url) do - assert Actors.get_actor_by_name_with_everything( + with {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_by_url(@remote_account_url) do + assert Actors.get_actor_by_name_with_preload( "#{actor.preferred_username}@#{actor.domain}" ).organized_events == [] event = insert(:event, organizer_actor: actor) event_found_id = - Actors.get_actor_by_name_with_everything( + Actors.get_actor_by_name_with_preload( "#{actor.preferred_username}@#{actor.domain}" ).organized_events |> hd @@ -166,42 +168,21 @@ defmodule Mobilizon.ActorsTest do end end - test "get_or_fetch_by_url/1 returns the local actor for the url", %{ - actor: %Actor{preferred_username: preferred_username} = actor - } do - with {:ok, %Actor{domain: domain} = actor} <- Actors.get_or_fetch_by_url(actor.url) do - assert preferred_username == actor.preferred_username - assert is_nil(domain) - end - end - - test "get_or_fetch_by_url/1 returns the remote actor for the url" do - use_cassette "actors/remote_actor_mastodon_tcit" do - with {:ok, %Actor{preferred_username: preferred_username, domain: domain}} <- - Actors.get_or_fetch_by_url!(@remote_account_url) do - assert preferred_username == @remote_account_username - assert domain == @remote_account_domain - end - end - end - - test "test find_local_by_username/1 returns local actors with similar usernames", %{ + test "test get_local_actor_by_username/1 returns local actors with similar usernames", %{ actor: actor } do actor2 = insert(:actor, preferred_username: "tcit") - [%Actor{id: actor_found_id} | tail] = Actors.find_local_by_username("tcit") + [%Actor{id: actor_found_id} | tail] = Actors.get_local_actor_by_username("tcit") %Actor{id: actor2_found_id} = hd(tail) assert MapSet.new([actor_found_id, actor2_found_id]) == MapSet.new([actor.id, actor2.id]) end - test "test find_and_count_actors_by_username_or_name/4 returns actors with similar usernames", - %{ - actor: %Actor{id: actor_id} - } do + test "test build_actors_by_username_or_name_page/4 returns actors with similar usernames", + %{actor: %Actor{id: actor_id}} do use_cassette "actors/remote_actor_mastodon_tcit" do - with {:ok, %Actor{id: actor2_id}} <- Actors.get_or_fetch_by_url(@remote_account_url) do - %{total: 2, elements: actors} = - Actors.find_and_count_actors_by_username_or_name("tcit", [:Person]) + with {:ok, %Actor{id: actor2_id}} <- ActivityPub.get_or_fetch_by_url(@remote_account_url) do + %Page{total: 2, elements: actors} = + Actors.build_actors_by_username_or_name_page("tcit", [:Person]) actors_ids = actors |> Enum.map(& &1.id) @@ -210,35 +191,13 @@ defmodule Mobilizon.ActorsTest do end end - test "test find_and_count_actors_by_username_or_name/4 returns actors with similar names" do + test "test build_actors_by_username_or_name_page/4 returns actors with similar names" do %{total: 0, elements: actors} = - Actors.find_and_count_actors_by_username_or_name("ohno", [:Person]) + Actors.build_actors_by_username_or_name_page("ohno", [:Person]) assert actors == [] end - test "test get_public_key_for_url/1 with local actor", %{actor: actor} do - assert Actor.get_public_key_for_url(actor.url) == - actor.keys |> Mobilizon.Actors.Actor.prepare_public_key() - end - - @remote_actor_key {:ok, - {:RSAPublicKey, - 20_890_513_599_005_517_665_557_846_902_571_022_168_782_075_040_010_449_365_706_450_877_170_130_373_892_202_874_869_873_999_284_399_697_282_332_064_948_148_602_583_340_776_692_090_472_558_740_998_357_203_838_580_321_412_679_020_304_645_826_371_196_718_081_108_049_114_160_630_664_514_340_729_769_453_281_682_773_898_619_827_376_232_969_899_348_462_205_389_310_883_299_183_817_817_999_273_916_446_620_095_414_233_374_619_948_098_516_821_650_069_821_783_810_210_582_035_456_563_335_930_330_252_551_528_035_801_173_640_288_329_718_719_895_926_309_416_142_129_926_226_047_930_429_802_084_560_488_897_717_417_403_272_782_469_039_131_379_953_278_833_320_195_233_761_955_815_307_522_871_787_339_192_744_439_894_317_730_207_141_881_699_363_391_788_150_650_217_284_777_541_358_381_165_360_697_136_307_663_640_904_621_178_632_289_787, - 65_537}} - test "test get_public_key_for_url/1 with remote actor" do - use_cassette "actors/remote_actor_mastodon_tcit" do - assert Actor.get_public_key_for_url(@remote_account_url) == @remote_actor_key - end - end - - test "test get_public_key_for_url/1 with remote actor and bad key" do - use_cassette "actors/remote_actor_mastodon_tcit_actor_deleted" do - assert Actor.get_public_key_for_url(@remote_account_url) == - {:error, :actor_fetch_error} - end - end - test "create_actor/1 with valid data creates a actor" do assert {:ok, %Actor{} = actor} = Actors.create_actor(@valid_attrs) assert actor.summary == "some description" @@ -351,10 +310,6 @@ defmodule Mobilizon.ActorsTest do "/" <> banner_path ) end - - test "change_actor/1 returns a actor changeset", %{actor: actor} do - assert %Ecto.Changeset{} = Actors.change_actor(actor) - end end describe "groups" do @@ -505,8 +460,8 @@ defmodule Mobilizon.ActorsTest do assert {:ok, %Follower{} = follower} = Actors.create_follower(valid_attrs) assert follower.approved == true - assert %{total: 1, elements: [target_actor]} = Actor.get_followings(actor) - assert %{total: 1, elements: [actor]} = Actor.get_followers(target_actor) + assert %{total: 1, elements: [target_actor]} = Actors.get_followings(actor) + assert %{total: 1, elements: [actor]} = Actors.get_followers(target_actor) end test "create_follower/1 with valid data but same actors fails to create a follower", %{ @@ -554,33 +509,28 @@ defmodule Mobilizon.ActorsTest do assert_raise Ecto.NoResultsError, fn -> Actors.get_follower!(follower.id) end end - test "change_follower/1 returns a follower changeset", context do - follower = create_test_follower(context) - assert %Ecto.Changeset{} = Actors.change_follower(follower) - end - test "follow/3 makes an actor follow another", %{actor: actor, target_actor: target_actor} do # Preloading followers/followings - actor = Actors.get_actor_with_everything(actor.id) - target_actor = Actors.get_actor_with_everything(target_actor.id) + actor = Actors.get_actor_with_preload(actor.id) + target_actor = Actors.get_actor_with_preload(target_actor.id) - {:ok, follower} = Actor.follow(target_actor, actor) + {:ok, follower} = Actors.follow(target_actor, actor) assert follower.actor.id == actor.id # Referesh followers/followings - actor = Actors.get_actor_with_everything(actor.id) - target_actor = Actors.get_actor_with_everything(target_actor.id) + actor = Actors.get_actor_with_preload(actor.id) + target_actor = Actors.get_actor_with_preload(target_actor.id) assert target_actor.followers |> Enum.map(& &1.actor_id) == [actor.id] assert actor.followings |> Enum.map(& &1.target_actor_id) == [target_actor.id] # Test if actor is already following target actor - assert {:error, :already_following, msg} = Actor.follow(target_actor, actor) + assert {:error, :already_following, msg} = Actors.follow(target_actor, actor) assert msg =~ "already following" # Test if target actor is suspended target_actor = %{target_actor | suspended: true} - assert {:error, :suspended, msg} = Actor.follow(target_actor, actor) + assert {:error, :suspended, msg} = Actors.follow(target_actor, actor) assert msg =~ "suspended" end end @@ -620,8 +570,8 @@ defmodule Mobilizon.ActorsTest do assert {:ok, %Member{} = member} = Actors.create_member(valid_attrs) assert member.role == :member - assert [group] = Actor.get_groups_member_of(actor) - assert [actor] = Actor.get_members_for_group(group) + assert [group] = Actors.get_groups_member_of(actor) + assert [actor] = Actors.get_members_for_group(group) end test "create_member/1 with valid data but same actors fails to create a member", %{ @@ -666,10 +616,5 @@ defmodule Mobilizon.ActorsTest do assert {:ok, %Member{}} = Actors.delete_member(member) assert_raise Ecto.NoResultsError, fn -> Actors.get_member!(member.id) end end - - test "change_member/1 returns a member changeset", context do - member = create_test_member(context) - assert %Ecto.Changeset{} = Actors.change_member(member) - end end end diff --git a/test/mobilizon/service/activity_pub/activity_pub_test.exs b/test/mobilizon/service/activity_pub/activity_pub_test.exs index c744d3d68..b0712cb87 100644 --- a/test/mobilizon/service/activity_pub/activity_pub_test.exs +++ b/test/mobilizon/service/activity_pub/activity_pub_test.exs @@ -11,7 +11,6 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do alias Mobilizon.Events alias Mobilizon.Events.Event alias Mobilizon.Actors.Actor - alias Mobilizon.Actors alias Mobilizon.Service.HTTPSignatures.Signature alias Mobilizon.Service.ActivityPub use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney @@ -48,7 +47,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do test "returns an actor from url" do use_cassette "activity_pub/fetch_framapiaf.org_users_tcit" do assert {:ok, %Actor{preferred_username: "tcit", domain: "framapiaf.org"}} = - Actors.get_or_fetch_by_url("https://framapiaf.org/users/tcit") + ActivityPub.get_or_fetch_by_url("https://framapiaf.org/users/tcit") end end end diff --git a/test/mobilizon/service/activity_pub/transmogrifier_test.exs b/test/mobilizon/service/activity_pub/transmogrifier_test.exs index cd636a3d9..1c515c6b9 100644 --- a/test/mobilizon/service/activity_pub/transmogrifier_test.exs +++ b/test/mobilizon/service/activity_pub/transmogrifier_test.exs @@ -222,8 +222,8 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do assert data["type"] == "Follow" assert data["id"] == "https://social.tcit.fr/users/tcit#follows/2" - actor = Actors.get_actor_with_everything(actor.id) - assert Actor.following?(Actors.get_actor_by_url!(data["actor"], true), actor) + actor = Actors.get_actor_with_preload(actor.id) + assert Actors.following?(Actors.get_actor_by_url!(data["actor"], true), actor) end # test "it works for incoming follow requests from hubzilla" do @@ -498,7 +498,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do assert data["actor"] == "https://social.tcit.fr/users/tcit" {:ok, followed} = Actors.get_actor_by_url(data["actor"]) - refute Actor.following?(followed, actor) + refute Actors.following?(followed, actor) end # test "it works for incoming blocks" do @@ -581,10 +581,10 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do follower = insert(:actor) followed = insert(:actor) - refute Actor.following?(follower, followed) + refute Actors.following?(follower, followed) {:ok, follow_activity, _} = ActivityPub.follow(follower, followed) - assert Actor.following?(follower, followed) + assert Actors.following?(follower, followed) accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") @@ -605,7 +605,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - assert Actor.following?(follower, followed) + assert Actors.following?(follower, followed) end test "it works for incoming accepts which are referenced by IRI only" do @@ -627,7 +627,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - assert Actor.following?(follower, followed) + assert Actors.following?(follower, followed) end test "it fails for incoming accepts which cannot be correlated" do @@ -646,7 +646,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - refute Actor.following?(follower, followed) + refute Actors.following?(follower, followed) end test "it fails for incoming rejects which cannot be correlated" do @@ -665,7 +665,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - refute Actor.following?(follower, followed) + refute Actors.following?(follower, followed) end test "it works for incoming rejects which are referenced by IRI only" do @@ -674,7 +674,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follow_activity, _} = ActivityPub.follow(follower, followed) - assert Actor.following?(follower, followed) + assert Actors.following?(follower, followed) reject_data = File.read!("test/fixtures/mastodon-reject-activity.json") @@ -684,7 +684,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, %Activity{data: _}, _} = Transmogrifier.handle_incoming(reject_data) - refute Actor.following?(follower, followed) + refute Actors.following?(follower, followed) end test "it rejects activities without a valid ID" do diff --git a/test/mobilizon_web/api/search_test.exs b/test/mobilizon_web/api/search_test.exs index 6f7395f68..d654a48e3 100644 --- a/test/mobilizon_web/api/search_test.exs +++ b/test/mobilizon_web/api/search_test.exs @@ -6,6 +6,8 @@ defmodule MobilizonWeb.API.SearchTest do alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Service.ActivityPub + alias Mobilizon.Storage.Page + alias MobilizonWeb.API.Search import Mock @@ -13,7 +15,7 @@ defmodule MobilizonWeb.API.SearchTest do test "search an user by username" do with_mock ActivityPub, find_or_make_actor_from_nickname: fn "toto@domain.tld" -> {:ok, %Actor{id: 42}} end do - assert {:ok, %{total: 1, elements: [%Actor{id: 42}]}} == + assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} == Search.search_actors("toto@domain.tld", 1, 10, :Person) assert_called(ActivityPub.find_or_make_actor_from_nickname("toto@domain.tld")) @@ -23,7 +25,7 @@ defmodule MobilizonWeb.API.SearchTest do test "search something by URL" do with_mock ActivityPub, fetch_object_from_url: fn "https://social.tcit.fr/users/tcit" -> {:ok, %Actor{id: 42}} end do - assert {:ok, %{total: 1, elements: [%Actor{id: 42}]}} == + assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} == Search.search_actors("https://social.tcit.fr/users/tcit", 1, 10, :Person) assert_called(ActivityPub.fetch_object_from_url("https://social.tcit.fr/users/tcit")) @@ -32,20 +34,20 @@ defmodule MobilizonWeb.API.SearchTest do test "search actors" do with_mock Actors, - find_and_count_actors_by_username_or_name: fn "toto", _type, 1, 10 -> - %{total: 1, elements: [%Actor{id: 42}]} + build_actors_by_username_or_name_page: fn "toto", _type, 1, 10 -> + %Page{total: 1, elements: [%Actor{id: 42}]} end do assert {:ok, %{total: 1, elements: [%Actor{id: 42}]}} = Search.search_actors("toto", 1, 10, :Person) - assert_called(Actors.find_and_count_actors_by_username_or_name("toto", [:Person], 1, 10)) + assert_called(Actors.build_actors_by_username_or_name_page("toto", [:Person], 1, 10)) end end test "search events" do with_mock Events, find_and_count_events_by_name: fn "toto", 1, 10 -> - %{total: 1, elements: [%Event{title: "super toto event"}]} + %Page{total: 1, elements: [%Event{title: "super toto event"}]} end do assert {:ok, %{total: 1, elements: [%Event{title: "super toto event"}]}} = Search.search_events("toto", 1, 10) diff --git a/test/mobilizon_web/controllers/activity_pub_controller_test.exs b/test/mobilizon_web/controllers/activity_pub_controller_test.exs index 54cb958da..c53c420e8 100644 --- a/test/mobilizon_web/controllers/activity_pub_controller_test.exs +++ b/test/mobilizon_web/controllers/activity_pub_controller_test.exs @@ -177,7 +177,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do test "it returns the followers in a collection", %{conn: conn} do actor = insert(:actor, visibility: :public) actor2 = insert(:actor) - Actor.follow(actor, actor2) + Actors.follow(actor, actor2) result = conn @@ -190,7 +190,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do test "it returns no followers for a private actor", %{conn: conn} do actor = insert(:actor, visibility: :private) actor2 = insert(:actor) - Actor.follow(actor, actor2) + Actors.follow(actor, actor2) result = conn @@ -205,7 +205,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do Enum.each(1..15, fn _ -> other_actor = insert(:actor) - Actor.follow(actor, other_actor) + Actors.follow(actor, other_actor) end) result = @@ -229,7 +229,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do test "it returns the followings in a collection", %{conn: conn} do actor = insert(:actor) actor2 = insert(:actor, visibility: :public) - Actor.follow(actor, actor2) + Actors.follow(actor, actor2) result = conn @@ -242,7 +242,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do test "it returns no followings for a private actor", %{conn: conn} do actor = insert(:actor) actor2 = insert(:actor, visibility: :private) - Actor.follow(actor, actor2) + Actors.follow(actor, actor2) result = conn @@ -257,7 +257,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do Enum.each(1..15, fn _ -> other_actor = insert(:actor) - Actor.follow(other_actor, actor) + Actors.follow(other_actor, actor) end) result = @@ -322,7 +322,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do # Enum.each(1..15, fn _ -> # actor = Repo.get(Actor, actor.id) # other_actor = insert(:actor) - # Actor.follow(actor, other_actor) + # Actors.follow(actor, other_actor) # end) # result = diff --git a/test/support/factory.ex b/test/support/factory.ex index 97974657d..a11634d39 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -1,10 +1,13 @@ defmodule Mobilizon.Factory do @moduledoc """ - Factory for fixtures with ExMachina + Factory for fixtures with ExMachina. """ - # with Ecto + use ExMachina.Ecto, repo: Mobilizon.Storage.Repo + alias Mobilizon.Actors.Actor + alias Mobilizon.Crypto + alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Endpoint alias MobilizonWeb.Upload @@ -21,10 +24,6 @@ defmodule Mobilizon.Factory do end def actor_factory do - key = :public_key.generate_key({:rsa, 2048, 65_537}) - entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) - pem = [entry] |> :public_key.pem_encode() |> String.trim_trailing() - preferred_username = sequence("thomas") %Mobilizon.Actors.Actor{ @@ -32,7 +31,7 @@ defmodule Mobilizon.Factory do domain: nil, followers: [], followings: [], - keys: pem, + keys: Crypto.generate_rsa_2048_private_key(), type: :Person, avatar: build(:file, name: "Avatar"), banner: build(:file, name: "Banner"), diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index f35a0a5f4..22f675806 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -22,7 +22,7 @@ defmodule Mix.Tasks.Mobilizon.RelayTest do {:ok, target_actor} = Actors.get_actor_by_url(target_instance) refute is_nil(target_actor.domain) - assert Actor.following?(local_actor, target_actor) + assert Actors.following?(local_actor, target_actor) end end end @@ -36,11 +36,11 @@ defmodule Mix.Tasks.Mobilizon.RelayTest do %Actor{} = local_actor = Relay.get_actor() {:ok, %Actor{} = target_actor} = Actors.get_actor_by_url(target_instance) - assert %Follower{} = Actor.following?(local_actor, target_actor) + assert %Follower{} = Actors.following?(local_actor, target_actor) Mix.Tasks.Mobilizon.Relay.run(["unfollow", target_instance]) - refute Actor.following?(local_actor, target_actor) + refute Actors.following?(local_actor, target_actor) end end end