don't crash on malformed avatar and banner values
[akkoma] / lib / pleroma / user.ex
index a2cf22e554a5e9d146e1df2a01ee4d7f6809c4e1..48052198489faeac2338782b1b4b8db76757690a 100644 (file)
@@ -3,6 +3,10 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.User do
+  @moduledoc """
+  A user, local or remote
+  """
+
   use Ecto.Schema
 
   import Ecto.Changeset
@@ -18,6 +22,8 @@ defmodule Pleroma.User do
   alias Pleroma.Emoji
   alias Pleroma.FollowingRelationship
   alias Pleroma.Formatter
+  alias Pleroma.Hashtag
+  alias Pleroma.User.HashtagFollow
   alias Pleroma.HTML
   alias Pleroma.Keys
   alias Pleroma.MFA
@@ -124,6 +130,7 @@ defmodule Pleroma.User do
     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)
@@ -144,10 +151,13 @@ defmodule Pleroma.User do
     field(:also_known_as, {:array, ObjectValidators.ObjectID}, default: [])
     field(:inbox, :string)
     field(:shared_inbox, :string)
-    field(:accepts_chat_messages, :boolean, default: nil)
     field(:last_active_at, :naive_datetime)
     field(:disclose_client, :boolean, default: true)
     field(:pinned_objects, :map, default: %{})
+    field(:is_suggested, :boolean, default: false)
+    field(:last_status_at, :naive_datetime)
+    field(:language, :string)
+    field(:status_ttl_days, :integer, default: nil)
 
     embeds_one(
       :notification_settings,
@@ -162,6 +172,14 @@ defmodule Pleroma.User do
     has_many(:outgoing_relationships, UserRelationship, foreign_key: :source_id)
     has_many(:incoming_relationships, UserRelationship, foreign_key: :target_id)
 
+    has_many(:frontend_profiles, Pleroma.Akkoma.FrontendSettingsProfile)
+
+    many_to_many(:followed_hashtags, Hashtag,
+      on_replace: :delete,
+      on_delete: :delete_all,
+      join_through: HashtagFollow
+    )
+
     for {relationship_type,
          [
            {outgoing_relation, outgoing_relation_target},
@@ -190,17 +208,6 @@ defmodule Pleroma.User do
       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,
@@ -266,7 +273,13 @@ defmodule Pleroma.User do
   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 get_follow_requests_query(user), to: FollowingRelationship
+
+  def get_follow_requests(user) do
+    get_follow_requests_query(user)
+    |> Repo.all()
+  end
+
   defdelegate search(query, opts \\ []), to: User.Search
 
   @doc """
@@ -353,21 +366,21 @@ defmodule Pleroma.User do
   def invisible?(_), do: false
 
   def avatar_url(user, options \\ []) do
-    case user.avatar do
-      %{"url" => [%{"href" => href} | _]} ->
-        href
-
-      _ ->
-        unless options[:no_default] do
-          Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png")
-        end
-    end
+    default = Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png")
+    do_optional_url(user.avatar, default, options)
   end
 
   def banner_url(user, options \\ []) do
-    case user.banner do
-      %{"url" => [%{"href" => href} | _]} -> href
-      _ -> !options[:no_default] && "#{Endpoint.url()}/images/banner.png"
+    do_optional_url(user.banner, "#{Endpoint.url()}/images/banner.png", options)
+  end
+
+  defp do_optional_url(field, default, options \\ []) do
+    case field do
+      %{"url" => [%{"href" => href} | _]} when is_binary(href) ->
+        href
+
+      _ ->
+        unless options[:no_default], do: default
     end
   end
 
@@ -462,7 +475,6 @@ defmodule Pleroma.User do
         :invisible,
         :actor_type,
         :also_known_as,
-        :accepts_chat_messages,
         :pinned_objects
       ]
     )
@@ -473,7 +485,7 @@ defmodule Pleroma.User do
     |> validate_format(:nickname, @email_regex)
     |> validate_length(:bio, max: bio_limit)
     |> validate_length(:name, max: name_limit)
-    |> validate_fields(true)
+    |> validate_fields(true, struct)
     |> validate_non_local()
   end
 
@@ -523,8 +535,8 @@ defmodule Pleroma.User do
         :pleroma_settings_store,
         :is_discoverable,
         :actor_type,
-        :accepts_chat_messages,
-        :disclose_client
+        :disclose_client,
+        :status_ttl_days
       ]
     )
     |> unique_constraint(:nickname)
@@ -532,6 +544,7 @@ defmodule Pleroma.User do
     |> validate_length(:bio, max: bio_limit)
     |> validate_length(:name, min: 1, max: name_limit)
     |> validate_inclusion(:actor_type, ["Person", "Service"])
+    |> validate_number(:status_ttl_days, greater_than: 0)
     |> put_fields()
     |> put_emoji()
     |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)})
@@ -542,13 +555,21 @@ defmodule Pleroma.User do
       :pleroma_settings_store,
       &{:ok, Map.merge(struct.pleroma_settings_store, &1)}
     )
-    |> validate_fields(false)
+    |> validate_fields(false, struct)
   end
 
   defp put_fields(changeset) do
+    # These fields are inconsistent in tests when it comes to binary/atom keys
     if raw_fields = get_change(changeset, :raw_fields) do
       raw_fields =
         raw_fields
+        |> Enum.map(fn
+          %{name: name, value: value} ->
+            %{"name" => name, "value" => value}
+
+          %{"name" => _} = field ->
+            field
+        end)
         |> Enum.filter(fn %{"name" => n} -> n != "" end)
 
       fields =
@@ -596,7 +617,13 @@ defmodule Pleroma.User do
          {:ok, new_value} <- value_function.(value) do
       put_change(changeset, map_field, new_value)
     else
-      _ -> changeset
+      {:error, :file_too_large} ->
+        Ecto.Changeset.validate_change(changeset, map_field, fn map_field, _value ->
+          [{map_field, "file is too large"}]
+        end)
+
+      _ ->
+        changeset
     end
   end
 
@@ -673,8 +700,6 @@ defmodule Pleroma.User do
   # 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])
@@ -686,8 +711,7 @@ defmodule Pleroma.User do
     |> cast(params, [
       :name,
       :nickname,
-      :email,
-      :accepts_chat_messages
+      :email
     ])
     |> validate_required([:name, :nickname])
     |> unique_constraint(:nickname)
@@ -696,13 +720,14 @@ defmodule Pleroma.User do
     |> put_ap_id()
     |> unique_constraint(:ap_id)
     |> put_following_and_follower_and_featured_address()
+    |> put_private_key()
   end
 
-  def register_changeset(struct, params \\ %{}, opts \\ []) do
+  @spec register_changeset(User.t(), map(), keyword()) :: Changeset.t()
+  def register_changeset(%User{} = struct, params \\ %{}, opts \\ []) do
     bio_limit = Config.get([:instance, :user_bio_length], 5000)
     name_limit = Config.get([:instance, :user_name_length], 100)
     reason_limit = Config.get([:instance, :registration_reason_length], 500)
-    params = Map.put_new(params, :accepts_chat_messages, true)
 
     confirmed? =
       if is_nil(opts[:confirmed]) do
@@ -730,8 +755,8 @@ defmodule Pleroma.User do
       :password,
       :password_confirmation,
       :emoji,
-      :accepts_chat_messages,
-      :registration_reason
+      :registration_reason,
+      :language
     ])
     |> validate_required([:name, :nickname, :password, :password_confirmation])
     |> validate_confirmation(:password)
@@ -757,6 +782,7 @@ defmodule Pleroma.User do
     |> put_ap_id()
     |> unique_constraint(:ap_id)
     |> put_following_and_follower_and_featured_address()
+    |> put_private_key()
   end
 
   def maybe_validate_required_email(changeset, true), do: changeset
@@ -769,12 +795,12 @@ defmodule Pleroma.User do
     end
   end
 
-  defp put_ap_id(changeset) do
+  def put_ap_id(changeset) do
     ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)})
     put_change(changeset, :ap_id, ap_id)
   end
 
-  defp put_following_and_follower_and_featured_address(changeset) do
+  def put_following_and_follower_and_featured_address(changeset) do
     user = %User{nickname: get_field(changeset, :nickname)}
     followers = ap_followers(user)
     following = ap_following(user)
@@ -786,6 +812,11 @@ defmodule Pleroma.User do
     |> put_change(:featured_address, featured)
   end
 
+  defp put_private_key(changeset) do
+    {:ok, pem} = Keys.generate_rsa_pem()
+    put_change(changeset, :keys, pem)
+  end
+
   defp autofollow_users(user) do
     candidates = Config.get([:instance, :autofollowed_nicknames])
 
@@ -807,12 +838,14 @@ defmodule Pleroma.User do
   end
 
   @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)"
+  @spec register(Changeset.t()) :: {:ok, User.t()} | {:error, any} | nil
   def register(%Ecto.Changeset{} = changeset) do
     with {:ok, user} <- Repo.insert(changeset) do
       post_register_action(user)
     end
   end
 
+  @spec post_register_action(User.t()) :: {:error, any} | {:ok, User.t()}
   def post_register_action(%User{is_confirmed: false} = user) do
     with {:ok, _} <- maybe_send_confirmation_email(user) do
       {:ok, user}
@@ -832,8 +865,7 @@ defmodule Pleroma.User do
          {:ok, user} <- set_cache(user),
          {:ok, _} <- maybe_send_registration_email(user),
          {:ok, _} <- maybe_send_welcome_email(user),
-         {:ok, _} <- maybe_send_welcome_message(user),
-         {:ok, _} <- maybe_send_welcome_chat_message(user) do
+         {:ok, _} <- maybe_send_welcome_message(user) do
       {:ok, user}
     end
   end
@@ -867,15 +899,6 @@ defmodule Pleroma.User do
     end
   end
 
-  defp maybe_send_welcome_chat_message(user) do
-    if User.WelcomeChatMessage.enabled?() do
-      User.WelcomeChatMessage.post_message(user)
-      {:ok, :enqueued}
-    else
-      {:ok, :noop}
-    end
-  end
-
   defp maybe_send_welcome_email(%User{email: email} = user) when is_binary(email) do
     if User.WelcomeEmail.enabled?() do
       User.WelcomeEmail.send_email(user)
@@ -937,7 +960,8 @@ defmodule Pleroma.User do
 
   def needs_update?(_), do: true
 
-  @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()}
+  @spec maybe_direct_follow(User.t(), User.t()) ::
+          {:ok, User.t(), 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
@@ -1070,6 +1094,11 @@ defmodule Pleroma.User do
     get_cached_by_nickname(nickname)
   end
 
+  @spec set_cache(
+          {:error, any}
+          | {:ok, User.t()}
+          | User.t()
+        ) :: {:ok, User.t()} | {:error, any}
   def set_cache({:ok, user}), do: set_cache(user)
   def set_cache({:error, err}), do: {:error, err}
 
@@ -1080,17 +1109,32 @@ defmodule Pleroma.User do
     {:ok, user}
   end
 
+  @spec update_and_set_cache(User.t(), map()) :: {:ok, User.t()} | {:error, any}
   def update_and_set_cache(struct, params) do
     struct
     |> update_changeset(params)
     |> update_and_set_cache()
   end
 
-  def update_and_set_cache(changeset) do
+  @spec update_and_set_cache(Changeset.t()) :: {:ok, User.t()} | {:error, any}
+  def update_and_set_cache(%{data: %Pleroma.User{} = user} = changeset) do
+    was_superuser_before_update = User.superuser?(user)
+
     with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
-      Pleroma.Elasticsearch.maybe_put_into_elasticsearch(user)
       set_cache(user)
     end
+    |> maybe_remove_report_notifications(was_superuser_before_update)
+  end
+
+  defp maybe_remove_report_notifications({:ok, %Pleroma.User{} = user} = result, true) do
+    if not User.superuser?(user),
+      do: user |> Notification.destroy_multiple_from_types(["pleroma:report"])
+
+    result
+  end
+
+  defp maybe_remove_report_notifications(result, _) do
+    result
   end
 
   def get_user_friends_ap_ids(user) do
@@ -1127,6 +1171,7 @@ defmodule Pleroma.User do
     end
   end
 
+  @spec get_cached_by_id(String.t()) :: nil | Pleroma.User.t()
   def get_cached_by_id(id) do
     key = "id:#{id}"
 
@@ -1484,13 +1529,19 @@ defmodule Pleroma.User do
         blocker
       end
 
-    # clear any requested follows as well
+    # clear any requested follows from both sides as well
     blocked =
       case CommonAPI.reject_follow_request(blocked, blocker) do
         {:ok, %User{} = updated_blocked} -> updated_blocked
         nil -> blocked
       end
 
+    blocker =
+      case CommonAPI.reject_follow_request(blocker, blocked) do
+        {:ok, %User{} = updated_blocker} -> updated_blocker
+        nil -> blocker
+      end
+
     unsubscribe(blocked, blocker)
 
     unfollowing_blocked = Config.get([:activitypub, :unfollow_blocked], true)
@@ -1677,6 +1728,22 @@ defmodule Pleroma.User do
 
   def confirm(%User{} = user), do: {:ok, user}
 
+  def set_suggestion(users, is_suggested) when is_list(users) do
+    Repo.transaction(fn ->
+      Enum.map(users, fn user ->
+        with {:ok, user} <- set_suggestion(user, is_suggested), do: user
+      end)
+    end)
+  end
+
+  def set_suggestion(%User{is_suggested: is_suggested} = user, is_suggested), do: {:ok, user}
+
+  def set_suggestion(%User{} = user, is_suggested) when is_boolean(is_suggested) do
+    user
+    |> change(is_suggested: is_suggested)
+    |> update_and_set_cache()
+  end
+
   def update_notification_settings(%User{} = user, settings) do
     user
     |> cast(%{notification_settings: settings}, [])
@@ -1713,6 +1780,7 @@ defmodule Pleroma.User do
       ap_enabled: false,
       is_moderator: false,
       is_admin: false,
+      mastofe_settings: nil,
       mascot: nil,
       emoji: %{},
       pleroma_settings_store: %{},
@@ -1889,7 +1957,8 @@ defmodule Pleroma.User do
       {%User{} = user, _} ->
         {:ok, user}
 
-      _ ->
+      e ->
+        Logger.error("Could not fetch user #{ap_id}, #{inspect(e)}")
         {:error, :not_found}
     end
   end
@@ -1931,11 +2000,13 @@ defmodule Pleroma.User do
     %User{
       invisible: true,
       local: true,
+      actor_type: "Application",
       ap_id: uri,
       nickname: nickname,
       follower_address: uri <> "/followers"
     }
     |> change
+    |> put_private_key()
     |> unique_constraint(:nickname)
     |> Repo.insert()
     |> set_cache()
@@ -1968,7 +2039,8 @@ defmodule Pleroma.User do
 
   @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("http://" <> _host = uri), do: get_or_fetch_by_ap_id(uri)
+  def get_or_fetch("https://" <> _host = uri), do: get_or_fetch_by_ap_id(uri)
   def get_or_fetch(nickname), do: get_or_fetch_by_nickname(nickname)
 
   # wait a period of time and return newest version of the User structs
@@ -2005,10 +2077,14 @@ defmodule Pleroma.User do
     # TODO: get profile URLs other than user.ap_id
     profile_urls = [user.ap_id]
 
-    bio
-    |> CommonUtils.format_input("text/plain",
+    CommonUtils.format_input(bio, "text/plain",
       mentions_format: :full,
-      rel: &RelMe.maybe_put_rel_me(&1, profile_urls)
+      rel: fn link ->
+        case RelMe.maybe_put_rel_me(link, profile_urls) do
+          "me" -> "me"
+          _ -> nil
+        end
+      end
     )
     |> elem(0)
   end
@@ -2054,7 +2130,7 @@ defmodule Pleroma.User do
     |> Enum.map(&String.downcase/1)
   end
 
-  defp local_nickname_regex do
+  def local_nickname_regex do
     if Config.get([:instance, :extended_nickname_format]) do
       @extended_local_nickname_regex
     else
@@ -2201,17 +2277,6 @@ defmodule Pleroma.User do
     }
   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,
@@ -2223,7 +2288,7 @@ defmodule Pleroma.User do
   defp put_password_hash(
          %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
        ) do
-    change(changeset, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt(password))
+    change(changeset, password_hash: Pleroma.Password.hash_pwd_salt(password))
   end
 
   defp put_password_hash(changeset), do: changeset
@@ -2253,6 +2318,39 @@ defmodule Pleroma.User do
     |> update_and_set_cache()
   end
 
+  def alias_users(user) do
+    user.also_known_as
+    |> Enum.map(&User.get_cached_by_ap_id/1)
+    |> Enum.filter(fn user -> user != nil end)
+  end
+
+  def add_alias(user, new_alias_user) do
+    current_aliases = user.also_known_as || []
+    new_alias_ap_id = new_alias_user.ap_id
+
+    if new_alias_ap_id in current_aliases do
+      {:ok, user}
+    else
+      user
+      |> cast(%{also_known_as: current_aliases ++ [new_alias_ap_id]}, [:also_known_as])
+      |> update_and_set_cache()
+    end
+  end
+
+  @spec delete_alias(User.t(), User.t()) :: {:error, :no_such_alias}
+  def delete_alias(user, alias_user) do
+    current_aliases = user.also_known_as || []
+    alias_ap_id = alias_user.ap_id
+
+    if alias_ap_id in current_aliases do
+      user
+      |> cast(%{also_known_as: current_aliases -- [alias_ap_id]}, [:also_known_as])
+      |> update_and_set_cache()
+    else
+      {:error, :no_such_alias}
+    end
+  end
+
   # Internal function; public one is `deactivate/2`
   defp set_activation_status(user, status) do
     user
@@ -2272,7 +2370,8 @@ defmodule Pleroma.User do
     |> update_and_set_cache()
   end
 
-  def validate_fields(changeset, remote? \\ false) do
+  @spec validate_fields(Ecto.Changeset.t(), Boolean.t(), User.t()) :: Ecto.Changeset.t()
+  def validate_fields(changeset, remote? \\ false, struct) do
     limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields
     limit = Config.get([:instance, limit_name], 0)
 
@@ -2285,6 +2384,7 @@ defmodule Pleroma.User do
         [fields: "invalid"]
       end
     end)
+    |> maybe_validate_rel_me_field(struct)
   end
 
   defp valid_field?(%{"name" => name, "value" => value}) do
@@ -2297,6 +2397,75 @@ defmodule Pleroma.User do
 
   defp valid_field?(_), do: false
 
+  defp is_url(nil), do: nil
+
+  defp is_url(uri) do
+    case URI.parse(uri) do
+      %URI{host: nil} -> false
+      %URI{scheme: nil} -> false
+      _ -> true
+    end
+  end
+
+  @spec maybe_validate_rel_me_field(Changeset.t(), User.t()) :: Changeset.t()
+  defp maybe_validate_rel_me_field(changeset, %User{ap_id: _ap_id} = struct) do
+    fields = get_change(changeset, :fields)
+    raw_fields = get_change(changeset, :raw_fields)
+
+    if is_nil(fields) do
+      changeset
+    else
+      validate_rel_me_field(changeset, fields, raw_fields, struct)
+    end
+  end
+
+  defp maybe_validate_rel_me_field(changeset, _), do: changeset
+
+  @spec validate_rel_me_field(Changeset.t(), [Map.t()], [Map.t()], User.t()) :: Changeset.t()
+  defp validate_rel_me_field(changeset, fields, raw_fields, %User{
+         nickname: nickname,
+         ap_id: ap_id
+       }) do
+    fields =
+      fields
+      |> Enum.with_index()
+      |> Enum.map(fn {%{"name" => name, "value" => value}, index} ->
+        raw_value =
+          if is_nil(raw_fields) do
+            nil
+          else
+            Enum.at(raw_fields, index)["value"]
+          end
+
+        if is_url(raw_value) do
+          frontend_url =
+            Pleroma.Web.Router.Helpers.redirect_url(
+              Pleroma.Web.Endpoint,
+              :redirector_with_meta,
+              nickname
+            )
+
+          possible_urls = [ap_id, frontend_url]
+
+          with "me" <- RelMe.maybe_put_rel_me(raw_value, possible_urls) do
+            %{
+              "name" => name,
+              "value" => value,
+              "verified_at" => DateTime.to_iso8601(DateTime.utc_now())
+            }
+          else
+            e ->
+              Logger.error("Could not check for rel=me, #{inspect(e)}")
+              %{"name" => name, "value" => value}
+          end
+        else
+          %{"name" => name, "value" => value}
+        end
+      end)
+
+    put_change(changeset, :fields, fields)
+  end
+
   defp truncate_field(%{"name" => name, "value" => value}) do
     {name, _chopped} =
       String.split_at(name, Config.get([:instance, :account_field_name_length], 255))
@@ -2330,6 +2499,13 @@ defmodule Pleroma.User do
     |> update_and_set_cache()
   end
 
+  def mastodon_settings_update(user, settings) do
+    user
+    |> cast(%{mastofe_settings: settings}, [:mastofe_settings])
+    |> validate_required([:mastofe_settings])
+    |> update_and_set_cache()
+  end
+
   @spec confirmation_changeset(User.t(), keyword()) :: Changeset.t()
   def confirmation_changeset(user, set_confirmation: confirmed?) do
     params =
@@ -2348,7 +2524,7 @@ defmodule Pleroma.User do
     cast(user, params, [:is_confirmed, :confirmation_token])
   end
 
-  @spec approval_changeset(User.t(), keyword()) :: Changeset.t()
+  @spec approval_changeset(Changeset.t(), keyword()) :: Changeset.t()
   def approval_changeset(user, set_approval: approved?) do
     cast(user, %{is_approved: approved?}, [:is_approved])
   end
@@ -2423,15 +2599,19 @@ defmodule Pleroma.User do
     with {:ok, relationship} <- UserRelationship.create_block(user, blocked) do
       @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
       {:ok, relationship}
+    else
+      err -> err
     end
   end
 
-  @spec add_to_block(User.t(), User.t()) ::
+  @spec remove_from_block(User.t(), User.t()) ::
           {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()}
   defp remove_from_block(%User{} = user, %User{} = blocked) do
     with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do
       @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}")
       {:ok, relationship}
+    else
+      err -> err
     end
   end
 
@@ -2453,11 +2633,8 @@ defmodule Pleroma.User do
   # - 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)
-        }
+      Enum.map(user.fields, fn %{"value" => value} = field ->
+        Map.put(field, "value", HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly))
       end)
 
     user
@@ -2483,4 +2660,66 @@ defmodule Pleroma.User do
     |> where([u], u.local == true)
     |> Repo.aggregate(:count)
   end
+
+  def update_last_status_at(user) do
+    User
+    |> where(id: ^user.id)
+    |> update([u], set: [last_status_at: fragment("NOW()")])
+    |> select([u], u)
+    |> Repo.update_all([])
+    |> case do
+      {1, [user]} -> set_cache(user)
+      _ -> {:error, user}
+    end
+  end
+
+  defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user)
+       when is_list(follows),
+       do: user
+
+  defp maybe_load_followed_hashtags(%User{} = user) do
+    followed_hashtags = HashtagFollow.get_by_user(user)
+    %{user | followed_hashtags: followed_hashtags}
+  end
+
+  def followed_hashtags(%User{followed_hashtags: follows})
+      when is_list(follows),
+      do: follows
+
+  def followed_hashtags(%User{} = user) do
+    {:ok, user} =
+      user
+      |> maybe_load_followed_hashtags()
+      |> set_cache()
+
+    user.followed_hashtags
+  end
+
+  def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+    Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}")
+    user = maybe_load_followed_hashtags(user)
+
+    with {:ok, _} <- HashtagFollow.new(user, hashtag),
+         follows <- HashtagFollow.get_by_user(user),
+         %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+      user
+      |> set_cache()
+    end
+  end
+
+  def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+    Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}")
+    user = maybe_load_followed_hashtags(user)
+
+    with {:ok, _} <- HashtagFollow.delete(user, hashtag),
+         follows <- HashtagFollow.get_by_user(user),
+         %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+      user
+      |> set_cache()
+    end
+  end
+
+  def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do
+    not is_nil(HashtagFollow.get(user, hashtag))
+  end
 end