From 1d648c1f0d4a3a5204cc7cf07a7381275bd69e27 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 29 May 2022 11:36:00 -0400 Subject: [PATCH 01/51] Allow Updates by every actor on the same origin --- .../object_validators/update_validator.ex | 4 +++- .../update_handling_test.exs | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) 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 a1fae47f5..2f0839c5b 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 @@ def validate_updating_rights(cng) 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/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs b/test/pleroma/web/activity_pub/object_validators/update_handling_test.exs index 15e4a82cd..4aed0239d 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 @@ test "validates a basic object", %{valid_update: valid_update} 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,27 @@ test "returns an error if the object can't be updated by the actor", %{ 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 end -- 2.43.0 From d941cd1afe784ddbf199d86da5f0a1fc0d91cbd8 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 29 May 2022 12:54:57 -0400 Subject: [PATCH 02/51] Handle Note and Question Updates --- lib/pleroma/web/activity_pub/side_effects.ex | 82 ++++++++++++++++--- .../web/activity_pub/side_effects_test.exs | 23 ++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 439268470..7617e6c97 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -150,23 +150,25 @@ def handle( # 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: @@ -395,6 +397,66 @@ def handle(object, meta) 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 + + @updatable_object_types ["Note", "Question"] + # We do not allow poll options to be changed, but the poll description can be. + @updatable_fields [ + "source", + "tag", + "updated", + "emoji", + "content", + "summary", + "sensitive", + "attachment", + "generator" + ] + defp handle_update_object( + %{data: %{"type" => "Update", "object" => updated_object}} = object, + meta + ) do + orig_object = Object.get_by_ap_id(updated_object["id"]) + orig_object_data = orig_object.data + + if orig_object_data["type"] in @updatable_object_types do + updated_object_data = + @updatable_fields + |> Enum.reduce( + orig_object_data, + fn field, acc -> + if Map.has_key?(updated_object, field) do + Map.put(acc, field, updated_object[field]) + else + Map.drop(acc, [field]) + end + end + ) + + orig_object + |> Object.change(%{data: updated_object_data}) + |> Object.update_and_set_cache() + end + + {:ok, object, meta} + end + def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do PollWorker.schedule_poll_end(activity) diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index e542c06f5..f30d016c8 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -145,6 +145,29 @@ test "it uses a given changeset to update", %{user: user, update: update} do end end + describe "update notes" do + setup do + user = insert(:user) + note = insert(:note, user: user) + + 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, object_id: note.id, update_data: update_data, update: update} + end + + test "it updates the note", %{object_id: object_id, update: update} do + {:ok, _, _} = SideEffects.handle(update) + new_note = Pleroma.Object.get_by_id(object_id) + assert %{"summary" => "edited summary", "content" => "edited content"} = new_note.data + end + end + describe "EmojiReact objects" do setup do poster = insert(:user) -- 2.43.0 From 11c7cf30109608febe6c3cb9bf411ed912abefd7 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 29 May 2022 13:54:16 -0400 Subject: [PATCH 03/51] Record edit history for Note and Question Updates --- lib/pleroma/web/activity_pub/side_effects.ex | 34 +++++++++++++++++ priv/static/schemas/litepub-0.1.jsonld | 4 +- .../web/activity_pub/side_effects_test.exs | 37 ++++++++++++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 7617e6c97..1dcf0cf10 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -415,6 +415,26 @@ defp handle_update_user( {:ok, object, meta} end + defp history_for_object(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 + _ -> history_skeleton() + end + end + + defp history_skeleton do + %{ + "type" => "OrderedCollection", + "totalItems" => 0, + "orderedItems" => [] + } + end + @updatable_object_types ["Note", "Question"] # We do not allow poll options to be changed, but the poll description can be. @updatable_fields [ @@ -436,6 +456,19 @@ defp handle_update_object( orig_object_data = orig_object.data if orig_object_data["type"] in @updatable_object_types do + # Put edit history + # Note that we may have got the edit history by first fetching the object + history = history_for_object(orig_object_data) + + latest_history_item = + orig_object_data + |> Map.drop(["id", "formerRepresentations"]) + + new_history = + history + |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]]) + |> Map.put("totalItems", history["totalItems"] + 1) + updated_object_data = @updatable_fields |> Enum.reduce( @@ -448,6 +481,7 @@ defp handle_update_object( end end ) + |> Map.put("formerRepresentations", new_history) orig_object |> Object.change(%{data: updated_object_data}) diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index d2b62ba77..f582ed42c 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -39,7 +39,9 @@ "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" - } + }, + "vcard": "http://www.w3.org/2006/vcard/ns#", + "formerRepresentations": "litepub:formerRepresentations" } ] } diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index f30d016c8..a39a639b5 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -158,7 +158,7 @@ test "it uses a given changeset to update", %{user: user, update: update} do {:ok, update_data, []} = Builder.update(user, updated_note) {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) - %{user: user, object_id: note.id, update_data: update_data, update: update} + %{user: user, note: note, object_id: note.id, update_data: update_data, update: update} end test "it updates the note", %{object_id: object_id, update: update} do @@ -166,6 +166,41 @@ test "it updates the note", %{object_id: object_id, update: update} do new_note = Pleroma.Object.get_by_id(object_id) assert %{"summary" => "edited summary", "content" => "edited content"} = new_note.data end + + test "it records the original note in formerRepresentations", %{ + note: note, + object_id: object_id, + update: update + } do + {:ok, _, _} = SideEffects.handle(update) + %{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", %{ + note: note, + object_id: object_id, + update: update + } do + {:ok, _, _} = SideEffects.handle(update) + %{data: first_edit} = Pleroma.Object.get_by_id(object_id) + {:ok, _, _} = SideEffects.handle(update) + %{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"]) + first_edit = Map.drop(first_edit, ["id", "formerRepresentations"]) + + assert [first_edit, original_version] == + new_note["formerRepresentations"]["orderedItems"] + + assert new_note["formerRepresentations"]["totalItems"] == 2 + end end describe "EmojiReact objects" do -- 2.43.0 From c94b86683aed47ce2b8a7d1e56648488d444beec Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 29 May 2022 22:16:03 -0400 Subject: [PATCH 04/51] Allow updating polls --- lib/pleroma/web/activity_pub/side_effects.ex | 81 ++++++++++++++----- .../web/activity_pub/side_effects_test.exs | 64 ++++++++++++++- 2 files changed, 125 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1dcf0cf10..96a1b0336 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -448,14 +448,29 @@ defp history_skeleton do "attachment", "generator" ] - defp handle_update_object( - %{data: %{"type" => "Update", "object" => updated_object}} = object, - meta - ) do - orig_object = Object.get_by_ap_id(updated_object["id"]) - orig_object_data = orig_object.data + defp update_content_fields(orig_object_data, updated_object) do + @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) - if orig_object_data["type"] in @updatable_object_types do + 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 + + defp maybe_update_history(updated_object, orig_object_data, updated) do + if not updated do + updated_object + else # Put edit history # Note that we may have got the edit history by first fetching the object history = history_for_object(orig_object_data) @@ -469,19 +484,47 @@ defp handle_update_object( |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]]) |> Map.put("totalItems", history["totalItems"] + 1) + updated_object + |> Map.put("formerRepresentations", new_history) + 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 + + defp handle_update_object( + %{data: %{"type" => "Update", "object" => updated_object}} = object, + meta + ) do + orig_object = Object.get_by_ap_id(updated_object["id"]) + orig_object_data = orig_object.data + + if orig_object_data["type"] in @updatable_object_types do + %{data: updated_object_data, updated: updated} = + orig_object_data + |> update_content_fields(updated_object) + updated_object_data = - @updatable_fields - |> Enum.reduce( - orig_object_data, - fn field, acc -> - if Map.has_key?(updated_object, field) do - Map.put(acc, field, updated_object[field]) - else - Map.drop(acc, [field]) - end - end - ) - |> Map.put("formerRepresentations", new_history) + updated_object_data + |> maybe_update_history(orig_object_data, updated) + |> maybe_update_poll(updated_object) orig_object |> Object.change(%{data: updated_object_data}) diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index a39a639b5..c2b923f1c 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -183,15 +183,24 @@ test "it records the original note in formerRepresentations", %{ end test "it puts the original note at the front of formerRepresentations", %{ + user: user, note: note, object_id: object_id, update: update } do {:ok, _, _} = SideEffects.handle(update) %{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) %{data: new_note} = Pleroma.Object.get_by_id(object_id) - assert %{"summary" => "edited summary", "content" => "edited content"} = new_note + 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"]) @@ -201,6 +210,59 @@ test "it puts the original note at the front of formerRepresentations", %{ 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 + } do + {:ok, _, _} = SideEffects.handle(update) + %{data: _first_edit} = Pleroma.Object.get_by_id(object_id) + + {:ok, _, _} = SideEffects.handle(update) + %{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) + + %{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 -- 2.43.0 From 5fe133fbc9b0d66fe635b00f71f87d50a4d79f24 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 29 May 2022 23:50:31 -0400 Subject: [PATCH 05/51] Implement mastodon api for showing edit history --- lib/pleroma/object.ex | 20 +++++ lib/pleroma/web/activity_pub/side_effects.ex | 22 +---- .../api_spec/operations/status_operation.ex | 82 +++++++++++++++++++ .../controllers/status_controller.ex | 30 ++++++- .../web/mastodon_api/views/status_view.ex | 65 +++++++++++++++ lib/pleroma/web/router.ex | 3 + .../controllers/status_controller_test.exs | 46 +++++++++++ 7 files changed, 245 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 00af77f57..94828c0c5 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -421,4 +421,24 @@ def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do end def object_data_hashtags(_), do: [] + + def history_for(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 + _ -> history_skeleton() + end + end + + defp history_skeleton do + %{ + "type" => "OrderedCollection", + "totalItems" => 0, + "orderedItems" => [] + } + end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 96a1b0336..2266068e3 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -415,26 +415,6 @@ defp handle_update_user( {:ok, object, meta} end - defp history_for_object(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 - _ -> history_skeleton() - end - end - - defp history_skeleton do - %{ - "type" => "OrderedCollection", - "totalItems" => 0, - "orderedItems" => [] - } - end - @updatable_object_types ["Note", "Question"] # We do not allow poll options to be changed, but the poll description can be. @updatable_fields [ @@ -473,7 +453,7 @@ defp maybe_update_history(updated_object, orig_object_data, updated) do else # Put edit history # Note that we may have got the edit history by first fetching the object - history = history_for_object(orig_object_data) + history = Object.history_for(orig_object_data) latest_history_item = orig_object_data diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 5332c9dca..86436246d 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 @@ -422,6 +426,29 @@ def translate_operation 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 + end + + def update_operation do + end + def array_of_statuses do %Schema{type: :array, items: Status, example: [Status.schema().example]} end @@ -580,6 +607,61 @@ defp status_response 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 context do %Schema{ title: "StatusContext", diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 41fbd7acf..79243443c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -40,7 +40,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do :index, :show, :context, - :translate + :translate, + :show_history, + :show_source ] ) @@ -51,7 +53,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do :create, :delete, :reblog, - :unreblog + :unreblog, + :update ] ) @@ -193,6 +196,29 @@ def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = c create(%Plug.Conn{conn | body_params: params}, %{}) end + @doc "GET /api/v1/statuses/:id/history" + def show_history(%{assigns: %{user: user}} = conn, %{id: id} = params) do + with %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: %{user: _user}} = _conn, %{id: _id} = _params) do + end + + @doc "PUT /api/v1/statuses/:id" + def update(%{assigns: %{user: _user}} = _conn, %{id: _id} = _params) do + 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/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 0d2571ab8..371b0d872 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -400,6 +400,71 @@ def render("show.json", _) 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.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] + + individual_opts = + opts + |> Map.put(:as, :object) + |> 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, object: object, 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" + ) + + 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("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url_data = URI.parse(page_url) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 175b1c4c0..cf4b7c4a5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -547,6 +547,9 @@ defmodule Pleroma.Web.Router do get("/bookmarks", StatusController, :bookmarks) post("/statuses", StatusController, :create) + get("/statuses/:id/history", StatusController, :show_history) + get("/statuses/:id/source", StatusController, :show_source) + put("/statuses/:id", StatusController, :update) delete("/statuses/:id", StatusController, :delete) post("/statuses/:id/reblog", StatusController, :reblog) post("/statuses/:id/unreblog", StatusController, :unreblog) 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 f76ab3d0d..dcf0e08e1 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2069,6 +2069,52 @@ test "posting a quote of a status that doesn't exist", %{conn: conn} do "quote_id" => "oops" }) |> json_response_and_validate_schema(422) + end + end + + describe "get status history" do + setup do + oauth_access(["read:statuses"]) + 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 2"}, %{"spoiler_text" => "title 1"}] = + json_response_and_validate_schema(conn, 200) end end -- 2.43.0 From 3f186180ec0db655356e350b63a989275de42f09 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Mon, 30 May 2022 00:59:23 -0400 Subject: [PATCH 06/51] Implement viewing source --- .../api_spec/operations/status_operation.ex | 36 +++++++++++++++++++ .../controllers/status_controller.ex | 11 +++++- .../web/mastodon_api/views/status_view.ex | 10 ++++++ .../controllers/status_controller_test.exs | 18 ++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 86436246d..46d7b42fc 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -444,6 +444,20 @@ def show_history_operation do 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 @@ -662,6 +676,28 @@ defp status_history_response do ) 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" + } + } + } + ) + end + defp context do %Schema{ title: "StatusContext", diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 79243443c..7457edff0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -212,7 +212,16 @@ def show_history(%{assigns: %{user: user}} = conn, %{id: id} = params) do end @doc "GET /api/v1/statuses/:id/source" - def show_source(%{assigns: %{user: _user}} = _conn, %{id: _id} = _params) do + def show_source(%{assigns: %{user: user}} = conn, %{id: id} = _params) do + with %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" diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 371b0d872..a7772b5b4 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -465,6 +465,16 @@ def render( } end + def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do + object = Object.normalize(activity, fetch: false) + + %{ + id: activity.id, + text: Map.get(object.data, "source", ""), + spoiler_text: Map.get(object.data, "summary", "") + } + end + def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url_data = URI.parse(page_url) 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 dcf0e08e1..1c14174d6 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2223,4 +2223,22 @@ test "should not allow translating of statuses you cannot see", %{conn: conn} do json_response_and_validate_schema(conn, 404) end end + + describe "get status source" do + setup do + oauth_access(["read:statuses"]) + 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 end -- 2.43.0 From 40f67954ac68f45f7525ae8326eb481fdf13c99e Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 31 May 2022 14:29:12 -0400 Subject: [PATCH 07/51] Implement mastodon api for editing status --- lib/pleroma/constants.ex | 24 ++++++ lib/pleroma/web/activity_pub/builder.ex | 13 ++- lib/pleroma/web/activity_pub/side_effects.ex | 16 +--- .../api_spec/operations/status_operation.ex | 72 ++++++++++++++++- lib/pleroma/web/common_api.ex | 36 +++++++++ .../controllers/status_controller.ex | 21 ++++- test/pleroma/web/common_api_test.exs | 29 +++++++ .../controllers/status_controller_test.exs | 79 ++++++++++++++++++- 8 files changed, 271 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index bf92f65cb..e65dd2f77 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -27,4 +27,28 @@ defmodule Pleroma.Constants do do: ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css) ) + + const(status_updatable_fields, + do: [ + "source", + "tag", + "updated", + "emoji", + "content", + "summary", + "sensitive", + "attachment", + "generator" + ] + ) + + const(actor_types, + do: [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ] + ) end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index ba756ed64..6d39ad3a8 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -278,10 +278,16 @@ def like(actor, object) do end end - # Retricted to user updates for now, always public @spec update(User.t(), Object.t()) :: {:ok, map(), keyword()} def update(actor, object) do - to = [Pleroma.Constants.as_public(), actor.follower_address] + {to, cc} = + if object["type"] in Pleroma.Constants.actor_types() do + # User updates, always public + {[Pleroma.Constants.as_public(), actor.follower_address], []} + else + # Status updates, follow the recipients in the object + {object["to"] || [], object["cc"] || []} + end {:ok, %{ @@ -289,7 +295,8 @@ def update(actor, object) do "type" => "Update", "actor" => actor.ap_id, "object" => object, - "to" => to + "to" => to, + "cc" => cc }, []} end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 2266068e3..f3ddaacdd 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -23,6 +23,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.Streamer alias Pleroma.Workers.PollWorker + require Pleroma.Constants require Logger @logger Pleroma.Config.get([:side_effects, :logger], Logger) @@ -416,20 +417,8 @@ defp handle_update_user( end @updatable_object_types ["Note", "Question"] - # We do not allow poll options to be changed, but the poll description can be. - @updatable_fields [ - "source", - "tag", - "updated", - "emoji", - "content", - "summary", - "sensitive", - "attachment", - "generator" - ] defp update_content_fields(orig_object_data, updated_object) do - @updatable_fields + Pleroma.Constants.status_updatable_fields() |> Enum.reduce( %{data: orig_object_data, updated: false}, fn field, %{data: data, updated: updated} -> @@ -507,6 +496,7 @@ defp handle_update_object( |> maybe_update_poll(updated_object) orig_object + |> Repo.preload(:hashtags) |> Object.change(%{data: updated_object_data}) |> Object.update_and_set_cache() end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 46d7b42fc..8ff9273c9 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -461,6 +461,22 @@ def show_source_operation do 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 @@ -571,6 +587,60 @@ defp create_request 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, @@ -691,7 +761,7 @@ defp status_source_response do spoiler_text: %Schema{ type: :string, description: - "Subject or summary line, below which status content is collapsed until expanded" + "Subject or summary line, below which status content is collapsed until expanded" } } } diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 23d353dc2..2ac8d41cf 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -347,6 +347,42 @@ def post(user, %{status: _} = data) 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 = + Pleroma.Constants.status_updatable_fields() + |> Enum.reduce(orig_object.data, fn key, acc -> + if Map.has_key?(draft.object, key) do + acc |> Map.put(key, Map.get(draft.object, key)) + else + acc |> Map.drop([key]) + end + end) + |> Map.put("updated", 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/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 7457edff0..432c018f2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -225,7 +225,26 @@ def show_source(%{assigns: %{user: user}} = conn, %{id: id} = _params) do end @doc "PUT /api/v1/statuses/:id" - def update(%{assigns: %{user: _user}} = _conn, %{id: _id} = _params) do + 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" diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 840d74d2f..7e76a2957 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1313,4 +1313,33 @@ test "unreact_with_emoji" 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 + 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 1c14174d6..61a63648b 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2238,7 +2238,84 @@ test "it returns the source", %{conn: conn} do id = activity.id - assert %{"id" => ^id, "text" => "mew mew #abc", "spoiler_text" => "#def"} = json_response_and_validate_schema(conn, 200) + 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", %{conn: conn, user: user} do + {: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" => "edited", + "spoiler_text" => "lol" + }) + |> json_response_and_validate_schema(200) + + assert response["content"] == "edited" + assert response["spoiler_text"] == "lol" + 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 -- 2.43.0 From ff9bebeeb30b1215f9e4a5ec205a2e78840f0cab Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Wed, 1 Jun 2022 12:02:03 -0400 Subject: [PATCH 08/51] Strip internal fields in formerRepresentation --- .../web/activity_pub/transmogrifier.ex | 19 ++++++- .../web/activity_pub/transmogrifier_test.exs | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 8ec4b0fec..83ab478ba 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -911,7 +911,24 @@ def prepare_attachments(object) do end def strip_internal_fields(object) do - Map.drop(object, Pleroma.Constants.object_internal_fields()) + outer = Map.drop(object, Pleroma.Constants.object_internal_fields()) + + case outer do + %{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) -> + update_in( + outer["formerRepresentations"]["orderedItems"], + &Enum.map( + &1, + fn + item when is_map(item) -> Map.drop(item, Pleroma.Constants.object_internal_fields()) + item -> item + end + ) + ) + + _ -> + outer + end end defp strip_internal_tags(%{"tag" => tags} = object) do diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index ae2fc067a..3bc0af345 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -564,4 +564,58 @@ test "puts dimensions into attachment url field" do assert Transmogrifier.fix_attachments(object) == expected end end + + describe "strip_internal_fields/1" do + test "it strips internal fields in formerRepresentations" do + original = %{ + "formerRepresentations" => %{ + "orderedItems" => [ + %{"generator" => %{}} + ] + } + } + + stripped = Transmogrifier.strip_internal_fields(original) + + refute Map.has_key?( + Enum.at(stripped["formerRepresentations"]["orderedItems"], 0), + "generator" + ) + end + + test "it strips internal fields in maybe badly-formed formerRepresentations" do + original = %{ + "formerRepresentations" => %{ + "orderedItems" => [ + %{"generator" => %{}}, + "https://example.com/1" + ] + } + } + + stripped = Transmogrifier.strip_internal_fields(original) + + refute Map.has_key?( + Enum.at(stripped["formerRepresentations"]["orderedItems"], 0), + "generator" + ) + + assert Enum.at(stripped["formerRepresentations"]["orderedItems"], 1) == + "https://example.com/1" + end + + test "it ignores if formerRepresentations does not look like an OrderedCollection" do + original = %{ + "formerRepresentations" => %{ + "items" => [ + %{"generator" => %{}} + ] + } + } + + stripped = Transmogrifier.strip_internal_fields(original) + + assert Map.has_key?(Enum.at(stripped["formerRepresentations"]["items"], 0), "generator") + end + end end -- 2.43.0 From 211b766bb30eb38ada809195be3bd6fab668e0eb Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Wed, 1 Jun 2022 19:30:50 -0400 Subject: [PATCH 09/51] Inject history when object is refetched --- lib/pleroma/object.ex | 22 +++ lib/pleroma/object/fetcher.ex | 27 +++ lib/pleroma/web/activity_pub/side_effects.ex | 24 +-- test/pleroma/object/fetcher_test.exs | 187 +++++++++++++++++++ 4 files changed, 237 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 94828c0c5..4a5aa25ac 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -441,4 +441,26 @@ defp history_skeleton do "orderedItems" => [] } end + + def maybe_update_history(updated_object, orig_object_data, updated) do + if not updated do + updated_object + else + # Put edit history + # Note that we may have got the edit history by first fetching the object + history = Object.history_for(orig_object_data) + + latest_history_item = + orig_object_data + |> Map.drop(["id", "formerRepresentations"]) + + new_history = + history + |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]]) + |> Map.put("totalItems", history["totalItems"] + 1) + + updated_object + |> Map.put("formerRepresentations", new_history) + end + end end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 4ca67f0fd..4289107e0 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -26,8 +26,35 @@ defp touch_changeset(changeset) 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) + + new_data |> Object.maybe_update_history(old_data, changed?) + else + new_data + end + Map.merge(new_data, internal_fields) end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index f3ddaacdd..799f22b51 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -436,28 +436,6 @@ defp update_content_fields(orig_object_data, updated_object) do ) end - defp maybe_update_history(updated_object, orig_object_data, updated) do - if not updated do - updated_object - else - # Put edit history - # Note that we may have got the edit history by first fetching the object - history = Object.history_for(orig_object_data) - - latest_history_item = - orig_object_data - |> Map.drop(["id", "formerRepresentations"]) - - new_history = - history - |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]]) - |> Map.put("totalItems", history["totalItems"] + 1) - - updated_object - |> Map.put("formerRepresentations", new_history) - 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" @@ -492,7 +470,7 @@ defp handle_update_object( updated_object_data = updated_object_data - |> maybe_update_history(orig_object_data, updated) + |> Object.maybe_update_history(orig_object_data, updated) |> maybe_update_poll(updated_object) orig_object diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index bd0a6e497..585be0376 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -269,4 +269,191 @@ test "it can refetch pruned objects" 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 -- 2.43.0 From 75dad5229b829a9057f5effb971e5804e4b82325 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 3 Jun 2022 21:15:17 -0400 Subject: [PATCH 10/51] Stream out edits --- lib/pleroma/web/activity_pub/activity_pub.ex | 13 ++++++-- lib/pleroma/web/activity_pub/side_effects.ex | 6 ++++ lib/pleroma/web/streamer.ex | 14 +++++++++ lib/pleroma/web/views/streamer_view.ex | 33 ++++++++++++++++++++ test/pleroma/web/streamer_test.exs | 33 ++++++++++++++++++++ 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 20acdf86e..dcdc7085f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -194,7 +194,16 @@ defp insert_activity_with_expiration(data, local, recipients) do def notify_and_stream(activity) do Notification.create_notifications(activity) - conversation = create_or_bump_conversation(activity, activity.actor) + original_activity = + case activity do + %{data: %{"type" => "Update"}, object: %{data: %{"id" => id}}} -> + Activity.get_create_by_object_ap_id_with_object(id) + + _ -> + activity + end + + conversation = create_or_bump_conversation(original_activity, original_activity.actor) participations = get_participations(conversation) stream_out(activity) stream_out_participations(participations) @@ -260,7 +269,7 @@ def stream_out_participations(_, _), do: :noop @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/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 799f22b51..5d7b9165a 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -477,6 +477,12 @@ defp handle_update_object( |> Repo.preload(:hashtags) |> Object.change(%{data: updated_object_data}) |> Object.update_and_set_cache() + + if updated do + object + |> Activity.normalize() + |> ActivityPub.notify_and_stream() + end end {:ok, object, meta} diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index fba5d1c02..550645e93 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -287,6 +287,20 @@ defp push_to_socket(topic, %Activity{ defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop + defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do + anon_render = StreamerView.render("status_update.json", item) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, auth?} -> + if auth? do + send(pid, {:render_with_user, StreamerView, "status_update.json", item}) + else + send(pid, {:text, anon_render}) + end + end) + end) + end + defp push_to_socket(topic, item) do anon_render = StreamerView.render("update.json", item, topic) diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index f455f941e..eba3d96ec 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -26,6 +26,23 @@ def render("update.json", %Activity{} = activity, %User{} = user, topic) do |> Jason.encode!() end + def render("status_update.json", %Activity{} = activity, %User{} = user, topic) do + activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"]) + + %{ + stream: [topic], + event: "status.update", + payload: + Pleroma.Web.MastodonAPI.StatusView.render( + "show.json", + activity: activity, + for: user + ) + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("notification.json", %Notification{} = notify, %User{} = user, topic) do %{ stream: [topic], @@ -54,6 +71,22 @@ def render("update.json", %Activity{} = activity, topic) do |> Jason.encode!() end + def render("status_update.json", %Activity{} = activity, topic) do + activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"]) + + %{ + stream: [topic], + event: "status.update", + payload: + Pleroma.Web.MastodonAPI.StatusView.render( + "show.json", + activity: activity + ) + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("follow_relationships_update.json", item, topic) do %{ stream: [topic], diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index 9ae733fc6..4b1175034 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -383,6 +383,20 @@ test "it sends follow relationships updates to the 'user' stream", %{ "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"}) + edited = Pleroma.Activity.normalize(edited) + + assert_receive {:render_with_user, _, "status_update.json", ^edited} + refute Streamer.filtered_by_user?(user, edited) + end end describe "public streams" do @@ -425,6 +439,25 @@ test "handles deletions" 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 end describe "thread_containment/2" do -- 2.43.0 From a762184cfd3eb721482bb3fc2a87d9ca703d0d65 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 3 Jun 2022 21:17:16 -0400 Subject: [PATCH 11/51] Test that own edits are streamed --- test/pleroma/web/streamer_test.exs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index 4b1175034..b87bd2446 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -397,6 +397,17 @@ test "it streams edits in the 'user' stream", %{user: user, token: oauth_token} assert_receive {:render_with_user, _, "status_update.json", ^edited} 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"}) + edited = Pleroma.Activity.normalize(edited) + + assert_receive {:render_with_user, _, "status_update.json", ^edited} + refute Streamer.filtered_by_user?(user, edited) + end end describe "public streams" do -- 2.43.0 From c41f27c24835f929ab2e09f95e42677b75df0a58 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 3 Jun 2022 21:47:40 -0400 Subject: [PATCH 12/51] Show edited_at in MastodonAPI/show --- lib/pleroma/web/api_spec/schemas/status.ex | 6 ++++++ .../web/mastodon_api/views/status_view.ex | 11 +++++++++++ .../web/mastodon_api/views/status_view_test.exs | 16 ++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index c5d9119ef..a6df9be94 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/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index a7772b5b4..359ee16ed 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -265,6 +265,16 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} 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"]) @@ -355,6 +365,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} content: content_html, text: opts[:with_source] && 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, 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 f46dded7c..b8ae9d1ab 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -267,6 +267,7 @@ test "a note activity" do content: HTML.filter_tags(object_data["content"]), text: nil, created_at: created_at, + edited_at: nil, reblogs_count: 0, replies_count: 0, favourites_count: 0, @@ -788,4 +789,19 @@ test "has a field for parent visibility" 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 end -- 2.43.0 From 6431bb9bd2ed3f88fb01a808dcc4a45f1e4dbdf1 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 3 Jun 2022 21:50:49 -0400 Subject: [PATCH 13/51] Fix SideEffectsTest --- test/pleroma/web/activity_pub/side_effects_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index c2b923f1c..9fb583dbf 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -149,6 +149,7 @@ test "it uses a given changeset to update", %{user: user, update: update} do setup do user = insert(:user) note = insert(:note, user: user) + _note_activity = insert(:note_activity, note: note) updated_note = note.data -- 2.43.0 From 803d8ed75cee7f122e26a71a28615fe4e1ad18dc Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 4 Jun 2022 12:56:56 -0400 Subject: [PATCH 14/51] Expose content type of status sources --- .../API/differences_in_mastoapi_responses.md | 4 +++ .../api_spec/operations/status_operation.ex | 4 +++ lib/pleroma/web/common_api/activity_draft.ex | 1 + lib/pleroma/web/common_api/utils.ex | 2 +- .../web/mastodon_api/views/status_view.ex | 27 ++++++++++++-- .../mastodon_api/views/status_view_test.exs | 36 +++++++++++++++++++ 6 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/docs/development/API/differences_in_mastoapi_responses.md b/docs/docs/development/API/differences_in_mastoapi_responses.md index 4465784bf..752be1762 100644 --- a/docs/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/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/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 8ff9273c9..65877cc64 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -762,6 +762,10 @@ defp status_source_response do 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" } } } diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 767b2bf0f..a672fc2e5 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -222,6 +222,7 @@ defp object(draft) do |> Map.put("source", %{ "content" => draft.status, "mediaType" => draft.params[:content_type] + "mediaType" => Utils.get_content_type(draft.params[:content_type]) }) |> Map.put("generator", draft.params[:generator]) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 15016eb47..18811cfad 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -219,7 +219,7 @@ def make_content_html(%ActivityDraft{} = draft) 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/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 359ee16ed..6d0c7ca4f 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -363,7 +363,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} 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, @@ -481,8 +481,9 @@ def render("source.json", %{activity: %{data: %{"object" => _object}} = activity %{ id: activity.id, - text: Map.get(object.data, "source", ""), - spoiler_text: Map.get(object.data, "summary", "") + 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 @@ -724,4 +725,24 @@ defp maybe_render_quote(quote, opts) do _ -> nil end end + + defp get_source_text(%{"content" => content} = _source) do + content + end + + defp get_source_text(source) when is_binary(source) do + source + end + + defp get_source_text(_) do + "" + end + + defp get_source_content_type(%{"mediaType" => type} = _source) do + type + end + + defp get_source_content_type(_source) do + Utils.get_content_type(nil) + end end 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 b8ae9d1ab..b3f0a1781 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -804,4 +804,40 @@ test "it shows edited_at" do 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 -- 2.43.0 From fa870c7188ec8126f5066a46a6ea9cf1431991dc Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 5 Jun 2022 15:02:25 -0400 Subject: [PATCH 15/51] Create Update notifications --- lib/pleroma/notification.ex | 22 +++++++- ...85734_add_update_to_notifications_enum.exs | 51 +++++++++++++++++++ test/pleroma/notification_test.exs | 46 +++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20220605185734_add_update_to_notifications_enum.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d8878338e..e62ae0ec9 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -384,7 +384,7 @@ def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = act end def create_notifications(%Activity{data: %{"type" => type}} = activity, options) - when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag"] do + when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do do_create_notifications(activity, options) end @@ -438,6 +438,9 @@ defp type_from_activity(%{data: %{"type" => type}} = activity) do activity |> type_from_activity_object() + "Update" -> + "update" + t -> raise "No notification type for activity type #{t}" end @@ -503,7 +506,7 @@ def create_poll_notifications(%Activity{} = activity) do def get_notified_from_activity(activity, local_only \\ true) def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) - when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact", "Flag"] do + when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact", "Flag", "Update"] do potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity) potential_receivers = @@ -543,6 +546,21 @@ def get_potential_receiver_ap_ids(%{data: %{"type" => "Flag", "actor" => actor}} (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/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/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 8db208878..68330465b 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -127,6 +127,28 @@ test "does not create a notification for subscribed users if status is a reply" subscriber_notifications = Notification.for_user(subscriber) assert Enum.empty?(subscriber_notifications) end + + test "it sends edited notifications to those who repeated a status" do + user = insert(:user) + repeated_user = insert(:user) + other_user = insert(:user) + + {:ok, activity_one} = + CommonAPI.post(user, %{ + status: "hey @#{other_user.nickname}!" + }) + + {:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user) + + {:ok, _edit_activity} = + CommonAPI.update(user, activity_one, %{ + status: "hey @#{other_user.nickname}! mew mew" + }) + + assert [%{type: "reblog"}] = Notification.for_user(user) + assert [%{type: "update"}] = Notification.for_user(repeated_user) + assert [%{type: "mention"}] = Notification.for_user(other_user) + end end test "create_poll_notifications/1" do @@ -838,6 +860,30 @@ test "it returns following domain-blocking recipient in enabled recipients list" 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 -- 2.43.0 From 118a94d22a1b9791888c0c1ee166994612927efc Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 5 Jun 2022 16:34:42 -0400 Subject: [PATCH 16/51] Return update notification in mastodon api --- .../controllers/notification_controller.ex | 1 + .../mastodon_api/views/notification_view.ex | 15 ++++++++--- .../views/notification_view_test.exs | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index ae4432e85..8e6cf2a6a 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 @@ def index(conn, %{account_id: account_id} = params) do move pleroma:emoji_reaction poll + update } def index(%{assigns: %{user: user}} = conn, params) do params = diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 83914a275..463d31d1a 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -17,7 +17,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - @parent_types ~w{Like Announce EmojiReact} + defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id + + defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id + + @parent_types ~w{Like Announce EmojiReact Update} def render("index.json", %{notifications: notifications, for: reading_user} = opts) do activities = Enum.map(notifications, & &1.activity) @@ -28,7 +32,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op %{data: %{"type" => type}} -> type in @parent_types end) - |> Enum.map(& &1.data["object"]) + |> Enum.map(&object_id_for/1) |> Activity.create_by_object_ap_id() |> Activity.with_preloaded_object(:left) |> Pleroma.Repo.all() @@ -76,9 +80,9 @@ def render( parent_activity_fn = fn -> if opts[:parent_activities] do - Activity.Queries.find_by_object_ap_id(opts[:parent_activities], activity.data["object"]) + Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity)) else - Activity.get_create_by_object_ap_id(activity.data["object"]) + Activity.get_create_by_object_ap_id(object_id_for(activity)) end end @@ -107,6 +111,9 @@ def render( "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/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index 803b1f438..64d2c8a2e 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -285,6 +285,32 @@ test "Report notification" 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) -- 2.43.0 From f7c722c3f7a57261456587bdcc9406eae73d3534 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 5 Jun 2022 16:35:01 -0400 Subject: [PATCH 17/51] Lint --- lib/pleroma/notification.ex | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index e62ae0ec9..593448713 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -506,7 +506,16 @@ def create_poll_notifications(%Activity{} = activity) 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", "Update"] 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 = -- 2.43.0 From a9038093baeb04aad38f3f60ef9cff8fd3c4414b Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Wed, 8 Jun 2022 11:05:48 -0400 Subject: [PATCH 18/51] Add object id to uploaded attachments --- lib/pleroma/upload.ex | 2 ++ test/pleroma/upload_test.exs | 30 ++++++++++++++++-------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 17822dc5e..9bf8e03df 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 :: @@ -88,6 +89,7 @@ def store(upload, opts \\ []) 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/test/pleroma/upload_test.exs b/test/pleroma/upload_test.exs index f1ab82a57..8f242630f 100644 --- a/test/pleroma/upload_test.exs +++ b/test/pleroma/upload_test.exs @@ -49,20 +49,22 @@ def put_file(upload), do: TestUploaderBase.put_file(upload, __MODULE__) 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 -- 2.43.0 From a0681391a1b30b230ddaa790d410cc7b1b301ea1 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Wed, 8 Jun 2022 11:45:24 -0400 Subject: [PATCH 19/51] Return the corresponding object id in attachment view --- lib/pleroma/web/common_api/utils.ex | 8 ++++++-- .../web/mastodon_api/views/status_view.ex | 13 +++++++++++-- .../controllers/status_controller_test.exs | 19 +++++++++++++++++++ test/support/factory.ex | 12 ++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 18811cfad..bf03b0a82 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -37,7 +37,7 @@ def attachments_from_ids_no_descs([]), 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 @@ def attachments_from_ids_descs(ids, descs_str) 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 diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 6d0c7ca4f..be3d1f4e2 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -539,10 +539,19 @@ def render("attachment.json", %{attachment: attachment}) do true -> "unknown" end - <> = :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 + _ -> + <> = :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, 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 61a63648b..5fe16fda7 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2264,6 +2264,25 @@ test "it updates the status", %{conn: conn, user: user} do 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, %{ diff --git a/test/support/factory.ex b/test/support/factory.ex index 64d983663..6695886dc 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -111,6 +111,18 @@ def note_factory(attrs \\ %{}) 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) -- 2.43.0 From a5fb36ed91f3569634260b54c34a1e870720247e Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Thu, 9 Jun 2022 11:39:51 -0400 Subject: [PATCH 20/51] Fix incorrectly cached content after editing --- lib/pleroma/activity/html.ex | 36 ++++++++++++++++ lib/pleroma/application.ex | 1 + lib/pleroma/web/activity_pub/side_effects.ex | 25 +++++++---- .../web/mastodon_api/views/status_view.ex | 41 ++++++++++++++++--- .../controllers/status_controller_test.exs | 16 +++++++- test/pleroma/web/metadata/utils_test.exs | 16 +++++++- 6 files changed, 119 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/activity/html.ex b/lib/pleroma/activity/html.ex index 0bf393836..30409d93d 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 @@ def get_cached_scrubbed_html_for_activity( @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 b809f7733..adccd7c5d 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -150,6 +150,7 @@ defp cachex_children 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/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5d7b9165a..1e1570e33 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -460,7 +460,8 @@ defp handle_update_object( %{data: %{"type" => "Update", "object" => updated_object}} = object, meta ) do - orig_object = Object.get_by_ap_id(updated_object["id"]) + orig_object_ap_id = updated_object["id"] + orig_object = Object.get_by_ap_id(orig_object_ap_id) orig_object_data = orig_object.data if orig_object_data["type"] in @updatable_object_types do @@ -473,15 +474,21 @@ defp handle_update_object( |> Object.maybe_update_history(orig_object_data, updated) |> maybe_update_poll(updated_object) - orig_object - |> Repo.preload(:hashtags) - |> Object.change(%{data: updated_object_data}) - |> Object.update_and_set_cache() + changeset = + orig_object + |> Repo.preload(:hashtags) + |> Object.change(%{data: updated_object_data}) - if updated do - object - |> Activity.normalize() - |> ActivityPub.notify_and_stream() + 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 updated do + object + |> Activity.normalize() + |> ActivityPub.notify_and_stream() + end end end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index be3d1f4e2..c809b11c2 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -279,6 +279,16 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"]) + history_len = + 1 + + (Object.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() @@ -288,14 +298,14 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} |> 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"] || "" @@ -426,9 +436,25 @@ def render("history.json", %{activity: %{data: %{"object" => _object}} = activit history = [object | past_history] + history_len = length(history) + + history = + Enum.with_index( + history, + fn object, index -> + %{ + # 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: history_len - 1 - index, + object: object + } + end + ) + individual_opts = opts - |> Map.put(:as, :object) + |> Map.put(:as, :item) |> Map.put(:user, user) |> Map.put(:hashtags, hashtags) @@ -437,7 +463,12 @@ def render("history.json", %{activity: %{data: %{"object" => _object}} = activit def render( "history_item.json", - %{activity: activity, user: user, object: object, hashtags: hashtags} = opts + %{ + activity: activity, + user: user, + item: %{object: object, chrono_order: chrono_order}, + hashtags: hashtags + } = opts ) do sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") @@ -455,7 +486,7 @@ def render( |> Activity.HTML.get_cached_scrubbed_html_for_activity( User.html_filter_policy(opts[:for]), activity, - "mastoapi:content" + "mastoapi:content:#{chrono_order}" ) summary = object.data["summary"] || "" 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 5fe16fda7..67a1bfeca 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2248,9 +2248,15 @@ test "it returns the source", %{conn: conn} do oauth_access(["write:statuses"]) end - test "it updates the status", %{conn: conn, user: user} do + 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") @@ -2262,6 +2268,14 @@ test "it updates the status", %{conn: conn, user: user} do 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 diff --git a/test/pleroma/web/metadata/utils_test.exs b/test/pleroma/web/metadata/utils_test.exs index 074bd2e2f..c99d11596 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 @@ test "it returns text without encode HTML" 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 -- 2.43.0 From c6f5bae654c875469100e529ac65be6fe7c638b8 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 11 Jun 2022 10:35:36 -0400 Subject: [PATCH 21/51] Expose history and source apis to anon users --- .../web/mastodon_api/controllers/status_controller.ex | 10 ++++++---- lib/pleroma/web/router.ex | 4 ++-- .../controllers/status_controller_test.exs | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 432c018f2..31f3b3a8d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -197,8 +197,9 @@ def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = c end @doc "GET /api/v1/statuses/:id/history" - def show_history(%{assigns: %{user: user}} = conn, %{id: id} = params) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), + 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, @@ -212,8 +213,9 @@ def show_history(%{assigns: %{user: user}} = conn, %{id: id} = params) do end @doc "GET /api/v1/statuses/:id/source" - def show_source(%{assigns: %{user: user}} = conn, %{id: id} = _params) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), + 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, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index cf4b7c4a5..cc63b2b04 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -547,8 +547,6 @@ defmodule Pleroma.Web.Router do get("/bookmarks", StatusController, :bookmarks) post("/statuses", StatusController, :create) - get("/statuses/:id/history", StatusController, :show_history) - get("/statuses/:id/source", StatusController, :show_source) put("/statuses/:id", StatusController, :update) delete("/statuses/:id", StatusController, :delete) post("/statuses/:id/reblog", StatusController, :reblog) @@ -615,6 +613,8 @@ defmodule Pleroma.Web.Router do get("/statuses/:id/context", StatusController, :context) get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) + get("/statuses/:id/history", StatusController, :show_history) + get("/statuses/:id/source", StatusController, :show_source) get("/custom_emojis", CustomEmojiController, :index) 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 67a1bfeca..856e9ceda 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2074,7 +2074,7 @@ test "posting a quote of a status that doesn't exist", %{conn: conn} do describe "get status history" do setup do - oauth_access(["read:statuses"]) + %{conn: build_conn()} end test "unedited post", %{conn: conn} do @@ -2226,7 +2226,7 @@ test "should not allow translating of statuses you cannot see", %{conn: conn} do describe "get status source" do setup do - oauth_access(["read:statuses"]) + %{conn: build_conn()} end test "it returns the source", %{conn: conn} do -- 2.43.0 From 1f705b5cabcbe9a2b8ca30d687acb7de5b79df25 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 11 Jun 2022 12:02:16 -0400 Subject: [PATCH 22/51] Send the correct update in streamer get_create_by_ap_id_with_object() seems to fetch the old object. Why this happens needs further investigation. --- lib/pleroma/web/streamer.ex | 8 +++++-- test/pleroma/web/streamer_test.exs | 37 ++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index 550645e93..77ca14a97 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -288,12 +288,16 @@ defp push_to_socket(topic, %Activity{ defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do - anon_render = StreamerView.render("status_update.json", item) + 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", item}) + send(pid, {:render_with_user, StreamerView, "status_update.json", create_activity}) else send(pid, {:text, anon_render}) end diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index b87bd2446..edbdb1bb5 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -392,9 +392,9 @@ test "it streams edits in the 'user' stream", %{user: user, token: oauth_token} Streamer.get_topic_and_add_socket("user", user, oauth_token) {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"}) - edited = Pleroma.Activity.normalize(edited) + create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"]) - assert_receive {:render_with_user, _, "status_update.json", ^edited} + assert_receive {:render_with_user, _, "status_update.json", ^create} refute Streamer.filtered_by_user?(user, edited) end @@ -403,9 +403,9 @@ test "it streams own edits in the 'user' stream", %{user: user, token: oauth_tok Streamer.get_topic_and_add_socket("user", user, oauth_token) {:ok, edited} = CommonAPI.update(user, activity, %{status: "mew mew"}) - edited = Pleroma.Activity.normalize(edited) + create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"]) - assert_receive {:render_with_user, _, "status_update.json", ^edited} + assert_receive {:render_with_user, _, "status_update.json", ^create} refute Streamer.filtered_by_user?(user, edited) end end @@ -469,6 +469,35 @@ test "it streams edits in the 'public' stream" do 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 -- 2.43.0 From a01cb6624e0e338b1c4f13115453fe9544453162 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 11 Jun 2022 16:28:59 -0400 Subject: [PATCH 23/51] Workaround with_index does not support function in Elixir 1.9 --- .../web/mastodon_api/views/status_view.ex | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index c809b11c2..f7d14b202 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -439,18 +439,16 @@ def render("history.json", %{activity: %{data: %{"object" => _object}} = activit history_len = length(history) history = - Enum.with_index( - history, - fn object, index -> - %{ - # 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: history_len - 1 - index, - object: object - } - end - ) + Enum.zip(history_len..0, history) + |> Enum.map(fn {chrono_order, object} -> + %{ + # 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 -- 2.43.0 From 9c31797806511a2a6572484df15c8667916a1060 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 11 Jun 2022 19:52:07 -0400 Subject: [PATCH 24/51] Show original status at the first of history --- lib/pleroma/web/mastodon_api/views/status_view.ex | 11 +++++------ .../controllers/status_controller_test.exs | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index f7d14b202..24ab14bc2 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -434,13 +434,12 @@ def render("history.json", %{activity: %{data: %{"object" => _object}} = activit |> Enum.map(&Map.put(&1, "id", object.data["id"])) |> Enum.map(&%Object{data: &1, id: object.id}) - history = [object | past_history] - - history_len = length(history) - history = - Enum.zip(history_len..0, history) - |> Enum.map(fn {chrono_order, object} -> + [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. 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 856e9ceda..ba0691517 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2113,7 +2113,7 @@ test "edited post", %{conn: conn} do conn = get(conn, "/api/v1/statuses/#{activity.id}/history") - assert [_, %{"spoiler_text" => "title 2"}, %{"spoiler_text" => "title 1"}] = + assert [%{"spoiler_text" => "title 1"}, %{"spoiler_text" => "title 2"}, _] = json_response_and_validate_schema(conn, 200) end end -- 2.43.0 From db74f786d9fddc86cfd0c46ebd4a69d0e76256d7 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 21 Jun 2022 12:32:44 -0400 Subject: [PATCH 25/51] Add editing to features --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 436519439..6612a7ec1 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -65,6 +65,7 @@ def features do "shareable_emoji_packs", "multifetch", "pleroma:api/v1/notifications:include_types_filter", + "editing", if Config.get([:media_proxy, :enabled]) do "media_proxy" end, -- 2.43.0 From 12f3b124ed47c5a61a693b84cdc1c8d36f3973d7 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 24 Jun 2022 10:10:22 -0400 Subject: [PATCH 26/51] Convert incoming Updated object into Pleroma format --- .../web/activity_pub/object_validator.ex | 16 +++++++++ .../article_note_page_validator.ex | 5 ++- .../article_note_page_validator_test.exs | 5 +++ .../update_handling_test.exs | 35 ++++++++++++++++++- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 283cd884c..3c52fe311 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -124,6 +124,22 @@ def validate(%{"type" => type} = object, meta) 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 {: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} + end + end + def validate(%{"type" => type} = object, meta) when type in ~w[Accept Reject Follow Update Like EmojiReact Announce 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 55323bc2e..0d45421e2 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 @@ -53,7 +53,10 @@ defp fix_url(%{"url" => url} = data) when is_bitstring(url), do: data 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/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 7c8e5a4e1..d52584d47 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 @@ -38,6 +38,11 @@ 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 + test "a note with a remote replies collection should validate", _ do insert(:user, %{ap_id: "https://bookwyrm.com/user/TestUser"}) collection = File.read!("test/fixtures/bookwyrm-replies-collection.json") 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 4aed0239d..2415355af 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 @@ -60,7 +60,40 @@ test "validates if the object is not of an Actor type" do {:ok, update, _} = Builder.update(other_user, updated_note) - assert {:ok, _update, []} = ObjectValidator.validate(update, []) + 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 end end -- 2.43.0 From 2dbd969e6e7a39583e6eae7ebdb95c213b0316bc Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 24 Jun 2022 10:26:01 -0400 Subject: [PATCH 27/51] Use meta[:object_data] in SideEffects for Update --- lib/pleroma/web/activity_pub/side_effects.ex | 2 + .../web/activity_pub/side_effects_test.exs | 49 ++++++++++++++----- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1e1570e33..1c216ca38 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -464,6 +464,8 @@ defp handle_update_object( orig_object = Object.get_by_ap_id(orig_object_ap_id) orig_object_data = orig_object.data + updated_object = meta[:object_data] + if orig_object_data["type"] in @updatable_object_types do %{data: updated_object_data, updated: updated} = orig_object_data diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index 9fb583dbf..d98fb38cf 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -159,21 +159,44 @@ test "it uses a given changeset to update", %{user: user, update: update} do {: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} + %{ + 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} do - {:ok, _, _} = SideEffects.handle(update) + 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 + update: update, + updated_note: updated_note } do - {:ok, _, _} = SideEffects.handle(update) + {: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 @@ -187,9 +210,10 @@ test "it puts the original note at the front of formerRepresentations", %{ user: user, note: note, object_id: object_id, - update: update + update: update, + updated_note: updated_note } do - {:ok, _, _} = SideEffects.handle(update) + {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) %{data: first_edit} = Pleroma.Object.get_by_id(object_id) second_updated_note = @@ -199,7 +223,7 @@ test "it puts the original note at the front of formerRepresentations", %{ {:ok, second_update_data, []} = Builder.update(user, second_updated_note) {:ok, update, _meta} = ActivityPub.persist(second_update_data, local: true) - {:ok, _, _} = SideEffects.handle(update) + {: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 @@ -215,12 +239,13 @@ test "it puts the original note at the front of formerRepresentations", %{ test "it does not prepend to formerRepresentations if no actual changes are made", %{ note: note, object_id: object_id, - update: update + update: update, + updated_note: updated_note } do - {:ok, _, _} = SideEffects.handle(update) + {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) %{data: _first_edit} = Pleroma.Object.get_by_id(object_id) - {:ok, _, _} = SideEffects.handle(update) + {: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 @@ -255,7 +280,7 @@ test "allows updating choice count without generating edit history", %{ {:ok, update_data, []} = Builder.update(user, updated_question) {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) - {:ok, _, _} = SideEffects.handle(update) + {:ok, _, _} = SideEffects.handle(update, object_data: updated_question) %{data: new_question} = Pleroma.Object.get_by_id(id) -- 2.43.0 From 5f0d1889ce8529d3c409b13537f4b748a0853dba Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Fri, 24 Jun 2022 10:54:11 -0400 Subject: [PATCH 28/51] Fix CommonAPITest --- .../web/activity_pub/object_validators/attachment_validator.ex | 3 ++- .../web/activity_pub/object_validators/common_fields.ex | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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 ffdb16976..dba18a3d0 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, :string, default: "application/octet-stream") field(:name, :string) @@ -43,7 +44,7 @@ def changeset(struct, data) do |> fix_url() struct - |> cast(data, [:type, :mediaType, :name, :blurhash]) + |> cast(data, [:id, :type, :mediaType, :name, :blurhash]) |> cast_embed(:url, with: &url_changeset/2, required: true) |> validate_inclusion(:type, ~w[Link Document Audio Image Video]) |> validate_required([:type, :mediaType]) 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 49aba68af..db28c38ef 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 @@ defmacro object_fields do field(:content, :string) field(:published, ObjectValidators.DateTime) + field(:updated, ObjectValidators.DateTime) field(:emoji, ObjectValidators.Emoji, default: %{}) embeds_many(:attachment, AttachmentValidator) end -- 2.43.0 From 7977931e01480299b298bd43cac18a968a569828 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 25 Jun 2022 00:32:22 -0400 Subject: [PATCH 29/51] Unify the logic of updating objects --- lib/pleroma/object.ex | 42 ----- lib/pleroma/object/fetcher.ex | 9 +- lib/pleroma/object/updater.ex | 157 ++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 62 ++----- lib/pleroma/web/common_api.ex | 10 +- .../web/mastodon_api/views/status_view.ex | 4 +- 6 files changed, 183 insertions(+), 101 deletions(-) create mode 100644 lib/pleroma/object/updater.ex diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 4a5aa25ac..00af77f57 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -421,46 +421,4 @@ def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do end def object_data_hashtags(_), do: [] - - def history_for(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 - _ -> history_skeleton() - end - end - - defp history_skeleton do - %{ - "type" => "OrderedCollection", - "totalItems" => 0, - "orderedItems" => [] - } - end - - def maybe_update_history(updated_object, orig_object_data, updated) do - if not updated do - updated_object - else - # Put edit history - # Note that we may have got the edit history by first fetching the object - history = Object.history_for(orig_object_data) - - latest_history_item = - orig_object_data - |> Map.drop(["id", "formerRepresentations"]) - - new_history = - history - |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]]) - |> Map.put("totalItems", history["totalItems"] + 1) - - updated_object - |> Map.put("formerRepresentations", new_history) - end - end end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 4289107e0..8ec28345f 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -50,7 +50,14 @@ defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do Pleroma.Constants.status_updatable_fields() |> Enum.any?(fn field -> Map.get(old_data, field) != Map.get(new_data, field) end) - new_data |> Object.maybe_update_history(old_data, changed?) + %{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 diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex new file mode 100644 index 000000000..03136c38e --- /dev/null +++ b/lib/pleroma/object/updater.ex @@ -0,0 +1,157 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# 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(updated_object) 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?: false) + + 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/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1c216ca38..8bc1cb78d 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -417,45 +417,6 @@ defp handle_update_user( end @updatable_object_types ["Note", "Question"] - defp 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 - - 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 - defp handle_update_object( %{data: %{"type" => "Update", "object" => updated_object}} = object, meta @@ -467,14 +428,11 @@ defp handle_update_object( updated_object = meta[:object_data] if orig_object_data["type"] in @updatable_object_types do - %{data: updated_object_data, updated: updated} = - orig_object_data - |> update_content_fields(updated_object) - - updated_object_data = - updated_object_data - |> Object.maybe_update_history(orig_object_data, updated) - |> maybe_update_poll(updated_object) + %{ + 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 @@ -486,6 +444,16 @@ defp handle_update_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() diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 2ac8d41cf..e3297ca32 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -367,15 +367,7 @@ defp make_update_data(user, orig_object, changes) do with {:ok, draft} <- ActivityDraft.create(user, params) do change = - Pleroma.Constants.status_updatable_fields() - |> Enum.reduce(orig_object.data, fn key, acc -> - if Map.has_key?(draft.object, key) do - acc |> Map.put(key, Map.get(draft.object, key)) - else - acc |> Map.drop([key]) - end - end) - |> Map.put("updated", Utils.make_date()) + Object.Updater.make_update_object_data(orig_object.data, draft.object, Utils.make_date()) {:ok, change} else diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 24ab14bc2..b3a35526e 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -281,7 +281,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} history_len = 1 + - (Object.history_for(object.data) + (Object.Updater.history_for(object.data) |> Map.get("orderedItems") |> length()) @@ -429,7 +429,7 @@ def render("history.json", %{activity: %{data: %{"object" => _object}} = activit user = CommonAPI.get_user(activity.data["actor"]) past_history = - Object.history_for(object.data) + 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}) -- 2.43.0 From bee60c461bd8319ab5fdcfc2e0d3f0141aaf5e86 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 25 Jun 2022 01:03:46 -0400 Subject: [PATCH 30/51] Reuse formerRepresentations from remote if possible --- lib/pleroma/object/updater.ex | 8 +++- test/pleroma/object/updater_test.exs | 65 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 test/pleroma/object/updater_test.exs diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex index 03136c38e..0b21f6c99 100644 --- a/lib/pleroma/object/updater.ex +++ b/lib/pleroma/object/updater.ex @@ -67,7 +67,7 @@ def maybe_update_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(updated_object) do + updated_history when not is_nil(updated_history) <- maybe_history(opts[:new_data]) do {updated_history, true} else _ -> @@ -142,7 +142,11 @@ def make_new_object_data_from_update_object(original_data, new_data) do %{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?: false) + |> maybe_update_history(original_data, + updated: updated, + use_history_in_new_object?: true, + new_data: new_data + ) updated_data = updated_data diff --git a/test/pleroma/object/updater_test.exs b/test/pleroma/object/updater_test.exs new file mode 100644 index 000000000..ef13439b9 --- /dev/null +++ b/test/pleroma/object/updater_test.exs @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# 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) + + %{ + 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? + end + end +end -- 2.43.0 From a299c64cad96a7c30b2d266caa063d60535e9668 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 25 Jun 2022 01:17:09 -0400 Subject: [PATCH 31/51] Verify that formerRepresentation provided in Update is used --- test/pleroma/object/updater_test.exs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/pleroma/object/updater_test.exs b/test/pleroma/object/updater_test.exs index ef13439b9..7e9b44823 100644 --- a/test/pleroma/object/updater_test.exs +++ b/test/pleroma/object/updater_test.exs @@ -52,14 +52,25 @@ test "it reuses formerRepresentations if it exists" do 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_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 -- 2.43.0 From f4d963314126cc63b3a2cdc93f33efa0edf4e202 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 25 Jun 2022 09:23:09 -0400 Subject: [PATCH 32/51] Fix local updates causing emojis to be lost --- lib/pleroma/web/activity_pub/side_effects.ex | 9 ++++++++- test/pleroma/web/common_api_test.exs | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 8bc1cb78d..72db68ab6 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -425,7 +425,14 @@ defp handle_update_object( orig_object = Object.get_by_ap_id(orig_object_ap_id) orig_object_data = orig_object.data - updated_object = meta[: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 @updatable_object_types do %{ diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 7e76a2957..42fdd753c 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1341,5 +1341,20 @@ test "does not change visibility" do 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 end end -- 2.43.0 From 9e4c87a15273810327a4d2ae4d78ea1c0bbc83ef Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 25 Jun 2022 10:03:19 -0400 Subject: [PATCH 33/51] Do not put meta[:object_data] for local Updates --- .../web/activity_pub/object_validator.ex | 12 ++++++- .../update_handling_test.exs | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 3c52fe311..ee716e9f9 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -129,7 +129,8 @@ def validate( meta ) when objtype in ~w[Question Answer Audio Video Event Article Note Page] do - with {:ok, object_data} <- cast_and_apply(object), + 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 @@ -137,6 +138,15 @@ def 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 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 2415355af..997276bcd 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 @@ -95,5 +95,36 @@ test "converts object into Pleroma's format" do 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 -- 2.43.0 From 1a17cef083ffd210469847c2bb81702e26ed9ec9 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 25 Jun 2022 11:20:46 -0400 Subject: [PATCH 34/51] Make outbound transmogrifier aware of edit history --- lib/pleroma/constants.ex | 12 +++ lib/pleroma/web/activity_pub/side_effects.ex | 3 +- .../web/activity_pub/transmogrifier.ex | 52 ++++++++----- .../web/activity_pub/transmogrifier_test.exs | 75 ++++++++++--------- test/pleroma/web/common_api_test.exs | 21 ++++++ 5 files changed, 109 insertions(+), 54 deletions(-) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index e65dd2f77..7343ef8c3 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -42,6 +42,18 @@ defmodule Pleroma.Constants do ] ) + const(updatable_object_types, + do: [ + "Note", + "Question", + "Audio", + "Video", + "Event", + "Article", + "Page" + ] + ) + const(actor_types, do: [ "Application", diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 72db68ab6..7995e2a4a 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -416,7 +416,6 @@ defp handle_update_user( {:ok, object, meta} end - @updatable_object_types ["Note", "Question"] defp handle_update_object( %{data: %{"type" => "Update", "object" => updated_object}} = object, meta @@ -434,7 +433,7 @@ defp handle_update_object( meta[:object_data] end - if orig_object_data["type"] in @updatable_object_types do + if orig_object_data["type"] in Pleroma.Constants.updatable_object_types() do %{ updated_data: updated_object_data, updated: updated, diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 83ab478ba..b9d853610 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -699,6 +699,24 @@ def prepare_object(object) do |> strip_internal_fields |> strip_internal_tags |> set_type + |> maybe_process_history + end + + defp maybe_process_history(%{"formerRepresentations" => %{"orderedItems" => history}} = object) do + processed_history = + Enum.map( + history, + fn + item when is_map(item) -> prepare_object(item) + item -> item + end + ) + + put_in(object, ["formerRepresentations", "orderedItems"], processed_history) + end + + defp maybe_process_history(object) do + object end # @doc @@ -723,6 +741,21 @@ def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data) {: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 @@ -911,24 +944,7 @@ def prepare_attachments(object) do end def strip_internal_fields(object) do - outer = Map.drop(object, Pleroma.Constants.object_internal_fields()) - - case outer do - %{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) -> - update_in( - outer["formerRepresentations"]["orderedItems"], - &Enum.map( - &1, - fn - item when is_map(item) -> Map.drop(item, Pleroma.Constants.object_internal_fields()) - item -> item - end - ) - ) - - _ -> - outer - end + Map.drop(object, Pleroma.Constants.object_internal_fields()) end defp strip_internal_tags(%{"tag" => tags} = object) do diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 3bc0af345..a10708481 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -301,6 +301,28 @@ test "custom emoji urls are URI encoded" 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 @@ -565,57 +587,42 @@ test "puts dimensions into attachment url field" do end end - describe "strip_internal_fields/1" do - test "it strips internal fields in formerRepresentations" do + describe "prepare_object/1" do + test "it processes history" do original = %{ "formerRepresentations" => %{ "orderedItems" => [ - %{"generator" => %{}} + %{ + "generator" => %{}, + "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"} + } ] } } - stripped = Transmogrifier.strip_internal_fields(original) + processed = Transmogrifier.prepare_object(original) - refute Map.has_key?( - Enum.at(stripped["formerRepresentations"]["orderedItems"], 0), - "generator" - ) + history_item = Enum.at(processed["formerRepresentations"]["orderedItems"], 0) + + refute Map.has_key?(history_item, "generator") + + assert [%{"name" => ":blobcat:"}] = history_item["tag"] end - test "it strips internal fields in maybe badly-formed formerRepresentations" do - original = %{ - "formerRepresentations" => %{ - "orderedItems" => [ - %{"generator" => %{}}, - "https://example.com/1" - ] - } - } - - stripped = Transmogrifier.strip_internal_fields(original) - - refute Map.has_key?( - Enum.at(stripped["formerRepresentations"]["orderedItems"], 0), - "generator" - ) - - assert Enum.at(stripped["formerRepresentations"]["orderedItems"], 1) == - "https://example.com/1" - end - - test "it ignores if formerRepresentations does not look like an OrderedCollection" do + test "it works when there is no or bad history" do original = %{ "formerRepresentations" => %{ "items" => [ - %{"generator" => %{}} + %{ + "generator" => %{}, + "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"} + } ] } } - stripped = Transmogrifier.strip_internal_fields(original) - - assert Map.has_key?(Enum.at(stripped["formerRepresentations"]["items"], 0), "generator") + 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 42fdd753c..242d71756 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1356,5 +1356,26 @@ test "updates a post with emoji" do 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 -- 2.43.0 From 090bda68774109f92c7944877850065768a01e5e Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 3 Jul 2022 20:02:52 -0400 Subject: [PATCH 35/51] Pass history items through ObjectValidator for updatable object types --- .../web/activity_pub/object_validator.ex | 69 +++++++++++++++++-- .../article_note_page_validator_test.exs | 44 ++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index ee716e9f9..b904d0c2f 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -111,15 +111,20 @@ def validate(%{"type" => type} = object, meta) end with {:ok, object} <- - object - |> validator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) + do_separate_with_history(object, fn object -> + with {:ok, object} <- + object + |> validator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) - # Insert copy of hashtags as strings for the non-hashtag table indexing - tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object}) - object = Map.put(object, "tag", tag) + # Insert copy of hashtags as strings for the non-hashtag table indexing + tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object}) + object = Map.put(object, "tag", tag) + {:ok, object} + end + end) do {:ok, object, meta} end end @@ -240,4 +245,54 @@ def fetch_actor_and_object(object) do Object.normalize(object["object"], fetch: true) :ok end + + defp for_each_history_item( + %{"type" => "OrderedCollection", "orderedItems" => items} = history, + object, + fun + ) do + processed_items = + Enum.map(items, fn item -> + with item <- Map.put(item, "id", object["id"]), + {:ok, item} <- fun.(item) do + item + else + _ -> nil + end + end) + + if Enum.all?(processed_items, &(not is_nil(&1))) do + {:ok, Map.put(history, "orderedItems", processed_items)} + else + {:error, :invalid_history} + end + end + + defp for_each_history_item(nil, _object, _fun) do + {:ok, nil} + end + + defp for_each_history_item(_, _object, _fun) do + {:error, :invalid_history} + end + + # fun is (object -> {:ok, validated_object_with_string_keys}) + defp do_separate_with_history(object, fun) do + with history <- object["formerRepresentations"], + object <- Map.drop(object, ["formerRepresentations"]), + {_, {:ok, object}} <- {:main_body, fun.(object)}, + {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do + object = + if history do + Map.put(object, "formerRepresentations", history) + else + object + end + + {:ok, object} + else + {:main_body, e} -> e + {:history_items, e} -> e + end + end end 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 d52584d47..5b95ebc51 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 @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest do use Pleroma.DataCase, async: true + alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator alias Pleroma.Web.ActivityPub.Utils @@ -164,4 +165,47 @@ test "a Note without replies/first/items validates" do %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note) end + + describe "Note with history" do + setup do + user = insert(:user) + {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"}) + {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"}) + + {:ok, %{"object" => external_rep}} = + Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data) + + %{external_rep: external_rep} + end + + test "edited note", %{external_rep: external_rep} do + assert %{"formerRepresentations" => %{"orderedItems" => [%{"tag" => [_]}]}} = external_rep + + {:ok, validate_res, []} = ObjectValidator.validate(external_rep, []) + + assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} = + validate_res + end + + test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do + external_rep = Map.put(external_rep, "formerRepresentations", %{}) + + assert {:error, _} = ObjectValidator.validate(external_rep, []) + end + + test "edited note, badly-formed history item", %{external_rep: external_rep} do + history_item = + Enum.at(external_rep["formerRepresentations"]["orderedItems"], 0) + |> Map.put("type", "Foo") + + external_rep = + put_in( + external_rep, + ["formerRepresentations", "orderedItems"], + [history_item] + ) + + assert {:error, _} = ObjectValidator.validate(external_rep, []) + end + end end -- 2.43.0 From 3411bb819f329936dd0f67888249ee859b824db0 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 3 Jul 2022 20:19:50 -0400 Subject: [PATCH 36/51] Validate object data for incoming Update activities In Create validator we do not validate the object data, but that is because the object itself will go through the pipeline again, which is not the case for Update. Thus, we added validation for objects in Update activities. --- .../web/activity_pub/object_validator.ex | 7 +++- .../update_handling_test.exs | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index b904d0c2f..1d4bcc863 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -135,8 +135,8 @@ def validate( ) 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, object_data, _}} <- {:object_validation, validate(object, meta)}, + meta = Keyword.put(meta, :object_data, object_data), {:ok, update_activity} <- update_activity |> UpdateValidator.cast_and_validate() @@ -152,6 +152,9 @@ def validate( object = stringify_keys(object) {:ok, object, meta} end + + {:object_validation, e} -> + e end end 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 997276bcd..a74ee2416 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 @@ -127,4 +127,42 @@ test "returns object_data in meta for a remote Update" do assert meta[:object_data] end end + + describe "update with history" do + setup do + user = insert(:user) + {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"}) + {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"}) + {:ok, external_rep} = Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data) + %{external_rep: external_rep} + end + + test "edited note", %{external_rep: external_rep} do + {:ok, _validate_res, meta} = ObjectValidator.validate(external_rep, []) + + assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} = + meta[:object_data] + end + + test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do + external_rep = put_in(external_rep, ["object", "formerRepresentations"], %{}) + + assert {:error, _} = ObjectValidator.validate(external_rep, []) + end + + test "edited note, badly-formed history item", %{external_rep: external_rep} do + history_item = + Enum.at(external_rep["object"]["formerRepresentations"]["orderedItems"], 0) + |> Map.put("type", "Foo") + + external_rep = + put_in( + external_rep, + ["object", "formerRepresentations", "orderedItems"], + [history_item] + ) + + assert {:error, _} = ObjectValidator.validate(external_rep, []) + end + end end -- 2.43.0 From 9b6cc76be0e7918192ec65a227afa81abe382b1a Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Wed, 6 Jul 2022 01:19:53 -0400 Subject: [PATCH 37/51] Fix cannot get full history on object fetch --- .../web/activity_pub/object_validator.ex | 13 ++- test/pleroma/object/fetcher_test.exs | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 1d4bcc863..cb0cc9ed7 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -86,8 +86,8 @@ def validate( meta ) when objtype in ~w[Question Answer Audio Video Event Article Note Page] do - with {:ok, object_data} <- cast_and_apply(object), - meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), + with {:ok, object_data} <- cast_and_apply_and_stringify_with_history(object), + meta = Keyword.put(meta, :object_data, object_data), {:ok, create_activity} <- create_activity |> CreateGenericValidator.cast_and_validate(meta) @@ -194,6 +194,15 @@ def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do def validate(o, m), do: {:error, {:validator_not_set, {o, m}}} + def cast_and_apply_and_stringify_with_history(object) do + do_separate_with_history(object, fn object -> + with {:ok, object_data} <- cast_and_apply(object), + object_data <- object_data |> stringify_keys() do + {:ok, object_data} + end + end) + end + def cast_and_apply(%{"type" => "Question"} = object) do QuestionValidator.cast_and_apply(object) end diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 585be0376..c321032ad 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -456,4 +456,84 @@ test "it adds to formerRepresentations if the remote does not have one and the o } = refetched.data end end + + describe "fetch with history" do + setup do + object2 = %{ + "id" => "https://mastodon.social/2", + "actor" => "https://mastodon.social/users/emelie", + "attributedTo" => "https://mastodon.social/users/emelie", + "type" => "Note", + "content" => "test 2", + "bcc" => [], + "bto" => [], + "cc" => ["https://mastodon.social/users/emelie/followers"], + "to" => [], + "summary" => "", + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [ + %{ + "type" => "Note", + "content" => "orig 2", + "actor" => "https://mastodon.social/users/emelie", + "attributedTo" => "https://mastodon.social/users/emelie", + "bcc" => [], + "bto" => [], + "cc" => ["https://mastodon.social/users/emelie/followers"], + "to" => [], + "summary" => "" + } + ], + "totalItems" => 1 + } + } + + mock(fn + %{ + method: :get, + url: "https://mastodon.social/2" + } -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: Jason.encode!(object2) + } + + %{ + method: :get, + url: "https://mastodon.social/users/emelie/collections/featured" + } -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => "https://mastodon.social/users/emelie/collections/featured", + "type" => "OrderedCollection", + "actor" => "https://mastodon.social/users/emelie", + "attributedTo" => "https://mastodon.social/users/emelie", + "orderedItems" => [], + "totalItems" => 0 + }) + } + + env -> + apply(HttpRequestMock, :request, [env]) + end) + + %{object2: object2} + end + + test "it gets history", %{object2: object2} do + {:ok, object} = Fetcher.fetch_object_from_id(object2["id"]) + + assert %{ + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [%{}] + } + } = object.data + end + end end -- 2.43.0 From 9e88fbcdb132e97c230edabab81d257765536c07 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Thu, 7 Jul 2022 15:11:29 -0400 Subject: [PATCH 38/51] Guard against outdated Updates It is possible for an earlier Update to be received by us later. For this, we now (1) only allows Updates to poll counts if there is no updated field, or the updated field is the same as the last updated date or creation date; (2) does not allow updating anything if the updated field is older than the last updated date or creation date; (3) allows updating updatable fields otherwise (normal updates); (4) if only the updated field is changed, it does not create a new history item on its own. --- lib/pleroma/object/updater.ex | 65 ++++++++++--- .../web/activity_pub/side_effects_test.exs | 96 ++++++++++++++++++- test/pleroma/web/common_api_test.exs | 2 +- 3 files changed, 145 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex index 0b21f6c99..3d34c3f27 100644 --- a/lib/pleroma/object/updater.ex +++ b/lib/pleroma/object/updater.ex @@ -10,7 +10,10 @@ def update_content_fields(orig_object_data, updated_object) do |> 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) + updated = + updated or + (field != "updated" and + Map.get(updated_object, field) != Map.get(orig_object_data, field)) data = if Map.has_key?(updated_object, field) do @@ -136,21 +139,57 @@ def make_update_object_data(original_data, new_data, date) do # 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) + update_is_reasonable = + with {_, updated} when not is_nil(updated) <- {:cur_updated, new_data["updated"]}, + {_, {:ok, updated_time, _}} <- {:cur_updated, DateTime.from_iso8601(updated)}, + {_, last_updated} when not is_nil(last_updated) <- + {:last_updated, original_data["updated"] || original_data["published"]}, + {_, {:ok, last_updated_time, _}} <- + {:last_updated, DateTime.from_iso8601(last_updated)}, + :gt <- DateTime.compare(updated_time, last_updated_time) do + :update_everything + else + # only allow poll updates + {:cur_updated, _} -> :no_content_update + :eq -> :no_content_update + # allow all updates + {:last_updated, _} -> :update_everything + # allow no updates + _ -> false + end - %{updated_object: updated_data, used_history_in_new_object?: used_history_in_new_object?} = - updated_data - |> maybe_update_history(original_data, - updated: updated, - use_history_in_new_object?: true, - new_data: new_data - ) + %{ + updated_object: updated_data, + used_history_in_new_object?: used_history_in_new_object?, + updated: updated + } = + if update_is_reasonable == :update_everything do + %{data: updated_data, updated: updated} = + original_data + |> update_content_fields(new_data) + + updated_data + |> maybe_update_history(original_data, + updated: updated, + use_history_in_new_object?: true, + new_data: new_data + ) + |> Map.put(:updated, updated) + else + %{ + updated_object: original_data, + used_history_in_new_object?: false, + updated: false + } + end updated_data = - updated_data - |> maybe_update_poll(new_data) + if update_is_reasonable != false do + updated_data + |> maybe_update_poll(new_data) + else + updated_data + end %{ updated_data: updated_data, diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index d98fb38cf..7cea1f993 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -147,14 +147,19 @@ test "it uses a given changeset to update", %{user: user, update: update} do describe "update notes" do setup do + make_time = fn -> + Pleroma.Web.ActivityPub.Utils.make_date() + end + user = insert(:user) - note = insert(:note, user: user) + note = insert(:note, user: user, data: %{"published" => make_time.()}) _note_activity = insert(:note_activity, note: note) updated_note = note.data |> Map.put("summary", "edited summary") |> Map.put("content", "edited content") + |> Map.put("updated", make_time.()) {:ok, update_data, []} = Builder.update(user, updated_note) {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) @@ -175,8 +180,69 @@ test "it updates the note", %{ updated_note: updated_note } do {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) + updated_time = updated_note["updated"] + new_note = Pleroma.Object.get_by_id(object_id) - assert %{"summary" => "edited summary", "content" => "edited content"} = new_note.data + + assert %{ + "summary" => "edited summary", + "content" => "edited content", + "updated" => ^updated_time + } = new_note.data + end + + test "it rejects updates with no updated attribute in object", %{ + object_id: object_id, + update: update, + updated_note: updated_note + } do + old_note = Pleroma.Object.get_by_id(object_id) + updated_note = Map.drop(updated_note, ["updated"]) + {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) + new_note = Pleroma.Object.get_by_id(object_id) + assert old_note.data == new_note.data + end + + test "it rejects updates with updated attribute older than what we have in the original object", + %{ + object_id: object_id, + update: update, + updated_note: updated_note + } do + old_note = Pleroma.Object.get_by_id(object_id) + {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"]) + + updated_note = + Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, -10))) + + {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) + new_note = Pleroma.Object.get_by_id(object_id) + assert old_note.data == new_note.data + end + + test "it rejects updates with updated attribute older than the last Update", %{ + object_id: object_id, + update: update, + updated_note: updated_note + } do + old_note = Pleroma.Object.get_by_id(object_id) + {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"]) + + updated_note = + Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, +10))) + + {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) + + old_note = Pleroma.Object.get_by_id(object_id) + {:ok, update_time, _} = DateTime.from_iso8601(old_note.data["updated"]) + + updated_note = + Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(update_time, -5))) + + {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) + + new_note = Pleroma.Object.get_by_id(object_id) + assert old_note.data == new_note.data end test "it updates using object_data", %{ @@ -220,6 +286,14 @@ test "it puts the original note at the front of formerRepresentations", %{ note.data |> Map.put("summary", "edited summary 2") |> Map.put("content", "edited content 2") + |> Map.put( + "updated", + first_edit["updated"] + |> DateTime.from_iso8601() + |> elem(1) + |> DateTime.add(10) + |> DateTime.to_iso8601() + ) {:ok, second_update_data, []} = Builder.update(user, second_updated_note) {:ok, update, _meta} = ActivityPub.persist(second_update_data, local: true) @@ -243,7 +317,18 @@ test "it does not prepend to formerRepresentations if no actual changes are made updated_note: updated_note } do {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) - %{data: _first_edit} = Pleroma.Object.get_by_id(object_id) + %{data: first_edit} = Pleroma.Object.get_by_id(object_id) + + updated_note = + updated_note + |> Map.put( + "updated", + first_edit["updated"] + |> DateTime.from_iso8601() + |> elem(1) + |> DateTime.add(10) + |> DateTime.to_iso8601() + ) {:ok, _, _} = SideEffects.handle(update, object_data: updated_note) %{data: new_note} = Pleroma.Object.get_by_id(object_id) @@ -275,7 +360,10 @@ test "allows updating choice count without generating edit history", %{ data["oneOf"] |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end) - updated_question = data |> Map.put("oneOf", new_choices) + updated_question = + data + |> Map.put("oneOf", new_choices) + |> Map.put("updated", Pleroma.Web.ActivityPub.Utils.make_date()) {:ok, update_data, []} = Builder.update(user, updated_question) {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 242d71756..63d43f2c2 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1368,7 +1368,7 @@ test "updates a post with emoji and federate properly" do clear_config([:instance, :federating], true) with_mock Pleroma.Web.Federator, - publish: fn p -> nil end do + publish: fn _p -> nil end do {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"}) assert updated.data["object"]["content"] == "updated 2 :#{emoji2}:" -- 2.43.0 From 7f3e156840793f97dbda277359e77589ad4c9500 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Thu, 7 Jul 2022 15:22:04 -0400 Subject: [PATCH 39/51] Test that Question updates are viable --- .../web/activity_pub/side_effects_test.exs | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index 7cea1f993..b2f0805d6 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -346,7 +346,12 @@ test "it does not prepend to formerRepresentations if no actual changes are made describe "update questions" do setup do user = insert(:user) - question = insert(:question, user: user) + + question = + insert(:question, + user: user, + data: %{"published" => Pleroma.Web.ActivityPub.Utils.make_date()} + ) %{user: user, data: question.data, id: question.id} end @@ -377,6 +382,59 @@ test "allows updating choice count without generating edit history", %{ refute Map.has_key?(new_question, "formerRepresentations") end + + test "allows updating choice count without updated field", %{ + user: user, + data: data, + id: id + } do + new_choices = + data["oneOf"] + |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end) + + updated_question = + data + |> Map.put("oneOf", new_choices) + + {:ok, update_data, []} = Builder.update(user, updated_question) + {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) + + {:ok, _, _} = SideEffects.handle(update, object_data: updated_question) + + %{data: new_question} = Pleroma.Object.get_by_id(id) + + assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] = + new_question["oneOf"] + + refute Map.has_key?(new_question, "formerRepresentations") + end + + test "allows updating choice count with updated field same as the creation date", %{ + user: user, + data: data, + id: id + } do + new_choices = + data["oneOf"] + |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end) + + updated_question = + data + |> Map.put("oneOf", new_choices) + |> Map.put("updated", data["published"]) + + {:ok, update_data, []} = Builder.update(user, updated_question) + {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) + + {:ok, _, _} = SideEffects.handle(update, object_data: updated_question) + + %{data: new_question} = Pleroma.Object.get_by_id(id) + + assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] = + new_question["oneOf"] + + refute Map.has_key?(new_question, "formerRepresentations") + end end describe "EmojiReact objects" do -- 2.43.0 From 0e06b59a209bde729d0535b7079df64aec510955 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 9 Jul 2022 18:00:42 -0400 Subject: [PATCH 40/51] Fix remote emoji in subject disappearing after edits --- lib/pleroma/web/common_api.ex | 9 +++++- test/pleroma/web/common_api_test.exs | 42 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index e3297ca32..f1f51acf5 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -360,7 +360,14 @@ def update(user, orig_activity, changes) do defp make_update_data(user, orig_object, changes) do kept_params = %{ - visibility: Visibility.get_visibility(orig_object) + visibility: Visibility.get_visibility(orig_object), + in_reply_to_id: + with replied_id when is_binary(replied_id) <- orig_object.data["inReplyTo"], + %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(replied_id) do + activity_id + else + _ -> nil + end } params = Map.merge(changes, kept_params) diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 63d43f2c2..77637ae62 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1377,5 +1377,47 @@ test "updates a post with emoji and federate properly" do assert called(Pleroma.Web.Federator.publish(updated)) end end + + test "editing a post that copied a remote title with remote emoji should keep that emoji" do + remote_emoji_uri = "https://remote.org/emoji.png" + + note = + insert( + :note, + data: %{ + "summary" => ":remoteemoji:", + "emoji" => %{ + "remoteemoji" => remote_emoji_uri + }, + "tag" => [ + %{ + "type" => "Emoji", + "name" => "remoteemoji", + "icon" => %{"url" => remote_emoji_uri} + } + ] + } + ) + + note_activity = insert(:note_activity, note: note) + + user = insert(:user) + + {:ok, reply} = + CommonAPI.post(user, %{ + status: "reply", + spoiler_text: ":remoteemoji:", + in_reply_to_id: note_activity.id + }) + + assert reply.object.data["emoji"]["remoteemoji"] == remote_emoji_uri + + {:ok, edit} = + CommonAPI.update(user, reply, %{status: "reply mew mew", spoiler_text: ":remoteemoji:"}) + + edited_note = Pleroma.Object.normalize(edit) + + assert edited_note.data["emoji"]["remoteemoji"] == remote_emoji_uri + end end end -- 2.43.0 From 593df1413ce1c273a2d354ddd8d6b3f45ccaba3d Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 23 Jul 2022 15:56:36 -0400 Subject: [PATCH 41/51] Make MRF Keyword history-aware --- lib/pleroma/object/updater.ex | 40 ++++++ .../web/activity_pub/mrf/keyword_policy.ex | 55 ++++++-- .../activity_pub/mrf/keyword_policy_test.exs | 131 ++++++++++++++++++ test/pleroma/web/common_api_test.exs | 17 +++ 4 files changed, 231 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex index 3d34c3f27..6381320bd 100644 --- a/lib/pleroma/object/updater.ex +++ b/lib/pleroma/object/updater.ex @@ -197,4 +197,44 @@ def make_new_object_data_from_update_object(original_data, new_data) do used_history_in_new_object?: used_history_in_new_object? } end + + defp for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do + new_items = + Enum.map(items, fun) + |> Enum.reduce_while( + {:ok, []}, + fn + {:ok, item}, {:ok, acc} -> {:cont, {:ok, acc ++ [item]}} + e, _acc -> {:halt, e} + end + ) + + case new_items do + {:ok, items} -> {:ok, Map.put(history, "orderedItems", items)} + e -> e + end + end + + defp for_each_history_item(history, _, _) do + {:ok, history} + end + + def do_with_history(object, fun) do + with history <- object["formerRepresentations"], + object <- Map.drop(object, ["formerRepresentations"]), + {_, {:ok, object}} <- {:main_body, fun.(object)}, + {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do + object = + if history do + Map.put(object, "formerRepresentations", history) + else + object + end + + {:ok, object} + else + {:main_body, e} -> e + {:history_items, e} -> e + end + end end diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index 1383fa757..7c921fc76 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -27,24 +27,46 @@ defp object_payload(%{} = object) do end defp check_reject(%{"object" => %{} = object} = message) do - payload = object_payload(object) + with {:ok, _new_object} <- + Pleroma.Object.Updater.do_with_history(object, fn object -> + payload = object_payload(object) - if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern -> - string_matches?(payload, pattern) - end) do - {:reject, "[KeywordPolicy] Matches with rejected keyword"} - else + if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern -> + string_matches?(payload, pattern) + end) do + {:reject, "[KeywordPolicy] Matches with rejected keyword"} + else + {:ok, message} + end + end) do {:ok, message} + else + e -> e end end - defp check_ftl_removal(%{"to" => to, "object" => %{} = object} = message) do - payload = object_payload(object) + defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = message) do + check_keyword = fn object -> + payload = object_payload(object) - if Pleroma.Constants.as_public() in to and - Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern -> + if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern -> string_matches?(payload, pattern) end) do + {:should_delist, nil} + else + {:ok, %{}} + end + end + + should_delist? = fn object -> + with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do + false + else + _ -> true + end + end + + if Pleroma.Constants.as_public() in to and should_delist?.(object) do to = List.delete(to, Pleroma.Constants.as_public()) cc = [Pleroma.Constants.as_public() | message["cc"] || []] @@ -59,8 +81,12 @@ defp check_ftl_removal(%{"to" => to, "object" => %{} = object} = message) do end end + defp check_ftl_removal(message) do + {:ok, message} + end + defp check_replace(%{"object" => %{} = object} = message) do - object = + replace_kw = fn object -> ["content", "name", "summary"] |> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end) |> Enum.reduce(object, fn field, object -> @@ -73,6 +99,10 @@ defp check_replace(%{"object" => %{} = object} = message) do Map.put(object, field, data) end) + |> (fn object -> {:ok, object} end).() + end + + {:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw) message = Map.put(message, "object", object) @@ -80,7 +110,8 @@ defp check_replace(%{"object" => %{} = object} = message) do end @impl true - def filter(%{"type" => "Create", "object" => %{"content" => _content}} = message) do + def filter(%{"type" => type, "object" => %{"content" => _content}} = message) + when type in ["Create", "Update"] do with {:ok, message} <- check_reject(message), {:ok, message} <- check_ftl_removal(message), {:ok, message} <- check_replace(message) do diff --git a/test/pleroma/web/activity_pub/mrf/keyword_policy_test.exs b/test/pleroma/web/activity_pub/mrf/keyword_policy_test.exs index 8af4c5efa..9bc8c8355 100644 --- a/test/pleroma/web/activity_pub/mrf/keyword_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/keyword_policy_test.exs @@ -79,6 +79,54 @@ test "rejects if regex matches in summary" do KeywordPolicy.filter(message) end) end + + test "rejects if string matches in history" do + clear_config([:mrf_keyword, :reject], ["pun"]) + + message = %{ + "type" => "Create", + "object" => %{ + "content" => "just a daily reminder that compLAINer is a good", + "summary" => "", + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [ + %{ + "content" => "just a daily reminder that compLAINer is a good pun", + "summary" => "" + } + ] + } + } + } + + assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} = + KeywordPolicy.filter(message) + end + + test "rejects Updates" do + clear_config([:mrf_keyword, :reject], ["pun"]) + + message = %{ + "type" => "Update", + "object" => %{ + "content" => "just a daily reminder that compLAINer is a good", + "summary" => "", + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [ + %{ + "content" => "just a daily reminder that compLAINer is a good pun", + "summary" => "" + } + ] + } + } + } + + assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} = + KeywordPolicy.filter(message) + end end describe "delisting from ftl based on keywords" do @@ -157,6 +205,31 @@ test "delists if regex matches in summary" do not (["https://www.w3.org/ns/activitystreams#Public"] == result["to"]) end) end + + test "delists if string matches in history" do + clear_config([:mrf_keyword, :federated_timeline_removal], ["pun"]) + + message = %{ + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "just a daily reminder that compLAINer is a good", + "summary" => "", + "formerRepresentations" => %{ + "orderedItems" => [ + %{ + "content" => "just a daily reminder that compLAINer is a good pun", + "summary" => "" + } + ] + } + } + } + + {:ok, result} = KeywordPolicy.filter(message) + assert ["https://www.w3.org/ns/activitystreams#Public"] == result["cc"] + refute ["https://www.w3.org/ns/activitystreams#Public"] == result["to"] + end end describe "replacing keywords" do @@ -221,5 +294,63 @@ test "replaces keyword if regex matches in summary" do result == "ZFS is free software" end) end + + test "replaces keyword if string matches in history" do + clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}]) + + message = %{ + "type" => "Create", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "object" => %{ + "content" => "ZFS is opensource", + "summary" => "", + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [ + %{"content" => "ZFS is opensource mew mew", "summary" => ""} + ] + } + } + } + + {:ok, + %{ + "object" => %{ + "content" => "ZFS is free software", + "formerRepresentations" => %{ + "orderedItems" => [%{"content" => "ZFS is free software mew mew"}] + } + } + }} = KeywordPolicy.filter(message) + end + + test "replaces keyword in Updates" do + clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}]) + + message = %{ + "type" => "Update", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "object" => %{ + "content" => "ZFS is opensource", + "summary" => "", + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [ + %{"content" => "ZFS is opensource mew mew", "summary" => ""} + ] + } + } + } + + {:ok, + %{ + "object" => %{ + "content" => "ZFS is free software", + "formerRepresentations" => %{ + "orderedItems" => [%{"content" => "ZFS is free software mew mew"}] + } + } + }} = KeywordPolicy.filter(message) + end end end diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 77637ae62..2b7a34be2 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1419,5 +1419,22 @@ test "editing a post that copied a remote title with remote emoji should keep th assert edited_note.data["emoji"]["remoteemoji"] == remote_emoji_uri end + + test "respects MRF" do + user = insert(:user) + + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy]) + clear_config([:mrf_keyword, :replace], [{"updated", "mewmew"}]) + + {:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "updated 1"}) + assert Object.normalize(activity).data["summary"] == "mewmew 1" + + {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"}) + + updated_object = Object.normalize(updated) + assert updated_object.data["content"] == "mewmew 2" + assert Map.get(updated_object.data, "summary", "") == "" + assert Map.has_key?(updated_object.data, "updated") + end end end -- 2.43.0 From 088951b65a0a3b5159a5ba7483f671dca1a9dd05 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 23 Jul 2022 17:48:39 -0400 Subject: [PATCH 42/51] Make EnsureRePrepended history-aware --- lib/pleroma/object/updater.ex | 4 +- lib/pleroma/web/activity_pub/mrf.ex | 45 +++++++++++++++- .../activity_pub/mrf/ensure_re_prepended.ex | 6 ++- lib/pleroma/web/activity_pub/mrf/policy.ex | 3 +- .../mrf/ensure_re_prepended_test.exs | 51 ++++++++++++++++++- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex index 6381320bd..ab38d3ed2 100644 --- a/lib/pleroma/object/updater.ex +++ b/lib/pleroma/object/updater.ex @@ -198,7 +198,7 @@ def make_new_object_data_from_update_object(original_data, new_data) do } end - defp for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do + def for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do new_items = Enum.map(items, fun) |> Enum.reduce_while( @@ -215,7 +215,7 @@ defp for_each_history_item(%{"orderedItems" => items} = history, _object, fun) d end end - defp for_each_history_item(history, _, _) do + def for_each_history_item(history, _, _) do {:ok, history} end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 20bce0d5f..4df226e80 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -63,10 +63,53 @@ defmodule Pleroma.Web.ActivityPub.MRF do @required_description_keys [:key, :related_policy] + def filter_one(policy, message) do + should_plug_history? = + if function_exported?(policy, :history_awareness, 0) do + policy.history_awareness() + else + :manual + end + |> Kernel.==(:auto) + + if not should_plug_history? do + policy.filter(message) + else + main_result = policy.filter(message) + + with {_, {:ok, main_message}} <- {:main, main_result}, + {_, + %{ + "formerRepresentations" => %{ + "orderedItems" => [_ | _] + } + }} = {_, object} <- {:object, message["object"]}, + {_, {:ok, new_history}} <- + {:history, + Pleroma.Object.Updater.for_each_history_item( + object["formerRepresentations"], + object, + fn item -> + with {:ok, filtered} <- policy.filter(Map.put(message, "object", item)) do + {:ok, filtered["object"]} + else + e -> e + end + end + )} do + {:ok, put_in(main_message, ["object", "formerRepresentations"], new_history)} + else + {:main, _} -> main_result + {:object, _} -> main_result + {:history, e} -> e + end + end + end + def filter(policies, %{} = message) do policies |> Enum.reduce({:ok, message}, fn - policy, {:ok, message} -> policy.filter(message) + policy, {:ok, message} -> filter_one(policy, message) _, error -> error end) end diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex index fad8d873b..c438b8f70 100644 --- a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex +++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex @@ -10,6 +10,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do @reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless]) + def history_awareness, do: :auto + def filter_by_summary( %{data: %{"summary" => parent_summary}} = _in_reply_to, %{"summary" => child_summary} = child @@ -27,8 +29,8 @@ def filter_by_summary( def filter_by_summary(_in_reply_to, child), do: child - def filter(%{"type" => "Create", "object" => child_object} = object) - when is_map(child_object) do + def filter(%{"type" => type, "object" => child_object} = object) + when type in ["Create", "Update"] and is_map(child_object) do child = child_object["inReplyTo"] |> Object.normalize(fetch: false) diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex index a4a960c01..75209b2db 100644 --- a/lib/pleroma/web/activity_pub/mrf/policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/policy.ex @@ -12,5 +12,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.Policy do label: String.t(), description: String.t() } - @optional_callbacks config_description: 0 + @callback history_awareness() :: :auto | :manual + @optional_callbacks config_description: 0, history_awareness: 0 end diff --git a/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs b/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs index 89439b65f..e174a83f7 100644 --- a/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs +++ b/test/pleroma/web/activity_pub/mrf/ensure_re_prepended_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do alias Pleroma.Activity alias Pleroma.Object + alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.MRF.EnsureRePrepended describe "rewrites summary" do @@ -35,10 +36,58 @@ test "it adds `re:` to summary object when child summary containts re-subject of assert {:ok, res} = EnsureRePrepended.filter(message) assert res["object"]["summary"] == "re: object-summary" end + + test "it adds `re:` to history" do + message = %{ + "type" => "Create", + "object" => %{ + "summary" => "object-summary", + "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}, + "formerRepresentations" => %{ + "orderedItems" => [ + %{ + "summary" => "object-summary", + "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}} + } + ] + } + } + } + + assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message) + assert res["object"]["summary"] == "re: object-summary" + + assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] == + "re: object-summary" + end + + test "it accepts Updates" do + message = %{ + "type" => "Update", + "object" => %{ + "summary" => "object-summary", + "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}, + "formerRepresentations" => %{ + "orderedItems" => [ + %{ + "summary" => "object-summary", + "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}} + } + ] + } + } + } + + assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message) + assert res["object"]["summary"] == "re: object-summary" + + assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] == + "re: object-summary" + end end describe "skip filter" do - test "it skip if type isn't 'Create'" do + test "it skip if type isn't 'Create' or 'Update'" do message = %{ "type" => "Annotation", "object" => %{"summary" => "object-summary"} -- 2.43.0 From 7032224a1021d83aa8df082b76b2843090e03c25 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 23 Jul 2022 22:34:03 -0400 Subject: [PATCH 43/51] Make MediaProxyWarmingPolicy history-aware --- .../mrf/media_proxy_warming_policy.ex | 9 ++-- .../mrf/media_proxy_warming_policy_test.exs | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index f60a76adf..72455afd0 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -15,6 +15,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do recv_timeout: 10_000 ] + @impl true + def history_awareness, do: :auto + defp prefetch(url) do # Fetching only proxiable resources if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do @@ -53,10 +56,8 @@ defp preload(%{"object" => %{"attachment" => attachments}} = _message) do end @impl true - def filter( - %{"type" => "Create", "object" => %{"attachment" => attachments} = _object} = message - ) - when is_list(attachments) and length(attachments) > 0 do + def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = message) + when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do preload(message) {:ok, message} diff --git a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs index 96e715d0d..3268e2321 100644 --- a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do use Pleroma.Tests.Helpers alias Pleroma.HTTP + alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy import Mock @@ -22,6 +23,25 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do } } + @message_with_history %{ + "type" => "Create", + "object" => %{ + "type" => "Note", + "content" => "content", + "formerRepresentations" => %{ + "orderedItems" => [ + %{ + "type" => "Note", + "content" => "content", + "attachment" => [ + %{"url" => [%{"href" => "http://example.com/image.jpg"}]} + ] + } + ] + } + } + } + setup do: clear_config([:media_proxy, :enabled], true) test "it prefetches media proxy URIs" do @@ -50,4 +70,28 @@ test "it does nothing when no attachments are present" do refute called(HTTP.get(:_, :_, :_)) end end + + test "history-aware" do + Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} -> + {:ok, %Tesla.Env{status: 200, body: ""}} + end) + + with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do + MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history) + + assert called(HTTP.get(:_, :_, :_)) + end + end + + test "works with Updates" do + Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} -> + {:ok, %Tesla.Env{status: 200, body: ""}} + end) + + with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do + MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update")) + + assert called(HTTP.get(:_, :_, :_)) + end + end end -- 2.43.0 From 037cb50051d120e679cf5cfdead0af78d9d534f5 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 23 Jul 2022 22:41:04 -0400 Subject: [PATCH 44/51] Make NoPlaceholderTextPolicy history-aware --- .../mrf/no_placeholder_text_policy.ex | 7 +++- .../mrf/no_placeholder_text_policy_test.exs | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex index 90272766c..f25bb4efd 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex @@ -6,14 +6,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do @moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + @impl true + def history_awareness, do: :auto + @impl true def filter( %{ - "type" => "Create", + "type" => type, "object" => %{"content" => content, "attachment" => _} = _child_object } = object ) - when content in [".", "

.

"] do + when type in ["Create", "Update"] and content in [".", "

.

"] do {:ok, put_in(object, ["object", "content"], "")} end diff --git a/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs b/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs index 81a6e0f50..59456d790 100644 --- a/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/no_placeholder_text_policy_test.exs @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do use Pleroma.DataCase, async: true + alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy test "it clears content object" do @@ -20,6 +21,46 @@ test "it clears content object" do assert res["object"]["content"] == "" end + test "history-aware" do + message = %{ + "type" => "Create", + "object" => %{ + "content" => ".", + "attachment" => "image", + "formerRepresentations" => %{ + "orderedItems" => [%{"content" => ".", "attachment" => "image"}] + } + } + } + + assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message) + + assert %{ + "content" => "", + "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]} + } = res["object"] + end + + test "works with Updates" do + message = %{ + "type" => "Update", + "object" => %{ + "content" => ".", + "attachment" => "image", + "formerRepresentations" => %{ + "orderedItems" => [%{"content" => ".", "attachment" => "image"}] + } + } + } + + assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message) + + assert %{ + "content" => "", + "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]} + } = res["object"] + end + @messages [ %{ "type" => "Create", -- 2.43.0 From 4c9b16c654c9655e01af1d3e66a3b96da08c5f7f Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 23 Jul 2022 22:50:38 -0400 Subject: [PATCH 45/51] Make NormalizeMarkup history-aware --- .../web/activity_pub/mrf/normalize_markup.ex | 6 +- .../mrf/normalize_markup_test.exs | 59 +++++++++++++++---- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex index 0d7146738..151c6ed20 100644 --- a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex +++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex @@ -9,7 +9,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true - def filter(%{"type" => "Create", "object" => child_object} = object) do + def history_awareness, do: :auto + + @impl true + def filter(%{"type" => type, "object" => child_object} = object) + when type in ["Create", "Update"] do scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy]) content = diff --git a/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs b/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs index edc330b6c..52a23fdca 100644 --- a/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs +++ b/test/pleroma/web/activity_pub/mrf/normalize_markup_test.exs @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do use Pleroma.DataCase, async: true + alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.MRF.NormalizeMarkup @html_sample """ @@ -16,24 +17,58 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do """ - test "it filter html tags" do - expected = """ - this is in bold -

this is a paragraph

- this is a linebreak
- this is a link with allowed "rel" attribute: - this is a link with not allowed "rel" attribute: example.com - this is an image:
- alert('hacked') - """ + @expected """ + this is in bold +

this is a paragraph

+ this is a linebreak
+ this is a link with allowed "rel" attribute: + this is a link with not allowed "rel" attribute: example.com + this is an image:
+ alert('hacked') + """ + test "it filter html tags" do message = %{"type" => "Create", "object" => %{"content" => @html_sample}} assert {:ok, res} = NormalizeMarkup.filter(message) - assert res["object"]["content"] == expected + assert res["object"]["content"] == @expected end - test "it skips filter if type isn't `Create`" do + test "history-aware" do + message = %{ + "type" => "Create", + "object" => %{ + "content" => @html_sample, + "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]} + } + } + + assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message) + + assert %{ + "content" => @expected, + "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]} + } = res["object"] + end + + test "works with Updates" do + message = %{ + "type" => "Update", + "object" => %{ + "content" => @html_sample, + "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]} + } + } + + assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message) + + assert %{ + "content" => @expected, + "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]} + } = res["object"] + end + + test "it skips filter if type isn't `Create` or `Update`" do message = %{"type" => "Note", "object" => %{}} assert {:ok, res} = NormalizeMarkup.filter(message) -- 2.43.0 From 6f44858f657029e43d182b2b5b16b37da17d74b6 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 24 Jul 2022 00:02:39 -0400 Subject: [PATCH 46/51] Make HashtagPolicy history-aware --- .../web/activity_pub/mrf/hashtag_policy.ex | 47 ++++++++++--- .../activity_pub/mrf/hashtag_policy_test.exs | 70 +++++++++++++++++++ 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex index b7db4fa3d..b5ad8b5b4 100644 --- a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex @@ -16,6 +16,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do @behaviour Pleroma.Web.ActivityPub.MRF.Policy + @impl true + def history_awareness, do: :manual + defp check_reject(message, hashtags) do if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do {:reject, "[HashtagPolicy] Matches with rejected keyword"} @@ -47,22 +50,46 @@ defp check_ftl_removal(%{"to" => to} = message, hashtags) do defp check_ftl_removal(message, _hashtags), do: {:ok, message} - defp check_sensitive(message, hashtags) do - if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do - {:ok, Kernel.put_in(message, ["object", "sensitive"], true)} - else - {:ok, message} - end + defp check_sensitive(message) do + {:ok, new_object} = + Object.Updater.do_with_history(message["object"], fn object -> + hashtags = Object.hashtags(%Object{data: object}) + + if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do + {:ok, Map.put(object, "sensitive", true)} + else + {:ok, object} + end + end) + + {:ok, Map.put(message, "object", new_object)} end @impl true - def filter(%{"type" => "Create", "object" => object} = message) do - hashtags = Object.hashtags(%Object{data: object}) + def filter(%{"type" => type, "object" => object} = message) when type in ["Create", "Update"] do + history_items = + with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do + items + else + _ -> [] + end + + historical_hashtags = + Enum.reduce(history_items, [], fn item, acc -> + acc ++ Object.hashtags(%Object{data: item}) + end) + + hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags if hashtags != [] do with {:ok, message} <- check_reject(message, hashtags), - {:ok, message} <- check_ftl_removal(message, hashtags), - {:ok, message} <- check_sensitive(message, hashtags) do + {:ok, message} <- + (if "type" == "Create" do + check_ftl_removal(message, hashtags) + else + {:ok, message} + end), + {:ok, message} <- check_sensitive(message) do {:ok, message} end else diff --git a/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs b/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs index 13415bb79..b88090869 100644 --- a/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs @@ -20,6 +20,76 @@ test "it sets the sensitive property with relevant hashtags" do assert modified["object"]["sensitive"] end + test "it is history-aware" do + activity = %{ + "type" => "Create", + "object" => %{ + "content" => "hey", + "tag" => [] + } + } + + activity_data = + activity + |> put_in( + ["object", "formerRepresentations"], + %{ + "type" => "OrderedCollection", + "orderedItems" => [ + Map.put( + activity["object"], + "tag", + [%{"type" => "Hashtag", "name" => "#nsfw"}] + ) + ] + } + ) + + {:ok, modified} = + Pleroma.Web.ActivityPub.MRF.filter_one( + Pleroma.Web.ActivityPub.MRF.HashtagPolicy, + activity_data + ) + + refute modified["object"]["sensitive"] + assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"] + end + + test "it works with Update" do + activity = %{ + "type" => "Update", + "object" => %{ + "content" => "hey", + "tag" => [] + } + } + + activity_data = + activity + |> put_in( + ["object", "formerRepresentations"], + %{ + "type" => "OrderedCollection", + "orderedItems" => [ + Map.put( + activity["object"], + "tag", + [%{"type" => "Hashtag", "name" => "#nsfw"}] + ) + ] + } + ) + + {:ok, modified} = + Pleroma.Web.ActivityPub.MRF.filter_one( + Pleroma.Web.ActivityPub.MRF.HashtagPolicy, + activity_data + ) + + refute modified["object"]["sensitive"] + assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"] + end + test "it doesn't sets the sensitive property with irrelevant hashtags" do user = insert(:user) -- 2.43.0 From 0583979f89ac90adb915fce404420216f61dc349 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sun, 24 Jul 2022 00:18:09 -0400 Subject: [PATCH 47/51] Make AntiLinkSpamPolicy history-aware --- .../activity_pub/mrf/anti_link_spam_policy.ex | 3 ++ .../mrf/anti_link_spam_policy_test.exs | 33 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index cdf17fd28..ba7c8400b 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -9,6 +9,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do require Logger + @impl true + def history_awareness, do: :auto + # has the user successfully posted before? defp old_user?(%User{} = u) do u.note_count > 0 || u.follower_count > 0 diff --git a/test/pleroma/web/activity_pub/mrf/anti_link_spam_policy_test.exs b/test/pleroma/web/activity_pub/mrf/anti_link_spam_policy_test.exs index 5b990451c..c3ee03a05 100644 --- a/test/pleroma/web/activity_pub/mrf/anti_link_spam_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/anti_link_spam_policy_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do import Pleroma.Factory import ExUnit.CaptureLog + alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy @linkless_message %{ @@ -49,11 +50,23 @@ test "it disallows posts with links" do assert user.note_count == 0 - message = - @linkful_message - |> Map.put("actor", user.ap_id) + message = %{ + "type" => "Create", + "actor" => user.ap_id, + "object" => %{ + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [ + %{ + "content" => "hi world!" + } + ] + }, + "content" => "mew" + } + } - {:reject, _} = AntiLinkSpamPolicy.filter(message) + {:reject, _} = MRF.filter_one(AntiLinkSpamPolicy, message) end test "it allows posts with links for local users" do @@ -67,6 +80,18 @@ test "it allows posts with links for local users" do {:ok, _message} = AntiLinkSpamPolicy.filter(message) end + + test "it disallows posts with links in history" do + user = insert(:user, local: false) + + assert user.note_count == 0 + + message = + @linkful_message + |> Map.put("actor", user.ap_id) + + {:reject, _} = AntiLinkSpamPolicy.filter(message) + end end describe "with old user" do -- 2.43.0 From ff7fbf70c0c13f8d8aa41d647023566e5ad987f5 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Sat, 23 Jul 2022 23:24:25 -0400 Subject: [PATCH 48/51] Make NoEmptyPolicy work with Update --- .../web/activity_pub/mrf/no_empty_policy.ex | 29 +++++++------------ lib/pleroma/web/common_api/activity_draft.ex | 1 - .../activity_pub/mrf/no_empty_policy_test.exs | 23 +++++++++++++++ .../controllers/status_controller_test.exs | 2 +- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex index b2939a4d6..19637a38d 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do @impl true def filter(%{"actor" => actor} = object) do with true <- is_local?(actor), + true <- is_eligible_type?(object), true <- is_note?(object), false <- has_attachment?(object), true <- only_mentions?(object) do @@ -32,7 +33,6 @@ defp is_local?(actor) do end defp has_attachment?(%{ - "type" => "Create", "object" => %{"type" => "Note", "attachment" => attachments} }) when length(attachments) > 0, @@ -40,23 +40,13 @@ defp has_attachment?(%{ defp has_attachment?(_), do: false - defp only_mentions?(%{"type" => "Create", "object" => %{"type" => "Note", "source" => source}}) - when is_binary(source) do - non_mentions = - source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length + defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do + source = + case source do + %{"content" => text} -> text + _ -> source + end - if non_mentions > 0 do - false - else - true - end - end - - defp only_mentions?(%{ - "type" => "Create", - "object" => %{"type" => "Note", "source" => %{"content" => source}} - }) - when is_binary(source) do non_mentions = source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length @@ -69,9 +59,12 @@ defp only_mentions?(%{ defp only_mentions?(_), do: false - defp is_note?(%{"type" => "Create", "object" => %{"type" => "Note"}}), do: true + defp is_note?(%{"object" => %{"type" => "Note"}}), do: true defp is_note?(_), do: false + defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true + defp is_eligible_type?(_), do: false + @impl true def describe, do: {:ok, %{}} end diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index a672fc2e5..b3a49de44 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -221,7 +221,6 @@ defp object(draft) do |> Map.put("emoji", emoji) |> Map.put("source", %{ "content" => draft.status, - "mediaType" => draft.params[:content_type] "mediaType" => Utils.get_content_type(draft.params[:content_type]) }) |> Map.put("generator", draft.params[:generator]) diff --git a/test/pleroma/web/activity_pub/mrf/no_empty_policy_test.exs b/test/pleroma/web/activity_pub/mrf/no_empty_policy_test.exs index 2c6fcbc41..d9e05d313 100644 --- a/test/pleroma/web/activity_pub/mrf/no_empty_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/no_empty_policy_test.exs @@ -153,4 +153,27 @@ test "Notes with no content are denied" do assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"} end + + test "works with Update" do + message = %{ + "actor" => "http://localhost:4001/users/testuser", + "cc" => ["http://localhost:4001/users/testuser/followers"], + "object" => %{ + "actor" => "http://localhost:4001/users/testuser", + "attachment" => [], + "cc" => ["http://localhost:4001/users/testuser/followers"], + "source" => "", + "to" => [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type" => "Note" + }, + "to" => [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type" => "Update" + } + + assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"} + end end 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 ba0691517..ea6ace69f 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2069,7 +2069,7 @@ test "posting a quote of a status that doesn't exist", %{conn: conn} do "quote_id" => "oops" }) |> json_response_and_validate_schema(422) - end + end end describe "get status history" do -- 2.43.0 From 6d7cad98f8fb7156235e50d633da53e1d9add3bf Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Tue, 6 Sep 2022 19:37:17 +0100 Subject: [PATCH 49/51] add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05cb69c40..e63cc1f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - support for reusing oauth tokens, and not requiring new authorizations - the ability to obfuscate domains in your MRF descriptions - automatic translation of statuses via DeepL or LibreTranslate +- ability to edit posts ### Changed - MFM parsing is now done on the backend by a modified version of ilja's parser -> https://akkoma.dev/AkkomaGang/mfm-parser -- 2.43.0 From c311a5e50709ca04c3c7d5523198415114ce03a5 Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Tue, 6 Sep 2022 19:55:52 +0100 Subject: [PATCH 50/51] update streamer tests --- lib/pleroma/web/streamer.ex | 7 +++++-- test/pleroma/web/streamer_test.exs | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index 77ca14a97..c03e7fc30 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -292,12 +292,15 @@ defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do 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) + anon_render = StreamerView.render("status_update.json", create_activity, topic) Registry.dispatch(@registry, topic, fn list -> Enum.each(list, fn {pid, auth?} -> if auth? do - send(pid, {:render_with_user, StreamerView, "status_update.json", create_activity}) + send( + pid, + {:render_with_user, StreamerView, "status_update.json", create_activity, topic} + ) else send(pid, {:text, anon_render}) end diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index edbdb1bb5..8e2ab5016 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -394,7 +394,8 @@ test "it streams edits in the 'user' stream", %{user: user, token: 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} + stream = "user:#{user.id}" + assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream} refute Streamer.filtered_by_user?(user, edited) end @@ -405,7 +406,8 @@ test "it streams own edits in the 'user' stream", %{user: user, token: oauth_tok {: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} + stream = "user:#{user.id}" + assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream} refute Streamer.filtered_by_user?(user, edited) end end -- 2.43.0 From 83746b001e205cf4526938e01ff78ad5eda2252b Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Tue, 2 Aug 2022 10:15:56 -0400 Subject: [PATCH 51/51] Use actor_types() to determine whether the Update is for user --- lib/pleroma/web/activity_pub/side_effects.ex | 5 +++-- test/pleroma/web/activity_pub/side_effects_test.exs | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 7995e2a4a..43b1b089b 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -160,8 +160,9 @@ def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, 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 + %{"type" => type} <- updated_object, + {_, is_user} <- {:is_user, type in Pleroma.Constants.actor_types()} do + if is_user do handle_update_user(object, meta) else handle_update_object(object, meta) diff --git a/test/pleroma/web/activity_pub/side_effects_test.exs b/test/pleroma/web/activity_pub/side_effects_test.exs index b2f0805d6..fa8171eab 100644 --- a/test/pleroma/web/activity_pub/side_effects_test.exs +++ b/test/pleroma/web/activity_pub/side_effects_test.exs @@ -123,7 +123,10 @@ test "it blocks but does not unfollow if the relevant setting is set", %{ describe "update users" do setup do user = insert(:user, local: false) - {:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"}) + + {:ok, update_data, []} = + Builder.update(user, %{"id" => user.ap_id, "type" => "Person", "name" => "new name!"}) + {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) %{user: user, update_data: update_data, update: update} -- 2.43.0