diff options
Diffstat (limited to 'lib/pleroma/user.ex')
-rw-r--r-- | lib/pleroma/user.ex | 969 |
1 files changed, 798 insertions, 171 deletions
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 3bd92c157..78eb29ddd 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1,13 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.User do use Ecto.Schema - import Ecto.{Changeset, Query} - alias Pleroma.{Repo, User, Object, Web, Activity, Notification} + import Ecto.Changeset + import Ecto.Query + alias Comeonin.Pbkdf2 + alias Pleroma.Activity alias Pleroma.Formatter + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Registration + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils - alias Pleroma.Web.{OStatus, Websub, OAuth} - alias Pleroma.Web.ActivityPub.{Utils, ActivityPub} + alias Pleroma.Web.OAuth + alias Pleroma.Web.OStatus + alias Pleroma.Web.RelMe + alias Pleroma.Web.Websub + + require Logger + + @type t :: %__MODULE__{} + + @primary_key {:id, Pleroma.FlakeId, 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_-]+$/ schema "users" do field(:bio, :string) @@ -22,25 +50,48 @@ defmodule Pleroma.User do field(:avatar, :map) field(:local, :boolean, default: true) field(:follower_address, :string) - field(:search_distance, :float, virtual: true) - field(:last_refreshed_at, :naive_datetime) + field(:search_rank, :float, virtual: true) + field(:search_type, :integer, virtual: true) + field(:tags, {:array, :string}, default: []) + field(:bookmarks, {:array, :string}, default: []) + field(:last_refreshed_at, :naive_datetime_usec) has_many(:notifications, Notification) + has_many(:registrations, Registration) embeds_one(:info, Pleroma.User.Info) timestamps() end - def avatar_url(user) do + def auth_active?(%User{info: %User.Info{confirmation_pending: true}}), + do: !Pleroma.Config.get([:instance, :account_activation_required]) + + def auth_active?(%User{}), do: true + + def visible_for?(user, for_user \\ nil) + + def visible_for?(%User{id: user_id}, %User{id: for_id}) when user_id == for_id, do: true + + def visible_for?(%User{} = user, for_user) do + auth_active?(user) || superuser?(for_user) + end + + def visible_for?(_, _), do: false + + def superuser?(%User{local: true, info: %User.Info{is_admin: true}}), do: true + def superuser?(%User{local: true, info: %User.Info{is_moderator: true}}), do: true + def superuser?(_), do: false + + def avatar_url(user, options \\ []) do case user.avatar do %{"url" => [%{"href" => href} | _]} -> href - _ -> "#{Web.base_url()}/images/avi.png" + _ -> !options[:no_default] && "#{Web.base_url()}/images/avi.png" end end - def banner_url(user) do + def banner_url(user, options \\ []) do case user.info.banner do %{"url" => [%{"href" => href} | _]} -> href - _ -> "#{Web.base_url()}/images/banner.png" + _ -> !options[:no_default] && "#{Web.base_url()}/images/banner.png" end end @@ -52,19 +103,8 @@ defmodule Pleroma.User do "#{Web.base_url()}/users/#{nickname}" end - def ap_followers(%User{} = user) do - "#{ap_id(user)}/followers" - end - - def follow_changeset(struct, params \\ %{}) do - struct - |> cast(params, [:following]) - |> validate_required([:following]) - end - - def info_changeset(struct, params \\ %{}) do - raise "NOT VALID ANYMORE" - end + 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 oneself = if user.local, do: 1, else: 0 @@ -74,11 +114,11 @@ defmodule Pleroma.User do 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 } end - @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])?)*$/ def remote_user_creation(params) do params = params @@ -118,7 +158,7 @@ defmodule Pleroma.User do struct |> cast(params, [:bio, :name, :avatar]) |> unique_constraint(:nickname) - |> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/) + |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: 5000) |> validate_length(:name, min: 1, max: 100) end @@ -135,7 +175,7 @@ defmodule Pleroma.User do struct |> cast(params, [:bio, :name, :follower_address, :avatar, :last_refreshed_at]) |> unique_constraint(:nickname) - |> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/) + |> validate_format(:nickname, local_nickname_regex()) |> validate_length(:bio, max: 5000) |> validate_length(:name, max: 100) |> put_embed(:info, info_cng) @@ -165,18 +205,36 @@ defmodule Pleroma.User do update_and_set_cache(password_update_changeset(user, data)) end - def register_changeset(struct, params \\ %{}) do + def register_changeset(struct, params \\ %{}, opts \\ []) do + confirmation_status = + if opts[:confirmed] || !Pleroma.Config.get([:instance, :account_activation_required]) do + :confirmed + else + :unconfirmed + end + + info_change = User.Info.confirmation_changeset(%User.Info{}, confirmation_status) + changeset = struct |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) - |> validate_required([:email, :name, :nickname, :password, :password_confirmation]) + |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) |> unique_constraint(:email) |> unique_constraint(:nickname) - |> validate_format(:nickname, ~r/^[a-zA-Z\d]+$/) + |> validate_exclusion(:nickname, Pleroma.Config.get([Pleroma.User, :restricted_nicknames])) + |> validate_format(:nickname, local_nickname_regex()) |> validate_format(:email, @email_regex) |> validate_length(:bio, max: 1000) |> validate_length(:name, min: 1, max: 100) + |> put_change(:info, info_change) + + changeset = + if opts[:external] do + changeset + else + validate_required(changeset, [:email]) + end if changeset.valid? do hashed = Pbkdf2.hashpwsalt(changeset.changes[:password]) @@ -186,6 +244,7 @@ defmodule Pleroma.User do changeset |> put_change(:password_hash, hashed) |> put_change(:ap_id, ap_id) + |> unique_constraint(:ap_id) |> put_change(:following, [followers]) |> put_change(:follower_address, followers) else @@ -193,12 +252,48 @@ defmodule Pleroma.User do end end + defp autofollow_users(user) do + candidates = Pleroma.Config.get([:instance, :autofollowed_nicknames]) + + autofollowed_users = + from(u in User, + where: u.local == true, + where: u.nickname in ^candidates + ) + |> Repo.all() + + follow_all(user, autofollowed_users) + 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), + {:ok, user} <- autofollow_users(user), + {:ok, _} <- Pleroma.User.WelcomeMessage.post_welcome_message_to_user(user), + {:ok, _} <- try_send_confirmation_email(user) do + {:ok, user} + end + end + + def try_send_confirmation_email(%User{} = user) do + if user.info.confirmation_pending && + Pleroma.Config.get([:instance, :account_activation_required]) do + user + |> Pleroma.Emails.UserEmail.account_confirmation_email() + |> Pleroma.Emails.Mailer.deliver_async() + + {:ok, :enqueued} + else + {:ok, :noop} + end + 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) >= 86400 + NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= 86_400 end def needs_update?(_), do: true @@ -212,14 +307,14 @@ defmodule Pleroma.User do end def maybe_direct_follow(%User{} = follower, %User{} = followed) do - if !User.ap_enabled?(followed) do + if not User.ap_enabled?(followed) do follow(follower, followed) else {:ok, follower} end end - def maybe_follow(%User{} = follower, %User{info: info} = followed) do + def maybe_follow(%User{} = follower, %User{info: _info} = followed) do if not following?(follower, followed) do follow(follower, followed) else @@ -227,6 +322,39 @@ defmodule Pleroma.User do 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 + followed_addresses = + followeds + |> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end) + |> Enum.map(fn %{follower_address: fa} -> fa end) + + q = + from(u in User, + where: u.id == ^follower.id, + update: [ + set: [ + following: + fragment( + "array(select distinct unnest (array_cat(?, ?)))", + u.following, + ^followed_addresses + ) + ] + ], + select: u + ) + + {1, [follower]} = Repo.update_all(q, []) + + Enum.each(followeds, fn followed -> + update_follower_count(followed) + end) + + set_cache(follower) + end + def follow(%User{} = follower, %User{info: info} = followed) do user_config = Application.get_env(:pleroma, :user) deny_follow_blocked = Keyword.get(user_config, :deny_follow_blocked) @@ -245,18 +373,18 @@ defmodule Pleroma.User do Websub.subscribe(follower, followed) end - following = - [ap_followers | follower.following] - |> Enum.uniq() + q = + from(u in User, + where: u.id == ^follower.id, + update: [push: [following: ^ap_followers]], + select: u + ) - follower = - follower - |> follow_changeset(%{following: following}) - |> update_and_set_cache + {1, [follower]} = Repo.update_all(q, []) {:ok, _} = update_follower_count(followed) - follower + set_cache(follower) end end @@ -264,41 +392,80 @@ defmodule Pleroma.User do ap_followers = followed.follower_address if following?(follower, followed) and follower.ap_id != followed.ap_id do - following = - follower.following - |> List.delete(ap_followers) + q = + from(u in User, + where: u.id == ^follower.id, + update: [pull: [following: ^ap_followers]], + select: u + ) - {:ok, follower} = - follower - |> follow_changeset(%{following: following}) - |> update_and_set_cache + {1, [follower]} = Repo.update_all(q, []) {:ok, followed} = update_follower_count(followed) + set_cache(follower) + {:ok, follower, Utils.fetch_latest_follow(follower, followed)} else {:error, "Not subscribed!"} end end + @spec following?(User.t(), User.t()) :: boolean def following?(%User{} = follower, %User{} = followed) do Enum.member?(follower.following, followed.follower_address) end + def follow_import(%User{} = follower, followed_identifiers) + when is_list(followed_identifiers) do + Enum.map( + followed_identifiers, + fn followed_identifier -> + with %User{} = followed <- get_or_fetch(followed_identifier), + {:ok, follower} <- maybe_direct_follow(follower, followed), + {:ok, _} <- ActivityPub.follow(follower, followed) do + followed + else + err -> + Logger.debug("follow_import failed for #{followed_identifier} with: #{inspect(err)}") + err + end + end + ) + end + def locked?(%User{} = user) do user.info.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 + # 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_by_nickname(nickname) + end + + def set_cache(user) do + Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) + Cachex.put(:user_cache, "nickname:#{user.nickname}", user) + Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user)) + {:ok, user} + end + def update_and_set_cache(changeset) do with {:ok, user} <- Repo.update(changeset) do - Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) - Cachex.put(:user_cache, "nickname:#{user.nickname}", user) - Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user)) - {:ok, user} + set_cache(user) else e -> e end @@ -315,20 +482,44 @@ defmodule Pleroma.User do Cachex.fetch!(:user_cache, key, fn _ -> get_by_ap_id(ap_id) 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 _ -> get_or_fetch_by_nickname(nickname) end) end + def get_cached_by_nickname_or_id(nickname_or_id) do + get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id) + end + def get_by_nickname(nickname) do - Repo.get_by(User, nickname: nickname) + 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 - case user = Repo.get_by(User, nickname: nickname_or_email) do - %User{} -> user - nil -> Repo.get_by(User, email: nickname_or_email) - end + get_by_nickname(nickname_or_email) || get_by_email(nickname_or_email) end def get_cached_user_info(user) do @@ -352,6 +543,10 @@ defmodule Pleroma.User do _e -> with [_nick, _domain] <- String.split(nickname, "@"), {:ok, user} <- fetch_by_nickname(nickname) do + if Pleroma.Config.get([:fetch_initial_posts, :enabled]) do + {:ok, _} = Task.start(__MODULE__, :fetch_initial_posts, [user]) + end + user else _e -> nil @@ -359,7 +554,18 @@ defmodule Pleroma.User do end end - def get_followers_query(%User{id: id, follower_address: follower_address}) do + @doc "Fetch some posts when the user has just been federated with" + def fetch_initial_posts(user) do + pages = Pleroma.Config.get!([:fetch_initial_posts, :pages]) + + Enum.each( + # Insert all the posts in reverse order, so they're in the right order on the timeline + Enum.reverse(Utils.fetch_ordered_collection(user.info.source_data["outbox"], pages)), + &Pleroma.Web.Federator.incoming_ap_doc/1 + ) + end + + def get_followers_query(%User{id: id, follower_address: follower_address}, nil) do from( u in User, where: fragment("? <@ ?", ^[follower_address], u.following), @@ -367,13 +573,26 @@ defmodule Pleroma.User do ) end - def get_followers(user) do - q = get_followers_query(user) + def get_followers_query(user, page) do + from(u in get_followers_query(user, nil)) + |> paginate(page, 20) + end + + def get_followers_query(user), do: get_followers_query(user, nil) + + def get_followers(user, page \\ nil) do + q = get_followers_query(user, page) {:ok, Repo.all(q)} end - def get_friends_query(%User{id: id, following: following}) do + def get_followers_ids(user, page \\ nil) do + q = get_followers_query(user, page) + + Repo.all(from(u in q, select: u.id)) + end + + def get_friends_query(%User{id: id, following: following}, nil) do from( u in User, where: u.follower_address in ^following, @@ -381,12 +600,25 @@ defmodule Pleroma.User do ) end - def get_friends(user) do - q = get_friends_query(user) + def get_friends_query(user, page) do + from(u in get_friends_query(user, nil)) + |> paginate(page, 20) + end + + def get_friends_query(user), do: get_friends_query(user, nil) + + def get_friends(user, page \\ nil) do + q = get_friends_query(user, page) {:ok, Repo.all(q)} end + def get_friends_ids(user, page \\ nil) do + q = get_friends_query(user, page) + + Repo.all(from(u in q, select: u.id)) + end + def get_follow_requests_query(%User{} = user) do from( a in Activity, @@ -402,44 +634,67 @@ defmodule Pleroma.User do ), where: fragment( - "? @> ?", + "coalesce((?)->'object'->>'id', (?)->>'object') = ?", a.data, - ^%{"object" => user.ap_id} + a.data, + ^user.ap_id ) ) end def get_follow_requests(%User{} = user) do - q = get_follow_requests_query(user) - reqs = Repo.all(q) - users = - Enum.map(reqs, fn req -> req.actor end) - |> Enum.uniq() - |> Enum.map(fn ap_id -> get_by_ap_id(ap_id) end) - |> Enum.filter(fn u -> !following?(u, user) end) + user + |> User.get_follow_requests_query() + |> join(:inner, [a], u in User, on: a.actor == u.ap_id) + |> where([a, u], not fragment("? @> ?", u.following, ^[user.follower_address])) + |> group_by([a, u], u.id) + |> select([a, u], u) + |> Repo.all() {:ok, users} end def increase_note_count(%User{} = user) do - info_cng = User.Info.add_to_note_count(user.info, 1) - - cng = - change(user) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + User + |> where(id: ^user.id) + |> update([u], + set: [ + info: + fragment( + "jsonb_set(?, '{note_count}', ((?->>'note_count')::int + 1)::varchar::jsonb, true)", + u.info, + u.info + ) + ] + ) + |> select([u], u) + |> Repo.update_all([]) + |> case do + {1, [user]} -> set_cache(user) + _ -> {:error, user} + end end def decrease_note_count(%User{} = user) do - info_cng = User.Info.add_to_note_count(user.info, -1) - - cng = - change(user) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + User + |> where(id: ^user.id) + |> update([u], + set: [ + info: + fragment( + "jsonb_set(?, '{note_count}', (greatest(0, (?->>'note_count')::int - 1))::varchar::jsonb, true)", + u.info, + u.info + ) + ] + ) + |> select([u], u) + |> Repo.update_all([]) + |> case do + {1, [user]} -> set_cache(user) + _ -> {:error, user} + end end def update_note_count(%User{} = user) do @@ -463,24 +718,30 @@ defmodule Pleroma.User do def update_follower_count(%User{} = user) do follower_count_query = - from( - u in User, - where: ^user.follower_address in u.following, - where: u.id != ^user.id, - select: count(u.id) - ) - - follower_count = Repo.one(follower_count_query) - - info_cng = - user.info - |> User.Info.set_follower_count(follower_count) - - cng = - change(user) - |> put_embed(:info, info_cng) - - update_and_set_cache(cng) + User + |> where([u], ^user.follower_address in u.following) + |> where([u], u.id != ^user.id) + |> 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 end def get_users_from_set_query(ap_ids, false) do @@ -517,37 +778,191 @@ defmodule Pleroma.User do Repo.all(query) end - def search(query, resolve \\ false) do - # strip the beginning @ off if there is a query + def search(query, resolve \\ false, for_user \\ nil) do + # Strip the beginning @ off if there is a query query = String.trim_leading(query, "@") - if resolve do - User.get_or_fetch_by_nickname(query) - end + if resolve, do: get_or_fetch(query) - inner = - from( - u in User, - select_merge: %{ - search_distance: - fragment( - "? <-> (? || ?)", - ^query, - u.nickname, - u.name + {:ok, results} = + Repo.transaction(fn -> + Ecto.Adapters.SQL.query(Repo, "select set_limit(0.25)", []) + Repo.all(search_query(query, for_user)) + end) + + results + end + + def search_query(query, for_user) do + fts_subquery = fts_search_subquery(query) + trigram_subquery = trigram_search_subquery(query) + union_query = from(s in trigram_subquery, union_all: ^fts_subquery) + distinct_query = from(s in subquery(union_query), order_by: s.search_type, distinct: s.id) + + from(s in subquery(boost_search_rank_query(distinct_query, for_user)), + order_by: [desc: s.search_rank], + limit: 20 + ) + end + + defp boost_search_rank_query(query, nil), do: query + + defp boost_search_rank_query(query, for_user) do + friends_ids = get_friends_ids(for_user) + followers_ids = get_followers_ids(for_user) + + from(u in subquery(query), + select_merge: %{ + search_rank: + fragment( + """ + CASE WHEN (?) THEN (?) * 1.3 + WHEN (?) THEN (?) * 1.2 + WHEN (?) THEN (?) * 1.1 + ELSE (?) END + """, + u.id in ^friends_ids and u.id in ^followers_ids, + u.search_rank, + u.id in ^friends_ids, + u.search_rank, + u.id in ^followers_ids, + u.search_rank, + u.search_rank + ) + } + ) + end + + defp fts_search_subquery(term, query \\ User) do + processed_query = + term + |> String.replace(~r/\W+/, " ") + |> String.trim() + |> String.split() + |> Enum.map(&(&1 <> ":*")) + |> Enum.join(" | ") + + from( + u in query, + select_merge: %{ + search_type: ^0, + search_rank: + fragment( + """ + ts_rank_cd( + setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B'), + to_tsquery('simple', ?), + 32 ) - }, - where: not is_nil(u.nickname) - ) + """, + u.nickname, + u.name, + ^processed_query + ) + }, + where: + fragment( + """ + (setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')) @@ to_tsquery('simple', ?) + """, + u.nickname, + u.name, + ^processed_query + ) + ) + end - q = - from( - s in subquery(inner), - order_by: s.search_distance, - limit: 20 - ) + defp trigram_search_subquery(term) do + from( + u in User, + select_merge: %{ + # ^1 gives 'Postgrex expected a binary, got 1' for some weird reason + search_type: fragment("?", 1), + search_rank: + fragment( + "similarity(?, trim(? || ' ' || coalesce(?, '')))", + ^term, + u.nickname, + u.name + ) + }, + where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^term) + ) + end + + def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers) do + Enum.map( + blocked_identifiers, + fn blocked_identifier -> + with %User{} = blocked <- get_or_fetch(blocked_identifier), + {:ok, blocker} <- block(blocker, blocked), + {:ok, _} <- ActivityPub.block(blocker, blocked) do + blocked + else + err -> + Logger.debug("blocks_import failed for #{blocked_identifier} with: #{inspect(err)}") + err + end + end + ) + end + + def mute(muter, %User{ap_id: ap_id}) do + info_cng = + muter.info + |> User.Info.add_to_mutes(ap_id) + + cng = + change(muter) + |> put_embed(:info, info_cng) + + update_and_set_cache(cng) + end + + def unmute(muter, %{ap_id: ap_id}) do + info_cng = + muter.info + |> User.Info.remove_from_mutes(ap_id) + + cng = + change(muter) + |> put_embed(:info, info_cng) + + update_and_set_cache(cng) + end - Repo.all(q) + def subscribe(subscriber, %{ap_id: ap_id}) do + deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) + + with %User{} = subscribed <- get_cached_by_ap_id(ap_id) do + blocked = blocks?(subscribed, subscriber) and deny_follow_blocked + + if blocked do + {:error, "Could not subscribe: #{subscribed.nickname} is blocking you"} + else + info_cng = + subscribed.info + |> User.Info.add_to_subscribers(subscriber.ap_id) + + change(subscribed) + |> put_embed(:info, info_cng) + |> update_and_set_cache() + end + end + end + + def unsubscribe(unsubscriber, %{ap_id: ap_id}) do + with %User{} = user <- get_cached_by_ap_id(ap_id) do + info_cng = + user.info + |> User.Info.remove_from_subscribers(unsubscriber.ap_id) + + change(user) + |> put_embed(:info, info_cng) + |> update_and_set_cache() + end end def block(blocker, %User{ap_id: ap_id} = blocked) do @@ -560,10 +975,20 @@ defmodule Pleroma.User do blocker end + blocker = + if subscribed_to?(blocked, blocker) do + {:ok, blocker} = unsubscribe(blocked, blocker) + blocker + else + blocker + end + if following?(blocked, blocker) do unfollow(blocked, blocker) end + {:ok, blocker} = update_follower_count(blocker) + info_cng = blocker.info |> User.Info.add_to_block(ap_id) @@ -592,6 +1017,9 @@ defmodule Pleroma.User do update_and_set_cache(cng) end + 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 @@ -603,6 +1031,21 @@ defmodule Pleroma.User do end) end + def subscribed_to?(user, %{ap_id: ap_id}) do + with %User{} = target <- User.get_by_ap_id(ap_id) do + Enum.member?(target.info.subscribers, user.ap_id) + end + end + + def muted_users(user), + do: Repo.all(from(u in User, where: u.ap_id in ^user.info.mutes)) + + def blocked_users(user), + do: Repo.all(from(u in User, where: u.ap_id in ^user.info.blocks)) + + def subscribers(user), + do: Repo.all(from(u in User, where: u.ap_id in ^user.info.subscribers)) + def block_domain(user, domain) do info_cng = user.info @@ -627,15 +1070,62 @@ defmodule Pleroma.User do update_and_set_cache(cng) end - def local_user_query() do + def maybe_local_user_query(query, local) do + if local, do: local_user_query(query), else: query + end + + def local_user_query(query \\ User) do from( - u in User, + u in query, where: u.local == true, where: not is_nil(u.nickname) ) end - def moderator_user_query() do + def maybe_external_user_query(query, external) do + if external, do: external_user_query(query), else: query + end + + def external_user_query(query \\ User) do + from( + u in query, + where: u.local == false, + where: not is_nil(u.nickname) + ) + end + + def maybe_active_user_query(query, active) do + if active, do: active_user_query(query), else: query + end + + def active_user_query(query \\ User) do + from( + u in query, + where: fragment("not (?->'deactivated' @> 'true')", u.info), + where: not is_nil(u.nickname) + ) + end + + def maybe_deactivated_user_query(query, deactivated) do + if deactivated, do: deactivated_user_query(query), else: query + end + + def deactivated_user_query(query \\ User) do + from( + u in query, + where: fragment("(?->'deactivated' @> 'true')", u.info), + where: not is_nil(u.nickname) + ) + end + + def active_local_user_query do + from( + u in local_user_query(), + where: fragment("not (?->'deactivated' @> 'true')", u.info) + ) + end + + def moderator_user_query do from( u in User, where: u.local == true, @@ -653,32 +1143,41 @@ defmodule Pleroma.User do update_and_set_cache(cng) end + def update_notification_settings(%User{} = user, settings \\ %{}) do + info_changeset = User.Info.update_notification_settings(user.info, settings) + + change(user) + |> put_embed(:info, info_changeset) + |> update_and_set_cache() + end + def delete(%User{} = user) do {:ok, user} = User.deactivate(user) # Remove all relationships {:ok, followers} = User.get_followers(user) - followers - |> Enum.each(fn follower -> User.unfollow(follower, user) end) + Enum.each(followers, fn follower -> User.unfollow(follower, user) end) {:ok, friends} = User.get_friends(user) - friends - |> Enum.each(fn followed -> User.unfollow(user, followed) end) + Enum.each(friends, fn followed -> User.unfollow(user, followed) end) - query = from(a in Activity, where: a.actor == ^user.ap_id) + delete_user_activities(user) + end - Repo.all(query) - |> Enum.each(fn activity -> - case activity.data["type"] do - "Create" -> - ActivityPub.delete(Object.normalize(activity.data["object"])) + def delete_user_activities(%User{ap_id: ap_id} = user) do + Activity + |> where(actor: ^ap_id) + |> Activity.with_preloaded_object() + |> Repo.all() + |> Enum.each(fn + %{data: %{"type" => "Create"}} = activity -> + activity |> Object.normalize() |> ActivityPub.delete() - # TODO: Do something with likes, follows, repeats. - _ -> - "Doing nothing" - end + # TODO: Do something with likes, follows, repeats. + _ -> + "Doing nothing" end) {:ok, user} @@ -688,7 +1187,24 @@ defmodule Pleroma.User do Pleroma.HTML.Scrubber.TwitterText end - def html_filter_policy(_), do: nil + @default_scrubbers Pleroma.Config.get([:markup, :scrub_policy]) + + def html_filter_policy(_), do: @default_scrubbers + + def fetch_by_ap_id(ap_id) do + ap_try = ActivityPub.make_user_from_ap_id(ap_id) + + case ap_try do + {:ok, user} -> + user + + _ -> + case OStatus.make_user(ap_id) do + {:ok, user} -> user + _ -> {:error, "Could not fetch by AP id"} + end + end + end def get_or_fetch_by_ap_id(ap_id) do user = get_by_ap_id(ap_id) @@ -696,18 +1212,18 @@ defmodule Pleroma.User do if !is_nil(user) and !User.needs_update?(user) do user else - ap_try = ActivityPub.make_user_from_ap_id(ap_id) + # Whether to fetch initial posts for the user (if it's a new user & the fetching is enabled) + should_fetch_initial = is_nil(user) and Pleroma.Config.get([:fetch_initial_posts, :enabled]) - case ap_try do - {:ok, user} -> - user + user = fetch_by_ap_id(ap_id) - _ -> - case OStatus.make_user(ap_id) do - {:ok, user} -> user - _ -> {:error, "Could not fetch by AP id"} - end + if should_fetch_initial do + with %User{} = user do + {:ok, _} = Task.start(__MODULE__, :fetch_initial_posts, [user]) + end end + + user end end @@ -735,7 +1251,8 @@ defmodule Pleroma.User do source_data: %{"publicKey" => %{"publicKeyPem" => public_key_pem}} }) do key = - :public_key.pem_decode(public_key_pem) + public_key_pem + |> :public_key.pem_decode() |> hd() |> :public_key.pem_entry_decode() @@ -773,20 +1290,17 @@ defmodule Pleroma.User do def ap_enabled?(%User{info: info}), do: info.ap_enabled def ap_enabled?(_), do: false - def get_or_fetch(uri_or_nickname) do - if String.starts_with?(uri_or_nickname, "http") do - get_or_fetch_by_ap_id(uri_or_nickname) - else - get_or_fetch_by_nickname(uri_or_nickname) - end - end + @doc "Gets or fetch a user by uri or nickname." + @spec get_or_fetch(String.t()) :: User.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 <- Repo.get(User, a.id), - %User{} = b <- Repo.get(User, b.id) do + with %User{} = a <- User.get_by_id(a.id), + %User{} = b <- User.get_by_id(b.id) do {:ok, a, b} else _e -> @@ -796,8 +1310,8 @@ defmodule Pleroma.User do def wait_and_refresh(timeout, %User{} = a, %User{} = b) do with :ok <- :timer.sleep(timeout), - %User{} = a <- Repo.get(User, a.id), - %User{} = b <- Repo.get(User, b.id) do + %User{} = a <- User.get_by_id(a.id), + %User{} = b <- User.get_by_id(b.id) do {:ok, a, b} else _e -> @@ -805,10 +1319,11 @@ defmodule Pleroma.User do end end - def parse_bio(bio, user \\ %User{info: %{source_data: %{}}}) do - mentions = Formatter.parse_mentions(bio) - tags = Formatter.parse_tags(bio) + def parse_bio(bio, user \\ %User{info: %{source_data: %{}}}) + def parse_bio(nil, _user), do: "" + def parse_bio(bio, _user) when bio == "", do: bio + def parse_bio(bio, user) do emoji = (user.info.source_data["tag"] || []) |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) @@ -816,6 +1331,118 @@ defmodule Pleroma.User do {String.trim(name, ":"), url} end) - CommonUtils.format_input(bio, mentions, tags, "text/plain") |> Formatter.emojify(emoji) + # 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) + |> Formatter.emojify(emoji) + end + + 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(User.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(User.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 + + def bookmark(%User{} = user, status_id) do + bookmarks = Enum.uniq(user.bookmarks ++ [status_id]) + update_bookmarks(user, bookmarks) + end + + def unbookmark(%User{} = user, status_id) do + bookmarks = Enum.uniq(user.bookmarks -- [status_id]) + update_bookmarks(user, bookmarks) + end + + def update_bookmarks(%User{} = user, bookmarks) do + user + |> change(%{bookmarks: bookmarks}) + |> update_and_set_cache + end + + defp normalize_tags(tags) do + [tags] + |> List.flatten() + |> Enum.map(&String.downcase(&1)) + end + + defp local_nickname_regex do + if Pleroma.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, + info: %User.Info{}, + nickname: "erroruser@example.com", + inserted_at: NaiveDateTime.utc_now() + } + end + + def all_superusers do + from( + u in User, + where: u.local == true, + where: fragment("?->'is_admin' @> 'true' OR ?->'is_moderator' @> 'true'", u.info, u.info) + ) + |> Repo.all() + end + + defp paginate(query, page, page_size) do + from(u in query, + limit: ^page_size, + offset: ^((page - 1) * page_size) + ) + end + + def showing_reblogs?(%User{} = user, %User{} = target) do + target.ap_id not in user.info.muted_reblogs end end |