import Ecto.Query
alias Comeonin.Pbkdf2
+ alias Ecto.Multi
alias Pleroma.Activity
alias Pleroma.Keys
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Registration
alias Pleroma.Repo
+ alias Pleroma.RepoStreamer
alias Pleroma.User
alias Pleroma.Web
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.OStatus
alias Pleroma.Web.RelMe
alias Pleroma.Web.Websub
+ alias Pleroma.Workers.BackgroundWorker
require Logger
@strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/
@extended_local_nickname_regex ~r/^[a-zA-Z\d_-]+$/
+ defdelegate worker_args(queue), to: Pleroma.Workers.Helper
+
schema "users" do
field(:bio, :string)
field(:email, :string)
field(:avatar, :map)
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)
has_many(:notifications, Notification)
has_many(:registrations, Registration)
embeds_one(:info, User.Info)
def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa
def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers"
- def user_info(%User{} = user) do
+ @spec ap_following(User.t()) :: Sring.t()
+ def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa
+ def ap_following(%User{} = user), do: "#{ap_id(user)}/following"
+
+ def user_info(%User{} = user, args \\ %{}) do
+ following_count =
+ if args[:following_count],
+ do: args[:following_count],
+ else: user.info.following_count || following_count(user)
+
+ follower_count =
+ if args[:follower_count], do: args[:follower_count], else: user.info.follower_count
+
%{
- following_count: following_count(user),
note_count: user.info.note_count,
- follower_count: user.info.follower_count,
locked: user.info.locked,
confirmation_pending: user.info.confirmation_pending,
default_scope: user.info.default_scope
}
+ |> Map.put(:following_count, following_count)
+ |> Map.put(:follower_count, follower_count)
end
+ def set_info_cache(user, args) do
+ Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user, args))
+ end
+
+ @spec restrict_deactivated(Ecto.Query.t()) :: Ecto.Query.t()
def restrict_deactivated(query) do
from(u in query,
where: not fragment("? \\? 'deactivated' AND ?->'deactivated' @> 'true'", u.info, u.info)
if changes.valid? do
case info_cng.changes[:source_data] do
- %{"followers" => followers} ->
+ %{"followers" => followers, "following" => following} ->
changes
|> put_change(:follower_address, followers)
+ |> put_change(:following_address, following)
_ ->
followers = User.ap_followers(%User{nickname: changes.changes[:nickname]})
|> User.Info.user_upgrade(params[:info])
struct
- |> cast(params, [:bio, :name, :follower_address, :avatar, :last_refreshed_at])
+ |> cast(params, [
+ :bio,
+ :name,
+ :follower_address,
+ :following_address,
+ :avatar,
+ :last_refreshed_at
+ ])
|> unique_constraint(:nickname)
|> validate_format(:nickname, local_nickname_regex())
|> validate_length(:bio, max: 5000)
end
def password_update_changeset(struct, params) do
- changeset =
- struct
- |> cast(params, [:password, :password_confirmation])
- |> validate_required([:password, :password_confirmation])
- |> validate_confirmation(:password)
-
- OAuth.Token.delete_user_tokens(struct)
- OAuth.Authorization.delete_user_authorizations(struct)
-
- if changeset.valid? do
- hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
-
- changeset
- |> put_change(:password_hash, hashed)
- else
- changeset
+ struct
+ |> cast(params, [:password, :password_confirmation])
+ |> validate_required([:password, :password_confirmation])
+ |> validate_confirmation(:password)
+ |> put_password_hash
+ end
+
+ @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
+ def reset_password(%User{id: user_id} = user, data) do
+ multi =
+ Multi.new()
+ |> Multi.update(:user, password_update_changeset(user, data))
+ |> 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 reset_password(user, data) do
- update_and_set_cache(password_update_changeset(user, data))
- end
-
def register_changeset(struct, params \\ %{}, opts \\ []) do
need_confirmation? =
if is_nil(opts[:need_confirmation]) do
end
if changeset.valid? do
- hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]})
followers = User.ap_followers(%User{nickname: changeset.changes[:nickname]})
changeset
- |> put_change(:password_hash, hashed)
+ |> put_password_hash
|> put_change(:ap_id, ap_id)
|> unique_constraint(:ap_id)
|> put_change(:following, [followers])
def needs_update?(_), do: true
+ @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()}
def maybe_direct_follow(%User{} = follower, %User{local: true, info: %{locked: true}}) do
{:ok, follower}
end
{1, [follower]} = Repo.update_all(q, [])
+ follower = maybe_update_following_count(follower)
+
{:ok, _} = update_follower_count(followed)
set_cache(follower)
{1, [follower]} = Repo.update_all(q, [])
+ follower = maybe_update_following_count(follower)
+
{:ok, followed} = update_follower_count(followed)
set_cache(follower)
end
def update_and_set_cache(changeset) do
- with {:ok, user} <- Repo.update(changeset) do
+ with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
set_cache(user)
else
e -> e
end
@doc "Fetch some posts when the user has just been federated with"
- def fetch_initial_posts(user),
- do: PleromaJobQueue.enqueue(:background, __MODULE__, [:fetch_initial_posts, user])
+ def fetch_initial_posts(user) do
+ %{"op" => "fetch_initial_posts", "user_id" => user.id}
+ |> BackgroundWorker.new(worker_args(:background))
+ |> Repo.insert()
+ end
@spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t()
def get_followers_query(%User{} = user, nil) do
@spec get_followers_query(User.t()) :: Ecto.Query.t()
def get_followers_query(user), do: get_followers_query(user, nil)
+ @spec get_followers(User.t(), pos_integer()) :: {:ok, list(User.t())}
def get_followers(user, page \\ nil) do
q = get_followers_query(user, page)
{:ok, Repo.all(q)}
end
+ @spec get_external_followers(User.t(), pos_integer()) :: {:ok, list(User.t())}
+ def get_external_followers(user, page \\ nil) do
+ q =
+ user
+ |> get_followers_query(page)
+ |> User.Query.build(%{external: true})
+
+ {:ok, Repo.all(q)}
+ end
+
def get_followers_ids(user, page \\ nil) do
q = get_followers_query(user, page)
|> update_and_set_cache()
end
+ 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
+ info_cng = User.Info.follow_information_update(user.info, info)
+
+ changeset =
+ user
+ |> change()
+ |> put_embed(:info, info_cng)
+
+ update_and_set_cache(changeset)
+ else
+ {:error, _} = e -> e
+ e -> {:error, e}
+ end
+ end
+
def update_follower_count(%User{} = user) do
- follower_count_query =
- User.Query.build(%{followers: user, deactivated: false})
- |> select([u], %{count: count(u.id)})
+ if user.local or !Pleroma.Config.get([:instance, :external_user_synchronization]) do
+ follower_count_query =
+ User.Query.build(%{followers: user, deactivated: false})
+ |> select([u], %{count: count(u.id)})
+
+ User
+ |> where(id: ^user.id)
+ |> join(:inner, [u], s in subquery(follower_count_query))
+ |> update([u, s],
+ set: [
+ info:
+ fragment(
+ "jsonb_set(?, '{follower_count}', ?::varchar::jsonb, true)",
+ u.info,
+ s.count
+ )
+ ]
+ )
+ |> select([u], u)
+ |> Repo.update_all([])
+ |> case do
+ {1, [user]} -> set_cache(user)
+ _ -> {:error, user}
+ end
+ else
+ {:ok, maybe_fetch_follow_information(user)}
+ end
+ end
- User
- |> where(id: ^user.id)
- |> join(:inner, [u], s in subquery(follower_count_query))
- |> update([u, s],
- set: [
- info:
- fragment(
- "jsonb_set(?, '{follower_count}', ?::varchar::jsonb, true)",
- u.info,
- s.count
- )
- ]
- )
- |> select([u], u)
- |> Repo.update_all([])
- |> case do
- {1, [user]} -> set_cache(user)
- _ -> {:error, user}
+ def maybe_update_following_count(%User{local: false} = user) do
+ if Pleroma.Config.get([:instance, :external_user_synchronization]) do
+ {:ok, maybe_fetch_follow_information(user)}
+ else
+ user
end
end
+ def maybe_update_following_count(user), do: user
+
def remove_duplicated_following(%User{following: following} = user) do
uniq_following = Enum.uniq(following)
|> Repo.all()
end
- def mute(muter, %User{ap_id: ap_id}) do
+ @spec mute(User.t(), User.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()}
+ def mute(muter, %User{ap_id: ap_id}, notifications? \\ true) do
+ info = muter.info
+
info_cng =
- muter.info
- |> User.Info.add_to_mutes(ap_id)
+ User.Info.add_to_mutes(info, ap_id)
+ |> User.Info.add_to_muted_notifications(info, ap_id, notifications?)
cng =
change(muter)
end
def unmute(muter, %{ap_id: ap_id}) do
+ info = muter.info
+
info_cng =
- muter.info
- |> User.Info.remove_from_mutes(ap_id)
+ User.Info.remove_from_mutes(info, ap_id)
+ |> User.Info.remove_from_muted_notifications(info, ap_id)
cng =
change(muter)
def mutes?(nil, _), do: false
def mutes?(user, %{ap_id: ap_id}), do: Enum.member?(user.info.mutes, ap_id)
- def blocks?(user, %{ap_id: ap_id}) do
- blocks = user.info.blocks
- domain_blocks = user.info.domain_blocks
- %{host: host} = URI.parse(ap_id)
+ @spec muted_notifications?(User.t() | nil, User.t() | map()) :: boolean()
+ def muted_notifications?(nil, _), do: false
- Enum.member?(blocks, ap_id) ||
- Enum.any?(domain_blocks, fn domain ->
- host == domain
- end)
+ def muted_notifications?(user, %{ap_id: ap_id}),
+ do: Enum.member?(user.info.muted_notifications, ap_id)
+
+ def blocks?(%User{} = user, %User{} = target) do
+ blocks_ap_id?(user, target) || blocks_domain?(user, target)
end
+ def blocks?(nil, _), do: false
+
+ def blocks_ap_id?(%User{} = user, %User{} = target) do
+ Enum.member?(user.info.blocks, target.ap_id)
+ end
+
+ def blocks_ap_id?(_, _), do: false
+
+ def blocks_domain?(%User{} = user, %User{} = target) do
+ domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.info.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, %{ap_id: ap_id}) do
with %User{} = target <- get_cached_by_ap_id(ap_id) do
Enum.member?(target.info.subscribers, user.ap_id)
end
def deactivate_async(user, status \\ true) do
- PleromaJobQueue.enqueue(:background, __MODULE__, [:deactivate_async, user, status])
+ %{"op" => "deactivate_user", "user_id" => user.id, "status" => status}
+ |> BackgroundWorker.new(worker_args(:background))
+ |> Repo.insert()
end
def deactivate(%User{} = user, status \\ true) do
|> update_and_set_cache()
end
- @spec delete(User.t()) :: :ok
- def delete(%User{} = user),
- do: PleromaJobQueue.enqueue(:background, __MODULE__, [:delete, user])
+ def delete(%User{} = user) do
+ %{"op" => "delete_user", "user_id" => user.id}
+ |> BackgroundWorker.new(worker_args(:background))
+ |> Repo.insert()
+ end
@spec perform(atom(), User.t()) :: {:ok, User.t()}
def perform(:delete, %User{} = user) do
- {:ok, user} = User.deactivate(user)
+ {:ok, _user} = ActivityPub.delete(user)
# Remove all relationships
{:ok, followers} = User.get_followers(user)
- Enum.each(followers, fn follower -> User.unfollow(follower, user) end)
+ Enum.each(followers, fn follower ->
+ ActivityPub.unfollow(follower, user)
+ User.unfollow(follower, user)
+ end)
{:ok, friends} = User.get_friends(user)
- Enum.each(friends, fn followed -> User.unfollow(user, followed) end)
+ Enum.each(friends, fn followed ->
+ ActivityPub.unfollow(user, followed)
+ User.unfollow(user, followed)
+ end)
delete_user_activities(user)
+ invalidate_cache(user)
+ Repo.delete(user)
end
@spec perform(atom(), User.t()) :: {:ok, User.t()}
)
end
- def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers),
- do:
- PleromaJobQueue.enqueue(:background, __MODULE__, [
- :blocks_import,
- blocker,
- blocked_identifiers
- ])
+ @spec external_users_query() :: Ecto.Query.t()
+ def external_users_query do
+ User.Query.build(%{
+ external: true,
+ active: true,
+ order_by: :id
+ })
+ end
- def follow_import(%User{} = follower, followed_identifiers) when is_list(followed_identifiers),
- do:
- PleromaJobQueue.enqueue(:background, __MODULE__, [
- :follow_import,
- follower,
- followed_identifiers
- ])
+ @spec external_users(keyword()) :: [User.t()]
+ def external_users(opts \\ []) do
+ query =
+ external_users_query()
+ |> select([u], struct(u, [:id, :ap_id, :info]))
- def delete_user_activities(%User{ap_id: ap_id} = user) do
- stream =
- ap_id
- |> Activity.query_by_actor()
- |> Repo.stream()
+ query =
+ if opts[:max_id],
+ do: where(query, [u], u.id > ^opts[:max_id]),
+ else: query
- Repo.transaction(fn -> Enum.each(stream, &delete_activity(&1)) end, timeout: :infinity)
+ query =
+ if opts[:limit],
+ do: limit(query, ^opts[:limit]),
+ else: query
+
+ Repo.all(query)
+ end
+
+ def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers) do
+ %{
+ "op" => "blocks_import",
+ "blocker_id" => blocker.id,
+ "blocked_identifiers" => blocked_identifiers
+ }
+ |> BackgroundWorker.new(worker_args(:background))
+ |> Repo.insert()
+ end
+
+ def follow_import(%User{} = follower, followed_identifiers)
+ when is_list(followed_identifiers) do
+ %{
+ "op" => "follow_import",
+ "follower_id" => follower.id,
+ "followed_identifiers" => followed_identifiers
+ }
+ |> BackgroundWorker.new(worker_args(:background))
+ |> Repo.insert()
+ end
+
+ def delete_user_activities(%User{ap_id: ap_id} = user) do
+ ap_id
+ |> Activity.query_by_actor()
+ |> RepoStreamer.chunk_stream(50)
+ |> Stream.each(fn activities ->
+ Enum.each(activities, &delete_activity(&1))
+ end)
+ |> Stream.run()
{:ok, user}
end
defp delete_activity(%{data: %{"type" => "Create"}} = activity) do
- Object.normalize(activity) |> ActivityPub.delete()
+ activity
+ |> Object.normalize()
+ |> ActivityPub.delete()
+ end
+
+ defp delete_activity(%{data: %{"type" => "Like"}} = activity) do
+ user = get_cached_by_ap_id(activity.actor)
+ object = Object.normalize(activity)
+
+ ActivityPub.unlike(user, object)
+ end
+
+ defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do
+ user = get_cached_by_ap_id(activity.actor)
+ object = Object.normalize(activity)
+
+ ActivityPub.unannounce(user, object)
end
defp delete_activity(_activity), do: "Doing nothing"
end
end
- def get_or_create_instance_user do
- relay_uri = "#{Pleroma.Web.Endpoint.url()}/relay"
-
- if user = get_cached_by_ap_id(relay_uri) do
+ @doc "Creates an internal service actor by URI if missing. Optionally takes nickname for addressing."
+ def get_or_create_service_actor_by_ap_id(uri, nickname \\ nil) do
+ if user = get_cached_by_ap_id(uri) do
user
else
changes =
%User{info: %User.Info{}}
|> cast(%{}, [:ap_id, :nickname, :local])
- |> put_change(:ap_id, relay_uri)
- |> put_change(:nickname, nil)
+ |> put_change(:ap_id, uri)
+ |> put_change(:nickname, nickname)
|> put_change(:local, true)
- |> put_change(:follower_address, relay_uri <> "/followers")
+ |> put_change(:follower_address, uri <> "/followers")
{:ok, user} = Repo.insert(changes)
user
end
# OStatus Magic Key
- def public_key_from_info(%{magic_key: magic_key}) do
+ def public_key_from_info(%{magic_key: magic_key}) when not is_nil(magic_key) do
{:ok, Pleroma.Web.Salmon.decode_key(magic_key)}
end
+ def public_key_from_info(_), do: {:error, "not found key"}
+
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_from_info(user.info) do
data
|> Map.put(:name, blank?(data[:name]) || data[:nickname])
|> remote_user_creation()
- |> Repo.insert(on_conflict: :replace_all, conflict_target: :nickname)
+ |> Repo.insert(on_conflict: :replace_all_except_primary_key, conflict_target: :nickname)
|> set_cache()
end
target.ap_id not in user.info.muted_reblogs
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: fragment("not (?->'deactivated' @> 'true')", u.info),
+ 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{info: %{email_notifications: %{"digest" => false}}}, "digest", true)
+ Pleroma.User{info: %{email_notifications: %{"digest" => true}}}
+
+ iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => true}}}, "digest", false)
+ Pleroma.User{info: %{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
+ info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status})
+
+ change(user)
+ |> put_embed(:info, info)
+ |> update_and_set_cache()
+ 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
need_confirmation? = !user.info.confirmation_pending
}
end
- def ensure_keys_present(user) do
- info = user.info
-
+ def ensure_keys_present(%User{info: info} = user) do
if info.keys do
{:ok, user}
else
{:ok, pem} = Keys.generate_rsa_pem()
- info_cng =
- info
- |> User.Info.set_keys(pem)
-
- cng =
- Ecto.Changeset.change(user)
- |> Ecto.Changeset.put_embed(:info, info_cng)
-
- update_and_set_cache(cng)
+ user
+ |> Ecto.Changeset.change()
+ |> Ecto.Changeset.put_embed(:info, User.Info.set_keys(info, pem))
+ |> update_and_set_cache()
end
end
end
defdelegate search(query, opts \\ []), to: User.Search
+
+ defp put_password_hash(
+ %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
+ ) do
+ change(changeset, password_hash: Pbkdf2.hashpwsalt(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
end