diff options
47 files changed, 2080 insertions, 54 deletions
| diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 73c46fff8..4007c63c8 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -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`: diff --git a/lib/pleroma/activity/html.ex b/lib/pleroma/activity/html.ex index 071a89c8d..706b2d36c 100644 --- a/lib/pleroma/activity/html.ex +++ b/lib/pleroma/activity/html.ex @@ -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 diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index d808bc732..e6b733f9b 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -189,6 +189,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), diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 7b63ab06e..cfb405218 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -28,6 +28,42 @@ defmodule Pleroma.Constants 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" +    ] +  ) +    # basic regex, just there to weed out potential mistakes    # https://datatracker.ietf.org/doc/html/rfc2045#section-5.1    const(mime_regex, diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 52fd2656b..2906c599d 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -385,7 +385,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 @@ -439,6 +439,9 @@ defmodule Pleroma.Notification do          activity          |> type_from_activity_object() +      "Update" -> +        "update" +        t ->          raise "No notification type for activity type #{t}"      end @@ -513,7 +516,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 = @@ -553,6 +565,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) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index deb3dc711..d81fdcf24 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -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 index 000000000..0b21f6c99 --- /dev/null +++ b/lib/pleroma/object/updater.ex @@ -0,0 +1,161 @@ +# 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 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 +    %{data: updated_data, updated: updated} = +      original_data +      |> update_content_fields(new_data) + +    %{updated_object: updated_data, used_history_in_new_object?: used_history_in_new_object?} = +      updated_data +      |> maybe_update_history(original_data, +        updated: updated, +        use_history_in_new_object?: true, +        new_data: new_data +      ) + +    updated_data = +      updated_data +      |> maybe_update_poll(new_data) + +    %{ +      updated_data: updated_data, +      updated: updated, +      used_history_in_new_object?: used_history_in_new_object? +    } +  end +end diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index db2909276..4aee9326f 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -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 :: @@ -99,6 +100,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" => [ diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 064f93b22..179e6763b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -190,7 +190,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) @@ -256,7 +265,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) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 5b25138a4..532047599 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -218,10 +218,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,       %{ @@ -229,7 +235,8 @@ defmodule Pleroma.Web.ActivityPub.Builder do         "type" => "Update",         "actor" => actor.ap_id,         "object" => object, -       "to" => to +       "to" => to, +       "cc" => cc       }, []}    end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index f3e31c931..d4bf9c31e 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -141,6 +141,32 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do      end    end +  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} <- cast_and_apply(object), +         meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), +         {: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 +    end +  end +    def validate(%{"type" => type} = object, meta)        when type in ~w[Accept Reject Follow Update Like EmojiReact Announce        ChatMessage Answer] do diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex index ca335bc8a..0a0d30e45 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex @@ -49,7 +49,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"]) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index 8b641d88d..3aae167c9 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -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, ObjectValidators.MIME, 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)      |> validate_inclusion(:type, ~w[Link Document Audio Image Video])      |> validate_required([:type, :mediaType, :url]) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex index 8e768ffbf..a59a6e545 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fields.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fields.ex @@ -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 diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex index a5def312e..1e940a400 100644 --- a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -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 -> diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index b997c15db..f56e357bf 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -25,6 +25,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do    alias Pleroma.Web.Streamer    alias Pleroma.Workers.PollWorker +  require Pleroma.Constants    require Logger    @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @@ -153,23 +154,25 @@ 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)}, +         {_, user} <- {:user, Pleroma.User.get_by_ap_id(updated_object_id)} do +      if 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: @@ -390,6 +393,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" => "ChatMessage"} = object, _activity, meta) do      with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do        actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index d6622df86..e4c04da0d 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -687,6 +687,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 @@ -711,6 +729,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 diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 639f24d49..e921128c7 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -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 @@ -434,6 +438,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 @@ -537,6 +594,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, @@ -579,6 +690,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", diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 6e6e30315..f803caec2 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -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, diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 1b95ee89c..e5a78c102 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -402,6 +402,34 @@ 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) +    } + +    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), diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 7c21c8c3a..9af635da8 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -224,7 +224,10 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do      object =        note_data        |> Map.put("emoji", emoji) -      |> Map.put("source", draft.status) +      |> Map.put("source", %{ +        "content" => draft.status, +        "mediaType" => Utils.get_content_type(draft.params[:content_type]) +      })        |> Map.put("generator", draft.params[:generator])      %__MODULE__{draft | object: object} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index ce850b038..5fc8c3220 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -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 diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 932bc6423..e93930771 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -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 = diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 42a95bdc5..e594ea491 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -38,7 +38,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do             :index,             :show,             :card, -           :context +           :context, +           :show_history, +           :show_source           ]    ) @@ -49,7 +51,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do             :create,             :delete,             :reblog, -           :unreblog +           :unreblog, +           :update           ]    ) @@ -191,6 +194,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), diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index ee52475d5..4f613416b 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -68,6 +68,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do        "shareable_emoji_packs",        "multifetch",        "pleroma:api/v1/notifications:include_types_filter", +      "editing",        if Config.get([:activitypub, :blockers_visible]) do          "blockers_visible"        end, diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 0dc7f3beb..b5b5b2376 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -19,7 +19,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do    alias Pleroma.Web.MastodonAPI.StatusView    alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView -  @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) @@ -30,7 +34,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() @@ -78,9 +82,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 @@ -109,6 +113,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, %{}) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 1ebfd6740..54e025aae 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -258,10 +258,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() @@ -271,14 +291,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"] || "" @@ -344,8 +364,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, @@ -384,6 +405,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) @@ -436,10 +551,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, @@ -601,4 +725,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do    end    defp build_image_url(_, _), do: nil + +  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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 7bbc20275..7a9f39fd2 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -558,6 +558,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) @@ -617,6 +618,8 @@ defmodule Pleroma.Web.Router do      get("/statuses/:id/card", StatusController, :card)      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) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index ff7f62a1e..fe909df0a 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -296,6 +296,24 @@ 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) + +    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}) +        else +          send(pid, {:text, anon_render}) +        end +      end) +    end) +  end +    defp push_to_socket(topic, item) do      anon_render = StreamerView.render("update.json", item) diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 16c2b7d61..6a55242b0 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -25,6 +25,20 @@ defmodule Pleroma.Web.StreamerView do      |> Jason.encode!()    end +  def render("status_update.json", %Activity{} = activity, %User{} = user) do +    %{ +      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) do      %{        event: "notification", @@ -51,6 +65,19 @@ defmodule Pleroma.Web.StreamerView do      |> Jason.encode!()    end +  def render("status_update.json", %Activity{} = activity) do +    %{ +      event: "status.update", +      payload: +        Pleroma.Web.MastodonAPI.StatusView.render( +          "show.json", +          activity: activity +        ) +        |> Jason.encode!() +    } +    |> Jason.encode!() +  end +    def render("chat_update.json", %{chat_message_reference: cm_ref}) do      # Explicitly giving the cmr for the object here, so we don't accidentally      # send a later 'last_message' that was inserted between inserting this and 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 index 000000000..0656c885f --- /dev/null +++ b/priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs @@ -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 diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 946099a6e..650118475 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -36,7 +36,8 @@                  "@id": "as:alsoKnownAs",                  "@type": "@id"              }, -            "vcard": "http://www.w3.org/2006/vcard/ns#" +            "vcard": "http://www.w3.org/2006/vcard/ns#", +            "formerRepresentations": "litepub:formerRepresentations"          }      ]  } diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 805764ea4..a000c0efd 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -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 @@ -839,6 +861,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 diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 98130f434..5a79e064f 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -269,4 +269,191 @@ 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  end diff --git a/test/pleroma/object/updater_test.exs b/test/pleroma/object/updater_test.exs new file mode 100644 index 000000000..7e9b44823 --- /dev/null +++ b/test/pleroma/object/updater_test.exs @@ -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 diff --git a/test/pleroma/upload_test.exs b/test/pleroma/upload_test.exs index f2795f985..6584c2def 100644 --- a/test/pleroma/upload_test.exs +++ b/test/pleroma/upload_test.exs @@ -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 diff --git a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs index f93537ed8..c87b07547 100644 --- a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs @@ -31,6 +31,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest      test "a basic note validates", %{note: note} do        %{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    end    test "a Note from Roadhouse validates" do diff --git a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs index 94bc5a89b..198c35cd3 100644 --- a/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs @@ -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,91 @@ 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  end diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index 64c4a8c14..8e84db774 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -140,6 +140,152 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do      end    end +  describe "update notes" do +    setup do +      user = insert(:user) +      note = insert(:note, user: user) +      _note_activity = insert(:note_activity, note: note) + +      updated_note = +        note.data +        |> Map.put("summary", "edited summary") +        |> Map.put("content", "edited content") + +      {: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) +      new_note = Pleroma.Object.get_by_id(object_id) +      assert %{"summary" => "edited summary", "content" => "edited content"} = 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") + +      {: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) + +      {: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) + +      %{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) + +      {: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) diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 335fe1a30..6520eabc9 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -312,6 +312,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 @@ -575,4 +597,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 diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index b502aaa03..842c75e21 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -586,7 +586,7 @@ defmodule Pleroma.Web.CommonAPITest do        object = Object.normalize(activity, fetch: false)        assert object.data["content"] == "<p><b>2hu</b></p>alert('xss')" -      assert object.data["source"] == post +      assert object.data["source"]["content"] == post      end      test "it filters out obviously bad tags when accepting a post as Markdown" do @@ -603,7 +603,7 @@ defmodule Pleroma.Web.CommonAPITest do        object = Object.normalize(activity, fetch: false)        assert object.data["content"] == "<p><b>2hu</b></p>" -      assert object.data["source"] == post +      assert object.data["source"]["content"] == post      end      test "it does not allow replies to direct messages that are not direct messages themselves" do @@ -1541,4 +1541,69 @@ 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 +  end  end diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index dc6912b7b..05c5d9ed5 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1990,4 +1990,178 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do               } = result      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 "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 diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index 8e4c9136a..d3d74f5cd 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -237,6 +237,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) diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 5d81c92b9..297889449 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -246,6 +246,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, @@ -708,4 +709,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 diff --git a/test/pleroma/web/metadata/utils_test.exs b/test/pleroma/web/metadata/utils_test.exs index ce8ed5683..5f2f4a056 100644 --- a/test/pleroma/web/metadata/utils_test.exs +++ b/test/pleroma/web/metadata/utils_test.exs @@ -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 diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index 4d4fed070..4891bf499 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -442,6 +442,31 @@ 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"]) + +      assert_receive {:render_with_user, _, "status_update.json", ^create} +      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"]) + +      assert_receive {:render_with_user, _, "status_update.json", ^create} +      refute Streamer.filtered_by_user?(user, edited) +    end    end    describe "public streams" do @@ -484,6 +509,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 diff --git a/test/support/factory.ex b/test/support/factory.ex index efbf3df2e..b01aff3ab 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -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) | 
