aboutsummaryrefslogtreecommitdiff
path: root/lib/pleroma/user.ex
diff options
context:
space:
mode:
authordcc <dcc@logografos.com>2023-09-02 00:52:52 -0700
committerdcc <dcc@logografos.com>2023-09-02 00:52:52 -0700
commit3a4773c3c2bd0bbef244eb519b07208da9108e49 (patch)
tree973567a6f3abb37bfb0f785b1cad14ed55840ef5 /lib/pleroma/user.ex
downloadanni-3a4773c3c2bd0bbef244eb519b07208da9108e49.tar.gz
anni-3a4773c3c2bd0bbef244eb519b07208da9108e49.tar.bz2
anni-3a4773c3c2bd0bbef244eb519b07208da9108e49.zip
First
Diffstat (limited to 'lib/pleroma/user.ex')
-rw-r--r--lib/pleroma/user.ex2724
1 files changed, 2724 insertions, 0 deletions
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
new file mode 100644
index 0000000..f6e3055
--- /dev/null
+++ b/lib/pleroma/user.ex
@@ -0,0 +1,2724 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.User do
+ use Ecto.Schema
+
+ import Ecto.Changeset
+ import Ecto.Query
+ import Ecto, only: [assoc: 2]
+
+ alias Ecto.Multi
+ alias Pleroma.Activity
+ alias Pleroma.Config
+ alias Pleroma.Conversation.Participation
+ alias Pleroma.Delivery
+ alias Pleroma.EctoType.ActivityPub.ObjectValidators
+ alias Pleroma.Emoji
+ alias Pleroma.FollowingRelationship
+ alias Pleroma.Formatter
+ alias Pleroma.HTML
+ alias Pleroma.Keys
+ alias Pleroma.MFA
+ alias Pleroma.Notification
+ alias Pleroma.Object
+ alias Pleroma.Registration
+ alias Pleroma.Repo
+ alias Pleroma.User
+ alias Pleroma.UserRelationship
+ alias Pleroma.Web.ActivityPub.ActivityPub
+ alias Pleroma.Web.ActivityPub.Builder
+ alias Pleroma.Web.ActivityPub.Pipeline
+ alias Pleroma.Web.ActivityPub.Utils
+ alias Pleroma.Web.CommonAPI
+ alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
+ alias Pleroma.Web.Endpoint
+ alias Pleroma.Web.OAuth
+ alias Pleroma.Web.RelMe
+ alias Pleroma.Workers.BackgroundWorker
+
+ require Logger
+
+ @type t :: %__MODULE__{}
+ @type account_status ::
+ :active
+ | :deactivated
+ | :password_reset_pending
+ | :confirmation_pending
+ | :approval_pending
+ @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true}
+
+ # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
+ @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])?)*$/
+
+ @strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/
+ @extended_local_nickname_regex ~r/^[a-zA-Z\d_-]+$/
+
+ # AP ID user relationships (blocks, mutes etc.)
+ # Format: [rel_type: [outgoing_rel: :outgoing_rel_target, incoming_rel: :incoming_rel_source]]
+ @user_relationships_config [
+ block: [
+ blocker_blocks: :blocked_users,
+ blockee_blocks: :blocker_users
+ ],
+ mute: [
+ muter_mutes: :muted_users,
+ mutee_mutes: :muter_users
+ ],
+ reblog_mute: [
+ reblog_muter_mutes: :reblog_muted_users,
+ reblog_mutee_mutes: :reblog_muter_users
+ ],
+ notification_mute: [
+ notification_muter_mutes: :notification_muted_users,
+ notification_mutee_mutes: :notification_muter_users
+ ],
+ # Note: `inverse_subscription` relationship is inverse: subscriber acts as relationship target
+ inverse_subscription: [
+ subscribee_subscriptions: :subscriber_users,
+ subscriber_subscriptions: :subscribee_users
+ ],
+ endorsement: [
+ endorser_endorsements: :endorsed_users,
+ endorsee_endorsements: :endorser_users
+ ]
+ ]
+
+ @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
+ schema "users" do
+ field(:bio, :string, default: "")
+ field(:raw_bio, :string)
+ field(:email, :string)
+ field(:name, :string)
+ field(:nickname, :string)
+ field(:password_hash, :string)
+ field(:password, :string, virtual: true)
+ field(:password_confirmation, :string, virtual: true)
+ field(:keys, :string)
+ field(:public_key, :string)
+ field(:ap_id, :string)
+ field(:avatar, :map, default: %{})
+ field(:local, :boolean, default: true)
+ field(:follower_address, :string)
+ field(:following_address, :string)
+ field(:featured_address, :string)
+ field(:search_rank, :float, virtual: true)
+ field(:search_type, :integer, virtual: true)
+ field(:tags, {:array, :string}, default: [])
+ field(:last_refreshed_at, :naive_datetime_usec)
+ field(:last_digest_emailed_at, :naive_datetime)
+ field(:banner, :map, default: %{})
+ field(:background, :map, default: %{})
+ field(:note_count, :integer, default: 0)
+ field(:follower_count, :integer, default: 0)
+ field(:following_count, :integer, default: 0)
+ field(:is_locked, :boolean, default: false)
+ field(:is_confirmed, :boolean, default: true)
+ field(:password_reset_pending, :boolean, default: false)
+ field(:is_approved, :boolean, default: true)
+ field(:registration_reason, :string, default: nil)
+ field(:confirmation_token, :string, default: nil)
+ field(:default_scope, :string, default: "public")
+ field(:domain_blocks, {:array, :string}, default: [])
+ field(:is_active, :boolean, default: true)
+ field(:no_rich_text, :boolean, default: false)
+ field(:ap_enabled, :boolean, default: false)
+ field(:is_moderator, :boolean, default: false)
+ field(:is_admin, :boolean, default: false)
+ field(:show_role, :boolean, default: true)
+ field(:uri, ObjectValidators.Uri, default: nil)
+ field(:hide_followers_count, :boolean, default: false)
+ field(:hide_follows_count, :boolean, default: false)
+ field(:hide_followers, :boolean, default: false)
+ field(:hide_follows, :boolean, default: false)
+ field(:hide_favorites, :boolean, default: true)
+ field(:email_notifications, :map, default: %{"digest" => false})
+ field(:mascot, :map, default: nil)
+ field(:emoji, :map, default: %{})
+ field(:pleroma_settings_store, :map, default: %{})
+ field(:fields, {:array, :map}, default: [])
+ field(:raw_fields, {:array, :map}, default: [])
+ field(:is_discoverable, :boolean, default: false)
+ field(:invisible, :boolean, default: false)
+ field(:allow_following_move, :boolean, default: true)
+ field(:skip_thread_containment, :boolean, default: false)
+ field(:actor_type, :string, default: "Person")
+ field(:also_known_as, {:array, ObjectValidators.ObjectID}, default: [])
+ field(:inbox, :string)
+ field(:shared_inbox, :string)
+ field(:accepts_chat_messages, :boolean, default: nil)
+ field(:last_active_at, :naive_datetime)
+ field(:disclose_client, :boolean, default: true)
+ field(:pinned_objects, :map, default: %{})
+ field(:is_suggested, :boolean, default: false)
+ field(:last_status_at, :naive_datetime)
+ field(:birthday, :date)
+ field(:show_birthday, :boolean, default: false)
+ field(:language, :string)
+
+ embeds_one(
+ :notification_settings,
+ Pleroma.User.NotificationSetting,
+ on_replace: :update
+ )
+
+ has_many(:notifications, Notification)
+ has_many(:registrations, Registration)
+ has_many(:deliveries, Delivery)
+
+ has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id)
+ has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id)
+
+ for {relationship_type,
+ [
+ {outgoing_relation, outgoing_relation_target},
+ {incoming_relation, incoming_relation_source}
+ ]} <- @user_relationships_config do
+ # Definitions of `has_many` relations: :blocker_blocks, :muter_mutes, :reblog_muter_mutes,
+ # :notification_muter_mutes, :subscribee_subscriptions, :endorser_endorsements
+ has_many(outgoing_relation, UserRelationship,
+ foreign_key: :source_id,
+ where: [relationship_type: relationship_type]
+ )
+
+ # Definitions of `has_many` relations: :blockee_blocks, :mutee_mutes, :reblog_mutee_mutes,
+ # :notification_mutee_mutes, :subscriber_subscriptions, :endorsee_endorsements
+ has_many(incoming_relation, UserRelationship,
+ foreign_key: :target_id,
+ where: [relationship_type: relationship_type]
+ )
+
+ # Definitions of `has_many` relations: :blocked_users, :muted_users, :reblog_muted_users,
+ # :notification_muted_users, :subscriber_users, :endorsed_users
+ has_many(outgoing_relation_target, through: [outgoing_relation, :target])
+
+ # Definitions of `has_many` relations: :blocker_users, :muter_users, :reblog_muter_users,
+ # :notification_muter_users, :subscribee_users, :endorser_users
+ has_many(incoming_relation_source, through: [incoming_relation, :source])
+ end
+
+ # `:blocks` is deprecated (replaced with `blocked_users` relation)
+ field(:blocks, {:array, :string}, default: [])
+ # `:mutes` is deprecated (replaced with `muted_users` relation)
+ field(:mutes, {:array, :string}, default: [])
+ # `:muted_reblogs` is deprecated (replaced with `reblog_muted_users` relation)
+ field(:muted_reblogs, {:array, :string}, default: [])
+ # `:muted_notifications` is deprecated (replaced with `notification_muted_users` relation)
+ field(:muted_notifications, {:array, :string}, default: [])
+ # `:subscribers` is deprecated (replaced with `subscriber_users` relation)
+ field(:subscribers, {:array, :string}, default: [])
+
+ embeds_one(
+ :multi_factor_authentication_settings,
+ MFA.Settings,
+ on_replace: :delete
+ )
+
+ timestamps()
+ end
+
+ for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <-
+ @user_relationships_config do
+ # `def blocked_users_relation/2`, `def muted_users_relation/2`,
+ # `def reblog_muted_users_relation/2`, `def notification_muted_users/2`,
+ # `def subscriber_users/2`, `def endorsed_users_relation/2`
+ def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do
+ target_users_query = assoc(user, unquote(outgoing_relation_target))
+
+ if restrict_deactivated? do
+ target_users_query
+ |> User.Query.build(%{deactivated: false})
+ else
+ target_users_query
+ end
+ end
+
+ # `def blocked_users/2`, `def muted_users/2`, `def reblog_muted_users/2`,
+ # `def notification_muted_users/2`, `def subscriber_users/2`, `def endorsed_users/2`
+ def unquote(outgoing_relation_target)(user, restrict_deactivated? \\ false) do
+ __MODULE__
+ |> apply(unquote(:"#{outgoing_relation_target}_relation"), [
+ user,
+ restrict_deactivated?
+ ])
+ |> Repo.all()
+ end
+
+ # `def blocked_users_ap_ids/2`, `def muted_users_ap_ids/2`, `def reblog_muted_users_ap_ids/2`,
+ # `def notification_muted_users_ap_ids/2`, `def subscriber_users_ap_ids/2`,
+ # `def endorsed_users_ap_ids/2`
+ def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \\ false) do
+ __MODULE__
+ |> apply(unquote(:"#{outgoing_relation_target}_relation"), [
+ user,
+ restrict_deactivated?
+ ])
+ |> select([u], u.ap_id)
+ |> Repo.all()
+ end
+ end
+
+ def cached_blocked_users_ap_ids(user) do
+ @cachex.fetch!(:user_cache, "blocked_users_ap_ids:#{user.ap_id}", fn _ ->
+ blocked_users_ap_ids(user)
+ end)
+ end
+
+ def cached_muted_users_ap_ids(user) do
+ @cachex.fetch!(:user_cache, "muted_users_ap_ids:#{user.ap_id}", fn _ ->
+ muted_users_ap_ids(user)
+ end)
+ end
+
+ defdelegate following_count(user), to: FollowingRelationship
+ defdelegate following(user), to: FollowingRelationship
+ defdelegate following?(follower, followed), to: FollowingRelationship
+ defdelegate following_ap_ids(user), to: FollowingRelationship
+ defdelegate get_follow_requests(user), to: FollowingRelationship
+ defdelegate search(query, opts \\ []), to: User.Search
+
+ @doc """
+ Dumps Flake Id to SQL-compatible format (16-byte UUID).
+ E.g. "9pQtDGXuq4p3VlcJEm" -> <<0, 0, 1, 110, 179, 218, 42, 92, 213, 41, 44, 227, 95, 213, 0, 0>>
+ """
+ def binary_id(source_id) when is_binary(source_id) do
+ with {:ok, dumped_id} <- FlakeId.Ecto.CompatType.dump(source_id) do
+ dumped_id
+ else
+ _ -> source_id
+ end
+ end
+
+ def binary_id(source_ids) when is_list(source_ids) do
+ Enum.map(source_ids, &binary_id/1)
+ end
+
+ def binary_id(%User{} = user), do: binary_id(user.id)
+
+ @doc "Returns status account"
+ @spec account_status(User.t()) :: account_status()
+ def account_status(%User{is_active: false}), do: :deactivated
+ def account_status(%User{password_reset_pending: true}), do: :password_reset_pending
+ def account_status(%User{local: true, is_approved: false}), do: :approval_pending
+ def account_status(%User{local: true, is_confirmed: false}), do: :confirmation_pending
+ def account_status(%User{}), do: :active
+
+ @spec visible_for(User.t(), User.t() | nil) ::
+ :visible
+ | :invisible
+ | :restricted_unauthenticated
+ | :deactivated
+ | :confirmation_pending
+ def visible_for(user, for_user \\ nil)
+
+ def visible_for(%User{invisible: true}, _), do: :invisible
+
+ def visible_for(%User{id: user_id}, %User{id: user_id}), do: :visible
+
+ def visible_for(%User{} = user, nil) do
+ if restrict_unauthenticated?(user) do
+ :restrict_unauthenticated
+ else
+ visible_account_status(user)
+ end
+ end
+
+ def visible_for(%User{} = user, for_user) do
+ if privileged?(for_user, :users_manage_activation_state) do
+ :visible
+ else
+ visible_account_status(user)
+ end
+ end
+
+ def visible_for(_, _), do: :invisible
+
+ defp restrict_unauthenticated?(%User{local: true}) do
+ Config.restrict_unauthenticated_access?(:profiles, :local)
+ end
+
+ defp restrict_unauthenticated?(%User{local: _}) do
+ Config.restrict_unauthenticated_access?(:profiles, :remote)
+ end
+
+ defp visible_account_status(user) do
+ status = account_status(user)
+
+ if status in [:active, :password_reset_pending] do
+ :visible
+ else
+ status
+ end
+ end
+
+ @spec privileged?(User.t(), atom()) :: boolean()
+ def privileged?(%User{is_admin: false, is_moderator: false}, _), do: false
+
+ def privileged?(
+ %User{local: true, is_admin: is_admin, is_moderator: is_moderator},
+ privilege_tag
+ ),
+ do:
+ privileged_for?(privilege_tag, is_admin, :admin_privileges) or
+ privileged_for?(privilege_tag, is_moderator, :moderator_privileges)
+
+ def privileged?(_, _), do: false
+
+ defp privileged_for?(privilege_tag, true, config_role_key),
+ do: privilege_tag in Config.get([:instance, config_role_key])
+
+ defp privileged_for?(_, _, _), do: false
+
+ @spec privileges(User.t()) :: [atom()]
+ def privileges(%User{local: false}) do
+ []
+ end
+
+ def privileges(%User{is_moderator: false, is_admin: false}) do
+ []
+ end
+
+ def privileges(%User{local: true, is_moderator: true, is_admin: true}) do
+ (Config.get([:instance, :moderator_privileges]) ++ Config.get([:instance, :admin_privileges]))
+ |> Enum.uniq()
+ end
+
+ def privileges(%User{local: true, is_moderator: true, is_admin: false}) do
+ Config.get([:instance, :moderator_privileges])
+ end
+
+ def privileges(%User{local: true, is_moderator: false, is_admin: true}) do
+ Config.get([:instance, :admin_privileges])
+ end
+
+ @spec invisible?(User.t()) :: boolean()
+ def invisible?(%User{invisible: true}), do: true
+ def invisible?(_), do: false
+
+ def avatar_url(user, options \\ []) do
+ case user.avatar do
+ %{"url" => [%{"href" => href} | _]} ->
+ href
+
+ _ ->
+ unless options[:no_default] do
+ Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png")
+ end
+ end
+ end
+
+ def banner_url(user, options \\ []) do
+ case user.banner do
+ %{"url" => [%{"href" => href} | _]} -> href
+ _ -> !options[:no_default] && "#{Endpoint.url()}/images/banner.png"
+ end
+ end
+
+ # Should probably be renamed or removed
+ @spec ap_id(User.t()) :: String.t()
+ def ap_id(%User{nickname: nickname}), do: "#{Endpoint.url()}/users/#{nickname}"
+
+ @spec ap_followers(User.t()) :: String.t()
+ def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa
+ def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers"
+
+ @spec ap_following(User.t()) :: String.t()
+ def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa
+ def ap_following(%User{} = user), do: "#{ap_id(user)}/following"
+
+ @spec ap_featured_collection(User.t()) :: String.t()
+ def ap_featured_collection(%User{featured_address: fa}) when is_binary(fa), do: fa
+
+ def ap_featured_collection(%User{} = user), do: "#{ap_id(user)}/collections/featured"
+
+ defp truncate_fields_param(params) do
+ if Map.has_key?(params, :fields) do
+ Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1))
+ else
+ params
+ end
+ end
+
+ defp truncate_if_exists(params, key, max_length) do
+ if Map.has_key?(params, key) and is_binary(params[key]) do
+ {value, _chopped} = String.split_at(params[key], max_length)
+ Map.put(params, key, value)
+ else
+ params
+ end
+ end
+
+ defp fix_follower_address(%{follower_address: _, following_address: _} = params), do: params
+
+ defp fix_follower_address(%{nickname: nickname} = params),
+ do: Map.put(params, :follower_address, ap_followers(%User{nickname: nickname}))
+
+ defp fix_follower_address(params), do: params
+
+ def remote_user_changeset(struct \\ %User{local: false}, params) do
+ bio_limit = Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Config.get([:instance, :user_name_length], 100)
+
+ name =
+ case params[:name] do
+ name when is_binary(name) and byte_size(name) > 0 -> name
+ _ -> params[:nickname]
+ end
+
+ params =
+ params
+ |> Map.put(:name, name)
+ |> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now())
+ |> truncate_if_exists(:name, name_limit)
+ |> truncate_if_exists(:bio, bio_limit)
+ |> truncate_fields_param()
+ |> fix_follower_address()
+
+ struct
+ |> cast(
+ params,
+ [
+ :bio,
+ :emoji,
+ :ap_id,
+ :inbox,
+ :shared_inbox,
+ :nickname,
+ :public_key,
+ :avatar,
+ :ap_enabled,
+ :banner,
+ :is_locked,
+ :last_refreshed_at,
+ :uri,
+ :follower_address,
+ :following_address,
+ :featured_address,
+ :hide_followers,
+ :hide_follows,
+ :hide_followers_count,
+ :hide_follows_count,
+ :follower_count,
+ :fields,
+ :following_count,
+ :is_discoverable,
+ :invisible,
+ :actor_type,
+ :also_known_as,
+ :accepts_chat_messages,
+ :pinned_objects,
+ :birthday,
+ :show_birthday
+ ]
+ )
+ |> cast(params, [:name], empty_values: [])
+ |> validate_required([:ap_id])
+ |> validate_required([:name], trim: false)
+ |> unique_constraint(:nickname)
+ |> validate_format(:nickname, @email_regex)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, max: name_limit)
+ |> validate_fields(true)
+ |> validate_non_local()
+ end
+
+ defp validate_non_local(cng) do
+ local? = get_field(cng, :local)
+
+ if local? do
+ cng
+ |> add_error(:local, "User is local, can't update with this changeset.")
+ else
+ cng
+ end
+ end
+
+ def update_changeset(struct, params \\ %{}) do
+ bio_limit = Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Config.get([:instance, :user_name_length], 100)
+
+ struct
+ |> cast(
+ params,
+ [
+ :bio,
+ :raw_bio,
+ :name,
+ :emoji,
+ :avatar,
+ :public_key,
+ :inbox,
+ :shared_inbox,
+ :is_locked,
+ :no_rich_text,
+ :default_scope,
+ :banner,
+ :hide_follows,
+ :hide_followers,
+ :hide_followers_count,
+ :hide_follows_count,
+ :hide_favorites,
+ :allow_following_move,
+ :also_known_as,
+ :background,
+ :show_role,
+ :skip_thread_containment,
+ :fields,
+ :raw_fields,
+ :pleroma_settings_store,
+ :is_discoverable,
+ :actor_type,
+ :accepts_chat_messages,
+ :disclose_client,
+ :birthday,
+ :show_birthday
+ ]
+ )
+ |> validate_min_age()
+ |> unique_constraint(:nickname)
+ |> validate_format(:nickname, local_nickname_regex())
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, min: 1, max: name_limit)
+ |> validate_inclusion(:actor_type, ["Person", "Service"])
+ |> put_fields()
+ |> put_emoji()
+ |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)})
+ |> put_change_if_present(:avatar, &put_upload(&1, :avatar))
+ |> put_change_if_present(:banner, &put_upload(&1, :banner))
+ |> put_change_if_present(:background, &put_upload(&1, :background))
+ |> put_change_if_present(
+ :pleroma_settings_store,
+ &{:ok, Map.merge(struct.pleroma_settings_store, &1)}
+ )
+ |> validate_fields(false)
+ end
+
+ defp put_fields(changeset) do
+ if raw_fields = get_change(changeset, :raw_fields) do
+ raw_fields =
+ raw_fields
+ |> Enum.filter(fn %{"name" => n} -> n != "" end)
+
+ fields =
+ raw_fields
+ |> Enum.map(fn f -> Map.update!(f, "value", &parse_fields(&1)) end)
+
+ changeset
+ |> put_change(:raw_fields, raw_fields)
+ |> put_change(:fields, fields)
+ else
+ changeset
+ end
+ end
+
+ defp parse_fields(value) do
+ value
+ |> Formatter.linkify(mentions_format: :full)
+ |> elem(0)
+ end
+
+ defp put_emoji(changeset) do
+ emojified_fields = [:bio, :name, :raw_fields]
+
+ if Enum.any?(changeset.changes, fn {k, _} -> k in emojified_fields end) do
+ bio = Emoji.Formatter.get_emoji_map(get_field(changeset, :bio))
+ name = Emoji.Formatter.get_emoji_map(get_field(changeset, :name))
+
+ emoji = Map.merge(bio, name)
+
+ emoji =
+ changeset
+ |> get_field(:raw_fields)
+ |> Enum.reduce(emoji, fn x, acc ->
+ Map.merge(acc, Emoji.Formatter.get_emoji_map(x["name"] <> x["value"]))
+ end)
+
+ put_change(changeset, :emoji, emoji)
+ else
+ changeset
+ end
+ end
+
+ defp put_change_if_present(changeset, map_field, value_function) do
+ with {:ok, value} <- fetch_change(changeset, map_field),
+ {:ok, new_value} <- value_function.(value) do
+ put_change(changeset, map_field, new_value)
+ else
+ {:error, :file_too_large} ->
+ Ecto.Changeset.validate_change(changeset, map_field, fn map_field, _value ->
+ [{map_field, "file is too large"}]
+ end)
+
+ _ ->
+ changeset
+ end
+ end
+
+ defp put_upload(value, type) do
+ with %Plug.Upload{} <- value,
+ {:ok, object} <- ActivityPub.upload(value, type: type) do
+ {:ok, object.data}
+ end
+ end
+
+ def update_as_admin_changeset(struct, params) do
+ struct
+ |> update_changeset(params)
+ |> cast(params, [:email])
+ |> delete_change(:also_known_as)
+ |> unique_constraint(:email)
+ |> validate_format(:email, @email_regex)
+ |> validate_inclusion(:actor_type, ["Person", "Service"])
+ end
+
+ @spec update_as_admin(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()}
+ def update_as_admin(user, params) do
+ params = Map.put(params, "password_confirmation", params["password"])
+ changeset = update_as_admin_changeset(user, params)
+
+ if params["password"] do
+ reset_password(user, changeset, params)
+ else
+ User.update_and_set_cache(changeset)
+ end
+ end
+
+ def password_update_changeset(struct, params) do
+ struct
+ |> cast(params, [:password, :password_confirmation])
+ |> validate_required([:password, :password_confirmation])
+ |> validate_confirmation(:password)
+ |> put_password_hash()
+ |> put_change(:password_reset_pending, false)
+ end
+
+ @spec reset_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()}
+ def reset_password(%User{} = user, params) do
+ reset_password(user, user, params)
+ end
+
+ def reset_password(%User{id: user_id} = user, struct, params) do
+ multi =
+ Multi.new()
+ |> Multi.update(:user, password_update_changeset(struct, params))
+ |> Multi.delete_all(:tokens, OAuth.Token.Query.get_by_user(user_id))
+ |> Multi.delete_all(:auth, OAuth.Authorization.delete_by_user_query(user))
+
+ case Repo.transaction(multi) do
+ {:ok, %{user: user} = _} -> set_cache(user)
+ {:error, _, changeset, _} -> {:error, changeset}
+ end
+ end
+
+ def update_password_reset_pending(user, value) do
+ user
+ |> change()
+ |> put_change(:password_reset_pending, value)
+ |> update_and_set_cache()
+ end
+
+ def force_password_reset_async(user) do
+ BackgroundWorker.enqueue("force_password_reset", %{"user_id" => user.id})
+ end
+
+ @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+ def force_password_reset(user), do: update_password_reset_pending(user, true)
+
+ # Used to auto-register LDAP accounts which won't have a password hash stored locally
+ def register_changeset_ldap(struct, params = %{password: password})
+ when is_nil(password) do
+ params = Map.put_new(params, :accepts_chat_messages, true)
+
+ params =
+ if Map.has_key?(params, :email) do
+ Map.put_new(params, :email, params[:email])
+ else
+ params
+ end
+
+ struct
+ |> cast(params, [
+ :name,
+ :nickname,
+ :email,
+ :accepts_chat_messages
+ ])
+ |> validate_required([:name, :nickname])
+ |> unique_constraint(:nickname)
+ |> validate_not_restricted_nickname(:nickname)
+ |> validate_format(:nickname, local_nickname_regex())
+ |> put_ap_id()
+ |> unique_constraint(:ap_id)
+ |> put_following_and_follower_and_featured_address()
+ |> put_private_key()
+ end
+
+ def register_changeset(struct, params \\ %{}, opts \\ []) do
+ bio_limit = Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Config.get([:instance, :user_name_length], 100)
+ reason_limit = Config.get([:instance, :registration_reason_length], 500)
+ params = Map.put_new(params, :accepts_chat_messages, true)
+
+ confirmed? =
+ if is_nil(opts[:confirmed]) do
+ !Config.get([:instance, :account_activation_required])
+ else
+ opts[:confirmed]
+ end
+
+ approved? =
+ if is_nil(opts[:approved]) do
+ !Config.get([:instance, :account_approval_required])
+ else
+ opts[:approved]
+ end
+
+ struct
+ |> confirmation_changeset(set_confirmation: confirmed?)
+ |> approval_changeset(set_approval: approved?)
+ |> cast(params, [
+ :bio,
+ :raw_bio,
+ :email,
+ :name,
+ :nickname,
+ :password,
+ :password_confirmation,
+ :emoji,
+ :accepts_chat_messages,
+ :registration_reason,
+ :birthday,
+ :language
+ ])
+ |> validate_required([:name, :nickname, :password, :password_confirmation])
+ |> validate_confirmation(:password)
+ |> unique_constraint(:email)
+ |> validate_format(:email, @email_regex)
+ |> validate_email_not_in_blacklisted_domain(:email)
+ |> unique_constraint(:nickname)
+ |> validate_not_restricted_nickname(:nickname)
+ |> validate_format(:nickname, local_nickname_regex())
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, min: 1, max: name_limit)
+ |> validate_length(:registration_reason, max: reason_limit)
+ |> maybe_validate_required_email(opts[:external])
+ |> maybe_validate_required_birthday
+ |> validate_min_age()
+ |> put_password_hash
+ |> put_ap_id()
+ |> unique_constraint(:ap_id)
+ |> put_following_and_follower_and_featured_address()
+ |> put_private_key()
+ end
+
+ def validate_not_restricted_nickname(changeset, field) do
+ validate_change(changeset, field, fn _, value ->
+ valid? =
+ Config.get([User, :restricted_nicknames])
+ |> Enum.all?(fn restricted_nickname ->
+ String.downcase(value) != String.downcase(restricted_nickname)
+ end)
+
+ if valid?, do: [], else: [nickname: "Invalid nickname"]
+ end)
+ end
+
+ def validate_email_not_in_blacklisted_domain(changeset, field) do
+ validate_change(changeset, field, fn _, value ->
+ valid? =
+ Config.get([User, :email_blacklist])
+ |> Enum.all?(fn blacklisted_domain ->
+ blacklisted_domain_downcase = String.downcase(blacklisted_domain)
+
+ !String.ends_with?(String.downcase(value), [
+ "@" <> blacklisted_domain_downcase,
+ "." <> blacklisted_domain_downcase
+ ])
+ end)
+
+ if valid?, do: [], else: [email: "Invalid email"]
+ end)
+ end
+
+ def maybe_validate_required_email(changeset, true), do: changeset
+
+ def maybe_validate_required_email(changeset, _) do
+ if Config.get([:instance, :account_activation_required]) do
+ validate_required(changeset, [:email])
+ else
+ changeset
+ end
+ end
+
+ defp maybe_validate_required_birthday(changeset) do
+ if Config.get([:instance, :birthday_required]) do
+ validate_required(changeset, [:birthday])
+ else
+ changeset
+ end
+ end
+
+ defp validate_min_age(changeset) do
+ changeset
+ |> validate_change(:birthday, fn :birthday, birthday ->
+ valid? =
+ Date.utc_today()
+ |> Date.diff(birthday) >=
+ Config.get([:instance, :birthday_min_age])
+
+ if valid?, do: [], else: [birthday: "Invalid age"]
+ end)
+ end
+
+ defp put_ap_id(changeset) do
+ ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)})
+ put_change(changeset, :ap_id, ap_id)
+ end
+
+ defp put_following_and_follower_and_featured_address(changeset) do
+ user = %User{nickname: get_field(changeset, :nickname)}
+ followers = ap_followers(user)
+ following = ap_following(user)
+ featured = ap_featured_collection(user)
+
+ changeset
+ |> put_change(:follower_address, followers)
+ |> put_change(:following_address, following)
+ |> put_change(:featured_address, featured)
+ end
+
+ defp put_private_key(changeset) do
+ {:ok, pem} = Keys.generate_rsa_pem()
+ put_change(changeset, :keys, pem)
+ end
+
+ defp autofollow_users(user) do
+ candidates = Config.get([:instance, :autofollowed_nicknames])
+
+ autofollowed_users =
+ User.Query.build(%{nickname: candidates, local: true, is_active: true})
+ |> Repo.all()
+
+ follow_all(user, autofollowed_users)
+ end
+
+ defp autofollowing_users(user) do
+ candidates = Config.get([:instance, :autofollowing_nicknames])
+
+ User.Query.build(%{nickname: candidates, local: true, deactivated: false})
+ |> Repo.all()
+ |> Enum.each(&follow(&1, user, :follow_accept))
+
+ {:ok, :success}
+ end
+
+ @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)"
+ def register(%Ecto.Changeset{} = changeset) do
+ with {:ok, user} <- Repo.insert(changeset) do
+ post_register_action(user)
+ end
+ end
+
+ def post_register_action(%User{is_confirmed: false} = user) do
+ with {:ok, _} <- maybe_send_confirmation_email(user) do
+ {:ok, user}
+ end
+ end
+
+ def post_register_action(%User{is_approved: false} = user) do
+ with {:ok, _} <- send_user_approval_email(user),
+ {:ok, _} <- send_admin_approval_emails(user) do
+ {:ok, user}
+ end
+ end
+
+ def post_register_action(%User{is_approved: true, is_confirmed: true} = user) do
+ with {:ok, user} <- autofollow_users(user),
+ {:ok, _} <- autofollowing_users(user),
+ {:ok, user} <- set_cache(user),
+ {:ok, _} <- maybe_send_registration_email(user),
+ {:ok, _} <- maybe_send_welcome_email(user),
+ {:ok, _} <- maybe_send_welcome_message(user),
+ {:ok, _} <- maybe_send_welcome_chat_message(user) do
+ {:ok, user}
+ end
+ end
+
+ defp send_user_approval_email(%User{email: email} = user) when is_binary(email) do
+ user
+ |> Pleroma.Emails.UserEmail.approval_pending_email()
+ |> Pleroma.Emails.Mailer.deliver_async()
+
+ {:ok, :enqueued}
+ end
+
+ defp send_user_approval_email(_user) do
+ {:ok, :skipped}
+ end
+
+ defp send_admin_approval_emails(user) do
+ all_superusers()
+ |> Enum.filter(fn user -> not is_nil(user.email) end)
+ |> Enum.each(fn superuser ->
+ superuser
+ |> Pleroma.Emails.AdminEmail.new_unapproved_registration(user)
+ |> Pleroma.Emails.Mailer.deliver_async()
+ end)
+
+ {:ok, :enqueued}
+ end
+
+ defp maybe_send_welcome_message(user) do
+ if User.WelcomeMessage.enabled?() do
+ User.WelcomeMessage.post_message(user)
+ {:ok, :enqueued}
+ else
+ {:ok, :noop}
+ end
+ end
+
+ defp maybe_send_welcome_chat_message(user) do
+ if User.WelcomeChatMessage.enabled?() do
+ User.WelcomeChatMessage.post_message(user)
+ {:ok, :enqueued}
+ else
+ {:ok, :noop}
+ end
+ end
+
+ defp maybe_send_welcome_email(%User{email: email} = user) when is_binary(email) do
+ if User.WelcomeEmail.enabled?() do
+ User.WelcomeEmail.send_email(user)
+ {:ok, :enqueued}
+ else
+ {:ok, :noop}
+ end
+ end
+
+ defp maybe_send_welcome_email(_), do: {:ok, :noop}
+
+ @spec maybe_send_confirmation_email(User.t()) :: {:ok, :enqueued | :noop}
+ def maybe_send_confirmation_email(%User{is_confirmed: false, email: email} = user)
+ when is_binary(email) do
+ if Config.get([:instance, :account_activation_required]) do
+ send_confirmation_email(user)
+ {:ok, :enqueued}
+ else
+ {:ok, :noop}
+ end
+ end
+
+ def maybe_send_confirmation_email(_), do: {:ok, :noop}
+
+ @spec send_confirmation_email(Uset.t()) :: User.t()
+ def send_confirmation_email(%User{} = user) do
+ user
+ |> Pleroma.Emails.UserEmail.account_confirmation_email()
+ |> Pleroma.Emails.Mailer.deliver_async()
+
+ user
+ end
+
+ @spec maybe_send_registration_email(User.t()) :: {:ok, :enqueued | :noop}
+ defp maybe_send_registration_email(%User{email: email} = user) when is_binary(email) do
+ with false <- User.WelcomeEmail.enabled?(),
+ false <- Config.get([:instance, :account_activation_required], false),
+ false <- Config.get([:instance, :account_approval_required], false) do
+ user
+ |> Pleroma.Emails.UserEmail.successful_registration_email()
+ |> Pleroma.Emails.Mailer.deliver_async()
+
+ {:ok, :enqueued}
+ else
+ _ ->
+ {:ok, :noop}
+ end
+ end
+
+ defp maybe_send_registration_email(_), do: {:ok, :noop}
+
+ def needs_update?(%User{local: true}), do: false
+
+ def needs_update?(%User{local: false, last_refreshed_at: nil}), do: true
+
+ def needs_update?(%User{local: false} = user) do
+ NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= 86_400
+ end
+
+ def needs_update?(_), do: true
+
+ @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()}
+
+ # "Locked" (self-locked) users demand explicit authorization of follow requests
+ def maybe_direct_follow(%User{} = follower, %User{local: true, is_locked: true} = followed) do
+ follow(follower, followed, :follow_pending)
+ end
+
+ def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do
+ follow(follower, followed)
+ end
+
+ def maybe_direct_follow(%User{} = follower, %User{} = followed) do
+ if not ap_enabled?(followed) do
+ follow(follower, followed)
+ else
+ {:ok, follower, followed}
+ end
+ end
+
+ @doc "A mass follow for local users. Respects blocks in both directions but does not create activities."
+ @spec follow_all(User.t(), list(User.t())) :: {atom(), User.t()}
+ def follow_all(follower, followeds) do
+ followeds
+ |> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end)
+ |> Enum.each(&follow(follower, &1, :follow_accept))
+
+ set_cache(follower)
+ end
+
+ def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do
+ deny_follow_blocked = Config.get([:user, :deny_follow_blocked])
+
+ cond do
+ not followed.is_active ->
+ {:error, "Could not follow user: #{followed.nickname} is deactivated."}
+
+ deny_follow_blocked and blocks?(followed, follower) ->
+ {:error, "Could not follow user: #{followed.nickname} blocked you."}
+
+ true ->
+ FollowingRelationship.follow(follower, followed, state)
+ end
+ end
+
+ def unfollow(%User{ap_id: ap_id}, %User{ap_id: ap_id}) do
+ {:error, "Not subscribed!"}
+ end
+
+ @spec unfollow(User.t(), User.t()) :: {:ok, User.t(), Activity.t()} | {:error, String.t()}
+ def unfollow(%User{} = follower, %User{} = followed) do
+ case do_unfollow(follower, followed) do
+ {:ok, follower, followed} ->
+ {:ok, follower, Utils.fetch_latest_follow(follower, followed)}
+
+ error ->
+ error
+ end
+ end
+
+ @spec do_unfollow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, String.t()}
+ defp do_unfollow(%User{} = follower, %User{} = followed) do
+ case get_follow_state(follower, followed) do
+ state when state in [:follow_pending, :follow_accept] ->
+ FollowingRelationship.unfollow(follower, followed)
+
+ nil ->
+ {:error, "Not subscribed!"}
+ end
+ end
+
+ @doc "Returns follow state as Pleroma.FollowingRelationship.State value"
+ def get_follow_state(%User{} = follower, %User{} = following) do
+ following_relationship = FollowingRelationship.get(follower, following)
+ get_follow_state(follower, following, following_relationship)
+ end
+
+ def get_follow_state(
+ %User{} = follower,
+ %User{} = following,
+ following_relationship
+ ) do
+ case {following_relationship, following.local} do
+ {nil, false} ->
+ case Utils.fetch_latest_follow(follower, following) do
+ %Activity{data: %{"state" => state}} when state in ["pending", "accept"] ->
+ FollowingRelationship.state_to_enum(state)
+
+ _ ->
+ nil
+ end
+
+ {%{state: state}, _} ->
+ state
+
+ {nil, _} ->
+ nil
+ end
+ end
+
+ def locked?(%User{} = user) do
+ user.is_locked || false
+ end
+
+ def get_by_id(id) do
+ Repo.get_by(User, id: id)
+ end
+
+ def get_by_ap_id(ap_id) do
+ Repo.get_by(User, ap_id: ap_id)
+ end
+
+ def get_by_uri(uri) do
+ Repo.get_by(User, uri: uri)
+ end
+
+ def get_all_by_ap_id(ap_ids) do
+ from(u in __MODULE__,
+ where: u.ap_id in ^ap_ids
+ )
+ |> Repo.all()
+ end
+
+ def get_all_by_ids(ids) do
+ from(u in __MODULE__, where: u.id in ^ids)
+ |> Repo.all()
+ end
+
+ # This is mostly an SPC migration fix. This guesses the user nickname by taking the last part
+ # of the ap_id and the domain and tries to get that user
+ def get_by_guessed_nickname(ap_id) do
+ domain = URI.parse(ap_id).host
+ name = List.last(String.split(ap_id, "/"))
+ nickname = "#{name}@#{domain}"
+
+ get_cached_by_nickname(nickname)
+ end
+
+ def set_cache({:ok, user}), do: set_cache(user)
+ def set_cache({:error, err}), do: {:error, err}
+
+ def set_cache(%User{} = user) do
+ @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user)
+ @cachex.put(:user_cache, "nickname:#{user.nickname}", user)
+ @cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user))
+ {:ok, user}
+ end
+
+ def update_and_set_cache(struct, params) do
+ struct
+ |> update_changeset(params)
+ |> update_and_set_cache()
+ end
+
+ def update_and_set_cache(changeset) do
+ with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
+ set_cache(user)
+ end
+ end
+
+ def get_user_friends_ap_ids(user) do
+ from(u in User.get_friends_query(user), select: u.ap_id)
+ |> Repo.all()
+ end
+
+ @spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()]
+ def get_cached_user_friends_ap_ids(user) do
+ @cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ ->
+ get_user_friends_ap_ids(user)
+ end)
+ end
+
+ def invalidate_cache(user) do
+ @cachex.del(:user_cache, "ap_id:#{user.ap_id}")
+ @cachex.del(:user_cache, "nickname:#{user.nickname}")
+ @cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}")
+ @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
+ @cachex.del(:user_cache, "muted_users_ap_ids:#{user.ap_id}")
+ end
+
+ @spec get_cached_by_ap_id(String.t()) :: User.t() | nil
+ def get_cached_by_ap_id(ap_id) do
+ key = "ap_id:#{ap_id}"
+
+ with {:ok, nil} <- @cachex.get(:user_cache, key),
+ user when not is_nil(user) <- get_by_ap_id(ap_id),
+ {:ok, true} <- @cachex.put(:user_cache, key, user) do
+ user
+ else
+ {:ok, user} -> user
+ nil -> nil
+ end
+ end
+
+ def get_cached_by_id(id) do
+ key = "id:#{id}"
+
+ ap_id =
+ @cachex.fetch!(:user_cache, key, fn _ ->
+ user = get_by_id(id)
+
+ if user do
+ @cachex.put(:user_cache, "ap_id:#{user.ap_id}", user)
+ {:commit, user.ap_id}
+ else
+ {:ignore, ""}
+ end
+ end)
+
+ get_cached_by_ap_id(ap_id)
+ end
+
+ def get_cached_by_nickname(nickname) do
+ key = "nickname:#{nickname}"
+
+ @cachex.fetch!(:user_cache, key, fn _ ->
+ case get_or_fetch_by_nickname(nickname) do
+ {:ok, user} -> {:commit, user}
+ {:error, _error} -> {:ignore, nil}
+ end
+ end)
+ end
+
+ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do
+ restrict_to_local = Config.get([:instance, :limit_to_local_content])
+
+ cond do
+ is_integer(nickname_or_id) or FlakeId.flake_id?(nickname_or_id) ->
+ get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id)
+
+ restrict_to_local == false or not String.contains?(nickname_or_id, "@") ->
+ get_cached_by_nickname(nickname_or_id)
+
+ restrict_to_local == :unauthenticated and match?(%User{}, opts[:for]) ->
+ get_cached_by_nickname(nickname_or_id)
+
+ true ->
+ nil
+ end
+ end
+
+ @spec get_by_nickname(String.t()) :: User.t() | nil
+ def get_by_nickname(nickname) do
+ Repo.get_by(User, nickname: nickname) ||
+ if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
+ Repo.get_by(User, nickname: local_nickname(nickname))
+ end
+ end
+
+ def get_by_email(email), do: Repo.get_by(User, email: email)
+
+ def get_by_nickname_or_email(nickname_or_email) do
+ get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email)
+ end
+
+ def fetch_by_nickname(nickname), do: ActivityPub.make_user_from_nickname(nickname)
+
+ def get_or_fetch_by_nickname(nickname) do
+ with %User{} = user <- get_by_nickname(nickname) do
+ {:ok, user}
+ else
+ _e ->
+ with [_nick, _domain] <- String.split(nickname, "@"),
+ {:ok, user} <- fetch_by_nickname(nickname) do
+ {:ok, user}
+ else
+ _e -> {:error, "not found " <> nickname}
+ end
+ end
+ end
+
+ @spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t()
+ def get_followers_query(%User{} = user, nil) do
+ User.Query.build(%{followers: user, is_active: true})
+ end
+
+ def get_followers_query(%User{} = user, page) do
+ user
+ |> get_followers_query(nil)
+ |> User.Query.paginate(page, 20)
+ end
+
+ @spec get_followers_query(User.t()) :: Ecto.Query.t()
+ def get_followers_query(%User{} = user), do: get_followers_query(user, nil)
+
+ @spec get_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())}
+ def get_followers(%User{} = user, page \\ nil) do
+ user
+ |> get_followers_query(page)
+ |> Repo.all()
+ end
+
+ @spec get_external_followers(User.t(), pos_integer() | nil) :: {:ok, list(User.t())}
+ def get_external_followers(%User{} = user, page \\ nil) do
+ user
+ |> get_followers_query(page)
+ |> User.Query.build(%{external: true})
+ |> Repo.all()
+ end
+
+ def get_followers_ids(%User{} = user, page \\ nil) do
+ user
+ |> get_followers_query(page)
+ |> select([u], u.id)
+ |> Repo.all()
+ end
+
+ @spec get_friends_query(User.t(), pos_integer() | nil) :: Ecto.Query.t()
+ def get_friends_query(%User{} = user, nil) do
+ User.Query.build(%{friends: user, deactivated: false})
+ end
+
+ def get_friends_query(%User{} = user, page) do
+ user
+ |> get_friends_query(nil)
+ |> User.Query.paginate(page, 20)
+ end
+
+ @spec get_friends_query(User.t()) :: Ecto.Query.t()
+ def get_friends_query(%User{} = user), do: get_friends_query(user, nil)
+
+ def get_friends(%User{} = user, page \\ nil) do
+ user
+ |> get_friends_query(page)
+ |> Repo.all()
+ end
+
+ def get_friends_ap_ids(%User{} = user) do
+ user
+ |> get_friends_query(nil)
+ |> select([u], u.ap_id)
+ |> Repo.all()
+ end
+
+ def get_friends_ids(%User{} = user, page \\ nil) do
+ user
+ |> get_friends_query(page)
+ |> select([u], u.id)
+ |> Repo.all()
+ end
+
+ def increase_note_count(%User{} = user) do
+ User
+ |> where(id: ^user.id)
+ |> update([u], inc: [note_count: 1])
+ |> select([u], u)
+ |> Repo.update_all([])
+ |> case do
+ {1, [user]} -> set_cache(user)
+ _ -> {:error, user}
+ end
+ end
+
+ def decrease_note_count(%User{} = user) do
+ User
+ |> where(id: ^user.id)
+ |> update([u],
+ set: [
+ note_count: fragment("greatest(0, note_count - 1)")
+ ]
+ )
+ |> select([u], u)
+ |> Repo.update_all([])
+ |> case do
+ {1, [user]} -> set_cache(user)
+ _ -> {:error, user}
+ end
+ end
+
+ def update_note_count(%User{} = user, note_count \\ nil) do
+ note_count =
+ note_count ||
+ from(
+ a in Object,
+ where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data),
+ select: count(a.id)
+ )
+ |> Repo.one()
+
+ user
+ |> cast(%{note_count: note_count}, [:note_count])
+ |> update_and_set_cache()
+ end
+
+ @spec maybe_fetch_follow_information(User.t()) :: User.t()
+ def maybe_fetch_follow_information(user) do
+ with {:ok, user} <- fetch_follow_information(user) do
+ user
+ else
+ e ->
+ Logger.error("Follower/Following counter update for #{user.ap_id} failed.\n#{inspect(e)}")
+
+ user
+ end
+ end
+
+ def fetch_follow_information(user) do
+ with {:ok, info} <- ActivityPub.fetch_follow_information_for_user(user) do
+ user
+ |> follow_information_changeset(info)
+ |> update_and_set_cache()
+ end
+ end
+
+ defp follow_information_changeset(user, params) do
+ user
+ |> cast(params, [
+ :hide_followers,
+ :hide_follows,
+ :follower_count,
+ :following_count,
+ :hide_followers_count,
+ :hide_follows_count
+ ])
+ end
+
+ @spec update_follower_count(User.t()) :: {:ok, User.t()}
+ def update_follower_count(%User{} = user) do
+ if user.local or !Config.get([:instance, :external_user_synchronization]) do
+ follower_count = FollowingRelationship.follower_count(user)
+
+ user
+ |> follow_information_changeset(%{follower_count: follower_count})
+ |> update_and_set_cache
+ else
+ {:ok, maybe_fetch_follow_information(user)}
+ end
+ end
+
+ @spec update_following_count(User.t()) :: {:ok, User.t()}
+ def update_following_count(%User{local: false} = user) do
+ if Config.get([:instance, :external_user_synchronization]) do
+ {:ok, maybe_fetch_follow_information(user)}
+ else
+ {:ok, user}
+ end
+ end
+
+ def update_following_count(%User{local: true} = user) do
+ following_count = FollowingRelationship.following_count(user)
+
+ user
+ |> follow_information_changeset(%{following_count: following_count})
+ |> update_and_set_cache()
+ end
+
+ @spec get_users_from_set([String.t()], keyword()) :: [User.t()]
+ def get_users_from_set(ap_ids, opts \\ []) do
+ local_only = Keyword.get(opts, :local_only, true)
+ criteria = %{ap_id: ap_ids, is_active: true}
+ criteria = if local_only, do: Map.put(criteria, :local, true), else: criteria
+
+ User.Query.build(criteria)
+ |> Repo.all()
+ end
+
+ @spec get_recipients_from_activity(Activity.t()) :: [User.t()]
+ def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do
+ to = [actor | to]
+
+ query = User.Query.build(%{recipients_from_activity: to, local: true, is_active: true})
+
+ query
+ |> Repo.all()
+ end
+
+ @spec mute(User.t(), User.t(), map()) ::
+ {:ok, list(UserRelationship.t())} | {:error, String.t()}
+ def mute(%User{} = muter, %User{} = mutee, params \\ %{}) do
+ notifications? = Map.get(params, :notifications, true)
+ duration = Map.get(params, :duration, 0)
+
+ expires_at =
+ if duration > 0 do
+ DateTime.utc_now()
+ |> DateTime.add(duration)
+ else
+ nil
+ end
+
+ with {:ok, user_mute} <- UserRelationship.create_mute(muter, mutee, expires_at),
+ {:ok, user_notification_mute} <-
+ (notifications? &&
+ UserRelationship.create_notification_mute(
+ muter,
+ mutee,
+ expires_at
+ )) ||
+ {:ok, nil} do
+ if duration > 0 do
+ Pleroma.Workers.MuteExpireWorker.enqueue(
+ "unmute_user",
+ %{"muter_id" => muter.id, "mutee_id" => mutee.id},
+ scheduled_at: expires_at
+ )
+ end
+
+ @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}")
+
+ {:ok, Enum.filter([user_mute, user_notification_mute], & &1)}
+ end
+ end
+
+ def unmute(%User{} = muter, %User{} = mutee) do
+ with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee),
+ {:ok, user_notification_mute} <-
+ UserRelationship.delete_notification_mute(muter, mutee) do
+ @cachex.del(:user_cache, "muted_users_ap_ids:#{muter.ap_id}")
+ {:ok, [user_mute, user_notification_mute]}
+ end
+ end
+
+ def unmute(muter_id, mutee_id) do
+ with {:muter, %User{} = muter} <- {:muter, User.get_by_id(muter_id)},
+ {:mutee, %User{} = mutee} <- {:mutee, User.get_by_id(mutee_id)} do
+ unmute(muter, mutee)
+ else
+ {who, result} = error ->
+ Logger.warn(
+ "User.unmute/2 failed. #{who}: #{result}, muter_id: #{muter_id}, mutee_id: #{mutee_id}"
+ )
+
+ {:error, error}
+ end
+ end
+
+ def subscribe(%User{} = subscriber, %User{} = target) do
+ deny_follow_blocked = Config.get([:user, :deny_follow_blocked])
+
+ if blocks?(target, subscriber) and deny_follow_blocked do
+ {:error, "Could not subscribe: #{target.nickname} is blocking you"}
+ else
+ # Note: the relationship is inverse: subscriber acts as relationship target
+ UserRelationship.create_inverse_subscription(target, subscriber)
+ end
+ end
+
+ def subscribe(%User{} = subscriber, %{ap_id: ap_id}) do
+ with %User{} = subscribee <- get_cached_by_ap_id(ap_id) do
+ subscribe(subscriber, subscribee)
+ end
+ end
+
+ def unsubscribe(%User{} = unsubscriber, %User{} = target) do
+ # Note: the relationship is inverse: subscriber acts as relationship target
+ UserRelationship.delete_inverse_subscription(target, unsubscriber)
+ end
+
+ def unsubscribe(%User{} = unsubscriber, %{ap_id: ap_id}) do
+ with %User{} = user <- get_cached_by_ap_id(ap_id) do
+ unsubscribe(unsubscriber, user)
+ end
+ end
+
+ def block(%User{} = blocker, %User{} = blocked) do
+ # sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213)
+ blocker =
+ if following?(blocker, blocked) do
+ {:ok, blocker, _} = unfollow(blocker, blocked)
+ blocker
+ else
+ blocker
+ end
+
+ # clear any requested follows from both sides as well
+ blocked =
+ case CommonAPI.reject_follow_request(blocked, blocker) do
+ {:ok, %User{} = updated_blocked} -> updated_blocked
+ nil -> blocked
+ end
+
+ blocker =
+ case CommonAPI.reject_follow_request(blocker, blocked) do
+ {:ok, %User{} = updated_blocker} -> updated_blocker
+ nil -> blocker
+ end
+
+ unsubscribe(blocked, blocker)
+
+ unfollowing_blocked = Config.get([:activitypub, :unfollow_blocked], true)
+ if unfollowing_blocked && following?(blocked, blocker), do: unfollow(blocked, blocker)
+
+ {:ok, blocker} = update_follower_count(blocker)
+ {:ok, blocker, _} = Participation.mark_all_as_read(blocker, blocked)
+ add_to_block(blocker, blocked)
+ end
+
+ # helper to handle the block given only an actor's AP id
+ def block(%User{} = blocker, %{ap_id: ap_id}) do
+ block(blocker, get_cached_by_ap_id(ap_id))
+ end
+
+ def unblock(%User{} = blocker, %User{} = blocked) do
+ remove_from_block(blocker, blocked)
+ end
+
+ # helper to handle the block given only an actor's AP id
+ def unblock(%User{} = blocker, %{ap_id: ap_id}) do
+ unblock(blocker, get_cached_by_ap_id(ap_id))
+ end
+
+ def endorse(%User{} = endorser, %User{} = target) do
+ with max_endorsed_users <- Pleroma.Config.get([:instance, :max_endorsed_users], 0),
+ endorsed_users <-
+ User.endorsed_users_relation(endorser)
+ |> Repo.aggregate(:count, :id) do
+ cond do
+ endorsed_users >= max_endorsed_users ->
+ {:error, "You have already pinned the maximum number of users"}
+
+ not following?(endorser, target) ->
+ {:error, "Could not endorse: You are not following #{target.nickname}"}
+
+ true ->
+ UserRelationship.create_endorsement(endorser, target)
+ end
+ end
+ end
+
+ def endorse(%User{} = endorser, %{ap_id: ap_id}) do
+ with %User{} = endorsed <- get_cached_by_ap_id(ap_id) do
+ endorse(endorser, endorsed)
+ end
+ end
+
+ def unendorse(%User{} = unendorser, %User{} = target) do
+ UserRelationship.delete_endorsement(unendorser, target)
+ end
+
+ def unendorse(%User{} = unendorser, %{ap_id: ap_id}) do
+ with %User{} = user <- get_cached_by_ap_id(ap_id) do
+ unendorse(unendorser, user)
+ end
+ end
+
+ def mutes?(nil, _), do: false
+ def mutes?(%User{} = user, %User{} = target), do: mutes_user?(user, target)
+
+ def mutes_user?(%User{} = user, %User{} = target) do
+ UserRelationship.mute_exists?(user, target)
+ end
+
+ @spec muted_notifications?(User.t() | nil, User.t() | map()) :: boolean()
+ def muted_notifications?(nil, _), do: false
+
+ def muted_notifications?(%User{} = user, %User{} = target),
+ do: UserRelationship.notification_mute_exists?(user, target)
+
+ def blocks?(nil, _), do: false
+
+ def blocks?(%User{} = user, %User{} = target) do
+ blocks_user?(user, target) ||
+ (blocks_domain?(user, target) and not User.following?(user, target))
+ end
+
+ def blocks_user?(%User{} = user, %User{} = target) do
+ UserRelationship.block_exists?(user, target)
+ end
+
+ def blocks_user?(_, _), do: false
+
+ def blocks_domain?(%User{} = user, %User{} = target) do
+ domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
+ %{host: host} = URI.parse(target.ap_id)
+ Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host)
+ end
+
+ def blocks_domain?(_, _), do: false
+
+ def subscribed_to?(%User{} = user, %User{} = target) do
+ # Note: the relationship is inverse: subscriber acts as relationship target
+ UserRelationship.inverse_subscription_exists?(target, user)
+ end
+
+ def subscribed_to?(%User{} = user, %{ap_id: ap_id}) do
+ with %User{} = target <- get_cached_by_ap_id(ap_id) do
+ subscribed_to?(user, target)
+ end
+ end
+
+ def endorses?(%User{} = user, %User{} = target) do
+ UserRelationship.endorsement_exists?(user, target)
+ end
+
+ @doc """
+ Returns map of outgoing (blocked, muted etc.) relationships' user AP IDs by relation type.
+ E.g. `outgoing_relationships_ap_ids(user, [:block])` -> `%{block: ["https://some.site/users/userapid"]}`
+ """
+ @spec outgoing_relationships_ap_ids(User.t(), list(atom())) :: %{atom() => list(String.t())}
+ def outgoing_relationships_ap_ids(_user, []), do: %{}
+
+ def outgoing_relationships_ap_ids(nil, _relationship_types), do: %{}
+
+ def outgoing_relationships_ap_ids(%User{} = user, relationship_types)
+ when is_list(relationship_types) do
+ db_result =
+ user
+ |> assoc(:outgoing_relationships)
+ |> join(:inner, [user_rel], u in assoc(user_rel, :target))
+ |> where([user_rel, u], user_rel.relationship_type in ^relationship_types)
+ |> select([user_rel, u], [user_rel.relationship_type, fragment("array_agg(?)", u.ap_id)])
+ |> group_by([user_rel, u], user_rel.relationship_type)
+ |> Repo.all()
+ |> Enum.into(%{}, fn [k, v] -> {k, v} end)
+
+ Enum.into(
+ relationship_types,
+ %{},
+ fn rel_type -> {rel_type, db_result[rel_type] || []} end
+ )
+ end
+
+ def incoming_relationships_ungrouped_ap_ids(user, relationship_types, ap_ids \\ nil)
+
+ def incoming_relationships_ungrouped_ap_ids(_user, [], _ap_ids), do: []
+
+ def incoming_relationships_ungrouped_ap_ids(nil, _relationship_types, _ap_ids), do: []
+
+ def incoming_relationships_ungrouped_ap_ids(%User{} = user, relationship_types, ap_ids)
+ when is_list(relationship_types) do
+ user
+ |> assoc(:incoming_relationships)
+ |> join(:inner, [user_rel], u in assoc(user_rel, :source))
+ |> where([user_rel, u], user_rel.relationship_type in ^relationship_types)
+ |> maybe_filter_on_ap_id(ap_ids)
+ |> select([user_rel, u], u.ap_id)
+ |> distinct(true)
+ |> Repo.all()
+ end
+
+ defp maybe_filter_on_ap_id(query, ap_ids) when is_list(ap_ids) do
+ where(query, [user_rel, u], u.ap_id in ^ap_ids)
+ end
+
+ defp maybe_filter_on_ap_id(query, _ap_ids), do: query
+
+ def set_activation_async(user, status \\ true) do
+ BackgroundWorker.enqueue("user_activation", %{"user_id" => user.id, "status" => status})
+ end
+
+ @spec set_activation([User.t()], boolean()) :: {:ok, User.t()} | {:error, Changeset.t()}
+ def set_activation(users, status) when is_list(users) do
+ Repo.transaction(fn ->
+ for user <- users, do: set_activation(user, status)
+ end)
+ end
+
+ @spec set_activation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()}
+ def set_activation(%User{} = user, status) do
+ with {:ok, user} <- set_activation_status(user, status) do
+ user
+ |> get_followers()
+ |> Enum.filter(& &1.local)
+ |> Enum.each(&set_cache(update_following_count(&1)))
+
+ # Only update local user counts, remote will be update during the next pull.
+ user
+ |> get_friends()
+ |> Enum.filter(& &1.local)
+ |> Enum.each(&do_unfollow(user, &1))
+
+ {:ok, user}
+ end
+ end
+
+ def approve(users) when is_list(users) do
+ Repo.transaction(fn ->
+ Enum.map(users, fn user ->
+ with {:ok, user} <- approve(user), do: user
+ end)
+ end)
+ end
+
+ def approve(%User{is_approved: false} = user) do
+ with chg <- change(user, is_approved: true),
+ {:ok, user} <- update_and_set_cache(chg) do
+ post_register_action(user)
+ {:ok, user}
+ end
+ end
+
+ def approve(%User{} = user), do: {:ok, user}
+
+ def confirm(users) when is_list(users) do
+ Repo.transaction(fn ->
+ Enum.map(users, fn user ->
+ with {:ok, user} <- confirm(user), do: user
+ end)
+ end)
+ end
+
+ def confirm(%User{is_confirmed: false} = user) do
+ with chg <- confirmation_changeset(user, set_confirmation: true),
+ {:ok, user} <- update_and_set_cache(chg) do
+ post_register_action(user)
+ {:ok, user}
+ end
+ end
+
+ def confirm(%User{} = user), do: {:ok, user}
+
+ def set_suggestion(users, is_suggested) when is_list(users) do
+ Repo.transaction(fn ->
+ Enum.map(users, fn user ->
+ with {:ok, user} <- set_suggestion(user, is_suggested), do: user
+ end)
+ end)
+ end
+
+ def set_suggestion(%User{is_suggested: is_suggested} = user, is_suggested), do: {:ok, user}
+
+ def set_suggestion(%User{} = user, is_suggested) when is_boolean(is_suggested) do
+ user
+ |> change(is_suggested: is_suggested)
+ |> update_and_set_cache()
+ end
+
+ def update_notification_settings(%User{} = user, settings) do
+ user
+ |> cast(%{notification_settings: settings}, [])
+ |> cast_embed(:notification_settings)
+ |> validate_required([:notification_settings])
+ |> update_and_set_cache()
+ end
+
+ @spec purge_user_changeset(User.t()) :: Changeset.t()
+ def purge_user_changeset(user) do
+ # "Right to be forgotten"
+ # https://gdpr.eu/right-to-be-forgotten/
+ change(user, %{
+ bio: "",
+ raw_bio: nil,
+ email: nil,
+ name: nil,
+ password_hash: nil,
+ avatar: %{},
+ tags: [],
+ last_refreshed_at: nil,
+ last_digest_emailed_at: nil,
+ banner: %{},
+ background: %{},
+ note_count: 0,
+ follower_count: 0,
+ following_count: 0,
+ is_locked: false,
+ password_reset_pending: false,
+ registration_reason: nil,
+ confirmation_token: nil,
+ domain_blocks: [],
+ is_active: false,
+ ap_enabled: false,
+ is_moderator: false,
+ is_admin: false,
+ mascot: nil,
+ emoji: %{},
+ pleroma_settings_store: %{},
+ fields: [],
+ raw_fields: [],
+ is_discoverable: false,
+ also_known_as: []
+ # id: preserved
+ # ap_id: preserved
+ # nickname: preserved
+ })
+ end
+
+ # Purge doesn't delete the user from the database.
+ # It just nulls all its fields and deactivates it.
+ # See `User.purge_user_changeset/1` above.
+ defp purge(%User{} = user) do
+ user
+ |> purge_user_changeset()
+ |> update_and_set_cache()
+ end
+
+ def delete(users) when is_list(users) do
+ for user <- users, do: delete(user)
+ end
+
+ def delete(%User{} = user) do
+ # Purge the user immediately
+ purge(user)
+ BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id})
+ end
+
+ # *Actually* delete the user from the DB
+ defp delete_from_db(%User{} = user) do
+ invalidate_cache(user)
+ Repo.delete(user)
+ end
+
+ # If the user never finalized their account, it's safe to delete them.
+ defp maybe_delete_from_db(%User{local: true, is_confirmed: false} = user),
+ do: delete_from_db(user)
+
+ defp maybe_delete_from_db(%User{local: true, is_approved: false} = user),
+ do: delete_from_db(user)
+
+ defp maybe_delete_from_db(user), do: {:ok, user}
+
+ def perform(:force_password_reset, user), do: force_password_reset(user)
+
+ @spec perform(atom(), User.t()) :: {:ok, User.t()}
+ def perform(:delete, %User{} = user) do
+ # Purge the user again, in case perform/2 is called directly
+ purge(user)
+
+ # Remove all relationships
+ user
+ |> get_followers()
+ |> Enum.each(fn follower ->
+ ActivityPub.unfollow(follower, user)
+ unfollow(follower, user)
+ end)
+
+ user
+ |> get_friends()
+ |> Enum.each(fn followed ->
+ ActivityPub.unfollow(user, followed)
+ unfollow(user, followed)
+ end)
+
+ delete_user_activities(user)
+ delete_notifications_from_user_activities(user)
+ delete_outgoing_pending_follow_requests(user)
+
+ maybe_delete_from_db(user)
+ end
+
+ def perform(:set_activation_async, user, status), do: set_activation(user, status)
+
+ @spec external_users_query() :: Ecto.Query.t()
+ def external_users_query do
+ User.Query.build(%{
+ external: true,
+ active: true,
+ order_by: :id
+ })
+ end
+
+ @spec external_users(keyword()) :: [User.t()]
+ def external_users(opts \\ []) do
+ query =
+ external_users_query()
+ |> select([u], struct(u, [:id, :ap_id]))
+
+ query =
+ if opts[:max_id],
+ do: where(query, [u], u.id > ^opts[:max_id]),
+ else: query
+
+ query =
+ if opts[:limit],
+ do: limit(query, ^opts[:limit]),
+ else: query
+
+ Repo.all(query)
+ end
+
+ def delete_notifications_from_user_activities(%User{ap_id: ap_id}) do
+ Notification
+ |> join(:inner, [n], activity in assoc(n, :activity))
+ |> where([n, a], fragment("? = ?", a.actor, ^ap_id))
+ |> Repo.delete_all()
+ end
+
+ def delete_user_activities(%User{ap_id: ap_id} = user) do
+ ap_id
+ |> Activity.Queries.by_actor()
+ |> Repo.chunk_stream(50, :batches)
+ |> Stream.each(fn activities ->
+ Enum.each(activities, fn activity -> delete_activity(activity, user) end)
+ end)
+ |> Stream.run()
+ end
+
+ defp delete_activity(%{data: %{"type" => "Create", "object" => object}} = activity, user) do
+ with {_, %Object{}} <- {:find_object, Object.get_by_ap_id(object)},
+ {:ok, delete_data, _} <- Builder.delete(user, object) do
+ Pipeline.common_pipeline(delete_data, local: user.local)
+ else
+ {:find_object, nil} ->
+ # We have the create activity, but not the object, it was probably pruned.
+ # Insert a tombstone and try again
+ with {:ok, tombstone_data, _} <- Builder.tombstone(user.ap_id, object),
+ {:ok, _tombstone} <- Object.create(tombstone_data) do
+ delete_activity(activity, user)
+ end
+
+ e ->
+ Logger.error("Could not delete #{object} created by #{activity.data["ap_id"]}")
+ Logger.error("Error: #{inspect(e)}")
+ end
+ end
+
+ defp delete_activity(%{data: %{"type" => type}} = activity, user)
+ when type in ["Like", "Announce"] do
+ {:ok, undo, _} = Builder.undo(user, activity)
+ Pipeline.common_pipeline(undo, local: user.local)
+ end
+
+ defp delete_activity(_activity, _user), do: "Doing nothing"
+
+ defp delete_outgoing_pending_follow_requests(user) do
+ user
+ |> FollowingRelationship.outgoing_pending_follow_requests_query()
+ |> Repo.delete_all()
+ end
+
+ def html_filter_policy(%User{no_rich_text: true}) do
+ Pleroma.HTML.Scrubber.TwitterText
+ end
+
+ def html_filter_policy(_), do: Config.get([:markup, :scrub_policy])
+
+ def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id)
+
+ def get_or_fetch_by_ap_id(ap_id) do
+ cached_user = get_cached_by_ap_id(ap_id)
+
+ maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id)
+
+ case {cached_user, maybe_fetched_user} do
+ {_, {:ok, %User{} = user}} ->
+ {:ok, user}
+
+ {%User{} = user, _} ->
+ {:ok, user}
+
+ _ ->
+ {:error, :not_found}
+ end
+ end
+
+ @doc """
+ Creates an internal service actor by URI if missing.
+ Optionally takes nickname for addressing.
+ """
+ @spec get_or_create_service_actor_by_ap_id(String.t(), String.t()) :: User.t() | nil
+ def get_or_create_service_actor_by_ap_id(uri, nickname) do
+ {_, user} =
+ case get_cached_by_ap_id(uri) do
+ nil ->
+ with {:error, %{errors: errors}} <- create_service_actor(uri, nickname) do
+ Logger.error("Cannot create service actor: #{uri}/.\n#{inspect(errors)}")
+ {:error, nil}
+ end
+
+ %User{invisible: false} = user ->
+ set_invisible(user)
+
+ user ->
+ {:ok, user}
+ end
+
+ user
+ end
+
+ @spec set_invisible(User.t()) :: {:ok, User.t()}
+ defp set_invisible(user) do
+ user
+ |> change(%{invisible: true})
+ |> update_and_set_cache()
+ end
+
+ @spec create_service_actor(String.t(), String.t()) ::
+ {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+ defp create_service_actor(uri, nickname) do
+ %User{
+ invisible: true,
+ local: true,
+ ap_id: uri,
+ nickname: nickname,
+ follower_address: uri <> "/followers"
+ }
+ |> change
+ |> put_private_key()
+ |> unique_constraint(:nickname)
+ |> Repo.insert()
+ |> set_cache()
+ end
+
+ def public_key(%{public_key: public_key_pem}) when is_binary(public_key_pem) do
+ key =
+ public_key_pem
+ |> :public_key.pem_decode()
+ |> hd()
+ |> :public_key.pem_entry_decode()
+
+ {:ok, key}
+ end
+
+ def public_key(_), do: {:error, "key not found"}
+
+ def get_public_key_for_ap_id(ap_id) do
+ with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id),
+ {:ok, public_key} <- public_key(user) do
+ {:ok, public_key}
+ else
+ _ -> :error
+ end
+ end
+
+ def ap_enabled?(%User{local: true}), do: true
+ def ap_enabled?(%User{ap_enabled: ap_enabled}), do: ap_enabled
+ def ap_enabled?(_), do: false
+
+ @doc "Gets or fetch a user by uri or nickname."
+ @spec get_or_fetch(String.t()) :: {:ok, User.t()} | {:error, String.t()}
+ def get_or_fetch("http://" <> _host = uri), do: get_or_fetch_by_ap_id(uri)
+ def get_or_fetch("https://" <> _host = uri), do: get_or_fetch_by_ap_id(uri)
+ def get_or_fetch(nickname), do: get_or_fetch_by_nickname(nickname)
+
+ # wait a period of time and return newest version of the User structs
+ # this is because we have synchronous follow APIs and need to simulate them
+ # with an async handshake
+ def wait_and_refresh(_, %User{local: true} = a, %User{local: true} = b) do
+ with %User{} = a <- get_cached_by_id(a.id),
+ %User{} = b <- get_cached_by_id(b.id) do
+ {:ok, a, b}
+ else
+ nil -> :error
+ end
+ end
+
+ def wait_and_refresh(timeout, %User{} = a, %User{} = b) do
+ with :ok <- :timer.sleep(timeout),
+ %User{} = a <- get_cached_by_id(a.id),
+ %User{} = b <- get_cached_by_id(b.id) do
+ {:ok, a, b}
+ else
+ nil -> :error
+ end
+ end
+
+ def parse_bio(bio) when is_binary(bio) and bio != "" do
+ bio
+ |> CommonUtils.format_input("text/plain", mentions_format: :full)
+ |> elem(0)
+ end
+
+ def parse_bio(_), do: ""
+
+ def parse_bio(bio, user) when is_binary(bio) and bio != "" do
+ # TODO: get profile URLs other than user.ap_id
+ profile_urls = [user.ap_id]
+
+ bio
+ |> CommonUtils.format_input("text/plain",
+ mentions_format: :full,
+ rel: &RelMe.maybe_put_rel_me(&1, profile_urls)
+ )
+ |> elem(0)
+ end
+
+ def parse_bio(_, _), do: ""
+
+ def tag(user_identifiers, tags) when is_list(user_identifiers) do
+ Repo.transaction(fn ->
+ for user_identifier <- user_identifiers, do: tag(user_identifier, tags)
+ end)
+ end
+
+ def tag(nickname, tags) when is_binary(nickname),
+ do: tag(get_by_nickname(nickname), tags)
+
+ def tag(%User{} = user, tags),
+ do: update_tags(user, Enum.uniq((user.tags || []) ++ normalize_tags(tags)))
+
+ def untag(user_identifiers, tags) when is_list(user_identifiers) do
+ Repo.transaction(fn ->
+ for user_identifier <- user_identifiers, do: untag(user_identifier, tags)
+ end)
+ end
+
+ def untag(nickname, tags) when is_binary(nickname),
+ do: untag(get_by_nickname(nickname), tags)
+
+ def untag(%User{} = user, tags),
+ do: update_tags(user, (user.tags || []) -- normalize_tags(tags))
+
+ defp update_tags(%User{} = user, new_tags) do
+ {:ok, updated_user} =
+ user
+ |> change(%{tags: new_tags})
+ |> update_and_set_cache()
+
+ updated_user
+ end
+
+ defp normalize_tags(tags) do
+ [tags]
+ |> List.flatten()
+ |> Enum.map(&String.downcase/1)
+ end
+
+ defp local_nickname_regex do
+ if Config.get([:instance, :extended_nickname_format]) do
+ @extended_local_nickname_regex
+ else
+ @strict_local_nickname_regex
+ end
+ end
+
+ def local_nickname(nickname_or_mention) do
+ nickname_or_mention
+ |> full_nickname()
+ |> String.split("@")
+ |> hd()
+ end
+
+ def full_nickname(%User{} = user) do
+ if String.contains?(user.nickname, "@") do
+ user.nickname
+ else
+ %{host: host} = URI.parse(user.ap_id)
+ user.nickname <> "@" <> host
+ end
+ end
+
+ def full_nickname(nickname_or_mention),
+ do: String.trim_leading(nickname_or_mention, "@")
+
+ def error_user(ap_id) do
+ %User{
+ name: ap_id,
+ ap_id: ap_id,
+ nickname: "erroruser@example.com",
+ inserted_at: NaiveDateTime.utc_now()
+ }
+ end
+
+ @spec all_superusers() :: [User.t()]
+ def all_superusers do
+ User.Query.build(%{super_users: true, local: true, is_active: true})
+ |> Repo.all()
+ end
+
+ @spec all_users_with_privilege(atom()) :: [User.t()]
+ def all_users_with_privilege(privilege) do
+ User.Query.build(%{is_privileged: privilege}) |> Repo.all()
+ end
+
+ def muting_reblogs?(%User{} = user, %User{} = target) do
+ UserRelationship.reblog_mute_exists?(user, target)
+ end
+
+ def showing_reblogs?(%User{} = user, %User{} = target) do
+ not muting_reblogs?(user, target)
+ end
+
+ @doc """
+ The function returns a query to get users with no activity for given interval of days.
+ Inactive users are those who didn't read any notification, or had any activity where
+ the user is the activity's actor, during `inactivity_threshold` days.
+ Deactivated users will not appear in this list.
+
+ ## Examples
+
+ iex> Pleroma.User.list_inactive_users()
+ %Ecto.Query{}
+ """
+ @spec list_inactive_users_query(integer()) :: Ecto.Query.t()
+ def list_inactive_users_query(inactivity_threshold \\ 7) do
+ negative_inactivity_threshold = -inactivity_threshold
+ now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+ # Subqueries are not supported in `where` clauses, join gets too complicated.
+ has_read_notifications =
+ from(n in Pleroma.Notification,
+ where: n.seen == true,
+ group_by: n.id,
+ having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"),
+ select: n.user_id
+ )
+ |> Pleroma.Repo.all()
+
+ from(u in Pleroma.User,
+ left_join: a in Pleroma.Activity,
+ on: u.ap_id == a.actor,
+ where: not is_nil(u.nickname),
+ where: u.is_active == ^true,
+ where: u.id not in ^has_read_notifications,
+ group_by: u.id,
+ having:
+ max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or
+ is_nil(max(a.inserted_at))
+ )
+ end
+
+ @doc """
+ Enable or disable email notifications for user
+
+ ## Examples
+
+ iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => false}}, "digest", true)
+ Pleroma.User{email_notifications: %{"digest" => true}}
+
+ iex> Pleroma.User.switch_email_notifications(Pleroma.User{email_notifications: %{"digest" => true}}, "digest", false)
+ Pleroma.User{email_notifications: %{"digest" => false}}
+ """
+ @spec switch_email_notifications(t(), String.t(), boolean()) ::
+ {:ok, t()} | {:error, Ecto.Changeset.t()}
+ def switch_email_notifications(user, type, status) do
+ User.update_email_notifications(user, %{type => status})
+ end
+
+ @doc """
+ Set `last_digest_emailed_at` value for the user to current time
+ """
+ @spec touch_last_digest_emailed_at(t()) :: t()
+ def touch_last_digest_emailed_at(user) do
+ now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+
+ {:ok, updated_user} =
+ user
+ |> change(%{last_digest_emailed_at: now})
+ |> update_and_set_cache()
+
+ updated_user
+ end
+
+ @spec set_confirmation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()}
+ def set_confirmation(%User{} = user, bool) do
+ user
+ |> confirmation_changeset(set_confirmation: bool)
+ |> update_and_set_cache()
+ end
+
+ def get_mascot(%{mascot: %{} = mascot}) when not is_nil(mascot) do
+ mascot
+ end
+
+ def get_mascot(%{mascot: mascot}) when is_nil(mascot) do
+ # use instance-default
+ config = Config.get([:assets, :mascots])
+ default_mascot = Config.get([:assets, :default_mascot])
+ mascot = Keyword.get(config, default_mascot)
+
+ %{
+ "id" => "default-mascot",
+ "url" => mascot[:url],
+ "preview_url" => mascot[:url],
+ "pleroma" => %{
+ "mime_type" => mascot[:mime_type]
+ }
+ }
+ end
+
+ def get_ap_ids_by_nicknames(nicknames) do
+ from(u in User,
+ where: u.nickname in ^nicknames,
+ order_by: fragment("array_position(?, ?)", ^nicknames, u.nickname),
+ select: u.ap_id
+ )
+ |> Repo.all()
+ end
+
+ defp put_password_hash(
+ %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
+ ) do
+ change(changeset, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt(password))
+ end
+
+ defp put_password_hash(changeset), do: changeset
+
+ def is_internal_user?(%User{nickname: nil}), do: true
+ def is_internal_user?(%User{local: true, nickname: "internal." <> _}), do: true
+ def is_internal_user?(_), do: false
+
+ # A hack because user delete activities have a fake id for whatever reason
+ # TODO: Get rid of this
+ def get_delivered_users_by_object_id("pleroma:fake_object_id"), do: []
+
+ def get_delivered_users_by_object_id(object_id) do
+ from(u in User,
+ inner_join: delivery in assoc(u, :deliveries),
+ where: delivery.object_id == ^object_id
+ )
+ |> Repo.all()
+ end
+
+ def change_email(user, email) do
+ user
+ |> cast(%{email: email}, [:email])
+ |> maybe_validate_required_email(false)
+ |> unique_constraint(:email)
+ |> validate_format(:email, @email_regex)
+ |> update_and_set_cache()
+ end
+
+ def alias_users(user) do
+ user.also_known_as
+ |> Enum.map(&User.get_cached_by_ap_id/1)
+ |> Enum.filter(fn user -> user != nil end)
+ end
+
+ def add_alias(user, new_alias_user) do
+ current_aliases = user.also_known_as || []
+ new_alias_ap_id = new_alias_user.ap_id
+
+ if new_alias_ap_id in current_aliases do
+ {:ok, user}
+ else
+ user
+ |> cast(%{also_known_as: current_aliases ++ [new_alias_ap_id]}, [:also_known_as])
+ |> update_and_set_cache()
+ end
+ end
+
+ def delete_alias(user, alias_user) do
+ current_aliases = user.also_known_as || []
+ alias_ap_id = alias_user.ap_id
+
+ if alias_ap_id in current_aliases do
+ user
+ |> cast(%{also_known_as: current_aliases -- [alias_ap_id]}, [:also_known_as])
+ |> update_and_set_cache()
+ else
+ {:error, :no_such_alias}
+ end
+ end
+
+ # Internal function; public one is `deactivate/2`
+ defp set_activation_status(user, status) do
+ user
+ |> cast(%{is_active: status}, [:is_active])
+ |> update_and_set_cache()
+ end
+
+ def update_banner(user, banner) do
+ user
+ |> cast(%{banner: banner}, [:banner])
+ |> update_and_set_cache()
+ end
+
+ def update_background(user, background) do
+ user
+ |> cast(%{background: background}, [:background])
+ |> update_and_set_cache()
+ end
+
+ def validate_fields(changeset, remote? \\ false) do
+ limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields
+ limit = Config.get([:instance, limit_name], 0)
+
+ changeset
+ |> validate_length(:fields, max: limit)
+ |> validate_change(:fields, fn :fields, fields ->
+ if Enum.all?(fields, &valid_field?/1) do
+ []
+ else
+ [fields: "invalid"]
+ end
+ end)
+ end
+
+ defp valid_field?(%{"name" => name, "value" => value}) do
+ name_limit = Config.get([:instance, :account_field_name_length], 255)
+ value_limit = Config.get([:instance, :account_field_value_length], 255)
+
+ is_binary(name) && is_binary(value) && String.length(name) <= name_limit &&
+ String.length(value) <= value_limit
+ end
+
+ defp valid_field?(_), do: false
+
+ defp truncate_field(%{"name" => name, "value" => value}) do
+ {name, _chopped} =
+ String.split_at(name, Config.get([:instance, :account_field_name_length], 255))
+
+ {value, _chopped} =
+ String.split_at(value, Config.get([:instance, :account_field_value_length], 255))
+
+ %{"name" => name, "value" => value}
+ end
+
+ def admin_api_update(user, params) do
+ user
+ |> cast(params, [
+ :is_moderator,
+ :is_admin,
+ :show_role
+ ])
+ |> update_and_set_cache()
+ end
+
+ @doc "Signs user out of all applications"
+ def global_sign_out(user) do
+ OAuth.Authorization.delete_user_authorizations(user)
+ OAuth.Token.delete_user_tokens(user)
+ end
+
+ def mascot_update(user, url) do
+ user
+ |> cast(%{mascot: url}, [:mascot])
+ |> validate_required([:mascot])
+ |> update_and_set_cache()
+ end
+
+ @spec confirmation_changeset(User.t(), keyword()) :: Changeset.t()
+ def confirmation_changeset(user, set_confirmation: confirmed?) do
+ params =
+ if confirmed? do
+ %{
+ is_confirmed: true,
+ confirmation_token: nil
+ }
+ else
+ %{
+ is_confirmed: false,
+ confirmation_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64()
+ }
+ end
+
+ cast(user, params, [:is_confirmed, :confirmation_token])
+ end
+
+ @spec approval_changeset(User.t(), keyword()) :: Changeset.t()
+ def approval_changeset(user, set_approval: approved?) do
+ cast(user, %{is_approved: approved?}, [:is_approved])
+ end
+
+ @spec add_pinned_object_id(User.t(), String.t()) :: {:ok, User.t()} | {:error, term()}
+ def add_pinned_object_id(%User{} = user, object_id) do
+ if !user.pinned_objects[object_id] do
+ params = %{pinned_objects: Map.put(user.pinned_objects, object_id, NaiveDateTime.utc_now())}
+
+ user
+ |> cast(params, [:pinned_objects])
+ |> validate_change(:pinned_objects, fn :pinned_objects, pinned_objects ->
+ max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0)
+
+ if Enum.count(pinned_objects) <= max_pinned_statuses do
+ []
+ else
+ [pinned_objects: "You have already pinned the maximum number of statuses"]
+ end
+ end)
+ else
+ change(user)
+ end
+ |> update_and_set_cache()
+ end
+
+ @spec remove_pinned_object_id(User.t(), String.t()) :: {:ok, t()} | {:error, term()}
+ def remove_pinned_object_id(%User{} = user, object_id) do
+ user
+ |> cast(
+ %{pinned_objects: Map.delete(user.pinned_objects, object_id)},
+ [:pinned_objects]
+ )
+ |> update_and_set_cache()
+ end
+
+ def update_email_notifications(user, settings) do
+ email_notifications =
+ user.email_notifications
+ |> Map.merge(settings)
+ |> Map.take(["digest"])
+
+ params = %{email_notifications: email_notifications}
+ fields = [:email_notifications]
+
+ user
+ |> cast(params, fields)
+ |> validate_required(fields)
+ |> update_and_set_cache()
+ end
+
+ defp set_domain_blocks(user, domain_blocks) do
+ params = %{domain_blocks: domain_blocks}
+
+ user
+ |> cast(params, [:domain_blocks])
+ |> validate_required([:domain_blocks])
+ |> update_and_set_cache()
+ end
+
+ def block_domain(user, domain_blocked) do
+ set_domain_blocks(user, Enum.uniq([domain_blocked | user.domain_blocks]))
+ end
+
+ def unblock_domain(user, domain_blocked) do
+ set_domain_blocks(user, List.delete(user.domain_blocks, domain_blocked))
+ end
+
+ @spec add_to_block(User.t(), User.t()) ::
+ {:ok, UserRelationship.t()} | {:error, Ecto.Changeset.t()}
+ defp add_to_block(%User{} = user, %User{} = blocked) do
+ with {:ok, relationship} <- UserRelationship.create_block(user, blocked) do
+ @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
+ {:ok, relationship}
+ end
+ end
+
+ @spec add_to_block(User.t(), User.t()) ::
+ {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()}
+ defp remove_from_block(%User{} = user, %User{} = blocked) do
+ with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do
+ @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
+ {:ok, relationship}
+ end
+ end
+
+ def set_invisible(user, invisible) do
+ params = %{invisible: invisible}
+
+ user
+ |> cast(params, [:invisible])
+ |> validate_required([:invisible])
+ |> update_and_set_cache()
+ end
+
+ def sanitize_html(%User{} = user) do
+ sanitize_html(user, nil)
+ end
+
+ # User data that mastodon isn't filtering (treated as plaintext):
+ # - field name
+ # - display name
+ def sanitize_html(%User{} = user, filter) do
+ fields =
+ Enum.map(user.fields, fn %{"name" => name, "value" => value} ->
+ %{
+ "name" => name,
+ "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly)
+ }
+ end)
+
+ user
+ |> Map.put(:bio, HTML.filter_tags(user.bio, filter))
+ |> Map.put(:fields, fields)
+ end
+
+ def get_host(%User{ap_id: ap_id} = _user) do
+ URI.parse(ap_id).host
+ end
+
+ def update_last_active_at(%__MODULE__{local: true} = user) do
+ user
+ |> cast(%{last_active_at: NaiveDateTime.utc_now()}, [:last_active_at])
+ |> update_and_set_cache()
+ end
+
+ def active_user_count(days \\ 30) do
+ active_after = Timex.shift(NaiveDateTime.utc_now(), days: -days)
+
+ __MODULE__
+ |> where([u], u.last_active_at >= ^active_after)
+ |> where([u], u.local == true)
+ |> Repo.aggregate(:count)
+ end
+
+ def update_last_status_at(user) do
+ User
+ |> where(id: ^user.id)
+ |> update([u], set: [last_status_at: fragment("NOW()")])
+ |> select([u], u)
+ |> Repo.update_all([])
+ |> case do
+ {1, [user]} -> set_cache(user)
+ _ -> {:error, user}
+ end
+ end
+
+ def get_friends_birthdays_query(%User{} = user, day, month) do
+ User.Query.build(%{
+ friends: user,
+ deactivated: false,
+ birthday_day: day,
+ birthday_month: month
+ })
+ end
+end