mirror of
https://akkoma.dev/AkkomaGang/akkoma.git
synced 2024-11-29 16:56:52 +00:00
7622aa27ca
Currently our own frontend doesn’t show backgrounds of other users, this
property is already publicly readable via REST API and likely was always
intended to be shown and federated.
Recently Sharkey added support for profile backgrounds and
immediately made them federate and be displayed to others.
We use the same AP field as Sharkey here which should make
it interoperable both ways out-of-the-box.
Ref.: 4e64397635
2767 lines
83 KiB
Elixir
2767 lines
83 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.User do
|
|
@moduledoc """
|
|
A user, local or remote
|
|
"""
|
|
|
|
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.Hashtag
|
|
alias Pleroma.User.HashtagFollow
|
|
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
|
|
|
|
use Pleroma.Web, :verified_routes
|
|
|
|
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
|
|
]
|
|
]
|
|
|
|
@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(:mastofe_settings, :map, default: nil)
|
|
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(: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(:language, :string)
|
|
field(:status_ttl_days, :integer, default: nil)
|
|
field(:permit_followback, :boolean, default: false)
|
|
|
|
field(:accepts_direct_messages_from, Ecto.Enum,
|
|
values: [:everybody, :people_i_follow, :nobody],
|
|
default: :everybody
|
|
)
|
|
|
|
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)
|
|
|
|
has_many(:frontend_profiles, Pleroma.Akkoma.FrontendSettingsProfile)
|
|
|
|
many_to_many(:followed_hashtags, Hashtag,
|
|
on_replace: :delete,
|
|
on_delete: :delete_all,
|
|
join_through: HashtagFollow
|
|
)
|
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
has_many(incoming_relation_source, through: [incoming_relation, :source])
|
|
end
|
|
|
|
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 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 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 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_query(user), to: FollowingRelationship
|
|
|
|
def get_follow_requests(user) do
|
|
get_follow_requests_query(user)
|
|
|> Repo.all()
|
|
end
|
|
|
|
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 superuser?(for_user) 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 superuser?(User.t()) :: boolean()
|
|
def superuser?(%User{local: true, is_admin: true}), do: true
|
|
def superuser?(%User{local: true, is_moderator: true}), do: true
|
|
def superuser?(_), do: false
|
|
|
|
@spec invisible?(User.t()) :: boolean()
|
|
def invisible?(%User{invisible: true}), do: true
|
|
def invisible?(_), do: false
|
|
|
|
def avatar_url(user, options \\ []) do
|
|
default = Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png")
|
|
do_optional_url(user.avatar, default, options)
|
|
end
|
|
|
|
def banner_url(user, options \\ []) do
|
|
do_optional_url(user.banner, "#{Endpoint.url()}/images/banner.png", options)
|
|
end
|
|
|
|
def background_url(user) do
|
|
do_optional_url(user.background, nil, no_default: true)
|
|
end
|
|
|
|
defp do_optional_url(field, default, options) do
|
|
case field do
|
|
%{"url" => [%{"href" => href} | _]} when is_binary(href) ->
|
|
href
|
|
|
|
_ ->
|
|
unless options[:no_default], do: default
|
|
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,
|
|
:background,
|
|
: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,
|
|
:pinned_objects
|
|
]
|
|
)
|
|
|> 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, struct)
|
|
|> 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,
|
|
:disclose_client,
|
|
:status_ttl_days,
|
|
:permit_followback,
|
|
:accepts_direct_messages_from
|
|
]
|
|
)
|
|
|> 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"])
|
|
|> validate_number(:status_ttl_days, greater_than: 0)
|
|
|> 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, struct)
|
|
end
|
|
|
|
defp put_fields(changeset) do
|
|
# These fields are inconsistent in tests when it comes to binary/atom keys
|
|
if raw_fields = get_change(changeset, :raw_fields) do
|
|
raw_fields =
|
|
raw_fields
|
|
|> Enum.map(fn
|
|
%{name: name, value: value} ->
|
|
%{"name" => name, "value" => value}
|
|
|
|
%{"name" => _} = field ->
|
|
field
|
|
end)
|
|
|> 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 =
|
|
if Map.has_key?(params, :email) do
|
|
Map.put_new(params, :email, params[:email])
|
|
else
|
|
params
|
|
end
|
|
|
|
struct
|
|
|> cast(params, [
|
|
:name,
|
|
:nickname,
|
|
:email
|
|
])
|
|
|> validate_required([:name, :nickname])
|
|
|> unique_constraint(:nickname)
|
|
|> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames]))
|
|
|> validate_format(:nickname, local_nickname_regex())
|
|
|> put_ap_id()
|
|
|> unique_constraint(:ap_id)
|
|
|> put_following_and_follower_and_featured_address()
|
|
|> put_private_key()
|
|
end
|
|
|
|
@spec register_changeset(User.t(), map(), keyword()) :: Changeset.t()
|
|
def register_changeset(%User{} = 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)
|
|
|
|
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,
|
|
:registration_reason,
|
|
:language
|
|
])
|
|
|> validate_required([:name, :nickname, :password, :password_confirmation])
|
|
|> validate_confirmation(:password)
|
|
|> unique_constraint(:email)
|
|
|> validate_format(:email, @email_regex)
|
|
|> validate_change(:email, fn :email, email ->
|
|
valid? =
|
|
Config.get([User, :email_blacklist])
|
|
|> Enum.all?(fn blacklisted_domain ->
|
|
!String.ends_with?(email, ["@" <> blacklisted_domain, "." <> blacklisted_domain])
|
|
end)
|
|
|
|
if valid?, do: [], else: [email: "Invalid email"]
|
|
end)
|
|
|> unique_constraint(:nickname)
|
|
|> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames]))
|
|
|> 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])
|
|
|> put_password_hash
|
|
|> put_ap_id()
|
|
|> unique_constraint(:ap_id)
|
|
|> put_following_and_follower_and_featured_address()
|
|
|> put_private_key()
|
|
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
|
|
|
|
def put_ap_id(changeset) do
|
|
ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)})
|
|
put_change(changeset, :ap_id, ap_id)
|
|
end
|
|
|
|
def 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.)"
|
|
@spec register(Changeset.t()) :: {:ok, User.t()} | {:error, any} | nil
|
|
def register(%Ecto.Changeset{} = changeset) do
|
|
with {:ok, user} <- Repo.insert(changeset) do
|
|
post_register_action(user)
|
|
end
|
|
end
|
|
|
|
@spec post_register_action(User.t()) :: {:error, any} | {:ok, User.t()}
|
|
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) 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_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
|
|
|
|
# "Locked" (self-locked) users demand explicit authorization of follow requests
|
|
@spec can_direct_follow_local(User.t(), User.t()) :: true | false
|
|
def can_direct_follow_local(%User{} = follower, %User{local: true} = followed) do
|
|
!followed.is_locked || (followed.permit_followback and is_friend_of(follower, followed))
|
|
end
|
|
|
|
@spec maybe_direct_follow(User.t(), User.t()) ::
|
|
{:ok, User.t(), User.t()} | {:error, String.t()}
|
|
|
|
def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do
|
|
if can_direct_follow_local(follower, followed) do
|
|
follow(follower, followed)
|
|
else
|
|
follow(follower, followed, :follow_pending)
|
|
end
|
|
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_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
|
|
|
|
@spec set_cache(
|
|
{:error, any}
|
|
| {:ok, User.t()}
|
|
| User.t()
|
|
) :: {:ok, User.t()} | {:error, any}
|
|
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
|
|
|
|
@spec update_and_set_cache(User.t(), map()) :: {:ok, User.t()} | {:error, any}
|
|
def update_and_set_cache(struct, params) do
|
|
struct
|
|
|> update_changeset(params)
|
|
|> update_and_set_cache()
|
|
end
|
|
|
|
@spec update_and_set_cache(Changeset.t()) :: {:ok, User.t()} | {:error, any}
|
|
def update_and_set_cache(%{data: %Pleroma.User{} = user} = changeset) do
|
|
was_superuser_before_update = User.superuser?(user)
|
|
|
|
with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
|
|
set_cache(user)
|
|
end
|
|
|> maybe_remove_report_notifications(was_superuser_before_update)
|
|
end
|
|
|
|
defp maybe_remove_report_notifications({:ok, %Pleroma.User{} = user} = result, true) do
|
|
if not User.superuser?(user),
|
|
do: user |> Notification.destroy_multiple_from_types(["pleroma:report"])
|
|
|
|
result
|
|
end
|
|
|
|
defp maybe_remove_report_notifications(result, _) do
|
|
result
|
|
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
|
|
|
|
@spec get_cached_by_id(String.t()) :: nil | Pleroma.User.t()
|
|
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 is_friend_of(%User{} = potential_friend, %User{local: true} = user) do
|
|
user
|
|
|> get_friends_query()
|
|
|> where(id: ^potential_friend.id)
|
|
|> Repo.exists?()
|
|
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)
|
|
expires_in = Map.get(params, :expires_in, 0)
|
|
|
|
with {:ok, user_mute} <- UserRelationship.create_mute(muter, mutee),
|
|
{:ok, user_notification_mute} <-
|
|
(notifications? && UserRelationship.create_notification_mute(muter, mutee)) ||
|
|
{:ok, nil} do
|
|
if expires_in > 0 do
|
|
Pleroma.Workers.MuteExpireWorker.enqueue(
|
|
"unmute_user",
|
|
%{"muter_id" => muter.id, "mutee_id" => mutee.id},
|
|
schedule_in: expires_in
|
|
)
|
|
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.warning(
|
|
"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 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
|
|
%{host: host} = URI.parse(target.ap_id)
|
|
Enum.member?(user.domain_blocks, host)
|
|
# TODO: functionality should probably be changed such that subdomains block as well,
|
|
# but as it stands, this just hecks up the relationships endpoint
|
|
# 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
|
|
|
|
@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,
|
|
mastofe_settings: nil,
|
|
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}
|
|
|
|
e ->
|
|
Logger.error("Could not fetch user #{ap_id}, #{inspect(e)}")
|
|
{: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]
|
|
|
|
CommonUtils.format_input(bio, "text/plain",
|
|
mentions_format: :full,
|
|
rel: fn link ->
|
|
case RelMe.maybe_put_rel_me(link, profile_urls) do
|
|
"me" -> "me"
|
|
_ -> nil
|
|
end
|
|
end
|
|
)
|
|
|> 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
|
|
|
|
def 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
|
|
|
|
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,
|
|
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.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
|
|
|
|
@spec delete_alias(User.t(), User.t()) :: {:error, :no_such_alias}
|
|
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
|
|
|
|
@spec validate_fields(Ecto.Changeset.t(), Boolean.t(), User.t()) :: Ecto.Changeset.t()
|
|
def validate_fields(changeset, remote? \\ false, struct) 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)
|
|
|> maybe_validate_rel_me_field(struct)
|
|
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 is_url(nil), do: nil
|
|
|
|
defp is_url(uri) do
|
|
case URI.parse(uri) do
|
|
%URI{host: nil} -> false
|
|
%URI{scheme: nil} -> false
|
|
_ -> true
|
|
end
|
|
end
|
|
|
|
@spec maybe_validate_rel_me_field(Changeset.t(), User.t()) :: Changeset.t()
|
|
defp maybe_validate_rel_me_field(changeset, %User{ap_id: _ap_id} = struct) do
|
|
fields = get_change(changeset, :fields)
|
|
raw_fields = get_change(changeset, :raw_fields)
|
|
|
|
if is_nil(fields) do
|
|
changeset
|
|
else
|
|
validate_rel_me_field(changeset, fields, raw_fields, struct)
|
|
end
|
|
end
|
|
|
|
defp maybe_validate_rel_me_field(changeset, _), do: changeset
|
|
|
|
@spec validate_rel_me_field(Changeset.t(), [Map.t()], [Map.t()], User.t()) :: Changeset.t()
|
|
defp validate_rel_me_field(changeset, fields, raw_fields, %User{
|
|
nickname: nickname,
|
|
ap_id: ap_id
|
|
}) do
|
|
fields =
|
|
fields
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {%{"name" => name, "value" => value}, index} ->
|
|
raw_value =
|
|
if is_nil(raw_fields) do
|
|
nil
|
|
else
|
|
Enum.at(raw_fields, index)["value"]
|
|
end
|
|
|
|
if is_url(raw_value) do
|
|
frontend_url = url(~p[/#{nickname}])
|
|
|
|
possible_urls = [ap_id, frontend_url]
|
|
|
|
with "me" <- RelMe.maybe_put_rel_me(raw_value, possible_urls) do
|
|
%{
|
|
"name" => name,
|
|
"value" => value,
|
|
"verified_at" => DateTime.to_iso8601(DateTime.utc_now())
|
|
}
|
|
else
|
|
e ->
|
|
Logger.error("Could not check for rel=me, #{inspect(e)}")
|
|
%{"name" => name, "value" => value}
|
|
end
|
|
else
|
|
%{"name" => name, "value" => value}
|
|
end
|
|
end)
|
|
|
|
put_change(changeset, :fields, fields)
|
|
end
|
|
|
|
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
|
|
|
|
def mastodon_settings_update(user, settings) do
|
|
user
|
|
|> cast(%{mastofe_settings: settings}, [:mastofe_settings])
|
|
|> validate_required([:mastofe_settings])
|
|
|> 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(Changeset.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}
|
|
else
|
|
err -> err
|
|
end
|
|
end
|
|
|
|
@spec remove_from_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}
|
|
else
|
|
err -> err
|
|
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 %{"value" => value} = field ->
|
|
Map.put(field, "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
|
|
|
|
defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user)
|
|
when is_list(follows),
|
|
do: user
|
|
|
|
defp maybe_load_followed_hashtags(%User{} = user) do
|
|
followed_hashtags = HashtagFollow.get_by_user(user)
|
|
%{user | followed_hashtags: followed_hashtags}
|
|
end
|
|
|
|
def followed_hashtags(%User{followed_hashtags: follows})
|
|
when is_list(follows),
|
|
do: follows
|
|
|
|
def followed_hashtags(%User{} = user) do
|
|
{:ok, user} =
|
|
user
|
|
|> maybe_load_followed_hashtags()
|
|
|> set_cache()
|
|
|
|
user.followed_hashtags
|
|
end
|
|
|
|
def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
|
|
Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}")
|
|
user = maybe_load_followed_hashtags(user)
|
|
|
|
with {:ok, _} <- HashtagFollow.new(user, hashtag),
|
|
follows <- HashtagFollow.get_by_user(user),
|
|
%User{} = user <- user |> Map.put(:followed_hashtags, follows) do
|
|
user
|
|
|> set_cache()
|
|
end
|
|
end
|
|
|
|
def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
|
|
Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}")
|
|
user = maybe_load_followed_hashtags(user)
|
|
|
|
with {:ok, _} <- HashtagFollow.delete(user, hashtag),
|
|
follows <- HashtagFollow.get_by_user(user),
|
|
%User{} = user <- user |> Map.put(:followed_hashtags, follows) do
|
|
user
|
|
|> set_cache()
|
|
end
|
|
end
|
|
|
|
def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do
|
|
not is_nil(HashtagFollow.get(user, hashtag))
|
|
end
|
|
|
|
def accepts_direct_messages?(
|
|
%User{accepts_direct_messages_from: :people_i_follow} = receiver,
|
|
%User{} = sender
|
|
) do
|
|
User.following?(receiver, sender)
|
|
end
|
|
|
|
def accepts_direct_messages?(%User{accepts_direct_messages_from: :everybody}, _), do: true
|
|
|
|
def accepts_direct_messages?(%User{accepts_direct_messages_from: :nobody}, _),
|
|
do: false
|
|
end
|