diff options
| author | rinpatch <rin@patch.cx> | 2021-04-16 09:53:47 +0000 | 
|---|---|---|
| committer | rinpatch <rin@patch.cx> | 2021-04-16 09:53:47 +0000 | 
| commit | 79376b4afb8bba0766cb3d04179aeaf4c0b7000b (patch) | |
| tree | f154defcd60036f76fbaf92154f0649c2e4892f3 /lib | |
| parent | 0ababdc068f0fd7655b5f8440a5a8c9759a32fea (diff) | |
| parent | 1885268c9c242aca2a51bd15ed839bd65d6a52dc (diff) | |
| download | pleroma-79376b4afb8bba0766cb3d04179aeaf4c0b7000b.tar.gz pleroma-79376b4afb8bba0766cb3d04179aeaf4c0b7000b.zip  | |
Merge branch 'feature/521-pinned-post-federation' into 'develop'
Pinned posts federation
Closes #521
See merge request pleroma/pleroma!3312
Diffstat (limited to 'lib')
20 files changed, 513 insertions, 104 deletions
diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index d59403884..53beca5e6 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -184,40 +184,48 @@ defmodule Pleroma.Activity do      |> Repo.one()    end -  @spec get_by_id(String.t()) :: Activity.t() | nil -  def get_by_id(id) do -    case FlakeId.flake_id?(id) do -      true -> -        Activity -        |> where([a], a.id == ^id) -        |> restrict_deactivated_users() -        |> Repo.one() - -      _ -> -        nil -    end -  end - -  def get_by_id_with_user_actor(id) do -    case FlakeId.flake_id?(id) do -      true -> -        Activity -        |> where([a], a.id == ^id) -        |> with_preloaded_user_actor() -        |> Repo.one() - -      _ -> -        nil +  @doc """ +  Gets activity by ID, doesn't load activities from deactivated actors by default. +  """ +  @spec get_by_id(String.t(), keyword()) :: t() | nil +  def get_by_id(id, opts \\ [filter: [:restrict_deactivated]]), do: get_by_id_with_opts(id, opts) + +  @spec get_by_id_with_user_actor(String.t()) :: t() | nil +  def get_by_id_with_user_actor(id), do: get_by_id_with_opts(id, preload: [:user_actor]) + +  @spec get_by_id_with_object(String.t()) :: t() | nil +  def get_by_id_with_object(id), do: get_by_id_with_opts(id, preload: [:object]) + +  defp get_by_id_with_opts(id, opts) do +    if FlakeId.flake_id?(id) do +      query = Queries.by_id(id) + +      with_filters_query = +        if is_list(opts[:filter]) do +          Enum.reduce(opts[:filter], query, fn +            {:type, type}, acc -> Queries.by_type(acc, type) +            :restrict_deactivated, acc -> restrict_deactivated_users(acc) +            _, acc -> acc +          end) +        else +          query +        end + +      with_preloads_query = +        if is_list(opts[:preload]) do +          Enum.reduce(opts[:preload], with_filters_query, fn +            :user_actor, acc -> with_preloaded_user_actor(acc) +            :object, acc -> with_preloaded_object(acc) +            _, acc -> acc +          end) +        else +          with_filters_query +        end + +      Repo.one(with_preloads_query)      end    end -  def get_by_id_with_object(id) do -    Activity -    |> where(id: ^id) -    |> with_preloaded_object() -    |> Repo.one() -  end -    def all_by_ids_with_object(ids) do      Activity      |> where([a], a.id in ^ids) @@ -269,6 +277,11 @@ defmodule Pleroma.Activity do    def get_create_by_object_ap_id_with_object(_), do: nil +  @spec create_by_id_with_object(String.t()) :: t() | nil +  def create_by_id_with_object(id) do +    get_by_id_with_opts(id, preload: [:object], filter: [type: "Create"]) +  end +    defp get_in_reply_to_activity_from_object(%Object{data: %{"inReplyTo" => ap_id}}) do      get_create_by_object_ap_id_with_object(ap_id)    end @@ -368,12 +381,6 @@ defmodule Pleroma.Activity do      end    end -  @spec pinned_by_actor?(Activity.t()) :: boolean() -  def pinned_by_actor?(%Activity{} = activity) do -    actor = user_actor(activity) -    activity.id in actor.pinned_activities -  end -    @spec get_by_object_ap_id_with_object(String.t()) :: t() | nil    def get_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do      ap_id @@ -384,4 +391,13 @@ defmodule Pleroma.Activity do    end    def get_by_object_ap_id_with_object(_), do: nil + +  @spec add_by_params_query(String.t(), String.t(), String.t()) :: Ecto.Query.t() +  def add_by_params_query(object_id, actor, target) do +    object_id +    |> Queries.by_object_id() +    |> Queries.by_type("Add") +    |> Queries.by_actor(actor) +    |> where([a], fragment("?->>'target' = ?", a.data, ^target)) +  end  end diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index a6b02a889..4632651b0 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -14,6 +14,11 @@ defmodule Pleroma.Activity.Queries do    alias Pleroma.Activity    alias Pleroma.User +  @spec by_id(query(), String.t()) :: query() +  def by_id(query \\ Activity, id) do +    from(a in query, where: a.id == ^id) +  end +    @spec by_ap_id(query, String.t()) :: query    def by_ap_id(query \\ Activity, ap_id) do      from( diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index fb0398f92..040537acf 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -71,6 +71,14 @@ defmodule Pleroma.Object.Containment do      compare_uris(id_uri, other_uri)    end +  # Mastodon pin activities don't have an id, so we check the object field, which will be pinned. +  def contain_origin_from_id(id, %{"object" => object}) when is_binary(object) do +    id_uri = URI.parse(id) +    object_uri = URI.parse(object) + +    compare_uris(id_uri, object_uri) +  end +    def contain_origin_from_id(_id, _data), do: :error    def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}), diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c1aa0f716..b78777141 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -99,6 +99,7 @@ defmodule Pleroma.User do      field(:local, :boolean, default: true)      field(:follower_address, :string)      field(:following_address, :string) +    field(:featured_address, :string)      field(:search_rank, :float, virtual: true)      field(:search_type, :integer, virtual: true)      field(:tags, {:array, :string}, default: []) @@ -130,7 +131,6 @@ defmodule Pleroma.User do      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: %{}) @@ -148,6 +148,7 @@ defmodule Pleroma.User do      field(:accepts_chat_messages, :boolean, default: nil)      field(:last_active_at, :naive_datetime)      field(:disclose_client, :boolean, default: true) +    field(:pinned_objects, :map, default: %{})      embeds_one(        :notification_settings, @@ -372,8 +373,10 @@ defmodule Pleroma.User do    end    # Should probably be renamed or removed +  @spec ap_id(User.t()) :: String.t()    def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" +  @spec ap_followers(User.t()) :: String.t()    def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa    def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" @@ -381,6 +384,11 @@ defmodule Pleroma.User do    def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa    def ap_following(%User{} = user), do: "#{ap_id(user)}/following" +  @spec ap_featured_collection(User.t()) :: String.t() +  def ap_featured_collection(%User{featured_address: fa}) when is_binary(fa), do: fa + +  def ap_featured_collection(%User{} = user), do: "#{ap_id(user)}/collections/featured" +    defp truncate_fields_param(params) do      if Map.has_key?(params, :fields) do        Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) @@ -443,6 +451,7 @@ defmodule Pleroma.User do          :uri,          :follower_address,          :following_address, +        :featured_address,          :hide_followers,          :hide_follows,          :hide_followers_count, @@ -454,7 +463,8 @@ defmodule Pleroma.User do          :invisible,          :actor_type,          :also_known_as, -        :accepts_chat_messages +        :accepts_chat_messages, +        :pinned_objects        ]      )      |> cast(params, [:name], empty_values: []) @@ -686,7 +696,7 @@ defmodule Pleroma.User do      |> validate_format(:nickname, local_nickname_regex())      |> put_ap_id()      |> unique_constraint(:ap_id) -    |> put_following_and_follower_address() +    |> put_following_and_follower_and_featured_address()    end    def register_changeset(struct, params \\ %{}, opts \\ []) do @@ -747,7 +757,7 @@ defmodule Pleroma.User do      |> put_password_hash      |> put_ap_id()      |> unique_constraint(:ap_id) -    |> put_following_and_follower_address() +    |> put_following_and_follower_and_featured_address()    end    def maybe_validate_required_email(changeset, true), do: changeset @@ -765,11 +775,16 @@ defmodule Pleroma.User do      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)}) +  defp put_following_and_follower_and_featured_address(changeset) do +    user = %User{nickname: get_field(changeset, :nickname)} +    followers = ap_followers(user) +    following = ap_following(user) +    featured = ap_featured_collection(user)      changeset      |> put_change(:follower_address, followers) +    |> put_change(:following_address, following) +    |> put_change(:featured_address, featured)    end    defp autofollow_users(user) do @@ -2343,45 +2358,35 @@ defmodule Pleroma.User do      cast(user, %{is_approved: approved?}, [:is_approved])    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 +  @spec add_pinned_object_id(User.t(), String.t()) :: {:ok, User.t()} | {:error, term()} +  def add_pinned_object_id(%User{} = user, object_id) do +    if !user.pinned_objects[object_id] do +      params = %{pinned_objects: Map.put(user.pinned_objects, object_id, NaiveDateTime.utc_now())}        user -      |> cast(params, [:pinned_activities]) -      |> validate_length(:pinned_activities, -        max: max_pinned_statuses, -        message: "You have already pinned the maximum number of statuses" -      ) +      |> cast(params, [:pinned_objects]) +      |> validate_change(:pinned_objects, fn :pinned_objects, pinned_objects -> +        max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) + +        if Enum.count(pinned_objects) <= max_pinned_statuses do +          [] +        else +          [pinned_objects: "You have already pinned the maximum number of statuses"] +        end +      end)      else        change(user)      end      |> update_and_set_cache()    end -  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 - +  @spec remove_pinned_object_id(User.t(), String.t()) :: {:ok, t()} | {:error, term()} +  def remove_pinned_object_id(%User{} = user, object_id) do      user -    |> cast(params, [:pinned_activities]) +    |> cast( +      %{pinned_objects: Map.delete(user.pinned_objects, object_id)}, +      [:pinned_objects] +    )      |> update_and_set_cache()    end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index efbf92c70..d0051d1cb 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -630,7 +630,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do        |> Map.put(:type, ["Create", "Announce"])        |> Map.put(:user, reading_user)        |> Map.put(:actor_id, user.ap_id) -      |> Map.put(:pinned_activity_ids, user.pinned_activities) +      |> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects))      params =        if User.blocks?(reading_user, user) do @@ -1075,8 +1075,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do    defp restrict_unlisted(query, _), do: query -  defp restrict_pinned(query, %{pinned: true, pinned_activity_ids: ids}) do -    from(activity in query, where: activity.id in ^ids) +  defp restrict_pinned(query, %{pinned: true, pinned_object_ids: ids}) do +    from( +      [activity, object: o] in query, +      where: +        fragment( +          "(?)->>'type' = 'Create' and coalesce((?)->'object'->>'id', (?)->>'object') = any (?)", +          activity.data, +          activity.data, +          activity.data, +          ^ids +        ) +    )    end    defp restrict_pinned(query, _), do: query @@ -1419,6 +1429,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      invisible = data["invisible"] || false      actor_type = data["type"] || "Person" +    featured_address = data["featured"] +    {:ok, pinned_objects} = fetch_and_prepare_featured_from_ap_id(featured_address) +      public_key =        if is_map(data["publicKey"]) && is_binary(data["publicKey"]["publicKeyPem"]) do          data["publicKey"]["publicKeyPem"] @@ -1447,13 +1460,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do        name: data["name"],        follower_address: data["followers"],        following_address: data["following"], +      featured_address: featured_address,        bio: data["summary"] || "",        actor_type: actor_type,        also_known_as: Map.get(data, "alsoKnownAs", []),        public_key: public_key,        inbox: data["inbox"],        shared_inbox: shared_inbox, -      accepts_chat_messages: accepts_chat_messages +      accepts_chat_messages: accepts_chat_messages, +      pinned_objects: pinned_objects      }      # nickname can be nil because of virtual actors @@ -1591,6 +1606,41 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do      end    end +  def pin_data_from_featured_collection(%{ +        "type" => type, +        "orderedItems" => objects +      }) +      when type in ["OrderedCollection", "Collection"] do +    Map.new(objects, fn %{"id" => object_ap_id} -> {object_ap_id, NaiveDateTime.utc_now()} end) +  end + +  def fetch_and_prepare_featured_from_ap_id(nil) do +    {:ok, %{}} +  end + +  def fetch_and_prepare_featured_from_ap_id(ap_id) do +    with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do +      {:ok, pin_data_from_featured_collection(data)} +    else +      e -> +        Logger.error("Could not decode featured collection at fetch #{ap_id}, #{inspect(e)}") +        {:ok, %{}} +    end +  end + +  def pinned_fetch_task(nil), do: nil + +  def pinned_fetch_task(%{pinned_objects: pins}) do +    if Enum.all?(pins, fn {ap_id, _} -> +         Object.get_cached_by_ap_id(ap_id) || +           match?({:ok, _object}, Fetcher.fetch_object_from_id(ap_id)) +       end) do +      :ok +    else +      :error +    end +  end +    def make_user_from_ap_id(ap_id) do      user = User.get_cached_by_ap_id(ap_id) @@ -1598,6 +1648,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do        Transmogrifier.upgrade_user_from_ap_id(ap_id)      else        with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do +        {:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) +          if user do            user            |> User.remote_user_changeset(data) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 9d3dcc7f9..5aa3b281a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -543,4 +543,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do        |> json(object.data)      end    end + +  def pinned(conn, %{"nickname" => nickname}) do +    with %User{} = user <- User.get_cached_by_nickname(nickname) do +      conn +      |> put_resp_header("content-type", "application/activity+json") +      |> json(UserView.render("featured.json", %{user: user})) +    end +  end  end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index f56bfc600..91a45836f 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -273,4 +273,36 @@ defmodule Pleroma.Web.ActivityPub.Builder do         "context" => object.data["context"]       }, []}    end + +  @spec pin(User.t(), Object.t()) :: {:ok, map(), keyword()} +  def pin(%User{} = user, object) do +    {:ok, +     %{ +       "id" => Utils.generate_activity_id(), +       "target" => pinned_url(user.nickname), +       "object" => object.data["id"], +       "actor" => user.ap_id, +       "type" => "Add", +       "to" => [Pleroma.Constants.as_public()], +       "cc" => [user.follower_address] +     }, []} +  end + +  @spec unpin(User.t(), Object.t()) :: {:ok, map, keyword()} +  def unpin(%User{} = user, object) do +    {:ok, +     %{ +       "id" => Utils.generate_activity_id(), +       "target" => pinned_url(user.nickname), +       "object" => object.data["id"], +       "actor" => user.ap_id, +       "type" => "Remove", +       "to" => [Pleroma.Constants.as_public()], +       "cc" => [user.follower_address] +     }, []} +  end + +  defp pinned_url(nickname) when is_binary(nickname) do +    Pleroma.Web.Router.Helpers.activity_pub_url(Pleroma.Web.Endpoint, :pinned, nickname) +  end  end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index f75744203..25df36cae 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -17,6 +17,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do    alias Pleroma.Object.Containment    alias Pleroma.User    alias Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator +  alias Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator    alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator @@ -143,6 +144,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do      end    end +  def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do +    with {:ok, object} <- +           object +           |> AddRemoveValidator.cast_and_validate() +           |> Ecto.Changeset.apply_action(:insert) do +      object = stringify_keys(object) +      {:ok, object, meta} +    end +  end +    def cast_and_apply(%{"type" => "ChatMessage"} = object) do      ChatMessageValidator.cast_and_apply(object)    end diff --git a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex new file mode 100644 index 000000000..f885aabe4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do +  use Ecto.Schema + +  import Ecto.Changeset +  import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + +  require Pleroma.Constants + +  alias Pleroma.EctoType.ActivityPub.ObjectValidators +  alias Pleroma.User + +  @primary_key false + +  embedded_schema do +    field(:id, ObjectValidators.ObjectID, primary_key: true) +    field(:target) +    field(:object, ObjectValidators.ObjectID) +    field(:actor, ObjectValidators.ObjectID) +    field(:type) +    field(:to, ObjectValidators.Recipients, default: []) +    field(:cc, ObjectValidators.Recipients, default: []) +  end + +  def cast_and_validate(data) do +    {:ok, actor} = User.get_or_fetch_by_ap_id(data["actor"]) + +    {:ok, actor} = maybe_refetch_user(actor) + +    data +    |> maybe_fix_data_for_mastodon(actor) +    |> cast_data() +    |> validate_data(actor) +  end + +  defp maybe_fix_data_for_mastodon(data, actor) do +    # Mastodon sends pin/unpin objects without id, to, cc fields +    data +    |> Map.put_new("id", Pleroma.Web.ActivityPub.Utils.generate_activity_id()) +    |> Map.put_new("to", [Pleroma.Constants.as_public()]) +    |> Map.put_new("cc", [actor.follower_address]) +  end + +  defp cast_data(data) do +    cast(%__MODULE__{}, data, __schema__(:fields)) +  end + +  defp validate_data(changeset, actor) do +    changeset +    |> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) +    |> validate_inclusion(:type, ~w(Add Remove)) +    |> validate_actor_presence() +    |> validate_collection_belongs_to_actor(actor) +    |> validate_object_presence() +  end + +  defp validate_collection_belongs_to_actor(changeset, actor) do +    validate_change(changeset, :target, fn :target, target -> +      if target == actor.featured_address do +        [] +      else +        [target: "collection doesn't belong to actor"] +      end +    end) +  end + +  defp maybe_refetch_user(%User{featured_address: address} = user) when is_binary(address) do +    {:ok, user} +  end + +  defp maybe_refetch_user(%User{ap_id: ap_id}) do +    Pleroma.Web.ActivityPub.Transmogrifier.upgrade_user_from_ap_id(ap_id) +  end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 093549a45..940430588 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do    alias Pleroma.Object    alias Pleroma.User +  @spec validate_any_presence(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t()    def validate_any_presence(cng, fields) do      non_empty =        fields @@ -29,6 +30,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do      end    end +  @spec validate_actor_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t()    def validate_actor_presence(cng, options \\ []) do      field_name = Keyword.get(options, :field_name, :actor) @@ -47,6 +49,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do      end)    end +  @spec validate_object_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t()    def validate_object_presence(cng, options \\ []) do      field_name = Keyword.get(options, :field_name, :object)      allowed_types = Keyword.get(options, :allowed_types, false) @@ -68,6 +71,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do      end)    end +  @spec validate_object_or_user_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t()    def validate_object_or_user_presence(cng, options \\ []) do      field_name = Keyword.get(options, :field_name, :object)      options = Keyword.put(options, :field_name, field_name) @@ -83,6 +87,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do      if actor_cng.valid?, do: actor_cng, else: object_cng    end +  @spec validate_host_match(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t()    def validate_host_match(cng, fields \\ [:id, :actor]) do      if same_domain?(cng, fields) do        cng @@ -95,6 +100,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do      end    end +  @spec validate_fields_match(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t()    def validate_fields_match(cng, fields) do      if map_unique?(cng, fields) do        cng @@ -122,12 +128,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do      end)    end +  @spec same_domain?(Ecto.Changeset.t(), [atom()]) :: boolean()    def same_domain?(cng, fields \\ [:actor, :object]) do      map_unique?(cng, fields, fn value -> URI.parse(value).host end)    end    # This figures out if a user is able to create, delete or modify something    # based on the domain and superuser status +  @spec validate_modification_rights(Ecto.Changeset.t()) :: Ecto.Changeset.t()    def validate_modification_rights(cng) do      actor = User.get_cached_by_ap_id(get_field(cng, :actor)) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 0b9a9f0c5..5fe143c2b 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -276,10 +276,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do      result =        case deleted_object do          %Object{} -> -          with {:ok, deleted_object, activity} <- Object.delete(deleted_object), +          with {:ok, deleted_object, _activity} <- Object.delete(deleted_object),                 {_, actor} when is_binary(actor) <- {:actor, deleted_object.data["actor"]},                 %User{} = user <- User.get_cached_by_ap_id(actor) do -            User.remove_pinnned_activity(user, activity) +            User.remove_pinned_object_id(user, deleted_object.data["id"])              {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) @@ -312,6 +312,63 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do      end    end +  # Tasks this handles: +  # - adds pin to user +  # - removes expiration job for pinned activity, if was set for expiration +  @impl true +  def handle(%{data: %{"type" => "Add"} = data} = object, meta) do +    with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), +         {:ok, _user} <- User.add_pinned_object_id(user, data["object"]) do +      # if pinned activity was scheduled for deletion, we remove job +      if expiration = Pleroma.Workers.PurgeExpiredActivity.get_expiration(meta[:activity_id]) do +        Oban.cancel_job(expiration.id) +      end + +      {:ok, object, meta} +    else +      nil -> +        {:error, :user_not_found} + +      {:error, changeset} -> +        if changeset.errors[:pinned_objects] do +          {:error, :pinned_statuses_limit_reached} +        else +          changeset.errors +        end +    end +  end + +  # Tasks this handles: +  # - removes pin from user +  # - removes corresponding Add activity +  # - if activity had expiration, recreates activity expiration job +  @impl true +  def handle(%{data: %{"type" => "Remove"} = data} = object, meta) do +    with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), +         {:ok, _user} <- User.remove_pinned_object_id(user, data["object"]) do +      data["object"] +      |> Activity.add_by_params_query(user.ap_id, user.featured_address) +      |> Repo.delete_all() + +      # if pinned activity was scheduled for deletion, we reschedule it for deletion +      if meta[:expires_at] do +        # MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation +        {:ok, expires_at} = +          Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime.cast(meta[:expires_at]) + +        Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ +          activity_id: meta[:activity_id], +          expires_at: expires_at +        }) +      end + +      {:ok, object, meta} +    else +      nil -> {:error, :user_not_found} +      error -> error +    end +  end +    # Nothing to do    @impl true    def handle(object, meta) do diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 8c7d6a747..c4caeff0a 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -534,7 +534,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do    end    def handle_incoming(%{"type" => type} = data, _options) -      when type in ~w{Like EmojiReact Announce} do +      when type in ~w{Like EmojiReact Announce Add Remove} do      with :ok <- ObjectValidator.fetch_actor_and_object(data),           {:ok, activity, _meta} <-             Pipeline.common_pipeline(data, local: false) do @@ -1000,6 +1000,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do      with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),           {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),           {:ok, user} <- update_user(user, data) do +      {:ok, _pid} = Task.start(fn -> ActivityPub.pinned_fetch_task(user) end)        TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id})        {:ok, user}      else diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 8adc9878a..462f3b4a7 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -6,8 +6,10 @@ defmodule Pleroma.Web.ActivityPub.UserView do    use Pleroma.Web, :view    alias Pleroma.Keys +  alias Pleroma.Object    alias Pleroma.Repo    alias Pleroma.User +  alias Pleroma.Web.ActivityPub.ObjectView    alias Pleroma.Web.ActivityPub.Transmogrifier    alias Pleroma.Web.ActivityPub.Utils    alias Pleroma.Web.Endpoint @@ -97,6 +99,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do        "followers" => "#{user.ap_id}/followers",        "inbox" => "#{user.ap_id}/inbox",        "outbox" => "#{user.ap_id}/outbox", +      "featured" => "#{user.ap_id}/collections/featured",        "preferredUsername" => user.nickname,        "name" => user.name,        "summary" => user.bio, @@ -245,6 +248,24 @@ defmodule Pleroma.Web.ActivityPub.UserView do      |> Map.merge(pagination)    end +  def render("featured.json", %{ +        user: %{featured_address: featured_address, pinned_objects: pinned_objects} +      }) do +    objects = +      pinned_objects +      |> Enum.sort_by(fn {_, pinned_at} -> pinned_at end, &>=/2) +      |> Enum.map(fn {id, _} -> +        ObjectView.render("object.json", %{object: Object.get_cached_by_ap_id(id)}) +      end) + +    %{ +      "id" => featured_address, +      "type" => "OrderedCollection", +      "orderedItems" => objects +    } +    |> Map.merge(Utils.make_json_ld_header()) +  end +    defp maybe_put_total_items(map, false, _total), do: map    defp maybe_put_total_items(map, true, total) do diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 4bdb8e281..802fbef3e 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -182,7 +182,34 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do        parameters: [id_param()],        responses: %{          200 => status_response(), -        400 => Operation.response("Error", "application/json", ApiError) +        400 => +          Operation.response("Bad Request", "application/json", %Schema{ +            allOf: [ApiError], +            title: "Unprocessable Entity", +            example: %{ +              "error" => "You have already pinned the maximum number of statuses" +            } +          }), +        404 => +          Operation.response("Not found", "application/json", %Schema{ +            allOf: [ApiError], +            title: "Unprocessable Entity", +            example: %{ +              "error" => "Record not found" +            } +          }), +        422 => +          Operation.response( +            "Unprocessable Entity", +            "application/json", +            %Schema{ +              allOf: [ApiError], +              title: "Unprocessable Entity", +              example: %{ +                "error" => "Someone else's status cannot be pinned" +              } +            } +          )        }      }    end @@ -197,7 +224,22 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do        parameters: [id_param()],        responses: %{          200 => status_response(), -        400 => Operation.response("Error", "application/json", ApiError) +        400 => +          Operation.response("Bad Request", "application/json", %Schema{ +            allOf: [ApiError], +            title: "Unprocessable Entity", +            example: %{ +              "error" => "You have already pinned the maximum number of statuses" +            } +          }), +        404 => +          Operation.response("Not found", "application/json", %Schema{ +            allOf: [ApiError], +            title: "Unprocessable Entity", +            example: %{ +              "error" => "Record not found" +            } +          })        }      }    end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 42fa98718..3d042dc19 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -194,6 +194,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do            parent_visible: %Schema{              type: :boolean,              description: "`true` if the parent post is visible to the user" +          }, +          pinned_at: %Schema{ +            type: :string, +            format: "date-time", +            nullable: true, +            description: +              "A datetime (ISO 8601) that states when the post was pinned or `null` if the post is not pinned"            }          }        }, diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index b003e30c7..b36be4d2a 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -411,29 +411,58 @@ defmodule Pleroma.Web.CommonAPI do      end    end -  def pin(id, %{ap_id: user_ap_id} = user) do -    with %Activity{ -           actor: ^user_ap_id, -           data: %{"type" => "Create"}, -           object: %Object{data: %{"type" => object_type}} -         } = activity <- Activity.get_by_id_with_object(id), -         true <- object_type in ["Note", "Article", "Question"], -         true <- Visibility.is_public?(activity), -         {:ok, _user} <- User.add_pinnned_activity(user, activity) do +  @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} +  def pin(id, %User{} = user) do +    with %Activity{} = activity <- create_activity_by_id(id), +         true <- activity_belongs_to_actor(activity, user.ap_id), +         true <- object_type_is_allowed_for_pin(activity.object), +         true <- activity_is_public(activity), +         {:ok, pin_data, _} <- Builder.pin(user, activity.object), +         {:ok, _pin, _} <- +           Pipeline.common_pipeline(pin_data, +             local: true, +             activity_id: id +           ) do        {:ok, activity}      else -      {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} -      _ -> {:error, dgettext("errors", "Could not pin")} +      {:error, {:execute_side_effects, error}} -> error +      error -> error      end    end +  defp create_activity_by_id(id) do +    with nil <- Activity.create_by_id_with_object(id) do +      {:error, :not_found} +    end +  end + +  defp activity_belongs_to_actor(%{actor: actor}, actor), do: true +  defp activity_belongs_to_actor(_, _), do: {:error, :ownership_error} + +  defp object_type_is_allowed_for_pin(%{data: %{"type" => type}}) do +    with false <- type in ["Note", "Article", "Question"] do +      {:error, :not_allowed} +    end +  end + +  defp activity_is_public(activity) do +    with false <- Visibility.is_public?(activity) do +      {:error, :visibility_error} +    end +  end + +  @spec unpin(String.t(), User.t()) :: {:ok, User.t()} | {:error, term()}    def unpin(id, user) do -    with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), -         {:ok, _user} <- User.remove_pinnned_activity(user, activity) do +    with %Activity{} = activity <- create_activity_by_id(id), +         {:ok, unpin_data, _} <- Builder.unpin(user, activity.object), +         {:ok, _unpin, _} <- +           Pipeline.common_pipeline(unpin_data, +             local: true, +             activity_id: activity.id, +             expires_at: activity.data["expires_at"], +             featured_address: user.featured_address +           ) do        {:ok, activity} -    else -      {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} -      _ -> {:error, dgettext("errors", "Could not unpin")}      end    end diff --git a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex index d25f84837..84621500e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex @@ -30,6 +30,12 @@ defmodule Pleroma.Web.MastodonAPI.FallbackController do      |> json(%{error: error_message})    end +  def call(conn, {:error, status, message}) do +    conn +    |> put_status(status) +    |> json(%{error: message}) +  end +    def call(conn, _) do      conn      |> put_status(:internal_server_error) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index b051fca74..724dc5c5d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -260,6 +260,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do    def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do      with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do        try_render(conn, "show.json", activity: activity, for: user, as: :activity) +    else +      {:error, :pinned_statuses_limit_reached} -> +        {:error, "You have already pinned the maximum number of statuses"} + +      {:error, :ownership_error} -> +        {:error, :unprocessable_entity, "Someone else's status cannot be pinned"} + +      {:error, :visibility_error} -> +        {:error, :unprocessable_entity, "Non-public status cannot be pinned"} + +      error -> +        error      end    end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 3753588f2..814b3d142 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -152,6 +152,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do        |> Enum.filter(& &1)        |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) +    {pinned?, pinned_at} = pin_data(object, user) +      %{        id: to_string(activity.id),        uri: object.data["id"], @@ -173,7 +175,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do        favourited: present?(favorited),        bookmarked: present?(bookmarked),        muted: false, -      pinned: pinned?(activity, user), +      pinned: pinned?,        sensitive: false,        spoiler_text: "",        visibility: get_visibility(activity), @@ -184,7 +186,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do        language: nil,        emojis: [],        pleroma: %{ -        local: activity.local +        local: activity.local, +        pinned_at: pinned_at        }      }    end @@ -316,6 +319,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do            fn for_user, user -> User.mutes?(for_user, user) end          ) +    {pinned?, pinned_at} = pin_data(object, user) +      %{        id: to_string(activity.id),        uri: object.data["id"], @@ -339,7 +344,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do        favourited: present?(favorited),        bookmarked: present?(bookmarked),        muted: muted, -      pinned: pinned?(activity, user), +      pinned: pinned?,        sensitive: sensitive,        spoiler_text: summary,        visibility: get_visibility(object), @@ -360,7 +365,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do          direct_conversation_id: direct_conversation_id,          thread_muted: thread_muted?,          emoji_reactions: emoji_reactions, -        parent_visible: visible_for_user?(reply_to, opts[:for]) +        parent_visible: visible_for_user?(reply_to, opts[:for]), +        pinned_at: pinned_at        }      }    end @@ -529,8 +535,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do    defp present?(false), do: false    defp present?(_), do: true -  defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}), -    do: id in pinned_activities +  defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do +    if pinned_at = pinned_objects[object_id] do +      {true, Utils.to_masto_date(pinned_at)} +    else +      {false, nil} +    end +  end    defp build_emoji_map(emoji, users, current_user) do      %{ diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index de0bd27d7..ccf2ef796 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -704,6 +704,7 @@ defmodule Pleroma.Web.Router do      # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`:      get("/users/:nickname/followers", ActivityPubController, :followers)      get("/users/:nickname/following", ActivityPubController, :following) +    get("/users/:nickname/collections/featured", ActivityPubController, :pinned)    end    scope "/", Pleroma.Web.ActivityPub do  | 
