Post editing (#202)
authorfloatingghost <hannah@coffee-and-dreams.uk>
Tue, 6 Sep 2022 19:24:02 +0000 (19:24 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Tue, 6 Sep 2022 19:24:02 +0000 (19:24 +0000)
Rebased from #103

Co-authored-by: Tusooa Zhu <tusooa@kazv.moe>
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/202

66 files changed:
CHANGELOG.md
docs/docs/development/API/differences_in_mastoapi_responses.md
lib/pleroma/activity/html.ex
lib/pleroma/application.ex
lib/pleroma/constants.ex
lib/pleroma/notification.ex
lib/pleroma/object/fetcher.ex
lib/pleroma/object/updater.ex [new file with mode: 0644]
lib/pleroma/upload.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/activity_pub/builder.ex
lib/pleroma/web/activity_pub/mrf.ex
lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex
lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex
lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
lib/pleroma/web/activity_pub/mrf/keyword_policy.ex
lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex
lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex
lib/pleroma/web/activity_pub/mrf/normalize_markup.ex
lib/pleroma/web/activity_pub/mrf/policy.ex
lib/pleroma/web/activity_pub/object_validator.ex
lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex
lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
lib/pleroma/web/activity_pub/object_validators/common_fields.ex
lib/pleroma/web/activity_pub/object_validators/update_validator.ex
lib/pleroma/web/activity_pub/side_effects.ex
lib/pleroma/web/activity_pub/transmogrifier.ex
lib/pleroma/web/api_spec/operations/status_operation.ex
lib/pleroma/web/api_spec/schemas/status.ex
lib/pleroma/web/common_api.ex
lib/pleroma/web/common_api/activity_draft.ex
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
lib/pleroma/web/mastodon_api/controllers/status_controller.ex
lib/pleroma/web/mastodon_api/views/instance_view.ex
lib/pleroma/web/mastodon_api/views/notification_view.ex
lib/pleroma/web/mastodon_api/views/status_view.ex
lib/pleroma/web/router.ex
lib/pleroma/web/streamer.ex
lib/pleroma/web/views/streamer_view.ex
priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs [new file with mode: 0644]
priv/static/schemas/litepub-0.1.jsonld
test/pleroma/notification_test.exs
test/pleroma/object/fetcher_test.exs
test/pleroma/object/updater_test.exs [new file with mode: 0644]
test/pleroma/upload_test.exs
test/pleroma/web/activity_pub/mrf/anti_link_spam_policy_test.exs
test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs
test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs
test/pleroma/web/activity_pub/mrf/keyword_policy_test.exs
test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs
test/pleroma/web/activity_pub/mrf/no_empty_policy_test.exs
test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs
test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs
test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs
test/pleroma/web/activity_pub/object_validators/update_handling_test.exs
test/pleroma/web/activity_pub/side_effects_test.exs
test/pleroma/web/activity_pub/transmogrifier_test.exs
test/pleroma/web/common_api_test.exs
test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
test/pleroma/web/mastodon_api/views/notification_view_test.exs
test/pleroma/web/mastodon_api/views/status_view_test.exs
test/pleroma/web/metadata/utils_test.exs
test/pleroma/web/streamer_test.exs
test/support/factory.ex

index 05cb69c40f6e9044e80971c01c40deac44118284..e63cc1f6ebe9f7464ac47ef6ffa9b2353eb5d65c 100644 (file)
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - support for reusing oauth tokens, and not requiring new authorizations
 - the ability to obfuscate domains in your MRF descriptions
 - automatic translation of statuses via DeepL or LibreTranslate
+- ability to edit posts
 
 ### Changed
 - MFM parsing is now done on the backend by a modified version of ilja's parser -> https://akkoma.dev/AkkomaGang/mfm-parser
index 4465784bf2ee5579cc1c071ad4ec35a9efbfeed7..752be1762e76a06634d8a49fda2691fbf6230fc6 100644 (file)
@@ -40,6 +40,10 @@ Has these additional fields under the `pleroma` object:
 - `parent_visible`: If the parent of this post is visible to the user or not.
 - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise.
 
+The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes:
+
+- `content_type`: The content type of the status source.
+
 ## Scheduled statuses
 
 Has these additional fields in `params`:
index 0bf39383674f3c385cd13e43f205fd4ac91a61cc..30409d93dc33b99bfb323f1d8f2157aec3f4c7f2 100644 (file)
@@ -8,6 +8,40 @@ defmodule Pleroma.Activity.HTML do
 
   @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
 
+  # We store a list of cache keys related to an activity in a
+  # separate cache, scrubber_management_cache. It has the same
+  # size as scrubber_cache (see application.ex). Every time we add
+  # a cache to scrubber_cache, we update scrubber_management_cache.
+  #
+  # The most recent write of a certain key in the management cache
+  # is the same as the most recent write of any record related to that
+  # key in the main cache.
+  # Assuming LRW ( https://hexdocs.pm/cachex/Cachex.Policy.LRW.html ),
+  # this means when the management cache is evicted by cachex, all
+  # related records in the main cache will also have been evicted.
+
+  defp get_cache_keys_for(activity_id) do
+    with {:ok, list} when is_list(list) <- @cachex.get(:scrubber_management_cache, activity_id) do
+      list
+    else
+      _ -> []
+    end
+  end
+
+  defp add_cache_key_for(activity_id, additional_key) do
+    current = get_cache_keys_for(activity_id)
+
+    unless additional_key in current do
+      @cachex.put(:scrubber_management_cache, activity_id, [additional_key | current])
+    end
+  end
+
+  def invalidate_cache_for(activity_id) do
+    keys = get_cache_keys_for(activity_id)
+    Enum.map(keys, &@cachex.del(:scrubber_cache, &1))
+    @cachex.del(:scrubber_management_cache, activity_id)
+  end
+
   def get_cached_scrubbed_html_for_activity(
         content,
         scrubbers,
@@ -19,6 +53,8 @@ defmodule Pleroma.Activity.HTML do
 
     @cachex.fetch!(:scrubber_cache, key, fn _key ->
       object = Object.normalize(activity, fetch: false)
+
+      add_cache_key_for(activity.id, key)
       HTML.ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback)
     end)
   end
index b809f77337bb62a72e86ed889987e97985b993e7..adccd7c5dfbc087fbec54e90b287b031c597c03b 100644 (file)
@@ -150,6 +150,7 @@ defmodule Pleroma.Application do
       build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500),
       build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000),
       build_cachex("scrubber", limit: 2500),
+      build_cachex("scrubber_management", limit: 2500),
       build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500),
       build_cachex("web_resp", limit: 2500),
       build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
index bf92f65cb170d1267fc5242765fcc521bde1861b..7343ef8c3be1a8071c262498b3adf0f8355f7a8c 100644 (file)
@@ -27,4 +27,40 @@ defmodule Pleroma.Constants do
     do:
       ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css)
   )
+
+  const(status_updatable_fields,
+    do: [
+      "source",
+      "tag",
+      "updated",
+      "emoji",
+      "content",
+      "summary",
+      "sensitive",
+      "attachment",
+      "generator"
+    ]
+  )
+
+  const(updatable_object_types,
+    do: [
+      "Note",
+      "Question",
+      "Audio",
+      "Video",
+      "Event",
+      "Article",
+      "Page"
+    ]
+  )
+
+  const(actor_types,
+    do: [
+      "Application",
+      "Group",
+      "Organization",
+      "Person",
+      "Service"
+    ]
+  )
 end
index d8878338ee5859b90f9a1ca843a4afc4871e4d69..593448713a59ff6e7aeb1a2f48c75243991ecbce 100644 (file)
@@ -384,7 +384,7 @@ defmodule Pleroma.Notification do
   end
 
   def create_notifications(%Activity{data: %{"type" => type}} = activity, options)
-      when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag"] do
+      when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do
     do_create_notifications(activity, options)
   end
 
@@ -438,6 +438,9 @@ defmodule Pleroma.Notification do
         activity
         |> type_from_activity_object()
 
+      "Update" ->
+        "update"
+
       t ->
         raise "No notification type for activity type #{t}"
     end
@@ -503,7 +506,16 @@ defmodule Pleroma.Notification do
   def get_notified_from_activity(activity, local_only \\ true)
 
   def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
-      when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact", "Flag"] do
+      when type in [
+             "Create",
+             "Like",
+             "Announce",
+             "Follow",
+             "Move",
+             "EmojiReact",
+             "Flag",
+             "Update"
+           ] do
     potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
 
     potential_receivers =
@@ -543,6 +555,21 @@ defmodule Pleroma.Notification do
     (User.all_superusers() |> Enum.map(fn user -> user.ap_id end)) -- [actor]
   end
 
+  # Update activity: notify all who repeated this
+  def get_potential_receiver_ap_ids(%{data: %{"type" => "Update", "actor" => actor}} = activity) do
+    with %Object{data: %{"id" => object_id}} <- Object.normalize(activity, fetch: false) do
+      repeaters =
+        Activity.Queries.by_type("Announce")
+        |> Activity.Queries.by_object_id(object_id)
+        |> Activity.with_joined_user_actor()
+        |> where([a, u], u.local)
+        |> select([a, u], u.ap_id)
+        |> Repo.all()
+
+      repeaters -- [actor]
+    end
+  end
+
   def get_potential_receiver_ap_ids(activity) do
     []
     |> Utils.maybe_notify_to_recipients(activity)
index 4ca67f0fda211e98b5e5f305c8ccf88f97cac2bc..8ec28345f3c29bd4321a8edfedc4bff9402d3fa8 100644 (file)
@@ -26,8 +26,42 @@ defmodule Pleroma.Object.Fetcher do
   end
 
   defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do
+    has_history? = fn
+      %{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) -> true
+      _ -> false
+    end
+
     internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields())
 
+    remote_history_exists? = has_history?.(new_data)
+
+    # If the remote history exists, we treat that as the only source of truth.
+    new_data =
+      if has_history?.(old_data) and not remote_history_exists? do
+        Map.put(new_data, "formerRepresentations", old_data["formerRepresentations"])
+      else
+        new_data
+      end
+
+    # If the remote does not have history information, we need to manage it ourselves
+    new_data =
+      if not remote_history_exists? do
+        changed? =
+          Pleroma.Constants.status_updatable_fields()
+          |> Enum.any?(fn field -> Map.get(old_data, field) != Map.get(new_data, field) end)
+
+        %{updated_object: updated_object} =
+          new_data
+          |> Object.Updater.maybe_update_history(old_data,
+            updated: changed?,
+            use_history_in_new_object?: false
+          )
+
+        updated_object
+      else
+        new_data
+      end
+
     Map.merge(new_data, internal_fields)
   end
 
diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex
new file mode 100644 (file)
index 0000000..ab38d3e
--- /dev/null
@@ -0,0 +1,240 @@
+# Pleroma: A lightweight social networking server
+# Copyright Â© 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Object.Updater do
+  require Pleroma.Constants
+
+  def update_content_fields(orig_object_data, updated_object) do
+    Pleroma.Constants.status_updatable_fields()
+    |> Enum.reduce(
+      %{data: orig_object_data, updated: false},
+      fn field, %{data: data, updated: updated} ->
+        updated =
+          updated or
+            (field != "updated" and
+               Map.get(updated_object, field) != Map.get(orig_object_data, field))
+
+        data =
+          if Map.has_key?(updated_object, field) do
+            Map.put(data, field, updated_object[field])
+          else
+            Map.drop(data, [field])
+          end
+
+        %{data: data, updated: updated}
+      end
+    )
+  end
+
+  def maybe_history(object) do
+    with history <- Map.get(object, "formerRepresentations"),
+         true <- is_map(history),
+         "OrderedCollection" <- Map.get(history, "type"),
+         true <- is_list(Map.get(history, "orderedItems")),
+         true <- is_integer(Map.get(history, "totalItems")) do
+      history
+    else
+      _ -> nil
+    end
+  end
+
+  def history_for(object) do
+    with history when not is_nil(history) <- maybe_history(object) do
+      history
+    else
+      _ -> history_skeleton()
+    end
+  end
+
+  defp history_skeleton do
+    %{
+      "type" => "OrderedCollection",
+      "totalItems" => 0,
+      "orderedItems" => []
+    }
+  end
+
+  def maybe_update_history(
+        updated_object,
+        orig_object_data,
+        opts
+      ) do
+    updated = opts[:updated]
+    use_history_in_new_object? = opts[:use_history_in_new_object?]
+
+    if not updated do
+      %{updated_object: updated_object, used_history_in_new_object?: false}
+    else
+      # Put edit history
+      # Note that we may have got the edit history by first fetching the object
+      {new_history, used_history_in_new_object?} =
+        with true <- use_history_in_new_object?,
+             updated_history when not is_nil(updated_history) <- maybe_history(opts[:new_data]) do
+          {updated_history, true}
+        else
+          _ ->
+            history = history_for(orig_object_data)
+
+            latest_history_item =
+              orig_object_data
+              |> Map.drop(["id", "formerRepresentations"])
+
+            updated_history =
+              history
+              |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]])
+              |> Map.put("totalItems", history["totalItems"] + 1)
+
+            {updated_history, false}
+        end
+
+      updated_object =
+        updated_object
+        |> Map.put("formerRepresentations", new_history)
+
+      %{updated_object: updated_object, used_history_in_new_object?: used_history_in_new_object?}
+    end
+  end
+
+  defp maybe_update_poll(to_be_updated, updated_object) do
+    choice_key = fn data ->
+      if Map.has_key?(data, "anyOf"), do: "anyOf", else: "oneOf"
+    end
+
+    with true <- to_be_updated["type"] == "Question",
+         key <- choice_key.(updated_object),
+         true <- key == choice_key.(to_be_updated),
+         orig_choices <- to_be_updated[key] |> Enum.map(&Map.drop(&1, ["replies"])),
+         new_choices <- updated_object[key] |> Enum.map(&Map.drop(&1, ["replies"])),
+         true <- orig_choices == new_choices do
+      # Choices are the same, but counts are different
+      to_be_updated
+      |> Map.put(key, updated_object[key])
+    else
+      # Choices (or vote type) have changed, do not allow this
+      _ -> to_be_updated
+    end
+  end
+
+  # This calculates the data to be sent as the object of an Update.
+  # new_data's formerRepresentations is not considered.
+  # formerRepresentations is added to the returned data.
+  def make_update_object_data(original_data, new_data, date) do
+    %{data: updated_data, updated: updated} =
+      original_data
+      |> update_content_fields(new_data)
+
+    if not updated do
+      updated_data
+    else
+      %{updated_object: updated_data} =
+        updated_data
+        |> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: false)
+
+      updated_data
+      |> Map.put("updated", date)
+    end
+  end
+
+  # This calculates the data of the new Object from an Update.
+  # new_data's formerRepresentations is considered.
+  def make_new_object_data_from_update_object(original_data, new_data) do
+    update_is_reasonable =
+      with {_, updated} when not is_nil(updated) <- {:cur_updated, new_data["updated"]},
+           {_, {:ok, updated_time, _}} <- {:cur_updated, DateTime.from_iso8601(updated)},
+           {_, last_updated} when not is_nil(last_updated) <-
+             {:last_updated, original_data["updated"] || original_data["published"]},
+           {_, {:ok, last_updated_time, _}} <-
+             {:last_updated, DateTime.from_iso8601(last_updated)},
+           :gt <- DateTime.compare(updated_time, last_updated_time) do
+        :update_everything
+      else
+        # only allow poll updates
+        {:cur_updated, _} -> :no_content_update
+        :eq -> :no_content_update
+        # allow all updates
+        {:last_updated, _} -> :update_everything
+        # allow no updates
+        _ -> false
+      end
+
+    %{
+      updated_object: updated_data,
+      used_history_in_new_object?: used_history_in_new_object?,
+      updated: updated
+    } =
+      if update_is_reasonable == :update_everything do
+        %{data: updated_data, updated: updated} =
+          original_data
+          |> update_content_fields(new_data)
+
+        updated_data
+        |> maybe_update_history(original_data,
+          updated: updated,
+          use_history_in_new_object?: true,
+          new_data: new_data
+        )
+        |> Map.put(:updated, updated)
+      else
+        %{
+          updated_object: original_data,
+          used_history_in_new_object?: false,
+          updated: false
+        }
+      end
+
+    updated_data =
+      if update_is_reasonable != false do
+        updated_data
+        |> maybe_update_poll(new_data)
+      else
+        updated_data
+      end
+
+    %{
+      updated_data: updated_data,
+      updated: updated,
+      used_history_in_new_object?: used_history_in_new_object?
+    }
+  end
+
+  def for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do
+    new_items =
+      Enum.map(items, fun)
+      |> Enum.reduce_while(
+        {:ok, []},
+        fn
+          {:ok, item}, {:ok, acc} -> {:cont, {:ok, acc ++ [item]}}
+          e, _acc -> {:halt, e}
+        end
+      )
+
+    case new_items do
+      {:ok, items} -> {:ok, Map.put(history, "orderedItems", items)}
+      e -> e
+    end
+  end
+
+  def for_each_history_item(history, _, _) do
+    {:ok, history}
+  end
+
+  def do_with_history(object, fun) do
+    with history <- object["formerRepresentations"],
+         object <- Map.drop(object, ["formerRepresentations"]),
+         {_, {:ok, object}} <- {:main_body, fun.(object)},
+         {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
+      object =
+        if history do
+          Map.put(object, "formerRepresentations", history)
+        else
+          object
+        end
+
+      {:ok, object}
+    else
+      {:main_body, e} -> e
+      {:history_items, e} -> e
+    end
+  end
+end
index 17822dc5eb65959e5ad753f9550d0a119de058d8..9bf8e03df49aab6c7b1fb64952ee6befd10168a0 100644 (file)
@@ -36,6 +36,7 @@ defmodule Pleroma.Upload do
   alias Ecto.UUID
   alias Pleroma.Config
   alias Pleroma.Maps
+  alias Pleroma.Web.ActivityPub.Utils
   require Logger
 
   @type source ::
@@ -88,6 +89,7 @@ defmodule Pleroma.Upload do
          {:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do
       {:ok,
        %{
+         "id" => Utils.generate_object_id(),
          "type" => opts.activity_type,
          "mediaType" => upload.content_type,
          "url" => [
index 20acdf86eeddccf065fd4a4065e3523b3ec84f58..dcdc7085fdaebc45eb36f4d279100ff33cce59a3 100644 (file)
@@ -194,7 +194,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
   def notify_and_stream(activity) do
     Notification.create_notifications(activity)
 
-    conversation = create_or_bump_conversation(activity, activity.actor)
+    original_activity =
+      case activity do
+        %{data: %{"type" => "Update"}, object: %{data: %{"id" => id}}} ->
+          Activity.get_create_by_object_ap_id_with_object(id)
+
+        _ ->
+          activity
+      end
+
+    conversation = create_or_bump_conversation(original_activity, original_activity.actor)
     participations = get_participations(conversation)
     stream_out(activity)
     stream_out_participations(participations)
@@ -260,7 +269,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
 
   @impl true
   def stream_out(%Activity{data: %{"type" => data_type}} = activity)
-      when data_type in ["Create", "Announce", "Delete"] do
+      when data_type in ["Create", "Announce", "Delete", "Update"] do
     activity
     |> Topics.get_activity_topics()
     |> Streamer.stream(activity)
index ba756ed64ac72d310ecc06c4e63529671ccb837c..6d39ad3a858bec9394acb0c9baac3d44f29a860e 100644 (file)
@@ -278,10 +278,16 @@ defmodule Pleroma.Web.ActivityPub.Builder do
     end
   end
 
-  # Retricted to user updates for now, always public
   @spec update(User.t(), Object.t()) :: {:ok, map(), keyword()}
   def update(actor, object) do
-    to = [Pleroma.Constants.as_public(), actor.follower_address]
+    {to, cc} =
+      if object["type"] in Pleroma.Constants.actor_types() do
+        # User updates, always public
+        {[Pleroma.Constants.as_public(), actor.follower_address], []}
+      else
+        # Status updates, follow the recipients in the object
+        {object["to"] || [], object["cc"] || []}
+      end
 
     {:ok,
      %{
@@ -289,7 +295,8 @@ defmodule Pleroma.Web.ActivityPub.Builder do
        "type" => "Update",
        "actor" => actor.ap_id,
        "object" => object,
-       "to" => to
+       "to" => to,
+       "cc" => cc
      }, []}
   end
 
index 20bce0d5fb5ccb4d4504718d49fe5a3c37fe02b5..4df226e80eb09de8754e8bb55e69c6a1f3b6362f 100644 (file)
@@ -63,10 +63,53 @@ defmodule Pleroma.Web.ActivityPub.MRF do
 
   @required_description_keys [:key, :related_policy]
 
+  def filter_one(policy, message) do
+    should_plug_history? =
+      if function_exported?(policy, :history_awareness, 0) do
+        policy.history_awareness()
+      else
+        :manual
+      end
+      |> Kernel.==(:auto)
+
+    if not should_plug_history? do
+      policy.filter(message)
+    else
+      main_result = policy.filter(message)
+
+      with {_, {:ok, main_message}} <- {:main, main_result},
+           {_,
+            %{
+              "formerRepresentations" => %{
+                "orderedItems" => [_ | _]
+              }
+            }} = {_, object} <- {:object, message["object"]},
+           {_, {:ok, new_history}} <-
+             {:history,
+              Pleroma.Object.Updater.for_each_history_item(
+                object["formerRepresentations"],
+                object,
+                fn item ->
+                  with {:ok, filtered} <- policy.filter(Map.put(message, "object", item)) do
+                    {:ok, filtered["object"]}
+                  else
+                    e -> e
+                  end
+                end
+              )} do
+        {:ok, put_in(main_message, ["object", "formerRepresentations"], new_history)}
+      else
+        {:main, _} -> main_result
+        {:object, _} -> main_result
+        {:history, e} -> e
+      end
+    end
+  end
+
   def filter(policies, %{} = message) do
     policies
     |> Enum.reduce({:ok, message}, fn
-      policy, {:ok, message} -> policy.filter(message)
+      policy, {:ok, message} -> filter_one(policy, message)
       _, error -> error
     end)
   end
index cdf17fd28c001aa6a3ff3d464e6033503e582194..ba7c8400bf8a8f3dfa877b2d79e126abb6ce2a80 100644 (file)
@@ -9,6 +9,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do
 
   require Logger
 
+  @impl true
+  def history_awareness, do: :auto
+
   # has the user successfully posted before?
   defp old_user?(%User{} = u) do
     u.note_count > 0 || u.follower_count > 0
index fad8d873bd5ffd6e4517f1efd23c15d61a8512fb..c438b8f704569dc51731df2473243e66cee97f8b 100644 (file)
@@ -10,6 +10,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do
 
   @reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
 
+  def history_awareness, do: :auto
+
   def filter_by_summary(
         %{data: %{"summary" => parent_summary}} = _in_reply_to,
         %{"summary" => child_summary} = child
@@ -27,8 +29,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do
 
   def filter_by_summary(_in_reply_to, child), do: child
 
-  def filter(%{"type" => "Create", "object" => child_object} = object)
-      when is_map(child_object) do
+  def filter(%{"type" => type, "object" => child_object} = object)
+      when type in ["Create", "Update"] and is_map(child_object) do
     child =
       child_object["inReplyTo"]
       |> Object.normalize(fetch: false)
index b7db4fa3d56de8ea398a9652d19acd751486d409..b5ad8b5b4d8b7ff2137588cde1dda9f9ca90ec67 100644 (file)
@@ -16,6 +16,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do
 
   @behaviour Pleroma.Web.ActivityPub.MRF.Policy
 
+  @impl true
+  def history_awareness, do: :manual
+
   defp check_reject(message, hashtags) do
     if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
       {:reject, "[HashtagPolicy] Matches with rejected keyword"}
@@ -47,22 +50,46 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do
 
   defp check_ftl_removal(message, _hashtags), do: {:ok, message}
 
-  defp check_sensitive(message, hashtags) do
-    if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
-      {:ok, Kernel.put_in(message, ["object", "sensitive"], true)}
-    else
-      {:ok, message}
-    end
+  defp check_sensitive(message) do
+    {:ok, new_object} =
+      Object.Updater.do_with_history(message["object"], fn object ->
+        hashtags = Object.hashtags(%Object{data: object})
+
+        if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
+          {:ok, Map.put(object, "sensitive", true)}
+        else
+          {:ok, object}
+        end
+      end)
+
+    {:ok, Map.put(message, "object", new_object)}
   end
 
   @impl true
-  def filter(%{"type" => "Create", "object" => object} = message) do
-    hashtags = Object.hashtags(%Object{data: object})
+  def filter(%{"type" => type, "object" => object} = message) when type in ["Create", "Update"] do
+    history_items =
+      with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do
+        items
+      else
+        _ -> []
+      end
+
+    historical_hashtags =
+      Enum.reduce(history_items, [], fn item, acc ->
+        acc ++ Object.hashtags(%Object{data: item})
+      end)
+
+    hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags
 
     if hashtags != [] do
       with {:ok, message} <- check_reject(message, hashtags),
-           {:ok, message} <- check_ftl_removal(message, hashtags),
-           {:ok, message} <- check_sensitive(message, hashtags) do
+           {:ok, message} <-
+             (if "type" == "Create" do
+                check_ftl_removal(message, hashtags)
+              else
+                {:ok, message}
+              end),
+           {:ok, message} <- check_sensitive(message) do
         {:ok, message}
       end
     else
index 1383fa757365836c2d102212f2c0c9868cb9ff62..7c921fc767d73fcaac873ca36c393145e02fd1d7 100644 (file)
@@ -27,24 +27,46 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
   end
 
   defp check_reject(%{"object" => %{} = object} = message) do
-    payload = object_payload(object)
-
-    if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
-         string_matches?(payload, pattern)
-       end) do
-      {:reject, "[KeywordPolicy] Matches with rejected keyword"}
-    else
+    with {:ok, _new_object} <-
+           Pleroma.Object.Updater.do_with_history(object, fn object ->
+             payload = object_payload(object)
+
+             if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
+                  string_matches?(payload, pattern)
+                end) do
+               {:reject, "[KeywordPolicy] Matches with rejected keyword"}
+             else
+               {:ok, message}
+             end
+           end) do
       {:ok, message}
+    else
+      e -> e
     end
   end
 
-  defp check_ftl_removal(%{"to" => to, "object" => %{} = object} = message) do
-    payload = object_payload(object)
+  defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = message) do
+    check_keyword = fn object ->
+      payload = object_payload(object)
 
-    if Pleroma.Constants.as_public() in to and
-         Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
+      if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
            string_matches?(payload, pattern)
          end) do
+        {:should_delist, nil}
+      else
+        {:ok, %{}}
+      end
+    end
+
+    should_delist? = fn object ->
+      with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do
+        false
+      else
+        _ -> true
+      end
+    end
+
+    if Pleroma.Constants.as_public() in to and should_delist?.(object) do
       to = List.delete(to, Pleroma.Constants.as_public())
       cc = [Pleroma.Constants.as_public() | message["cc"] || []]
 
@@ -59,8 +81,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
     end
   end
 
+  defp check_ftl_removal(message) do
+    {:ok, message}
+  end
+
   defp check_replace(%{"object" => %{} = object} = message) do
-    object =
+    replace_kw = fn object ->
       ["content", "name", "summary"]
       |> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end)
       |> Enum.reduce(object, fn field, object ->
@@ -73,6 +99,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
 
         Map.put(object, field, data)
       end)
+      |> (fn object -> {:ok, object} end).()
+    end
+
+    {:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw)
 
     message = Map.put(message, "object", object)
 
@@ -80,7 +110,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
   end
 
   @impl true
-  def filter(%{"type" => "Create", "object" => %{"content" => _content}} = message) do
+  def filter(%{"type" => type, "object" => %{"content" => _content}} = message)
+      when type in ["Create", "Update"] do
     with {:ok, message} <- check_reject(message),
          {:ok, message} <- check_ftl_removal(message),
          {:ok, message} <- check_replace(message) do
index f60a76adfa18eca7a9cc4a4a3ddb4a0cd88f6868..72455afd0a5f518267616a6da965adf42308ee6a 100644 (file)
@@ -15,6 +15,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
     recv_timeout: 10_000
   ]
 
+  @impl true
+  def history_awareness, do: :auto
+
   defp prefetch(url) do
     # Fetching only proxiable resources
     if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
@@ -53,10 +56,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
   end
 
   @impl true
-  def filter(
-        %{"type" => "Create", "object" => %{"attachment" => attachments} = _object} = message
-      )
-      when is_list(attachments) and length(attachments) > 0 do
+  def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = message)
+      when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do
     preload(message)
 
     {:ok, message}
index b2939a4d6db03e839b6c29a99243d0a939a74258..19637a38d0f1a0e54aca234e15fc5b2c9b63a415 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
   @impl true
   def filter(%{"actor" => actor} = object) do
     with true <- is_local?(actor),
+         true <- is_eligible_type?(object),
          true <- is_note?(object),
          false <- has_attachment?(object),
          true <- only_mentions?(object) do
@@ -32,7 +33,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
   end
 
   defp has_attachment?(%{
-         "type" => "Create",
          "object" => %{"type" => "Note", "attachment" => attachments}
        })
        when length(attachments) > 0,
@@ -40,23 +40,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
 
   defp has_attachment?(_), do: false
 
-  defp only_mentions?(%{"type" => "Create", "object" => %{"type" => "Note", "source" => source}})
-       when is_binary(source) do
-    non_mentions =
-      source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
+  defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do
+    source =
+      case source do
+        %{"content" => text} -> text
+        _ -> source
+      end
 
-    if non_mentions > 0 do
-      false
-    else
-      true
-    end
-  end
-
-  defp only_mentions?(%{
-         "type" => "Create",
-         "object" => %{"type" => "Note", "source" => %{"content" => source}}
-       })
-       when is_binary(source) do
     non_mentions =
       source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
 
@@ -69,9 +59,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
 
   defp only_mentions?(_), do: false
 
-  defp is_note?(%{"type" => "Create", "object" => %{"type" => "Note"}}), do: true
+  defp is_note?(%{"object" => %{"type" => "Note"}}), do: true
   defp is_note?(_), do: false
 
+  defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true
+  defp is_eligible_type?(_), do: false
+
   @impl true
   def describe, do: {:ok, %{}}
 end
index 90272766c4f1d84489f44f20fd5bb59a2a1ecf60..f25bb4efd537e016fba06b835a18e5276bbd0a62 100644 (file)
@@ -6,14 +6,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do
   @moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)"
   @behaviour Pleroma.Web.ActivityPub.MRF.Policy
 
+  @impl true
+  def history_awareness, do: :auto
+
   @impl true
   def filter(
         %{
-          "type" => "Create",
+          "type" => type,
           "object" => %{"content" => content, "attachment" => _} = _child_object
         } = object
       )
-      when content in [".", "<p>.</p>"] do
+      when type in ["Create", "Update"] and content in [".", "<p>.</p>"] do
     {:ok, put_in(object, ["object", "content"], "")}
   end
 
index 0d71467387a98793606b3b7496792f71de996ef4..151c6ed2053a7d8a281b13c15d9a5f5ded77a672 100644 (file)
@@ -9,7 +9,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do
   @behaviour Pleroma.Web.ActivityPub.MRF.Policy
 
   @impl true
-  def filter(%{"type" => "Create", "object" => child_object} = object) do
+  def history_awareness, do: :auto
+
+  @impl true
+  def filter(%{"type" => type, "object" => child_object} = object)
+      when type in ["Create", "Update"] do
     scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy])
 
     content =
index a4a960c013f110e21c5d811299fc34810b9c1016..75209b2db012a4db78515e55ee7a9e86bfa3105f 100644 (file)
@@ -12,5 +12,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.Policy do
               label: String.t(),
               description: String.t()
             }
-  @optional_callbacks config_description: 0
+  @callback history_awareness() :: :auto | :manual
+  @optional_callbacks config_description: 0, history_awareness: 0
 end
index 283cd884c948e5c4d243708ab9ca0db6e107769c..cb0cc9ed792f5287ce0f74389569fae8d20cff06 100644 (file)
@@ -86,8 +86,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
         meta
       )
       when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
-    with {:ok, object_data} <- cast_and_apply(object),
-         meta = Keyword.put(meta, :object_data, object_data |> stringify_keys),
+    with {:ok, object_data} <- cast_and_apply_and_stringify_with_history(object),
+         meta = Keyword.put(meta, :object_data, object_data),
          {:ok, create_activity} <-
            create_activity
            |> CreateGenericValidator.cast_and_validate(meta)
@@ -111,16 +111,50 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
       end
 
     with {:ok, object} <-
-           object
-           |> validator.cast_and_validate()
-           |> Ecto.Changeset.apply_action(:insert) do
-      object = stringify_keys(object)
+           do_separate_with_history(object, fn object ->
+             with {:ok, object} <-
+                    object
+                    |> validator.cast_and_validate()
+                    |> Ecto.Changeset.apply_action(:insert) do
+               object = stringify_keys(object)
+
+               # Insert copy of hashtags as strings for the non-hashtag table indexing
+               tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
+               object = Map.put(object, "tag", tag)
+
+               {:ok, object}
+             end
+           end) do
+      {:ok, object, meta}
+    end
+  end
 
-      # Insert copy of hashtags as strings for the non-hashtag table indexing
-      tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
-      object = Map.put(object, "tag", tag)
+  def validate(
+        %{"type" => "Update", "object" => %{"type" => objtype} = object} = update_activity,
+        meta
+      )
+      when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
+    with {_, false} <- {:local, Access.get(meta, :local, false)},
+         {_, {:ok, object_data, _}} <- {:object_validation, validate(object, meta)},
+         meta = Keyword.put(meta, :object_data, object_data),
+         {:ok, update_activity} <-
+           update_activity
+           |> UpdateValidator.cast_and_validate()
+           |> Ecto.Changeset.apply_action(:insert) do
+      update_activity = stringify_keys(update_activity)
+      {:ok, update_activity, meta}
+    else
+      {:local, _} ->
+        with {:ok, object} <-
+               update_activity
+               |> UpdateValidator.cast_and_validate()
+               |> Ecto.Changeset.apply_action(:insert) do
+          object = stringify_keys(object)
+          {:ok, object, meta}
+        end
 
-      {:ok, object, meta}
+      {:object_validation, e} ->
+        e
     end
   end
 
@@ -160,6 +194,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
 
   def validate(o, m), do: {:error, {:validator_not_set, {o, m}}}
 
+  def cast_and_apply_and_stringify_with_history(object) do
+    do_separate_with_history(object, fn object ->
+      with {:ok, object_data} <- cast_and_apply(object),
+           object_data <- object_data |> stringify_keys() do
+        {:ok, object_data}
+      end
+    end)
+  end
+
   def cast_and_apply(%{"type" => "Question"} = object) do
     QuestionValidator.cast_and_apply(object)
   end
@@ -214,4 +257,54 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
     Object.normalize(object["object"], fetch: true)
     :ok
   end
+
+  defp for_each_history_item(
+         %{"type" => "OrderedCollection", "orderedItems" => items} = history,
+         object,
+         fun
+       ) do
+    processed_items =
+      Enum.map(items, fn item ->
+        with item <- Map.put(item, "id", object["id"]),
+             {:ok, item} <- fun.(item) do
+          item
+        else
+          _ -> nil
+        end
+      end)
+
+    if Enum.all?(processed_items, &(not is_nil(&1))) do
+      {:ok, Map.put(history, "orderedItems", processed_items)}
+    else
+      {:error, :invalid_history}
+    end
+  end
+
+  defp for_each_history_item(nil, _object, _fun) do
+    {:ok, nil}
+  end
+
+  defp for_each_history_item(_, _object, _fun) do
+    {:error, :invalid_history}
+  end
+
+  # fun is (object -> {:ok, validated_object_with_string_keys})
+  defp do_separate_with_history(object, fun) do
+    with history <- object["formerRepresentations"],
+         object <- Map.drop(object, ["formerRepresentations"]),
+         {_, {:ok, object}} <- {:main_body, fun.(object)},
+         {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
+      object =
+        if history do
+          Map.put(object, "formerRepresentations", history)
+        else
+          object
+        end
+
+      {:ok, object}
+    else
+      {:main_body, e} -> e
+      {:history_items, e} -> e
+    end
+  end
 end
index 55323bc2e87dbef5a2f990cdf297b9c43571fa62..0d45421e2555cd64b39f6c3807b7fddb8d6454ca 100644 (file)
@@ -53,7 +53,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
   defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"])
   defp fix_url(data), do: data
 
-  defp fix_tag(%{"tag" => tag} = data) when is_list(tag), do: data
+  defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do
+    Map.put(data, "tag", Enum.filter(tag, &is_map/1))
+  end
+
   defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
   defp fix_tag(data), do: Map.drop(data, ["tag"])
 
index ffdb16976f30263fc71b7f7dea9592eff4d5b4e3..dba18a3d0b2baf2000f043c3a6773b8e465f3d40 100644 (file)
@@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
 
   @primary_key false
   embedded_schema do
+    field(:id, :string)
     field(:type, :string)
     field(:mediaType, :string, default: "application/octet-stream")
     field(:name, :string)
@@ -43,7 +44,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
       |> fix_url()
 
     struct
-    |> cast(data, [:type, :mediaType, :name, :blurhash])
+    |> cast(data, [:id, :type, :mediaType, :name, :blurhash])
     |> cast_embed(:url, with: &url_changeset/2, required: true)
     |> validate_inclusion(:type, ~w[Link Document Audio Image Video])
     |> validate_required([:type, :mediaType])
index 49aba68af6511842d4ba8f7befb3ad7b17e6b64f..db28c38eff39ace8af27242ee1ac99640a425731 100644 (file)
@@ -33,6 +33,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFields do
       field(:content, :string)
 
       field(:published, ObjectValidators.DateTime)
+      field(:updated, ObjectValidators.DateTime)
       field(:emoji, ObjectValidators.Emoji, default: %{})
       embeds_many(:attachment, AttachmentValidator)
     end
index a1fae47f58aeb84ad7baba87826a3a1a7a058eb5..2f0839c5b1569c7c07a045ef7b9d213241768f83 100644 (file)
@@ -51,7 +51,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do
     with actor = get_field(cng, :actor),
          object = get_field(cng, :object),
          {:ok, object_id} <- ObjectValidators.ObjectID.cast(object),
-         true <- actor == object_id do
+         actor_uri <- URI.parse(actor),
+         object_uri <- URI.parse(object_id),
+         true <- actor_uri.host == object_uri.host do
       cng
     else
       _e ->
index 439268470c544d0c4cbf7b55989a4f1635635d8b..43b1b089b6e938496e325973f3d7a11133f6c391 100644 (file)
@@ -23,6 +23,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
   alias Pleroma.Web.Streamer
   alias Pleroma.Workers.PollWorker
 
+  require Pleroma.Constants
   require Logger
 
   @logger Pleroma.Config.get([:side_effects, :logger], Logger)
@@ -150,23 +151,26 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
 
   # Tasks this handles:
   # - Update the user
+  # - Update a non-user object (Note, Question, etc.)
   #
   # For a local user, we also get a changeset with the full information, so we
   # can update non-federating, non-activitypub settings as well.
   @impl true
   def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do
-    if changeset = Keyword.get(meta, :user_update_changeset) do
-      changeset
-      |> User.update_and_set_cache()
+    updated_object_id = updated_object["id"]
+
+    with {_, true} <- {:has_id, is_binary(updated_object_id)},
+         %{"type" => type} <- updated_object,
+         {_, is_user} <- {:is_user, type in Pleroma.Constants.actor_types()} do
+      if is_user do
+        handle_update_user(object, meta)
+      else
+        handle_update_object(object, meta)
+      end
     else
-      {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
-
-      User.get_by_ap_id(updated_object["id"])
-      |> User.remote_user_changeset(new_user_data)
-      |> User.update_and_set_cache()
+      _ ->
+        {:ok, object, meta}
     end
-
-    {:ok, object, meta}
   end
 
   # Tasks this handles:
@@ -395,6 +399,79 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
     {:ok, object, meta}
   end
 
+  defp handle_update_user(
+         %{data: %{"type" => "Update", "object" => updated_object}} = object,
+         meta
+       ) do
+    if changeset = Keyword.get(meta, :user_update_changeset) do
+      changeset
+      |> User.update_and_set_cache()
+    else
+      {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
+
+      User.get_by_ap_id(updated_object["id"])
+      |> User.remote_user_changeset(new_user_data)
+      |> User.update_and_set_cache()
+    end
+
+    {:ok, object, meta}
+  end
+
+  defp handle_update_object(
+         %{data: %{"type" => "Update", "object" => updated_object}} = object,
+         meta
+       ) do
+    orig_object_ap_id = updated_object["id"]
+    orig_object = Object.get_by_ap_id(orig_object_ap_id)
+    orig_object_data = orig_object.data
+
+    updated_object =
+      if meta[:local] do
+        # If this is a local Update, we don't process it by transmogrifier,
+        # so we use the embedded object as-is.
+        updated_object
+      else
+        meta[:object_data]
+      end
+
+    if orig_object_data["type"] in Pleroma.Constants.updatable_object_types() do
+      %{
+        updated_data: updated_object_data,
+        updated: updated,
+        used_history_in_new_object?: used_history_in_new_object?
+      } = Object.Updater.make_new_object_data_from_update_object(orig_object_data, updated_object)
+
+      changeset =
+        orig_object
+        |> Repo.preload(:hashtags)
+        |> Object.change(%{data: updated_object_data})
+
+      with {:ok, new_object} <- Repo.update(changeset),
+           {:ok, _} <- Object.invalid_object_cache(new_object),
+           {:ok, _} <- Object.set_cache(new_object),
+           # The metadata/utils.ex uses the object id for the cache.
+           {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(new_object.id) do
+        if used_history_in_new_object? do
+          with create_activity when not is_nil(create_activity) <-
+                 Pleroma.Activity.get_create_by_object_ap_id(orig_object_ap_id),
+               {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(create_activity.id) do
+            nil
+          else
+            _ -> nil
+          end
+        end
+
+        if updated do
+          object
+          |> Activity.normalize()
+          |> ActivityPub.notify_and_stream()
+        end
+      end
+    end
+
+    {:ok, object, meta}
+  end
+
   def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do
     with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
       PollWorker.schedule_poll_end(activity)
index 8ec4b0fecd830fe2ad4b130b22699b29a7ac51db..b9d8536105c9173c30d78ee9d00a0ac989d6c241 100644 (file)
@@ -699,6 +699,24 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     |> strip_internal_fields
     |> strip_internal_tags
     |> set_type
+    |> maybe_process_history
+  end
+
+  defp maybe_process_history(%{"formerRepresentations" => %{"orderedItems" => history}} = object) do
+    processed_history =
+      Enum.map(
+        history,
+        fn
+          item when is_map(item) -> prepare_object(item)
+          item -> item
+        end
+      )
+
+    put_in(object, ["formerRepresentations", "orderedItems"], processed_history)
+  end
+
+  defp maybe_process_history(object) do
+    object
   end
 
   #  @doc
@@ -723,6 +741,21 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
     {:ok, data}
   end
 
+  def prepare_outgoing(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data)
+      when objtype in Pleroma.Constants.updatable_object_types() do
+    object =
+      object
+      |> prepare_object
+
+    data =
+      data
+      |> Map.put("object", object)
+      |> Map.merge(Utils.make_json_ld_header())
+      |> Map.delete("bcc")
+
+    {:ok, data}
+  end
+
   def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
     object =
       object_id
index 5332c9dcabe09002724dd457271ddd4c124c8f0f..65877cc64f6e78293332e03ae70a11a7eae8dc7c 100644 (file)
@@ -6,9 +6,13 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
   alias OpenApiSpex.Operation
   alias OpenApiSpex.Schema
   alias Pleroma.Web.ApiSpec.AccountOperation
+  alias Pleroma.Web.ApiSpec.Schemas.Account
   alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.Attachment
   alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
+  alias Pleroma.Web.ApiSpec.Schemas.Emoji
   alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+  alias Pleroma.Web.ApiSpec.Schemas.Poll
   alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
   alias Pleroma.Web.ApiSpec.Schemas.Status
   alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
@@ -422,6 +426,59 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     }
   end
 
+  def show_history_operation do
+    %Operation{
+      tags: ["Retrieve status history"],
+      summary: "Status history",
+      description: "View history of a status",
+      operationId: "StatusController.show_history",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [
+        id_param()
+      ],
+      responses: %{
+        200 => status_history_response(),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def show_source_operation do
+    %Operation{
+      tags: ["Retrieve status source"],
+      summary: "Status source",
+      description: "View source of a status",
+      operationId: "StatusController.show_source",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [
+        id_param()
+      ],
+      responses: %{
+        200 => status_source_response(),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Update status"],
+      summary: "Update status",
+      description: "Change the content of a status",
+      operationId: "StatusController.update",
+      security: [%{"oAuth" => ["write:statuses"]}],
+      parameters: [
+        id_param()
+      ],
+      requestBody: request_body("Parameters", update_request(), required: true),
+      responses: %{
+        200 => status_response(),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
   def array_of_statuses do
     %Schema{type: :array, items: Status, example: [Status.schema().example]}
   end
@@ -530,6 +587,60 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     }
   end
 
+  defp update_request do
+    %Schema{
+      title: "StatusUpdateRequest",
+      type: :object,
+      properties: %{
+        status: %Schema{
+          type: :string,
+          nullable: true,
+          description:
+            "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
+        },
+        media_ids: %Schema{
+          nullable: true,
+          type: :array,
+          items: %Schema{type: :string},
+          description: "Array of Attachment ids to be attached as media."
+        },
+        poll: poll_params(),
+        sensitive: %Schema{
+          allOf: [BooleanLike],
+          nullable: true,
+          description: "Mark status and attached media as sensitive?"
+        },
+        spoiler_text: %Schema{
+          type: :string,
+          nullable: true,
+          description:
+            "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
+        },
+        content_type: %Schema{
+          type: :string,
+          nullable: true,
+          description:
+            "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
+        },
+        to: %Schema{
+          type: :array,
+          nullable: true,
+          items: %Schema{type: :string},
+          description:
+            "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
+        }
+      },
+      example: %{
+        "status" => "What time is it?",
+        "sensitive" => "false",
+        "poll" => %{
+          "options" => ["Cofe", "Adventure"],
+          "expires_in" => 420
+        }
+      }
+    }
+  end
+
   def poll_params do
     %Schema{
       nullable: true,
@@ -580,6 +691,87 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     Operation.response("Status", "application/json", Status)
   end
 
+  defp status_history_response do
+    Operation.response(
+      "Status History",
+      "application/json",
+      %Schema{
+        title: "Status history",
+        description: "Response schema for history of a status",
+        type: :array,
+        items: %Schema{
+          type: :object,
+          properties: %{
+            account: %Schema{
+              allOf: [Account],
+              description: "The account that authored this status"
+            },
+            content: %Schema{
+              type: :string,
+              format: :html,
+              description: "HTML-encoded status content"
+            },
+            sensitive: %Schema{
+              type: :boolean,
+              description: "Is this status marked as sensitive content?"
+            },
+            spoiler_text: %Schema{
+              type: :string,
+              description:
+                "Subject or summary line, below which status content is collapsed until expanded"
+            },
+            created_at: %Schema{
+              type: :string,
+              format: "date-time",
+              description: "The date when this status was created"
+            },
+            media_attachments: %Schema{
+              type: :array,
+              items: Attachment,
+              description: "Media that is attached to this status"
+            },
+            emojis: %Schema{
+              type: :array,
+              items: Emoji,
+              description: "Custom emoji to be used when rendering status content"
+            },
+            poll: %Schema{
+              allOf: [Poll],
+              nullable: true,
+              description: "The poll attached to the status"
+            }
+          }
+        }
+      }
+    )
+  end
+
+  defp status_source_response do
+    Operation.response(
+      "Status Source",
+      "application/json",
+      %Schema{
+        type: :object,
+        properties: %{
+          id: FlakeID,
+          text: %Schema{
+            type: :string,
+            description: "Raw source of status content"
+          },
+          spoiler_text: %Schema{
+            type: :string,
+            description:
+              "Subject or summary line, below which status content is collapsed until expanded"
+          },
+          content_type: %Schema{
+            type: :string,
+            description: "The content type of the source"
+          }
+        }
+      }
+    )
+  end
+
   defp context do
     %Schema{
       title: "StatusContext",
index c5d9119ef0a262a07c3808c2e979e71657c33810..a6df9be94d7e242f0259c6b8746076813f29634b 100644 (file)
@@ -73,6 +73,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
         format: "date-time",
         description: "The date when this status was created"
       },
+      edited_at: %Schema{
+        type: :string,
+        format: "date-time",
+        nullable: true,
+        description: "The date when this status was last edited"
+      },
       emojis: %Schema{
         type: :array,
         items: Emoji,
index 23d353dc226cf843e55aca18754ff569735a42ec..f1f51acf5c9bcc55e15d08987e11b04c3351f5a9 100644 (file)
@@ -347,6 +347,41 @@ defmodule Pleroma.Web.CommonAPI do
     end
   end
 
+  def update(user, orig_activity, changes) do
+    with orig_object <- Object.normalize(orig_activity),
+         {:ok, new_object} <- make_update_data(user, orig_object, changes),
+         {:ok, update_data, _} <- Builder.update(user, new_object),
+         {:ok, update, _} <- Pipeline.common_pipeline(update_data, local: true) do
+      {:ok, update}
+    else
+      _ -> {:error, nil}
+    end
+  end
+
+  defp make_update_data(user, orig_object, changes) do
+    kept_params = %{
+      visibility: Visibility.get_visibility(orig_object),
+      in_reply_to_id:
+        with replied_id when is_binary(replied_id) <- orig_object.data["inReplyTo"],
+             %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(replied_id) do
+          activity_id
+        else
+          _ -> nil
+        end
+    }
+
+    params = Map.merge(changes, kept_params)
+
+    with {:ok, draft} <- ActivityDraft.create(user, params) do
+      change =
+        Object.Updater.make_update_object_data(orig_object.data, draft.object, Utils.make_date())
+
+      {:ok, change}
+    else
+      _ -> {:error, nil}
+    end
+  end
+
   @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),
index 767b2bf0feaeb5d75b0bb972122315ce898052cd..b3a49de441d8b13957ba43263fed476f5c564f67 100644 (file)
@@ -221,7 +221,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
       |> Map.put("emoji", emoji)
       |> Map.put("source", %{
         "content" => draft.status,
-        "mediaType" => draft.params[:content_type]
+        "mediaType" => Utils.get_content_type(draft.params[:content_type])
       })
       |> Map.put("generator", draft.params[:generator])
 
index 15016eb47dea78cc5eb9bcbfd3a50ab4d95b2bf1..bf03b0a8253fec9cf6b47b27df7a9dddd316ab4a 100644 (file)
@@ -37,7 +37,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
 
   def attachments_from_ids_no_descs(ids) do
     Enum.map(ids, fn media_id ->
-      case Repo.get(Object, media_id) do
+      case get_attachment(media_id) do
         %Object{data: data} -> data
         _ -> nil
       end
@@ -51,13 +51,17 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     {_, descs} = Jason.decode(descs_str)
 
     Enum.map(ids, fn media_id ->
-      with %Object{data: data} <- Repo.get(Object, media_id) do
+      with %Object{data: data} <- get_attachment(media_id) do
         Map.put(data, "name", descs[media_id])
       end
     end)
     |> Enum.reject(&is_nil/1)
   end
 
+  defp get_attachment(media_id) do
+    Repo.get(Object, media_id)
+  end
+
   @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
 
   def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
@@ -219,7 +223,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     |> maybe_add_attachments(draft.attachments, attachment_links)
   end
 
-  defp get_content_type(content_type) do
+  def get_content_type(content_type) do
     if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
       content_type
     else
index ae4432e85c3001b26ec901a2e31ffc4553c0fb91..8e6cf2a6aef43620ef852625fabc423383aa5427 100644 (file)
@@ -51,6 +51,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
     move
     pleroma:emoji_reaction
     poll
+    update
   }
   def index(%{assigns: %{user: user}} = conn, params) do
     params =
index 41fbd7acf2f910e8a8ec47a01e7bec9af70e7a37..31f3b3a8de095d7892cbc23505f3b960f2e9835e 100644 (file)
@@ -40,7 +40,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
            :index,
            :show,
            :context,
-           :translate
+           :translate,
+           :show_history,
+           :show_source
          ]
   )
 
@@ -51,7 +53,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
            :create,
            :delete,
            :reblog,
-           :unreblog
+           :unreblog,
+           :update
          ]
   )
 
@@ -193,6 +196,59 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
     create(%Plug.Conn{conn | body_params: params}, %{})
   end
 
+  @doc "GET /api/v1/statuses/:id/history"
+  def show_history(%{assigns: assigns} = conn, %{id: id} = params) do
+    with user = assigns[:user],
+         %Activity{} = activity <- Activity.get_by_id_with_object(id),
+         true <- Visibility.visible_for_user?(activity, user) do
+      try_render(conn, "history.json",
+        activity: activity,
+        for: user,
+        with_direct_conversation_id: true,
+        with_muted: Map.get(params, :with_muted, false)
+      )
+    else
+      _ -> {:error, :not_found}
+    end
+  end
+
+  @doc "GET /api/v1/statuses/:id/source"
+  def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do
+    with user = assigns[:user],
+         %Activity{} = activity <- Activity.get_by_id_with_object(id),
+         true <- Visibility.visible_for_user?(activity, user) do
+      try_render(conn, "source.json",
+        activity: activity,
+        for: user
+      )
+    else
+      _ -> {:error, :not_found}
+    end
+  end
+
+  @doc "PUT /api/v1/statuses/:id"
+  def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} = params) do
+    with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)},
+         {_, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+         {_, true} <- {:is_create, activity.data["type"] == "Create"},
+         actor <- Activity.user_actor(activity),
+         {_, true} <- {:own_status, actor.id == user.id},
+         changes <- body_params |> put_application(conn),
+         {_, {:ok, _update_activity}} <- {:pipeline, CommonAPI.update(user, activity, changes)},
+         {_, %Activity{}} = {_, activity} <- {:refetched, Activity.get_by_id_with_object(id)} do
+      try_render(conn, "show.json",
+        activity: activity,
+        for: user,
+        with_direct_conversation_id: true,
+        with_muted: Map.get(params, :with_muted, false)
+      )
+    else
+      {:own_status, _} -> {:error, :forbidden}
+      {:pipeline, _} -> {:error, :internal_server_error}
+      _ -> {:error, :not_found}
+    end
+  end
+
   @doc "GET /api/v1/statuses/:id"
   def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do
     with %Activity{} = activity <- Activity.get_by_id_with_object(id),
index 43651943906380be17dc8335006c6e5558f623a1..6612a7ec1fe06f551a6f151a31d6f6d535c017e3 100644 (file)
@@ -65,6 +65,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       "shareable_emoji_packs",
       "multifetch",
       "pleroma:api/v1/notifications:include_types_filter",
+      "editing",
       if Config.get([:media_proxy, :enabled]) do
         "media_proxy"
       end,
index 83914a2755ef3156de2de8858fadb4b3c0ff94eb..463d31d1ab7863c9648e124a29b89aea2536aaba 100644 (file)
@@ -17,7 +17,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
   alias Pleroma.Web.MastodonAPI.NotificationView
   alias Pleroma.Web.MastodonAPI.StatusView
 
-  @parent_types ~w{Like Announce EmojiReact}
+  defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
+
+  defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
+
+  @parent_types ~w{Like Announce EmojiReact Update}
 
   def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
     activities = Enum.map(notifications, & &1.activity)
@@ -28,7 +32,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
         %{data: %{"type" => type}} ->
           type in @parent_types
       end)
-      |> Enum.map(& &1.data["object"])
+      |> Enum.map(&object_id_for/1)
       |> Activity.create_by_object_ap_id()
       |> Activity.with_preloaded_object(:left)
       |> Pleroma.Repo.all()
@@ -76,9 +80,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
 
     parent_activity_fn = fn ->
       if opts[:parent_activities] do
-        Activity.Queries.find_by_object_ap_id(opts[:parent_activities], activity.data["object"])
+        Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity))
       else
-        Activity.get_create_by_object_ap_id(activity.data["object"])
+        Activity.get_create_by_object_ap_id(object_id_for(activity))
       end
     end
 
@@ -107,6 +111,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
       "reblog" ->
         put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
 
+      "update" ->
+        put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
+
       "move" ->
         put_target(response, activity, reading_user, %{})
 
index 0d2571ab8eeb8dc2db98c7b754acac06e11ca8d8..b3a35526e5dae6d6a90e8ca017dcd6871e524b14 100644 (file)
@@ -265,10 +265,30 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
 
     created_at = Utils.to_masto_date(object.data["published"])
 
+    edited_at =
+      with %{"updated" => updated} <- object.data,
+           date <- Utils.to_masto_date(updated),
+           true <- date != "" do
+        date
+      else
+        _ ->
+          nil
+      end
+
     reply_to = get_reply_to(activity, opts)
 
     reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
 
+    history_len =
+      1 +
+        (Object.Updater.history_for(object.data)
+         |> Map.get("orderedItems")
+         |> length())
+
+    # See render("history.json", ...) for more details
+    # Here the implicit index of the current content is 0
+    chrono_order = history_len - 1
+
     content =
       object
       |> render_content()
@@ -278,14 +298,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       |> Activity.HTML.get_cached_scrubbed_html_for_activity(
         User.html_filter_policy(opts[:for]),
         activity,
-        "mastoapi:content"
+        "mastoapi:content:#{chrono_order}"
       )
 
     content_plaintext =
       content
       |> Activity.HTML.get_cached_stripped_html_for_activity(
         activity,
-        "mastoapi:content"
+        "mastoapi:content:#{chrono_order}"
       )
 
     summary = object.data["summary"] || ""
@@ -353,8 +373,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       reblog: nil,
       card: card,
       content: content_html,
-      text: opts[:with_source] && object.data["source"],
+      text: opts[:with_source] && get_source_text(object.data["source"]),
       created_at: created_at,
+      edited_at: edited_at,
       reblogs_count: announcement_count,
       replies_count: object.data["repliesCount"] || 0,
       favourites_count: like_count,
@@ -400,6 +421,100 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
     nil
   end
 
+  def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
+    object = Object.normalize(activity, fetch: false)
+
+    hashtags = Object.hashtags(object)
+
+    user = CommonAPI.get_user(activity.data["actor"])
+
+    past_history =
+      Object.Updater.history_for(object.data)
+      |> Map.get("orderedItems")
+      |> Enum.map(&Map.put(&1, "id", object.data["id"]))
+      |> Enum.map(&%Object{data: &1, id: object.id})
+
+    history =
+      [object | past_history]
+      # Mastodon expects the original to be at the first
+      |> Enum.reverse()
+      |> Enum.with_index()
+      |> Enum.map(fn {object, chrono_order} ->
+        %{
+          # The history is prepended every time there is a new edit.
+          # In chrono_order, the oldest item is always at 0, and so on.
+          # The chrono_order is an invariant kept between edits.
+          chrono_order: chrono_order,
+          object: object
+        }
+      end)
+
+    individual_opts =
+      opts
+      |> Map.put(:as, :item)
+      |> Map.put(:user, user)
+      |> Map.put(:hashtags, hashtags)
+
+    render_many(history, StatusView, "history_item.json", individual_opts)
+  end
+
+  def render(
+        "history_item.json",
+        %{
+          activity: activity,
+          user: user,
+          item: %{object: object, chrono_order: chrono_order},
+          hashtags: hashtags
+        } = opts
+      ) do
+    sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
+
+    attachment_data = object.data["attachment"] || []
+    attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
+
+    created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
+
+    content =
+      object
+      |> render_content()
+
+    content_html =
+      content
+      |> Activity.HTML.get_cached_scrubbed_html_for_activity(
+        User.html_filter_policy(opts[:for]),
+        activity,
+        "mastoapi:content:#{chrono_order}"
+      )
+
+    summary = object.data["summary"] || ""
+
+    %{
+      account:
+        AccountView.render("show.json", %{
+          user: user,
+          for: opts[:for]
+        }),
+      content: content_html,
+      sensitive: sensitive,
+      spoiler_text: summary,
+      created_at: created_at,
+      media_attachments: attachments,
+      emojis: build_emojis(object.data["emoji"]),
+      poll: render(PollView, "show.json", object: object, for: opts[:for])
+    }
+  end
+
+  def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
+    object = Object.normalize(activity, fetch: false)
+
+    %{
+      id: activity.id,
+      text: get_source_text(Map.get(object.data, "source", "")),
+      spoiler_text: Map.get(object.data, "summary", ""),
+      content_type: get_source_content_type(object.data["source"])
+    }
+  end
+
   def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
     page_url_data = URI.parse(page_url)
 
@@ -452,10 +567,19 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
         true -> "unknown"
       end
 
-    <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
+    attachment_id =
+      with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
+           {_, %Object{data: _object_data, id: object_id}} <-
+             {:object, Object.get_by_ap_id(ap_id)} do
+        to_string(object_id)
+      else
+        _ ->
+          <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
+          to_string(attachment["id"] || hash_id)
+      end
 
     %{
-      id: to_string(attachment["id"] || hash_id),
+      id: attachment_id,
       url: href,
       remote_url: href,
       preview_url: href_preview,
@@ -638,4 +762,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       _ -> nil
     end
   end
+
+  defp get_source_text(%{"content" => content} = _source) do
+    content
+  end
+
+  defp get_source_text(source) when is_binary(source) do
+    source
+  end
+
+  defp get_source_text(_) do
+    ""
+  end
+
+  defp get_source_content_type(%{"mediaType" => type} = _source) do
+    type
+  end
+
+  defp get_source_content_type(_source) do
+    Utils.get_content_type(nil)
+  end
 end
index 175b1c4c07fc6d5305082554730c79c77754ac63..cc63b2b049d3c31f9162804340fdd95bd3c46c18 100644 (file)
@@ -547,6 +547,7 @@ defmodule Pleroma.Web.Router do
     get("/bookmarks", StatusController, :bookmarks)
 
     post("/statuses", StatusController, :create)
+    put("/statuses/:id", StatusController, :update)
     delete("/statuses/:id", StatusController, :delete)
     post("/statuses/:id/reblog", StatusController, :reblog)
     post("/statuses/:id/unreblog", StatusController, :unreblog)
@@ -612,6 +613,8 @@ defmodule Pleroma.Web.Router do
     get("/statuses/:id/context", StatusController, :context)
     get("/statuses/:id/favourited_by", StatusController, :favourited_by)
     get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
+    get("/statuses/:id/history", StatusController, :show_history)
+    get("/statuses/:id/source", StatusController, :show_source)
 
     get("/custom_emojis", CustomEmojiController, :index)
 
index fba5d1c0232ef29ab5d6ef632e3a966694c024b7..c03e7fc30a569844cf7032ce4a93e0e8bc7a4903 100644 (file)
@@ -287,6 +287,27 @@ defmodule Pleroma.Web.Streamer do
 
   defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
 
+  defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do
+    create_activity =
+      Pleroma.Activity.get_create_by_object_ap_id(item.object.data["id"])
+      |> Map.put(:object, item.object)
+
+    anon_render = StreamerView.render("status_update.json", create_activity, topic)
+
+    Registry.dispatch(@registry, topic, fn list ->
+      Enum.each(list, fn {pid, auth?} ->
+        if auth? do
+          send(
+            pid,
+            {:render_with_user, StreamerView, "status_update.json", create_activity, topic}
+          )
+        else
+          send(pid, {:text, anon_render})
+        end
+      end)
+    end)
+  end
+
   defp push_to_socket(topic, item) do
     anon_render = StreamerView.render("update.json", item, topic)
 
index f455f941e9b746bfb26a7a2b056f6e08a3904088..eba3d96ec19f59cfc43973d387858d57cf69368a 100644 (file)
@@ -26,6 +26,23 @@ defmodule Pleroma.Web.StreamerView do
     |> Jason.encode!()
   end
 
+  def render("status_update.json", %Activity{} = activity, %User{} = user, topic) do
+    activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+    %{
+      stream: [topic],
+      event: "status.update",
+      payload:
+        Pleroma.Web.MastodonAPI.StatusView.render(
+          "show.json",
+          activity: activity,
+          for: user
+        )
+        |> Jason.encode!()
+    }
+    |> Jason.encode!()
+  end
+
   def render("notification.json", %Notification{} = notify, %User{} = user, topic) do
     %{
       stream: [topic],
@@ -54,6 +71,22 @@ defmodule Pleroma.Web.StreamerView do
     |> Jason.encode!()
   end
 
+  def render("status_update.json", %Activity{} = activity, topic) do
+    activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+    %{
+      stream: [topic],
+      event: "status.update",
+      payload:
+        Pleroma.Web.MastodonAPI.StatusView.render(
+          "show.json",
+          activity: activity
+        )
+        |> Jason.encode!()
+    }
+    |> Jason.encode!()
+  end
+
   def render("follow_relationships_update.json", item, topic) do
     %{
       stream: [topic],
diff --git a/priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs b/priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs
new file mode 100644 (file)
index 0000000..0656c88
--- /dev/null
@@ -0,0 +1,51 @@
+defmodule Pleroma.Repo.Migrations.AddUpdateToNotificationsEnum do
+  use Ecto.Migration
+
+  @disable_ddl_transaction true
+
+  def up do
+    """
+    alter type notification_type add value 'update'
+    """
+    |> execute()
+  end
+
+  # 20210717000000_add_poll_to_notifications_enum.exs
+  def down do
+    alter table(:notifications) do
+      modify(:type, :string)
+    end
+
+    """
+    delete from notifications where type = 'update'
+    """
+    |> execute()
+
+    """
+    drop type if exists notification_type
+    """
+    |> execute()
+
+    """
+    create type notification_type as enum (
+      'follow',
+      'follow_request',
+      'mention',
+      'move',
+      'pleroma:emoji_reaction',
+      'pleroma:chat_mention',
+      'reblog',
+      'favourite',
+      'pleroma:report',
+      'poll'
+    )
+    """
+    |> execute()
+
+    """
+    alter table notifications
+    alter column type type notification_type using (type::notification_type)
+    """
+    |> execute()
+  end
+end
index d2b62ba775967532a781a749e8fbfb385a7ebb8a..f582ed42c263ea651351f2de895dae330cadc0e4 100644 (file)
@@ -39,7 +39,9 @@
             "alsoKnownAs": {
                 "@id": "as:alsoKnownAs",
                 "@type": "@id"
-            }
+            },
+            "vcard": "http://www.w3.org/2006/vcard/ns#",
+            "formerRepresentations": "litepub:formerRepresentations"
         }
     ]
 }
index 8db2088789475e89fe7e2f2de7fd139bd5e9c50b..68330465b9eb007a1024063e28f8e2f65b3e3993 100644 (file)
@@ -127,6 +127,28 @@ defmodule Pleroma.NotificationTest do
       subscriber_notifications = Notification.for_user(subscriber)
       assert Enum.empty?(subscriber_notifications)
     end
+
+    test "it sends edited notifications to those who repeated a status" do
+      user = insert(:user)
+      repeated_user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, activity_one} =
+        CommonAPI.post(user, %{
+          status: "hey @#{other_user.nickname}!"
+        })
+
+      {:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
+
+      {:ok, _edit_activity} =
+        CommonAPI.update(user, activity_one, %{
+          status: "hey @#{other_user.nickname}! mew mew"
+        })
+
+      assert [%{type: "reblog"}] = Notification.for_user(user)
+      assert [%{type: "update"}] = Notification.for_user(repeated_user)
+      assert [%{type: "mention"}] = Notification.for_user(other_user)
+    end
   end
 
   test "create_poll_notifications/1" do
@@ -838,6 +860,30 @@ defmodule Pleroma.NotificationTest do
       assert [other_user] == enabled_receivers
       assert [] == disabled_receivers
     end
+
+    test "it sends edited notifications to those who repeated a status" do
+      user = insert(:user)
+      repeated_user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, activity_one} =
+        CommonAPI.post(user, %{
+          status: "hey @#{other_user.nickname}!"
+        })
+
+      {:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
+
+      {:ok, edit_activity} =
+        CommonAPI.update(user, activity_one, %{
+          status: "hey @#{other_user.nickname}! mew mew"
+        })
+
+      {enabled_receivers, _disabled_receivers} =
+        Notification.get_notified_from_activity(edit_activity)
+
+      assert repeated_user in enabled_receivers
+      assert other_user not in enabled_receivers
+    end
   end
 
   describe "notification lifecycle" do
index bd0a6e497617bad3933654326b0017116d3edac7..c321032adb7f896fc794e16749ecae797959bdbc 100644 (file)
@@ -269,4 +269,271 @@ defmodule Pleroma.Object.FetcherTest do
       refute called(Pleroma.Signature.sign(:_, :_))
     end
   end
+
+  describe "refetching" do
+    setup do
+      object1 = %{
+        "id" => "https://mastodon.social/1",
+        "actor" => "https://mastodon.social/users/emelie",
+        "attributedTo" => "https://mastodon.social/users/emelie",
+        "type" => "Note",
+        "content" => "test 1",
+        "bcc" => [],
+        "bto" => [],
+        "cc" => [],
+        "to" => [],
+        "summary" => ""
+      }
+
+      object2 = %{
+        "id" => "https://mastodon.social/2",
+        "actor" => "https://mastodon.social/users/emelie",
+        "attributedTo" => "https://mastodon.social/users/emelie",
+        "type" => "Note",
+        "content" => "test 2",
+        "bcc" => [],
+        "bto" => [],
+        "cc" => [],
+        "to" => [],
+        "summary" => "",
+        "formerRepresentations" => %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            %{
+              "type" => "Note",
+              "content" => "orig 2",
+              "actor" => "https://mastodon.social/users/emelie",
+              "attributedTo" => "https://mastodon.social/users/emelie",
+              "bcc" => [],
+              "bto" => [],
+              "cc" => [],
+              "to" => [],
+              "summary" => ""
+            }
+          ],
+          "totalItems" => 1
+        }
+      }
+
+      mock(fn
+        %{
+          method: :get,
+          url: "https://mastodon.social/1"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body: Jason.encode!(object1)
+          }
+
+        %{
+          method: :get,
+          url: "https://mastodon.social/2"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body: Jason.encode!(object2)
+          }
+
+        %{
+          method: :get,
+          url: "https://mastodon.social/users/emelie/collections/featured"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body:
+              Jason.encode!(%{
+                "id" => "https://mastodon.social/users/emelie/collections/featured",
+                "type" => "OrderedCollection",
+                "actor" => "https://mastodon.social/users/emelie",
+                "attributedTo" => "https://mastodon.social/users/emelie",
+                "orderedItems" => [],
+                "totalItems" => 0
+              })
+          }
+
+        env ->
+          apply(HttpRequestMock, :request, [env])
+      end)
+
+      %{object1: object1, object2: object2}
+    end
+
+    test "it keeps formerRepresentations if remote does not have this attr", %{object1: object1} do
+      full_object1 =
+        object1
+        |> Map.merge(%{
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "type" => "Note",
+                "content" => "orig 2",
+                "actor" => "https://mastodon.social/users/emelie",
+                "attributedTo" => "https://mastodon.social/users/emelie",
+                "bcc" => [],
+                "bto" => [],
+                "cc" => [],
+                "to" => [],
+                "summary" => ""
+              }
+            ],
+            "totalItems" => 1
+          }
+        })
+
+      {:ok, o} = Object.create(full_object1)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
+               refetched.data
+    end
+
+    test "it uses formerRepresentations from remote if possible", %{object2: object2} do
+      {:ok, o} = Object.create(object2)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
+               refetched.data
+    end
+
+    test "it replaces formerRepresentations with the one from remote", %{object2: object2} do
+      full_object2 =
+        object2
+        |> Map.merge(%{
+          "content" => "mew mew #def",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"type" => "Note", "content" => "mew mew 2"}
+            ],
+            "totalItems" => 1
+          }
+        })
+
+      {:ok, o} = Object.create(full_object2)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{
+               "content" => "test 2",
+               "formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}
+             } = refetched.data
+    end
+
+    test "it adds to formerRepresentations if the remote does not have one and the object has changed",
+         %{object1: object1} do
+      full_object1 =
+        object1
+        |> Map.merge(%{
+          "content" => "mew mew #def",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"type" => "Note", "content" => "mew mew 1"}
+            ],
+            "totalItems" => 1
+          }
+        })
+
+      {:ok, o} = Object.create(full_object1)
+
+      assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+      assert %{
+               "content" => "test 1",
+               "formerRepresentations" => %{
+                 "orderedItems" => [
+                   %{"content" => "mew mew #def"},
+                   %{"content" => "mew mew 1"}
+                 ],
+                 "totalItems" => 2
+               }
+             } = refetched.data
+    end
+  end
+
+  describe "fetch with history" do
+    setup do
+      object2 = %{
+        "id" => "https://mastodon.social/2",
+        "actor" => "https://mastodon.social/users/emelie",
+        "attributedTo" => "https://mastodon.social/users/emelie",
+        "type" => "Note",
+        "content" => "test 2",
+        "bcc" => [],
+        "bto" => [],
+        "cc" => ["https://mastodon.social/users/emelie/followers"],
+        "to" => [],
+        "summary" => "",
+        "formerRepresentations" => %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            %{
+              "type" => "Note",
+              "content" => "orig 2",
+              "actor" => "https://mastodon.social/users/emelie",
+              "attributedTo" => "https://mastodon.social/users/emelie",
+              "bcc" => [],
+              "bto" => [],
+              "cc" => ["https://mastodon.social/users/emelie/followers"],
+              "to" => [],
+              "summary" => ""
+            }
+          ],
+          "totalItems" => 1
+        }
+      }
+
+      mock(fn
+        %{
+          method: :get,
+          url: "https://mastodon.social/2"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body: Jason.encode!(object2)
+          }
+
+        %{
+          method: :get,
+          url: "https://mastodon.social/users/emelie/collections/featured"
+        } ->
+          %Tesla.Env{
+            status: 200,
+            headers: [{"content-type", "application/activity+json"}],
+            body:
+              Jason.encode!(%{
+                "id" => "https://mastodon.social/users/emelie/collections/featured",
+                "type" => "OrderedCollection",
+                "actor" => "https://mastodon.social/users/emelie",
+                "attributedTo" => "https://mastodon.social/users/emelie",
+                "orderedItems" => [],
+                "totalItems" => 0
+              })
+          }
+
+        env ->
+          apply(HttpRequestMock, :request, [env])
+      end)
+
+      %{object2: object2}
+    end
+
+    test "it gets history", %{object2: object2} do
+      {:ok, object} = Fetcher.fetch_object_from_id(object2["id"])
+
+      assert %{
+               "formerRepresentations" => %{
+                 "type" => "OrderedCollection",
+                 "orderedItems" => [%{}]
+               }
+             } = object.data
+    end
+  end
 end
diff --git a/test/pleroma/object/updater_test.exs b/test/pleroma/object/updater_test.exs
new file mode 100644 (file)
index 0000000..7e9b448
--- /dev/null
@@ -0,0 +1,76 @@
+# Pleroma: A lightweight social networking server
+# Copyright Â© 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Object.UpdaterTest do
+  use Pleroma.DataCase
+  use Oban.Testing, repo: Pleroma.Repo
+
+  import Pleroma.Factory
+
+  alias Pleroma.Object.Updater
+
+  describe "make_update_object_data/3" do
+    setup do
+      note = insert(:note)
+      %{original_data: note.data}
+    end
+
+    test "it makes an updated field", %{original_data: original_data} do
+      new_data = Map.put(original_data, "content", "new content")
+
+      date = Pleroma.Web.ActivityPub.Utils.make_date()
+      update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+      assert %{"updated" => ^date} = update_object_data
+    end
+
+    test "it creates formerRepresentations", %{original_data: original_data} do
+      new_data = Map.put(original_data, "content", "new content")
+
+      date = Pleroma.Web.ActivityPub.Utils.make_date()
+      update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+
+      history_item = original_data |> Map.drop(["id", "formerRepresentations"])
+
+      assert %{
+               "formerRepresentations" => %{
+                 "totalItems" => 1,
+                 "orderedItems" => [^history_item]
+               }
+             } = update_object_data
+    end
+  end
+
+  describe "make_new_object_data_from_update_object/2" do
+    test "it reuses formerRepresentations if it exists" do
+      %{data: original_data} = insert(:note)
+
+      new_data =
+        original_data
+        |> Map.put("content", "edited")
+
+      date = Pleroma.Web.ActivityPub.Utils.make_date()
+      update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+
+      history = update_object_data["formerRepresentations"]["orderedItems"]
+
+      update_object_data =
+        update_object_data
+        |> put_in(
+          ["formerRepresentations", "orderedItems"],
+          history ++ [Map.put(original_data, "summary", "additional summary")]
+        )
+        |> put_in(["formerRepresentations", "totalItems"], length(history) + 1)
+
+      %{
+        updated_data: updated_data,
+        updated: updated,
+        used_history_in_new_object?: used_history_in_new_object?
+      } = Updater.make_new_object_data_from_update_object(original_data, update_object_data)
+
+      assert updated
+      assert used_history_in_new_object?
+      assert updated_data["formerRepresentations"] == update_object_data["formerRepresentations"]
+    end
+  end
+end
index f1ab82a57441b00ad37ec8e8ffcb23504ece8719..8f242630ffbaecfb2d12afaea429cb7048b0a27f 100644 (file)
@@ -49,20 +49,22 @@ defmodule Pleroma.UploadTest do
     test "it returns file" do
       File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
 
-      assert Upload.store(@upload_file) ==
-               {:ok,
-                %{
-                  "name" => "image.jpg",
-                  "type" => "Document",
-                  "mediaType" => "image/jpeg",
-                  "url" => [
-                    %{
-                      "href" => "http://localhost:4001/media/post-process-file.jpg",
-                      "mediaType" => "image/jpeg",
-                      "type" => "Link"
-                    }
-                  ]
-                }}
+      assert {:ok, result} = Upload.store(@upload_file)
+
+      assert result ==
+               %{
+                 "id" => result["id"],
+                 "name" => "image.jpg",
+                 "type" => "Document",
+                 "mediaType" => "image/jpeg",
+                 "url" => [
+                   %{
+                     "href" => "http://localhost:4001/media/post-process-file.jpg",
+                     "mediaType" => "image/jpeg",
+                     "type" => "Link"
+                   }
+                 ]
+               }
 
       Task.await(Agent.get(TestUploaderSuccess, fn task_pid -> task_pid end))
     end
index 5b990451c238ab673cfab5c9e6b1abb4dfac3c46..c3ee03a054f7974e290077c81e9fe27b7476aff1 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
   import Pleroma.Factory
   import ExUnit.CaptureLog
 
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy
 
   @linkless_message %{
@@ -49,15 +50,39 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
 
       assert user.note_count == 0
 
+      message = %{
+        "type" => "Create",
+        "actor" => user.ap_id,
+        "object" => %{
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "content" => "<a href='https://example.com'>hi world!</a>"
+              }
+            ]
+          },
+          "content" => "mew"
+        }
+      }
+
+      {:reject, _} = MRF.filter_one(AntiLinkSpamPolicy, message)
+    end
+
+    test "it allows posts with links for local users" do
+      user = insert(:user)
+
+      assert user.note_count == 0
+
       message =
         @linkful_message
         |> Map.put("actor", user.ap_id)
 
-      {:reject, _} = AntiLinkSpamPolicy.filter(message)
+      {:ok, _message} = AntiLinkSpamPolicy.filter(message)
     end
 
-    test "it allows posts with links for local users" do
-      user = insert(:user)
+    test "it disallows posts with links in history" do
+      user = insert(:user, local: false)
 
       assert user.note_count == 0
 
@@ -65,7 +90,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
         @linkful_message
         |> Map.put("actor", user.ap_id)
 
-      {:ok, _message} = AntiLinkSpamPolicy.filter(message)
+      {:reject, _} = AntiLinkSpamPolicy.filter(message)
     end
   end
 
index 89439b65fe8a88e102f0aa670f0814d16269b34e..e174a83f7e7d441bd0b0b4dd1fe2278af74a747c 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do
 
   alias Pleroma.Activity
   alias Pleroma.Object
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.EnsureRePrepended
 
   describe "rewrites summary" do
@@ -35,10 +36,58 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do
       assert {:ok, res} = EnsureRePrepended.filter(message)
       assert res["object"]["summary"] == "re: object-summary"
     end
+
+    test "it adds `re:` to history" do
+      message = %{
+        "type" => "Create",
+        "object" => %{
+          "summary" => "object-summary",
+          "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
+          "formerRepresentations" => %{
+            "orderedItems" => [
+              %{
+                "summary" => "object-summary",
+                "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
+      assert res["object"]["summary"] == "re: object-summary"
+
+      assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
+               "re: object-summary"
+    end
+
+    test "it accepts Updates" do
+      message = %{
+        "type" => "Update",
+        "object" => %{
+          "summary" => "object-summary",
+          "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
+          "formerRepresentations" => %{
+            "orderedItems" => [
+              %{
+                "summary" => "object-summary",
+                "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
+      assert res["object"]["summary"] == "re: object-summary"
+
+      assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
+               "re: object-summary"
+    end
   end
 
   describe "skip filter" do
-    test "it skip if type isn't 'Create'" do
+    test "it skip if type isn't 'Create' or 'Update'" do
       message = %{
         "type" => "Annotation",
         "object" => %{"summary" => "object-summary"}
index 13415bb79ef168d13b4e1f3009bb91deb3db07ed..b88090869fe2fa23644ca6a1d8c9e264e3a14efc 100644 (file)
@@ -20,6 +20,76 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicyTest do
     assert modified["object"]["sensitive"]
   end
 
+  test "it is history-aware" do
+    activity = %{
+      "type" => "Create",
+      "object" => %{
+        "content" => "hey",
+        "tag" => []
+      }
+    }
+
+    activity_data =
+      activity
+      |> put_in(
+        ["object", "formerRepresentations"],
+        %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            Map.put(
+              activity["object"],
+              "tag",
+              [%{"type" => "Hashtag", "name" => "#nsfw"}]
+            )
+          ]
+        }
+      )
+
+    {:ok, modified} =
+      Pleroma.Web.ActivityPub.MRF.filter_one(
+        Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+        activity_data
+      )
+
+    refute modified["object"]["sensitive"]
+    assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
+  end
+
+  test "it works with Update" do
+    activity = %{
+      "type" => "Update",
+      "object" => %{
+        "content" => "hey",
+        "tag" => []
+      }
+    }
+
+    activity_data =
+      activity
+      |> put_in(
+        ["object", "formerRepresentations"],
+        %{
+          "type" => "OrderedCollection",
+          "orderedItems" => [
+            Map.put(
+              activity["object"],
+              "tag",
+              [%{"type" => "Hashtag", "name" => "#nsfw"}]
+            )
+          ]
+        }
+      )
+
+    {:ok, modified} =
+      Pleroma.Web.ActivityPub.MRF.filter_one(
+        Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+        activity_data
+      )
+
+    refute modified["object"]["sensitive"]
+    assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
+  end
+
   test "it doesn't sets the sensitive property with irrelevant hashtags" do
     user = insert(:user)
 
index 8af4c5efad8929bf65fdb26e815465d3a64f50fe..9bc8c835503bbc9e2ec9f4ae25f1b6fc3c5911ca 100644 (file)
@@ -79,6 +79,54 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
                    KeywordPolicy.filter(message)
                end)
     end
+
+    test "rejects if string matches in history" do
+      clear_config([:mrf_keyword, :reject], ["pun"])
+
+      message = %{
+        "type" => "Create",
+        "object" => %{
+          "content" => "just a daily reminder that compLAINer is a good",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "content" => "just a daily reminder that compLAINer is a good pun",
+                "summary" => ""
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
+               KeywordPolicy.filter(message)
+    end
+
+    test "rejects Updates" do
+      clear_config([:mrf_keyword, :reject], ["pun"])
+
+      message = %{
+        "type" => "Update",
+        "object" => %{
+          "content" => "just a daily reminder that compLAINer is a good",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{
+                "content" => "just a daily reminder that compLAINer is a good pun",
+                "summary" => ""
+              }
+            ]
+          }
+        }
+      }
+
+      assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
+               KeywordPolicy.filter(message)
+    end
   end
 
   describe "delisting from ftl based on keywords" do
@@ -157,6 +205,31 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
                    not (["https://www.w3.org/ns/activitystreams#Public"] == result["to"])
                end)
     end
+
+    test "delists if string matches in history" do
+      clear_config([:mrf_keyword, :federated_timeline_removal], ["pun"])
+
+      message = %{
+        "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+        "type" => "Create",
+        "object" => %{
+          "content" => "just a daily reminder that compLAINer is a good",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "orderedItems" => [
+              %{
+                "content" => "just a daily reminder that compLAINer is a good pun",
+                "summary" => ""
+              }
+            ]
+          }
+        }
+      }
+
+      {:ok, result} = KeywordPolicy.filter(message)
+      assert ["https://www.w3.org/ns/activitystreams#Public"] == result["cc"]
+      refute ["https://www.w3.org/ns/activitystreams#Public"] == result["to"]
+    end
   end
 
   describe "replacing keywords" do
@@ -221,5 +294,63 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do
                  result == "ZFS is free software"
                end)
     end
+
+    test "replaces keyword if string matches in history" do
+      clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
+
+      message = %{
+        "type" => "Create",
+        "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+        "object" => %{
+          "content" => "ZFS is opensource",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"content" => "ZFS is opensource mew mew", "summary" => ""}
+            ]
+          }
+        }
+      }
+
+      {:ok,
+       %{
+         "object" => %{
+           "content" => "ZFS is free software",
+           "formerRepresentations" => %{
+             "orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
+           }
+         }
+       }} = KeywordPolicy.filter(message)
+    end
+
+    test "replaces keyword in Updates" do
+      clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
+
+      message = %{
+        "type" => "Update",
+        "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+        "object" => %{
+          "content" => "ZFS is opensource",
+          "summary" => "",
+          "formerRepresentations" => %{
+            "type" => "OrderedCollection",
+            "orderedItems" => [
+              %{"content" => "ZFS is opensource mew mew", "summary" => ""}
+            ]
+          }
+        }
+      }
+
+      {:ok,
+       %{
+         "object" => %{
+           "content" => "ZFS is free software",
+           "formerRepresentations" => %{
+             "orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
+           }
+         }
+       }} = KeywordPolicy.filter(message)
+    end
   end
 end
index 96e715d0d859275c163339ea36f459fa79582bb0..3268e23211de3c8f7295babb65a2593ba3cb1f5e 100644 (file)
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
   use Pleroma.Tests.Helpers
 
   alias Pleroma.HTTP
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy
 
   import Mock
@@ -22,6 +23,25 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
     }
   }
 
+  @message_with_history %{
+    "type" => "Create",
+    "object" => %{
+      "type" => "Note",
+      "content" => "content",
+      "formerRepresentations" => %{
+        "orderedItems" => [
+          %{
+            "type" => "Note",
+            "content" => "content",
+            "attachment" => [
+              %{"url" => [%{"href" => "http://example.com/image.jpg"}]}
+            ]
+          }
+        ]
+      }
+    }
+  }
+
   setup do: clear_config([:media_proxy, :enabled], true)
 
   test "it prefetches media proxy URIs" do
@@ -50,4 +70,28 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
       refute called(HTTP.get(:_, :_, :_))
     end
   end
+
+  test "history-aware" do
+    Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
+      {:ok, %Tesla.Env{status: 200, body: ""}}
+    end)
+
+    with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
+      MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history)
+
+      assert called(HTTP.get(:_, :_, :_))
+    end
+  end
+
+  test "works with Updates" do
+    Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
+      {:ok, %Tesla.Env{status: 200, body: ""}}
+    end)
+
+    with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
+      MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update"))
+
+      assert called(HTTP.get(:_, :_, :_))
+    end
+  end
 end
index 2c6fcbc416e6b3c49a6f0c3f884c0b7cbc37c92d..d9e05d3131f3f8dede09d27f4cd1c86ed13f69c0 100644 (file)
@@ -153,4 +153,27 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicyTest do
 
     assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
   end
+
+  test "works with Update" do
+    message = %{
+      "actor" => "http://localhost:4001/users/testuser",
+      "cc" => ["http://localhost:4001/users/testuser/followers"],
+      "object" => %{
+        "actor" => "http://localhost:4001/users/testuser",
+        "attachment" => [],
+        "cc" => ["http://localhost:4001/users/testuser/followers"],
+        "source" => "",
+        "to" => [
+          "https://www.w3.org/ns/activitystreams#Public"
+        ],
+        "type" => "Note"
+      },
+      "to" => [
+        "https://www.w3.org/ns/activitystreams#Public"
+      ],
+      "type" => "Update"
+    }
+
+    assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
+  end
 end
index 81a6e0f507a33046ad127079459991f26b914f43..59456d79064ba99ddfb6fcf83314a3674b937241 100644 (file)
@@ -4,6 +4,7 @@
 
 defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
   use Pleroma.DataCase, async: true
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy
 
   test "it clears content object" do
@@ -20,6 +21,46 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
     assert res["object"]["content"] == ""
   end
 
+  test "history-aware" do
+    message = %{
+      "type" => "Create",
+      "object" => %{
+        "content" => ".",
+        "attachment" => "image",
+        "formerRepresentations" => %{
+          "orderedItems" => [%{"content" => ".", "attachment" => "image"}]
+        }
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
+
+    assert %{
+             "content" => "",
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
+           } = res["object"]
+  end
+
+  test "works with Updates" do
+    message = %{
+      "type" => "Update",
+      "object" => %{
+        "content" => ".",
+        "attachment" => "image",
+        "formerRepresentations" => %{
+          "orderedItems" => [%{"content" => ".", "attachment" => "image"}]
+        }
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
+
+    assert %{
+             "content" => "",
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
+           } = res["object"]
+  end
+
   @messages [
     %{
       "type" => "Create",
index edc330b6cb1c297ee79a1f458ad659002f5257df..52a23fdca4723d50a674edbf132046cf2d317014 100644 (file)
@@ -4,6 +4,7 @@
 
 defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
   use Pleroma.DataCase, async: true
+  alias Pleroma.Web.ActivityPub.MRF
   alias Pleroma.Web.ActivityPub.MRF.NormalizeMarkup
 
   @html_sample """
@@ -16,24 +17,58 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
   <script>alert('hacked')</script>
   """
 
-  test "it filter html tags" do
-    expected = """
-    <b>this is in bold</b>
-    <p>this is a paragraph</p>
-    this is a linebreak<br/>
-    this is a link with allowed &quot;rel&quot; attribute: <a href="http://example.com/" rel="tag">example.com</a>
-    this is a link with not allowed &quot;rel&quot; attribute: <a href="http://example.com/">example.com</a>
-    this is an image: <img src="http://example.com/image.jpg"/><br/>
-    alert(&#39;hacked&#39;)
-    """
+  @expected """
+  <b>this is in bold</b>
+  <p>this is a paragraph</p>
+  this is a linebreak<br/>
+  this is a link with allowed &quot;rel&quot; attribute: <a href="http://example.com/" rel="tag">example.com</a>
+  this is a link with not allowed &quot;rel&quot; attribute: <a href="http://example.com/">example.com</a>
+  this is an image: <img src="http://example.com/image.jpg"/><br/>
+  alert(&#39;hacked&#39;)
+  """
 
+  test "it filter html tags" do
     message = %{"type" => "Create", "object" => %{"content" => @html_sample}}
 
     assert {:ok, res} = NormalizeMarkup.filter(message)
-    assert res["object"]["content"] == expected
+    assert res["object"]["content"] == @expected
+  end
+
+  test "history-aware" do
+    message = %{
+      "type" => "Create",
+      "object" => %{
+        "content" => @html_sample,
+        "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
+
+    assert %{
+             "content" => @expected,
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
+           } = res["object"]
+  end
+
+  test "works with Updates" do
+    message = %{
+      "type" => "Update",
+      "object" => %{
+        "content" => @html_sample,
+        "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
+      }
+    }
+
+    assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
+
+    assert %{
+             "content" => @expected,
+             "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
+           } = res["object"]
   end
 
-  test "it skips filter if type isn't `Create`" do
+  test "it skips filter if type isn't `Create` or `Update`" do
     message = %{"type" => "Note", "object" => %{}}
 
     assert {:ok, res} = NormalizeMarkup.filter(message)
index 7c8e5a4e15b3fef94cadaa58c1a2681ba558dbc6..5b95ebc51f61877867ad1b76bd3327a4e85b9897 100644 (file)
@@ -5,6 +5,7 @@
 defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest do
   use Pleroma.DataCase, async: true
 
+  alias Pleroma.Web.ActivityPub.ObjectValidator
   alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator
   alias Pleroma.Web.ActivityPub.Utils
 
@@ -38,6 +39,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
       %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
     end
 
+    test "a note from factory validates" do
+      note = insert(:note)
+      %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note.data)
+    end
+
     test "a note with a remote replies collection should validate", _ do
       insert(:user, %{ap_id: "https://bookwyrm.com/user/TestUser"})
       collection = File.read!("test/fixtures/bookwyrm-replies-collection.json")
@@ -159,4 +165,47 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest
 
     %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
   end
+
+  describe "Note with history" do
+    setup do
+      user = insert(:user)
+      {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
+      {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
+
+      {:ok, %{"object" => external_rep}} =
+        Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
+
+      %{external_rep: external_rep}
+    end
+
+    test "edited note", %{external_rep: external_rep} do
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"tag" => [_]}]}} = external_rep
+
+      {:ok, validate_res, []} = ObjectValidator.validate(external_rep, [])
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
+               validate_res
+    end
+
+    test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
+      external_rep = Map.put(external_rep, "formerRepresentations", %{})
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
+    end
+
+    test "edited note, badly-formed history item", %{external_rep: external_rep} do
+      history_item =
+        Enum.at(external_rep["formerRepresentations"]["orderedItems"], 0)
+        |> Map.put("type", "Foo")
+
+      external_rep =
+        put_in(
+          external_rep,
+          ["formerRepresentations", "orderedItems"],
+          [history_item]
+        )
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
+    end
+  end
 end
index 15e4a82cd0a7d0c9ac061803f978e21b26d3bba4..a74ee2416710ba12191c03f8419bb05a1eb38faa 100644 (file)
@@ -32,7 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do
     test "returns an error if the object can't be updated by the actor", %{
       valid_update: valid_update
     } do
-      other_user = insert(:user)
+      other_user = insert(:user, local: false)
 
       update =
         valid_update
@@ -40,5 +40,129 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do
 
       assert {:error, _cng} = ObjectValidator.validate(update, [])
     end
+
+    test "validates as long as the object is same-origin with the actor", %{
+      valid_update: valid_update
+    } do
+      other_user = insert(:user)
+
+      update =
+        valid_update
+        |> Map.put("actor", other_user.ap_id)
+
+      assert {:ok, _update, []} = ObjectValidator.validate(update, [])
+    end
+
+    test "validates if the object is not of an Actor type" do
+      note = insert(:note)
+      updated_note = note.data |> Map.put("content", "edited content")
+      other_user = insert(:user)
+
+      {:ok, update, _} = Builder.update(other_user, updated_note)
+
+      assert {:ok, _update, _} = ObjectValidator.validate(update, [])
+    end
+  end
+
+  describe "update note" do
+    test "converts object into Pleroma's format" do
+      mastodon_tags = [
+        %{
+          "icon" => %{
+            "mediaType" => "image/png",
+            "type" => "Image",
+            "url" => "https://somewhere.org/emoji/url/1.png"
+          },
+          "id" => "https://somewhere.org/emoji/1",
+          "name" => ":some_emoji:",
+          "type" => "Emoji",
+          "updated" => "2021-04-07T11:00:00Z"
+        }
+      ]
+
+      user = insert(:user)
+      note = insert(:note, user: user)
+
+      updated_note =
+        note.data
+        |> Map.put("content", "edited content")
+        |> Map.put("tag", mastodon_tags)
+
+      {:ok, update, _} = Builder.update(user, updated_note)
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
+
+      assert %{"emoji" => %{"some_emoji" => "https://somewhere.org/emoji/url/1.png"}} =
+               meta[:object_data]
+    end
+
+    test "returns no object_data in meta for a local Update" do
+      user = insert(:user)
+      note = insert(:note, user: user)
+
+      updated_note =
+        note.data
+        |> Map.put("content", "edited content")
+
+      {:ok, update, _} = Builder.update(user, updated_note)
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, local: true)
+      assert is_nil(meta[:object_data])
+    end
+
+    test "returns object_data in meta for a remote Update" do
+      user = insert(:user)
+      note = insert(:note, user: user)
+
+      updated_note =
+        note.data
+        |> Map.put("content", "edited content")
+
+      {:ok, update, _} = Builder.update(user, updated_note)
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, local: false)
+      assert meta[:object_data]
+
+      assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
+      assert meta[:object_data]
+    end
+  end
+
+  describe "update with history" do
+    setup do
+      user = insert(:user)
+      {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
+      {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
+      {:ok, external_rep} = Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
+      %{external_rep: external_rep}
+    end
+
+    test "edited note", %{external_rep: external_rep} do
+      {:ok, _validate_res, meta} = ObjectValidator.validate(external_rep, [])
+
+      assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
+               meta[:object_data]
+    end
+
+    test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
+      external_rep = put_in(external_rep, ["object", "formerRepresentations"], %{})
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
+    end
+
+    test "edited note, badly-formed history item", %{external_rep: external_rep} do
+      history_item =
+        Enum.at(external_rep["object"]["formerRepresentations"]["orderedItems"], 0)
+        |> Map.put("type", "Foo")
+
+      external_rep =
+        put_in(
+          external_rep,
+          ["object", "formerRepresentations", "orderedItems"],
+          [history_item]
+        )
+
+      assert {:error, _} = ObjectValidator.validate(external_rep, [])
+    end
   end
 end
index e542c06f587de417b44437c7232e723981750502..fa8171eabc4f5495a865ce72bd15993571138d3b 100644 (file)
@@ -123,7 +123,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
   describe "update users" do
     setup do
       user = insert(:user, local: false)
-      {:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"})
+
+      {:ok, update_data, []} =
+        Builder.update(user, %{"id" => user.ap_id, "type" => "Person", "name" => "new name!"})
+
       {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
 
       %{user: user, update_data: update_data, update: update}
@@ -145,6 +148,298 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
     end
   end
 
+  describe "update notes" do
+    setup do
+      make_time = fn ->
+        Pleroma.Web.ActivityPub.Utils.make_date()
+      end
+
+      user = insert(:user)
+      note = insert(:note, user: user, data: %{"published" => make_time.()})
+      _note_activity = insert(:note_activity, note: note)
+
+      updated_note =
+        note.data
+        |> Map.put("summary", "edited summary")
+        |> Map.put("content", "edited content")
+        |> Map.put("updated", make_time.())
+
+      {:ok, update_data, []} = Builder.update(user, updated_note)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      %{
+        user: user,
+        note: note,
+        object_id: note.id,
+        update_data: update_data,
+        update: update,
+        updated_note: updated_note
+      }
+    end
+
+    test "it updates the note", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      updated_time = updated_note["updated"]
+
+      new_note = Pleroma.Object.get_by_id(object_id)
+
+      assert %{
+               "summary" => "edited summary",
+               "content" => "edited content",
+               "updated" => ^updated_time
+             } = new_note.data
+    end
+
+    test "it rejects updates with no updated attribute in object", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      old_note = Pleroma.Object.get_by_id(object_id)
+      updated_note = Map.drop(updated_note, ["updated"])
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert old_note.data == new_note.data
+    end
+
+    test "it rejects updates with updated attribute older than what we have in the original object",
+         %{
+           object_id: object_id,
+           update: update,
+           updated_note: updated_note
+         } do
+      old_note = Pleroma.Object.get_by_id(object_id)
+      {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
+
+      updated_note =
+        Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, -10)))
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert old_note.data == new_note.data
+    end
+
+    test "it rejects updates with updated attribute older than the last Update", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      old_note = Pleroma.Object.get_by_id(object_id)
+      {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
+
+      updated_note =
+        Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, +10)))
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+
+      old_note = Pleroma.Object.get_by_id(object_id)
+      {:ok, update_time, _} = DateTime.from_iso8601(old_note.data["updated"])
+
+      updated_note =
+        Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(update_time, -5)))
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert old_note.data == new_note.data
+    end
+
+    test "it updates using object_data", %{
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      updated_note = Map.put(updated_note, "summary", "mew mew")
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      new_note = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "mew mew", "content" => "edited content"} = new_note.data
+    end
+
+    test "it records the original note in formerRepresentations", %{
+      note: note,
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
+
+      assert [Map.drop(note.data, ["id", "formerRepresentations"])] ==
+               new_note["formerRepresentations"]["orderedItems"]
+
+      assert new_note["formerRepresentations"]["totalItems"] == 1
+    end
+
+    test "it puts the original note at the front of formerRepresentations", %{
+      user: user,
+      note: note,
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: first_edit} = Pleroma.Object.get_by_id(object_id)
+
+      second_updated_note =
+        note.data
+        |> Map.put("summary", "edited summary 2")
+        |> Map.put("content", "edited content 2")
+        |> Map.put(
+          "updated",
+          first_edit["updated"]
+          |> DateTime.from_iso8601()
+          |> elem(1)
+          |> DateTime.add(10)
+          |> DateTime.to_iso8601()
+        )
+
+      {:ok, second_update_data, []} = Builder.update(user, second_updated_note)
+      {:ok, update, _meta} = ActivityPub.persist(second_update_data, local: true)
+      {:ok, _, _} = SideEffects.handle(update, object_data: second_updated_note)
+      %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "edited summary 2", "content" => "edited content 2"} = new_note
+
+      original_version = Map.drop(note.data, ["id", "formerRepresentations"])
+      first_edit = Map.drop(first_edit, ["id", "formerRepresentations"])
+
+      assert [first_edit, original_version] ==
+               new_note["formerRepresentations"]["orderedItems"]
+
+      assert new_note["formerRepresentations"]["totalItems"] == 2
+    end
+
+    test "it does not prepend to formerRepresentations if no actual changes are made", %{
+      note: note,
+      object_id: object_id,
+      update: update,
+      updated_note: updated_note
+    } do
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: first_edit} = Pleroma.Object.get_by_id(object_id)
+
+      updated_note =
+        updated_note
+        |> Map.put(
+          "updated",
+          first_edit["updated"]
+          |> DateTime.from_iso8601()
+          |> elem(1)
+          |> DateTime.add(10)
+          |> DateTime.to_iso8601()
+        )
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+      %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+      assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
+
+      original_version = Map.drop(note.data, ["id", "formerRepresentations"])
+
+      assert [original_version] ==
+               new_note["formerRepresentations"]["orderedItems"]
+
+      assert new_note["formerRepresentations"]["totalItems"] == 1
+    end
+  end
+
+  describe "update questions" do
+    setup do
+      user = insert(:user)
+
+      question =
+        insert(:question,
+          user: user,
+          data: %{"published" => Pleroma.Web.ActivityPub.Utils.make_date()}
+        )
+
+      %{user: user, data: question.data, id: question.id}
+    end
+
+    test "allows updating choice count without generating edit history", %{
+      user: user,
+      data: data,
+      id: id
+    } do
+      new_choices =
+        data["oneOf"]
+        |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+      updated_question =
+        data
+        |> Map.put("oneOf", new_choices)
+        |> Map.put("updated", Pleroma.Web.ActivityPub.Utils.make_date())
+
+      {:ok, update_data, []} = Builder.update(user, updated_question)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+      %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+      assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+               new_question["oneOf"]
+
+      refute Map.has_key?(new_question, "formerRepresentations")
+    end
+
+    test "allows updating choice count without updated field", %{
+      user: user,
+      data: data,
+      id: id
+    } do
+      new_choices =
+        data["oneOf"]
+        |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+      updated_question =
+        data
+        |> Map.put("oneOf", new_choices)
+
+      {:ok, update_data, []} = Builder.update(user, updated_question)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+      %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+      assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+               new_question["oneOf"]
+
+      refute Map.has_key?(new_question, "formerRepresentations")
+    end
+
+    test "allows updating choice count with updated field same as the creation date", %{
+      user: user,
+      data: data,
+      id: id
+    } do
+      new_choices =
+        data["oneOf"]
+        |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+      updated_question =
+        data
+        |> Map.put("oneOf", new_choices)
+        |> Map.put("updated", data["published"])
+
+      {:ok, update_data, []} = Builder.update(user, updated_question)
+      {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+      {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+      %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+      assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+               new_question["oneOf"]
+
+      refute Map.has_key?(new_question, "formerRepresentations")
+    end
+  end
+
   describe "EmojiReact objects" do
     setup do
       poster = insert(:user)
index ae2fc067af9fb2f14a07d4a57f00b859dd8abc17..a1070848106ccc585a22149cbffe8fb20da69590 100644 (file)
@@ -301,6 +301,28 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
 
       assert url == "http://localhost:4001/emoji/dino%20walking.gif"
     end
+
+    test "Updates of Notes are handled" do
+      user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "everybody do the dinosaur :dinosaur:"})
+      {:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew :blank:"})
+
+      {:ok, prepared} = Transmogrifier.prepare_outgoing(update.data)
+
+      assert %{
+               "content" => "mew mew :blank:",
+               "tag" => [%{"name" => ":blank:", "type" => "Emoji"}],
+               "formerRepresentations" => %{
+                 "orderedItems" => [
+                   %{
+                     "content" => "everybody do the dinosaur :dinosaur:",
+                     "tag" => [%{"name" => ":dinosaur:", "type" => "Emoji"}]
+                   }
+                 ]
+               }
+             } = prepared["object"]
+    end
   end
 
   describe "user upgrade" do
@@ -564,4 +586,43 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
       assert Transmogrifier.fix_attachments(object) == expected
     end
   end
+
+  describe "prepare_object/1" do
+    test "it processes history" do
+      original = %{
+        "formerRepresentations" => %{
+          "orderedItems" => [
+            %{
+              "generator" => %{},
+              "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
+            }
+          ]
+        }
+      }
+
+      processed = Transmogrifier.prepare_object(original)
+
+      history_item = Enum.at(processed["formerRepresentations"]["orderedItems"], 0)
+
+      refute Map.has_key?(history_item, "generator")
+
+      assert [%{"name" => ":blobcat:"}] = history_item["tag"]
+    end
+
+    test "it works when there is no or bad history" do
+      original = %{
+        "formerRepresentations" => %{
+          "items" => [
+            %{
+              "generator" => %{},
+              "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
+            }
+          ]
+        }
+      }
+
+      processed = Transmogrifier.prepare_object(original)
+      assert processed["formerRepresentations"] == original["formerRepresentations"]
+    end
+  end
 end
index 840d74d2fbe3a49438efa39c2b7c231d8b9521c7..2b7a34be23f6ef12ef994275a39de6cbdd56276c 100644 (file)
@@ -1313,4 +1313,128 @@ defmodule Pleroma.Web.CommonAPITest do
       end
     end
   end
+
+  describe "update/3" do
+    test "updates a post" do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1"})
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "updated 2"
+      assert Map.get(updated_object.data, "summary", "") == ""
+      assert Map.has_key?(updated_object.data, "updated")
+    end
+
+    test "does not change visibility" do
+      user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1", visibility: "private"})
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "updated 2"
+      assert Map.get(updated_object.data, "summary", "") == ""
+      assert Visibility.get_visibility(updated_object) == "private"
+      assert Visibility.get_visibility(updated) == "private"
+    end
+
+    test "updates a post with emoji" do
+      [{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
+
+      user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "updated 2 :#{emoji2}:"
+      assert %{^emoji2 => _} = updated_object.data["emoji"]
+    end
+
+    test "updates a post with emoji and federate properly" do
+      [{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
+
+      user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
+
+      clear_config([:instance, :federating], true)
+
+      with_mock Pleroma.Web.Federator,
+        publish: fn _p -> nil end do
+        {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
+
+        assert updated.data["object"]["content"] == "updated 2 :#{emoji2}:"
+        assert %{^emoji2 => _} = updated.data["object"]["emoji"]
+
+        assert called(Pleroma.Web.Federator.publish(updated))
+      end
+    end
+
+    test "editing a post that copied a remote title with remote emoji should keep that emoji" do
+      remote_emoji_uri = "https://remote.org/emoji.png"
+
+      note =
+        insert(
+          :note,
+          data: %{
+            "summary" => ":remoteemoji:",
+            "emoji" => %{
+              "remoteemoji" => remote_emoji_uri
+            },
+            "tag" => [
+              %{
+                "type" => "Emoji",
+                "name" => "remoteemoji",
+                "icon" => %{"url" => remote_emoji_uri}
+              }
+            ]
+          }
+        )
+
+      note_activity = insert(:note_activity, note: note)
+
+      user = insert(:user)
+
+      {:ok, reply} =
+        CommonAPI.post(user, %{
+          status: "reply",
+          spoiler_text: ":remoteemoji:",
+          in_reply_to_id: note_activity.id
+        })
+
+      assert reply.object.data["emoji"]["remoteemoji"] == remote_emoji_uri
+
+      {:ok, edit} =
+        CommonAPI.update(user, reply, %{status: "reply mew mew", spoiler_text: ":remoteemoji:"})
+
+      edited_note = Pleroma.Object.normalize(edit)
+
+      assert edited_note.data["emoji"]["remoteemoji"] == remote_emoji_uri
+    end
+
+    test "respects MRF" do
+      user = insert(:user)
+
+      clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy])
+      clear_config([:mrf_keyword, :replace], [{"updated", "mewmew"}])
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "updated 1"})
+      assert Object.normalize(activity).data["summary"] == "mewmew 1"
+
+      {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+      updated_object = Object.normalize(updated)
+      assert updated_object.data["content"] == "mewmew 2"
+      assert Map.get(updated_object.data, "summary", "") == ""
+      assert Map.has_key?(updated_object.data, "updated")
+    end
+  end
 end
index f76ab3d0d91e9cdb5570f815325e344e8a689699..ea6ace69f5ac60529e6881b22651be1511d85725 100644 (file)
@@ -2072,6 +2072,52 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
     end
   end
 
+  describe "get status history" do
+    setup do
+      %{conn: build_conn()}
+    end
+
+    test "unedited post", %{conn: conn} do
+      activity = insert(:note_activity)
+
+      conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
+
+      assert [_] = json_response_and_validate_schema(conn, 200)
+    end
+
+    test "edited post", %{conn: conn} do
+      note =
+        insert(
+          :note,
+          data: %{
+            "formerRepresentations" => %{
+              "type" => "OrderedCollection",
+              "orderedItems" => [
+                %{
+                  "type" => "Note",
+                  "content" => "mew mew 2",
+                  "summary" => "title 2"
+                },
+                %{
+                  "type" => "Note",
+                  "content" => "mew mew 1",
+                  "summary" => "title 1"
+                }
+              ],
+              "totalItems" => 2
+            }
+          }
+        )
+
+      activity = insert(:note_activity, note: note)
+
+      conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
+
+      assert [%{"spoiler_text" => "title 1"}, %{"spoiler_text" => "title 2"}, _] =
+               json_response_and_validate_schema(conn, 200)
+    end
+  end
+
   describe "translating statuses" do
     setup do
       clear_config([:translator, :enabled], true)
@@ -2177,4 +2223,132 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
       json_response_and_validate_schema(conn, 404)
     end
   end
+
+  describe "get status source" do
+    setup do
+      %{conn: build_conn()}
+    end
+
+    test "it returns the source", %{conn: conn} do
+      user = insert(:user)
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      conn = get(conn, "/api/v1/statuses/#{activity.id}/source")
+
+      id = activity.id
+
+      assert %{"id" => ^id, "text" => "mew mew #abc", "spoiler_text" => "#def"} =
+               json_response_and_validate_schema(conn, 200)
+    end
+  end
+
+  describe "update status" do
+    setup do
+      oauth_access(["write:statuses"])
+    end
+
+    test "it updates the status" do
+      %{conn: conn, user: user} = oauth_access(["write:statuses", "read:statuses"])
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      conn
+      |> get("/api/v1/statuses/#{activity.id}")
+      |> json_response_and_validate_schema(200)
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/statuses/#{activity.id}", %{
+          "status" => "edited",
+          "spoiler_text" => "lol"
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert response["content"] == "edited"
+      assert response["spoiler_text"] == "lol"
+
+      response =
+        conn
+        |> get("/api/v1/statuses/#{activity.id}")
+        |> json_response_and_validate_schema(200)
+
+      assert response["content"] == "edited"
+      assert response["spoiler_text"] == "lol"
+    end
+
+    test "it updates the attachments", %{conn: conn, user: user} do
+      attachment = insert(:attachment, user: user)
+      attachment_id = to_string(attachment.id)
+
+      {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/statuses/#{activity.id}", %{
+          "status" => "mew mew #abc",
+          "spoiler_text" => "#def",
+          "media_ids" => [attachment_id]
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert [%{"id" => ^attachment_id}] = response["media_attachments"]
+    end
+
+    test "it does not update visibility", %{conn: conn, user: user} do
+      {:ok, activity} =
+        CommonAPI.post(user, %{
+          status: "mew mew #abc",
+          spoiler_text: "#def",
+          visibility: "private"
+        })
+
+      response =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> put("/api/v1/statuses/#{activity.id}", %{
+          "status" => "edited",
+          "spoiler_text" => "lol"
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert response["visibility"] == "private"
+    end
+
+    test "it refuses to update when original post is not by the user", %{conn: conn} do
+      another_user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(another_user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/statuses/#{activity.id}", %{
+        "status" => "edited",
+        "spoiler_text" => "lol"
+      })
+      |> json_response_and_validate_schema(:forbidden)
+    end
+
+    test "it returns 404 if the user cannot see the post", %{conn: conn} do
+      another_user = insert(:user)
+
+      {:ok, activity} =
+        CommonAPI.post(another_user, %{
+          status: "mew mew #abc",
+          spoiler_text: "#def",
+          visibility: "private"
+        })
+
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> put("/api/v1/statuses/#{activity.id}", %{
+        "status" => "edited",
+        "spoiler_text" => "lol"
+      })
+      |> json_response_and_validate_schema(:not_found)
+    end
+  end
 end
index 803b1f438308355e4c6af57522a3d6bac0660a36..64d2c8a2eac7a0a1db1d665e55c767444ede2801 100644 (file)
@@ -285,6 +285,32 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
     test_notifications_rendering([notification], moderator_user, [expected])
   end
 
+  test "Edit notification" do
+    user = insert(:user)
+    repeat_user = insert(:user)
+
+    {:ok, activity} = CommonAPI.post(user, %{status: "mew"})
+    {:ok, _} = CommonAPI.repeat(activity.id, repeat_user)
+    {:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew"})
+
+    user = Pleroma.User.get_by_ap_id(user.ap_id)
+    activity = Pleroma.Activity.normalize(activity)
+    update = Pleroma.Activity.normalize(update)
+
+    {:ok, [notification]} = Notification.create_notifications(update)
+
+    expected = %{
+      id: to_string(notification.id),
+      pleroma: %{is_seen: false, is_muted: false},
+      type: "update",
+      account: AccountView.render("show.json", %{user: user, for: repeat_user}),
+      created_at: Utils.to_masto_date(notification.inserted_at),
+      status: StatusView.render("show.json", %{activity: activity, for: repeat_user})
+    }
+
+    test_notifications_rendering([notification], repeat_user, [expected])
+  end
+
   test "muted notification" do
     user = insert(:user)
     another_user = insert(:user)
index f46dded7c20b10485e5871934f31bdc66102b0f9..b3f0a178155a7ab902b8d206a672c54ce42ec1bb 100644 (file)
@@ -267,6 +267,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
       content: HTML.filter_tags(object_data["content"]),
       text: nil,
       created_at: created_at,
+      edited_at: nil,
       reblogs_count: 0,
       replies_count: 0,
       favourites_count: 0,
@@ -788,4 +789,55 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
     status = StatusView.render("show.json", activity: visible, for: poster)
     assert status.pleroma.parent_visible
   end
+
+  test "it shows edited_at" do
+    poster = insert(:user)
+
+    {:ok, post} = CommonAPI.post(poster, %{status: "hey"})
+
+    status = StatusView.render("show.json", activity: post)
+    refute status.edited_at
+
+    {:ok, _} = CommonAPI.update(poster, post, %{status: "mew mew"})
+    edited = Pleroma.Activity.normalize(post)
+
+    status = StatusView.render("show.json", activity: edited)
+    assert status.edited_at
+  end
+
+  test "with a source object" do
+    note =
+      insert(:note,
+        data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
+      )
+
+    activity = insert(:note_activity, note: note)
+
+    status = StatusView.render("show.json", activity: activity, with_source: true)
+    assert status.text == "object source"
+  end
+
+  describe "source.json" do
+    test "with a source object, renders both source and content type" do
+      note =
+        insert(:note,
+          data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
+        )
+
+      activity = insert(:note_activity, note: note)
+
+      status = StatusView.render("source.json", activity: activity)
+      assert status.text == "object source"
+      assert status.content_type == "text/markdown"
+    end
+
+    test "with a source string, renders source and put text/plain as the content type" do
+      note = insert(:note, data: %{"source" => "string source"})
+      activity = insert(:note_activity, note: note)
+
+      status = StatusView.render("source.json", activity: activity)
+      assert status.text == "string source"
+      assert status.content_type == "text/plain"
+    end
+  end
 end
index 074bd2e2f8edde049ff48c1b0bb82f1c2e9c6ac5..c99d11596f250552ca679d901d880a47932c656d 100644 (file)
@@ -3,7 +3,7 @@
 # SPDX-License-Identifier: AGPL-3.0-only
 
 defmodule Pleroma.Web.Metadata.UtilsTest do
-  use Pleroma.DataCase, async: true
+  use Pleroma.DataCase, async: false
   import Pleroma.Factory
   alias Pleroma.Web.Metadata.Utils
 
@@ -22,6 +22,20 @@ defmodule Pleroma.Web.Metadata.UtilsTest do
 
       assert Utils.scrub_html_and_truncate(note) == "Pleroma's really cool!"
     end
+
+    test "it does not return old content after editing" do
+      user = insert(:user)
+
+      {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew #def"})
+
+      object = Pleroma.Object.normalize(activity)
+      assert Utils.scrub_html_and_truncate(object) == "mew mew #def"
+
+      {:ok, update} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "mew mew #abc"})
+      update = Pleroma.Activity.normalize(update)
+      object = Pleroma.Object.normalize(update)
+      assert Utils.scrub_html_and_truncate(object) == "mew mew #abc"
+    end
   end
 
   describe "scrub_html_and_truncate/2" do
index 9ae733fc6ec59b944addd59cd04157dc227e19f5..8e2ab5016cf4ebb19e8b708f46b7619d1de5771a 100644 (file)
@@ -383,6 +383,33 @@ defmodule Pleroma.Web.StreamerTest do
                "state" => "follow_accept"
              } = Jason.decode!(payload)
     end
+
+    test "it streams edits in the 'user' stream", %{user: user, token: oauth_token} do
+      sender = insert(:user)
+      {:ok, _, _, _} = CommonAPI.follow(user, sender)
+
+      {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+      create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+      stream = "user:#{user.id}"
+      assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
+      refute Streamer.filtered_by_user?(user, edited)
+    end
+
+    test "it streams own edits in the 'user' stream", %{user: user, token: oauth_token} do
+      {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+      {:ok, edited} = CommonAPI.update(user, activity, %{status: "mew mew"})
+      create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+      stream = "user:#{user.id}"
+      assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
+      refute Streamer.filtered_by_user?(user, edited)
+    end
   end
 
   describe "public streams" do
@@ -425,6 +452,54 @@ defmodule Pleroma.Web.StreamerTest do
       assert_receive {:text, event}
       assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
     end
+
+    test "it streams edits in the 'public' stream" do
+      sender = insert(:user)
+
+      Streamer.get_topic_and_add_socket("public", nil, nil)
+      {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+      assert_receive {:text, _}
+
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+
+      edited = Pleroma.Activity.normalize(edited)
+
+      %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+
+      assert_receive {:text, event}
+      assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+      assert %{"id" => ^activity_id} = Jason.decode!(payload)
+      refute Streamer.filtered_by_user?(sender, edited)
+    end
+
+    test "it streams multiple edits in the 'public' stream correctly" do
+      sender = insert(:user)
+
+      Streamer.get_topic_and_add_socket("public", nil, nil)
+      {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+      assert_receive {:text, _}
+
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+
+      edited = Pleroma.Activity.normalize(edited)
+
+      %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+
+      assert_receive {:text, event}
+      assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+      assert %{"id" => ^activity_id} = Jason.decode!(payload)
+      refute Streamer.filtered_by_user?(sender, edited)
+
+      {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew 2"})
+
+      edited = Pleroma.Activity.normalize(edited)
+
+      %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+      assert_receive {:text, event}
+      assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+      assert %{"id" => ^activity_id, "content" => "mew mew 2"} = Jason.decode!(payload)
+      refute Streamer.filtered_by_user?(sender, edited)
+    end
   end
 
   describe "thread_containment/2" do
index 64d98366377a931643b18fa40102acca3fb3768a..6695886dc4f237f7ffceef53c2f476679bd61d0a 100644 (file)
@@ -111,6 +111,18 @@ defmodule Pleroma.Factory do
     }
   end
 
+  def attachment_factory(attrs \\ %{}) do
+    user = attrs[:user] || insert(:user)
+
+    data =
+      attachment_data(user.ap_id, nil)
+      |> Map.put("id", Pleroma.Web.ActivityPub.Utils.generate_object_id())
+
+    %Pleroma.Object{
+      data: merge_attributes(data, Map.get(attrs, :data, %{}))
+    }
+  end
+
   def attachment_note_factory(attrs \\ %{}) do
     user = attrs[:user] || insert(:user)
     {length, attrs} = Map.pop(attrs, :length, 1)