diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index a42c71d23..bbb95104f 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 5b25138a4..532047599 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -218,10 +218,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, %{ @@ -229,7 +235,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 894c0ceef..49054c320 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -25,6 +25,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.Streamer alias Pleroma.Workers.PollWorker + require Pleroma.Constants require Logger @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @@ -411,20 +412,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} -> @@ -502,6 +491,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 617aba460..c69307a4d 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -473,6 +473,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 @@ -578,6 +594,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, @@ -690,7 +760,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 1b95ee89c..e60c26053 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -402,6 +402,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 ea9e08aa8..fa86e9dc0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -223,7 +223,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 b502aaa03..af91fdf74 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1541,4 +1541,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 f27cf5048..d4ca2d618 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2051,7 +2051,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