diff --git a/config/config.exs b/config/config.exs index 28978814f..9d19f5b02 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,7 +7,7 @@ use Mix.Config # General application configuration config :mobilizon, - ecto_repos: [Mobilizon.Repo] + ecto_repos: [Mobilizon.Storage.Repo] config :mobilizon, :instance, name: System.get_env("MOBILIZON_INSTANCE_NAME") || "Localhost", @@ -78,7 +78,7 @@ config :mobilizon, MobilizonWeb.Guardian, secret_key: "ty0WM7YBE3ojvxoUQxo8AERrNpfbXnIJ82ovkPdqbUFw31T5LcK8wGjaOiReVQjo" config :guardian, Guardian.DB, - repo: Mobilizon.Repo, + repo: Mobilizon.Storage.Repo, # default schema_name: "guardian_tokens", # store all token types if not set diff --git a/config/dev.exs b/config/dev.exs index db854ff92..e3b746515 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -61,11 +61,11 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime -config :mobilizon, Mobilizon.Mailer, adapter: Bamboo.LocalAdapter +config :mobilizon, MobilizonWeb.Email.Mailer, adapter: Bamboo.LocalAdapter # Configure your database -config :mobilizon, Mobilizon.Repo, - types: Mobilizon.PostgresTypes, +config :mobilizon, Mobilizon.Storage.Repo, + types: Mobilizon.Storage.PostgresTypes, username: System.get_env("MOBILIZON_DATABASE_USERNAME") || "mobilizon", password: System.get_env("MOBILIZON_DATABASE_PASSWORD") || "mobilizon", database: System.get_env("MOBILIZON_DATABASE_DBNAME") || "mobilizon_dev", diff --git a/config/dogma.exs b/config/dogma.exs deleted file mode 100644 index 8c2d5141f..000000000 --- a/config/dogma.exs +++ /dev/null @@ -1,16 +0,0 @@ -use Mix.Config -alias Dogma.Rule - -config :dogma, - # Select a set of rules as a base - rule_set: Dogma.RuleSet.All, - - # Pick paths not to lint - exclude: [ - ~r(\Alib/vendor/) - ], - - # Override an existing rule configuration - override: [ - %Rule.LineLength{enabled: false} - ] diff --git a/config/prod.exs b/config/prod.exs index 79835c89b..334790b5e 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -12,8 +12,8 @@ config :mobilizon, MobilizonWeb.Endpoint, cache_static_manifest: "priv/static/manifest.json" # Configure your database -config :mobilizon, Mobilizon.Repo, - types: Mobilizon.PostgresTypes, +config :mobilizon, Mobilizon.Storage.Repo, + types: Mobilizon.Storage.PostgresTypes, username: System.get_env("MOBILIZON_DATABASE_USERNAME") || "mobilizon", password: System.get_env("MOBILIZON_DATABASE_PASSWORD") || "mobilizon", database: System.get_env("MOBILIZON_DATABASE_DBNAME") || "mobilizon_prod", @@ -21,7 +21,7 @@ config :mobilizon, Mobilizon.Repo, port: System.get_env("MOBILIZON_DATABASE_PORT") || "5432", pool_size: 15 -config :mobilizon, Mobilizon.Mailer, +config :mobilizon, MobilizonWeb.Email.Mailer, adapter: Bamboo.SMTPAdapter, server: "localhost", hostname: "localhost", diff --git a/config/test.exs b/config/test.exs index 21432393b..c72f44b4d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -22,16 +22,15 @@ config :logger, level: :info # Configure your database -config :mobilizon, Mobilizon.Repo, - types: Mobilizon.PostgresTypes, +config :mobilizon, Mobilizon.Storage.Repo, + types: Mobilizon.Storage.PostgresTypes, username: System.get_env("MOBILIZON_DATABASE_USERNAME") || "mobilizon", password: System.get_env("MOBILIZON_DATABASE_PASSWORD") || "mobilizon", database: System.get_env("MOBILIZON_DATABASE_DBNAME") || "mobilizon_test", hostname: System.get_env("MOBILIZON_DATABASE_HOST") || "localhost", - pool: Ecto.Adapters.SQL.Sandbox, - types: Mobilizon.PostgresTypes + pool: Ecto.Adapters.SQL.Sandbox -config :mobilizon, Mobilizon.Mailer, adapter: Bamboo.TestAdapter +config :mobilizon, MobilizonWeb.Email.Mailer, adapter: Bamboo.TestAdapter config :mobilizon, MobilizonWeb.Upload, filters: [], link_name: false diff --git a/lib/mix/tasks/mobilizon/create_bot.ex b/lib/mix/tasks/mobilizon/create_bot.ex index c4b85dd22..13c321316 100644 --- a/lib/mix/tasks/mobilizon/create_bot.ex +++ b/lib/mix/tasks/mobilizon/create_bot.ex @@ -15,7 +15,7 @@ defmodule Mix.Tasks.Mobilizon.CreateBot do Mix.Task.run("app.start") with {:ok, %User{} = user} <- Users.get_user_by_email(email, true), - actor <- Actors.register_bot_account(%{name: name, summary: summary}), + actor <- Actors.register_bot(%{name: name, summary: summary}), {:ok, %Bot{} = bot} <- Actors.create_bot(%{ "type" => type, diff --git a/lib/mobilizon.ex b/lib/mobilizon.ex index 1953b2895..7b37e7a14 100644 --- a/lib/mobilizon.ex +++ b/lib/mobilizon.ex @@ -1,9 +1,92 @@ defmodule Mobilizon do @moduledoc """ - Mobilizon is a decentralized and federated Meetup-like using [ActivityPub](http://activitypub.rocks/). + Mobilizon is a decentralized and federated Meetup-like using + [ActivityPub](http://activitypub.rocks/). - It consists of an API server build with [Elixir](http://elixir-lang.github.io/) and the [Phoenix Framework](https://hexdocs.pm/phoenix). + It consists of an API server build with [Elixir](http://elixir-lang.github.io/) + and the [Phoenix Framework](https://hexdocs.pm/phoenix). - Mobilizon relies on `Guardian` for auth and `Geo`/Postgis for geographical informations. + Mobilizon relies on `Guardian` for auth and `Geo`/Postgis for geographical + information. """ + + use Application + + import Cachex.Spec + + alias Mobilizon.Config + alias Mobilizon.Service.Export.{Feed, ICalendar} + + @name Mix.Project.config()[:name] + @version Mix.Project.config()[:version] + + @spec named_version :: String.t() + def named_version, do: "#{@name} #{@version}" + + @spec user_agent :: String.t(:w) + def user_agent do + info = "#{MobilizonWeb.Endpoint.url()} <#{Config.get([:instance, :email], "")}>" + + "#{named_version()}; #{info}" + end + + @spec start(:normal | {:takeover, node} | {:failover, node}, term) :: + {:ok, pid} | {:ok, pid, term} | {:error, term} + def start(_type, _args) do + children = [ + # supervisors + Mobilizon.Storage.Repo, + MobilizonWeb.Endpoint, + # workers + Guardian.DB.Token.SweeperServer, + Mobilizon.Service.Federator, + cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1), + cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1), + cachex_spec(:statistics, 10, 60, 60), + cachex_spec(:activity_pub, 2500, 3, 15) + ] + + opts = [strategy: :one_for_one, name: Mobilizon.Supervisor] + + Supervisor.start_link(children, opts) + end + + @spec config_change(keyword, keyword, [atom]) :: :ok + def config_change(changed, _new, removed) do + MobilizonWeb.Endpoint.config_change(changed, removed) + + :ok + end + + @spec cachex_spec(atom, integer, integer, integer, function | nil) :: Supervisor.child_spec() + defp cachex_spec(name, limit, default, interval, fallback \\ nil) do + %{ + id: :"cache_#{name}", + start: + {Cachex, :start_link, + [ + name, + Keyword.merge( + cachex_options(limit, default, interval), + fallback_options(fallback) + ) + ]} + } + end + + @spec cachex_options(integer, integer, integer) :: keyword + defp cachex_options(limit, default, interval) do + [ + limit: limit, + expiration: + expiration( + default: :timer.minutes(default), + interval: :timer.seconds(interval) + ) + ] + end + + @spec fallback_options(function | nil) :: keyword + defp fallback_options(nil), do: [] + defp fallback_options(fallback), do: [fallback: fallback(default: fallback)] end diff --git a/lib/mobilizon/activity.ex b/lib/mobilizon/activity.ex deleted file mode 100644 index 2bcdc4fed..000000000 --- a/lib/mobilizon/activity.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Mobilizon.Activity do - @moduledoc """ - Represents an activity - """ - - defstruct [:data, :local, :actor, :recipients, :notifications] -end diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex index 2cf983479..610c68a73 100644 --- a/lib/mobilizon/actors/actor.ex +++ b/lib/mobilizon/actors/actor.ex @@ -1,52 +1,115 @@ -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 - alias Mobilizon.Actors - alias Mobilizon.Users.User - alias Mobilizon.Actors.{Actor, Follower, Member} + alias Mobilizon.{Actors, Config, Crypto} + alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member} alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Media.File - alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Users.User alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Endpoint - import Ecto.Query - import Mobilizon.Ecto - alias Mobilizon.Repo - 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: [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 -- [:url] + @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,258 +118,212 @@ 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, __MODULE__, join_through: Member) timestamps() end + @doc """ + Checks whether actor visibility is public. + """ + @spec is_public_visibility(t) :: boolean + def is_public_visibility(%__MODULE__{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(t) :: String.t() + def display_name(%__MODULE__{name: name} = actor) when name in [nil, ""] do + preferred_username_and_domain(actor) + end + + def display_name(%__MODULE__{name: name}), do: name + + @doc """ + Returns display name and username. + """ + @spec display_name_and_username(t) :: String.t() + def display_name_and_username(%__MODULE__{name: name} = actor) when name in [nil, ""] do + preferred_username_and_domain(actor) + end + + def display_name_and_username(%__MODULE__{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(t) :: String.t() + def preferred_username_and_domain(%__MODULE__{ + preferred_username: preferred_username, + domain: nil + }) do + preferred_username + end + + def preferred_username_and_domain(%__MODULE__{ + preferred_username: preferred_username, + domain: domain + }) do + "#{preferred_username}@#{domain}" + end + @doc false - def changeset(%Actor{} = actor, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = 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 - def update_changeset(%Actor{} = actor, attrs) do + @spec update_changeset(t, map) :: Ecto.Changeset.t() + def update_changeset(%__MODULE__{} = 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() - def registration_changeset(%Actor{} = actor, attrs) do + @spec registration_changeset(t, map) :: Ecto.Changeset.t() + def registration_changeset(%__MODULE__{} = 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 = - %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 - ]) + @spec remote_actor_creation_changeset(map) :: Ecto.Changeset.t() + def remote_actor_creation_changeset(attrs) do + changeset = + %__MODULE__{} + |> 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" => Mobilizon.CommonConfig.get([:instance, :name], "Mobilizon"), - "summary" => - Mobilizon.CommonConfig.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(%__MODULE__{}, relay_creation_attrs, @relay_creation_attrs) end @doc """ Changeset for group creation """ - @spec group_creation(struct(), map()) :: Ecto.Changeset.t() - def group_creation(%Actor{} = actor, params) do + @spec group_creation_changeset(t, map) :: Ecto.Changeset.t() + def group_creation_changeset(%__MODULE__{} = 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") + %__MODULE__{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 @@ -314,19 +331,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 @@ -334,267 +351,24 @@ 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(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(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 - """ - @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) + @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 7ab1a9b0b..66b53fa9c 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -3,107 +3,163 @@ defmodule Mobilizon.Actors do The Actors context. """ - import Ecto.Query, warn: false - import Mobilizon.Ecto + import Ecto.Query + import EctoEnum - alias Mobilizon.Repo - - alias Mobilizon.Actors.{Actor, Bot, Member, Follower} - alias Mobilizon.Media.File alias Ecto.Multi - alias Mobilizon.Service.ActivityPub + alias Mobilizon.Actors.{Actor, Bot, Follower, Member} + alias Mobilizon.{Crypto, Events} + alias Mobilizon.Media.File + 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 + ]) - @doc false - def query(queryable, _params) do - queryable - end + defenum(ActorOpenness, :actor_openness, [ + :invite_only, + :moderated, + :open + ]) - @doc """ - Returns the list of actors. + defenum(ActorVisibility, :actor_visibility, [ + :public, + :unlisted, + # Probably unused + :restricted, + :private + ]) - ## Examples + defenum(MemberRole, :member_role, [ + :not_approved, + :member, + :moderator, + :administrator, + :creator + ]) - iex> Mobilizon.Actors.list_actors() - [%Mobilizon.Actors.Actor{}] - - """ - @spec list_actors() :: list() - def list_actors do - Repo.all(Actor) - end + @public_visibility [:public, :unlisted] + @administrator_roles [:creator, :administrator] @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() | nil + def get_actor(id), do: Repo.get(Actor, id) @doc """ - Returns an actor with every relation + Gets a single actor. + Raises `Ecto.NoResultsError` if the actor does not exist. """ - @spec get_actor_with_everything(integer()) :: Mobilizon.Actors.Actor.t() - def get_actor_with_everything(id) do + @spec get_actor!(integer | String.t()) :: Actor.t() + def get_actor!(id), do: Repo.get!(Actor, id) + + @doc """ + Gets an actor with preloaded relations. + """ + @spec get_actor_with_preload(integer | String.t()) :: Actor.t() | nil + def get_actor_with_preload(id) 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 + query = from(a in Actor) + + query + |> filter_by_type(type) + |> filter_by_name(String.split(name, "@")) + |> Repo.one() + end + + @doc """ + Gets a local actor by its preferred username. + """ + @spec get_local_actor_by_name(String.t()) :: Actor.t() | nil + def get_local_actor_by_name(name) do + query = from(a in Actor) + + query + |> filter_by_name([name]) + |> Repo.one() + end + + @doc """ + Gets a local actor by its preferred username and preloaded relations + (organized events, followers and followings). + """ + @spec get_local_actor_by_name_with_preload(String.t()) :: Actor.t() | nil + def get_local_actor_by_name_with_preload(name) do + name + |> get_local_actor_by_name() + |> Repo.preload([:organized_events, :followers, :followings]) + end + + @doc """ + Gets an actor by name and preloads the organized events. + """ + @spec get_actor_by_name_with_preload(String.t(), atom() | nil) :: Actor.t() | nil + def get_actor_by_name_with_preload(name, type \\ nil) do + name + |> get_actor_by_name(type) + |> Repo.preload(:organized_events) + 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) @@ -111,17 +167,26 @@ 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{}} - + Creates a new person actor. """ + @spec new_person(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} + def new_person(args) do + args = Map.put(args, :keys, Crypto.generate_rsa_2048_private_key()) + + with {:ok, %Actor{} = person} <- + %Actor{} + |> Actor.registration_changeset(args) + |> Repo.insert() do + Events.create_feed_token(%{"user_id" => args["user_id"], "actor_id" => person.id}) + + {:ok, person} + end + end + + @doc """ + Updates an actor. + """ + @spec update_actor(Actor.t(), map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} def update_actor(%Actor{} = actor, attrs) do actor |> Actor.update_changeset(attrs) @@ -129,43 +194,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} @@ -174,533 +242,138 @@ defmodule Mobilizon.Actors do end end - def delete_actor(%Actor{} = actor) do - Repo.delete(actor) - end + def delete_actor(%Actor{} = actor), do: Repo.delete(actor) @doc """ - Returns an `%Ecto.Changeset{}` for tracking actor changes. - - ## Examples - - iex> change_actor(%Actor{}) - %Ecto.Changeset{data: %Mobilizon.Actors.Actor{}} - + Returns the list of actors. """ - @spec change_actor(Actor.t()) :: Ecto.Changeset.t() - def change_actor(%Actor{} = actor) do - Actor.changeset(actor, %{}) - end + @spec list_actors :: [Actor.t()] + def list_actors, do: Repo.all(Actor) @doc """ - List the groups + Returns the list of local actors by their username. """ - @spec list_groups(number(), number()) :: list(Actor.t()) - def list_groups(page \\ nil, limit \\ nil) do - Repo.all( - from( - a in Actor, - where: a.type == ^:Group, - where: a.visibility in [^:public, ^:unlisted] - ) - |> paginate(page, limit) - ) - end - - @doc """ - Get the default member role depending on the actor openness - """ - @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) - ) - end - - @doc """ - Creates a group. - - ## Examples - - iex> create_group(%{name: "group name"}) - {:ok, %Mobilizon.Actors.Actor{}} - - iex> create_group(%{name: nil}) - {:error, %Ecto.Changeset{}} - - """ - def create_group(attrs \\ %{}) do - %Actor{} - |> Actor.group_creation(attrs) - |> Repo.insert() - end - - @doc """ - Delete a group - """ - def delete_group!(%Actor{type: :Group} = group) do - Repo.delete!(group) - end - - @doc """ - Upsert an actor. - - Conflicts on actor's URL/AP ID. Replaces keys, avatar and banner, name and summary. - """ - @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) + @spec list_local_actor_by_username(String.t()) :: [Actor.t()] + def list_local_actor_by_username(username) do + username + |> actor_by_username_query() + |> filter_local() + |> Repo.all() |> Repo.preload(:organized_events) end @doc """ - Returns a cached local actor by username + Builds a page struct for actors by their name or displayed name. """ - @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( + @spec build_actors_by_username_or_name_page( 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 = - 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 - ) - ) - |> 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)} + [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 """ - Get a group by its actor id + Gets a group by its title. """ + @spec get_group_by_title(String.t()) :: Actor.t() | nil + def get_group_by_title(title) do + group_query() + |> filter_by_name(String.split(title, "@")) + |> Repo.one() + end + + @doc """ + Gets a local group by its title. + """ + @spec get_local_group_by_title(String.t()) :: Actor.t() | nil + def get_local_group_by_title(title) do + group_query() + |> filter_by_name([title]) + |> Repo.one() + end + + @doc """ + Gets a group by its actor id. + """ + @spec get_group_by_actor_id(integer | String.t()) :: + {:ok, Actor.t()} | {:error, :group_not_found} def get_group_by_actor_id(actor_id) do case Repo.get_by(Actor, id: actor_id, type: :Group) do - nil -> {:error, :group_not_found} - actor -> {:ok, actor} - end - end + nil -> + {:error, :group_not_found} - @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) - - with {:ok, %Actor{} = person} <- - %Actor{} - |> Actor.registration_changeset(args) - |> Repo.insert() do - Mobilizon.Events.create_feed_token(%{"user_id" => args["user_id"], "actor_id" => person.id}) - {:ok, person} - end - end - - @doc """ - Register a new bot actor. - """ - @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, - summary: summary, - type: :Service - }) - - try do - Mobilizon.Repo.insert!(actor) - rescue - e in Ecto.InvalidChangesetError -> - {:error, e.changeset} - end - end - - def get_or_create_service_actor_by_url(url, preferred_username \\ "relay") do - case get_actor_by_url(url) do - {:ok, %Actor{} = actor} -> + actor -> {:ok, actor} - - _ -> - %{url: url, preferred_username: preferred_username} - |> Actor.relay_creation() - |> Repo.insert() end end - alias Mobilizon.Actors.Member + @doc """ + Creates a group. + """ + @spec create_group(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} + def create_group(attrs \\ %{}) do + %Actor{} + |> Actor.group_creation_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Deletes a group. + """ + def delete_group!(%Actor{type: :Group} = group), do: Repo.delete!(group) + + @doc """ + Lists the groups. + """ + @spec list_groups(integer | nil, integer | nil) :: [Actor.t()] + def list_groups(page \\ nil, limit \\ nil) do + groups_query() + |> Page.paginate(page, limit) + |> Repo.all() + end + + @doc """ + Returns the list of groups an actor is member of. + """ + @spec list_groups_member_of(Actor.t()) :: [Actor.t()] + def list_groups_member_of(%Actor{id: actor_id}) do + actor_id + |> groups_member_of_query() + |> Repo.all() + end @doc """ Gets a single member. - - Raises `Ecto.NoResultsError` if the Member does not exist. - - ## Examples - - iex> get_member!(123) - %Mobilizon.Actors.Member{} - - iex> get_member!(456) - ** (Ecto.NoResultsError) - + Raises `Ecto.NoResultsError` if the member does not exist. """ + @spec get_member!(integer | String.t()) :: Member.t() def get_member!(id), do: Repo.get!(Member, id) @doc """ - Creates a member. - - ## Examples - - iex> create_member(%{actor: %Actor{}}) - {:ok, %Mobilizon.Actors.Member{}} - - iex> create_member(%{actor: nil}) - {:error, %Ecto.Changeset{}} - + Gets a single member of an actor (for example a group). """ + @spec get_member(integer | String.t(), integer | String.t()) :: + {:ok, Member.t()} | {:error, :member_not_found} + def get_member(actor_id, parent_id) do + case Repo.get_by(Member, actor_id: actor_id, parent_id: parent_id) do + nil -> + {:error, :member_not_found} + + member -> + {:ok, member} + end + end + + @doc """ + Creates a member. + """ + @spec create_member(map) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()} def create_member(attrs \\ %{}) do with {:ok, %Member{} = member} <- %Member{} @@ -712,16 +385,8 @@ defmodule Mobilizon.Actors do @doc """ Updates a member. - - ## Examples - - iex> update_member(%Member{}, %{role: 3}) - {:ok, %Mobilizon.Actors.Member{}} - - iex> update_member(%Member{}, %{role: nil}) - {:error, %Ecto.Changeset{}} - """ + @spec update_member(Member.t(), map) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()} def update_member(%Member{} = member, attrs) do member |> Member.changeset(attrs) @@ -729,113 +394,71 @@ defmodule Mobilizon.Actors do end @doc """ - Deletes a Member. - - ## Examples - - iex> delete_member(%Member{}) - {:ok, %Mobilizon.Actors.Member{}} - - iex> delete_member(%Member{}) - {:error, %Ecto.Changeset{}} - + Deletes a member. """ - def delete_member(%Member{} = member) do - Repo.delete(member) + @spec delete_member(Member.t()) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()} + def delete_member(%Member{} = member), do: Repo.delete(member) + + @doc """ + Returns the list of members for an actor. + """ + @spec list_members_for_actor(Actor.t()) :: [Member.t()] + def list_members_for_actor(%Actor{id: actor_id}) do + actor_id + |> members_for_actor_query() + |> Repo.all() 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 members for a group. """ - def change_member(%Member{} = member) do - Member.changeset(member, %{}) + @spec list_members_for_group(Actor.t()) :: [Member.t()] + def list_members_for_group(%Actor{id: group_id, type: :Group}) do + group_id + |> members_for_group_query() + |> Repo.all() end @doc """ - Returns the memberships for an actor + Returns the list of administrator members for a group. """ - @spec groups_memberships_for_actor(Actor.t()) :: list(Member.t()) - def groups_memberships_for_actor(%Actor{id: id} = _actor) do - Repo.all( - from( - m in Member, - where: m.actor_id == ^id, - preload: [:parent] - ) - ) + @spec list_administrator_members_for_group(integer | String.t(), integer | nil, integer | nil) :: + [Member.t()] + def list_administrator_members_for_group(id, page \\ nil, limit \\ nil) do + id + |> administrator_members_for_group_query() + |> Page.paginate(page, limit) + |> Repo.all() end @doc """ - Returns the memberships for a group + Returns the list of all group ids where the actor_id is the last administrator. """ - @spec memberships_for_group(Actor.t()) :: list(Member.t()) - def memberships_for_group(%Actor{type: :Group, id: id} = _group) do - Repo.all( - from( - m in Member, - where: m.parent_id == ^id, - preload: [:parent, :actor] - ) - ) - end - - alias Mobilizon.Actors.Bot - - @doc """ - Returns the list of bots. - - ## Examples - - iex> list_bots() - [%Mobilizon.Actors.Bot{}] - - """ - def list_bots do - Repo.all(Bot) + @spec list_group_ids_where_last_administrator(integer | String.t()) :: [integer] + def list_group_ids_where_last_administrator(actor_id) do + actor_id + |> group_ids_where_last_administrator_query() + |> Repo.all() end @doc """ Gets a single bot. - - Raises `Ecto.NoResultsError` if the Bot does not exist. - - ## Examples - - iex> get_bot!(123) - %Mobilizon.Actors.Bot{} - - iex> get_bot!(456) - ** (Ecto.NoResultsError) - + Raises `Ecto.NoResultsError` if the bot does not exist. """ def get_bot!(id), do: Repo.get!(Bot, id) @doc """ - Get the bot associated to an actor + Gets the bot associated to an actor. """ - @spec get_bot_by_actor(Actor.t()) :: Bot.t() - def get_bot_by_actor(%Actor{} = actor) do - Repo.get_by!(Bot, actor_id: actor.id) + @spec get_bot_for_actor(Actor.t()) :: Bot.t() + def get_bot_for_actor(%Actor{id: actor_id}) do + Repo.get_by!(Bot, actor_id: actor_id) end @doc """ Creates a bot. - - ## Examples - - iex> create_bot(%{source: "toto"}) - {:ok, %Mobilizon.Actors.Bot{}} - - iex> create_bot(%{source: nil}) - {:error, %Ecto.Changeset{}} - """ + @spec create_bot(map) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()} def create_bot(attrs \\ %{}) do %Bot{} |> Bot.changeset(attrs) @@ -843,17 +466,41 @@ defmodule Mobilizon.Actors do end @doc """ - Updates a bot. - - ## Examples - - iex> update_bot(%Bot{}, %{source: "new"}) - {:ok, %Mobilizon.Actors.Bot{}} - - iex> update_bot(%Bot{}, %{source: nil}) - {:error, %Ecto.Changeset{}} - + Registers a new bot. """ + @spec register_bot(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} + def register_bot(%{name: name, summary: summary}) do + attrs = %{ + preferred_username: name, + domain: nil, + keys: Crypto.generate_rsa_2048_private_key(), + summary: summary, + type: :Service + } + + %Actor{} + |> Actor.registration_changeset(attrs) + |> Repo.insert() + end + + @spec get_or_create_actor_by_url(String.t(), String.t()) :: + {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} + def get_or_create_actor_by_url(url, preferred_username \\ "relay") do + case get_actor_by_url(url) do + {:ok, %Actor{} = actor} -> + {:ok, actor} + + _ -> + %{url: url, preferred_username: preferred_username} + |> Actor.relay_creation_changeset() + |> Repo.insert() + end + end + + @doc """ + Updates a bot. + """ + @spec update_bot(Bot.t(), map) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()} def update_bot(%Bot{} = bot, attrs) do bot |> Bot.changeset(attrs) @@ -861,90 +508,52 @@ defmodule Mobilizon.Actors do end @doc """ - Deletes a Bot. - - ## Examples - - iex> delete_bot(%Bot{}) - {:ok, %Mobilizon.Actors.Bot{}} - - iex> delete_bot(%Bot{}) - {:error, %Ecto.Changeset{}} - + Deletes a bot. """ - def delete_bot(%Bot{} = bot) do - Repo.delete(bot) - end + @spec delete_bot(Bot.t()) :: {:ok, Bot.t()} | {:error, Ecto.Changeset.t()} + def delete_bot(%Bot{} = bot), do: Repo.delete(bot) @doc """ - Returns an `%Ecto.Changeset{}` for tracking bot changes. - - ## Examples - - iex> change_bot(%Bot{}) - %Ecto.Changeset{data: %Mobilizon.Actors.Bot{}} - + Returns the list of bots. """ - def change_bot(%Bot{} = bot) do - Bot.changeset(bot, %{}) - end - - alias Mobilizon.Actors.Follower + @spec list_bots :: [Bot.t()] + def list_bots, do: Repo.all(Bot) @doc """ Gets a single follower. - - Raises `Ecto.NoResultsError` if the Follower does not exist. - - ## Examples - - iex> get_follower!(123) - %Mobilizon.Actors.Follower{} - - iex> get_follower!(456) - ** (Ecto.NoResultsError) - + Raises `Ecto.NoResultsError` if the follower does not exist. """ + @spec get_follower!(integer | String.t()) :: Follower.t() def get_follower!(id) do - Repo.get!(Follower, id) + Follower + |> Repo.get!(id) |> Repo.preload([:actor, :target_actor]) end @doc """ - Get a follow by the followed actor and following actor + Get a follower by the url. """ - @spec get_follower(Actor.t(), Actor.t()) :: Follower.t() - def get_follower(%Actor{id: followed_id}, %Actor{id: follower_id}) do - Repo.one( - from(f in Follower, where: f.target_actor_id == ^followed_id and f.actor_id == ^follower_id) - ) + @spec get_follower_by_url(String.t()) :: Follower.t() + def get_follower_by_url(url) do + url + |> follower_by_url() + |> Repo.one() end @doc """ - Get a follow by the followed actor and following actor + Gets a follower by the followed actor and following actor """ - @spec get_follow_by_url(String.t()) :: Follower.t() - def get_follow_by_url(url) do - Repo.one( - from(f in Follower, - where: f.url == ^url, - preload: [:actor, :target_actor] - ) - ) + @spec get_follower_by_followed_and_following(Actor.t(), Actor.t()) :: Follower.t() | nil + def get_follower_by_followed_and_following(%Actor{id: followed_id}, %Actor{id: following_id}) do + followed_id + |> follower_by_followed_and_following_query(following_id) + |> Repo.one() end @doc """ Creates a follower. - - ## Examples - - iex> create_follower(%{actor: %Actor{}}) - {:ok, %Mobilizon.Actors.Follower{}} - - iex> create_follower(%{actor: nil}) - {:error, %Ecto.Changeset{}} - """ + @spec create_follower(map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} def create_follower(attrs \\ %{}) do with {:ok, %Follower{} = follower} <- %Follower{} @@ -956,16 +565,8 @@ defmodule Mobilizon.Actors do @doc """ Updates a follower. - - ## Examples - - iex> update_follower(Follower{}, %{approved: true}) - {:ok, %Mobilizon.Actors.Follower{}} - - iex> update_follower(Follower{}, %{approved: nil}) - {:error, %Ecto.Changeset{}} - """ + @spec update_follower(Follower.t(), map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} def update_follower(%Follower{} = follower, attrs) do follower |> Follower.changeset(attrs) @@ -973,64 +574,144 @@ defmodule Mobilizon.Actors do end @doc """ - Deletes a Follower. - - ## Examples - - iex> delete_follower(Follower{}) - {:ok, %Mobilizon.Actors.Follower{}} - - iex> delete_follower(Follower{}) - {:error, %Ecto.Changeset{}} - + Deletes a follower. """ - def delete_follower(%Follower{} = follower) do - Repo.delete(follower) - end + @spec delete_follower(Follower.t()) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} + def delete_follower(%Follower{} = follower), do: Repo.delete(follower) @doc """ - Delete a follower by followed and follower actors - - ## Examples - - iex> delete_follower(%Actor{}, %Actor{}) - {:ok, %Mobilizon.Actors.Follower{}} - - iex> delete_follower(%Actor{}, %Actor{}) - {:error, %Ecto.Changeset{}} - + Deletes a follower by followed and following actors. """ - @spec delete_follower(Actor.t(), Actor.t()) :: + @spec delete_follower_by_followed_and_following(Actor.t(), Actor.t()) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} - def delete_follower(%Actor{} = followed, %Actor{} = follower) do - get_follower(followed, follower) |> Repo.delete() + def delete_follower_by_followed_and_following(%Actor{} = followed, %Actor{} = following) do + followed + |> get_follower_by_followed_and_following(following) + |> Repo.delete() end @doc """ - Returns an `%Ecto.Changeset{}` for tracking follower changes. - - ## Examples - - iex> change_follower(Follower{}) - %Ecto.Changeset{data: %Mobilizon.Actors.Follower{}} - + Returns the list of followers for an actor. + If actor A and C both follow actor B, actor B's followers are A and C. """ - def change_follower(%Follower{} = follower) do - Follower.changeset(follower, %{}) + @spec list_followers_for_actor(Actor.t()) :: [Follower.t()] + def list_followers_for_actor(%Actor{id: actor_id}) do + actor_id + |> followers_for_actor_query() + |> Repo.all() end + @doc """ + Returns the list of external followers for an actor. + """ + @spec list_external_followers_for_actor(Actor.t()) :: [Follower.t()] + def list_external_followers_for_actor(%Actor{id: actor_id}) do + actor_id + |> followers_for_actor_query() + |> filter_external() + |> Repo.all() + end + + @doc """ + Build a page struct for followers of an actor. + """ + @spec build_followers_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t() + def build_followers_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do + actor_id + |> followers_for_actor_query() + |> Page.build_page(page, limit) + end + + @doc """ + Returns the list of followings for an actor. + If actor A follows actor B and C, actor A's followings are B and C. + """ + @spec list_followings_for_actor(Actor.t()) :: [Follower.t()] + def list_followings_for_actor(%Actor{id: actor_id}) do + actor_id + |> followings_for_actor_query() + |> Repo.all() + end + + @doc """ + Build a page struct for followings of an actor. + """ + @spec build_followings_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t() + def build_followings_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do + actor_id + |> followings_for_actor_query() + |> Page.build_page(page, limit) + end + + @doc """ + Makes an actor following another actor. + """ + @spec follow(Actor.t(), Actor.t(), String.t() | nil, boolean | nil) :: + {:ok, Follower.t()} | {:error, atom, String.t()} + def follow(%Actor{} = followed, %Actor{} = follower, url \\ nil, approved \\ true) do + with {:suspended, false} <- {:suspended, followed.suspended}, + # Check if followed has blocked follower + {:already_following, nil} <- {:already_following, is_following(follower, followed)} do + Logger.info( + "Making #{follower.preferred_username} follow #{followed.preferred_username} " <> + "(approved: #{approved})" + ) + + create_follower(%{ + "actor_id" => follower.id, + "target_actor_id" => followed.id, + "approved" => approved, + "url" => url + }) + else + {:already_following, %Follower{}} -> + {:error, :already_following, + "Could not follow actor: you are already following #{followed.preferred_username}"} + + {:suspended, _} -> + {:error, :suspended, + "Could not follow actor: #{followed.preferred_username} has been suspended"} + end + end + + @doc """ + Unfollows an actor (removes a Follower record). + """ + @spec unfollow(Actor.t(), Actor.t()) :: + {:ok, Follower.t()} | {:error, Ecto.Changeset.t() | String.t()} + def unfollow(%Actor{} = followed, %Actor{} = follower) do + case {:already_following, is_following(follower, followed)} do + {:already_following, %Follower{} = follow} -> + delete_follower(follow) + + {:already_following, nil} -> + {:error, "Could not unfollow actor: you are not following #{followed.preferred_username}"} + end + end + + @doc """ + Checks whether an actor is following another actor. + """ + @spec is_following(Actor.t(), Actor.t()) :: Follower.t() | nil + def is_following(%Actor{} = follower_actor, %Actor{} = followed_actor) do + get_follower_by_followed_and_following(followed_actor, follower_actor) + end + + @spec remove_banner(Actor.t()) :: {:ok, Actor.t()} defp remove_banner(%Actor{banner: nil} = actor), do: {:ok, actor} defp remove_banner(%Actor{banner: %File{url: url}} = actor) do safe_remove_file(url, actor) end + @spec remove_avatar(Actor.t()) :: {:ok, Actor.t()} defp remove_avatar(%Actor{avatar: nil} = actor), do: {:ok, actor} defp remove_avatar(%Actor{avatar: %File{url: url}} = actor) do safe_remove_file(url, actor) end + @spec safe_remove_file(String.t(), Actor.t()) :: {:ok, Actor.t()} defp safe_remove_file(url, %Actor{} = actor) do case MobilizonWeb.Upload.remove(url) do {:ok, _value} -> @@ -1039,7 +720,224 @@ defmodule Mobilizon.Actors do {:error, error} -> Logger.error("Error while removing an upload file") Logger.debug(inspect(error)) + {:ok, actor} end end + + @spec delete_files_if_media_changed(Ecto.Changeset.t()) :: Ecto.Changeset.t() + defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do + Enum.each([:avatar, :banner], fn key -> + if Map.has_key?(changes, key) do + with %Ecto.Changeset{changes: %{url: new_url}} <- changes[key], + %{url: old_url} <- data |> Map.from_struct() |> Map.get(key), + false <- new_url == old_url do + MobilizonWeb.Upload.remove(old_url) + end + end + end) + + changeset + end + + @spec actor_with_preload_query(integer | String.t()) :: Ecto.Query.t() + defp actor_with_preload_query(actor_id) do + from( + a in Actor, + where: a.id == ^actor_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 groups_member_of_query(integer | String.t()) :: Ecto.Query.t() + defp groups_member_of_query(actor_id) do + from( + a in Actor, + join: m in Member, + on: a.id == m.parent_id, + where: m.actor_id == ^actor_id + ) + end + + @spec groups_query :: Ecto.Query.t() + defp groups_query do + from( + a in Actor, + where: a.type == ^:Group, + where: a.visibility in ^@public_visibility + ) + end + + @spec members_for_actor_query(integer | String.t()) :: Ecto.Query.t() + defp members_for_actor_query(actor_id) do + from( + m in Member, + where: m.actor_id == ^actor_id, + preload: [:parent] + ) + end + + @spec members_for_group_query(integer | String.t()) :: Ecto.Query.t() + defp members_for_group_query(group_id) do + from( + m in Member, + where: m.parent_id == ^group_id, + preload: [:parent, :actor] + ) + end + + @spec administrator_members_for_group_query(integer | String.t()) :: Ecto.Query.t() + defp administrator_members_for_group_query(group_id) do + from( + m in Member, + where: m.parent_id == ^group_id and m.role in ^@administrator_roles, + preload: [:actor] + ) + end + + @spec administrator_members_for_actor_query(integer | String.t()) :: Ecto.Query.t() + defp administrator_members_for_actor_query(actor_id) do + from( + m in Member, + where: m.actor_id == ^actor_id and m.role in ^@administrator_roles, + select: m.parent_id + ) + end + + @spec group_ids_where_last_administrator_query(integer | String.t()) :: Ecto.Query.t() + defp group_ids_where_last_administrator_query(actor_id) do + from( + m in Member, + where: m.role in ^@administrator_roles, + join: m2 in subquery(administrator_members_for_actor_query(actor_id)), + on: m.parent_id == m2.parent_id, + group_by: m.parent_id, + select: m.parent_id, + having: count(m.actor_id) == 1 + ) + end + + @spec follower_by_url(String.t()) :: Ecto.Query.t() + defp follower_by_url(url) do + from( + f in Follower, + where: f.url == ^url, + preload: [:actor, :target_actor] + ) + end + + @spec follower_by_followed_and_following_query(integer | String.t(), integer | String.t()) :: + Ecto.Query.t() + defp follower_by_followed_and_following_query(followed_id, follower_id) do + from( + f in Follower, + where: f.target_actor_id == ^followed_id and f.actor_id == ^follower_id + ) + end + + @spec followers_for_actor_query(integer | String.t()) :: Ecto.Query.t() + defp followers_for_actor_query(actor_id) do + from( + a in Actor, + join: f in Follower, + on: a.id == f.actor_id, + where: f.target_actor_id == ^actor_id + ) + end + + @spec followings_for_actor_query(integer | String.t()) :: Ecto.Query.t() + defp followings_for_actor_query(actor_id) do + from( + a in Actor, + join: f in Follower, + on: a.id == f.target_actor_id, + where: f.actor_id == ^actor_id + ) + end + + @spec filter_local(Ecto.Query.t()) :: Ecto.Query.t() + defp filter_local(query) do + from(a in query, where: is_nil(a.domain)) + end + + @spec filter_external(Ecto.Query.t()) :: Ecto.Query.t() + defp filter_external(query) do + from(a in query, where: not is_nil(a.domain)) + end + + @spec filter_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 end diff --git a/lib/mobilizon/actors/bot.ex b/lib/mobilizon/actors/bot.ex index cde23e939..8312f44b7 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 - def changeset(bot, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = 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..f38e00fd2 100644 --- a/lib/mobilizon/actors/follower.ex +++ b/lib/mobilizon/actors/follower.ex @@ -1,52 +1,66 @@ 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, 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) + {:ok, _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 c3b4767be..97327e6a4 100644 --- a/lib/mobilizon/actors/member.ex +++ b/lib/mobilizon/actors/member.ex @@ -1,101 +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, warn: false - import Mobilizon.Ecto - alias Mobilizon.Actors.Member - alias Mobilizon.Actors.Actor - alias Mobilizon.Repo + alias Mobilizon.Actors.{Actor, 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. + Checks whether the member is an administrator (admin or creator) of the 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] - ) - |> paginate(page, limit) - ) + def is_administrator(%__MODULE__{role: :administrator}), do: {:is_admin, true} + def is_administrator(%__MODULE__{role: :creator}), do: {:is_admin, true} + def is_administrator(%__MODULE__{}), do: {:is_admin, false} + + @doc false + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = member, attrs) do + member + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) + |> unique_constraint(:parent_id, name: :members_actor_parent_unique_index) 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 - """ - 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} end diff --git a/lib/mobilizon/addresses/address.ex b/lib/mobilizon/addresses/address.ex index 97f635668..edbdc5dcf 100644 --- a/lib/mobilizon/addresses/address.ex +++ b/lib/mobilizon/addresses/address.ex @@ -1,12 +1,30 @@ defmodule Mobilizon.Addresses.Address do - @moduledoc "An address for an event or a group" + @moduledoc """ + Represents an address for an event or a group. + """ use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Addresses.Address + alias Mobilizon.Events.Event - # alias Mobilizon.Actors.Actor - @attrs [ + + @type t :: %__MODULE__{ + country: String.t(), + locality: String.t(), + region: String.t(), + description: String.t(), + floor: String.t(), + geom: Geo.PostGIS.Geometry.t(), + postal_code: String.t(), + street: String.t(), + url: String.t(), + origin_id: String.t(), + events: [Event.t()] + } + + @required_attrs [:url] + @optional_attrs [ :description, :floor, :geom, @@ -15,12 +33,9 @@ defmodule Mobilizon.Addresses.Address do :region, :postal_code, :street, - :url, :origin_id ] - @required [ - :url - ] + @attrs @required_attrs ++ @optional_attrs schema "addresses" do field(:country, :string) @@ -33,22 +48,25 @@ defmodule Mobilizon.Addresses.Address do field(:street, :string) field(:url, :string) field(:origin_id, :string) - has_many(:event, Event, foreign_key: :physical_address_id) + + has_many(:events, Event, foreign_key: :physical_address_id) timestamps() end @doc false - def changeset(%Address{} = address, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = address, attrs) do address |> cast(attrs, @attrs) |> set_url() - |> validate_required(@required) + |> validate_required(@required_attrs) end + @spec set_url(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp set_url(%Ecto.Changeset{changes: changes} = changeset) do - url = - Map.get(changes, :url, MobilizonWeb.Endpoint.url() <> "/address/#{Ecto.UUID.generate()}") + uuid = Ecto.UUID.generate() + url = Map.get(changes, :url, "#{MobilizonWeb.Endpoint.url()}/address/#{uuid}") put_change(changeset, :url, url) end diff --git a/lib/mobilizon/addresses/addresses.ex b/lib/mobilizon/addresses/addresses.ex index cc6c407c5..0c250e107 100644 --- a/lib/mobilizon/addresses/addresses.ex +++ b/lib/mobilizon/addresses/addresses.ex @@ -3,82 +3,36 @@ defmodule Mobilizon.Addresses do The Addresses context. """ - import Ecto.Query, warn: false - alias Mobilizon.Repo - require Logger + import Ecto.Query alias Mobilizon.Addresses.Address + alias Mobilizon.Storage.Repo - @geom_types [:point] - - @doc false - def data() do - Dataloader.Ecto.new(Repo, query: &query/2) - end - - @doc false - def query(queryable, _params) do - queryable - end - - @doc """ - Returns the list of addresses. - - ## Examples - - iex> list_addresses() - [%Address{}, ...] - - """ - def list_addresses do - Repo.all(Address) - end + require Logger @doc """ Gets a single address. - - Raises `Ecto.NoResultsError` if the Address does not exist. - - ## Examples - - iex> get_address!(123) - %Address{} - - iex> get_address!(456) - ** (Ecto.NoResultsError) - """ - def get_address!(id), do: Repo.get!(Address, id) - + @spec get_address(integer | String.t()) :: Address.t() | nil def get_address(id), do: Repo.get(Address, id) @doc """ - Gets a single address by it's url - - ## Examples - - iex> get_address_by_url("https://mobilizon.social/addresses/4572") - %Address{} - - iex> get_address_by_url("https://mobilizon.social/addresses/099") - nil + Gets a single address. + Raises `Ecto.NoResultsError` if the address does not exist. """ - def get_address_by_url(url) do - Repo.get_by(Address, url: url) - end + @spec get_address!(integer | String.t()) :: Address.t() + def get_address!(id), do: Repo.get!(Address, id) @doc """ - Creates a address. - - ## Examples - - iex> create_address(%{field: value}) - {:ok, %Address{}} - - iex> create_address(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - + Gets a single address by its url. """ + @spec get_address_by_url(String.t()) :: Address.t() | nil + def get_address_by_url(url), do: Repo.get_by(Address, url: url) + + @doc """ + Creates an address. + """ + @spec create_address(map) :: {:ok, Address.t()} | {:error, Ecto.Changeset.t()} def create_address(attrs \\ %{}) do %Address{} |> Address.changeset(attrs) @@ -89,17 +43,9 @@ defmodule Mobilizon.Addresses do end @doc """ - Updates a address. - - ## Examples - - iex> update_address(address, %{field: new_value}) - {:ok, %Address{}} - - iex> update_address(address, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - + Updates an address. """ + @spec update_address(Address.t(), map) :: {:ok, Address.t()} | {:error, Ecto.Changeset.t()} def update_address(%Address{} = address, attrs) do address |> Address.changeset(attrs) @@ -107,131 +53,96 @@ defmodule Mobilizon.Addresses do end @doc """ - Deletes a Address. - - ## Examples - - iex> delete_address(address) - {:ok, %Address{}} - - iex> delete_address(address) - {:error, %Ecto.Changeset{}} - + Deletes an address. """ - def delete_address(%Address{} = address) do - Repo.delete(address) - end + @spec delete_address(Address.t()) :: {:ok, Address.t()} | {:error, Ecto.Changeset.t()} + def delete_address(%Address{} = address), do: Repo.delete(address) @doc """ - Returns an `%Ecto.Changeset{}` for tracking address changes. - - ## Examples - - iex> change_address(address) - %Ecto.Changeset{source: %Address{}} - + Returns the list of addresses. """ - def change_address(%Address{} = address) do - Address.changeset(address, %{}) - end + @spec list_addresses :: [Address.t()] + def list_addresses, do: Repo.all(Address) @doc """ - Processes raw geo data informations and return a `Geo` geometry which can be one of `Geo.Point`. - """ - # TODO: Unused, remove me - def process_geom(%{"type" => type_input, "data" => data}) do - type = - if !is_atom(type_input) && type_input != nil do - try do - String.to_existing_atom(type_input) - rescue - e in ArgumentError -> - Logger.error("#{type_input} is not an existing atom : #{inspect(e)}") - :invalid_type - end - else - type_input - end + Searches addresses. - if Enum.member?(@geom_types, type) do - case type do - :point -> - process_point(data["latitude"], data["longitude"]) - end - else - {:error, :invalid_type} + We only look at the description for now, and eventually order by object distance. + """ + @spec search_addresses(String.t(), keyword) :: [Address.t()] + def search_addresses(search, options \\ []) do + query = + search + |> search_addresses_query(Keyword.get(options, :limit, 5)) + |> order_by_coords(Keyword.get(options, :coords)) + |> filter_by_contry(Keyword.get(options, :country)) + + case Keyword.get(options, :single, false) do + true -> + Repo.one(query) + + false -> + Repo.all(query) end end - @doc false - def process_geom(nil) do - {:error, nil} - end - - @spec process_point(number(), number()) :: tuple() - defp process_point(latitude, longitude) when is_number(latitude) and is_number(longitude) do - {:ok, %Geo.Point{coordinates: {latitude, longitude}, srid: 4326}} - end - - defp process_point(_, _) do - {:error, "Latitude and longitude must be numbers"} - end - @doc """ - Search addresses in our database + Reverse geocode from coordinates. - We only look at the description for now, and eventually order by object distance + We only take addresses 50km around and sort them by distance. """ - @spec search_addresses(String.t(), list()) :: list(Address.t()) - def search_addresses(search, options \\ []) do - limit = Keyword.get(options, :limit, 5) - - query = from(a in Address, where: ilike(a.description, ^"%#{search}%"), limit: ^limit) - - query = - if coords = Keyword.get(options, :coords, false), - do: - from(a in query, - order_by: [fragment("? <-> ?", a.geom, ^"POINT(#{coords.lon} #{coords.lat})'")] - ), - else: query - - query = - if country = Keyword.get(options, :country, nil), - do: from(a in query, where: ilike(a.country, ^"%#{country}%")), - else: query - - if Keyword.get(options, :single, false) == true, do: Repo.one(query), else: Repo.all(query) - end - - @doc """ - Reverse geocode from coordinates in our database - - We only take addresses 50km around and sort them by distance - """ - @spec reverse_geocode(number(), number(), list()) :: list(Address.t()) + @spec reverse_geocode(number, number, keyword) :: [Address.t()] def reverse_geocode(lon, lat, options) do limit = Keyword.get(options, :limit, 5) radius = Keyword.get(options, :radius, 50_000) - country = Keyword.get(options, :country, nil) + country = Keyword.get(options, :country) srid = Keyword.get(options, :srid, 4326) - import Geo.PostGIS - with {:ok, point} <- Geo.WKT.decode("SRID=#{srid};POINT(#{lon} #{lat})") do - query = - from(a in Address, - order_by: [fragment("? <-> ?", a.geom, ^point)], - limit: ^limit, - where: st_dwithin_in_meters(^point, a.geom, ^radius) - ) - - query = - if country, - do: from(a in query, where: ilike(a.country, ^"%#{country}%")), - else: query - - Repo.all(query) + point + |> addresses_around_query(radius, limit) + |> filter_by_contry(country) + |> Repo.all() end end + + @spec search_addresses_query(String.t(), integer) :: Ecto.Query.t() + defp search_addresses_query(search, limit) do + from( + a in Address, + where: ilike(a.description, ^"%#{search}%"), + limit: ^limit + ) + end + + @spec order_by_coords(Ecto.Query.t(), map | nil) :: Ecto.Query.t() + defp order_by_coords(query, nil), do: query + + defp order_by_coords(query, coords) do + from( + a in query, + order_by: [fragment("? <-> ?", a.geom, ^"POINT(#{coords.lon} #{coords.lat})'")] + ) + end + + @spec filter_by_contry(Ecto.Query.t(), String.t() | nil) :: Ecto.Query.t() + defp filter_by_contry(query, nil), do: query + + defp filter_by_contry(query, country) do + from( + a in query, + where: ilike(a.country, ^"%#{country}%") + ) + end + + @spec addresses_around_query(Geo.geometry(), integer, integer) :: Ecto.Query.t() + defp addresses_around_query(point, radius, limit) do + import Geo.PostGIS + + from(a in Address, + where: st_dwithin_in_meters(^point, a.geom, ^radius), + order_by: [fragment("? <-> ?", a.geom, ^point)], + limit: ^limit + ) + end end diff --git a/lib/mobilizon/admin.ex b/lib/mobilizon/admin.ex deleted file mode 100644 index 33c2286ea..000000000 --- a/lib/mobilizon/admin.ex +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Mobilizon.Admin do - @moduledoc """ - The Admin context. - """ - - import Ecto.Query, warn: false - alias Mobilizon.Repo - import Mobilizon.Ecto - - alias Mobilizon.Admin.ActionLog - - @doc """ - Returns the list of action_logs. - - ## Examples - - iex> list_action_logs() - [%ActionLog{}, ...] - - """ - @spec list_action_logs(integer(), integer()) :: list(ActionLog.t()) - def list_action_logs(page \\ nil, limit \\ nil) do - from( - r in ActionLog, - preload: [:actor], - order_by: [desc: :id] - ) - |> paginate(page, limit) - |> Repo.all() - end - - @doc """ - Creates a action_log. - - ## Examples - - iex> create_action_log(%{field: value}) - {:ok, %ActionLog{}} - - iex> create_action_log(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_action_log(attrs \\ %{}) do - %ActionLog{} - |> ActionLog.changeset(attrs) - |> Repo.insert() - end -end diff --git a/lib/mobilizon/admin/action_log.ex b/lib/mobilizon/admin/action_log.ex index 62977e4ea..49e823043 100644 --- a/lib/mobilizon/admin/action_log.ex +++ b/lib/mobilizon/admin/action_log.ex @@ -8,30 +8,45 @@ defenum(Mobilizon.Admin.ActionLogAction, [ defmodule Mobilizon.Admin.ActionLog do @moduledoc """ - ActionLog entity schema + Represents an action log entity. """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Actors.Actor alias Mobilizon.Admin.ActionLogAction - @timestamps_opts [type: :utc_datetime] + @type t :: %__MODULE__{ + action: String.t(), + target_type: String.t(), + target_id: integer, + changes: map, + actor: Actor.t() + } + @required_attrs [:action, :target_type, :target_id, :changes, :actor_id] + @attrs @required_attrs + + @timestamps_opts [type: :utc_datetime] schema "admin_action_logs" do field(:action, ActionLogAction) field(:target_type, :string) field(:target_id, :integer) field(:changes, :map) + belongs_to(:actor, Actor) timestamps() end @doc false - def changeset(action_log, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = action_log, attrs) do action_log - |> cast(attrs, @required_attrs) - |> validate_required(@required_attrs -- [:changes]) + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) end end diff --git a/lib/mobilizon/admin/admin.ex b/lib/mobilizon/admin/admin.ex new file mode 100644 index 000000000..3d162434f --- /dev/null +++ b/lib/mobilizon/admin/admin.ex @@ -0,0 +1,35 @@ +defmodule Mobilizon.Admin do + @moduledoc """ + The Admin context. + """ + + import Ecto.Query + + alias Mobilizon.Admin.ActionLog + alias Mobilizon.Storage.{Page, Repo} + + @doc """ + Creates a action_log. + """ + @spec create_action_log(map) :: {:ok, ActionLog.t()} | {:error, Ecto.Changeset.t()} + def create_action_log(attrs \\ %{}) do + %ActionLog{} + |> ActionLog.changeset(attrs) + |> 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], order_by: [desc: :id]) + end +end diff --git a/lib/mobilizon/application.ex b/lib/mobilizon/application.ex deleted file mode 100644 index 294831bba..000000000 --- a/lib/mobilizon/application.ex +++ /dev/null @@ -1,112 +0,0 @@ -defmodule Mobilizon.Application do - @moduledoc """ - The Mobilizon application - """ - use Application - import Cachex.Spec - alias Mobilizon.Service.Export.{Feed, ICalendar} - - @name Mix.Project.config()[:name] - @version Mix.Project.config()[:version] - - # See https://hexdocs.pm/elixir/Application.html - # for more information on OTP Applications - def start(_type, _args) do - import Supervisor.Spec - - # Define workers and child supervisors to be supervised - children = [ - # Start the Ecto repository - supervisor(Mobilizon.Repo, []), - # Start the endpoint when the application starts - supervisor(MobilizonWeb.Endpoint, []), - # Start your own worker by calling: Mobilizon.Worker.start_link(arg1, arg2, arg3) - # worker(Mobilizon.Worker, [arg1, arg2, arg3]), - worker( - Cachex, - [ - :feed, - [ - limit: 2500, - expiration: - expiration( - default: :timer.minutes(60), - interval: :timer.seconds(60) - ), - fallback: fallback(default: &Feed.create_cache/1) - ] - ], - id: :cache_feed - ), - worker( - Cachex, - [ - :ics, - [ - limit: 2500, - expiration: - expiration( - default: :timer.minutes(60), - interval: :timer.seconds(60) - ), - fallback: fallback(default: &ICalendar.create_cache/1) - ] - ], - id: :cache_ics - ), - worker( - Cachex, - [ - :statistics, - [ - limit: 10, - expiration: - expiration( - default: :timer.minutes(60), - interval: :timer.seconds(60) - ) - ] - ], - id: :cache_statistics - ), - worker( - Cachex, - [ - :activity_pub, - [ - limit: 2500, - expiration: - expiration( - default: :timer.minutes(3), - interval: :timer.seconds(15) - ) - ] - ], - id: :cache_activity_pub - ), - worker(Guardian.DB.Token.SweeperServer, []), - worker(Mobilizon.Service.Federator, []) - ] - - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options - opts = [strategy: :one_for_one, name: Mobilizon.Supervisor] - Supervisor.start_link(children, opts) - end - - # Tell Phoenix to update the endpoint configuration - # whenever the application is updated. - def config_change(changed, _new, removed) do - MobilizonWeb.Endpoint.config_change(changed, removed) - :ok - end - - def named_version, do: @name <> " " <> @version - - def user_agent do - info = - "#{MobilizonWeb.Endpoint.url()} <#{Mobilizon.CommonConfig.get([:instance, :email], "")}>" - - named_version() <> "; " <> info - end -end diff --git a/lib/mobilizon/common-config.ex b/lib/mobilizon/common-config.ex deleted file mode 100644 index 091857777..000000000 --- a/lib/mobilizon/common-config.ex +++ /dev/null @@ -1,71 +0,0 @@ -defmodule Mobilizon.CommonConfig do - @moduledoc """ - Instance configuration wrapper - """ - - def registrations_open?() do - instance_config() - |> get_in([:registrations_open]) - |> to_bool - end - - def instance_name() do - instance_config() - |> get_in([:name]) - end - - def instance_description() do - instance_config() - |> get_in([:description]) - end - - def instance_hostname() do - instance_config() - |> get_in([:hostname]) - end - - def instance_config(), do: Application.get_env(:mobilizon, :instance) - - defp to_bool(v), do: v == true or v == "true" or v == "True" - - def get(key), do: get(key, nil) - - def get([key], default), do: get(key, default) - - def get([parent_key | keys], default) do - case :mobilizon - |> Application.get_env(parent_key) - |> get_in(keys) do - nil -> default - any -> any - end - end - - def get(key, default) do - Application.get_env(:mobilizon, key, default) - end - - def get!(key) do - value = get(key, nil) - - if value == nil do - raise("Missing configuration value: #{inspect(key)}") - else - value - end - end - - def put([key], value), do: put(key, value) - - def put([parent_key | keys], value) do - parent = - Application.get_env(:mobilizon, parent_key) - |> put_in(keys, value) - - Application.put_env(:mobilizon, parent_key, parent) - end - - def put(key, value) do - Application.put_env(:mobilizon, key, value) - end -end diff --git a/lib/mobilizon/config.ex b/lib/mobilizon/config.ex new file mode 100644 index 000000000..1d3581baa --- /dev/null +++ b/lib/mobilizon/config.ex @@ -0,0 +1,77 @@ +defmodule Mobilizon.Config do + @moduledoc """ + Configuration wrapper. + """ + + @spec instance_config :: keyword + def instance_config, do: Application.get_env(:mobilizon, :instance) + + @spec instance_url :: String.t() + def instance_url, do: instance_config()[:instance] + + @spec instance_name :: String.t() + def instance_name, do: instance_config()[:name] + + @spec instance_description :: String.t() + def instance_description, do: instance_config()[:description] + + @spec instance_version :: String.t() + def instance_version, do: instance_config()[:version] + + @spec instance_hostname :: String.t() + def instance_hostname, do: instance_config()[:hostname] + + @spec instance_registrations_open? :: boolean + def instance_registrations_open?, do: to_boolean(instance_config()[:registrations_open]) + + @spec instance_repository :: String.t() + def instance_repository, do: instance_config()[:repository] + + @spec instance_email_from :: String.t() + def instance_email_from, do: instance_config()[:email_from] + + @spec instance_email_reply_to :: String.t() + def instance_email_reply_to, do: instance_config()[:email_reply_to] + + @spec get(module | atom) :: any + def get(key), do: get(key, nil) + + @spec get([module | atom]) :: any + def get([key], default), do: get(key, default) + + def get([parent_key | keys], default) do + case get_in(Application.get_env(:mobilizon, parent_key), keys) do + nil -> default + any -> any + end + end + + @spec get(module | atom, any) :: any + def get(key, default), do: Application.get_env(:mobilizon, key, default) + + @spec get!(module | atom) :: any + def get!(key) do + value = get(key, nil) + + if value == nil do + raise("Missing configuration value: #{inspect(key)}") + else + value + end + end + + @spec put([module | atom], any) :: any + def put([key], value), do: put(key, value) + + def put([parent_key | keys], value) do + parent = put_in(Application.get_env(:mobilizon, parent_key), keys, value) + + Application.put_env(:mobilizon, parent_key, parent) + end + + @spec put(module | atom, any) :: any + def put(key, value), do: Application.put_env(:mobilizon, key, value) + + @spec to_boolean(boolean | String.t()) :: boolean + defp to_boolean(boolean), do: "true" == String.downcase("#{boolean}") +end diff --git a/lib/mobilizon/crypto.ex b/lib/mobilizon/crypto.ex new file mode 100644 index 000000000..05ced94db --- /dev/null +++ b/lib/mobilizon/crypto.ex @@ -0,0 +1,28 @@ +defmodule Mobilizon.Crypto do + @moduledoc """ + Utility module which contains cryptography related functions. + """ + + @doc """ + Returns random byte sequence of the length encoded to Base64. + """ + @spec random_string(integer) :: String.t() + def random_string(length) do + length + |> :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/ecto.ex b/lib/mobilizon/ecto.ex deleted file mode 100644 index 66fb2ec72..000000000 --- a/lib/mobilizon/ecto.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule Mobilizon.Ecto do - @moduledoc """ - Mobilizon Ecto utils - """ - - import Ecto.Query, warn: false - - @doc """ - Add limit and offset to the query - """ - def paginate(query, page \\ 1, size \\ 10) - def paginate(query, page, _size) when is_nil(page), do: paginate(query) - def paginate(query, page, size) when is_nil(size), do: paginate(query, page) - - def paginate(query, page, size) do - from(query, - limit: ^size, - offset: ^((page - 1) * size) - ) - end - - @doc """ - Add sort to the query - """ - def sort(query, sort, direction) do - from( - query, - order_by: [{^direction, ^sort}] - ) - end - - def increment_slug(slug) do - case List.pop_at(String.split(slug, "-"), -1) do - {nil, _} -> - slug - - {suffix, slug_parts} -> - case Integer.parse(suffix) do - {id, _} -> Enum.join(slug_parts, "-") <> "-" <> Integer.to_string(id + 1) - :error -> slug <> "-1" - end - end - end -end diff --git a/lib/mobilizon/email/admin.ex b/lib/mobilizon/email/admin.ex deleted file mode 100644 index 2157257a6..000000000 --- a/lib/mobilizon/email/admin.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Mobilizon.Email.Admin do - @moduledoc """ - Handles emails sent to admins - """ - alias Mobilizon.Users.User - - import Bamboo.Email - import Bamboo.Phoenix - use Bamboo.Phoenix, view: Mobilizon.EmailView - import MobilizonWeb.Gettext - alias Mobilizon.Reports.Report - - def report(%User{email: email} = _user, %Report{} = report, locale \\ "en") do - Gettext.put_locale(locale) - instance_url = get_config(:hostname) - - base_email() - |> to(email) - |> subject(gettext("Mobilizon: New report on instance %{instance}", instance: instance_url)) - |> put_header("Reply-To", get_config(:email_reply_to)) - |> assign(:report, report) - |> assign(:instance, instance_url) - |> render(:report) - end - - defp base_email do - # Here you can set a default from, default headers, etc. - new_email() - |> from(get_config(:email_from)) - |> put_html_layout({Mobilizon.EmailView, "email.html"}) - |> put_text_layout({Mobilizon.EmailView, "email.text"}) - end - - @spec get_config(atom()) :: any() - defp get_config(key) do - Mobilizon.CommonConfig.instance_config() |> Keyword.get(key) - end -end diff --git a/lib/mobilizon/email/user.ex b/lib/mobilizon/email/user.ex deleted file mode 100644 index 53bdb4a1d..000000000 --- a/lib/mobilizon/email/user.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule Mobilizon.Email.User do - @moduledoc """ - Handles emails sent to users - """ - alias Mobilizon.Users.User - - import Bamboo.Email - import Bamboo.Phoenix - use Bamboo.Phoenix, view: Mobilizon.EmailView - import MobilizonWeb.Gettext - - def confirmation_email(%User{} = user, locale \\ "en") do - Gettext.put_locale(locale) - instance_url = get_config(:instance) - - base_email() - |> to(user.email) - |> subject( - gettext("Mobilizon: Confirmation instructions for %{instance}", instance: instance_url) - ) - |> put_header("Reply-To", get_config(:email_reply_to)) - |> assign(:token, user.confirmation_token) - |> assign(:instance, instance_url) - |> render(:registration_confirmation) - end - - def reset_password_email(%User{} = user, locale \\ "en") do - Gettext.put_locale(locale) - instance_url = get_config(:hostname) - - base_email() - |> to(user.email) - |> subject( - gettext( - "Mobilizon: Reset your password on %{instance} instructions", - instance: instance_url - ) - ) - |> put_header("Reply-To", get_config(:email_reply_to)) - |> assign(:token, user.reset_password_token) - |> assign(:instance, instance_url) - |> render(:password_reset) - end - - defp base_email do - # Here you can set a default from, default headers, etc. - new_email() - |> from(get_config(:email_from)) - |> put_html_layout({Mobilizon.EmailView, "email.html"}) - |> put_text_layout({Mobilizon.EmailView, "email.text"}) - end - - @spec get_config(atom()) :: any() - defp get_config(key) do - Mobilizon.CommonConfig.instance_config() |> Keyword.get(key) - end -end diff --git a/lib/mobilizon/events/comment.ex b/lib/mobilizon/events/comment.ex index 5b2c038ee..a9c9b7869 100644 --- a/lib/mobilizon/events/comment.ex +++ b/lib/mobilizon/events/comment.ex @@ -1,33 +1,42 @@ -import EctoEnum - -defenum(Mobilizon.Events.CommentVisibilityEnum, :comment_visibility_type, [ - :public, - :unlisted, - :private, - :moderated, - :invite -]) - defmodule Mobilizon.Events.Comment do @moduledoc """ - An actor comment (for instance on an event or on a group) + Represents an actor comment (for instance on an event or on a group). """ use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.Event alias Mobilizon.Actors.Actor - alias Mobilizon.Events.Comment + alias Mobilizon.Events.{Comment, CommentVisibility, Event} + alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Endpoint + @type t :: %__MODULE__{ + text: String.t(), + url: String.t(), + local: boolean, + visibility: CommentVisibility.t(), + uuid: Ecto.UUID.t(), + actor: Actor.t(), + attributed_to: Actor.t(), + event: Event.t(), + in_reply_to_comment: t, + origin_comment: t + } + + @required_attrs [:text, :actor_id, :url] + @optional_attrs [:event_id, :in_reply_to_comment_id, :origin_comment_id, :attributed_to_id] + @attrs @required_attrs ++ @optional_attrs + schema "comments" do field(:text, :string) field(:url, :string) field(:local, :boolean, default: true) - field(:visibility, Mobilizon.Events.CommentVisibilityEnum, default: :public) + field(:visibility, CommentVisibility, default: :public) field(:uuid, Ecto.UUID) + belongs_to(:actor, Actor, foreign_key: :actor_id) belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id) belongs_to(:event, Event, foreign_key: :event_id) @@ -37,38 +46,27 @@ defmodule Mobilizon.Events.Comment do timestamps(type: :utc_datetime) end - @doc false - def changeset(comment, attrs) do - uuid = - if Map.has_key?(attrs, "uuid"), - do: attrs["uuid"], - else: Ecto.UUID.generate() - - # TODO : really change me right away - url = - if Map.has_key?(attrs, "url"), - do: attrs["url"], - else: Routes.page_url(Endpoint, :comment, uuid) - - comment - |> Ecto.Changeset.cast(attrs, [ - :url, - :text, - :actor_id, - :event_id, - :in_reply_to_comment_id, - :origin_comment_id, - :attributed_to_id - ]) - |> put_change(:uuid, uuid) - |> put_change(:url, url) - |> validate_required([:text, :actor_id, :url]) - end - @doc """ - Returns the id of the first comment in the conversation + Returns the id of the first comment in the conversation. """ - def get_thread_id(%Comment{id: id, origin_comment_id: origin_comment_id}) do + @spec get_thread_id(t) :: integer + def get_thread_id(%__MODULE__{id: id, origin_comment_id: origin_comment_id}) do origin_comment_id || id end + + @doc false + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = comment, attrs) do + uuid = attrs["uuid"] || Ecto.UUID.generate() + url = attrs["url"] || generate_url(uuid) + + comment + |> cast(attrs, @attrs) + |> put_change(:uuid, uuid) + |> put_change(:url, url) + |> validate_required(@required_attrs) + end + + @spec generate_url(String.t()) :: String.t() + defp generate_url(uuid), do: Routes.page_url(Endpoint, :comment, uuid) end diff --git a/lib/mobilizon/events/event.ex b/lib/mobilizon/events/event.ex index 52d1c765d..8a5d2a3cc 100644 --- a/lib/mobilizon/events/event.ex +++ b/lib/mobilizon/events/event.ex @@ -1,43 +1,87 @@ -import EctoEnum - -defenum(Mobilizon.Events.EventVisibilityEnum, :event_visibility_type, [ - :public, - :unlisted, - :restricted, - :private -]) - -defenum(Mobilizon.Events.JoinOptionsEnum, :event_join_options_type, [ - :free, - :restricted, - :invite -]) - -defenum(Mobilizon.Events.EventStatusEnum, :event_status_type, [ - :tentative, - :confirmed, - :cancelled -]) - -defenum(Mobilizon.Event.EventCategoryEnum, :event_category_type, [ - :business, - :conference, - :birthday, - :demonstration, - :meeting -]) - defmodule Mobilizon.Events.Event do @moduledoc """ - Represents an event + Represents an event. """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.{Event, Participant, Tag, Session, Track} + alias Mobilizon.Actors.Actor - alias Mobilizon.Media.Picture alias Mobilizon.Addresses.Address + alias Mobilizon.Events.{ + EventOptions, + EventStatus, + EventVisibility, + JoinOptions, + Participant, + Tag, + Session, + Track + } + + alias Mobilizon.Media.Picture + + @type t :: %__MODULE__{ + url: String.t(), + local: boolean, + begins_on: DateTime.t(), + slug: String.t(), + description: String.t(), + ends_on: DateTime.t(), + title: String.t(), + status: EventStatus.t(), + visibility: EventVisibility.t(), + join_options: JoinOptions.t(), + publish_at: DateTime.t(), + uuid: Ecto.UUID.t(), + online_address: String.t(), + phone_address: String.t(), + category: String.t(), + options: EventOptions.t(), + organizer_actor: Actor.t(), + attributed_to: Actor.t(), + physical_address: Address.t(), + picture: Picture.t(), + tracks: [Track.t()], + sessions: [Session.t()], + tags: [Tag.t()], + participants: [Actor.t()] + } + + @required_attrs [:title, :begins_on, :organizer_actor_id, :url, :uuid] + @optional_attrs [ + :slug, + :description, + :ends_on, + :category, + :status, + :visibility, + :publish_at, + :online_address, + :phone_address, + :picture_id, + :physical_address_id + ] + @attrs @required_attrs ++ @optional_attrs + + @update_required_attrs @required_attrs + @update_optional_attrs [ + :slug, + :description, + :ends_on, + :category, + :status, + :visibility, + :publish_at, + :online_address, + :phone_address, + :picture_id, + :physical_address_id + ] + @update_attrs @update_required_attrs ++ @update_optional_attrs + schema "events" do field(:url, :string) field(:local, :boolean, default: true) @@ -46,96 +90,59 @@ defmodule Mobilizon.Events.Event do field(:description, :string) field(:ends_on, :utc_datetime) field(:title, :string) - field(:status, Mobilizon.Events.EventStatusEnum, default: :confirmed) - field(:visibility, Mobilizon.Events.EventVisibilityEnum, default: :public) - field(:join_options, Mobilizon.Events.JoinOptionsEnum, default: :free) + field(:status, EventStatus, default: :confirmed) + field(:visibility, EventVisibility, default: :public) + field(:join_options, JoinOptions, default: :free) field(:publish_at, :utc_datetime) field(:uuid, Ecto.UUID, default: Ecto.UUID.generate()) field(:online_address, :string) field(:phone_address, :string) field(:category, :string) - embeds_one(:options, Mobilizon.Events.EventOptions, on_replace: :update) + + embeds_one(:options, EventOptions, on_replace: :update) belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id) belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id) - many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete) - many_to_many(:participants, Actor, join_through: Participant) - has_many(:tracks, Track) - has_many(:sessions, Session) belongs_to(:physical_address, Address) belongs_to(:picture, Picture) + has_many(:tracks, Track) + has_many(:sessions, Session) + many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete) + many_to_many(:participants, Actor, join_through: Participant) timestamps(type: :utc_datetime) end @doc false - def changeset(%Event{} = event, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = event, attrs) do event - |> Ecto.Changeset.cast(attrs, [ - :title, - :slug, - :description, - :url, - :begins_on, - :ends_on, - :organizer_actor_id, - :category, - :status, - :visibility, - :publish_at, - :online_address, - :phone_address, - :uuid, - :picture_id, - :physical_address_id - ]) + |> cast(attrs, @attrs) |> cast_embed(:options) - |> validate_required([ - :title, - :begins_on, - :organizer_actor_id, - :url, - :uuid - ]) + |> validate_required(@required_attrs) end @doc false - def update_changeset(%Event{} = event, attrs) do + @spec update_changeset(t, map) :: Ecto.Changeset.t() + def update_changeset(%__MODULE__{} = event, attrs) do event - |> Ecto.Changeset.cast(attrs, [ - :title, - :slug, - :description, - :begins_on, - :ends_on, - :category, - :status, - :visibility, - :publish_at, - :online_address, - :phone_address, - :picture_id, - :physical_address_id - ]) + |> Ecto.Changeset.cast(attrs, @update_attrs) |> cast_embed(:options) |> put_tags(attrs) - |> validate_required([ - :title, - :begins_on, - :organizer_actor_id, - :url, - :uuid - ]) + |> validate_required(@update_required_attrs) end - defp put_tags(changeset, %{"tags" => tags}), do: put_assoc(changeset, :tags, tags) - defp put_tags(changeset, _), do: changeset - - def can_event_be_managed_by(%Event{organizer_actor_id: organizer_actor_id}, actor_id) + @doc """ + Checks whether an event can be managed. + """ + @spec can_be_managed_by(t, integer | String.t()) :: boolean + def can_be_managed_by(%__MODULE__{organizer_actor_id: organizer_actor_id}, actor_id) when organizer_actor_id == actor_id do {:event_can_be_managed, true} end - def can_event_be_managed_by(_event, _actor) do - {:event_can_be_managed, false} - end + def can_be_managed_by(_event, _actor), do: {:event_can_be_managed, false} + + @spec put_tags(Ecto.Changeset.t(), map) :: Ecto.Changeset.t() + defp put_tags(changeset, %{"tags" => tags}), do: put_assoc(changeset, :tags, tags) + defp put_tags(changeset, _), do: changeset end diff --git a/lib/mobilizon/events/event_offer.ex b/lib/mobilizon/events/event_offer.ex new file mode 100644 index 000000000..c30f67b3e --- /dev/null +++ b/lib/mobilizon/events/event_offer.ex @@ -0,0 +1,19 @@ +defmodule Mobilizon.Events.EventOffer do + @moduledoc """ + Represents an event offer. + """ + + use Ecto.Schema + + @type t :: %__MODULE__{ + price: float, + price_currency: String.t(), + url: String.t() + } + + embedded_schema do + field(:price, :float) + field(:price_currency, :string) + field(:url, :string) + end +end diff --git a/lib/mobilizon/events/event_options.ex b/lib/mobilizon/events/event_options.ex index 7c552d822..e4d5f03c7 100644 --- a/lib/mobilizon/events/event_options.ex +++ b/lib/mobilizon/events/event_options.ex @@ -1,70 +1,58 @@ -import EctoEnum - -defenum(Mobilizon.Events.CommentModeration, :comment_moderation, [:allow_all, :moderated, :closed]) - -defmodule Mobilizon.Events.EventOffer do - @moduledoc """ - Represents an event offer - """ - use Ecto.Schema - - embedded_schema do - field(:price, :float) - field(:price_currency, :string) - field(:url, :string) - end -end - -defmodule Mobilizon.Events.EventParticipationCondition do - @moduledoc """ - Represents an event participation condition - """ - use Ecto.Schema - - embedded_schema do - field(:title, :string) - field(:content, :string) - field(:url, :string) - end -end - defmodule Mobilizon.Events.EventOptions do @moduledoc """ - Represents an event options + Represents an event options. """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Events.{ - EventOptions, EventOffer, EventParticipationCondition, CommentModeration } + @type t :: %__MODULE__{ + maximum_attendee_capacity: integer, + remaining_attendee_capacity: integer, + show_remaining_attendee_capacity: boolean, + attendees: [String.t()], + program: String.t(), + comment_moderation: CommentModeration.t(), + show_participation_price: boolean, + offers: [EventOffer.t()], + participation_condition: [EventParticipationCondition.t()] + } + + @attrs [ + :maximum_attendee_capacity, + :remaining_attendee_capacity, + :show_remaining_attendee_capacity, + :attendees, + :program, + :comment_moderation, + :show_participation_price + ] + @primary_key false @derive Jason.Encoder embedded_schema do field(:maximum_attendee_capacity, :integer) field(:remaining_attendee_capacity, :integer) field(:show_remaining_attendee_capacity, :boolean) - embeds_many(:offers, EventOffer) - embeds_many(:participation_condition, EventParticipationCondition) field(:attendees, {:array, :string}) field(:program, :string) field(:comment_moderation, CommentModeration) field(:show_participation_price, :boolean) + + embeds_many(:offers, EventOffer) + embeds_many(:participation_condition, EventParticipationCondition) end - def changeset(%EventOptions{} = event_options, attrs) do - event_options - |> Ecto.Changeset.cast(attrs, [ - :maximum_attendee_capacity, - :remaining_attendee_capacity, - :show_remaining_attendee_capacity, - :attendees, - :program, - :comment_moderation, - :show_participation_price - ]) + @doc false + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = event_options, attrs) do + cast(event_options, attrs, @attrs) end end diff --git a/lib/mobilizon/events/event_participation_condition.ex b/lib/mobilizon/events/event_participation_condition.ex new file mode 100644 index 000000000..9fd4bff1d --- /dev/null +++ b/lib/mobilizon/events/event_participation_condition.ex @@ -0,0 +1,19 @@ +defmodule Mobilizon.Events.EventParticipationCondition do + @moduledoc """ + Represents an event participation condition. + """ + + use Ecto.Schema + + @type t :: %__MODULE__{ + title: String.t(), + content: String.t(), + url: String.t() + } + + embedded_schema do + field(:title, :string) + field(:content, :string) + field(:url, :string) + end +end diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 33521d4cc..55f7a52b7 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -3,395 +3,226 @@ defmodule Mobilizon.Events do The Events context. """ - import Ecto.Query, warn: false - import Mobilizon.Ecto - - alias Mobilizon.Repo - alias Mobilizon.Events.{Event, Comment, Participant} - alias Mobilizon.Actors.Actor - alias Mobilizon.Users.User - alias Mobilizon.Addresses.Address - - def data() do - Dataloader.Ecto.new(Mobilizon.Repo, query: &query/2) - end - - def query(queryable, _params) do - queryable - end - - def get_public_events_for_actor(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do - query = - from( - e in Event, - where: e.organizer_actor_id == ^actor_id and e.visibility in [^:public, ^:unlisted], - order_by: [desc: :id], - preload: [ - :organizer_actor, - :sessions, - :tracks, - :tags, - :participants, - :physical_address, - :picture - ] - ) - |> paginate(page, limit) - - events = Repo.all(query) - - count_events = - Repo.one(from(e in Event, select: count(e.id), where: e.organizer_actor_id == ^actor_id)) - - {:ok, events, count_events} - end - - @doc """ - Get an actor's eventual upcoming public event - """ - @spec get_actor_upcoming_public_event(Actor.t(), String.t()) :: Event.t() | nil - def get_actor_upcoming_public_event(%Actor{id: actor_id} = _actor, not_event_uuid \\ nil) do - query = - from( - e in Event, - where: - e.organizer_actor_id == ^actor_id and e.visibility in [^:public, ^:unlisted] and - e.begins_on > ^DateTime.utc_now(), - order_by: [asc: :begins_on], - limit: 1, - preload: [ - :organizer_actor, - :tags, - :participants, - :physical_address - ] - ) - - query = - if is_nil(not_event_uuid), - do: query, - else: from(q in query, where: q.uuid != ^not_event_uuid) - - Repo.one(query) - end - - def count_local_events do - Repo.one( - from( - e in Event, - select: count(e.id), - where: e.local == ^true and e.visibility in [^:public, ^:unlisted] - ) - ) - end - - def count_local_comments do - Repo.one( - from( - c in Comment, - select: count(c.id), - where: c.local == ^true and c.visibility in [^:public, ^:unlisted] - ) - ) - end - import Geo.PostGIS - @doc """ - Find close events to coordinates + import Ecto.Query + import EctoEnum - Radius is in meters and defaults to 50km. - """ - @spec find_close_events(number(), number(), number(), number()) :: list(Event.t()) - def find_close_events(lon, lat, radius \\ 50_000, srid \\ 4326) do - with {:ok, point} <- Geo.WKT.decode("SRID=#{srid};POINT(#{lon} #{lat})") do - Repo.all( - from( - e in Event, - join: a in Address, - on: a.id == e.physical_address_id, - where: e.visibility == ^:public and st_dwithin_in_meters(^point, a.geom, ^radius), - preload: :organizer_actor - ) - ) - end - end + import Mobilizon.Storage.Ecto - @doc """ - Gets a single event. + alias Mobilizon.Actors.Actor + alias Mobilizon.Addresses.Address - Raises `Ecto.NoResultsError` if the Event does not exist. + alias Mobilizon.Events.{ + Comment, + Event, + FeedToken, + Participant, + Session, + Tag, + TagRelation, + Track + } - ## Examples + alias Mobilizon.Storage.{Page, Repo} + alias Mobilizon.Users.User - iex> get_event!(123) - %Event{} + defenum(EventVisibility, :event_visibility, [ + :public, + :unlisted, + :restricted, + :private + ]) - iex> get_event!(456) - ** (Ecto.NoResultsError) + defenum(JoinOptions, :join_options, [ + :free, + :restricted, + :invite + ]) - """ - def get_event!(id), do: Repo.get!(Event, id) + defenum(EventStatus, :event_status, [ + :tentative, + :confirmed, + :cancelled + ]) + + defenum(EventCategory, :event_category, [ + :business, + :conference, + :birthday, + :demonstration, + :meeting + ]) + + defenum(CommentVisibility, :comment_visibility, [ + :public, + :unlisted, + :private, + :moderated, + :invite + ]) + + defenum(CommentModeration, :comment_moderation, [ + :allow_all, + :moderated, + :closed + ]) + + defenum(ParticipantRole, :participant_role, [ + :not_approved, + :participant, + :moderator, + :administrator, + :creator + ]) + + @public_visibility [:public, :unlisted] + + @event_preloads [ + :organizer_actor, + :sessions, + :tracks, + :tags, + :participants, + :physical_address, + :picture + ] + + @comment_preloads [:actor, :attributed_to, :in_reply_to_comment] @doc """ Gets a single event. """ + @spec get_event(integer | String.t()) :: {:ok, Event.t()} | {:error, :event_not_found} def get_event(id) do - case Repo.get(Event, id) do - nil -> {:error, :event_not_found} - event -> {:ok, event} - end - end - - @doc """ - Gets an event by it's URL - """ - def get_event_by_url(url) do - Repo.get_by(Event, url: url) - end - - @doc """ - Gets an event by it's URL - """ - def get_event_by_url!(url) do - Repo.get_by!(Event, url: url) - end - - # @doc """ - # Gets an event by it's UUID - # """ - # @depreciated "Use get_event_full_by_uuid/3 instead" - # def get_event_by_uuid(uuid) do - # Repo.get_by(Event, uuid: uuid) - # end - - @doc """ - Gets a full event by it's UUID - """ - @spec get_event_full_by_uuid(String.t()) :: Event.t() - def get_event_full_by_uuid(uuid) do - from( - e in Event, - where: e.uuid == ^uuid and e.visibility in [^:public, ^:unlisted], - preload: [ - :organizer_actor, - :sessions, - :tracks, - :tags, - :participants, - :physical_address, - :picture - ] - ) - |> Repo.one() - end - - def get_cached_event_full_by_uuid(uuid) do - Cachex.fetch(:activity_pub, "event_" <> uuid, fn "event_" <> uuid -> - case get_event_full_by_uuid(uuid) do - %Event{} = event -> - {:commit, event} - - _ -> - {:ignore, nil} - end - end) - end - - @doc """ - Gets a single event, with all associations loaded. - """ - def get_event_full!(id) do - event = Repo.get!(Event, id) - - Repo.preload(event, [ - :organizer_actor, - :sessions, - :tracks, - :tags, - :participants, - :physical_address - ]) - end - - @doc """ - Gets a single event, with all associations loaded. - """ - def get_event_full(id) do case Repo.get(Event, id) do %Event{} = event -> - {:ok, - Repo.preload(event, [ - :organizer_actor, - :sessions, - :tracks, - :tags, - :participants, - :physical_address, - :picture - ])} + {:ok, event} - _err -> + nil -> {:error, :event_not_found} end end @doc """ - Gets an event by it's URL + Gets a single event. + Raises `Ecto.NoResultsError` if the event does not exist. """ - def get_event_full_by_url(url) do - case Repo.one( - from(e in Event, - where: e.url == ^url and e.visibility in [^:public, ^:unlisted], - preload: [ - :organizer_actor, - :sessions, - :tracks, - :tags, - :participants, - :physical_address - ] - ) - ) do - nil -> {:error, :event_not_found} - event -> {:ok, event} + @spec get_event!(integer | String.t()) :: Event.t() + def get_event!(id), do: Repo.get!(Event, id) + + @doc """ + Gets a single event, with all associations loaded. + """ + @spec get_event_with_preload(integer | String.t()) :: + {:ok, Event.t()} | {:error, :event_not_found} + def get_event_with_preload(id) do + case Repo.get(Event, id) do + %Event{} = event -> + {:ok, Repo.preload(event, @event_preloads)} + + nil -> + {:error, :event_not_found} end end @doc """ - Gets an event by it's URL + Gets a single event, with all associations loaded. + Raises `Ecto.NoResultsError` if the event does not exist. """ - def get_event_full_by_url!(url) do - Repo.one( - from(e in Event, - where: e.url == ^url and e.visibility in [^:public, ^:unlisted], - preload: [ - :organizer_actor, - :sessions, - :tracks, - :tags, - :participants, - :physical_address, - :picture - ] - ) - ) + @spec get_event_with_preload!(integer | String.t()) :: Event.t() + def get_event_with_preload!(id) do + Event + |> Repo.get!(id) + |> Repo.preload(@event_preloads) end @doc """ - Returns the list of events. - - ## Examples - - iex> list_events() - [%Event{}, ...] - + Gets an event by its URL. """ - @spec list_events(integer(), integer(), atom(), atom()) :: list(Event.t()) - def list_events( - page \\ nil, - limit \\ nil, - sort \\ :begins_on, - direction \\ :asc, - unlisted \\ false, - future \\ true - ) do - query = - from( - e in Event, - preload: [:organizer_actor, :participants] - ) - |> paginate(page, limit) - |> sort(sort, direction) - |> restrict_future_events(future) - |> allow_unlisted(unlisted) - - Repo.all(query) - end - - # Make sure we only show future events - @spec restrict_future_events(Ecto.Query.t(), boolean()) :: Ecto.Query.t() - defp restrict_future_events(query, true), - do: from(q in query, where: q.begins_on > ^DateTime.utc_now()) - - defp restrict_future_events(query, false), do: query - - # Make sure unlisted events don't show up where they're not allowed - @spec allow_unlisted(Ecto.Query.t(), boolean()) :: Ecto.Query.t() - defp allow_unlisted(query, true), - do: from(q in query, where: q.visibility in [^:public, ^:unlisted]) - - defp allow_unlisted(query, false), do: from(q in query, where: q.visibility == ^:public) - - @doc """ - Find events by name - """ - def find_and_count_events_by_name(name, page \\ nil, limit \\ nil) - - def find_and_count_events_by_name(name, page, limit) do - name = String.trim(name) - - query = - from(e in Event, - where: - e.visibility == ^:public and - fragment( - "f_unaccent(?) %> f_unaccent(?)", - e.title, - ^name - ), - order_by: - fragment( - "word_similarity(?, ?) desc", - e.title, - ^name - ), - preload: [:organizer_actor] - ) - |> 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)} + @spec get_event_by_url(String.t()) :: Event.t() | nil + def get_event_by_url(url) do + url + |> event_by_url_query() + |> Repo.one() end @doc """ - Find events with the same tags + Gets an event by its URL. + Raises `Ecto.NoResultsError` if the event does not exist. """ - @spec find_similar_events_by_common_tags(list(), integer()) :: {:ok, list(Event.t())} - def find_similar_events_by_common_tags(tags, limit \\ 2) do - tags_ids = Enum.map(tags, & &1.id) - - query = - from(e in Event, - distinct: e.uuid, - join: te in "events_tags", - on: e.id == te.event_id, - where: e.begins_on > ^DateTime.utc_now(), - where: e.visibility in [^:public, ^:unlisted], - where: te.tag_id in ^tags_ids, - order_by: [asc: e.begins_on], - limit: ^limit - ) - - Repo.all(query) + @spec get_event_by_url!(String.t()) :: Event.t() + def get_event_by_url!(url) do + url + |> event_by_url_query() + |> Repo.one!() end @doc """ - Creates a event. - - ## Examples - - iex> create_event(%{field: value}) - {:ok, %Event{}} - - iex> create_event(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - + Gets an event by its URL, with all associations loaded. """ + @spec get_public_event_by_url_with_preload(String.t()) :: + {:ok, Event.t()} | {:error, :event_not_found} + def get_public_event_by_url_with_preload(url) do + event = + url + |> event_by_url_query() + |> filter_public_visibility() + |> preload_for_event() + |> Repo.one() + + case event do + %Event{} = event -> + {:ok, event} + + nil -> + {:error, :event_not_found} + end + end + + @doc """ + Gets an event by its URL, with all associations loaded. + Raises `Ecto.NoResultsError` if the event does not exist. + """ + @spec get_public_event_by_url_with_preload(String.t()) :: Event.t() + def get_public_event_by_url_with_preload!(url) do + url + |> event_by_url_query() + |> filter_public_visibility() + |> preload_for_event() + |> Repo.one!() + end + + @doc """ + Gets an event by its UUID, with all associations loaded. + """ + @spec get_public_event_by_uuid_with_preload(String.t()) :: Event.t() | nil + def get_public_event_by_uuid_with_preload(uuid) do + uuid + |> event_by_uuid_query() + |> filter_public_visibility() + |> preload_for_event() + |> Repo.one() + end + + @doc """ + Gets an actor's eventual upcoming public event. + """ + @spec get_upcoming_public_event_for_actor(Actor.t(), String.t() | nil) :: Event.t() | nil + def get_upcoming_public_event_for_actor(%Actor{id: actor_id}, not_event_uuid \\ nil) do + actor_id + |> upcoming_public_event_for_actor_query() + |> filter_public_visibility() + |> filter_not_event_uuid(not_event_uuid) + |> Repo.one() + end + + @doc """ + Creates an event. + """ + @spec create_event(map) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()} def create_event(attrs \\ %{}) do - with %Event{} = event <- do_create_event(attrs), + with {:ok, %Event{} = event} <- do_create_event(attrs), {:ok, %Participant{} = _participant} <- %Participant{} |> Participant.changeset(%{ @@ -404,10 +235,14 @@ defmodule Mobilizon.Events do end end + @spec do_create_event(map) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()} defp do_create_event(attrs) do - with {:ok, %Event{} = event} <- %Event{} |> Event.changeset(attrs) |> Repo.insert(), + with {:ok, %Event{} = event} <- + %Event{} + |> Event.changeset(attrs) + |> Repo.insert(), %Event{} = event <- - event |> Repo.preload([:tags, :organizer_actor, :physical_address, :picture]), + Repo.preload(event, [:tags, :organizer_actor, :physical_address, :picture]), {:has_tags, true, _} <- {:has_tags, Map.has_key?(attrs, "tags"), event} do event |> Ecto.Changeset.change() @@ -415,7 +250,7 @@ defmodule Mobilizon.Events do |> Repo.update() else {:has_tags, false, event} -> - event + {:ok, event} error -> error @@ -423,17 +258,9 @@ defmodule Mobilizon.Events do end @doc """ - Updates a event. - - ## Examples - - iex> update_event(event, %{field: new_value}) - {:ok, %Event{}} - - iex> update_event(event, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - + Updates an event. """ + @spec update_event(Event.t(), map) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()} def update_event(%Event{} = event, attrs) do event |> Repo.preload(:tags) @@ -442,107 +269,134 @@ defmodule Mobilizon.Events do end @doc """ - Deletes a Event. - - ## Examples - - iex> delete_event(event) - {:ok, %Event{}} - - iex> delete_event(event) - {:error, %Ecto.Changeset{}} - + Deletes an event. """ - def delete_event(%Event{} = event) do - Repo.delete(event) - end + @spec delete_event(Event.t()) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()} + def delete_event(%Event{} = event), do: Repo.delete(event) @doc """ - Deletes a Event. - + Deletes an event. Raises an exception if it fails. """ - def delete_event!(%Event{} = event) do - Repo.delete!(event) + @spec delete_event(Event.t()) :: Event.t() + def delete_event!(%Event{} = event), do: Repo.delete!(event) + + @doc """ + Returns the list of events. + """ + @spec list_events(integer | nil, integer | nil, atom, atom, boolean, boolean) :: [Event.t()] + def list_events( + page \\ nil, + limit \\ nil, + sort \\ :begins_on, + direction \\ :asc, + is_unlisted \\ false, + is_future \\ true + ) do + query = from(e in Event, preload: [:organizer_actor, :participants]) + + query + |> Page.paginate(page, limit) + |> sort(sort, direction) + |> filter_future_events(is_future) + |> filter_unlisted(is_unlisted) + |> Repo.all() end @doc """ - Returns an `%Ecto.Changeset{}` for tracking event changes. - - ## Examples - - iex> change_event(event) - %Ecto.Changeset{source: %Event{}} - + Returns the list of events with the same tags. """ - def change_event(%Event{} = event) do - Event.changeset(event, %{}) - end - - alias Mobilizon.Events.Tag - - @doc """ - Returns the list of tags. - - ## Examples - - iex> list_tags() - [%Tag{}, ...] - - """ - def list_tags(page \\ nil, limit \\ nil) do - Repo.all( - Tag - |> paginate(page, limit) - ) + @spec list_events_by_tags([Tag.t()], integer) :: [Event.t()] + def list_events_by_tags(tags, limit \\ 2) do + tags + |> Enum.map(& &1.id) + |> events_by_tags_query(limit) + |> Repo.all() end @doc """ - Returns the list of tags for an event. - - ## Examples - - iex> list_tags_for_event(id) - [%Participant{}, ...] - + Lists public events for the actor, with all associations loaded. """ - def list_tags_for_event(id, page \\ nil, limit \\ nil) do - Repo.all( - from( - t in Tag, - join: e in "events_tags", - on: t.id == e.tag_id, - where: e.event_id == ^id - ) - |> paginate(page, limit) - ) + @spec list_public_events_for_actor(Actor.t(), integer | nil, integer | nil) :: + {:ok, [Event.t()], integer} + def list_public_events_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do + events = + actor_id + |> event_for_actor_query() + |> filter_public_visibility() + |> preload_for_event() + |> Page.paginate(page, limit) + |> Repo.all() + + events_count = + actor_id + |> count_events_for_actor_query() + |> Repo.one() + + {:ok, events, events_count} + end + + @doc """ + Finds close events to coordinates. + Radius is in meters and defaults to 50km. + """ + @spec find_close_events(number, number, number, number) :: [Event.t()] + def find_close_events(lon, lat, radius \\ 50_000, srid \\ 4326) do + "SRID=#{srid};POINT(#{lon} #{lat})" + |> Geo.WKT.decode!() + |> close_events_query(radius) + |> Repo.all() + end + + @doc """ + Counts local events. + """ + @spec count_local_events :: integer + def count_local_events do + count_local_events_query() + |> filter_public_visibility() + |> Repo.one() + end + + @doc """ + Builds a page struct for events by their name. + """ + @spec build_events_by_name(String.t(), integer | nil, integer | nil) :: Page.t() + def build_events_by_name(name, page \\ nil, limit \\ nil) do + name + |> String.trim() + |> events_by_name_query() + |> Page.build_page(page, limit) end @doc """ Gets a single tag. - - Raises `Ecto.NoResultsError` if the Tag does not exist. - - ## Examples - - iex> get_tag!(123) - %Tag{} - - iex> get_tag!(456) - ** (Ecto.NoResultsError) - """ - def get_tag!(id), do: Repo.get!(Tag, id) - + @spec get_tag(integer | String.t()) :: Tag.t() | nil def get_tag(id), do: Repo.get(Tag, id) @doc """ - Get an existing tag or create one + Gets a single tag. + Raises `Ecto.NoResultsError` if the tag does not exist. """ - @spec get_or_create_tag(map()) :: {:ok, Tag.t()} | {:error, any()} - def get_or_create_tag(tag) do - "#" <> title = tag["name"] + @spec get_tag!(integer | String.t()) :: Tag.t() + def get_tag!(id), do: Repo.get!(Tag, id) + @doc """ + Gets a tag by its slug. + """ + @spec get_tag_by_slug(String.t()) :: Tag.t() | nil + def get_tag_by_slug(slug) do + slug + |> tag_by_slug_query() + |> Repo.one() + end + + @doc """ + Gets an existing tag or creates the new one. + """ + @spec get_or_create_tag(map) :: {:ok, Tag.t()} | {:error, Ecto.Changeset.t()} + def get_or_create_tag(%{"name" => "#" <> title}) do case Repo.get_by(Tag, title: title) do %Tag{} = tag -> {:ok, tag} @@ -554,16 +408,8 @@ defmodule Mobilizon.Events do @doc """ Creates a tag. - - ## Examples - - iex> create_tag(%{field: value}) - {:ok, %Tag{}} - - iex> create_tag(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec create_tag(map) :: {:ok, Tag.t()} | {:error, Ecto.Changeset.t()} def create_tag(attrs \\ %{}) do %Tag{} |> Tag.changeset(attrs) @@ -572,16 +418,8 @@ defmodule Mobilizon.Events do @doc """ Updates a tag. - - ## Examples - - iex> update_tag(tag, %{field: new_value}) - {:ok, %Tag{}} - - iex> update_tag(tag, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec update_tag(Tag.t(), map) :: {:ok, Tag.t()} | {:error, Ecto.Changeset.t()} def update_tag(%Tag{} = tag, attrs) do tag |> Tag.changeset(attrs) @@ -589,70 +427,72 @@ defmodule Mobilizon.Events do end @doc """ - Deletes a Tag. - - ## Examples - - iex> delete_tag(tag) - {:ok, %Tag{}} - - iex> delete_tag(tag) - {:error, %Ecto.Changeset{}} - + Deletes a tag. """ - def delete_tag(%Tag{} = tag) do - Repo.delete(tag) + @spec delete_tag(Tag.t()) :: {:ok, Tag.t()} | {:error, Ecto.Changeset.t()} + def delete_tag(%Tag{} = tag), do: Repo.delete(tag) + + @doc """ + Returns the list of tags. + """ + @spec list_tags(integer | nil, integer | nil) :: [Tag.t()] + def list_tags(page \\ nil, limit \\ nil) do + Tag + |> Page.paginate(page, limit) + |> Repo.all() end @doc """ - Returns an `%Ecto.Changeset{}` for tracking tag changes. - - ## Examples - - iex> change_tag(tag) - %Ecto.Changeset{source: %Tag{}} - + Returns the list of tags for the event. """ - def change_tag(%Tag{} = tag) do - Tag.changeset(tag, %{}) + @spec list_tags_for_event(integer | String.t(), integer | nil, integer | nil) :: [Tag.t()] + def list_tags_for_event(event_id, page \\ nil, limit \\ nil) do + event_id + |> tags_for_event_query() + |> Page.paginate(page, limit) + |> Repo.all() end - alias Mobilizon.Events.TagRelation + @doc """ + Checks whether two tags are linked or not. + """ + @spec are_tags_linked(Tag.t(), Tag.t()) :: boolean + def are_tags_linked(%Tag{id: tag1_id}, %Tag{id: tag2_id}) do + tag_relation = + tag1_id + |> tags_linked_query(tag2_id) + |> Repo.one() + + !!tag_relation + end @doc """ - Create a relation between two tags + Creates a relation between two tags. """ - @spec create_tag_relation(map()) :: {:ok, TagRelation.t()} | {:error, Ecto.Changeset.t()} + @spec create_tag_relation(map) :: {:ok, TagRelation.t()} | {:error, Ecto.Changeset.t()} def create_tag_relation(attrs \\ {}) do %TagRelation{} |> TagRelation.changeset(attrs) - |> Repo.insert(conflict_target: [:tag_id, :link_id], on_conflict: [inc: [weight: 1]]) + |> Repo.insert( + conflict_target: [:tag_id, :link_id], + on_conflict: [inc: [weight: 1]] + ) end @doc """ - Remove a tag relation + Removes a tag relation. """ + @spec delete_tag_relation(TagRelation.t()) :: + {:ok, TagRelation.t()} | {:error, Ecto.Changeset.t()} def delete_tag_relation(%TagRelation{} = tag_relation) do Repo.delete(tag_relation) end - @doc """ - Returns whether two tags are linked or not - """ - def are_tags_linked(%Tag{id: tag1_id}, %Tag{id: tag2_id}) do - case from(tr in TagRelation, - where: tr.tag_id == ^min(tag1_id, tag2_id) and tr.link_id == ^max(tag1_id, tag2_id) - ) - |> Repo.one() do - %TagRelation{} -> true - _ -> false - end - end - @doc """ Returns the tags neighbors for a given tag - We can't rely on the single many_to_many relation since we also want tags that link to our tag, not just tags linked by this one + We can't rely on the single many_to_many relation since we also want tags that + link to our tag, not just tags linked by this one. The SQL query looks like this: ```sql @@ -671,218 +511,74 @@ defmodule Mobilizon.Events do DESC; ``` """ - def tag_neighbors(%Tag{id: id}, relation_minimum \\ 1, limit \\ 10) do - query2 = - from(tr in TagRelation, - select: %{id: tr.tag_id, weight: tr.weight}, - where: tr.link_id == ^id - ) - - query = - from(tr in TagRelation, - select: %{id: tr.link_id, weight: tr.weight}, - union_all: ^query2, - where: tr.tag_id == ^id - ) - - final_query = - from(t in Tag, - right_join: q in subquery(query), - on: [id: t.id], - where: q.weight >= ^relation_minimum, - limit: ^limit, - order_by: [desc: q.weight] - ) - - Repo.all(final_query) - end - - alias Mobilizon.Events.Participant - - @doc """ - Returns the list of participants. - - ## Examples - - iex> list_participants() - [%Participant{}, ...] - - """ - def list_participants do - Repo.all(Participant) - end - - @doc """ - Returns the list of participants for an event. - - Default behaviour is to not return :not_approved participants - - ## Examples - - iex> list_participants_for_event(some_uuid) - [%Participant{}, ...] - - """ - def list_participants_for_event(uuid, page \\ nil, limit \\ nil, include_not_improved \\ false) - - def list_participants_for_event(uuid, page, limit, false) do - query = do_list_participants_for_event(uuid, page, limit) - query = from(p in query, where: p.role != ^:not_approved) - Repo.all(query) - end - - def list_participants_for_event(uuid, page, limit, true) do - query = do_list_participants_for_event(uuid, page, limit) - Repo.all(query) - end - - defp do_list_participants_for_event(uuid, page, limit) do - from( - p in Participant, - join: e in Event, - on: p.event_id == e.id, - where: e.uuid == ^uuid, - preload: [:actor] - ) - |> paginate(page, limit) - end - - def count_approved_participants(id) do - query = - from( - p in Participant, - where: p.role != ^:not_approved, - where: p.event_id == ^id - ) - - Repo.aggregate(query, :count, :id) - end - - def count_unapproved_participants(id) do - query = - from( - p in Participant, - where: p.role == ^:not_approved, - where: p.event_id == ^id - ) - - Repo.aggregate(query, :count, :id) - end - - @doc """ - Returns the list of participations for an actor. - - Default behaviour is to not return :not_approved participants - - ## Examples - - iex> list_participants_for_actor(%Actor{}) - [%Participant{}, ...] - - """ - def list_event_participations_for_actor(%Actor{id: id}, page \\ nil, limit \\ nil) do - Repo.all( - from( - e in Event, - join: p in Participant, - join: a in Actor, - on: p.actor_id == a.id, - on: p.event_id == e.id, - where: a.id == ^id and p.role != ^:not_approved, - preload: [:picture, :tags] - ) - |> paginate(page, limit) - ) - end - - @doc """ - Returns the list of organizers participants for an event. - - ## Examples - - iex> list_organizers_participants_for_event(id) - [%Participant{role: :creator}, ...] - - """ - def list_organizers_participants_for_event(id, page \\ nil, limit \\ nil) do - Repo.all( - from( - p in Participant, - where: p.event_id == ^id and p.role == ^:creator, - preload: [:actor] - ) - |> paginate(page, limit) - ) + @spec list_tag_neighbors(Tag.t(), integer, integer) :: [Tag.t()] + def list_tag_neighbors(%Tag{id: tag_id}, relation_minimum \\ 1, limit \\ 10) do + tag_id + |> tag_relation_subquery() + |> tag_relation_union_subquery(tag_id) + |> tag_neighbors_query(relation_minimum, limit) + |> Repo.all() end @doc """ Gets a single participant. - - Raises `Ecto.NoResultsError` if the Participant does not exist. - - ## Examples - - iex> get_participant!(123) - %Participant{} - - iex> get_participant!(456) - ** (Ecto.NoResultsError) - """ + @spec get_participant(integer | String.t(), integer | String.t()) :: + {:ok, Participant.t()} | {:error, :participant_not_found} + def get_participant(event_id, actor_id) do + case Repo.get_by(Participant, event_id: event_id, actor_id: actor_id) do + %Participant{} = participant -> + {:ok, participant} + + nil -> + {:error, :participant_not_found} + end + end + + @doc """ + Gets a single participant. + Raises `Ecto.NoResultsError` if the participant does not exist. + """ + @spec get_participant!(integer | String.t(), integer | String.t()) :: Participant.t() def get_participant!(event_id, actor_id) do Repo.get_by!(Participant, event_id: event_id, actor_id: actor_id) end @doc """ - Get a single participant + Gets a participant by its URL. """ - def get_participant(event_id, actor_id) do - case Repo.get_by(Participant, event_id: event_id, actor_id: actor_id) do - nil -> {:error, :participant_not_found} - participant -> {:ok, participant} - end - end - + @spec get_participant_by_url(String.t()) :: Participant.t() | nil def get_participant_by_url(url) do - Repo.one( - from(p in Participant, - where: p.url == ^url, - preload: [:actor, :event] - ) - ) + url + |> participant_by_url_query() + |> Repo.one() end @doc """ - Creates a participant. - - ## Examples - - iex> create_participant(%{field: value}) - {:ok, %Participant{}} - - iex> create_participant(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - + Gets the default participant role depending on the event join options. """ + @spec get_default_participant_role(Event.t()) :: :participant | :not_approved + def get_default_participant_role(%Event{join_options: :free}), do: :participant + def get_default_participant_role(%Event{join_options: _}), do: :not_approved + + @doc """ + Creates a participant. + """ + @spec create_participant(map) :: {:ok, Participant.t()} | {:error, Ecto.Changeset.t()} def create_participant(attrs \\ %{}) do with {:ok, %Participant{} = participant} <- - %Participant{} |> Participant.changeset(attrs) |> Repo.insert() do + %Participant{} + |> Participant.changeset(attrs) + |> Repo.insert() do {:ok, Repo.preload(participant, [:event, :actor])} end end @doc """ Updates a participant. - - ## Examples - - iex> update_participant(participant, %{field: new_value}) - {:ok, %Participant{}} - - iex> update_participant(participant, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec update_participant(Participant.t(), map) :: + {:ok, Participant.t()} | {:error, Ecto.Changeset.t()} def update_participant(%Participant{} = participant, attrs) do participant |> Participant.changeset(attrs) @@ -890,112 +586,110 @@ defmodule Mobilizon.Events do end @doc """ - Deletes a Participant. - - ## Examples - - iex> delete_participant(participant) - {:ok, %Participant{}} - - iex> delete_participant(participant) - {:error, %Ecto.Changeset{}} - + Deletes a participant. """ - def delete_participant(%Participant{} = participant) do - Repo.delete(participant) - end + @spec delete_participant(Participant.t()) :: + {:ok, Participant.t()} | {:error, Ecto.Changeset.t()} + def delete_participant(%Participant{} = participant), do: Repo.delete(participant) @doc """ - Returns an `%Ecto.Changeset{}` for tracking participant changes. - - ## Examples - - iex> change_participant(participant) - %Ecto.Changeset{source: %Participant{}} - + Returns the list of participants. """ - def change_participant(%Participant{} = participant) do - Participant.changeset(participant, %{}) - end + @spec list_participants :: [Participant.t()] + def list_participants, do: Repo.all(Participant) @doc """ - Get the default participant role depending on the event join options + Returns the list of participants for an event. + Default behaviour is to not return :not_approved participants """ - def get_default_participant_role(%Event{} = event) do - case event.join_options do - # Participant - :free -> :participant - # Not approved - _ -> :not_approved - end - end - - @doc """ - List event participation requests for an actor - """ - @spec list_requests_for_actor(Actor.t()) :: list(Participant.t()) - def list_requests_for_actor(%Actor{id: actor_id}) do - Repo.all(from(p in Participant, where: p.actor_id == ^actor_id and p.role == ^:not_approved)) - end - - alias Mobilizon.Events.Session - - @doc """ - Returns the list of sessions. - - ## Examples - - iex> list_sessions() - [%Session{}, ...] - - """ - def list_sessions do - Repo.all(Session) - end - - @doc """ - Returns the list of sessions for an event - """ - @spec list_sessions_for_event(Event.t()) :: list(Session.t()) - def list_sessions_for_event(%Event{id: event_id}) do - Repo.all( - from( - s in Session, - join: e in Event, - on: s.event_id == e.id, - where: e.id == ^event_id + @spec list_participants_for_event(String.t(), integer | nil, integer | nil, boolean) :: + [Participant.t()] + def list_participants_for_event( + event_uuid, + page \\ nil, + limit \\ nil, + include_not_improved \\ false ) - ) + + def list_participants_for_event(event_uuid, page, limit, include_not_improved) do + event_uuid + |> participants_for_event() + |> filter_role(include_not_improved) + |> Page.paginate(page, limit) + |> Repo.all() + end + + @doc """ + Returns the list of organizers participants for an event. + """ + @spec list_organizers_participants_for_event( + integer | String.t(), + integer | nil, + integer | nil + ) :: + [Participant.t()] + def list_organizers_participants_for_event(event_id, page \\ nil, limit \\ nil) do + event_id + |> organizers_participants_for_event() + |> Page.paginate(page, limit) + |> Repo.all() + end + + @doc """ + Returns the list of event participation requests for an actor. + """ + @spec list_requests_for_actor(Actor.t()) :: [Participant.t()] + def list_requests_for_actor(%Actor{id: actor_id}) do + actor_id + |> requests_for_actor_query() + |> Repo.all() + end + + @doc """ + Returns the list of participations for an actor. + """ + @spec list_event_participations_for_actor(Actor.t(), integer | nil, integer | nil) :: + [Event.t()] + def list_event_participations_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do + actor_id + |> event_participations_for_actor_query() + |> Page.paginate(page, limit) + |> Repo.all() + end + + @doc """ + Counts approved participants. + """ + @spec count_approved_participants(integer | String.t()) :: integer + def count_approved_participants(event_id) do + event_id + |> count_participants_query() + |> filter_approved_role() + |> Repo.aggregate(:count, :id) + end + + @doc """ + Counts unapproved participants. + """ + @spec count_unapproved_participants(integer | String.t()) :: integer + def count_unapproved_participants(event_id) do + event_id + |> count_participants_query() + |> filter_unapproved_role() + |> Repo.aggregate(:count, :id) end @doc """ Gets a single session. - - Raises `Ecto.NoResultsError` if the Session does not exist. - - ## Examples - - iex> get_session!(123) - %Session{} - - iex> get_session!(456) - ** (Ecto.NoResultsError) - + Raises `Ecto.NoResultsError` if the session does not exist. """ + @spec get_session!(integer | String.t()) :: Session.t() def get_session!(id), do: Repo.get!(Session, id) @doc """ Creates a session. - - ## Examples - - iex> create_session(%{field: value}) - {:ok, %Session{}} - - iex> create_session(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec create_session(map) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()} def create_session(attrs \\ %{}) do %Session{} |> Session.changeset(attrs) @@ -1004,16 +698,8 @@ defmodule Mobilizon.Events do @doc """ Updates a session. - - ## Examples - - iex> update_session(session, %{field: new_value}) - {:ok, %Session{}} - - iex> update_session(session, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec update_session(Session.t(), map) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()} def update_session(%Session{} = session, attrs) do session |> Session.changeset(attrs) @@ -1021,85 +707,38 @@ defmodule Mobilizon.Events do end @doc """ - Deletes a Session. - - ## Examples - - iex> delete_session(session) - {:ok, %Session{}} - - iex> delete_session(session) - {:error, %Ecto.Changeset{}} - + Deletes a session. """ - def delete_session(%Session{} = session) do - Repo.delete(session) - end + @spec delete_session(Session.t()) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()} + def delete_session(%Session{} = session), do: Repo.delete(session) @doc """ - Returns an `%Ecto.Changeset{}` for tracking session changes. - - ## Examples - - iex> change_session(session) - %Ecto.Changeset{source: %Session{}} - + Returns the list of sessions. """ - def change_session(%Session{} = session) do - Session.changeset(session, %{}) - end - - alias Mobilizon.Events.Track + @spec list_sessions :: [Session.t()] + def list_sessions, do: Repo.all(Session) @doc """ - Returns the list of tracks. - - ## Examples - - iex> list_tracks() - [%Track{}, ...] - + Returns the list of sessions for the event. """ - def list_tracks do - Repo.all(Track) - end - - @doc """ - Returns the list of sessions for a track - """ - @spec list_sessions_for_track(Track.t()) :: list(Session.t()) - def list_sessions_for_track(%Track{id: track_id}) do - Repo.all(from(s in Session, where: s.track_id == ^track_id)) + @spec list_sessions_for_event(Event.t()) :: [Session.t()] + def list_sessions_for_event(%Event{id: event_id}) do + event_id + |> sessions_for_event_query() + |> Repo.all() end @doc """ Gets a single track. - - Raises `Ecto.NoResultsError` if the Track does not exist. - - ## Examples - - iex> get_track!(123) - %Track{} - - iex> get_track!(456) - ** (Ecto.NoResultsError) - + Raises `Ecto.NoResultsError` if the track does not exist. """ + @spec get_track!(integer | String.t()) :: Track.t() def get_track!(id), do: Repo.get!(Track, id) @doc """ Creates a track. - - ## Examples - - iex> create_track(%{field: value}) - {:ok, %Track{}} - - iex> create_track(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec create_track(map) :: {:ok, Track.t()} | {:error, Ecto.Changeset.t()} def create_track(attrs \\ %{}) do %Track{} |> Track.changeset(attrs) @@ -1108,16 +747,8 @@ defmodule Mobilizon.Events do @doc """ Updates a track. - - ## Examples - - iex> update_track(track, %{field: new_value}) - {:ok, %Track{}} - - iex> update_track(track, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec update_track(Track.t(), map) :: {:ok, Track.t()} | {:error, Ecto.Changeset.t()} def update_track(%Track{} = track, attrs) do track |> Track.changeset(attrs) @@ -1125,196 +756,108 @@ defmodule Mobilizon.Events do end @doc """ - Deletes a Track. - - ## Examples - - iex> delete_track(track) - {:ok, %Track{}} - - iex> delete_track(track) - {:error, %Ecto.Changeset{}} - + Deletes a track. """ - def delete_track(%Track{} = track) do - Repo.delete(track) - end + @spec delete_track(Track.t()) :: {:ok, Track.t()} | {:error, Ecto.Changeset.t()} + def delete_track(%Track{} = track), do: Repo.delete(track) @doc """ - Returns an `%Ecto.Changeset{}` for tracking track changes. - - ## Examples - - iex> change_track(track) - %Ecto.Changeset{source: %Track{}} - + Returns the list of tracks. """ - def change_track(%Track{} = track) do - Track.changeset(track, %{}) - end - - alias Mobilizon.Events.Comment + @spec list_tracks :: [Track.t()] + def list_tracks, do: Repo.all(Track) @doc """ - Returns the list of public comments. - - ## Examples - - iex> list_comments() - [%Comment{}, ...] - + Returns the list of sessions for the track. """ - def list_comments do - Repo.all(from(c in Comment, where: c.visibility == ^:public)) - end - - def get_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do - query = - from( - c in Comment, - where: c.actor_id == ^actor_id and c.visibility in [^:public, ^:unlisted], - order_by: [desc: :id], - preload: [ - :actor, - :in_reply_to_comment, - :origin_comment, - :event - ] - ) - |> paginate(page, limit) - - comments = Repo.all(query) - - count_comments = - Repo.one(from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id)) - - {:ok, comments, count_comments} - end - - @doc """ - Gets a single comment. - - Raises `Ecto.NoResultsError` if the Comment does not exist. - - ## Examples - - iex> get_comment!(123) - %Comment{} - - iex> get_comment!(456) - ** (Ecto.NoResultsError) - - """ - def get_comment!(id), do: Repo.get!(Comment, id) - - # @doc """ - # Gets a single comment from it's UUID - - # """ - # @spec get_comment_from_uuid(String.t) :: {:ok, Comment.t} | {:error, nil} - # def get_comment_from_uuid(uuid), do: Repo.get_by(Comment, uuid: uuid) - - # @doc """ - # Gets a single comment by it's UUID. - - # Raises `Ecto.NoResultsError` if the Comment does not exist. - - # ## Examples - - # iex> get_comment_from_uuid!("123AFV13") - # %Comment{} - - # iex> get_comment_from_uuid!("20R9HKDJHF") - # ** (Ecto.NoResultsError) - - # """ - # @spec get_comment_from_uuid(String.t) :: Comment.t - # def get_comment_from_uuid!(uuid), do: Repo.get_by!(Comment, uuid: uuid) - - def get_comment_full_from_uuid(uuid) do - with %Comment{} = comment <- Repo.get_by!(Comment, uuid: uuid) do - Repo.preload(comment, [:actor, :attributed_to, :in_reply_to_comment]) - end - end - - def get_cached_comment_full_by_uuid(uuid) do - Cachex.fetch(:activity_pub, "comment_" <> uuid, fn "comment_" <> uuid -> - case get_comment_full_from_uuid(uuid) do - %Comment{} = comment -> - {:commit, comment} - - _ -> - {:ignore, nil} - end - end) - end - - def get_comment_from_url(url), do: Repo.get_by(Comment, url: url) - - def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url) - - def get_comment_full_from_url(url) do - case Repo.one( - from(c in Comment, where: c.url == ^url, preload: [:actor, :in_reply_to_comment]) - ) do - nil -> {:error, :comment_not_found} - comment -> {:ok, comment} - end - end - - def get_comment_full_from_url!(url) do - with %Comment{} = comment <- Repo.get_by!(Comment, url: url) do - Repo.preload(comment, [:actor, :in_reply_to_comment]) - end - end - - @doc """ - Get all comments by an actor and a list of ids - """ - def get_all_comments_by_actor_and_ids(actor_id, comment_ids \\ []) - def get_all_comments_by_actor_and_ids(_actor_id, []), do: [] - - def get_all_comments_by_actor_and_ids(actor_id, comment_ids) do - Comment - |> where([c], c.id in ^comment_ids) - |> where([c], c.actor_id == ^actor_id) + @spec list_sessions_for_track(Track.t()) :: [Session.t()] + def list_sessions_for_track(%Track{id: track_id}) do + track_id + |> sessions_for_track_query() |> Repo.all() end @doc """ - Creates a comment. - - ## Examples - - iex> create_comment(%{field: value}) - {:ok, %Comment{}} - - iex> create_comment(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - + Gets a single comment. + Raises `Ecto.NoResultsError` if the comment does not exist. """ + @spec get_comment!(integer | String.t()) :: Comment.t() + def get_comment!(id), do: Repo.get!(Comment, id) + + @doc """ + Gets a comment by its URL. + """ + @spec get_comment_from_url(String.t()) :: Comment.t() | nil + def get_comment_from_url(url), do: Repo.get_by(Comment, url: url) + + @doc """ + Gets a comment by its URL. + Raises `Ecto.NoResultsError` if the comment does not exist. + """ + @spec get_comment_from_url!(String.t()) :: Comment.t() + def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url) + + @doc """ + Gets a comment by its URL, with all associations loaded. + """ + @spec get_comment_from_url_with_preload(String.t()) :: + {:ok, Comment.t()} | {:error, :comment_not_found} + def get_comment_from_url_with_preload(url) do + query = from(c in Comment, where: c.url == ^url) + + comment = + query + |> preload_for_comment() + |> Repo.one() + + case comment do + %Comment{} = comment -> + {:ok, comment} + + nil -> + {:error, :comment_not_found} + end + end + + @doc """ + Gets a comment by its URL, with all associations loaded. + Raises `Ecto.NoResultsError` if the comment does not exist. + """ + @spec get_comment_from_url_with_preload(String.t()) :: Comment.t() + def get_comment_from_url_with_preload!(url) do + Comment + |> Repo.get_by!(url: url) + |> Repo.preload(@comment_preloads) + end + + @doc """ + Gets a comment by its UUID, with all associations loaded. + """ + @spec get_comment_from_uuid_with_preload(String.t()) :: Comment.t() + def get_comment_from_uuid_with_preload(uuid) do + Comment + |> Repo.get_by(uuid: uuid) + |> Repo.preload(@comment_preloads) + end + + @doc """ + Creates a comment. + """ + @spec create_comment(map) :: {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} def create_comment(attrs \\ %{}) do with {:ok, %Comment{} = comment} <- %Comment{} |> Comment.changeset(attrs) |> Repo.insert(), - %Comment{} = comment <- Repo.preload(comment, [:actor, :in_reply_to_comment]) do + %Comment{} = comment <- Repo.preload(comment, @comment_preloads) do {:ok, comment} end end @doc """ Updates a comment. - - ## Examples - - iex> update_comment(comment, %{field: new_value}) - {:ok, %Comment{}} - - iex> update_comment(comment, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} def update_comment(%Comment{} = comment, attrs) do comment |> Comment.changeset(attrs) @@ -1322,114 +865,85 @@ defmodule Mobilizon.Events do end @doc """ - Deletes a Comment. - - ## Examples - - iex> delete_comment(comment) - {:ok, %Comment{}} - - iex> delete_comment(comment) - {:error, %Ecto.Changeset{}} - + Deletes a comment. """ - def delete_comment(%Comment{} = comment) do - Repo.delete(comment) + @spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} + def delete_comment(%Comment{} = comment), do: Repo.delete(comment) + + @doc """ + Returns the list of public comments. + """ + @spec list_comments :: [Comment.t()] + def list_comments do + Repo.all(from(c in Comment, where: c.visibility == ^:public)) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking comment changes. - - ## Examples - - iex> change_comment(comment) - %Ecto.Changeset{source: %Comment{}} - + Returns the list of public comments for the actor. """ - def change_comment(%Comment{} = comment) do - Comment.changeset(comment, %{}) + @spec list_public_events_for_actor(Actor.t(), integer | nil, integer | nil) :: + {:ok, [Comment.t()], integer} + def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do + comments = + actor_id + |> public_comments_for_actor_query() + |> Page.paginate(page, limit) + |> Repo.all() + + count_comments = + actor_id + |> count_comments_query() + |> Repo.one() + + {:ok, comments, count_comments} end - alias Mobilizon.Events.FeedToken + @doc """ + Returns the list of comments by an actor and a list of ids. + """ + @spec list_comments_by_actor_and_ids(integer | String.t(), [integer | String.t()]) :: + [Comment.t()] + def list_comments_by_actor_and_ids(actor_id, comment_ids \\ []) + def list_comments_by_actor_and_ids(_actor_id, []), do: [] + + def list_comments_by_actor_and_ids(actor_id, comment_ids) do + Comment + |> where([c], c.id in ^comment_ids) + |> where([c], c.actor_id == ^actor_id) + |> Repo.all() + end + + @doc """ + Counts local comments. + """ + @spec count_local_comments :: integer + def count_local_comments, do: Repo.one(count_local_comments_query()) @doc """ Gets a single feed token. - - ## Examples - - iex> get_feed_token("123") - {:ok, %FeedToken{}} - - iex> get_feed_token("456") - {:error, nil} - """ + @spec get_feed_token(String.t()) :: FeedToken.t() | nil def get_feed_token(token) do - from(ftk in FeedToken, where: ftk.token == ^token, preload: [:actor, :user]) + token + |> feed_token_query() |> Repo.one() end @doc """ Gets a single feed token. - - Raises `Ecto.NoResultsError` if the FeedToken does not exist. - - ## Examples - - iex> get_feed_token!(123) - %FeedToken{} - - iex> get_feed_token!(456) - ** (Ecto.NoResultsError) - + Raises `Ecto.NoResultsError` if the feed token does not exist. """ + @spec get_feed_token!(String.t()) :: FeedToken.t() def get_feed_token!(token) do - from( - tk in FeedToken, - where: tk.token == ^token, - preload: [:actor, :user] - ) + token + |> feed_token_query() |> Repo.one!() end - @doc """ - Get feed tokens for an user - """ - @spec get_feed_tokens(User.t()) :: list(FeedTokens.t()) - def get_feed_tokens(%User{id: id}) do - from( - tk in FeedToken, - where: tk.user_id == ^id, - preload: [:actor, :user] - ) - |> Repo.all() - end - - @doc """ - Get feed tokens for an actor - """ - @spec get_feed_tokens(Actor.t()) :: list(FeedTokens.t()) - def get_feed_tokens(%Actor{id: id, domain: nil}) do - from( - tk in FeedToken, - where: tk.actor_id == ^id, - preload: [:actor, :user] - ) - |> Repo.all() - end - @doc """ Creates a feed token. - - ## Examples - - iex> create_feed_token(%{field: value}) - {:ok, %FeedToken{}} - - iex> create_feed_token(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec create_feed_token(map) :: {:ok, FeedToken.t()} | {:error, Ecto.Changeset.t()} def create_feed_token(attrs \\ %{}) do attrs = Map.put(attrs, "token", Ecto.UUID.generate()) @@ -1440,16 +954,9 @@ defmodule Mobilizon.Events do @doc """ Updates a feed token. - - ## Examples - - iex> update_feed_token(feed_token, %{field: new_value}) - {:ok, %FeedToken{}} - - iex> update_feed_token(feed_token, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ + @spec update_feed_token(FeedToken.t(), map) :: + {:ok, FeedToken.t()} | {:error, Ecto.Changeset.t()} def update_feed_token(%FeedToken{} = feed_token, attrs) do feed_token |> FeedToken.changeset(attrs) @@ -1457,31 +964,330 @@ defmodule Mobilizon.Events do end @doc """ - Deletes a FeedToken. - - ## Examples - - iex> delete_feed_token(feed_token) - {:ok, %FeedToken{}} - - iex> delete_feed_token(feed_token) - {:error, %Ecto.Changeset{}} - + Deletes a feed token. """ - def delete_feed_token(%FeedToken{} = feed_token) do - Repo.delete(feed_token) + @spec delete_feed_token(FeedToken.t()) :: {:ok, FeedToken.t()} | {:error, Ecto.Changeset.t()} + def delete_feed_token(%FeedToken{} = feed_token), do: Repo.delete(feed_token) + + @doc """ + Returns the list of feed tokens for an user. + """ + @spec list_feed_tokens_for_user(User.t()) :: [FeedTokens.t()] + def list_feed_tokens_for_user(%User{id: user_id}) do + user_id + |> feed_token_for_user_query() + |> Repo.all() end @doc """ - Returns an `%Ecto.Changeset{}` for tracking feed_token changes. - - ## Examples - - iex> change_feed_token(feed_token) - %Ecto.Changeset{source: %FeedToken{}} - + Returns the list of feed tokens for an actor. """ - def change_feed_token(%FeedToken{} = feed_token) do - FeedToken.changeset(feed_token, %{}) + @spec list_feed_tokens_for_actor(Actor.t()) :: [FeedTokens.t()] + def list_feed_tokens_for_actor(%Actor{id: actor_id, domain: nil}) do + actor_id + |> feed_token_for_actor_query() + |> Repo.all() end + + @spec event_by_url_query(String.t()) :: Ecto.Query.t() + defp event_by_url_query(url) do + from(e in Event, where: e.url == ^url) + end + + @spec event_by_uuid_query(String.t()) :: Ecto.Query.t() + defp event_by_uuid_query(uuid) do + from(e in Event, where: e.uuid == ^uuid) + end + + @spec event_for_actor_query(integer | String.t()) :: Ecto.Query.t() + defp event_for_actor_query(actor_id) do + from( + e in Event, + where: e.organizer_actor_id == ^actor_id, + order_by: [desc: :id] + ) + end + + @spec upcoming_public_event_for_actor_query(integer | String.t()) :: Ecto.Query.t() + defp upcoming_public_event_for_actor_query(actor_id) do + from( + e in Event, + where: + e.organizer_actor_id == ^actor_id and + e.begins_on > ^DateTime.utc_now(), + order_by: [asc: :begins_on], + limit: 1, + preload: [ + :organizer_actor, + :tags, + :participants, + :physical_address + ] + ) + end + + @spec close_events_query(Geo.geometry(), number) :: Ecto.Query.t() + defp close_events_query(point, radius) do + from( + e in Event, + join: a in Address, + on: a.id == e.physical_address_id, + where: e.visibility == ^:public and st_dwithin_in_meters(^point, a.geom, ^radius), + preload: :organizer_actor + ) + end + + @spec events_by_name_query(String.t()) :: Ecto.Query.t() + defp events_by_name_query(name) do + from( + e in Event, + where: + e.visibility == ^:public and + fragment("f_unaccent(?) %> f_unaccent(?)", e.title, ^name), + order_by: fragment("word_similarity(?, ?) desc", e.title, ^name), + preload: [:organizer_actor] + ) + end + + @spec events_by_tags_query([integer], integer) :: Ecto.Query.t() + def events_by_tags_query(tags_ids, limit) do + from( + e in Event, + distinct: e.uuid, + join: te in "events_tags", + on: e.id == te.event_id, + where: e.begins_on > ^DateTime.utc_now(), + where: e.visibility in ^@public_visibility, + where: te.tag_id in ^tags_ids, + order_by: [asc: e.begins_on], + limit: ^limit + ) + end + + @spec count_events_for_actor_query(integer | String.t()) :: Ecto.Query.t() + defp count_events_for_actor_query(actor_id) do + from( + e in Event, + select: count(e.id), + where: e.organizer_actor_id == ^actor_id + ) + end + + @spec count_local_events_query :: Ecto.Query.t() + defp count_local_events_query do + from(e in Event, select: count(e.id), where: e.local == ^true) + end + + @spec tag_by_slug_query(String.t()) :: Ecto.Query.t() + defp tag_by_slug_query(slug) do + from(t in Tag, where: t.slug == ^slug) + end + + @spec tags_for_event_query(integer) :: Ecto.Query.t() + defp tags_for_event_query(event_id) do + from( + t in Tag, + join: e in "events_tags", + on: t.id == e.tag_id, + where: e.event_id == ^event_id + ) + end + + @spec tags_linked_query(integer, integer) :: Ecto.Query.t() + defp tags_linked_query(tag1_id, tag2_id) do + from( + tr in TagRelation, + where: + tr.tag_id == ^min(tag1_id, tag2_id) and + tr.link_id == ^max(tag1_id, tag2_id) + ) + end + + @spec tag_relation_subquery(integer) :: Ecto.Query.t() + defp tag_relation_subquery(tag_id) do + from( + tr in TagRelation, + select: %{id: tr.tag_id, weight: tr.weight}, + where: tr.link_id == ^tag_id + ) + end + + @spec tag_relation_union_subquery(Ecto.Query.t(), integer) :: Ecto.Query.t() + defp tag_relation_union_subquery(subquery, tag_id) do + from( + tr in TagRelation, + select: %{id: tr.link_id, weight: tr.weight}, + union_all: ^subquery, + where: tr.tag_id == ^tag_id + ) + end + + @spec tag_neighbors_query(Ecto.Query.t(), integer, integer) :: Ecto.Query.t() + defp tag_neighbors_query(subquery, relation_minimum, limit) do + from( + t in Tag, + right_join: q in subquery(subquery), + on: [id: t.id], + where: q.weight >= ^relation_minimum, + limit: ^limit, + order_by: [desc: q.weight] + ) + end + + @spec participant_by_url_query(String.t()) :: Ecto.Query.t() + defp participant_by_url_query(url) do + from( + p in Participant, + where: p.url == ^url, + preload: [:actor, :event] + ) + end + + @spec participants_for_event(String.t()) :: Ecto.Query.t() + defp participants_for_event(event_uuid) do + from( + p in Participant, + join: e in Event, + on: p.event_id == e.id, + where: e.uuid == ^event_uuid, + preload: [:actor] + ) + end + + defp organizers_participants_for_event(event_id) do + from( + p in Participant, + where: p.event_id == ^event_id and p.role == ^:creator, + preload: [:actor] + ) + end + + @spec requests_for_actor_query(integer) :: Ecto.Query.t() + defp requests_for_actor_query(actor_id) do + from(p in Participant, where: p.actor_id == ^actor_id and p.role == ^:not_approved) + end + + @spec count_participants_query(integer) :: Ecto.Query.t() + defp count_participants_query(event_id) do + from(p in Participant, where: p.event_id == ^event_id) + end + + @spec event_participations_for_actor_query(integer) :: Ecto.Query.t() + def event_participations_for_actor_query(actor_id) do + from( + e in Event, + join: p in Participant, + join: a in Actor, + on: p.actor_id == a.id, + on: p.event_id == e.id, + where: a.id == ^actor_id and p.role != ^:not_approved, + preload: [:picture, :tags] + ) + end + + @spec sessions_for_event_query(integer) :: Ecto.Query.t() + defp sessions_for_event_query(event_id) do + from( + s in Session, + join: e in Event, + on: s.event_id == e.id, + where: e.id == ^event_id + ) + end + + @spec sessions_for_track_query(integer) :: Ecto.Query.t() + defp sessions_for_track_query(track_id) do + from(s in Session, where: s.track_id == ^track_id) + end + + defp public_comments_for_actor_query(actor_id) do + from( + c in Comment, + where: c.actor_id == ^actor_id and c.visibility in ^@public_visibility, + order_by: [desc: :id], + preload: [ + :actor, + :in_reply_to_comment, + :origin_comment, + :event + ] + ) + end + + @spec count_comments_query(integer) :: Ecto.Query.t() + defp count_comments_query(actor_id) do + from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id) + end + + @spec count_local_comments_query :: Ecto.Query.t() + defp count_local_comments_query do + from( + c in Comment, + select: count(c.id), + where: c.local == ^true and c.visibility in ^@public_visibility + ) + end + + @spec feed_token_query(String.t()) :: Ecto.Query.t() + defp feed_token_query(token) do + from(ftk in FeedToken, where: ftk.token == ^token, preload: [:actor, :user]) + end + + @spec feed_token_for_user_query(integer) :: Ecto.Query.t() + defp feed_token_for_user_query(user_id) do + from(tk in FeedToken, where: tk.user_id == ^user_id, preload: [:actor, :user]) + end + + @spec feed_token_for_actor_query(integer) :: Ecto.Query.t() + defp feed_token_for_actor_query(actor_id) do + from(tk in FeedToken, where: tk.actor_id == ^actor_id, preload: [:actor, :user]) + end + + @spec filter_public_visibility(Ecto.Query.t()) :: Ecto.Query.t() + defp filter_public_visibility(query) do + from(e in query, where: e.visibility in ^@public_visibility) + end + + @spec filter_not_event_uuid(Ecto.Query.t(), String.t() | nil) :: Ecto.Query.t() + defp filter_not_event_uuid(query, nil), do: query + + defp filter_not_event_uuid(query, not_event_uuid) do + from(e in query, where: e.uuid != ^not_event_uuid) + end + + @spec filter_future_events(Ecto.Query.t(), boolean) :: Ecto.Query.t() + defp filter_future_events(query, true) do + from(q in query, where: q.begins_on > ^DateTime.utc_now()) + end + + defp filter_future_events(query, false), do: query + + @spec filter_unlisted(Ecto.Query.t(), boolean) :: Ecto.Query.t() + defp filter_unlisted(query, true) do + from(q in query, where: q.visibility in ^@public_visibility) + end + + defp filter_unlisted(query, false) do + from(q in query, where: q.visibility == ^:public) + end + + @spec filter_approved_role(Ecto.Query.t()) :: Ecto.Query.t() + defp filter_approved_role(query) do + from(p in query, where: p.role != ^:not_approved) + end + + @spec filter_unapproved_role(Ecto.Query.t()) :: Ecto.Query.t() + defp filter_unapproved_role(query) do + from(p in query, where: p.role == ^:not_approved) + end + + @spec filter_role(Ecto.Query.t(), boolean) :: Ecto.Query.t() + defp filter_role(query, false), do: filter_approved_role(query) + defp filter_role(query, true), do: query + + @spec preload_for_event(Ecto.Query.t()) :: Ecto.Query.t() + defp preload_for_event(query), do: preload(query, ^@event_preloads) + + @spec preload_for_comment(Ecto.Query.t()) :: Ecto.Query.t() + defp preload_for_comment(query), do: preload(query, ^@comment_preloads) end diff --git a/lib/mobilizon/events/feed_token.ex b/lib/mobilizon/events/feed_token.ex index f4b55e477..d2f255db6 100644 --- a/lib/mobilizon/events/feed_token.ex +++ b/lib/mobilizon/events/feed_token.ex @@ -1,16 +1,29 @@ defmodule Mobilizon.Events.FeedToken do @moduledoc """ - Represents a Token for a Feed of events + Represents a token for a feed of events. """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.FeedToken + alias Mobilizon.Actors.Actor alias Mobilizon.Users.User + @type t :: %__MODULE__{ + token: Ecto.UUID.t(), + actor: Actor.t(), + user: User.t() + } + + @required_attrs [:token, :user_id] + @optional_attrs [:actor_id] + @attrs @required_attrs ++ @optional_attrs + @primary_key false schema "feed_tokens" do field(:token, Ecto.UUID, primary_key: true) + belongs_to(:actor, Actor) belongs_to(:user, User) @@ -18,9 +31,10 @@ defmodule Mobilizon.Events.FeedToken do end @doc false - def changeset(%FeedToken{} = feed_token, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = feed_token, attrs) do feed_token - |> Ecto.Changeset.cast(attrs, [:token, :actor_id, :user_id]) - |> validate_required([:token, :user_id]) + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) end end diff --git a/lib/mobilizon/events/participant.ex b/lib/mobilizon/events/participant.ex index 144dc7b38..c3d851e18 100644 --- a/lib/mobilizon/events/participant.ex +++ b/lib/mobilizon/events/participant.ex @@ -1,78 +1,87 @@ -import EctoEnum - -defenum(Mobilizon.Events.ParticipantRoleEnum, :participant_role_type, [ - :not_approved, - :participant, - :moderator, - :administrator, - :creator -]) - defmodule Mobilizon.Events.Participant do @moduledoc """ - Represents a participant, an actor participating to an event + Represents a participant, an actor participating to an event. """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.{Participant, Event} + alias Mobilizon.Actors.Actor + alias Mobilizon.Events + alias Mobilizon.Events.{Event, ParticipantRole} + + alias MobilizonWeb.Endpoint + + @type t :: %__MODULE__{ + role: ParticipantRole.t(), + url: String.t(), + event: Event.t(), + actor: Actor.t() + } + + @required_attrs [:url, :role, :event_id, :actor_id] + @attrs @required_attrs @primary_key {:id, :binary_id, autogenerate: true} schema "participants" do - field(:role, Mobilizon.Events.ParticipantRoleEnum, default: :participant) + field(:role, ParticipantRole, default: :participant) field(:url, :string) + belongs_to(:event, Event, primary_key: true) belongs_to(:actor, Actor, primary_key: true) timestamps() end - @doc false - def changeset(%Participant{} = participant, attrs) do - participant - |> Ecto.Changeset.cast(attrs, [:url, :role, :event_id, :actor_id]) - |> generate_url() - |> validate_required([:url, :role, :event_id, :actor_id]) - end - - # If there's a blank URL that's because we're doing the first insert - defp generate_url(%Ecto.Changeset{data: %Participant{url: nil}} = changeset) do - case fetch_change(changeset, :url) do - {:ok, _url} -> changeset - :error -> do_generate_url(changeset) - end - end - - # Most time just go with the given URL - defp generate_url(%Ecto.Changeset{} = changeset), do: changeset - - defp do_generate_url(%Ecto.Changeset{} = changeset) do - uuid = Ecto.UUID.generate() - - changeset - |> put_change( - :url, - "#{MobilizonWeb.Endpoint.url()}/join/event/#{uuid}" - ) - |> put_change( - :id, - uuid - ) - end - @doc """ - We check that the actor asking to leave the event is not it's only organizer + We check that the actor asking to leave the event is not it's only organizer. We start by fetching the list of organizers and if there's only one of them - and that it's the actor requesting leaving the event we return true + and that it's the actor requesting leaving the event we return true. """ - @spec check_that_participant_is_not_only_organizer(integer(), integer()) :: boolean() - def check_that_participant_is_not_only_organizer(event_id, actor_id) do - case Mobilizon.Events.list_organizers_participants_for_event(event_id) do - [%Participant{actor: %Actor{id: participant_actor_id}}] -> + @spec is_not_only_organizer(integer | String.t(), integer | String.t()) :: boolean + def is_not_only_organizer(event_id, actor_id) do + case Events.list_organizers_participants_for_event(event_id) do + [%__MODULE__{actor: %Actor{id: participant_actor_id}}] -> participant_actor_id == actor_id _ -> false end end + + @doc false + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = participant, attrs) do + participant + |> cast(attrs, @attrs) + |> ensure_url() + |> validate_required(@required_attrs) + end + + # If there's a blank URL that's because we're doing the first insert + @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 -> + update_url(changeset) + end + end + + defp ensure_url(%Ecto.Changeset{} = changeset), do: changeset + + defp update_url(%Ecto.Changeset{} = changeset) do + uuid = Ecto.UUID.generate() + url = generate_url(uuid) + + changeset + |> put_change(:id, uuid) + |> put_change(:url, url) + end + + @spec generate_url(String.t()) :: String.t() + defp generate_url(uuid), do: "#{Endpoint.url()}/join/event/#{uuid}" end diff --git a/lib/mobilizon/events/session.ex b/lib/mobilizon/events/session.ex index 5f03ec00e..3804d8935 100644 --- a/lib/mobilizon/events/session.ex +++ b/lib/mobilizon/events/session.ex @@ -1,10 +1,41 @@ defmodule Mobilizon.Events.Session do @moduledoc """ - Represents a session for an event (such as a talk at a conference) + Represents a session for an event (such as a talk at a conference). """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.{Session, Event, Track} + + alias Mobilizon.Events.{Event, Track} + + @type t :: %__MODULE__{ + audios_urls: String.t(), + language: String.t(), + long_abstract: String.t(), + short_abstract: String.t(), + slides_url: String.t(), + subtitle: String.t(), + title: String.t(), + videos_urls: String.t(), + begins_on: DateTime.t(), + ends_on: DateTime.t(), + event: Event.t(), + track: Track.t() + } + + @required_attrs [ + :title, + :subtitle, + :short_abstract, + :long_abstract, + :language, + :slides_url, + :videos_urls, + :audios_urls + ] + @optional_attrs [:event_id, :track_id] + @attrs @required_attrs ++ @optional_attrs schema "sessions" do field(:audios_urls, :string) @@ -17,6 +48,7 @@ defmodule Mobilizon.Events.Session do field(:videos_urls, :string) field(:begins_on, :utc_datetime) field(:ends_on, :utc_datetime) + belongs_to(:event, Event) belongs_to(:track, Track) @@ -24,29 +56,10 @@ defmodule Mobilizon.Events.Session do end @doc false - def changeset(%Session{} = session, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = session, attrs) do session - |> cast(attrs, [ - :title, - :subtitle, - :short_abstract, - :long_abstract, - :language, - :slides_url, - :videos_urls, - :audios_urls, - :event_id, - :track_id - ]) - |> validate_required([ - :title, - :subtitle, - :short_abstract, - :long_abstract, - :language, - :slides_url, - :videos_urls, - :audios_urls - ]) + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) end end diff --git a/lib/mobilizon/events/tag.ex b/lib/mobilizon/events/tag.ex index fafcfe157..b1d0ec8d7 100644 --- a/lib/mobilizon/events/tag.ex +++ b/lib/mobilizon/events/tag.ex @@ -1,60 +1,40 @@ -defmodule Mobilizon.Events.Tag.TitleSlug do - @moduledoc """ - Generates slugs for tags - """ - alias Mobilizon.Events.Tag - import Ecto.Query - alias Mobilizon.Repo - use EctoAutoslugField.Slug, from: :title, to: :slug - - def build_slug(sources, changeset) do - slug = super(sources, changeset) - build_unique_slug(slug, changeset) - end - - defp build_unique_slug(slug, changeset) do - query = - from( - t in Tag, - where: t.slug == ^slug - ) - - case Repo.one(query) do - nil -> - slug - - _tag -> - slug - |> Mobilizon.Ecto.increment_slug() - |> build_unique_slug(changeset) - end - end -end - defmodule Mobilizon.Events.Tag do @moduledoc """ - Represents a tag for events + Represents a tag for events. """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.Tag - alias Mobilizon.Events.Tag.TitleSlug + alias Mobilizon.Events.TagRelation + alias Mobilizon.Events.Tag.TitleSlug + + @type t :: %__MODULE__{ + title: String.t(), + slug: TitleSlug.Type.t(), + related_tags: [t] + } + + @required_attrs [:title, :slug] + @attrs @required_attrs schema "tags" do field(:title, :string) field(:slug, TitleSlug.Type) - many_to_many(:related_tags, Tag, join_through: TagRelation) + + many_to_many(:related_tags, __MODULE__, join_through: TagRelation) timestamps() end @doc false - def changeset(%Tag{} = tag, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = tag, attrs) do tag - |> cast(attrs, [:title]) + |> cast(attrs, @attrs) |> TitleSlug.maybe_generate_slug() - |> validate_required([:title, :slug]) + |> validate_required(@required_attrs) |> TitleSlug.unique_constraint() end end diff --git a/lib/mobilizon/events/tag/title_slug.ex b/lib/mobilizon/events/tag/title_slug.ex new file mode 100644 index 000000000..5e3fcf3b1 --- /dev/null +++ b/lib/mobilizon/events/tag/title_slug.ex @@ -0,0 +1,53 @@ +defmodule Mobilizon.Events.Tag.TitleSlug do + @moduledoc """ + Generates slugs for tags. + """ + + use EctoAutoslugField.Slug, from: :title, to: :slug + + alias Mobilizon.Events + + @slug_separator "-" + + @doc """ + Builds a slug. + """ + @spec build_slug(keyword, Ecto.Changeset.t()) :: String.t() + def build_slug(sources, changeset) do + slug = super(sources, changeset) + + build_unique_slug(slug, changeset) + end + + @spec build_unique_slug(String.t(), Ecto.Changeset.t()) :: String.t() + defp build_unique_slug(slug, changeset) do + case Events.get_tag_by_slug(slug) do + nil -> + slug + + _tag -> + slug + |> increment_slug() + |> build_unique_slug(changeset) + end + end + + @spec increment_slug(String.t()) :: String.t() + defp increment_slug(slug) do + case List.pop_at(String.split(slug, @slug_separator), -1) do + {nil, _} -> + slug + + {suffix, slug_parts} -> + case Integer.parse(suffix) do + {id, _} -> + Enum.join(slug_parts, @slug_separator) <> + @slug_separator <> + Integer.to_string(id + 1) + + :error -> + "#{slug}#{@slug_separator}1" + end + end + end +end diff --git a/lib/mobilizon/events/tag_relation.ex b/lib/mobilizon/events/tag_relation.ex new file mode 100644 index 000000000..2d038358f --- /dev/null +++ b/lib/mobilizon/events/tag_relation.ex @@ -0,0 +1,48 @@ +defmodule Mobilizon.Events.TagRelation do + @moduledoc """ + Represents a tag relation. + """ + + use Ecto.Schema + + import Ecto.Changeset + + alias Mobilizon.Events.Tag + + @type t :: %__MODULE__{ + weight: integer, + tag: Tag.t(), + link: Tag.t() + } + + @required_attrs [:tag_id, :link_id] + @optional_attrs [:weight] + @attrs @required_attrs ++ @optional_attrs + + @primary_key false + schema "tag_relations" do + field(:weight, :integer, default: 1) + + belongs_to(:tag, Tag, primary_key: true) + belongs_to(:link, Tag, primary_key: true) + end + + @doc false + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = tag, attrs) do + # Return if tag_id or link_id are not set because it will fail later otherwise + with %Ecto.Changeset{errors: [], changes: changes} = changeset <- + tag + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) do + changeset + |> put_change(:tag_id, min(changes.tag_id, changes.link_id)) + |> put_change(:link_id, max(changes.tag_id, changes.link_id)) + |> unique_constraint(:tag_id, name: :tag_relations_pkey) + |> check_constraint(:tag_id, + name: :no_self_loops_check, + message: "Can't add a relation on self" + ) + end + end +end diff --git a/lib/mobilizon/events/tag_relations.ex b/lib/mobilizon/events/tag_relations.ex deleted file mode 100644 index 65618cb19..000000000 --- a/lib/mobilizon/events/tag_relations.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Mobilizon.Events.TagRelation do - @moduledoc """ - Represents a tag for events - """ - use Ecto.Schema - import Ecto.Changeset - alias Mobilizon.Events.Tag - alias Mobilizon.Events.TagRelation - - @primary_key false - schema "tag_relations" do - belongs_to(:tag, Tag, primary_key: true) - belongs_to(:link, Tag, primary_key: true) - field(:weight, :integer, default: 1) - end - - @doc false - def changeset(%TagRelation{} = tag, attrs) do - changeset = - tag - |> cast(attrs, [:tag_id, :link_id, :weight]) - |> validate_required([:tag_id, :link_id]) - - # Return if tag_id or link_id are not set because it will fail later otherwise - with %Ecto.Changeset{errors: []} <- changeset do - changes = changeset.changes - - changeset = - changeset - |> put_change(:tag_id, min(changes.tag_id, changes.link_id)) - |> put_change(:link_id, max(changes.tag_id, changes.link_id)) - - changeset - |> unique_constraint(:tag_id, name: :tag_relations_pkey) - |> check_constraint(:tag_id, - name: :no_self_loops_check, - message: "Can't add a relation on self" - ) - end - end -end diff --git a/lib/mobilizon/events/track.ex b/lib/mobilizon/events/track.ex index a3dab3709..dd626a823 100644 --- a/lib/mobilizon/events/track.ex +++ b/lib/mobilizon/events/track.ex @@ -1,15 +1,31 @@ defmodule Mobilizon.Events.Track do @moduledoc """ - Represents a track for an event (such as a theme) having multiple sessions + Represents a track for an event (such as a theme) having multiple sessions. """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.{Track, Event, Session} + + alias Mobilizon.Events.{Event, Session} + + @type t :: %__MODULE__{ + color: String.t(), + description: String.t(), + name: String.t(), + event: Event.t(), + sessions: [Session.t()] + } + + @required_attrs [:name, :description, :color] + @optional_attrs [:event_id] + @attrs @required_attrs ++ @optional_attrs schema "tracks" do field(:color, :string) field(:description, :string) field(:name, :string) + belongs_to(:event, Event) has_many(:sessions, Session) @@ -17,9 +33,10 @@ defmodule Mobilizon.Events.Track do end @doc false - def changeset(%Track{} = track, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = track, attrs) do track - |> cast(attrs, [:name, :description, :color, :event_id]) - |> validate_required([:name, :description, :color]) + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) end end diff --git a/lib/mobilizon/media.ex b/lib/mobilizon/media.ex deleted file mode 100644 index 850b5274e..000000000 --- a/lib/mobilizon/media.ex +++ /dev/null @@ -1,125 +0,0 @@ -defmodule Mobilizon.Media do - @moduledoc """ - The Media context. - """ - - import Ecto.Query, warn: false - alias Mobilizon.Repo - - alias Mobilizon.Media.Picture - alias Mobilizon.Media.File - alias Ecto.Multi - - @doc false - def data() do - Dataloader.Ecto.new(Mobilizon.Repo, query: &query/2) - end - - @doc false - def query(queryable, _params) do - queryable - end - - @doc """ - Gets a single picture. - - Raises `Ecto.NoResultsError` if the Picture does not exist. - - ## Examples - - iex> get_picture!(123) - %Picture{} - - iex> get_picture!(456) - ** (Ecto.NoResultsError) - - """ - def get_picture!(id), do: Repo.get!(Picture, id) - - def get_picture(id), do: Repo.get(Picture, id) - - @doc """ - Get a picture by it's URL - """ - @spec get_picture_by_url(String.t()) :: Picture.t() | nil - def get_picture_by_url(url) do - from( - p in Picture, - where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|) - ) - |> Repo.one() - end - - @doc """ - Creates a picture. - - ## Examples - - iex> create_picture(%{field: value}) - {:ok, %Picture{}} - - iex> create_picture(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_picture(attrs \\ %{}) do - %Picture{} - |> Picture.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a picture. - - ## Examples - - iex> update_picture(picture, %{field: new_value}) - {:ok, %Picture{}} - - iex> update_picture(picture, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_picture(%Picture{} = picture, attrs) do - picture - |> Picture.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a Picture. - - ## Examples - - iex> delete_picture(picture) - {:ok, %Picture{}} - - iex> delete_picture(picture) - {:error, %Ecto.Changeset{}} - - """ - def delete_picture(%Picture{} = picture) do - case Multi.new() - |> Multi.delete(:picture, picture) - |> Multi.run(:remove, fn _repo, %{picture: %Picture{file: %File{url: url}}} = _picture -> - MobilizonWeb.Upload.remove(url) - end) - |> Repo.transaction() do - {:ok, %{picture: %Picture{} = picture}} -> {:ok, picture} - {:error, :remove, error, _} -> {:error, error} - end - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking picture changes. - - ## Examples - - iex> change_picture(picture) - %Ecto.Changeset{source: %Picture{}} - - """ - def change_picture(%Picture{} = picture) do - Picture.changeset(picture, %{}) - end -end diff --git a/lib/mobilizon/media/file.ex b/lib/mobilizon/media/file.ex index 2574a012b..f82acc417 100644 --- a/lib/mobilizon/media/file.ex +++ b/lib/mobilizon/media/file.ex @@ -1,9 +1,22 @@ defmodule Mobilizon.Media.File do @moduledoc """ - Represents a file entity + Represents a file entity. """ + use Ecto.Schema - import Ecto.Changeset + + import Ecto.Changeset, only: [cast: 3, validate_required: 2] + + @type t :: %__MODULE__{ + name: String.t(), + url: String.t(), + content_type: String.t(), + size: integer + } + + @required_attrs [:name, :url] + @optional_attrs [:content_type, :size] + @attrs @required_attrs ++ @optional_attrs embedded_schema do field(:name, :string) @@ -15,9 +28,10 @@ defmodule Mobilizon.Media.File do end @doc false - def changeset(picture, attrs) do - picture - |> cast(attrs, [:name, :url, :content_type, :size]) - |> validate_required([:name, :url]) + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = file, attrs) do + file + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) end end diff --git a/lib/mobilizon/media/media.ex b/lib/mobilizon/media/media.ex new file mode 100644 index 000000000..2062ae074 --- /dev/null +++ b/lib/mobilizon/media/media.ex @@ -0,0 +1,85 @@ +defmodule Mobilizon.Media do + @moduledoc """ + The Media context. + """ + + import Ecto.Query + + alias Ecto.Multi + + alias Mobilizon.Media.{File, Picture} + alias Mobilizon.Storage.Repo + + @doc """ + Gets a single picture. + """ + @spec get_picture(integer | String.t()) :: Picture.t() | nil + def get_picture(id), do: Repo.get(Picture, id) + + @doc """ + Gets a single picture. + Raises `Ecto.NoResultsError` if the picture does not exist. + """ + @spec get_picture!(integer | String.t()) :: Picture.t() + def get_picture!(id), do: Repo.get!(Picture, id) + + @doc """ + Get a picture by it's URL. + """ + @spec get_picture_by_url(String.t()) :: Picture.t() | nil + def get_picture_by_url(url) do + url + |> picture_by_url_query() + |> Repo.one() + end + + @doc """ + Creates a picture. + """ + @spec create_picture(map) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()} + def create_picture(attrs \\ %{}) do + %Picture{} + |> Picture.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a picture. + """ + @spec update_picture(Picture.t(), map) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()} + def update_picture(%Picture{} = picture, attrs) do + picture + |> Picture.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a picture. + """ + @spec delete_picture(Picture.t()) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()} + def delete_picture(%Picture{} = picture) do + transaction = + Multi.new() + |> Multi.delete(:picture, picture) + |> Multi.run(:remove, fn _repo, %{picture: %Picture{file: %File{url: url}}} -> + MobilizonWeb.Upload.remove(url) + end) + |> Repo.transaction() + + case transaction do + {:ok, %{picture: %Picture{} = picture}} -> + {:ok, picture} + + {:error, :remove, error, _} -> + {:error, error} + end + end + + @spec picture_by_url_query(String.t()) :: Ecto.Query.t() + defp picture_by_url_query(url) do + from( + p in Picture, + where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|) + ) + end +end diff --git a/lib/mobilizon/media/picture.ex b/lib/mobilizon/media/picture.ex index 62b811894..bd1948307 100644 --- a/lib/mobilizon/media/picture.ex +++ b/lib/mobilizon/media/picture.ex @@ -1,11 +1,19 @@ defmodule Mobilizon.Media.Picture do @moduledoc """ - Represents a picture entity + Represents a picture entity. """ + use Ecto.Schema - import Ecto.Changeset - alias Mobilizon.Media.File + + import Ecto.Changeset, only: [cast: 3, cast_embed: 2] + alias Mobilizon.Actors.Actor + alias Mobilizon.Media.File + + @type t :: %__MODULE__{ + file: File.t(), + actor: Actor.t() + } schema "pictures" do embeds_one(:file, File, on_replace: :update) @@ -15,7 +23,8 @@ defmodule Mobilizon.Media.Picture do end @doc false - def changeset(picture, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = picture, attrs) do picture |> cast(attrs, [:actor_id]) |> cast_embed(:file) diff --git a/lib/mobilizon/postgrex_types.ex b/lib/mobilizon/postgrex_types.ex deleted file mode 100644 index d06805578..000000000 --- a/lib/mobilizon/postgrex_types.ex +++ /dev/null @@ -1,5 +0,0 @@ -Postgrex.Types.define( - Mobilizon.PostgresTypes, - [Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(), - json: Jason -) diff --git a/lib/mobilizon/reports.ex b/lib/mobilizon/reports.ex deleted file mode 100644 index 6a00dc70e..000000000 --- a/lib/mobilizon/reports.ex +++ /dev/null @@ -1,248 +0,0 @@ -defmodule Mobilizon.Reports do - @moduledoc """ - The Reports context. - """ - - import Ecto.Query, warn: false - alias Mobilizon.Repo - import Mobilizon.Ecto - - alias Mobilizon.Reports.Report - alias Mobilizon.Reports.Note - - @doc false - def data() do - Dataloader.Ecto.new(Mobilizon.Repo, query: &query/2) - end - - @doc false - def query(queryable, _params) do - queryable - end - - @doc """ - Returns the list of reports. - - ## Examples - - iex> list_reports() - [%Report{}, ...] - - """ - @spec list_reports(integer(), integer(), atom(), atom()) :: list(Report.t()) - def list_reports( - page \\ nil, - limit \\ nil, - sort \\ :updated_at, - direction \\ :desc, - status \\ :open - ) do - from( - r in Report, - preload: [:reported, :reporter, :manager, :event, :comments, :notes], - where: r.status == ^status - ) - |> paginate(page, limit) - |> sort(sort, direction) - |> Repo.all() - end - - def count_opened_reports() do - query = from(r in Report, where: r.status == ^:open) - Repo.aggregate(query, :count, :id) - end - - @doc """ - Gets a single report. - - Raises `Ecto.NoResultsError` if the Report does not exist. - - ## Examples - - iex> get_report!(123) - %Report{} - - iex> get_report!(456) - ** (Ecto.NoResultsError) - - """ - def get_report!(id) do - with %Report{} = report <- Repo.get!(Report, id) do - Repo.preload(report, [:reported, :reporter, :manager, :event, :comments, :notes]) - end - end - - @doc """ - Gets a single report. - - Returns `nil` if the Report does not exist. - - ## Examples - - iex> get_report(123) - %Report{} - - iex> get_report(456) - nil - - """ - def get_report(id) do - with %Report{} = report <- Repo.get(Report, id) do - Repo.preload(report, [:reported, :reporter, :manager, :event, :comments, :notes]) - end - end - - @doc """ - Get a report by it's URL - """ - @spec get_report_by_url(String.t()) :: Report.t() | nil - def get_report_by_url(url) do - from( - r in Report, - where: r.uri == ^url - ) - |> Repo.one() - end - - @doc """ - Creates a report. - - ## Examples - - iex> create_report(%{field: value}) - {:ok, %Report{}} - - iex> create_report(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_report(attrs \\ %{}) do - with {:ok, %Report{} = report} <- - %Report{} - |> Report.creation_changeset(attrs) - |> Repo.insert() do - {:ok, Repo.preload(report, [:event, :reported, :reporter, :comments])} - end - end - - @doc """ - Updates a report. - - ## Examples - - iex> update_report(report, %{field: new_value}) - {:ok, %Report{}} - - iex> update_report(report, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_report(%Report{} = report, attrs) do - report - |> Report.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a Report. - - ## Examples - - iex> delete_report(report) - {:ok, %Report{}} - - iex> delete_report(report) - {:error, %Ecto.Changeset{}} - - """ - def delete_report(%Report{} = report) do - Repo.delete(report) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking report changes. - - ## Examples - - iex> change_report(report) - %Ecto.Changeset{source: %Report{}} - - """ - def change_report(%Report{} = report) do - Report.changeset(report, %{}) - end - - @doc """ - Returns the list of notes for a report. - - ## Examples - - iex> list_notes_for_report(%Report{id: 1}) - [%Note{}, ...] - - """ - @spec list_notes_for_report(Report.t()) :: list(Report.t()) - def list_notes_for_report(%Report{id: report_id}) do - from( - n in Note, - where: n.report_id == ^report_id, - preload: [:report, :moderator] - ) - |> Repo.all() - end - - @doc """ - Gets a single note. - - Raises `Ecto.NoResultsError` if the Note does not exist. - - ## Examples - - iex> get_note!(123) - %Note{} - - iex> get_note!(456) - ** (Ecto.NoResultsError) - - """ - def get_note!(id), do: Repo.get!(Note, id) - - def get_note(id), do: Repo.get(Note, id) - - @doc """ - Creates a note report. - - ## Examples - - iex> create_report_note(%{field: value}) - {:ok, %Note{}} - - iex> create_report_note(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_report_note(attrs \\ %{}) do - with {:ok, %Note{} = note} <- - %Note{} - |> Note.changeset(attrs) - |> Repo.insert() do - {:ok, Repo.preload(note, [:report, :moderator])} - end - end - - @doc """ - Deletes a note report. - - ## Examples - - iex> delete_report_note(note) - {:ok, %Note{}} - - iex> delete_report_note(note) - {:error, %Ecto.Changeset{}} - - """ - def delete_report_note(%Note{} = note) do - Repo.delete(note) - end -end diff --git a/lib/mobilizon/reports/note.ex b/lib/mobilizon/reports/note.ex index 56d1993c7..560f62469 100644 --- a/lib/mobilizon/reports/note.ex +++ b/lib/mobilizon/reports/note.ex @@ -1,28 +1,41 @@ defmodule Mobilizon.Reports.Note do @moduledoc """ - Report Note entity + Represents a note entity. """ + use Ecto.Schema - import Ecto.Changeset + + import Ecto.Changeset, only: [cast: 3, validate_required: 2] + alias Mobilizon.Actors.Actor alias Mobilizon.Reports.Report + @required_attrs [:content, :moderator_id, :report_id] + @attrs @required_attrs + @timestamps_opts [type: :utc_datetime] - @attrs [:content, :moderator_id, :report_id] + + @type t :: %__MODULE__{ + content: String.t(), + report: Report.t(), + moderator: Actor.t() + } @derive {Jason.Encoder, only: [:content]} schema "report_notes" do field(:content, :string) - belongs_to(:moderator, Actor) + belongs_to(:report, Report) + belongs_to(:moderator, Actor) timestamps() end @doc false - def changeset(note, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = note, attrs) do note |> cast(attrs, @attrs) - |> validate_required(@attrs) + |> validate_required(@required_attrs) end end diff --git a/lib/mobilizon/reports/report.ex b/lib/mobilizon/reports/report.ex index 2d5b5b0cc..bcce0ec3e 100644 --- a/lib/mobilizon/reports/report.ex +++ b/lib/mobilizon/reports/report.ex @@ -1,45 +1,50 @@ -import EctoEnum - -defenum(Mobilizon.Reports.ReportStateEnum, :report_state, [ - :open, - :closed, - :resolved -]) - defmodule Mobilizon.Reports.Report do @moduledoc """ - Report entity + Represents a report entity. """ + use Ecto.Schema + import Ecto.Changeset - alias Mobilizon.Events.Comment - alias Mobilizon.Events.Event + alias Mobilizon.Actors.Actor - alias Mobilizon.Reports.Note + alias Mobilizon.Events.{Comment, Event} + alias Mobilizon.Reports.{Note, ReportStatus} + + @type t :: %__MODULE__{ + content: String.t(), + status: ReportStatus.t(), + uri: String.t(), + reported: Actor.t(), + reporter: Actor.t(), + manager: Actor.t(), + event: Event.t(), + comments: [Comment.t()], + notes: [Note.t()] + } + + @required_attrs [:uri, :reported_id, :reporter_id] + @optional_attrs [:content, :status, :manager_id, :event_id] + @attrs @required_attrs ++ @optional_attrs @timestamps_opts [type: :utc_datetime] @derive {Jason.Encoder, only: [:status, :uri]} schema "reports" do field(:content, :string) - field(:status, Mobilizon.Reports.ReportStateEnum, default: :open) + field(:status, ReportStatus, default: :open) field(:uri, :string) # The reported actor belongs_to(:reported, Actor) - # The actor who reported belongs_to(:reporter, Actor) - # The actor who last acted on this report belongs_to(:manager, Actor) - # The eventual Event inside the report belongs_to(:event, Event) - # The eventual Comments inside the report many_to_many(:comments, Comment, join_through: "reports_comments", on_replace: :delete) - # The notes associated to the report has_many(:notes, Note, foreign_key: :report_id) @@ -47,13 +52,16 @@ defmodule Mobilizon.Reports.Report do end @doc false - def changeset(report, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = report, attrs) do report - |> cast(attrs, [:content, :status, :uri, :reported_id, :reporter_id, :manager_id, :event_id]) - |> validate_required([:uri, :reported_id, :reporter_id]) + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) end - def creation_changeset(report, attrs) do + @doc false + @spec creation_changeset(t, map) :: Ecto.Changeset.t() + def creation_changeset(%__MODULE__{} = report, attrs) do report |> changeset(attrs) |> put_assoc(:comments, attrs["comments"]) diff --git a/lib/mobilizon/reports/reports.ex b/lib/mobilizon/reports/reports.ex new file mode 100644 index 000000000..8c76c6332 --- /dev/null +++ b/lib/mobilizon/reports/reports.ex @@ -0,0 +1,171 @@ +defmodule Mobilizon.Reports do + @moduledoc """ + The Reports context. + """ + + import Ecto.Query + import EctoEnum + + import Mobilizon.Storage.Ecto + + alias Mobilizon.Reports.{Note, Report} + alias Mobilizon.Storage.{Page, Repo} + + defenum(ReportStatus, :report_status, [:open, :closed, :resolved]) + + @doc """ + Gets a single report. + """ + @spec get_report(integer | String.t()) :: Report.t() | nil + def get_report(id) do + Report + |> Repo.get(id) + |> Repo.preload([:reported, :reporter, :manager, :event, :comments, :notes]) + end + + @doc """ + Gets a single report. + Raises `Ecto.NoResultsError` if the report does not exist. + """ + @spec get_report!(integer | String.t()) :: Report.t() + def get_report!(id) do + Report + |> Repo.get!(id) + |> Repo.preload([:reported, :reporter, :manager, :event, :comments, :notes]) + end + + @doc """ + Get a report by its URL + """ + @spec get_report_by_url(String.t()) :: Report.t() | nil + def get_report_by_url(url) do + url + |> report_by_url_query() + |> Repo.one() + end + + @doc """ + Creates a report. + """ + @spec create_report(map) :: {:ok, Report.t()} | {:error, Ecto.Changeset.t()} + def create_report(attrs \\ %{}) do + with {:ok, %Report{} = report} <- + %Report{} + |> Report.changeset(attrs) + |> Repo.insert() do + {:ok, Repo.preload(report, [:event, :reported, :reporter, :comments])} + end + end + + @doc """ + Updates a report. + """ + @spec update_report(Report.t(), map) :: {:ok, Report.t()} | {:error, Ecto.Changeset.t()} + def update_report(%Report{} = report, attrs) do + report + |> Report.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a report. + """ + @spec delete_report(Report.t()) :: {:ok, Report.t()} | {:error, Ecto.Changeset.t()} + def delete_report(%Report{} = report), do: Repo.delete(report) + + @doc """ + Returns the list of reports. + """ + @spec list_reports(integer | nil, integer | nil, atom, atom, ReportStatus) :: [Report.t()] + def list_reports( + page \\ nil, + limit \\ nil, + sort \\ :updated_at, + direction \\ :asc, + status \\ :open + ) do + status + |> list_reports_query() + |> Page.paginate(page, limit) + |> sort(sort, direction) + |> Repo.all() + end + + @doc """ + Counts opened reports. + """ + @spec count_opened_reports :: integer + def count_opened_reports do + Repo.aggregate(count_reports_query(), :count, :id) + end + + @doc """ + Gets a single note. + """ + @spec get_note(integer | String.t()) :: Note.t() | nil + def get_note(id), do: Repo.get(Note, id) + + @doc """ + Gets a single note. + Raises `Ecto.NoResultsError` if the Note does not exist. + """ + @spec get_note!(integer | String.t()) :: Note.t() + def get_note!(id), do: Repo.get!(Note, id) + + @doc """ + Creates a note. + """ + @spec create_note(map) :: {:ok, Note.t()} | {:error, Ecto.Changeset.t()} + def create_note(attrs \\ %{}) do + with {:ok, %Note{} = note} <- + %Note{} + |> Note.changeset(attrs) + |> Repo.insert() do + {:ok, Repo.preload(note, [:report, :moderator])} + end + end + + @doc """ + Deletes a note. + """ + @spec delete_note(Note.t()) :: {:ok, Note.t()} | {:error, Ecto.Changeset.t()} + 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(ReportStatus.t()) :: Ecto.Query.t() + defp list_reports_query(status) do + from( + r in Report, + preload: [:reported, :reporter, :manager, :event, :comments, :notes], + where: r.status == ^status + ) + end + + @spec count_reports_query :: Ecto.Query.t() + defp count_reports_query do + from(r in Report, where: r.status == ^:open) + end + + @spec list_notes_for_report_query(integer | String.t()) :: Ecto.Query.t() + defp list_notes_for_report_query(report_id) do + from( + n in Note, + where: n.report_id == ^report_id, + preload: [:report, :moderator] + ) + end +end diff --git a/lib/mobilizon/storage/ecto.ex b/lib/mobilizon/storage/ecto.ex new file mode 100644 index 000000000..3a1dc5411 --- /dev/null +++ b/lib/mobilizon/storage/ecto.ex @@ -0,0 +1,15 @@ +defmodule Mobilizon.Storage.Ecto do + @moduledoc """ + Mobilizon Ecto utils + """ + + import Ecto.Query, warn: false + + @doc """ + Adds sort to the query. + """ + @spec sort(Ecto.Query.t(), atom, atom) :: Ecto.Query.t() + def sort(query, sort, direction) do + from(query, order_by: [{^direction, ^sort}]) + end +end diff --git a/lib/mobilizon/storage/page.ex b/lib/mobilizon/storage/page.ex new file mode 100644 index 000000000..af20d4a7b --- /dev/null +++ b/lib/mobilizon/storage/page.ex @@ -0,0 +1,48 @@ +defmodule Mobilizon.Storage.Page do + @moduledoc """ + Module for pagination of queries. + """ + + import Ecto.Query + + alias Mobilizon.Storage.Repo + + defstruct [ + :total, + :elements + ] + + @type t :: %__MODULE__{ + total: integer, + elements: struct + } + + @doc """ + Returns a Page struct for a query. + """ + @spec build_page(Ecto.Query.t(), integer | nil, integer | nil) :: t + def build_page(query, page, limit) do + [total, elements] = + [ + fn -> Repo.aggregate(query, :count, :id) end, + fn -> Repo.all(paginate(query, page, limit)) end + ] + |> Enum.map(&Task.async/1) + |> Enum.map(&Task.await/1) + + %__MODULE__{total: total, elements: elements} + end + + @doc """ + Add limit and offset to the query. + """ + @spec paginate(Ecto.Query.t() | struct, integer | nil, integer | nil) :: Ecto.Query.t() + def paginate(query, page \\ 1, size \\ 10) + + def paginate(query, page, _size) when is_nil(page), do: paginate(query) + def paginate(query, page, size) when is_nil(size), do: paginate(query, page) + + def paginate(query, page, size) do + from(query, limit: ^size, offset: ^((page - 1) * size)) + end +end diff --git a/lib/mobilizon/storage/postgrex_types.ex b/lib/mobilizon/storage/postgrex_types.ex new file mode 100644 index 000000000..a260d062d --- /dev/null +++ b/lib/mobilizon/storage/postgrex_types.ex @@ -0,0 +1,5 @@ +Postgrex.Types.define( + Mobilizon.Storage.PostgresTypes, + [Geo.PostGIS.Extension | Ecto.Adapters.Postgres.extensions()], + json: Jason +) diff --git a/lib/mobilizon/repo.ex b/lib/mobilizon/storage/repo.ex similarity index 61% rename from lib/mobilizon/repo.ex rename to lib/mobilizon/storage/repo.ex index d25e5b21b..8cdde1a45 100644 --- a/lib/mobilizon/repo.ex +++ b/lib/mobilizon/storage/repo.ex @@ -1,14 +1,14 @@ -defmodule Mobilizon.Repo do +defmodule Mobilizon.Storage.Repo do @moduledoc """ - Mobilizon Repo + Mobilizon Repo. """ + use Ecto.Repo, otp_app: :mobilizon, adapter: Ecto.Adapters.Postgres @doc """ - Dynamically loads the repository url from the - DATABASE_URL environment variable. + Dynamically loads the repository url from the DATABASE_URL environment variable. """ def init(_, opts) do {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} diff --git a/lib/mobilizon/users/user.ex b/lib/mobilizon/users/user.ex index 449f4b1cb..981b9de4a 100644 --- a/lib/mobilizon/users/user.ex +++ b/lib/mobilizon/users/user.ex @@ -1,63 +1,80 @@ -import EctoEnum - -defenum(Mobilizon.Users.UserRoleEnum, :user_role_type, [ - :administrator, - :moderator, - :user -]) - defmodule Mobilizon.Users.User do @moduledoc """ - Represents a local user + Represents a local user. """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Actors.Actor - alias Mobilizon.Users.User - alias Mobilizon.Service.EmailChecker + alias Mobilizon.Crypto alias Mobilizon.Events.FeedToken + alias Mobilizon.Service.EmailChecker + alias Mobilizon.Users.UserRole + + @type t :: %__MODULE__{ + email: String.t(), + password_hash: String.t(), + password: String.t(), + role: UserRole.t(), + confirmed_at: DateTime.t(), + confirmation_sent_at: DateTime.t(), + confirmation_token: String.t(), + reset_password_sent_at: DateTime.t(), + reset_password_token: String.t(), + default_actor: Actor.t(), + actors: [Actor.t()], + feed_tokens: [FeedToken.t()] + } + + @required_attrs [:email] + @optional_attrs [ + :role, + :password, + :password_hash, + :confirmed_at, + :confirmation_sent_at, + :confirmation_token, + :reset_password_sent_at, + :reset_password_token + ] + @attrs @required_attrs ++ @optional_attrs + + @registration_required_attrs [:email, :password] + + @password_reset_required_attrs [:password, :reset_password_token, :reset_password_sent_at] + + @confirmation_token_length 30 schema "users" do field(:email, :string) field(:password_hash, :string) field(:password, :string, virtual: true) - field(:role, Mobilizon.Users.UserRoleEnum, default: :user) - has_many(:actors, Actor) - belongs_to(:default_actor, Actor) + field(:role, UserRole, default: :user) field(:confirmed_at, :utc_datetime) field(:confirmation_sent_at, :utc_datetime) field(:confirmation_token, :string) field(:reset_password_sent_at, :utc_datetime) field(:reset_password_token, :string) + + belongs_to(:default_actor, Actor) + has_many(:actors, Actor) has_many(:feed_tokens, FeedToken, foreign_key: :user_id) timestamps() end @doc false - def changeset(%User{} = user, attrs) do + @spec changeset(t, map) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = user, attrs) do changeset = user - |> cast(attrs, [ - :email, - :role, - :password, - :password_hash, - :confirmed_at, - :confirmation_sent_at, - :confirmation_token, - :reset_password_sent_at, - :reset_password_token - ]) - |> validate_required([:email]) + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) |> unique_constraint(:email, message: "This email is already used.") |> validate_email() - |> validate_length( - :password, - min: 6, - max: 100, - message: "The chosen password is too short." - ) + |> validate_length(:password, min: 6, max: 100, message: "The chosen password is too short.") if Map.has_key?(attrs, :default_actor) do put_assoc(changeset, :default_actor, attrs.default_actor) @@ -66,11 +83,13 @@ defmodule Mobilizon.Users.User do end end - def registration_changeset(struct, params) do - struct - |> changeset(params) + @doc false + @spec registration_changeset(t, map) :: Ecto.Changeset.t() + def registration_changeset(%__MODULE__{} = user, attrs) do + user + |> changeset(attrs) |> cast_assoc(:default_actor) - |> validate_required([:email, :password]) + |> validate_required(@registration_required_attrs) |> hash_password() |> save_confirmation_token() |> unique_constraint( @@ -79,16 +98,18 @@ defmodule Mobilizon.Users.User do ) end - def send_password_reset_changeset(%User{} = user, attrs) do - user - |> cast(attrs, [:reset_password_token, :reset_password_sent_at]) + @doc false + @spec send_password_reset_changeset(t, map) :: Ecto.Changeset.t() + def send_password_reset_changeset(%__MODULE__{} = user, attrs) do + cast(user, attrs, [:reset_password_token, :reset_password_sent_at]) end - def password_reset_changeset(%User{} = user, attrs) do + @doc false + @spec password_reset_changeset(t, map) :: Ecto.Changeset.t() + def password_reset_changeset(%__MODULE__{} = user, attrs) do user - |> cast(attrs, [:password, :reset_password_token, :reset_password_sent_at]) - |> validate_length( - :password, + |> cast(attrs, @password_reset_required_attrs) + |> validate_length(:password, min: 6, max: 100, message: "registration.error.password_too_short" @@ -96,28 +117,48 @@ defmodule Mobilizon.Users.User do |> hash_password() end + @doc """ + Checks whether an user is confirmed. + """ + @spec is_confirmed(t) :: boolean + def is_confirmed(%__MODULE__{confirmed_at: nil}), do: false + def is_confirmed(%__MODULE__{}), do: true + + @doc """ + Returns whether an user owns an actor. + """ + @spec owns_actor(t, integer | String.t()) :: {:is_owned, Actor.t() | nil} + def owns_actor(%__MODULE__{actors: actors}, actor_id) do + user_actor = Enum.find(actors, fn actor -> "#{actor.id}" == "#{actor_id}" end) + + {:is_owned, user_actor} + end + + @spec save_confirmation_token(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp save_confirmation_token(changeset) do case changeset do %Ecto.Changeset{valid?: true, changes: %{email: _email}} -> - changeset = put_change(changeset, :confirmation_token, random_string(30)) + now = DateTime.utc_now() - put_change( - changeset, - :confirmation_sent_at, - DateTime.utc_now() |> DateTime.truncate(:second) - ) + changeset + |> put_change(:confirmation_token, Crypto.random_string(@confirmation_token_length)) + |> put_change(:confirmation_sent_at, DateTime.truncate(now, :second)) _ -> changeset end end + @spec validate_email(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp validate_email(changeset) do case changeset do %Ecto.Changeset{valid?: true, changes: %{email: email}} -> case EmailChecker.valid?(email) do - false -> add_error(changeset, :email, "Email doesn't fit required format") - _ -> changeset + false -> + add_error(changeset, :email, "Email doesn't fit required format") + + true -> + changeset end _ -> @@ -125,46 +166,14 @@ defmodule Mobilizon.Users.User do end end - defp random_string(length) do - length - |> :crypto.strong_rand_bytes() - |> Base.url_encode64() - end - - # Hash password when it's changed + @spec hash_password(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp hash_password(changeset) do case changeset do %Ecto.Changeset{valid?: true, changes: %{password: password}} -> - put_change( - changeset, - :password_hash, - Argon2.hash_pwd_salt(password) - ) + put_change(changeset, :password_hash, Argon2.hash_pwd_salt(password)) _ -> changeset end end - - def is_confirmed(%User{confirmed_at: nil} = _user), do: {:error, :unconfirmed} - def is_confirmed(%User{} = user), do: {:ok, user} - - @doc """ - Returns whether an user owns an actor - """ - @spec owns_actor(struct(), String.t()) :: {:is_owned, false} | {:is_owned, true, Actor.t()} - def owns_actor(%User{} = user, actor_id) when is_binary(actor_id) do - case Integer.parse(actor_id) do - {actor_id, ""} -> owns_actor(user, actor_id) - _ -> {:is_owned, false} - end - end - - @spec owns_actor(struct(), integer()) :: {:is_owned, false} | {:is_owned, true, Actor.t()} - def owns_actor(%User{actors: actors}, actor_id) do - case Enum.find(actors, fn a -> a.id == actor_id end) do - nil -> {:is_owned, false} - actor -> {:is_owned, true, actor} - end - end end diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index 92697d931..2fe784980 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -3,116 +3,86 @@ defmodule Mobilizon.Users do The Users context. """ - import Ecto.Query, warn: false + import Ecto.Query + import EctoEnum - alias Mobilizon.Repo - import Mobilizon.Ecto + import Mobilizon.Storage.Ecto alias Mobilizon.Actors.Actor + alias Mobilizon.Events + alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Users.User - @doc false - def data() do - Dataloader.Ecto.new(Repo, query: &query/2) - end + @type tokens :: %{ + required(:access_token) => String.t(), + required(:refresh_token) => String.t() + } - @doc false - def query(queryable, _params) do - queryable - end + defenum(UserRole, :user_role, [:administrator, :moderator, :user]) @doc """ - Register user + Registers an user. """ - @spec register(map()) :: {:ok, User.t()} | {:error, String.t()} + @spec register(map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def register(%{email: _email, password: _password} = args) do with {:ok, %User{} = user} <- %User{} |> User.registration_changeset(args) - |> Mobilizon.Repo.insert() do - Mobilizon.Events.create_feed_token(%{"user_id" => user.id}) + |> Repo.insert() do + Events.create_feed_token(%{"user_id" => user.id}) + {:ok, user} end end @doc """ - Gets an user by it's email - - ## Examples - - iex> get_user_by_email("test@test.tld", true) - {:ok, %Mobilizon.Users.User{}} - - iex> get_user_by_email("test@notfound.tld", false) - {:error, :user_not_found} + Gets a single user. + Raises `Ecto.NoResultsError` if the user does not exist. """ + @spec get_user!(integer | String.t()) :: User.t() + def get_user!(id), do: Repo.get!(User, id) + + @doc """ + Gets an user by its email. + """ + @spec get_user_by_email(String.t(), boolean | nil) :: + {:ok, User.t()} | {:error, :user_not_found} def get_user_by_email(email, activated \\ nil) do - query = - case activated do - nil -> - from(u in User, where: u.email == ^email, preload: :default_actor) - - true -> - from( - u in User, - where: u.email == ^email and not is_nil(u.confirmed_at), - preload: :default_actor - ) - - false -> - from( - u in User, - where: u.email == ^email and is_nil(u.confirmed_at), - preload: :default_actor - ) - end + query = user_by_email_query(email, activated) case Repo.one(query) do - nil -> {:error, :user_not_found} - user -> {:ok, user} + nil -> + {:error, :user_not_found} + + user -> + {:ok, user} end end @doc """ - Get an user by it's activation token + Get an user by its activation token. """ - @spec get_user_by_activation_token(String.t()) :: Actor.t() + @spec get_user_by_activation_token(String.t()) :: Actor.t() | nil def get_user_by_activation_token(token) do - Repo.one( - from( - u in User, - where: u.confirmation_token == ^token, - preload: [:default_actor] - ) - ) + token + |> user_by_activation_token_query() + |> Repo.one() end @doc """ - Get an user by it's reset password token + Get an user by its reset password token. """ @spec get_user_by_reset_password_token(String.t()) :: Actor.t() def get_user_by_reset_password_token(token) do - Repo.one( - from( - u in User, - where: u.reset_password_token == ^token, - preload: [:default_actor] - ) - ) + token + |> user_by_reset_password_token_query() + |> Repo.one() end @doc """ - Updates a user. - - ## Examples - - iex> update_user(User{}, %{password: "coucou"}) - {:ok, %Mobilizon.Users.User{}} - - iex> update_user(User{}, %{password: nil}) - {:error, %Ecto.Changeset{}} - + Updates an user. """ + @spec update_user(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def update_user(%User{} = user, attrs) do with {:ok, %User{} = user} <- user @@ -123,65 +93,26 @@ defmodule Mobilizon.Users do end @doc """ - Deletes a User. - - ## Examples - - iex> delete_user(%User{email: "test@test.tld"}) - {:ok, %Mobilizon.Users.User{}} - - iex> delete_user(%User{}) - {:error, %Ecto.Changeset{}} - + Deletes an user. """ - def delete_user(%User{} = user) do - Repo.delete(user) - end - - # @doc """ - # Returns an `%Ecto.Changeset{}` for tracking user changes. - - # ## Examples - - # iex> change_user(%Mobilizon.Users.User{}) - # %Ecto.Changeset{data: %Mobilizon.Users.User{}} - - # """ - # def change_user(%User{} = user) do - # User.changeset(user, %{}) - # end + @spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def delete_user(%User{} = user), do: Repo.delete(user) @doc """ - Gets a single user. - - Raises `Ecto.NoResultsError` if the User does not exist. - - ## Examples - - iex> get_user!(123) - %Mobilizon.Users.User{} - - iex> get_user!(456) - ** (Ecto.NoResultsError) - + Get an user with its actors + Raises `Ecto.NoResultsError` if the user does not exist. """ - def get_user!(id), do: Repo.get!(User, id) - - @doc """ - Get an user with it's actors - - Raises `Ecto.NoResultsError` if the User does not exist. - """ - @spec get_user_with_actors!(integer()) :: User.t() + @spec get_user_with_actors!(integer | String.t()) :: User.t() def get_user_with_actors!(id) do - user = Repo.get!(User, id) - Repo.preload(user, [:actors, :default_actor]) + id + |> get_user!() + |> Repo.preload([:actors, :default_actor]) end @doc """ - Get user with it's actors by ID + Get user with its actors. """ - @spec get_user_with_actors(integer()) :: User.t() + @spec get_user_with_actors(integer()) :: {:ok, User.t()} | {:error, String.t()} def get_user_with_actors(id) do case Repo.get(User, id) do nil -> @@ -198,23 +129,24 @@ defmodule Mobilizon.Users do end @doc """ - Returns the associated actor for an user, either the default set one or the first found + Gets the associated actor for an user, either the default set one or the first + found. """ - @spec get_actor_for_user(Mobilizon.Users.User.t()) :: Mobilizon.Actors.Actor.t() - def get_actor_for_user(%Mobilizon.Users.User{} = user) do - case Repo.one( - from( - a in Actor, - join: u in User, - on: u.default_actor_id == a.id, - where: u.id == ^user.id - ) - ) do + @spec get_actor_for_user(User.t()) :: Actor.t() | nil + def get_actor_for_user(%User{} = user) do + actor = + user + |> actor_for_user_query() + |> Repo.one() + + case actor do nil -> - case user - |> get_actors_for_user() do - [] -> nil - actors -> hd(actors) + case get_actors_for_user(user) do + [] -> + nil + + actors -> + hd(actors) end actor -> @@ -222,94 +154,48 @@ defmodule Mobilizon.Users do end end - def get_actors_for_user(%User{id: user_id}) do - Repo.all(from(a in Actor, where: a.user_id == ^user_id)) + @doc """ + Gets actors for an user. + """ + @spec get_actors_for_user(User.t()) :: [Actor.t()] + def get_actors_for_user(%User{} = user) do + user + |> actors_for_user_query() + |> Repo.all() end @doc """ - Authenticate user + Updates user's default actor. + Raises `Ecto.NoResultsError` if the user does not exist. """ - def authenticate(%{user: user, password: password}) do - # Does password match the one stored in the database? - with true <- Argon2.verify_pass(password, user.password_hash), - # Yes, create and return the token - {:ok, tokens} <- generate_tokens(user) do - {:ok, tokens} - else - _ -> - # No, return an error - {:error, :unauthorized} - end - end - - @doc """ - Generate access token and refresh token - """ - def generate_tokens(user) do - with {:ok, access_token} <- generate_access_token(user), - {:ok, refresh_token} <- generate_refresh_token(user) do - {:ok, %{access_token: access_token, refresh_token: refresh_token}} - end - end - - defp generate_access_token(user) do - with {:ok, access_token, _claims} <- - MobilizonWeb.Guardian.encode_and_sign(user, %{}, token_type: "access") do - {:ok, access_token} - end - end - - def generate_refresh_token(user) do - with {:ok, refresh_token, _claims} <- - MobilizonWeb.Guardian.encode_and_sign(user, %{}, token_type: "refresh") do - {:ok, refresh_token} - end - end - + @spec update_user_default_actor(integer | String.t(), integer | String.t()) :: User.t() def update_user_default_actor(user_id, actor_id) do with _ <- - from( - u in User, - where: u.id == ^user_id, - update: [ - set: [ - default_actor_id: ^actor_id - ] - ] - ) + user_id + |> update_user_default_actor_query(actor_id) |> Repo.update_all([]) do - Repo.get!(User, user_id) + user_id + |> get_user!() |> Repo.preload([:default_actor]) end end @doc """ Returns the list of users. - - ## Examples - - iex> list_users() - [%Mobilizon.Users.User{}] - """ + @spec list_users(integer | nil, integer | nil, atom | nil, atom | nil) :: [User.t()] def list_users(page \\ nil, limit \\ nil, sort \\ nil, direction \\ nil) do - Repo.all( - User - |> paginate(page, limit) - |> sort(sort, direction) - ) + User + |> Page.paginate(page, limit) + |> sort(sort, direction) + |> Repo.all() end @doc """ Returns the list of administrators. - - ## Examples - - iex> list_admins() - [%Mobilizon.Users.User{role: :administrator}] - """ - def list_admins() do + @spec list_admins :: [User.t()] + def list_admins do User |> where([u], u.role == ^:administrator) |> Repo.all() @@ -317,25 +203,127 @@ defmodule Mobilizon.Users do @doc """ Returns the list of moderators. - - ## Examples - - iex> list_moderators() - [%Mobilizon.Users.User{role: :moderator}, %Mobilizon.Users.User{role: :administrator}] - """ - def list_moderators() do + @spec list_moderators :: [User.t()] + def list_moderators do User |> where([u], u.role in ^[:administrator, :moderator]) |> Repo.all() end - def count_users() do - Repo.one( - from( - u in User, - select: count(u.id) - ) + @doc """ + Counts users. + """ + @spec count_users :: integer + def count_users, do: Repo.one(from(u in User, select: count(u.id))) + + @doc """ + Authenticate an user. + """ + @spec authenticate(User.t()) :: {:ok, tokens} | {:error, :unauthorized} + def authenticate(%{user: %User{password_hash: password_hash} = user, password: password}) do + # Does password match the one stored in the database? + if Argon2.verify_pass(password, password_hash) do + {:ok, _tokens} = generate_tokens(user) + else + {:error, :unauthorized} + end + end + + @doc """ + Generates access token and refresh token for an user. + """ + @spec generate_tokens(User.t()) :: {:ok, tokens} + def generate_tokens(user) do + with {:ok, access_token} <- generate_access_token(user), + {:ok, refresh_token} <- generate_refresh_token(user) do + {:ok, %{access_token: access_token, refresh_token: refresh_token}} + end + end + + @doc """ + Generates access token for an user. + """ + @spec generate_access_token(User.t()) :: {:ok, String.t()} + def generate_access_token(user) do + with {:ok, access_token, _claims} <- + MobilizonWeb.Guardian.encode_and_sign(user, %{}, token_type: "access") do + {:ok, access_token} + end + end + + @doc """ + Generates refresh token for an user. + """ + @spec generate_refresh_token(User.t()) :: {:ok, String.t()} + def generate_refresh_token(user) do + with {:ok, refresh_token, _claims} <- + MobilizonWeb.Guardian.encode_and_sign(user, %{}, token_type: "refresh") do + {:ok, refresh_token} + end + end + + @spec user_by_email_query(String.t(), boolean | nil) :: Ecto.Query.t() + defp user_by_email_query(email, nil) do + from(u in User, where: u.email == ^email, preload: :default_actor) + end + + defp user_by_email_query(email, true) do + from( + u in User, + where: u.email == ^email and not is_nil(u.confirmed_at), + preload: :default_actor + ) + end + + defp user_by_email_query(email, false) do + from( + u in User, + where: u.email == ^email and is_nil(u.confirmed_at), + preload: :default_actor + ) + end + + @spec user_by_activation_token_query(String.t()) :: Ecto.Query.t() + defp user_by_activation_token_query(token) do + from( + u in User, + where: u.confirmation_token == ^token, + preload: [:default_actor] + ) + end + + @spec user_by_reset_password_token_query(String.t()) :: Ecto.Query.t() + defp user_by_reset_password_token_query(token) do + from( + u in User, + where: u.reset_password_token == ^token, + preload: [:default_actor] + ) + end + + @spec actor_for_user_query(User.t()) :: Ecto.Query.t() + defp actor_for_user_query(%User{id: user_id}) do + from( + a in Actor, + join: u in User, + on: u.default_actor_id == a.id, + where: u.id == ^user_id + ) + end + + @spec actors_for_user_query(User.t()) :: Ecto.Query.t() + defp actors_for_user_query(%User{id: user_id}) do + from(a in Actor, where: a.user_id == ^user_id) + end + + @spec update_user_default_actor_query(integer | String.t(), integer | String.t()) :: + Ecto.Query.t() + defp update_user_default_actor_query(user_id, actor_id) do + from( + u in User, + where: u.id == ^user_id, + update: [set: [default_actor_id: ^actor_id]] ) end end diff --git a/lib/mobilizon_web/api/events.ex b/lib/mobilizon_web/api/events.ex index 6a9493a25..55a05dbde 100644 --- a/lib/mobilizon_web/api/events.ex +++ b/lib/mobilizon_web/api/events.ex @@ -5,6 +5,7 @@ defmodule MobilizonWeb.API.Events do alias Mobilizon.Events.Event alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils + alias Mobilizon.Service.ActivityPub.Activity alias MobilizonWeb.API.Utils @doc """ diff --git a/lib/mobilizon_web/api/follows.ex b/lib/mobilizon_web/api/follows.ex index 626c710b9..4cba7a5ee 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.is_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/groups.ex b/lib/mobilizon_web/api/groups.ex index 7ff870670..aaac7a6b1 100644 --- a/lib/mobilizon_web/api/groups.ex +++ b/lib/mobilizon_web/api/groups.ex @@ -3,6 +3,7 @@ defmodule MobilizonWeb.API.Groups do API for Events """ alias Mobilizon.Actors + alias Mobilizon.Actors.Actor alias Mobilizon.Users.User alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils @@ -22,21 +23,13 @@ defmodule MobilizonWeb.API.Groups do banner: _banner } = args ) do - with {:is_owned, true, actor} <- User.owns_actor(user, creator_actor_id), + with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, creator_actor_id), title <- String.trim(title), {:existing_group, nil} <- {:existing_group, Actors.get_group_by_title(title)}, visibility <- Map.get(args, :visibility, :public), {content_html, tags, to, cc} <- Utils.prepare_content(actor, summary, visibility, [], nil), - group <- - ActivityPubUtils.make_group_data( - actor.url, - to, - title, - content_html, - tags, - cc - ) do + group <- ActivityPubUtils.make_group_data(actor.url, to, title, content_html, tags, cc) do ActivityPub.create(%{ to: ["https://www.w3.org/ns/activitystreams#Public"], actor: actor, @@ -47,7 +40,7 @@ defmodule MobilizonWeb.API.Groups do {:existing_group, _} -> {:error, "A group with this name already exists"} - {:is_owned, _} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} end end diff --git a/lib/mobilizon_web/api/reports.ex b/lib/mobilizon_web/api/reports.ex index 0560c80f4..ad5cfa0bf 100644 --- a/lib/mobilizon_web/api/reports.ex +++ b/lib/mobilizon_web/api/reports.ex @@ -3,17 +3,18 @@ defmodule MobilizonWeb.API.Reports do API for Reports """ + import MobilizonWeb.API.Utils + import Mobilizon.Service.Admin.ActionLogService + alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Events - alias Mobilizon.Activity + alias Mobilizon.Service.ActivityPub.Activity alias Mobilizon.Reports, as: ReportsAction alias Mobilizon.Reports.{Report, Note} alias Mobilizon.Service.ActivityPub alias Mobilizon.Users alias Mobilizon.Users.User - import MobilizonWeb.API.Utils - import Mobilizon.Service.Admin.ActionLogService @doc """ Create a report/flag on an actor, and optionally on an event or on comments. @@ -61,7 +62,7 @@ defmodule MobilizonWeb.API.Reports do """ def update_report_status(%Actor{} = actor, %Report{} = report, state) do with {:valid_state, true} <- - {:valid_state, Mobilizon.Reports.ReportStateEnum.valid_value?(state)}, + {:valid_state, Mobilizon.Reports.ReportStatus.valid_value?(state)}, {:ok, report} <- ReportsAction.update_report(report, %{"status" => state}), {:ok, _} <- log_action(actor, "update", report) do {:ok, report} @@ -72,7 +73,7 @@ defmodule MobilizonWeb.API.Reports do defp get_report_comments(%Actor{id: actor_id}, comment_ids) do {:get_report_comments, - Events.get_all_comments_by_actor_and_ids(actor_id, comment_ids) |> Enum.map(& &1.url)} + Events.list_comments_by_actor_and_ids(actor_id, comment_ids) |> Enum.map(& &1.url)} end defp get_report_comments(_, _), do: {:get_report_comments, nil} @@ -89,7 +90,7 @@ defmodule MobilizonWeb.API.Reports do with %User{role: role} <- Users.get_user!(user_id), {:role, true} <- {:role, role in [:administrator, :moderator]}, {:ok, %Note{} = note} <- - Mobilizon.Reports.create_report_note(%{ + Mobilizon.Reports.create_note(%{ "report_id" => report_id, "moderator_id" => moderator_id, "content" => content @@ -114,7 +115,7 @@ defmodule MobilizonWeb.API.Reports do %User{role: role} <- Users.get_user!(user_id), {:role, true} <- {:role, role in [:administrator, :moderator]}, {:ok, %Note{} = note} <- - Mobilizon.Reports.delete_report_note(note), + Mobilizon.Reports.delete_note(note), {:ok, _} <- log_action(moderator, "delete", note) do {:ok, note} else diff --git a/lib/mobilizon_web/api/search.ex b/lib/mobilizon_web/api/search.ex index 0e1bfabe5..835bbce5a 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,59 +57,52 @@ 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: []}} end true -> - {:ok, Events.find_and_count_events_by_name(search, page, limit)} + {:ok, Events.build_events_by_name(search, page, limit)} end 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/api/utils.ex b/lib/mobilizon_web/api/utils.ex index d54a1de22..b37d49610 100644 --- a/lib/mobilizon_web/api/utils.ex +++ b/lib/mobilizon_web/api/utils.ex @@ -2,7 +2,9 @@ defmodule MobilizonWeb.API.Utils do @moduledoc """ Utils for API """ + alias Mobilizon.Actors.Actor + alias Mobilizon.Config alias Mobilizon.Service.Formatter @doc """ @@ -125,7 +127,7 @@ defmodule MobilizonWeb.API.Utils do def make_report_content_text(nil), do: {:ok, nil} def make_report_content_text(comment) do - max_size = Mobilizon.CommonConfig.get([:instance, :max_report_comment_size], 1000) + max_size = Config.get([:instance, :max_report_comment_size], 1000) if String.length(comment) <= max_size do {:ok, Formatter.html_escape(comment, "text/plain")} diff --git a/lib/mobilizon_web/cache.ex b/lib/mobilizon_web/cache.ex new file mode 100644 index 000000000..70fba94f7 --- /dev/null +++ b/lib/mobilizon_web/cache.ex @@ -0,0 +1,24 @@ +defmodule MobilizonWeb.Cache do + @moduledoc """ + Facade module which provides access to all cached data. + """ + + alias Mobilizon.Actors.Actor + + alias MobilizonWeb.Cache.ActivityPub + + @caches [:activity_pub, :feed, :ics] + + @doc """ + Clears all caches for an actor. + """ + @spec clear_cache(Actor.t()) :: {:ok, true} + def clear_cache(%Actor{preferred_username: preferred_username, domain: nil}) do + Enum.each(@caches, &Cachex.del(&1, "actor_" <> preferred_username)) + end + + defdelegate get_local_actor_by_name(name), to: ActivityPub + defdelegate get_public_event_by_uuid_with_preload(uuid), to: ActivityPub + defdelegate get_comment_by_uuid_with_preload(uuid), to: ActivityPub + defdelegate get_relay, to: ActivityPub +end diff --git a/lib/mobilizon_web/cache/activity_pub.ex b/lib/mobilizon_web/cache/activity_pub.ex new file mode 100644 index 000000000..06e9e4311 --- /dev/null +++ b/lib/mobilizon_web/cache/activity_pub.ex @@ -0,0 +1,70 @@ +defmodule MobilizonWeb.Cache.ActivityPub do + @moduledoc """ + The ActivityPub related functions. + """ + + alias Mobilizon.{Actors, Events, Service} + alias Mobilizon.Actors.Actor + alias Mobilizon.Events.{Comment, Event} + + @cache :activity_pub + + @doc """ + Gets a local actor by username. + """ + @spec get_local_actor_by_name(String.t()) :: + {:commit, Actor.t()} | {:ignore, nil} + def get_local_actor_by_name(name) do + Cachex.fetch(@cache, "actor_" <> name, fn "actor_" <> name -> + case Actors.get_local_actor_by_name(name) do + %Actor{} = actor -> + {:commit, actor} + + nil -> + {:ignore, nil} + end + end) + end + + @doc """ + Gets a public event by its UUID, with all associations loaded. + """ + @spec get_public_event_by_uuid_with_preload(String.t()) :: + {:commit, Event.t()} | {:ignore, nil} + def get_public_event_by_uuid_with_preload(uuid) do + Cachex.fetch(@cache, "event_" <> uuid, fn "event_" <> uuid -> + case Events.get_public_event_by_uuid_with_preload(uuid) do + %Event{} = event -> + {:commit, event} + + nil -> + {:ignore, nil} + end + end) + end + + @doc """ + Gets a comment by its UUID, with all associations loaded. + """ + @spec get_comment_by_uuid_with_preload(String.t()) :: + {:commit, Comment.t()} | {:ignore, nil} + def get_comment_by_uuid_with_preload(uuid) do + Cachex.fetch(@cache, "comment_" <> uuid, fn "comment_" <> uuid -> + case Events.get_comment_from_uuid_with_preload(uuid) do + %Comment{} = comment -> + {:commit, comment} + + nil -> + {:ignore, nil} + end + end) + end + + @doc """ + Gets a relay. + """ + @spec get_relay :: {:commit, Actor.t()} | {:ignore, nil} + def get_relay do + Cachex.fetch(@cache, "relay_actor", &Service.ActivityPub.Relay.get_actor/0) + end +end diff --git a/lib/mobilizon_web/controllers/activity_pub_controller.ex b/lib/mobilizon_web/controllers/activity_pub_controller.ex index 63e7d3fb9..0c749c963 100644 --- a/lib/mobilizon_web/controllers/activity_pub_controller.ex +++ b/lib/mobilizon_web/controllers/activity_pub_controller.ex @@ -5,11 +5,14 @@ defmodule MobilizonWeb.ActivityPubController do use MobilizonWeb, :controller - alias Mobilizon.{Actors, Actors.Actor} - alias MobilizonWeb.ActivityPub.ActorView + + alias Mobilizon.{Actors, Actors.Actor, Config} alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.Federator + alias MobilizonWeb.ActivityPub.ActorView + alias MobilizonWeb.Cache + require Logger action_fallback(:errors) @@ -17,7 +20,7 @@ defmodule MobilizonWeb.ActivityPubController do plug(:relay_active? when action in [:relay]) def relay_active?(conn, _) do - if Mobilizon.CommonConfig.get([:instance, :allow_relay]) do + if Config.get([:instance, :allow_relay]) do conn else conn @@ -29,7 +32,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})) @@ -37,7 +40,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})) @@ -46,7 +49,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})) @@ -54,7 +57,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})) @@ -111,13 +114,7 @@ defmodule MobilizonWeb.ActivityPubController do end def relay(conn, _params) do - with {status, actor} <- - Cachex.fetch( - :activity_pub, - "relay_actor", - &Mobilizon.Service.ActivityPub.Relay.get_actor/0 - ), - true <- status in [:ok, :commit] do + with {:commit, %Actor{} = actor} <- Cache.get_relay() do conn |> put_resp_header("content-type", "application/activity+json") |> json(ActorView.render("actor.json", %{actor: actor})) diff --git a/lib/mobilizon_web/controllers/feed_controller.ex b/lib/mobilizon_web/controllers/feed_controller.ex index 3b7a274be..4e4669b7d 100644 --- a/lib/mobilizon_web/controllers/feed_controller.ex +++ b/lib/mobilizon_web/controllers/feed_controller.ex @@ -8,60 +8,60 @@ defmodule MobilizonWeb.FeedController do def actor(conn, %{"name" => name, "format" => "atom"}) do case Cachex.fetch(:feed, "actor_" <> name) do - {status, data} when status in [:ok, :commit] -> + {:commit, data} -> conn |> put_resp_content_type("application/atom+xml") |> send_resp(200, data) - _err -> + _ -> {:error, :not_found} end end def actor(conn, %{"name" => name, "format" => "ics"}) do case Cachex.fetch(:ics, "actor_" <> name) do - {status, data} when status in [:ok, :commit] -> + {:commit, data} -> conn |> put_resp_content_type("text/calendar") |> send_resp(200, data) - _err -> + _ -> {:error, :not_found} end end def event(conn, %{"uuid" => uuid, "format" => "ics"}) do case Cachex.fetch(:ics, "event_" <> uuid) do - {status, data} when status in [:ok, :commit] -> + {:commit, data} -> conn |> put_resp_content_type("text/calendar") |> send_resp(200, data) - _err -> + _ -> {:error, :not_found} end end def going(conn, %{"token" => token, "format" => "ics"}) do case Cachex.fetch(:ics, "token_" <> token) do - {status, data} when status in [:ok, :commit] -> + {:commit, data} -> conn |> put_resp_content_type("text/calendar") |> send_resp(200, data) - _err -> + _ -> {:error, :not_found} end end def going(conn, %{"token" => token, "format" => "atom"}) do case Cachex.fetch(:feed, "token_" <> token) do - {status, data} when status in [:ok, :commit] -> + {:commit, data} -> conn |> put_resp_content_type("application/atom+xml") |> send_resp(200, data) - _err -> + {:ignore, _} -> {:error, :not_found} end end diff --git a/lib/mobilizon_web/controllers/media_proxy_controller.ex b/lib/mobilizon_web/controllers/media_proxy_controller.ex index 55f3fc20a..630d8b25f 100644 --- a/lib/mobilizon_web/controllers/media_proxy_controller.ex +++ b/lib/mobilizon_web/controllers/media_proxy_controller.ex @@ -5,13 +5,16 @@ defmodule MobilizonWeb.MediaProxyController do use MobilizonWeb, :controller + + alias Mobilizon.Config + alias MobilizonWeb.ReverseProxy alias MobilizonWeb.MediaProxy @default_proxy_opts [max_body_length: 25 * 1_048_576, http: [follow_redirect: true]] def remote(conn, %{"sig" => sig64, "url" => url64} = params) do - with config <- Mobilizon.CommonConfig.get([:media_proxy], []), + with config <- Config.get([:media_proxy], []), true <- Keyword.get(config, :enabled, false), {:ok, url} <- MediaProxy.decode_url(sig64, url64), :ok <- filename_matches(Map.has_key?(params, "filename"), conn.request_path, url) do diff --git a/lib/mobilizon_web/controllers/node_info_controller.ex b/lib/mobilizon_web/controllers/node_info_controller.ex index aad33df9f..f5df307a8 100644 --- a/lib/mobilizon_web/controllers/node_info_controller.ex +++ b/lib/mobilizon_web/controllers/node_info_controller.ex @@ -6,10 +6,9 @@ defmodule MobilizonWeb.NodeInfoController do use MobilizonWeb, :controller - alias Mobilizon.CommonConfig + alias Mobilizon.Config alias Mobilizon.Service.Statistics - @instance Application.get_env(:mobilizon, :instance) @node_info_supported_versions ["2.0", "2.1"] @node_info_schema_uri "http://nodeinfo.diaspora.software/ns/schema/" @@ -35,14 +34,14 @@ defmodule MobilizonWeb.NodeInfoController do version: version, software: %{ name: "Mobilizon", - version: Keyword.get(@instance, :version) + version: Config.instance_version() }, protocols: ["activitypub"], services: %{ inbound: [], outbound: ["atom1.0"] }, - openRegistrations: CommonConfig.registrations_open?(), + openRegistrations: Config.instance_registrations_open?(), usage: %{ users: %{ total: Statistics.get_cached_value(:local_users) @@ -51,14 +50,14 @@ defmodule MobilizonWeb.NodeInfoController do localComments: Statistics.get_cached_value(:local_comments) }, metadata: %{ - nodeName: CommonConfig.instance_name(), - nodeDescription: CommonConfig.instance_description() + nodeName: Config.instance_name(), + nodeDescription: Config.instance_description() } } response = if version == "2.1" do - put_in(response, [:software, :repository], Keyword.get(@instance, :repository)) + put_in(response, [:software, :repository], Config.instance_repository()) else response end diff --git a/lib/mobilizon_web/controllers/page_controller.ex b/lib/mobilizon_web/controllers/page_controller.ex index 0c608a6df..6e7f5fe89 100644 --- a/lib/mobilizon_web/controllers/page_controller.ex +++ b/lib/mobilizon_web/controllers/page_controller.ex @@ -3,8 +3,8 @@ defmodule MobilizonWeb.PageController do Controller to load our webapp """ use MobilizonWeb, :controller - alias Mobilizon.Actors - alias Mobilizon.Events + + alias MobilizonWeb.Cache plug(:put_layout, false) action_fallback(MobilizonWeb.FallbackController) @@ -12,17 +12,17 @@ defmodule MobilizonWeb.PageController do def index(conn, _params), do: render(conn, :index) def actor(conn, %{"name" => name}) do - {status, actor} = Actors.get_cached_local_actor_by_name(name) + {status, actor} = Cache.get_local_actor_by_name(name) render_or_error(conn, &ok_status?/2, status, :actor, actor) end def event(conn, %{"uuid" => uuid}) do - {status, event} = Events.get_cached_event_full_by_uuid(uuid) + {status, event} = Cache.get_public_event_by_uuid_with_preload(uuid) render_or_error(conn, &ok_status_and_is_visible?/2, status, :event, event) end def comment(conn, %{"uuid" => uuid}) do - {status, comment} = Events.get_cached_comment_full_by_uuid(uuid) + {status, comment} = Cache.get_comment_by_uuid_with_preload(uuid) render_or_error(conn, &ok_status_and_is_visible?/2, status, :comment, comment) end diff --git a/lib/mobilizon_web/email/admin.ex b/lib/mobilizon_web/email/admin.ex new file mode 100644 index 000000000..b50059c9d --- /dev/null +++ b/lib/mobilizon_web/email/admin.ex @@ -0,0 +1,38 @@ +defmodule MobilizonWeb.Email.Admin do + @moduledoc """ + Handles emails sent to admins. + """ + + use Bamboo.Phoenix, view: MobilizonWeb.EmailView + + import Bamboo.{Email, Phoenix} + + import MobilizonWeb.Gettext + + alias Mobilizon.Config + alias Mobilizon.Reports.Report + alias Mobilizon.Users.User + + alias MobilizonWeb.Email + + @spec report(User.t(), Report.t(), String.t()) :: Bamboo.Email.t() + def report(%User{email: email}, %Report{} = report, locale \\ "en") do + Gettext.put_locale(locale) + + instance_url = Config.instance_url() + + subject = + gettext( + "Mobilizon: New report on instance %{instance}", + instance: instance_url + ) + + Email.base_email() + |> to(email) + |> subject(subject) + |> put_header("Reply-To", Config.instance_email_reply_to()) + |> assign(:report, report) + |> assign(:instance, instance_url) + |> render(:report) + end +end diff --git a/lib/mobilizon_web/email/email.ex b/lib/mobilizon_web/email/email.ex new file mode 100644 index 000000000..7277f6f56 --- /dev/null +++ b/lib/mobilizon_web/email/email.ex @@ -0,0 +1,17 @@ +defmodule MobilizonWeb.Email do + @moduledoc """ + The Email context. + """ + + use Bamboo.Phoenix, view: MobilizonWeb.EmailView + + alias Mobilizon.Config + + @spec base_email :: Bamboo.Email.t() + def base_email do + new_email() + |> from(Config.instance_email_from()) + |> put_html_layout({MobilizonWeb.EmailView, "email.html"}) + |> put_text_layout({MobilizonWeb.EmailView, "email.text"}) + end +end diff --git a/lib/mobilizon/mailer.ex b/lib/mobilizon_web/email/mailer.ex similarity index 53% rename from lib/mobilizon/mailer.ex rename to lib/mobilizon_web/email/mailer.ex index 0884ceb77..246d20d05 100644 --- a/lib/mobilizon/mailer.ex +++ b/lib/mobilizon_web/email/mailer.ex @@ -1,6 +1,6 @@ -defmodule Mobilizon.Mailer do +defmodule MobilizonWeb.Email.Mailer do @moduledoc """ - Mailer + Mobilizon Mailer. """ use Bamboo.Mailer, otp_app: :mobilizon end diff --git a/lib/mobilizon_web/email/user.ex b/lib/mobilizon_web/email/user.ex new file mode 100644 index 000000000..c4895be2a --- /dev/null +++ b/lib/mobilizon_web/email/user.ex @@ -0,0 +1,64 @@ +defmodule MobilizonWeb.Email.User do + @moduledoc """ + Handles emails sent to users. + """ + + use Bamboo.Phoenix, view: MobilizonWeb.EmailView + + import Bamboo.{Email, Phoenix} + + import MobilizonWeb.Gettext + + alias Mobilizon.Config + alias Mobilizon.Users.User + + alias MobilizonWeb.Email + + @spec confirmation_email(User.t(), String.t()) :: Bamboo.Email.t() + def confirmation_email( + %User{email: email, confirmation_token: confirmation_token}, + locale \\ "en" + ) do + Gettext.put_locale(locale) + + instance_url = Config.instance_url() + + subject = + gettext( + "Mobilizon: Confirmation instructions for %{instance}", + instance: instance_url + ) + + Email.base_email() + |> to(email) + |> subject(subject) + |> put_header("Reply-To", Config.instance_email_reply_to()) + |> assign(:token, confirmation_token) + |> assign(:instance, instance_url) + |> render(:registration_confirmation) + end + + @spec reset_password_email(User.t(), String.t()) :: Bamboo.Email.t() + def reset_password_email( + %User{email: email, reset_password_token: reset_password_token}, + locale \\ "en" + ) do + Gettext.put_locale(locale) + + instance_url = Config.instance_url() + + subject = + gettext( + "Mobilizon: Reset your password on %{instance} instructions", + instance: instance_url + ) + + Email.base_email() + |> to(email) + |> subject(subject) + |> put_header("Reply-To", Config.instance_email_reply_to()) + |> assign(:token, reset_password_token) + |> assign(:instance, instance_url) + |> render(:password_reset) + end +end diff --git a/lib/mobilizon_web/media_proxy.ex b/lib/mobilizon_web/media_proxy.ex index 229e50e24..0f03544e2 100644 --- a/lib/mobilizon_web/media_proxy.ex +++ b/lib/mobilizon_web/media_proxy.ex @@ -7,6 +7,9 @@ defmodule MobilizonWeb.MediaProxy do @moduledoc """ Handles proxifying media files """ + + alias Mobilizon.Config + @base64_opts [padding: false] def url(nil), do: nil @@ -66,7 +69,7 @@ defmodule MobilizonWeb.MediaProxy do def build_url(sig_base64, url_base64, filename \\ nil) do [ - Mobilizon.CommonConfig.get([:media_proxy, :base_url], MobilizonWeb.Endpoint.url()), + Config.get([:media_proxy, :base_url], MobilizonWeb.Endpoint.url()), "proxy", sig_base64, url_base64, diff --git a/lib/mobilizon/mime.ex b/lib/mobilizon_web/mime.ex similarity index 99% rename from lib/mobilizon/mime.ex rename to lib/mobilizon_web/mime.ex index 12ea8f46f..49739915a 100644 --- a/lib/mobilizon/mime.ex +++ b/lib/mobilizon_web/mime.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/mime.ex -defmodule Mobilizon.MIME do +defmodule MobilizonWeb.MIME do @moduledoc """ Returns the mime-type of a binary and optionally a normalized file-name. """ diff --git a/lib/mobilizon_web.ex b/lib/mobilizon_web/mobilizon_web.ex similarity index 100% rename from lib/mobilizon_web.ex rename to lib/mobilizon_web/mobilizon_web.ex diff --git a/lib/mobilizon_web/plugs/uploaded_media.ex b/lib/mobilizon_web/plugs/uploaded_media.ex index f755e0e87..13164a8d5 100644 --- a/lib/mobilizon_web/plugs/uploaded_media.ex +++ b/lib/mobilizon_web/plugs/uploaded_media.ex @@ -8,10 +8,14 @@ defmodule MobilizonWeb.Plugs.UploadedMedia do Serves uploaded media files """ + @behaviour Plug + import Plug.Conn + + alias Mobilizon.Config + require Logger - @behaviour Plug # no slashes @path "media" @@ -38,7 +42,7 @@ defmodule MobilizonWeb.Plugs.UploadedMedia do conn end - config = Mobilizon.CommonConfig.get([MobilizonWeb.Upload]) + config = Config.get([MobilizonWeb.Upload]) with uploader <- Keyword.fetch!(config, :uploader), proxy_remote = Keyword.get(config, :proxy_remote, false), @@ -75,7 +79,7 @@ defmodule MobilizonWeb.Plugs.UploadedMedia do conn |> MobilizonWeb.ReverseProxy.call( url, - Mobilizon.CommonConfig.get([Mobilizon.Upload, :proxy_opts], []) + Config.get([Mobilizon.Upload, :proxy_opts], []) ) end diff --git a/lib/mobilizon_web/resolvers/comment.ex b/lib/mobilizon_web/resolvers/comment.ex index c167b898f..d00b610ce 100644 --- a/lib/mobilizon_web/resolvers/comment.ex +++ b/lib/mobilizon_web/resolvers/comment.ex @@ -2,12 +2,14 @@ defmodule MobilizonWeb.Resolvers.Comment do @moduledoc """ Handles the comment-related GraphQL calls """ - require Logger + alias Mobilizon.Events.Comment - alias Mobilizon.Activity alias Mobilizon.Users.User + alias Mobilizon.Service.ActivityPub.Activity alias MobilizonWeb.API.Comments + require Logger + def create_comment(_parent, %{text: comment, actor_username: username}, %{ context: %{current_user: %User{} = _user} }) do diff --git a/lib/mobilizon_web/resolvers/config.ex b/lib/mobilizon_web/resolvers/config.ex index 3cb632da9..dc762c1e2 100644 --- a/lib/mobilizon_web/resolvers/config.ex +++ b/lib/mobilizon_web/resolvers/config.ex @@ -1,19 +1,19 @@ defmodule MobilizonWeb.Resolvers.Config do @moduledoc """ - Handles the config-related GraphQL calls + Handles the config-related GraphQL calls. """ - import Mobilizon.CommonConfig + alias Mobilizon.Config @doc """ - Get config + Gets config. """ def get_config(_parent, _params, _context) do {:ok, %{ - name: instance_name(), - registrations_open: registrations_open?(), - description: instance_description() + name: Config.instance_name(), + registrations_open: Config.instance_registrations_open?(), + description: Config.instance_description() }} end end diff --git a/lib/mobilizon_web/resolvers/event.ex b/lib/mobilizon_web/resolvers/event.ex index ed6a9aa45..d3aff4f94 100644 --- a/lib/mobilizon_web/resolvers/event.ex +++ b/lib/mobilizon_web/resolvers/event.ex @@ -2,16 +2,17 @@ defmodule MobilizonWeb.Resolvers.Event do @moduledoc """ Handles the event-related GraphQL calls """ - alias Mobilizon.Activity + alias Mobilizon.Actors.Actor alias Mobilizon.Addresses alias Mobilizon.Addresses.Address alias Mobilizon.Events - alias Mobilizon.Events.{Event, Participant, EventOptions} + alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Media.Picture alias Mobilizon.Users.User alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias MobilizonWeb.Resolvers.Person + alias Mobilizon.Service.ActivityPub.Activity import Mobilizon.Service.Admin.ActionLogService # We limit the max number of events that can be retrieved @@ -28,7 +29,7 @@ defmodule MobilizonWeb.Resolvers.Event do end def find_event(_parent, %{uuid: uuid}, _resolution) do - case Mobilizon.Events.get_event_full_by_uuid(uuid) do + case Mobilizon.Events.get_public_event_by_uuid_with_preload(uuid) do nil -> {:error, "Event with UUID #{uuid} not found"} @@ -69,17 +70,14 @@ defmodule MobilizonWeb.Resolvers.Event do ) do # We get the organizer's next public event events = - [Events.get_actor_upcoming_public_event(organizer_actor, uuid)] + [Events.get_upcoming_public_event_for_actor(organizer_actor, uuid)] |> Enum.filter(&is_map/1) # We find similar events with the same tags # uniq_by : It's possible event_from_same_actor is inside events_from_tags events = - (events ++ - Events.find_similar_events_by_common_tags( - tags, - @number_of_related_events - )) + events + |> Enum.concat(Events.list_events_by_tags(tags, @number_of_related_events)) |> uniq_events() # TODO: We should use tag_relations to find more appropriate events @@ -87,8 +85,10 @@ defmodule MobilizonWeb.Resolvers.Event do # We've considered all recommended events, so we fetch the latest events events = if @number_of_related_events - length(events) > 0 do - (events ++ - Events.list_events(1, @number_of_related_events, :begins_on, :asc, true, true)) + events + |> Enum.concat( + Events.list_events(1, @number_of_related_events, :begins_on, :asc, true, true) + ) |> uniq_events() else events @@ -112,26 +112,23 @@ defmodule MobilizonWeb.Resolvers.Event do def actor_join_event( _parent, %{actor_id: actor_id, event_id: event_id}, - %{ - context: %{ - current_user: user - } - } + %{context: %{current_user: user}} ) do - with {:is_owned, true, actor} <- User.owns_actor(user, actor_id), + with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id), {:has_event, {:ok, %Event{} = event}} <- - {:has_event, Mobilizon.Events.get_event_full(event_id)}, + {:has_event, Mobilizon.Events.get_event_with_preload(event_id)}, {:error, :participant_not_found} <- Mobilizon.Events.get_participant(event_id, actor_id), {:ok, _activity, participant} <- MobilizonWeb.API.Participations.join(event, actor), participant <- - Map.put(participant, :event, event) + participant + |> Map.put(:event, event) |> Map.put(:actor, Person.proxify_pictures(actor)) do {:ok, participant} else {:has_event, _} -> {:error, "Event with this ID #{inspect(event_id)} doesn't exist"} - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} {:error, :event_not_found} -> @@ -152,32 +149,18 @@ defmodule MobilizonWeb.Resolvers.Event do def actor_leave_event( _parent, %{actor_id: actor_id, event_id: event_id}, - %{ - context: %{ - current_user: user - } - } + %{context: %{current_user: user}} ) do - with {:is_owned, true, actor} <- User.owns_actor(user, actor_id), + with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id), {:has_event, {:ok, %Event{} = event}} <- - {:has_event, Mobilizon.Events.get_event_full(event_id)}, + {:has_event, Mobilizon.Events.get_event_with_preload(event_id)}, {:ok, _activity, _participant} <- MobilizonWeb.API.Participations.leave(event, actor) do - { - :ok, - %{ - event: %{ - id: event_id - }, - actor: %{ - id: actor_id - } - } - } + {:ok, %{event: %{id: event_id}, actor: %{id: actor_id}}} else {:has_event, _} -> {:error, "Event with this ID #{inspect(event_id)} doesn't exist"} - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} {:only_organizer, true} -> @@ -198,31 +181,19 @@ defmodule MobilizonWeb.Resolvers.Event do def create_event( _parent, %{organizer_actor_id: organizer_actor_id} = args, - %{ - context: %{ - current_user: user - } - } = _resolution + %{context: %{current_user: user}} = _resolution ) do # See https://github.com/absinthe-graphql/absinthe/issues/490 with args <- Map.put(args, :options, args[:options] || %{}), - {:is_owned, true, organizer_actor} <- User.owns_actor(user, organizer_actor_id), + {:is_owned, %Actor{} = organizer_actor} <- User.owns_actor(user, organizer_actor_id), args_with_organizer <- Map.put(args, :organizer_actor, organizer_actor), {:ok, args_with_organizer} <- save_attached_picture(args_with_organizer), {:ok, args_with_organizer} <- save_physical_address(args_with_organizer), - { - :ok, - %Activity{ - data: %{ - "object" => %{"type" => "Event"} = _object - } - }, - %Event{} = event - } <- + {:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <- MobilizonWeb.API.Events.create_event(args_with_organizer) do {:ok, event} else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Organizer actor id is not owned by the user"} end end @@ -237,35 +208,24 @@ defmodule MobilizonWeb.Resolvers.Event do def update_event( _parent, %{event_id: event_id} = args, - %{ - context: %{ - current_user: user - } - } = _resolution + %{context: %{current_user: user}} = _resolution ) do # See https://github.com/absinthe-graphql/absinthe/issues/490 with args <- Map.put(args, :options, args[:options] || %{}), - {:ok, %Event{} = event} <- Mobilizon.Events.get_event_full(event_id), - {:is_owned, true, organizer_actor} <- User.owns_actor(user, event.organizer_actor_id), + {:ok, %Event{} = event} <- Events.get_event_with_preload(event_id), + {:is_owned, %Actor{} = organizer_actor} <- + User.owns_actor(user, event.organizer_actor_id), args <- Map.put(args, :organizer_actor, organizer_actor), {:ok, args} <- save_attached_picture(args), {:ok, args} <- save_physical_address(args), - { - :ok, - %Activity{ - data: %{ - "object" => %{"type" => "Event"} = _object - } - }, - %Event{} = event - } <- + {:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <- MobilizonWeb.API.Events.update_event(args, event) do {:ok, event} else {:error, :event_not_found} -> {:error, "Event not found"} - {:is_owned, _} -> + {:is_owned, nil} -> {:error, "User doesn't own actor"} end end @@ -279,24 +239,14 @@ defmodule MobilizonWeb.Resolvers.Event do # However, we need to pass it's actor ID @spec save_attached_picture(map()) :: {:ok, map()} defp save_attached_picture( - %{ - picture: %{ - picture: %{file: %Plug.Upload{} = _picture} = all_pic - } - } = args + %{picture: %{picture: %{file: %Plug.Upload{} = _picture} = all_pic}} = args ) do {:ok, Map.put(args, :picture, Map.put(all_pic, :actor_id, args.organizer_actor.id))} end # Otherwise if we use a previously uploaded picture we need to fetch it from database @spec save_attached_picture(map()) :: {:ok, map()} - defp save_attached_picture( - %{ - picture: %{ - picture_id: picture_id - } - } = args - ) do + defp save_attached_picture(%{picture: %{picture_id: picture_id}} = args) do with %Picture{} = picture <- Mobilizon.Media.get_picture(picture_id) do {:ok, Map.put(args, :picture, picture)} end @@ -306,13 +256,7 @@ defmodule MobilizonWeb.Resolvers.Event do defp save_attached_picture(args), do: {:ok, args} @spec save_physical_address(map()) :: {:ok, map()} - defp save_physical_address( - %{ - physical_address: %{ - url: physical_address_url - } - } = args - ) + defp save_physical_address(%{physical_address: %{url: physical_address_url}} = args) when not is_nil(physical_address_url) do with %Address{} = address <- Addresses.get_address_by_url(physical_address_url), args <- Map.put(args, :physical_address, address.url) do @@ -337,23 +281,20 @@ defmodule MobilizonWeb.Resolvers.Event do def delete_event( _parent, %{event_id: event_id, actor_id: actor_id}, - %{ - context: %{ - current_user: %User{role: role} = user - } - } + %{context: %{current_user: %User{role: role} = user}} ) do - with {:ok, %Event{local: is_local} = event} <- Mobilizon.Events.get_event_full(event_id), + with {:ok, %Event{local: is_local} = event} <- Events.get_event_with_preload(event_id), {actor_id, ""} <- Integer.parse(actor_id), - {:is_owned, true, _} <- User.owns_actor(user, actor_id) do + {:is_owned, %Actor{}} <- User.owns_actor(user, actor_id) do cond do - Event.can_event_be_managed_by(event, actor_id) == {:event_can_be_managed, true} -> + {:event_can_be_managed, true} == Event.can_be_managed_by(event, actor_id) -> do_delete_event(event) role in [:moderator, :administrator] -> with {:ok, res} <- do_delete_event(event, !is_local), %Actor{} = actor <- Actors.get_actor(actor_id) do log_action(actor, "delete", event) + {:ok, res} end @@ -364,7 +305,7 @@ defmodule MobilizonWeb.Resolvers.Event do {:error, :event_not_found} -> {:error, "Event not found"} - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} end end diff --git a/lib/mobilizon_web/resolvers/feed_token.ex b/lib/mobilizon_web/resolvers/feed_token.ex index 02ad34bc7..3d8242a84 100644 --- a/lib/mobilizon_web/resolvers/feed_token.ex +++ b/lib/mobilizon_web/resolvers/feed_token.ex @@ -2,10 +2,11 @@ defmodule MobilizonWeb.Resolvers.FeedToken do @moduledoc """ Handles the feed tokens-related GraphQL calls """ - require Logger + alias Mobilizon.Actors.Actor alias Mobilizon.Users.User alias Mobilizon.Events alias Mobilizon.Events.FeedToken + require Logger @doc """ Create an feed token for an user and a defined actor @@ -14,11 +15,11 @@ defmodule MobilizonWeb.Resolvers.FeedToken do def create_feed_token(_parent, %{actor_id: actor_id}, %{ context: %{current_user: %User{id: id} = user} }) do - with {:is_owned, true, _actor} <- User.owns_actor(user, actor_id), + with {:is_owned, %Actor{}} <- User.owns_actor(user, actor_id), {:ok, feed_token} <- Events.create_feed_token(%{"user_id" => id, "actor_id" => actor_id}) do {:ok, feed_token} else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} end end diff --git a/lib/mobilizon_web/resolvers/group.ex b/lib/mobilizon_web/resolvers/group.ex index 2fcb0d44c..a7330970f 100644 --- a/lib/mobilizon_web/resolvers/group.ex +++ b/lib/mobilizon_web/resolvers/group.ex @@ -4,10 +4,12 @@ defmodule MobilizonWeb.Resolvers.Group do """ alias Mobilizon.Actors alias Mobilizon.Actors.{Actor, Member} + alias Mobilizon.Service.ActivityPub.Activity alias Mobilizon.Users.User alias Mobilizon.Service.ActivityPub - alias Mobilizon.Activity + alias MobilizonWeb.Resolvers.Person + require Logger @doc """ @@ -40,19 +42,11 @@ defmodule MobilizonWeb.Resolvers.Group do def create_group( _parent, args, - %{ - context: %{ - current_user: user - } - } + %{context: %{current_user: user}} ) do with { :ok, - %Activity{ - data: %{ - "object" => %{"type" => "Group"} = _object - } - }, + %Activity{data: %{"object" => %{"type" => "Group"} = _object}}, %Actor{} = group } <- MobilizonWeb.API.Groups.create_group( @@ -66,10 +60,7 @@ defmodule MobilizonWeb.Resolvers.Group do banner: Map.get(args, "banner") } ) do - { - :ok, - group - } + {:ok, group} end end @@ -83,17 +74,13 @@ defmodule MobilizonWeb.Resolvers.Group do def delete_group( _parent, %{group_id: group_id, actor_id: actor_id}, - %{ - context: %{ - current_user: user - } - } + %{context: %{current_user: user}} ) do with {actor_id, ""} <- Integer.parse(actor_id), {group_id, ""} <- Integer.parse(group_id), {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), - {:is_owned, true, _} <- User.owns_actor(user, actor_id), - {:ok, %Member{} = member} <- Member.get_member(actor_id, group.id), + {:is_owned, %Actor{}} <- User.owns_actor(user, actor_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}} @@ -101,7 +88,7 @@ defmodule MobilizonWeb.Resolvers.Group do {:error, :group_not_found} -> {:error, "Group not found"} - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} {:error, :member_not_found} -> @@ -122,39 +109,26 @@ defmodule MobilizonWeb.Resolvers.Group do def join_group( _parent, %{group_id: group_id, actor_id: actor_id}, - %{ - context: %{ - current_user: user - } - } + %{context: %{current_user: user}} ) do with {actor_id, ""} <- Integer.parse(actor_id), {group_id, ""} <- Integer.parse(group_id), - {:is_owned, true, actor} <- User.owns_actor(user, actor_id), + {: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), - {:ok, _} <- - Actors.create_member(%{ - parent_id: group.id, - actor_id: actor.id, - role: role - }) do + role <- Member.get_default_member_role(group), + {:ok, _} <- Actors.create_member(%{parent_id: group.id, actor_id: actor.id, role: role}) do { :ok, %{ - parent: - group - |> Person.proxify_pictures(), - actor: - actor - |> Person.proxify_pictures(), + parent: Person.proxify_pictures(group), + actor: Person.proxify_pictures(actor), role: role } } else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} {:error, :group_not_found} -> @@ -178,33 +152,19 @@ defmodule MobilizonWeb.Resolvers.Group do def leave_group( _parent, %{group_id: group_id, actor_id: actor_id}, - %{ - context: %{ - current_user: user - } - } + %{context: %{current_user: user}} ) do with {actor_id, ""} <- Integer.parse(actor_id), {group_id, ""} <- Integer.parse(group_id), - {:is_owned, true, actor} <- User.owns_actor(user, actor_id), - {:ok, %Member{} = member} <- Member.get_member(actor.id, group_id), + {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_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, _} <- Mobilizon.Actors.delete_member(member) do - { - :ok, - %{ - parent: %{ - id: group_id - }, - actor: %{ - id: actor_id - } - } - } + {:ok, %{parent: %{id: group_id}, actor: %{id: actor_id}}} else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} {:error, :member_not_found} -> @@ -224,14 +184,8 @@ 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 - [ - %Member{ - actor: %Actor{ - id: member_actor_id - } - } - ] -> + 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/member.ex b/lib/mobilizon_web/resolvers/member.ex index b12430644..1cfd320a2 100644 --- a/lib/mobilizon_web/resolvers/member.ex +++ b/lib/mobilizon_web/resolvers/member.ex @@ -9,7 +9,7 @@ defmodule MobilizonWeb.Resolvers.Member do Find members for group """ def find_members_for_group(%Actor{} = actor, _args, _resolution) do - members = Actors.memberships_for_group(actor) + members = Actors.list_members_for_group(actor) {:ok, members} end end diff --git a/lib/mobilizon_web/resolvers/person.ex b/lib/mobilizon_web/resolvers/person.ex index 88092c65c..7c39d1250 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 @@ -50,9 +51,7 @@ defmodule MobilizonWeb.Resolvers.Person do def create_person( _parent, %{preferred_username: _preferred_username} = args, - %{ - context: %{current_user: user} - } = _resolution + %{context: %{current_user: user}} = _resolution ) do args = Map.put(args, :user_id, user.id) @@ -75,17 +74,13 @@ defmodule MobilizonWeb.Resolvers.Person do def update_person( _parent, %{preferred_username: preferred_username} = args, - %{ - context: %{ - current_user: user - } - } = _resolution + %{context: %{current_user: user}} = _resolution ) do args = Map.put(args, :user_id, user.id) with {:find_actor, %Actor{} = actor} <- {:find_actor, Actors.get_actor_by_name(preferred_username)}, - {:is_owned, true, _} <- User.owns_actor(user, actor.id), + {:is_owned, %Actor{}} <- User.owns_actor(user, actor.id), args <- save_attached_pictures(args), {:ok, actor} <- Actors.update_actor(actor, args) do {:ok, actor} @@ -93,7 +88,7 @@ defmodule MobilizonWeb.Resolvers.Person do {:find_actor, nil} -> {:error, "Actor not found"} - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor is not owned by authenticated user"} end end @@ -108,15 +103,11 @@ defmodule MobilizonWeb.Resolvers.Person do def delete_person( _parent, %{preferred_username: preferred_username} = _args, - %{ - context: %{ - current_user: user - } - } = _resolution + %{context: %{current_user: user}} = _resolution ) do with {:find_actor, %Actor{} = actor} <- {:find_actor, Actors.get_actor_by_name(preferred_username)}, - {:is_owned, true, _} <- User.owns_actor(user, actor.id), + {:is_owned, %Actor{}} <- User.owns_actor(user, actor.id), {:last_identity, false} <- {:last_identity, last_identity?(user)}, {:last_admin, false} <- {:last_admin, last_admin_of_a_group?(actor.id)}, {:ok, actor} <- Actors.delete_actor(actor) do @@ -131,7 +122,7 @@ defmodule MobilizonWeb.Resolvers.Person do {:last_admin, true} -> {:error, "Cannot remove the last administrator of a group"} - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor is not owned by authenticated user"} end end @@ -184,14 +175,12 @@ defmodule MobilizonWeb.Resolvers.Person do @doc """ Returns the list of events this person is going to """ - def person_going_to_events(%Actor{id: actor_id}, _args, %{ - context: %{current_user: user} - }) do - with {:is_owned, true, actor} <- User.owns_actor(user, actor_id), + def person_going_to_events(%Actor{id: actor_id}, _args, %{context: %{current_user: user}}) do + with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id), events <- Events.list_event_participations_for_actor(actor) do {:ok, events} else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} end end @@ -199,9 +188,7 @@ defmodule MobilizonWeb.Resolvers.Person do @doc """ Returns the list of events this person is going to """ - def person_going_to_events(_parent, %{}, %{ - context: %{current_user: user} - }) do + def person_going_to_events(_parent, %{}, %{context: %{current_user: user}}) do with %Actor{} = actor <- Users.get_actor_for_user(user), events <- Events.list_event_participations_for_actor(actor) do {:ok, events} @@ -220,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_ids_where_last_administrator(actor_id)) > 0 end @spec proxify_avatar(Actor.t()) :: Actor.t() diff --git a/lib/mobilizon_web/resolvers/picture.ex b/lib/mobilizon_web/resolvers/picture.ex index 494ab6916..9043c2558 100644 --- a/lib/mobilizon_web/resolvers/picture.ex +++ b/lib/mobilizon_web/resolvers/picture.ex @@ -2,6 +2,7 @@ defmodule MobilizonWeb.Resolvers.Picture do @moduledoc """ Handles the picture-related GraphQL calls """ + alias Mobilizon.Actors.Actor alias Mobilizon.Media alias Mobilizon.Media.Picture alias Mobilizon.Users.User @@ -10,9 +11,7 @@ defmodule MobilizonWeb.Resolvers.Picture do Get picture for an event's pic """ def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do - with {:ok, picture} <- do_fetch_picture(picture_id) do - {:ok, picture} - end + with {:ok, picture} <- do_fetch_picture(picture_id), do: {:ok, picture} end @doc """ @@ -20,15 +19,9 @@ defmodule MobilizonWeb.Resolvers.Picture do See MobilizonWeb.Resolvers.Event.create_event/3 """ - def picture(%{picture: picture} = _parent, _args, _resolution) do - {:ok, picture} - end - + def picture(%{picture: picture} = _parent, _args, _resolution), do: {:ok, picture} def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id) - - def picture(_parent, _args, _resolution) do - {:ok, nil} - end + def picture(_parent, _args, _resolution), do: {:ok, nil} @spec do_fetch_picture(nil) :: {:error, nil} defp do_fetch_picture(nil), do: {:error, nil} @@ -36,7 +29,7 @@ defmodule MobilizonWeb.Resolvers.Picture do @spec do_fetch_picture(String.t()) :: {:ok, Picture.t()} | {:error, :not_found} defp do_fetch_picture(picture_id) do case Media.get_picture(picture_id) do - %Picture{id: id, file: file} = _pic -> + %Picture{id: id, file: file} -> {:ok, %{ name: file.name, @@ -46,18 +39,18 @@ defmodule MobilizonWeb.Resolvers.Picture do size: file.size }} - _err -> + _error -> {:error, "Picture with ID #{picture_id} was not found"} end end @spec upload_picture(map(), map(), map()) :: {:ok, Picture.t()} | {:error, any()} - def upload_picture(_parent, %{file: %Plug.Upload{} = file, actor_id: actor_id} = args, %{ - context: %{ - current_user: user - } - }) do - with {:is_owned, true, _actor} <- User.owns_actor(user, actor_id), + def upload_picture( + _parent, + %{file: %Plug.Upload{} = file, actor_id: actor_id} = args, + %{context: %{current_user: user}} + ) do + with {:is_owned, %Actor{}} <- User.owns_actor(user, actor_id), {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <- MobilizonWeb.Upload.store(file), args <- @@ -76,11 +69,11 @@ defmodule MobilizonWeb.Resolvers.Picture do size: picture.file.size }} else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} - err -> - {:error, err} + error -> + {:error, error} end end diff --git a/lib/mobilizon_web/resolvers/report.ex b/lib/mobilizon_web/resolvers/report.ex index 51f815513..ff9ce0391 100644 --- a/lib/mobilizon_web/resolvers/report.ex +++ b/lib/mobilizon_web/resolvers/report.ex @@ -10,9 +10,11 @@ defmodule MobilizonWeb.Resolvers.Report do alias MobilizonWeb.API.Reports, as: ReportsAPI import Mobilizon.Users.Guards - def list_reports(_parent, %{page: page, limit: limit, status: status}, %{ - context: %{current_user: %User{role: role}} - }) + def list_reports( + _parent, + %{page: page, limit: limit, status: status}, + %{context: %{current_user: %User{role: role}}} + ) when is_moderator(role) do {:ok, Mobilizon.Reports.list_reports(page, limit, :updated_at, :desc, status)} end @@ -21,9 +23,7 @@ defmodule MobilizonWeb.Resolvers.Report do {:error, "You need to be logged-in and a moderator to list reports"} end - def get_report(_parent, %{id: id}, %{ - context: %{current_user: %User{role: role}} - }) + def get_report(_parent, %{id: id}, %{context: %{current_user: %User{role: role}}}) when is_moderator(role) do case Mobilizon.Reports.get_report(id) do %Report{} = report -> @@ -46,14 +46,14 @@ defmodule MobilizonWeb.Resolvers.Report do %{reporter_actor_id: reporter_actor_id} = args, %{context: %{current_user: user}} = _resolution ) do - with {:is_owned, true, _} <- User.owns_actor(user, reporter_actor_id), + with {:is_owned, %Actor{}} <- User.owns_actor(user, reporter_actor_id), {:ok, _, %Report{} = report} <- ReportsAPI.report(args) do {:ok, report} else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Reporter actor id is not owned by authenticated user"} - _err -> + _error -> {:error, "Error while saving report"} end end @@ -68,22 +68,19 @@ defmodule MobilizonWeb.Resolvers.Report do def update_report( _parent, %{report_id: report_id, moderator_id: moderator_id, status: status}, - %{ - context: %{current_user: %User{role: role} = user} - } + %{context: %{current_user: %User{role: role} = user}} ) when is_moderator(role) do - with {:is_owned, true, _} <- User.owns_actor(user, moderator_id), - %Actor{} = actor <- Actors.get_actor!(moderator_id), + with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, moderator_id), %Report{} = report <- Mobilizon.Reports.get_report(report_id), {:ok, %Report{} = report} <- MobilizonWeb.API.Reports.update_report_status(actor, report, status) do {:ok, report} else - {:is_owned, false} -> + {:is_owned, nil} -> {:error, "Actor id is not owned by authenticated user"} - _err -> + _error -> {:error, "Error while updating report"} end end @@ -95,27 +92,27 @@ defmodule MobilizonWeb.Resolvers.Report do def create_report_note( _parent, %{report_id: report_id, moderator_id: moderator_id, content: content}, - %{ - context: %{current_user: %User{role: role} = user} - } + %{context: %{current_user: %User{role: role} = user}} ) when is_moderator(role) do - with {:is_owned, true, _} <- User.owns_actor(user, moderator_id), + 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} end end - def delete_report_note(_parent, %{note_id: note_id, moderator_id: moderator_id}, %{ - context: %{current_user: %User{role: role} = user} - }) + def delete_report_note( + _parent, + %{note_id: note_id, moderator_id: moderator_id}, + %{context: %{current_user: %User{role: role} = user}} + ) when is_moderator(role) do - with {:is_owned, true, _} <- User.owns_actor(user, moderator_id), + 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/resolvers/tag.ex b/lib/mobilizon_web/resolvers/tag.ex index ecbef7720..75f26a6cb 100644 --- a/lib/mobilizon_web/resolvers/tag.ex +++ b/lib/mobilizon_web/resolvers/tag.ex @@ -33,7 +33,7 @@ defmodule MobilizonWeb.Resolvers.Tag do # """ # def get_related_tags(_parent, %{tag_id: tag_id}, _resolution) do # with %Tag{} = tag <- Mobilizon.Events.get_tag!(tag_id), - # tags <- Mobilizon.Events.tag_neighbors(tag) do + # tags <- Mobilizon.Events.list_tag_neighbors(tag) do # {:ok, tags} # end # end @@ -42,7 +42,7 @@ defmodule MobilizonWeb.Resolvers.Tag do Retrieve the list of related tags for a parent tag """ def get_related_tags(%Tag{} = tag, _args, _resolution) do - with tags <- Mobilizon.Events.tag_neighbors(tag) do + with tags <- Mobilizon.Events.list_tag_neighbors(tag) do {:ok, tags} end end diff --git a/lib/mobilizon_web/resolvers/user.ex b/lib/mobilizon_web/resolvers/user.ex index 500036fa3..2f747abb3 100644 --- a/lib/mobilizon_web/resolvers/user.ex +++ b/lib/mobilizon_web/resolvers/user.ex @@ -2,12 +2,14 @@ defmodule MobilizonWeb.Resolvers.User do @moduledoc """ Handles the user-related GraphQL calls """ + + alias Mobilizon.{Actors, Config, Users} alias Mobilizon.Actors.Actor - alias Mobilizon.CommonConfig - alias Mobilizon.Users.User - alias Mobilizon.{Actors, Users} alias Mobilizon.Service.Users.{ResetPassword, Activation} + alias Mobilizon.Users.User + import Mobilizon.Users.Guards + require Logger @doc """ @@ -110,7 +112,8 @@ defmodule MobilizonWeb.Resolvers.User do """ @spec create_user(any(), map(), any()) :: tuple() def create_user(_parent, args, _resolution) do - with {:registrations_open, true} <- {:registrations_open, CommonConfig.registrations_open?()}, + with {:registrations_open, true} <- + {:registrations_open, Config.instance_registrations_open?()}, {:ok, %User{} = user} <- Users.register(args) do Activation.send_confirmation_email(user) {:ok, user} @@ -118,8 +121,8 @@ defmodule MobilizonWeb.Resolvers.User do {:registrations_open, false} -> {:error, "Registrations are not enabled"} - err -> - err + error -> + error end end @@ -139,9 +142,9 @@ defmodule MobilizonWeb.Resolvers.User do user: Map.put(user, :default_actor, actor) }} else - err -> + error -> Logger.info("Unable to validate user with token #{token}") - Logger.debug(inspect(err)) + Logger.debug(inspect(error)) {:error, "Unable to validate user"} end end @@ -213,7 +216,7 @@ defmodule MobilizonWeb.Resolvers.User do {:user_actor, _} -> {:error, :actor_not_from_user} - _err -> + _error -> {:error, :unable_to_change_default_actor} end end diff --git a/lib/mobilizon_web/reverse_proxy.ex b/lib/mobilizon_web/reverse_proxy.ex index 98c240d04..30347097a 100644 --- a/lib/mobilizon_web/reverse_proxy.ex +++ b/lib/mobilizon_web/reverse_proxy.ex @@ -260,7 +260,7 @@ defmodule MobilizonWeb.ReverseProxy do headers, "user-agent", 0, - {"user-agent", Mobilizon.Application.user_agent()} + {"user-agent", Mobilizon.user_agent()} ) else headers diff --git a/lib/mobilizon_web/schema.ex b/lib/mobilizon_web/schema.ex index 5445b49f8..6403e8360 100644 --- a/lib/mobilizon_web/schema.ex +++ b/lib/mobilizon_web/schema.ex @@ -7,6 +7,7 @@ defmodule MobilizonWeb.Schema do alias Mobilizon.{Actors, Events, Users, Addresses, Media, Reports} alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Events.{Event, Comment, Participant} + alias Mobilizon.Storage.Repo import_types(MobilizonWeb.Schema.Custom.UUID) import_types(MobilizonWeb.Schema.Custom.Point) @@ -87,14 +88,17 @@ defmodule MobilizonWeb.Schema do end def context(ctx) do + default_query = fn queryable, _params -> queryable end + default_source = Dataloader.Ecto.new(Repo, query: default_query) + loader = Dataloader.new() - |> Dataloader.add_source(Actors, Actors.data()) - |> Dataloader.add_source(Users, Users.data()) - |> Dataloader.add_source(Events, Events.data()) - |> Dataloader.add_source(Addresses, Addresses.data()) - |> Dataloader.add_source(Media, Media.data()) - |> Dataloader.add_source(Reports, Reports.data()) + |> Dataloader.add_source(Actors, default_source) + |> Dataloader.add_source(Users, default_source) + |> Dataloader.add_source(Events, default_source) + |> Dataloader.add_source(Addresses, default_source) + |> Dataloader.add_source(Media, default_source) + |> Dataloader.add_source(Reports, default_source) Map.put(ctx, :loader, loader) end diff --git a/lib/mobilizon_web/upload.ex b/lib/mobilizon_web/upload.ex index 486d820f1..71f16d6e7 100644 --- a/lib/mobilizon_web/upload.ex +++ b/lib/mobilizon_web/upload.ex @@ -31,7 +31,13 @@ defmodule MobilizonWeb.Upload do * `MobilizonWeb.Upload.Filter` """ + alias Ecto.UUID + + alias Mobilizon.Config + + alias MobilizonWeb.MIME + require Logger @type source :: @@ -110,33 +116,33 @@ defmodule MobilizonWeb.Upload do {size_limit, activity_type} = case Keyword.get(opts, :type) do :banner -> - {Mobilizon.CommonConfig.get!([:instance, :banner_upload_limit]), "Image"} + {Config.get!([:instance, :banner_upload_limit]), "Image"} :avatar -> - {Mobilizon.CommonConfig.get!([:instance, :avatar_upload_limit]), "Image"} + {Config.get!([:instance, :avatar_upload_limit]), "Image"} _ -> - {Mobilizon.CommonConfig.get!([:instance, :upload_limit]), nil} + {Config.get!([:instance, :upload_limit]), nil} end %{ activity_type: Keyword.get(opts, :activity_type, activity_type), size_limit: Keyword.get(opts, :size_limit, size_limit), - uploader: Keyword.get(opts, :uploader, Mobilizon.CommonConfig.get([__MODULE__, :uploader])), - filters: Keyword.get(opts, :filters, Mobilizon.CommonConfig.get([__MODULE__, :filters])), + uploader: Keyword.get(opts, :uploader, Config.get([__MODULE__, :uploader])), + filters: Keyword.get(opts, :filters, Config.get([__MODULE__, :filters])), description: Keyword.get(opts, :description), base_url: Keyword.get( opts, :base_url, - Mobilizon.CommonConfig.get([__MODULE__, :base_url], MobilizonWeb.Endpoint.url()) + Config.get([__MODULE__, :base_url], MobilizonWeb.Endpoint.url()) ) } end defp prepare_upload(%Plug.Upload{} = file, opts) do with {:ok, size} <- check_file_size(file.path, opts.size_limit), - {:ok, content_type, name} <- Mobilizon.MIME.file_mime_type(file.path, file.filename) do + {:ok, content_type, name} <- MIME.file_mime_type(file.path, file.filename) do {:ok, %__MODULE__{ id: UUID.generate(), @@ -173,7 +179,7 @@ defmodule MobilizonWeb.Upload do defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do path = URI.encode(path, &char_unescaped?/1) <> - if Mobilizon.CommonConfig.get([__MODULE__, :link_name], false) do + if Config.get([__MODULE__, :link_name], false) do "?name=#{URI.encode(name, &char_unescaped?/1)}" else "" diff --git a/lib/mobilizon_web/upload/filter/anonymize_filename.ex b/lib/mobilizon_web/upload/filter/anonymize_filename.ex index 57c5cbb76..290b9df5a 100644 --- a/lib/mobilizon_web/upload/filter/anonymize_filename.ex +++ b/lib/mobilizon_web/upload/filter/anonymize_filename.ex @@ -9,11 +9,14 @@ defmodule MobilizonWeb.Upload.Filter.AnonymizeFilename do Should be used after `MobilizonWeb.Upload.Filter.Dedupe`. """ + @behaviour MobilizonWeb.Upload.Filter + alias Mobilizon.Config + def filter(upload) do extension = List.last(String.split(upload.name, ".")) - name = Mobilizon.CommonConfig.get([__MODULE__, :text], random(extension)) + name = Config.get([__MODULE__, :text], random(extension)) {:ok, %MobilizonWeb.Upload{upload | name: name}} end diff --git a/lib/mobilizon_web/upload/filter/mogrify.ex b/lib/mobilizon_web/upload/filter/mogrify.ex index f0d1f42c2..e7ae715cd 100644 --- a/lib/mobilizon_web/upload/filter/mogrify.ex +++ b/lib/mobilizon_web/upload/filter/mogrify.ex @@ -7,13 +7,16 @@ defmodule MobilizonWeb.Upload.Filter.Mogrify do @moduledoc """ Handle mogrify transformations """ + @behaviour MobilizonWeb.Upload.Filter + alias Mobilizon.Config + @type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()} @type conversions :: conversion() | [conversion()] def filter(%MobilizonWeb.Upload{tempfile: file, content_type: "image" <> _}) do - filters = Mobilizon.CommonConfig.get!([__MODULE__, :args]) + filters = Config.get!([__MODULE__, :args]) file |> Mogrify.open() diff --git a/lib/mobilizon_web/uploaders/local.ex b/lib/mobilizon_web/uploaders/local.ex index 82d87ae56..4e5f0ea99 100644 --- a/lib/mobilizon_web/uploaders/local.ex +++ b/lib/mobilizon_web/uploaders/local.ex @@ -7,8 +7,11 @@ defmodule MobilizonWeb.Uploaders.Local do @moduledoc """ Local uploader for files """ + @behaviour MobilizonWeb.Uploaders.Uploader + alias Mobilizon.Config + def get_file(_) do {:ok, {:static_dir, upload_path()}} end @@ -59,6 +62,6 @@ defmodule MobilizonWeb.Uploaders.Local do end def upload_path do - Mobilizon.CommonConfig.get!([__MODULE__, :uploads]) + Config.get!([__MODULE__, :uploads]) end end diff --git a/lib/mobilizon_web/views/activity_pub/actor_view.ex b/lib/mobilizon_web/views/activity_pub/actor_view.ex index 2d57cc278..b37c5848b 100644 --- a/lib/mobilizon_web/views/activity_pub/actor_view.ex +++ b/lib/mobilizon_web/views/activity_pub/actor_view.ex @@ -1,10 +1,11 @@ defmodule MobilizonWeb.ActivityPub.ActorView do use MobilizonWeb, :view + alias Mobilizon.Actors alias Mobilizon.Actors.Actor + alias Mobilizon.Service.ActivityPub.Activity alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils - alias Mobilizon.Activity @private_visibility_empty_collection %{elements: [], total: 0} @@ -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.build_followings_for_actor(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.build_followings_for_actor(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.build_followers_for_actor(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.build_followers_for_actor(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/mobilizon_web/views/activity_pub/object_view.ex b/lib/mobilizon_web/views/activity_pub/object_view.ex index 9a1da8fca..4ad2b9fee 100644 --- a/lib/mobilizon_web/views/activity_pub/object_view.ex +++ b/lib/mobilizon_web/views/activity_pub/object_view.ex @@ -1,7 +1,7 @@ defmodule MobilizonWeb.ActivityPub.ObjectView do use MobilizonWeb, :view - alias Mobilizon.Service.ActivityPub.Utils - alias Mobilizon.Activity + + alias Mobilizon.Service.ActivityPub.{Activity, Utils} def render("activity.json", %{activity: %Activity{local: local, data: data} = activity}) do %{ diff --git a/lib/mobilizon_web/views/email_view.ex b/lib/mobilizon_web/views/email_view.ex index 7c2ecb679..d34589acd 100644 --- a/lib/mobilizon_web/views/email_view.ex +++ b/lib/mobilizon_web/views/email_view.ex @@ -1,3 +1,3 @@ -defmodule Mobilizon.EmailView do +defmodule MobilizonWeb.EmailView do use MobilizonWeb, :view end diff --git a/lib/service/activity_pub/activity.ex b/lib/service/activity_pub/activity.ex new file mode 100644 index 000000000..d0be2e2c0 --- /dev/null +++ b/lib/service/activity_pub/activity.ex @@ -0,0 +1,21 @@ +defmodule Mobilizon.Service.ActivityPub.Activity do + @moduledoc """ + Represents an activity. + """ + + @type t :: %__MODULE__{ + data: String.t(), + local: boolean, + actor: Actor.t(), + recipients: [String.t()] + # notifications: [???] + } + + defstruct [ + :data, + :local, + :actor, + :recipients + # :notifications + ] +end diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index e111dac0f..5dab5b12e 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -10,11 +10,11 @@ defmodule Mobilizon.Service.ActivityPub do Every ActivityPub method """ + alias Mobilizon.Config alias Mobilizon.Events alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Service.ActivityPub.Transmogrifier alias Mobilizon.Service.WebFinger - alias Mobilizon.Activity alias Mobilizon.Actors alias Mobilizon.Actors.{Actor, Follower} @@ -22,11 +22,10 @@ defmodule Mobilizon.Service.ActivityPub do alias Mobilizon.Service.Federator alias Mobilizon.Service.HTTPSignatures.Signature - alias Mobilizon.Service.ActivityPub.Convertible + alias Mobilizon.Service.ActivityPub.{Activity, Convertible} require Logger - import Mobilizon.Service.ActivityPub.Utils - import Mobilizon.Service.ActivityPub.Visibility + import Mobilizon.Service.ActivityPub.{Utils, Visibility} @doc """ Get recipients for an activity or object @@ -84,10 +83,10 @@ defmodule Mobilizon.Service.ActivityPub do {:ok, _activity, %{url: object_url} = _object} <- Transmogrifier.handle_incoming(params) do case data["type"] do "Event" -> - {:ok, Events.get_event_full_by_url!(object_url)} + {:ok, Events.get_public_event_by_url_with_preload!(object_url)} "Note" -> - {:ok, Events.get_comment_full_from_url!(object_url)} + {:ok, Events.get_comment_from_url_with_preload!(object_url)} "Actor" -> {:ok, Actors.get_actor_by_url!(object_url, true)} @@ -97,10 +96,10 @@ defmodule Mobilizon.Service.ActivityPub do end else {:existing_event, %Event{url: event_url}} -> - {:ok, Events.get_event_full_by_url!(event_url)} + {:ok, Events.get_public_event_by_url_with_preload!(event_url)} {:existing_comment, %Comment{url: comment_url}} -> - {:ok, Events.get_comment_full_from_url!(comment_url)} + {:ok, Events.get_comment_from_url_with_preload!(comment_url)} {:existing_actor, {:ok, %Actor{url: actor_url}}} -> {:ok, Actors.get_actor_by_url!(actor_url, true)} @@ -112,6 +111,28 @@ 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" """ @@ -278,7 +299,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), @@ -297,7 +318,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( @@ -437,8 +458,7 @@ defmodule Mobilizon.Service.ActivityPub do local ) do with {:only_organizer, false} <- - {:only_organizer, - Participant.check_that_participant_is_not_only_organizer(event_id, actor_id)}, + {:only_organizer, Participant.is_not_only_organizer(event_id, actor_id)}, {:ok, %Participant{} = participant} <- Mobilizon.Events.get_participant(event_id, actor_id), {:ok, %Participant{} = participant} <- Mobilizon.Events.delete_participant(participant), @@ -464,7 +484,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} -> @@ -520,15 +540,14 @@ defmodule Mobilizon.Service.ActivityPub do public = is_public?(activity) - if public && is_delete_activity?(activity) == false && - Mobilizon.CommonConfig.get([:instance, :allow_relay]) do + if public && !is_delete_activity?(activity) && Config.get([:instance, :allow_relay]) do Logger.info(fn -> "Relaying #{activity.data["id"]} out" end) Mobilizon.Service.ActivityPub.Relay.publish(activity) end followers = if actor.followers_url in activity.recipients do - Actor.get_full_external_followers(actor) + Actors.list_external_followers_for_actor(actor) else [] end @@ -664,8 +683,8 @@ defmodule Mobilizon.Service.ActivityPub do """ @spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: map() def fetch_public_activities_for_actor(%Actor{} = actor, page \\ 1, limit \\ 10) do - {:ok, events, total_events} = Events.get_public_events_for_actor(actor, page, limit) - {:ok, comments, total_comments} = Events.get_public_comments_for_actor(actor, page, limit) + {:ok, events, total_events} = Events.list_public_events_for_actor(actor, page, limit) + {:ok, comments, total_comments} = Events.list_public_comments_for_actor(actor, page, limit) event_activities = Enum.map(events, &event_to_activity/1) diff --git a/lib/service/activity_pub/converters/actor.ex b/lib/service/activity_pub/converters/actor.ex index 5e8c4ac3c..002e7c3e8 100644 --- a/lib/service/activity_pub/converters/actor.ex +++ b/lib/service/activity_pub/converters/actor.ex @@ -33,7 +33,7 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Actor do "type" => String.to_existing_atom(object["type"]), "preferred_username" => object["preferredUsername"], "summary" => object["summary"], - "url" => object["url"], + "url" => object["id"], "name" => object["name"], "avatar" => avatar, "banner" => banner, 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..7b4396a1f 100644 --- a/lib/service/activity_pub/relay.ex +++ b/lib/service/activity_pub/relay.ex @@ -8,23 +8,25 @@ defmodule Mobilizon.Service.ActivityPub.Relay do Handles following and unfollowing relays and instances """ - alias Mobilizon.Activity alias Mobilizon.Actors alias Mobilizon.Actors.Actor + alias Mobilizon.Service.ActivityPub.Activity alias Mobilizon.Service.ActivityPub + alias MobilizonWeb.API.Follows + require Logger def get_actor do with {:ok, %Actor{} = actor} <- - Actors.get_or_create_service_actor_by_url("#{MobilizonWeb.Endpoint.url()}/relay") do + Actors.get_or_create_actor_by_url("#{MobilizonWeb.Endpoint.url()}/relay") do actor end end 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 +39,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 +52,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 +60,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..ef34e1477 100644 --- a/lib/service/activity_pub/transmogrifier.ex +++ b/lib/service/activity_pub/transmogrifier.ex @@ -12,8 +12,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do alias Mobilizon.Events alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Service.ActivityPub - alias Mobilizon.Service.ActivityPub.Utils - alias Mobilizon.Service.ActivityPub.Visibility + alias Mobilizon.Service.ActivityPub.{Visibility, Utils} require Logger @@ -139,7 +138,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 +162,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 +186,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 +206,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 +235,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 +278,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 +346,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 +450,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} @@ -642,7 +641,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do defp get_follow(follow_object) do with follow_object_id when not is_nil(follow_object_id) <- Utils.get_url(follow_object), {:not_found, %Follower{} = follow} <- - {:not_found, Actors.get_follow_by_url(follow_object_id)} do + {:not_found, Actors.get_follower_by_url(follow_object_id)} do {:ok, follow} else {:not_found, _err} -> diff --git a/lib/service/activity_pub/utils.ex b/lib/service/activity_pub/utils.ex index b0c40d748..9f9caf2c0 100644 --- a/lib/service/activity_pub/utils.ex +++ b/lib/service/activity_pub/utils.ex @@ -10,24 +10,25 @@ defmodule Mobilizon.Service.ActivityPub.Utils do Various utils """ - alias Mobilizon.Repo + alias Ecto.Changeset + alias Mobilizon.Addresses alias Mobilizon.Addresses.Address alias Mobilizon.Actors alias Mobilizon.Actors.Actor - alias Mobilizon.Events.Event - alias Mobilizon.Events.Comment - alias Mobilizon.Media.Picture alias Mobilizon.Events - alias Mobilizon.Activity + alias Mobilizon.Events.{Comment, Event} + alias Mobilizon.Media.Picture alias Mobilizon.Reports alias Mobilizon.Reports.Report + alias Mobilizon.Service.ActivityPub.{Activity, Converters} + alias Mobilizon.Storage.Repo alias Mobilizon.Users - alias Mobilizon.Service.ActivityPub.Converters - alias Ecto.Changeset - require Logger + + alias MobilizonWeb.{Email, Endpoint} alias MobilizonWeb.Router.Helpers, as: Routes - alias MobilizonWeb.Endpoint + + require Logger @actor_types ["Group", "Person", "Application"] @@ -164,8 +165,8 @@ defmodule Mobilizon.Service.ActivityPub.Utils do {:ok, %Report{} = report} <- Reports.create_report(data) do Enum.each(Users.list_moderators(), fn moderator -> moderator - |> Mobilizon.Email.Admin.report(report) - |> Mobilizon.Mailer.deliver_later() + |> Email.Admin.report(report) + |> Email.Mailer.deliver_later() end) {:ok, report} diff --git a/lib/service/activity_pub/visibility.ex b/lib/service/activity_pub/visibility.ex index 93a5c5aba..adca3e2aa 100644 --- a/lib/service/activity_pub/visibility.ex +++ b/lib/service/activity_pub/visibility.ex @@ -7,7 +7,8 @@ defmodule Mobilizon.Service.ActivityPub.Visibility do @moduledoc """ Utility functions related to content visibility """ - alias Mobilizon.Activity + + alias Mobilizon.Service.ActivityPub.Activity @public "https://www.w3.org/ns/activitystreams#Public" diff --git a/lib/service/export/feed.ex b/lib/service/export/feed.ex index e58f05e3e..ce33e73bb 100644 --- a/lib/service/export/feed.ex +++ b/lib/service/export/feed.ex @@ -44,8 +44,8 @@ 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)}, - {:ok, events, _count} <- Events.get_public_events_for_actor(actor) do + {:visibility, true} <- {:visibility, Actor.is_public_visibility(actor)}, + {:ok, events, _count} <- Events.list_public_events_for_actor(actor) do {:ok, build_actor_feed(actor, events)} else err -> diff --git a/lib/service/export/icalendar.ex b/lib/service/export/icalendar.ex index 866685b33..70d865f48 100644 --- a/lib/service/export/icalendar.ex +++ b/lib/service/export/icalendar.ex @@ -44,8 +44,8 @@ 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), - {:ok, events, _} <- Events.get_public_events_for_actor(actor) do + with true <- Actor.is_public_visibility(actor), + {:ok, events, _} <- Events.list_public_events_for_actor(actor) do {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} end end @@ -74,7 +74,7 @@ defmodule Mobilizon.Service.Export.ICalendar do Create cache for an actor """ def create_cache("event_" <> uuid) do - with %Event{} = event <- Events.get_event_full_by_uuid(uuid), + with %Event{} = event <- Events.get_public_event_by_uuid_with_preload(uuid), {:ok, res} <- export_public_event(event) do {:commit, res} else diff --git a/lib/service/federator.ex b/lib/service/federator.ex index 5e19e8a4c..a0051706a 100644 --- a/lib/service/federator.ex +++ b/lib/service/federator.ex @@ -9,10 +9,11 @@ defmodule Mobilizon.Service.Federator do """ use GenServer + alias Mobilizon.Actors - alias Mobilizon.Activity alias Mobilizon.Service.ActivityPub - alias Mobilizon.Service.ActivityPub.Transmogrifier + alias Mobilizon.Service.ActivityPub.{Activity, Transmogrifier} + require Logger @max_jobs 20 @@ -21,7 +22,7 @@ defmodule Mobilizon.Service.Federator do {:ok, args} end - def start_link do + def start_link(_) do spawn(fn -> # 1 minute Process.sleep(1000 * 60) diff --git a/lib/service/http_signatures/signature.ex b/lib/service/http_signatures/signature.ex index 4fc6dcfb3..0eabf6325 100644 --- a/lib/service/http_signatures/signature.ex +++ b/lib/service/http_signatures/signature.ex @@ -7,32 +7,72 @@ 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", "")) - else + if is_nil(path) do uri + else + Map.put(uri, :path, String.trim_trailing(path, "/publickey")) end 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 +85,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 +93,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/lib/service/users/activation.ex b/lib/service/users/activation.ex index c89d2ec95..1d03da1a2 100644 --- a/lib/service/users/activation.ex +++ b/lib/service/users/activation.ex @@ -1,11 +1,12 @@ defmodule Mobilizon.Service.Users.Activation do @moduledoc false - alias Mobilizon.{Mailer, Users} + alias Mobilizon.Users alias Mobilizon.Users.User - alias Mobilizon.Email.User, as: UserEmail alias Mobilizon.Service.Users.Tools + alias MobilizonWeb.Email + require Logger @doc false @@ -39,7 +40,7 @@ defmodule Mobilizon.Service.Users.Activation do def send_confirmation_email(%User{} = user, locale \\ "en") do user - |> UserEmail.confirmation_email(locale) - |> Mailer.deliver_later() + |> Email.User.confirmation_email(locale) + |> Email.Mailer.deliver_later() end end diff --git a/lib/service/users/reset_password.ex b/lib/service/users/reset_password.ex index e34565166..1fa744f54 100644 --- a/lib/service/users/reset_password.ex +++ b/lib/service/users/reset_password.ex @@ -1,12 +1,14 @@ defmodule Mobilizon.Service.Users.ResetPassword do @moduledoc false - require Logger - - alias Mobilizon.Users.User - alias Mobilizon.{Mailer, Repo, Users} - alias Mobilizon.Email.User, as: UserEmail alias Mobilizon.Service.Users.Tools + alias Mobilizon.Storage.Repo + alias Mobilizon.Users + alias Mobilizon.Users.User + + alias MobilizonWeb.Email + + require Logger @doc """ Check that the provided token is correct and update provided password @@ -49,8 +51,8 @@ defmodule Mobilizon.Service.Users.ResetPassword do ) do mail = user_updated - |> UserEmail.reset_password_email(locale) - |> Mailer.deliver_later() + |> Email.User.reset_password_email(locale) + |> Email.Mailer.deliver_later() {:ok, mail} else diff --git a/mix.exs b/mix.exs index 83d043e07..68a3a619d 100644 --- a/mix.exs +++ b/mix.exs @@ -36,7 +36,7 @@ defmodule Mobilizon.Mixfile do # Type `mix help compile.app` for more information. def application do [ - mod: {Mobilizon.Application, []}, + mod: {Mobilizon, []}, extra_applications: [:logger, :runtime_tools, :guardian, :bamboo, :geolix, :crypto, :cachex] ] end @@ -120,9 +120,20 @@ defmodule Mobilizon.Mixfile do # See the documentation for `Mix` for more info on aliases. defp aliases do [ - "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], - "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate", "test"], + "ecto.setup": [ + "ecto.create", + "ecto.migrate", + "run priv/repo/seeds.exs" + ], + "ecto.reset": [ + "ecto.drop", + "ecto.setup" + ], + test: [ + "ecto.create --quiet", + "ecto.migrate", + &run_test/1 + ], "phx.deps_migrate_serve": [ "deps.get", "ecto.create --quiet", @@ -133,6 +144,11 @@ defmodule Mobilizon.Mixfile do ] end + defp run_test(args) do + Mix.Task.run("test", args) + File.rm_rf!("test/uploads") + end + defp docs() do [ source_ref: "v#{@version}", @@ -172,15 +188,16 @@ 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, Mobilizon.Addresses, Mobilizon.Addresses.Address, Mobilizon.Events, + Mobilizon.Service.ActivityPub.Activity, Mobilizon.Events.Event, Mobilizon.Events.Comment, Mobilizon.Events.FeedToken, @@ -189,22 +206,21 @@ defmodule Mobilizon.Mixfile do Mobilizon.Events.Tag, Mobilizon.Events.TagRelations, Mobilizon.Events.Track, - Mobilizon.Event.EventCategoryEnum, - Mobilizon.Events.CommentVisibilityEnum, - Mobilizon.Events.EventStatusEnum, - Mobilizon.Events.EventVisibilityEnum, - Mobilizon.Events.JoinOptionsEnum, - Mobilizon.Events.ParticipantRoleEnum, + Mobilizon.Event.EventCategory, + Mobilizon.Events.CommentVisibility, + Mobilizon.Events.EventStatus, + Mobilizon.Events.EventVisibility, + Mobilizon.Events.JoinOptions, + Mobilizon.Events.ParticipantRole, Mobilizon.Events.Tag.TitleSlug, Mobilizon.Events.Tag.TitleSlug.Type, Mobilizon.Events.TagRelation, Mobilizon.Users, Mobilizon.Users.User, - Mobilizon.Users.UserRoleEnum, + Mobilizon.Users.UserRole, Mobilizon.Users.Guards, - Mobilizon.Activity, - Mobilizon.Ecto, - Mobilizon.Repo + Mobilizon.Storage.Ecto, + Mobilizon.Storage.Repo ], APIs: [ MobilizonWeb.API.Comments, @@ -220,6 +236,7 @@ defmodule Mobilizon.Mixfile do MobilizonWeb.Router.Helpers, MobilizonWeb.AuthErrorHandler, MobilizonWeb.AuthPipeline, + MobilizonWeb.Cache, MobilizonWeb.ChangesetView, MobilizonWeb.Context, MobilizonWeb.Endpoint, @@ -299,9 +316,9 @@ defmodule Mobilizon.Mixfile do Tools: [ Mobilizon.Application, Mobilizon.Factory, - Mobilizon.Mailer, - Mobilizon.EmailView, - Mobilizon.Email.User + MobilizonWeb.Email.Mailer, + MobilizonWeb.Email.User, + MobilizonWeb.EmailView ] ] end 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/20190103150805_fix_event_visibility.exs b/priv/repo/migrations/20190103150805_fix_event_visibility.exs index 0d1fd294b..e5eecdbf9 100644 --- a/priv/repo/migrations/20190103150805_fix_event_visibility.exs +++ b/priv/repo/migrations/20190103150805_fix_event_visibility.exs @@ -2,20 +2,20 @@ defmodule Mobilizon.Repo.Migrations.FixEventVisibility do use Ecto.Migration def up do - Mobilizon.Events.EventVisibilityEnum.create_type() - Mobilizon.Events.EventStatusEnum.create_type() - Mobilizon.Events.CommentVisibilityEnum.create_type() + Mobilizon.Events.EventVisibility.create_type() + Mobilizon.Events.EventStatus.create_type() + Mobilizon.Events.CommentVisibility.create_type() alter table(:events) do remove(:public) remove(:status) remove(:state) - add(:visibility, Mobilizon.Events.EventVisibilityEnum.type()) - add(:status, Mobilizon.Events.EventStatusEnum.type()) + add(:visibility, Mobilizon.Events.EventVisibility.type()) + add(:status, Mobilizon.Events.EventStatus.type()) end alter table(:comments) do - add(:visibility, Mobilizon.Events.CommentVisibilityEnum.type()) + add(:visibility, Mobilizon.Events.CommentVisibility.type()) end end @@ -32,8 +32,8 @@ defmodule Mobilizon.Repo.Migrations.FixEventVisibility do remove(:visibility) end - Mobilizon.Events.EventVisibilityEnum.drop_type() - Mobilizon.Events.EventStatusEnum.drop_type() - Mobilizon.Events.CommentVisibilityEnum.drop_type() + Mobilizon.Events.EventVisibility.drop_type() + Mobilizon.Events.EventStatus.drop_type() + Mobilizon.Events.CommentVisibility.drop_type() end end diff --git a/priv/repo/migrations/20190130151607_split_event_visibility_and_join_options.exs b/priv/repo/migrations/20190130151607_split_event_visibility_and_join_options.exs index a991ef11b..0255ea2af 100644 --- a/priv/repo/migrations/20190130151607_split_event_visibility_and_join_options.exs +++ b/priv/repo/migrations/20190130151607_split_event_visibility_and_join_options.exs @@ -1,32 +1,32 @@ defmodule Mobilizon.Repo.Migrations.SplitEventVisibilityAndJoinOptions do use Ecto.Migration - alias Mobilizon.Events.EventVisibilityEnum - alias Mobilizon.Events.JoinOptionsEnum + alias Mobilizon.Events.EventVisibility + alias Mobilizon.Events.JoinOptions @doc """ - EventVisibilityEnum has dropped some possible values, so we need to recreate it + EventVisibility has dropped some possible values, so we need to recreate it Visibility allowed nullable values previously """ def up do execute("ALTER TABLE events ALTER COLUMN visibility TYPE VARCHAR USING visibility::text") - EventVisibilityEnum.drop_type() - EventVisibilityEnum.create_type() + EventVisibility.drop_type() + EventVisibility.create_type() execute( - "ALTER TABLE events ALTER COLUMN visibility TYPE event_visibility_type USING visibility::event_visibility_type" + "ALTER TABLE events ALTER COLUMN visibility TYPE event_visibility USING visibility::event_visibility" ) - JoinOptionsEnum.create_type() + JoinOptions.create_type() alter table(:events) do - add(:join_options, JoinOptionsEnum.type(), null: false, default: "free") + add(:join_options, JoinOptions.type(), null: false, default: "free") end execute("UPDATE events SET visibility = 'public' WHERE visibility IS NULL") alter table(:events) do - modify(:visibility, EventVisibilityEnum.type(), null: false, default: "public") + modify(:visibility, EventVisibility.type(), null: false, default: "public") end end @@ -35,14 +35,14 @@ defmodule Mobilizon.Repo.Migrations.SplitEventVisibilityAndJoinOptions do remove(:join_options) end - JoinOptionsEnum.drop_type() + JoinOptions.drop_type() execute("ALTER TABLE events ALTER COLUMN visibility TYPE VARCHAR USING visibility::text") - EventVisibilityEnum.drop_type() - EventVisibilityEnum.create_type() + EventVisibility.drop_type() + EventVisibility.create_type() execute( - "ALTER TABLE events ALTER COLUMN visibility TYPE event_visibility_type USING visibility::event_visibility_type" + "ALTER TABLE events ALTER COLUMN visibility TYPE event_visibility USING visibility::event_visibility" ) end end diff --git a/priv/repo/migrations/20190207134142_move_participant_role_to_enum.exs b/priv/repo/migrations/20190207134142_move_participant_role_to_enum.exs index 68e8a9713..065ff0991 100644 --- a/priv/repo/migrations/20190207134142_move_participant_role_to_enum.exs +++ b/priv/repo/migrations/20190207134142_move_participant_role_to_enum.exs @@ -1,12 +1,12 @@ defmodule Mobilizon.Repo.Migrations.MoveParticipantRoleToEnum do use Ecto.Migration - alias Mobilizon.Events.ParticipantRoleEnum + alias Mobilizon.Events.ParticipantRole def up do - ParticipantRoleEnum.create_type() + ParticipantRole.create_type() alter table(:participants) do - add(:role_tmp, ParticipantRoleEnum.type(), default: "participant") + add(:role_tmp, ParticipantRole.type(), default: "participant") end execute("UPDATE participants set role_tmp = 'not_approved' where role = 0") @@ -37,7 +37,7 @@ defmodule Mobilizon.Repo.Migrations.MoveParticipantRoleToEnum do remove(:role) end - ParticipantRoleEnum.drop_type() + ParticipantRole.drop_type() rename(table(:participants), :role_tmp, to: :role) end 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/20190307125009_move_user_role_to_enum.exs b/priv/repo/migrations/20190307125009_move_user_role_to_enum.exs index 1508391ce..66f8ce5be 100644 --- a/priv/repo/migrations/20190307125009_move_user_role_to_enum.exs +++ b/priv/repo/migrations/20190307125009_move_user_role_to_enum.exs @@ -1,13 +1,13 @@ defmodule Mobilizon.Repo.Migrations.MoveUserRoleToEnum do use Ecto.Migration - alias Mobilizon.Users.UserRoleEnum + alias Mobilizon.Users.UserRole def up do - UserRoleEnum.create_type() + UserRole.create_type() alter table(:users) do - add(:role_tmp, UserRoleEnum.type(), default: "user") + add(:role_tmp, UserRole.type(), default: "user") end execute("UPDATE users set role_tmp = 'user' where role = 0") @@ -34,7 +34,7 @@ defmodule Mobilizon.Repo.Migrations.MoveUserRoleToEnum do remove(:role) end - UserRoleEnum.drop_type() + UserRole.drop_type() rename(table(:users), :role_tmp, to: :role) 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/priv/repo/migrations/20190712125833_create_reports.exs b/priv/repo/migrations/20190712125833_create_reports.exs index ce466b3e9..334b7ac08 100644 --- a/priv/repo/migrations/20190712125833_create_reports.exs +++ b/priv/repo/migrations/20190712125833_create_reports.exs @@ -1,13 +1,13 @@ defmodule Mobilizon.Repo.Migrations.CreateReports do use Ecto.Migration - alias Mobilizon.Reports.ReportStateEnum + alias Mobilizon.Reports.ReportStatus def up do - ReportStateEnum.create_type() + ReportStatus.create_type() create table(:reports) do add(:content, :string) - add(:status, ReportStateEnum.type(), default: "open", null: false) + add(:status, ReportStatus.type(), default: "open", null: false) add(:uri, :string, null: false) add(:reported_id, references(:actors, on_delete: :delete_all), null: false) @@ -28,6 +28,6 @@ defmodule Mobilizon.Repo.Migrations.CreateReports do drop(table(:reports_comments)) drop(table(:reports)) - ReportStateEnum.drop_type() + ReportStatus.drop_type() end end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index f3367609b..9ef0b716a 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -5,7 +5,7 @@ # Inside the script, you can read and write to any of your # repositories directly: # -# Mobilizon.Repo.insert!(%Mobilizon.SomeSchema{}) +# Mobilizon.Storage.Repo.insert!(%Mobilizon.SomeSchema{}) # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. diff --git a/test/mobilizon/actors/actors_test.exs b/test/mobilizon/actors/actors_test.exs index 6b2ae6917..df5a5755b 100644 --- a/test/mobilizon/actors/actors_test.exs +++ b/test/mobilizon/actors/actors_test.exs @@ -1,12 +1,15 @@ defmodule Mobilizon.ActorsTest do + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + use Mobilizon.DataCase - alias Mobilizon.Actors - alias Mobilizon.Actors.{Actor, Member, Follower, Bot} - alias Mobilizon.Users - alias Mobilizon.Media.File, as: FileModel import Mobilizon.Factory - use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + + alias Mobilizon.{Actors, Config, Users} + alias Mobilizon.Actors.{Actor, Member, Follower, Bot} + alias Mobilizon.Media.File, as: FileModel + alias Mobilizon.Service.ActivityPub + alias Mobilizon.Storage.Page describe "actors" do @valid_attrs %{ @@ -41,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) @@ -71,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 @@ -98,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" @@ -112,53 +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( - "#{actor.preferred_username}@#{actor.domain}" - ).organized_events + Actors.get_actor_by_name_with_preload("#{actor.preferred_username}@#{actor.domain}").organized_events |> hd |> Map.get(:id) @@ -167,42 +166,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 list_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.list_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) @@ -211,35 +189,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" @@ -281,12 +237,12 @@ defmodule Mobilizon.ActorsTest do %URI{path: "/media/" <> banner_path} = URI.parse(banner_url) assert File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> avatar_path ) assert File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> banner_path ) @@ -312,12 +268,12 @@ defmodule Mobilizon.ActorsTest do refute actor.suspended refute File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> avatar_path ) assert File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> banner_path ) end @@ -335,12 +291,12 @@ defmodule Mobilizon.ActorsTest do %URI{path: "/media/" <> banner_path} = URI.parse(banner_url) assert File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> avatar_path ) assert File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> banner_path ) @@ -348,14 +304,10 @@ defmodule Mobilizon.ActorsTest do assert_raise Ecto.NoResultsError, fn -> Actors.get_actor!(actor_id) end refute File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> 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 @@ -464,11 +416,6 @@ defmodule Mobilizon.ActorsTest do assert {:ok, %Bot{}} = Actors.delete_bot(bot) assert_raise Ecto.NoResultsError, fn -> Actors.get_bot!(bot.id) end end - - test "change_bot/1 returns a bot changeset" do - bot = insert(:bot) - assert %Ecto.Changeset{} = Actors.change_bot(bot) - end end describe "followers" do @@ -506,8 +453,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.build_followings_for_actor(actor) + assert %{total: 1, elements: [actor]} = Actors.build_followers_for_actor(target_actor) end test "create_follower/1 with valid data but same actors fails to create a follower", %{ @@ -555,33 +502,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 @@ -621,8 +563,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.list_groups_member_of(actor) + assert [actor] = Actors.list_members_for_group(group) end test "create_member/1 with valid data but same actors fails to create a member", %{ @@ -667,10 +609,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/addresses/addresses_test.exs b/test/mobilizon/addresses/addresses_test.exs index 8f9e267fe..8be79b2f1 100644 --- a/test/mobilizon/addresses/addresses_test.exs +++ b/test/mobilizon/addresses/addresses_test.exs @@ -76,23 +76,5 @@ defmodule Mobilizon.AddressesTest do assert {:ok, %Address{}} = Addresses.delete_address(address) assert_raise Ecto.NoResultsError, fn -> Addresses.get_address!(address.id) end end - - test "change_address/1 returns a address changeset" do - address = insert(:address) - assert %Ecto.Changeset{} = Addresses.change_address(address) - end - - test "process_geom/2 with valid data returns a Point element" do - attrs = %{"type" => "point", "data" => %{"latitude" => 10, "longitude" => -10}} - assert {:ok, %Geo.Point{}} = Addresses.process_geom(attrs) - end - - test "process_geom/2 with invalid data returns nil" do - attrs = %{"type" => :point, "data" => %{"latitude" => nil, "longitude" => nil}} - assert {:error, "Latitude and longitude must be numbers"} = Addresses.process_geom(attrs) - - attrs = %{"type" => :not_valid, "data" => %{"latitude" => nil, "longitude" => nil}} - assert {:error, :invalid_type} == Addresses.process_geom(attrs) - end end end diff --git a/test/mobilizon/events/events_test.exs b/test/mobilizon/events/events_test.exs index 29a396906..83e5dca53 100644 --- a/test/mobilizon/events/events_test.exs +++ b/test/mobilizon/events/events_test.exs @@ -4,6 +4,7 @@ defmodule Mobilizon.EventsTest do import Mobilizon.Factory alias Mobilizon.Events + alias Mobilizon.Storage.Page @event_valid_attrs %{ begins_on: "2010-04-17 14:00:00Z", @@ -47,29 +48,29 @@ defmodule Mobilizon.EventsTest do refute Ecto.assoc_loaded?(Events.get_event!(event.id).organizer_actor) end - test "get_event_full!/1 returns the event with given id", %{event: event} do - assert Events.get_event_full!(event.id).organizer_actor.preferred_username == + test "get_event_with_preload!/1 returns the event with given id", %{event: event} do + assert Events.get_event_with_preload!(event.id).organizer_actor.preferred_username == event.organizer_actor.preferred_username - assert Events.get_event_full!(event.id).participants == [] + assert Events.get_event_with_preload!(event.id).participants == [] end - test "find_and_count_events_by_name/1 returns events for a given name", %{ + test "build_events_by_name/1 returns events for a given name", %{ event: %Event{title: title} = event } do - assert title == hd(Events.find_and_count_events_by_name(event.title).elements).title + assert title == hd(Events.build_events_by_name(event.title).elements).title %Event{} = event2 = insert(:event, title: "Special event") assert event2.title == - Events.find_and_count_events_by_name("Special").elements |> hd() |> Map.get(:title) + Events.build_events_by_name("Special").elements |> hd() |> Map.get(:title) assert event2.title == - Events.find_and_count_events_by_name(" Special ").elements + Events.build_events_by_name(" Special ").elements |> hd() |> Map.get(:title) - assert %{elements: [], total: 0} == Events.find_and_count_events_by_name("") + assert %Page{elements: [], total: 0} == Events.build_events_by_name("") end test "find_close_events/3 returns events in the area" do @@ -127,19 +128,15 @@ defmodule Mobilizon.EventsTest do assert_raise Ecto.NoResultsError, fn -> Events.get_event!(event.id) end end - test "change_event/1 returns a event changeset", %{event: event} do - assert %Ecto.Changeset{} = Events.change_event(event) - end - - test "get_public_events_for_actor/1", %{actor: actor, event: event} do - assert {:ok, [event_found], 1} = Events.get_public_events_for_actor(actor) + test "list_public_events_for_actor/1", %{actor: actor, event: event} do + assert {:ok, [event_found], 1} = Events.list_public_events_for_actor(actor) assert event_found.title == event.title end - test "get_public_events_for_actor/3", %{actor: actor, event: event} do + test "list_public_events_for_actor/3", %{actor: actor, event: event} do event1 = insert(:event, organizer_actor: actor) - case Events.get_public_events_for_actor(actor, 1, 10) do + case Events.list_public_events_for_actor(actor, 1, 10) do {:ok, events_found, 2} -> event_ids = MapSet.new(events_found |> Enum.map(& &1.id)) assert event_ids == MapSet.new([event.id, event1.id]) @@ -149,10 +146,10 @@ defmodule Mobilizon.EventsTest do end end - test "get_public_events_for_actor/3 with limited results", %{actor: actor, event: event} do + test "list_public_events_for_actor/3 with limited results", %{actor: actor, event: event} do event1 = insert(:event, organizer_actor: actor) - case Events.get_public_events_for_actor(actor, 1, 1) do + case Events.list_public_events_for_actor(actor, 1, 1) do {:ok, [%Event{id: event_found_id}], 2} -> assert event_found_id in [event.id, event1.id] @@ -229,11 +226,6 @@ defmodule Mobilizon.EventsTest do assert {:ok, %Tag{}} = Events.delete_tag(tag) assert_raise Ecto.NoResultsError, fn -> Events.get_tag!(tag.id) end end - - test "change_tag/1 returns a tag changeset" do - tag = insert(:tag) - assert %Ecto.Changeset{} = Events.change_tag(tag) - end end describe "tags_relations" do @@ -272,7 +264,7 @@ defmodule Mobilizon.EventsTest do assert {:ok, %TagRelation{}} = Events.delete_tag_relation(tag_relation) end - test "tag_neighbors/2 return the connected tags for a given tag", %{ + test "list_tag_neighbors/2 return the connected tags for a given tag", %{ tag1: %Tag{} = tag1, tag2: %Tag{} = tag2 } do @@ -307,7 +299,7 @@ defmodule Mobilizon.EventsTest do }} = Events.create_tag_relation(%{tag_id: tag1.id, link_id: tag1.id}) # The order is preserved, since tag4 has one more relation than tag2 - assert [tag4, tag2] == Events.tag_neighbors(tag1) + assert [tag4, tag2] == Events.list_tag_neighbors(tag1) end end @@ -383,10 +375,6 @@ defmodule Mobilizon.EventsTest do test "delete_participant/1 deletes the participant", %{participant: participant} do assert {:ok, %Participant{}} = Events.delete_participant(participant) end - - test "change_participant/1 returns a participant changeset", %{participant: participant} do - assert %Ecto.Changeset{} = Events.change_participant(participant) - end end describe "sessions" do @@ -431,7 +419,7 @@ defmodule Mobilizon.EventsTest do test "list_sessions_for_event/1 returns sessions for an event" do event = insert(:event) session = insert(:session, event: event) - assert Events.list_sessions_for_event(event) |> Enum.map(& &1.id) == [session.id] + assert event |> Events.list_sessions_for_event() |> Enum.map(& &1.id) == [session.id] end test "get_session!/1 returns the session with given id" do @@ -481,11 +469,6 @@ defmodule Mobilizon.EventsTest do assert {:ok, %Session{}} = Events.delete_session(session) assert_raise Ecto.NoResultsError, fn -> Events.get_session!(session.id) end end - - test "change_session/1 returns a session changeset" do - session = insert(:session) - assert %Ecto.Changeset{} = Events.change_session(session) - end end describe "tracks" do @@ -508,7 +491,7 @@ defmodule Mobilizon.EventsTest do event = insert(:event) track = insert(:track, event: event) session = insert(:session, track: track, event: event) - assert Events.list_sessions_for_track(track) |> Enum.map(& &1.id) == [session.id] + assert track |> Events.list_sessions_for_track() |> Enum.map(& &1.id) == [session.id] end test "get_track!/1 returns the track with given id" do @@ -548,11 +531,6 @@ defmodule Mobilizon.EventsTest do assert {:ok, %Track{}} = Events.delete_track(track) assert_raise Ecto.NoResultsError, fn -> Events.get_track!(track.id) end end - - test "change_track/1 returns a track changeset" do - track = insert(:track) - assert %Ecto.Changeset{} = Events.change_track(track) - end end describe "comments" do @@ -616,10 +594,5 @@ defmodule Mobilizon.EventsTest do assert {:ok, %Comment{}} = Events.delete_comment(comment) assert_raise Ecto.NoResultsError, fn -> Events.get_comment!(comment.id) end end - - test "change_comment/1 returns a comment changeset" do - comment = insert(:comment) - assert %Ecto.Changeset{} = Events.change_comment(comment) - end end end diff --git a/test/mobilizon/media/media_test.exs b/test/mobilizon/media/media_test.exs index 6ba645d6a..2fb5e3f21 100644 --- a/test/mobilizon/media/media_test.exs +++ b/test/mobilizon/media/media_test.exs @@ -1,9 +1,10 @@ defmodule Mobilizon.MediaTest do use Mobilizon.DataCase - alias Mobilizon.Media import Mobilizon.Factory + alias Mobilizon.{Config, Media} + describe "media" do setup [:ensure_local_uploader] alias Mobilizon.Media.Picture @@ -48,7 +49,7 @@ defmodule Mobilizon.MediaTest do %URI{path: "/media/" <> path} = URI.parse(picture.file.url) assert File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> path ) @@ -56,14 +57,9 @@ defmodule Mobilizon.MediaTest do assert_raise Ecto.NoResultsError, fn -> Media.get_picture!(picture.id) end refute File.exists?( - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> path ) end - - test "change_picture/1 returns a picture changeset" do - picture = insert(:picture) - assert %Ecto.Changeset{} = Media.change_picture(picture) - 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 ff3c0d018..8fa3f1d0b 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 @@ -49,7 +48,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 @@ -114,7 +113,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do describe "deletion" do test "it creates a delete activity and deletes the original event" do event = insert(:event) - event = Events.get_event_full_by_url!(event.url) + event = Events.get_public_event_by_url_with_preload!(event.url) {:ok, delete, _} = ActivityPub.delete(event) assert delete.data["type"] == "Delete" @@ -129,7 +128,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do maybe_federate: fn _ -> :ok end, lazy_put_activity_defaults: fn args -> args end do event = insert(:event) - event = Events.get_event_full_by_url!(event.url) + event = Events.get_public_event_by_url_with_preload!(event.url) {:ok, delete, _} = ActivityPub.delete(event, false) assert delete.data["type"] == "Delete" @@ -145,7 +144,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do test "it creates a delete activity and deletes the original comment" do comment = insert(:comment) - comment = Events.get_comment_full_from_url!(comment.url) + comment = Events.get_comment_from_url_with_preload!(comment.url) {:ok, delete, _} = ActivityPub.delete(comment) assert delete.data["type"] == "Delete" diff --git a/test/mobilizon/service/activity_pub/transmogrifier_test.exs b/test/mobilizon/service/activity_pub/transmogrifier_test.exs index cd636a3d9..19b816139 100644 --- a/test/mobilizon/service/activity_pub/transmogrifier_test.exs +++ b/test/mobilizon/service/activity_pub/transmogrifier_test.exs @@ -8,13 +8,12 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do import Mobilizon.Factory - alias Mobilizon.Activity alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Events alias Mobilizon.Events.{Comment, Event, Participant} alias Mobilizon.Service.ActivityPub - alias Mobilizon.Service.ActivityPub.Utils + alias Mobilizon.Service.ActivityPub.{Utils, Activity} alias Mobilizon.Service.ActivityPub.Transmogrifier use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney @@ -26,7 +25,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do test "it works for incoming events" do data = File.read!("test/fixtures/mobilizon-post-activity.json") |> Jason.decode!() - {:ok, %Mobilizon.Activity{data: data, local: false}, %Event{} = event} = + {:ok, %Activity{data: data, local: false}, %Event{} = event} = Transmogrifier.handle_incoming(data) assert data["id"] == @@ -116,8 +115,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do test "it works for incoming notices" do data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() - {:ok, %Mobilizon.Activity{data: data, local: false}, _} = - Transmogrifier.handle_incoming(data) + {:ok, %Activity{data: data, local: false}, _} = Transmogrifier.handle_incoming(data) assert data["id"] == "https://framapiaf.org/users/admin/statuses/99512778738411822/activity" @@ -222,8 +220,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.is_following(Actors.get_actor_by_url!(data["actor"], true), actor) end # test "it works for incoming follow requests from hubzilla" do @@ -240,7 +238,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do # assert data["actor"] == "https://hubzilla.example.org/channel/kaniini" # assert data["type"] == "Follow" # assert data["id"] == "https://hubzilla.example.org/channel/kaniini#follows/2" - # assert User.following?(User.get_by_ap_id(data["actor"]), user) + # assert User.is_following(User.get_by_ap_id(data["actor"]), user) # end # test "it works for incoming likes" do @@ -498,7 +496,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.is_following(followed, actor) end # test "it works for incoming blocks" do @@ -581,10 +579,10 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do follower = insert(:actor) followed = insert(:actor) - refute Actor.following?(follower, followed) + refute Actors.is_following(follower, followed) {:ok, follow_activity, _} = ActivityPub.follow(follower, followed) - assert Actor.following?(follower, followed) + assert Actors.is_following(follower, followed) accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") @@ -605,7 +603,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - assert Actor.following?(follower, followed) + assert Actors.is_following(follower, followed) end test "it works for incoming accepts which are referenced by IRI only" do @@ -627,7 +625,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - assert Actor.following?(follower, followed) + assert Actors.is_following(follower, followed) end test "it fails for incoming accepts which cannot be correlated" do @@ -646,7 +644,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - refute Actor.following?(follower, followed) + refute Actors.is_following(follower, followed) end test "it fails for incoming rejects which cannot be correlated" do @@ -665,7 +663,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follower} = Actors.get_actor_by_url(follower.url) - refute Actor.following?(follower, followed) + refute Actors.is_following(follower, followed) end test "it works for incoming rejects which are referenced by IRI only" do @@ -674,7 +672,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, follow_activity, _} = ActivityPub.follow(follower, followed) - assert Actor.following?(follower, followed) + assert Actors.is_following(follower, followed) reject_data = File.read!("test/fixtures/mastodon-reject-activity.json") @@ -684,7 +682,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do {:ok, %Activity{data: _}, _} = Transmogrifier.handle_incoming(reject_data) - refute Actor.following?(follower, followed) + refute Actors.is_following(follower, followed) end test "it rejects activities without a valid ID" do diff --git a/test/mobilizon_web/api/report_test.exs b/test/mobilizon_web/api/report_test.exs index e757d00cd..9ff39b988 100644 --- a/test/mobilizon_web/api/report_test.exs +++ b/test/mobilizon_web/api/report_test.exs @@ -1,17 +1,17 @@ defmodule MobilizonWeb.API.ReportTest do use Mobilizon.DataCase - alias Mobilizon.Events.Event - alias Mobilizon.Events.Comment - alias Mobilizon.Actors.Actor - alias MobilizonWeb.API.Reports - alias Mobilizon.Reports.{Report, Note} - alias Mobilizon.Activity - alias Mobilizon.Users.User - alias Mobilizon.Users - import Mobilizon.Factory + alias Mobilizon.Actors.Actor + alias Mobilizon.Events.{Comment, Event} + alias Mobilizon.Reports.{Report, Note} + alias Mobilizon.Users + alias Mobilizon.Users.User + alias Mobilizon.Service.ActivityPub.Activity + + alias MobilizonWeb.API.Reports + describe "reports" do test "creates a report on a event" do %Actor{id: reporter_id, url: reporter_url} = insert(:actor) diff --git a/test/mobilizon_web/api/search_test.exs b/test/mobilizon_web/api/search_test.exs index 6f7395f68..af26b4018 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,25 +34,25 @@ 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"}]} + build_events_by_name: fn "toto", 1, 10 -> + %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) - assert_called(Events.find_and_count_events_by_name("toto", 1, 10)) + assert_called(Events.build_events_by_name("toto", 1, 10)) end end end diff --git a/test/mobilizon_web/controllers/activity_pub_controller_test.exs b/test/mobilizon_web/controllers/activity_pub_controller_test.exs index df97d7763..c53c420e8 100644 --- a/test/mobilizon_web/controllers/activity_pub_controller_test.exs +++ b/test/mobilizon_web/controllers/activity_pub_controller_test.exs @@ -8,7 +8,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do import Mobilizon.Factory alias MobilizonWeb.ActivityPub.ActorView alias MobilizonWeb.PageView - alias Mobilizon.Actors + alias Mobilizon.{Actors, Config} alias Mobilizon.Actors.Actor alias Mobilizon.Service.ActivityPub use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney @@ -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 = @@ -290,14 +290,14 @@ defmodule MobilizonWeb.ActivityPubControllerTest do end test "with the relay disabled, it returns 404", %{conn: conn} do - Mobilizon.CommonConfig.put([:instance, :allow_relay], false) + Config.put([:instance, :allow_relay], false) conn |> get(activity_pub_path(conn, :relay)) |> json_response(404) |> assert - Mobilizon.CommonConfig.put([:instance, :allow_relay], true) + Config.put([:instance, :allow_relay], true) end end @@ -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/mobilizon_web/controllers/nodeinfo_controller_test.exs b/test/mobilizon_web/controllers/nodeinfo_controller_test.exs index 5bd4f8303..6715c5459 100644 --- a/test/mobilizon_web/controllers/nodeinfo_controller_test.exs +++ b/test/mobilizon_web/controllers/nodeinfo_controller_test.exs @@ -1,7 +1,7 @@ defmodule MobilizonWeb.NodeInfoControllerTest do use MobilizonWeb.ConnCase - @instance Application.get_env(:mobilizon, :instance) + alias Mobilizon.Config test "Get node info schemas", %{conn: conn} do conn = get(conn, node_info_path(conn, :schemas)) @@ -38,16 +38,16 @@ defmodule MobilizonWeb.NodeInfoControllerTest do assert resp == %{ "metadata" => %{ - "nodeName" => Mobilizon.CommonConfig.instance_name(), - "nodeDescription" => Mobilizon.CommonConfig.instance_description() + "nodeName" => Config.instance_name(), + "nodeDescription" => Config.instance_description() }, - "openRegistrations" => Keyword.get(@instance, :registrations_open), + "openRegistrations" => Config.instance_registrations_open?(), "protocols" => ["activitypub"], "services" => %{"inbound" => [], "outbound" => ["atom1.0"]}, "software" => %{ "name" => "Mobilizon", - "version" => Keyword.get(@instance, :version), - "repository" => Keyword.get(@instance, :repository) + "version" => Config.instance_version(), + "repository" => Config.instance_repository() }, "usage" => %{"localComments" => 0, "localPosts" => 0, "users" => %{"total" => 0}}, "version" => "2.1" diff --git a/test/mobilizon_web/media_proxy_test.exs b/test/mobilizon_web/media_proxy_test.exs index 912806c59..46ba74420 100644 --- a/test/mobilizon_web/media_proxy_test.exs +++ b/test/mobilizon_web/media_proxy_test.exs @@ -5,18 +5,22 @@ defmodule MobilizonWeb.MediaProxyTest do use ExUnit.Case + import MobilizonWeb.MediaProxy + + alias Mobilizon.Config + alias MobilizonWeb.MediaProxyController setup do - enabled = Mobilizon.CommonConfig.get([:media_proxy, :enabled]) - on_exit(fn -> Mobilizon.CommonConfig.put([:media_proxy, :enabled], enabled) end) + enabled = Config.get([:media_proxy, :enabled]) + on_exit(fn -> Config.put([:media_proxy, :enabled], enabled) end) :ok end describe "when enabled" do setup do - Mobilizon.CommonConfig.put([:media_proxy, :enabled], true) + Config.put([:media_proxy, :enabled], true) :ok end @@ -43,7 +47,7 @@ defmodule MobilizonWeb.MediaProxyTest do assert String.starts_with?( encoded, - Mobilizon.CommonConfig.get([:media_proxy, :base_url], MobilizonWeb.Endpoint.url()) + Config.get([:media_proxy, :base_url], MobilizonWeb.Endpoint.url()) ) assert String.ends_with?(encoded, "/logo.png") @@ -80,15 +84,15 @@ defmodule MobilizonWeb.MediaProxyTest do end test "validates signature" do - secret_key_base = Mobilizon.CommonConfig.get([MobilizonWeb.Endpoint, :secret_key_base]) + secret_key_base = Config.get([MobilizonWeb.Endpoint, :secret_key_base]) on_exit(fn -> - Mobilizon.CommonConfig.put([MobilizonWeb.Endpoint, :secret_key_base], secret_key_base) + Config.put([MobilizonWeb.Endpoint, :secret_key_base], secret_key_base) end) encoded = url("https://pleroma.social") - Mobilizon.CommonConfig.put( + Config.put( [MobilizonWeb.Endpoint, :secret_key_base], "00000000000000000000000000000000000000000000000" ) @@ -126,20 +130,20 @@ defmodule MobilizonWeb.MediaProxyTest do end test "uses the configured base_url" do - base_url = Mobilizon.CommonConfig.get([:media_proxy, :base_url]) + base_url = Config.get([:media_proxy, :base_url]) if base_url do on_exit(fn -> - Mobilizon.CommonConfig.put([:media_proxy, :base_url], base_url) + Config.put([:media_proxy, :base_url], base_url) end) end - Mobilizon.CommonConfig.put([:media_proxy, :base_url], "https://cache.pleroma.social") + Config.put([:media_proxy, :base_url], "https://cache.pleroma.social") url = "https://pleroma.soykaf.com/static/logo.png" encoded = url(url) - assert String.starts_with?(encoded, Mobilizon.CommonConfig.get([:media_proxy, :base_url])) + assert String.starts_with?(encoded, Config.get([:media_proxy, :base_url])) end # https://git.pleroma.social/pleroma/pleroma/issues/580 @@ -154,13 +158,13 @@ defmodule MobilizonWeb.MediaProxyTest do describe "when disabled" do setup do - enabled = Mobilizon.CommonConfig.get([:media_proxy, :enabled]) + enabled = Config.get([:media_proxy, :enabled]) if enabled do - Mobilizon.CommonConfig.put([:media_proxy, :enabled], false) + Config.put([:media_proxy, :enabled], false) on_exit(fn -> - Mobilizon.CommonConfig.put([:media_proxy, :enabled], enabled) + Config.put([:media_proxy, :enabled], enabled) :ok end) end diff --git a/test/mobilizon_web/resolvers/tag_resolver_test.exs b/test/mobilizon_web/resolvers/tag_resolver_test.exs index fe5a1f081..ee84f0ee2 100644 --- a/test/mobilizon_web/resolvers/tag_resolver_test.exs +++ b/test/mobilizon_web/resolvers/tag_resolver_test.exs @@ -39,9 +39,7 @@ defmodule MobilizonWeb.Resolvers.TagResolverTest do |> Enum.map(fn tag -> tag["slug"] end) |> MapSet.new() == [tag2, tag3] - |> Enum.map(fn - tag -> tag.slug - end) + |> Enum.map(fn tag -> tag.slug end) |> MapSet.new() end end diff --git a/test/mobilizon_web/resolvers/user_resolver_test.exs b/test/mobilizon_web/resolvers/user_resolver_test.exs index b996d7ea9..5f483e9b8 100644 --- a/test/mobilizon_web/resolvers/user_resolver_test.exs +++ b/test/mobilizon_web/resolvers/user_resolver_test.exs @@ -1,15 +1,19 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do use MobilizonWeb.ConnCase - alias Mobilizon.{Actors, Users, CommonConfig} - alias Mobilizon.Actors.Actor - alias Mobilizon.Users.User - alias Mobilizon.Users - alias MobilizonWeb.AbsintheHelpers - alias Mobilizon.Service.Users.ResetPassword + import Mobilizon.Factory import Mock + use Bamboo.Test + alias Mobilizon.{Actors, Config, Users} + alias Mobilizon.Actors.Actor + alias Mobilizon.Service.Users.ResetPassword + alias Mobilizon.Users.User + alias Mobilizon.Users + + alias MobilizonWeb.{AbsintheHelpers, Email} + @valid_actor_params %{email: "test@test.tld", password: "testest", username: "test"} @valid_single_actor_params %{preferred_username: "test2", keys: "yolo"} @@ -401,7 +405,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do end test "test create_user/3 doesn't create a user when registration is disabled", context do - with_mock CommonConfig, registrations_open?: fn -> false end do + with_mock Config, instance_registrations_open?: fn -> false end do mutation = """ mutation { createUser( @@ -503,7 +507,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) assert json_response(res, 200)["data"]["resendConfirmationEmail"] == user.email - assert_delivered_email(Mobilizon.Email.User.confirmation_email(user)) + assert_delivered_email(Email.User.confirmation_email(user)) end test "test resend_confirmation_email/3 with invalid email resends an validation email", diff --git a/test/mobilizon_web/upload_test.exs b/test/mobilizon_web/upload_test.exs index 4434d0937..de41d172f 100644 --- a/test/mobilizon_web/upload_test.exs +++ b/test/mobilizon_web/upload_test.exs @@ -4,9 +4,12 @@ # Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/test/upload_test.ex defmodule Mobilizon.UploadTest do - alias MobilizonWeb.Upload use Mobilizon.DataCase + alias Mobilizon.Config + + alias MobilizonWeb.Upload + describe "Storing a file with the Local uploader" do setup [:ensure_local_uploader] @@ -185,7 +188,7 @@ defmodule Mobilizon.UploadTest do test "delete a not existing file" do file = - Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> + Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/not_existing/definitely.jpg" refute File.exists?(file) @@ -215,6 +218,6 @@ defmodule Mobilizon.UploadTest do assert String.starts_with?(url, MobilizonWeb.Endpoint.url() <> "/media/") %URI{path: "/media/" <> path} = URI.parse(url) - {Mobilizon.CommonConfig.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> path, url} + {Config.get!([MobilizonWeb.Uploaders.Local, :uploads]) <> "/" <> path, url} end end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex index cc77f22f4..c9b207b83 100644 --- a/test/support/channel_case.ex +++ b/test/support/channel_case.ex @@ -26,10 +26,10 @@ defmodule MobilizonWeb.ChannelCase do end setup tags do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(Mobilizon.Repo) + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Mobilizon.Storage.Repo) unless tags[:async] do - Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Repo, {:shared, self()}) + Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Storage.Repo, {:shared, self()}) end :ok diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 170d6e9e6..eae09e0c7 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,10 +35,10 @@ defmodule MobilizonWeb.ConnCase do end setup tags do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(Mobilizon.Repo) + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Mobilizon.Storage.Repo) unless tags[:async] do - Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Repo, {:shared, self()}) + Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Storage.Repo, {:shared, self()}) end {:ok, conn: Phoenix.ConnTest.build_conn()} diff --git a/test/support/data_case.ex b/test/support/data_case.ex index bb22f12bf..76f4d26dc 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -14,9 +14,11 @@ defmodule Mobilizon.DataCase do use ExUnit.CaseTemplate + alias Mobilizon.Config + using do quote do - alias Mobilizon.Repo + alias Mobilizon.Storage.Repo import Ecto import Ecto.Changeset @@ -26,10 +28,10 @@ defmodule Mobilizon.DataCase do end setup tags do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(Mobilizon.Repo) + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Mobilizon.Storage.Repo) unless tags[:async] do - Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Repo, {:shared, self()}) + Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Storage.Repo, {:shared, self()}) end :ok @@ -52,16 +54,16 @@ defmodule Mobilizon.DataCase do end def ensure_local_uploader(_context) do - uploader = Mobilizon.CommonConfig.get([MobilizonWeb.Upload, :uploader]) - filters = Mobilizon.CommonConfig.get([MobilizonWeb.Upload, :filters]) + uploader = Config.get([MobilizonWeb.Upload, :uploader]) + filters = Config.get([MobilizonWeb.Upload, :filters]) unless uploader == MobilizonWeb.Uploaders.Local || filters != [] do - Mobilizon.CommonConfig.put([MobilizonWeb.Upload, :uploader], MobilizonWeb.Uploaders.Local) - Mobilizon.CommonConfig.put([MobilizonWeb.Upload, :filters], []) + Config.put([MobilizonWeb.Upload, :uploader], MobilizonWeb.Uploaders.Local) + Config.put([MobilizonWeb.Upload, :filters], []) on_exit(fn -> - Mobilizon.CommonConfig.put([MobilizonWeb.Upload, :uploader], uploader) - Mobilizon.CommonConfig.put([MobilizonWeb.Upload, :filters], filters) + Config.put([MobilizonWeb.Upload, :uploader], uploader) + Config.put([MobilizonWeb.Upload, :filters], filters) end) end diff --git a/test/support/factory.ex b/test/support/factory.ex index e91cd42a5..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.Repo + + 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..c93e8ad47 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.is_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.is_following(local_actor, target_actor) Mix.Tasks.Mobilizon.Relay.run(["unfollow", target_instance]) - refute Actor.following?(local_actor, target_actor) + refute Actors.is_following(local_actor, target_actor) end end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 43b41df65..ee5409205 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -3,4 +3,4 @@ ExUnit.configure(formatters: [ExUnit.CLIFormatter, ExUnitNotifier]) ExUnit.start() -Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Repo, :manual) +Ecto.Adapters.SQL.Sandbox.mode(Mobilizon.Storage.Repo, :manual)