2c55f7d7cb
Current FedSocket implementation has a bunch of problems. It doesn't have proper error handling (in case of an error the server just doesn't respond until the connection is closed, while the client doesn't match any error messages and just assumes there has been an error after 15s) and the code is full of bad descisions (see: fetch registry which uses uuids for no reason and waits for a response by recursively querying a ets table until the value changes, or double JSON encoding). Sometime ago I almost completed rewriting fedsockets from scrach to adress these issues. However, while doing so, I realized that fedsockets are just too overkill for what they were trying to accomplish, which is reduce the overhead of federation by not signing every message. This could be done without reimplementing failure states and endpoint logic we already have with HTTP by, for example, using TLS cert auth, or switching to a more performant signature algorithm. I opened https://git.pleroma.social/pleroma/pleroma/-/issues/2262 for further discussion on alternatives to fedsockets. From discussions I had with other Pleroma developers it seems like they would approve the descision to remove them as well, therefore I am submitting this patch.
2387 lines
71 KiB
Elixir
2387 lines
71 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2020 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
|
|
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.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
|
|
]
|
|
]
|
|
|
|
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(: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(:confirmation_pending, :boolean, default: false)
|
|
field(:password_reset_pending, :boolean, default: false)
|
|
field(:approval_pending, :boolean, default: false)
|
|
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(:deactivated, :boolean, default: false)
|
|
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(:pinned_activities, {:array, :string}, default: [])
|
|
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, :string}, default: [])
|
|
field(:inbox, :string)
|
|
field(:shared_inbox, :string)
|
|
field(:accepts_chat_messages, :boolean, default: nil)
|
|
|
|
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
|
|
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
|
|
|
|
# `: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 unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do
|
|
target_users_query = assoc(user, unquote(outgoing_relation_target))
|
|
|
|
if restrict_deactivated? do
|
|
restrict_deactivated(target_users_query)
|
|
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
|
|
|
|
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{deactivated: true}), do: :deactivated
|
|
def account_status(%User{password_reset_pending: true}), do: :password_reset_pending
|
|
def account_status(%User{local: true, approval_pending: true}), do: :approval_pending
|
|
|
|
def account_status(%User{local: true, confirmation_pending: true}) do
|
|
if Config.get([:instance, :account_activation_required]) do
|
|
:confirmation_pending
|
|
else
|
|
:active
|
|
end
|
|
end
|
|
|
|
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
|
|
case user.avatar do
|
|
%{"url" => [%{"href" => href} | _]} ->
|
|
href
|
|
|
|
_ ->
|
|
unless options[:no_default] do
|
|
Config.get([:assets, :default_user_avatar], "#{Web.base_url()}/images/avi.png")
|
|
end
|
|
end
|
|
end
|
|
|
|
def banner_url(user, options \\ []) do
|
|
case user.banner do
|
|
%{"url" => [%{"href" => href} | _]} -> href
|
|
_ -> !options[:no_default] && "#{Web.base_url()}/images/banner.png"
|
|
end
|
|
end
|
|
|
|
# Should probably be renamed or removed
|
|
def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}"
|
|
|
|
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 restrict_deactivated(Ecto.Query.t()) :: Ecto.Query.t()
|
|
def restrict_deactivated(query) do
|
|
from(u in query, where: u.deactivated != ^true)
|
|
end
|
|
|
|
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,
|
|
: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
|
|
]
|
|
)
|
|
|> 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)
|
|
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,
|
|
:background,
|
|
:show_role,
|
|
:skip_thread_containment,
|
|
:fields,
|
|
:raw_fields,
|
|
:pleroma_settings_store,
|
|
:is_discoverable,
|
|
:actor_type,
|
|
:also_known_as,
|
|
:accepts_chat_messages
|
|
]
|
|
)
|
|
|> 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
|
|
_ -> 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_exclusion(:nickname, Config.get([User, :restricted_nicknames]))
|
|
|> validate_format(:nickname, local_nickname_regex())
|
|
|> put_ap_id()
|
|
|> unique_constraint(:ap_id)
|
|
|> put_following_and_follower_address()
|
|
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)
|
|
|
|
need_confirmation? =
|
|
if is_nil(opts[:need_confirmation]) do
|
|
Config.get([:instance, :account_activation_required])
|
|
else
|
|
opts[:need_confirmation]
|
|
end
|
|
|
|
need_approval? =
|
|
if is_nil(opts[:need_approval]) do
|
|
Config.get([:instance, :account_approval_required])
|
|
else
|
|
opts[:need_approval]
|
|
end
|
|
|
|
struct
|
|
|> confirmation_changeset(need_confirmation: need_confirmation?)
|
|
|> approval_changeset(need_approval: need_approval?)
|
|
|> cast(params, [
|
|
:bio,
|
|
:raw_bio,
|
|
:email,
|
|
:name,
|
|
:nickname,
|
|
:password,
|
|
:password_confirmation,
|
|
:emoji,
|
|
:accepts_chat_messages,
|
|
:registration_reason
|
|
])
|
|
|> 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_address()
|
|
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 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_address(changeset) do
|
|
followers = ap_followers(%User{nickname: get_field(changeset, :nickname)})
|
|
|
|
changeset
|
|
|> put_change(:follower_address, followers)
|
|
end
|
|
|
|
defp autofollow_users(user) do
|
|
candidates = Config.get([:instance, :autofollowed_nicknames])
|
|
|
|
autofollowed_users =
|
|
User.Query.build(%{nickname: candidates, local: true, deactivated: false})
|
|
|> 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{} = user) do
|
|
with {:ok, user} <- autofollow_users(user),
|
|
{:ok, _} <- autofollowing_users(user),
|
|
{:ok, user} <- set_cache(user),
|
|
{:ok, _} <- send_welcome_email(user),
|
|
{:ok, _} <- send_welcome_message(user),
|
|
{:ok, _} <- send_welcome_chat_message(user),
|
|
{:ok, _} <- try_send_confirmation_email(user) do
|
|
{:ok, user}
|
|
end
|
|
end
|
|
|
|
def send_welcome_message(user) do
|
|
if User.WelcomeMessage.enabled?() do
|
|
User.WelcomeMessage.post_message(user)
|
|
{:ok, :enqueued}
|
|
else
|
|
{:ok, :noop}
|
|
end
|
|
end
|
|
|
|
def send_welcome_chat_message(user) do
|
|
if User.WelcomeChatMessage.enabled?() do
|
|
User.WelcomeChatMessage.post_message(user)
|
|
{:ok, :enqueued}
|
|
else
|
|
{:ok, :noop}
|
|
end
|
|
end
|
|
|
|
def 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
|
|
|
|
def send_welcome_email(_), do: {:ok, :noop}
|
|
|
|
@spec try_send_confirmation_email(User.t()) :: {:ok, :enqueued | :noop}
|
|
def try_send_confirmation_email(%User{confirmation_pending: true, 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 try_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
|
|
|
|
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}
|
|
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
|
|
followed.deactivated ->
|
|
{: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)
|
|
|
|
{:ok, _} = update_follower_count(followed)
|
|
|
|
follower
|
|
|> update_following_count()
|
|
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)
|
|
{:ok, followed} = update_follower_count(followed)
|
|
|
|
{:ok, follower} = update_following_count(follower)
|
|
|
|
{:ok, 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
|
|
|
|
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}")
|
|
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, deactivated: false})
|
|
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, deactivated: false}
|
|
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, deactivated: false})
|
|
|
|
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
|
|
|
|
{: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
|
|
{: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 as well
|
|
blocked =
|
|
case CommonAPI.reject_follow_request(blocked, blocker) do
|
|
{:ok, %User{} = updated_blocked} -> updated_blocked
|
|
nil -> blocked
|
|
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
|
|
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 deactivate_async(user, status \\ true) do
|
|
BackgroundWorker.enqueue("deactivate_user", %{"user_id" => user.id, "status" => status})
|
|
end
|
|
|
|
def deactivate(user, status \\ true)
|
|
|
|
def deactivate(users, status) when is_list(users) do
|
|
Repo.transaction(fn ->
|
|
for user <- users, do: deactivate(user, status)
|
|
end)
|
|
end
|
|
|
|
def deactivate(%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{} = user) do
|
|
change(user, approval_pending: false)
|
|
|> 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,
|
|
keys: nil,
|
|
public_key: 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,
|
|
confirmation_pending: false,
|
|
password_reset_pending: false,
|
|
approval_pending: false,
|
|
registration_reason: nil,
|
|
confirmation_token: nil,
|
|
domain_blocks: [],
|
|
deactivated: true,
|
|
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: []
|
|
})
|
|
end
|
|
|
|
def delete(users) when is_list(users) do
|
|
for user <- users, do: delete(user)
|
|
end
|
|
|
|
def delete(%User{} = user) do
|
|
BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id})
|
|
end
|
|
|
|
defp delete_and_invalidate_cache(%User{} = user) do
|
|
invalidate_cache(user)
|
|
Repo.delete(user)
|
|
end
|
|
|
|
defp delete_or_deactivate(%User{local: false} = user), do: delete_and_invalidate_cache(user)
|
|
|
|
defp delete_or_deactivate(%User{local: true} = user) do
|
|
status = account_status(user)
|
|
|
|
case status do
|
|
:confirmation_pending ->
|
|
delete_and_invalidate_cache(user)
|
|
|
|
:approval_pending ->
|
|
delete_and_invalidate_cache(user)
|
|
|
|
_ ->
|
|
user
|
|
|> purge_user_changeset()
|
|
|> update_and_set_cache()
|
|
end
|
|
end
|
|
|
|
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
|
|
# 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)
|
|
|
|
delete_or_deactivate(user)
|
|
end
|
|
|
|
def perform(:deactivate_async, user, status), do: deactivate(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
|
|
|> 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(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(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, deactivated: false})
|
|
|> 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.deactivated != ^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 toggle_confirmation(User.t()) :: {:ok, User.t()} | {:error, Changeset.t()}
|
|
def toggle_confirmation(%User{} = user) do
|
|
user
|
|
|> confirmation_changeset(need_confirmation: !user.confirmation_pending)
|
|
|> update_and_set_cache()
|
|
end
|
|
|
|
@spec toggle_confirmation([User.t()]) :: [{:ok, User.t()} | {:error, Changeset.t()}]
|
|
def toggle_confirmation(users) do
|
|
Enum.map(users, &toggle_confirmation/1)
|
|
end
|
|
|
|
@spec need_confirmation(User.t(), boolean()) :: {:ok, User.t()} | {:error, Changeset.t()}
|
|
def need_confirmation(%User{} = user, bool) do
|
|
user
|
|
|> confirmation_changeset(need_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 ensure_keys_present(%{keys: keys} = user) when not is_nil(keys), do: {:ok, user}
|
|
|
|
def ensure_keys_present(%User{} = user) do
|
|
with {:ok, pem} <- Keys.generate_rsa_pem() do
|
|
user
|
|
|> cast(%{keys: pem}, [:keys])
|
|
|> validate_required([:keys])
|
|
|> update_and_set_cache()
|
|
end
|
|
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: 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])
|
|
|> validate_required([:email])
|
|
|> unique_constraint(:email)
|
|
|> validate_format(:email, @email_regex)
|
|
|> update_and_set_cache()
|
|
end
|
|
|
|
# Internal function; public one is `deactivate/2`
|
|
defp set_activation_status(user, deactivated) do
|
|
user
|
|
|> cast(%{deactivated: deactivated}, [:deactivated])
|
|
|> 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 roles(%{is_moderator: is_moderator, is_admin: is_admin}) do
|
|
%{
|
|
admin: is_admin,
|
|
moderator: is_moderator
|
|
}
|
|
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
|
|
|
|
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, need_confirmation: need_confirmation?) do
|
|
params =
|
|
if need_confirmation? do
|
|
%{
|
|
confirmation_pending: true,
|
|
confirmation_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64()
|
|
}
|
|
else
|
|
%{
|
|
confirmation_pending: false,
|
|
confirmation_token: nil
|
|
}
|
|
end
|
|
|
|
cast(user, params, [:confirmation_pending, :confirmation_token])
|
|
end
|
|
|
|
@spec approval_changeset(User.t(), keyword()) :: Changeset.t()
|
|
def approval_changeset(user, need_approval: need_approval?) do
|
|
params = if need_approval?, do: %{approval_pending: true}, else: %{approval_pending: false}
|
|
cast(user, params, [:approval_pending])
|
|
end
|
|
|
|
def add_pinnned_activity(user, %Pleroma.Activity{id: id}) do
|
|
if id not in user.pinned_activities do
|
|
max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0)
|
|
params = %{pinned_activities: user.pinned_activities ++ [id]}
|
|
|
|
# if pinned activity was scheduled for deletion, we remove job
|
|
if expiration = Pleroma.Workers.PurgeExpiredActivity.get_expiration(id) do
|
|
Oban.cancel_job(expiration.id)
|
|
end
|
|
|
|
user
|
|
|> cast(params, [:pinned_activities])
|
|
|> validate_length(:pinned_activities,
|
|
max: max_pinned_statuses,
|
|
message: "You have already pinned the maximum number of statuses"
|
|
)
|
|
else
|
|
change(user)
|
|
end
|
|
|> update_and_set_cache()
|
|
end
|
|
|
|
def remove_pinnned_activity(user, %Pleroma.Activity{id: id, data: data}) do
|
|
params = %{pinned_activities: List.delete(user.pinned_activities, id)}
|
|
|
|
# if pinned activity was scheduled for deletion, we reschedule it for deletion
|
|
if data["expires_at"] do
|
|
# MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation
|
|
{:ok, expires_at} =
|
|
data["expires_at"] |> Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime.cast()
|
|
|
|
Pleroma.Workers.PurgeExpiredActivity.enqueue(%{
|
|
activity_id: id,
|
|
expires_at: expires_at
|
|
})
|
|
end
|
|
|
|
user
|
|
|> cast(params, [:pinned_activities])
|
|
|> 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
|
|
UserRelationship.create_block(user, blocked)
|
|
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
|
|
UserRelationship.delete_block(user, blocked)
|
|
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
|
|
end
|