commit: 2f4854493719e7ded3f6d02306bacfb995dce2d9 parent bd41d15100046cbc4dcbafb9e1d8d0c87bdefd21 Author: lain <lain@soykaf.club> Date: Tue, 23 Dec 2025 13:55:18 +0000 Merge branch 'akkoma-fixes-1014-1018' into 'develop' Status visibility checks for post interactions, stop leaking internal Activity representation (Akkoma PR 1014 and 1018) Closes #3383 See merge request pleroma/pleroma!4400Diffstat:
27 files changed, 1261 insertions(+), 73 deletions(-)diff --git a/changelog.d/ap-c2s-interaction-perms.fix b/changelog.d/ap-c2s-interaction-perms.fix@@ -0,0 +1 @@ +AP C2S: Reject interactions with statuses not visible to Actordiff --git a/changelog.d/mastoapi-interaction-perms.fix b/changelog.d/mastoapi-interaction-perms.fix@@ -0,0 +1 @@ +MastodonAPI: Reject interactions with statuses not visible to userdiff --git a/changelog.d/view-internals-leaks.fix b/changelog.d/view-internals-leaks.fix@@ -0,0 +1 @@ +ObjectView: Do not leak unsanitized internal representation of non-Create/non-Undo Activities on fetchesdiff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex@@ -21,7 +21,8 @@ defmodule Pleroma.Constants do "pleroma_internal", "generator", "rules", - "language" + "language", + "voters" ] )diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex@@ -126,7 +126,7 @@ defmodule Pleroma.Object do Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}") end - def normalize(_, options \\ [fetch: false, id_only: false]) + def normalize(_, options \\ [fetch: false]) # If we pass an Activity to Object.normalize(), we can try to use the preloaded object. # Use this whenever possible, especially when walking graphs in an O(N) loop! @@ -155,9 +155,6 @@ defmodule Pleroma.Object do def normalize(ap_id, options) when is_binary(ap_id) do cond do - Keyword.get(options, :id_only) -> - ap_id - Keyword.get(options, :fetch) -> case Fetcher.fetch_object_from_id(ap_id, options) do {:ok, object} -> objectdiff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex@@ -482,6 +482,42 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {:ok, activity} end + # We currently lack a Flag ObjectValidator since both CommonAPI and Transmogrifier + # both send it straight to ActivityPub.flag and C2S currently has to go through + # the normal pipeline which requires an ObjectValidator. + # TODO: Add a Flag Activity ObjectValidator + defp check_allowed_action(_, %{"type" => "Flag"}) do + {:error, "Flag activities aren't currently supported in C2S"} + end + + # It would respond with 201 and silently fail with: + # Could not decode featured collection at fetch #{user.ap_id} \ + # {:error, "Trying to fetch local resource"} + defp check_allowed_action(%{ap_id: ap_id}, %{"type" => "Update", "object" => %{"id" => ap_id}}), + do: {:error, "Updating profile is not currently supported in C2S"} + + defp check_allowed_action(_, activity), do: {:ok, activity} + + defp validate_visibility(%User{} = user, %{"type" => type, "object" => object} = activity) do + with {_, %Object{} = normalized_object} <- + {:normalize, Object.normalize(object, fetch: false)}, + {_, true} <- {:visibility, Visibility.visible_for_user?(normalized_object, user)} do + {:ok, activity} + else + {:normalize, _} -> + if type in ["Create", "Listen"] do + # Creating new object via C2S; user is local and authenticated + # via the :authenticate Plug pipeline. + {:ok, activity} + else + {:error, "No such object found"} + end + + {:visibility, _} -> + {:forbidden, "You can't interact with this object"} + end + end + def update_outbox( %{assigns: %{user: %User{nickname: nickname, ap_id: actor} = user}} = conn, %{"nickname" => nickname} = params @@ -493,6 +529,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> Map.put("actor", actor) with {:ok, params} <- fix_user_message(user, params), + {:ok, params} <- check_allowed_action(user, params), + {:ok, params} <- validate_visibility(user, params), {:ok, activity, _} <- Pipeline.common_pipeline(params, local: true), %Activity{data: activity_data} <- Activity.normalize(activity) do conndiff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex@@ -873,6 +873,27 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do {:ok, data} end + def prepare_outgoing(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data) + when objtype in Pleroma.Constants.actor_types() do + object = + object + |> maybe_fix_user_object() + |> strip_internal_fields() + + data = + data + |> Map.put("object", object) + |> strip_internal_fields() + |> Map.merge(Utils.make_json_ld_header(object)) + |> Map.delete("bcc") + + {:ok, data} + end + + def prepare_outgoing(%{"type" => "Update", "object" => %{}} = data) do + raise "Requested to serve an Update for non-updateable object type: #{inspect(data)}" + end + def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do object = object_iddiff --git a/lib/pleroma/web/activity_pub/views/object_view.ex b/lib/pleroma/web/activity_pub/views/object_view.ex@@ -15,26 +15,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectView do Map.merge(base, additional) end - def render("object.json", %{object: %Activity{data: %{"type" => activity_type}} = activity}) - when activity_type in ["Create", "Listen"] do - base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data) - object = Object.normalize(activity, fetch: false) - - additional = - Transmogrifier.prepare_object(activity.data) - |> Map.put("object", Transmogrifier.prepare_object(object.data)) - - Map.merge(base, additional) - end - def render("object.json", %{object: %Activity{} = activity}) do - base = Pleroma.Web.ActivityPub.Utils.make_json_ld_header(activity.data) - object_id = Object.normalize(activity, id_only: true) - - additional = - Transmogrifier.prepare_object(activity.data) - |> Map.put("object", object_id) - - Map.merge(base, additional) + {:ok, ap_data} = Transmogrifier.prepare_outgoing(activity.data) + ap_data end enddiff --git a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.Status @@ -35,7 +36,8 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do security: [%{"oAuth" => ["read:statuses"]}], operationId: "EmojiReactionController.index", responses: %{ - 200 => array_of_reactions_response() + 200 => array_of_reactions_response(), + 404 => Operation.response("Access denied", "application/json", ApiNotFoundError) } } end @@ -54,7 +56,8 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do operationId: "EmojiReactionController.create", responses: %{ 200 => Operation.response("Status", "application/json", Status), - 400 => Operation.response("Bad Request", "application/json", ApiError) + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiNotFoundError) } } enddiff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex@@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.ReportOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError alias Pleroma.Web.ApiSpec.Schemas.BooleanLike def open_api_operation(action) do @@ -24,7 +25,8 @@ defmodule Pleroma.Web.ApiSpec.ReportOperation do requestBody: Helpers.request_body("Parameters", create_request(), required: true), responses: %{ 200 => Operation.response("Report", "application/json", create_response()), - 400 => Operation.response("Report", "application/json", ApiError) + 400 => Operation.response("Report", "application/json", ApiError), + 404 => Operation.response("Report", "application/json", ApiNotFoundError) } } enddiff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex@@ -8,6 +8,7 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do alias Pleroma.Web.ApiSpec.AccountOperation alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError alias Pleroma.Web.ApiSpec.Schemas.Attachment alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.Emoji @@ -177,6 +178,7 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do parameters: [id_param()], responses: %{ 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError), 404 => Operation.response("Not Found", "application/json", ApiError) } } @@ -242,14 +244,19 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do "error" => "You have already pinned the maximum number of statuses" } }), - 404 => - Operation.response("Not found", "application/json", %Schema{ - allOf: [ApiError], - title: "Unprocessable Entity", - example: %{ - "error" => "Record not found" + 404 => Operation.response("Not found", "application/json", ApiNotFoundError), + 422 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Someone else's status cannot be unpinned" + } } - }) + ) } } end @@ -275,7 +282,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do } }), responses: %{ - 200 => status_response() + 200 => status_response(), + 404 => Operation.response("Not found", "application/json", ApiNotFoundError) } } end @@ -289,7 +297,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do operationId: "StatusController.unbookmark", parameters: [id_param()], responses: %{ - 200 => status_response() + 200 => status_response(), + 404 => Operation.response("Not found", "application/json", ApiNotFoundError) } } end @@ -324,7 +333,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do ], responses: %{ 200 => status_response(), - 400 => Operation.response("Error", "application/json", ApiError) + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Not found", "application/json", ApiNotFoundError) } } end @@ -340,7 +350,8 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do parameters: [id_param()], responses: %{ 200 => status_response(), - 400 => Operation.response("Error", "application/json", ApiError) + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiNotFoundError) } } enddiff --git a/lib/pleroma/web/api_spec/schemas/api_not_found_error.ex b/lib/pleroma/web/api_spec/schemas/api_not_found_error.ex@@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ApiNotFoundError do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Not Found", + description: "Response schema for 404 API errors", + type: :object, + properties: %{error: %Schema{type: :string}}, + example: %{ + "error" => "Record not found" + } + }) +enddiff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex@@ -269,6 +269,7 @@ defmodule Pleroma.Web.CommonAPI do defp favorite_helper(user, id) do with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)}, + {_, true} <- {:visibility_error, activity_visible_to_actor(object, user)}, {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, {_, {:ok, %Activity{} = activity, _meta}} <- {:common_pipeline, @@ -278,6 +279,9 @@ defmodule Pleroma.Web.CommonAPI do {:find_object, _} -> {:error, :not_found} + {:visibility_error, _} -> + {:error, :not_found} + {:common_pipeline, {:error, {:validate, {:error, changeset}}}} = e -> if {:object, {"already liked by this actor", []}} in changeset.errors do {:ok, :already_liked} @@ -296,6 +300,7 @@ defmodule Pleroma.Web.CommonAPI do with {_, %Activity{data: %{"type" => "Create"}} = activity} <- {:find_activity, Activity.get_by_id(id)}, %Object{} = note <- Object.normalize(activity, fetch: false), + {_, true} <- {:visibility_error, activity_visible_to_actor(note, user)}, %Activity{} = like <- Utils.get_existing_like(user.ap_id, note), {_, {:ok, _}} <- {:cancel_jobs, maybe_cancel_jobs(like)}, {:ok, undo, _} <- Builder.undo(user, like), @@ -303,6 +308,7 @@ defmodule Pleroma.Web.CommonAPI do {:ok, activity} else {:find_activity, _} -> {:error, :not_found} + {:visibility_error, _} -> {:error, :not_found} _ -> {:error, dgettext("errors", "Could not unfavorite")} end end @@ -311,11 +317,15 @@ defmodule Pleroma.Web.CommonAPI do {:ok, Activity.t()} | {:error, String.t()} def react_with_emoji(id, user, emoji) do with %Activity{} = activity <- Activity.get_by_id(id), + {_, true} <- {:visibility_error, activity_visible_to_actor(activity, user)}, object <- Object.normalize(activity, fetch: false), {:ok, emoji_react, _} <- Builder.emoji_react(user, object, emoji), {:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do {:ok, activity} else + {:visibility_error, _} -> + {:error, :not_found} + _ -> {:error, dgettext("errors", "Could not add reaction emoji")} end @@ -506,6 +516,7 @@ defmodule Pleroma.Web.CommonAPI do @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors() def pin(id, %User{} = user) do with %Activity{} = activity <- create_activity_by_id(id), + true <- activity_visible_to_actor(activity, user), true <- activity_belongs_to_actor(activity, user.ap_id), true <- object_type_is_allowed_for_pin(activity.object), true <- activity_is_public(activity), @@ -531,6 +542,14 @@ defmodule Pleroma.Web.CommonAPI do defp activity_belongs_to_actor(%{actor: actor}, actor), do: true defp activity_belongs_to_actor(_, _), do: {:error, :ownership_error} + defp activity_visible_to_actor(activity, %User{} = user) do + if Visibility.visible_for_user?(activity, user) do + true + else + {:error, :visibility_error} + end + end + defp object_type_is_allowed_for_pin(%{data: %{"type" => type}}) do with false <- type in ["Note", "Article", "Question"] do {:error, :not_allowed} @@ -539,13 +558,18 @@ defmodule Pleroma.Web.CommonAPI do defp activity_is_public(activity) do with false <- Visibility.public?(activity) do - {:error, :visibility_error} + {:error, :non_public_error} end end @spec unpin(String.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors() def unpin(id, user) do + # Order of visibility/belonging matters for MastoAPI responses. + # post not visible -> 404 + # post visible, not owned -> 422 with %Activity{} = activity <- create_activity_by_id(id), + true <- activity_visible_to_actor(activity, user), + true <- activity_belongs_to_actor(activity, user.ap_id), {:ok, unpin_data, _} <- Builder.unpin(user, activity.object), {:ok, _unpin, _} <- Pipeline.common_pipeline(unpin_data, @@ -562,7 +586,8 @@ defmodule Pleroma.Web.CommonAPI do def add_mute(activity, user, params \\ %{}) do expires_in = Map.get(params, :expires_in, 0) - with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]), + with true <- activity_visible_to_actor(activity, user), + {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]), _ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do if expires_in > 0 do Pleroma.Workers.MuteExpireWorker.new( @@ -574,14 +599,21 @@ defmodule Pleroma.Web.CommonAPI do {:ok, activity} else + {:error, :visibility_error} -> {:error, :visibility_error} {:error, _} -> {:error, dgettext("errors", "conversation is already muted")} end end @spec remove_mute(Activity.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} def remove_mute(%Activity{} = activity, %User{} = user) do - ThreadMute.remove_mute(user.id, activity.data["context"]) - {:ok, activity} + case activity_visible_to_actor(activity, user) do + true -> + ThreadMute.remove_mute(user.id, activity.data["context"]) + {:ok, activity} + + error -> + error + end end @spec remove_mute(String.t(), String.t()) :: {:ok, Activity.t()} | {:error, any()} @@ -612,6 +644,7 @@ defmodule Pleroma.Web.CommonAPI do with {:ok, account} <- get_reported_account(data.account_id), {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), {:ok, statuses} <- get_report_statuses(account, data), + true <- check_statuses_visibility(user, statuses), rules <- get_report_rules(Map.get(data, :rule_ids, nil)) do ActivityPub.flag(%{ context: Utils.generate_context_id(), @@ -622,9 +655,27 @@ defmodule Pleroma.Web.CommonAPI do forward: Map.get(data, :forward, false), rules: rules }) + else + false -> + {:error, :visibility_error} + + error -> + error end end + defp check_statuses_visibility(user, statuses) when is_list(statuses) do + visibility = for status <- statuses, do: Visibility.visible_for_user?(status, user) + + case Enum.all?(visibility) do + true -> true + _ -> false + end + end + + # There are no statuses associated with the report, pass! + defp check_statuses_visibility(_, status) when status == nil, do: true + defp get_reported_account(account_id) do case User.get_cached_by_id(account_id) do %User{} = account -> {:ok, account}diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex@@ -136,22 +136,34 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft - defp in_reply_to(%{params: %{in_reply_to_status_id: :deleted}} = draft) do - add_error(draft, dgettext("errors", "Cannot reply to a deleted status")) - end - - defp in_reply_to(%{params: %{in_reply_to_status_id: id} = params} = draft) when is_binary(id) do - activity = Activity.get_by_id(id) - - params = - if is_nil(activity) do - # Deleted activities are returned as nil - Map.put(params, :in_reply_to_status_id, :deleted) - else - Map.put(params, :in_reply_to_status_id, activity) - end - - in_reply_to(%{draft | params: params}) + defp in_reply_to(%{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do + # If a post was deleted all its activities (except the newly added Delete) are purged too, + # thus lookup by Create db ID will yield nil just as if it never existed in the first place. + # + # We allow replying to Announce here, due to a Pleroma-FE quirk where if presented with + # an Announce id it will render it as if it was just the normal referenced post, but + # use the Announce id for replies in the in_reply_to_id key of a POST request to + # /api/v1/statuses, or as an :id in /api/v1/statuses/:id/*. + # TODO: Fix this quirk in FE and remove here and other affected places + with %Activity{} = activity <- Activity.get_by_id(id), + true <- Visibility.visible_for_user?(activity, draft.user), + {_, type} when type in ["Create", "Announce"] <- {:type, activity.data["type"]} do + %__MODULE__{draft | in_reply_to: activity} + else + nil -> + add_error(draft, dgettext("errors", "Cannot reply to a deleted status")) + + false -> + add_error(draft, dgettext("errors", "Record not found")) + + {:type, type} -> + add_error( + draft, + dgettext("errors", "Can only reply to posts, not %{type} activities", + type: inspect(type) + ) + ) + end end defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) dodiff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex@@ -16,6 +16,12 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do def create(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do render(conn, "show.json", activity: activity) + else + {:error, :visibility_error} -> + {:error, :not_found, "Record not found"} + + error -> + error end end enddiff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex@@ -319,6 +319,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do @doc "DELETE /api/v1/statuses/:id" def delete(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), + # CommonAPI already checks whether user is allowed to delete {:ok, %Activity{}} <- CommonAPI.delete(id, user) do try_render(conn, "show.json", activity: activity, @@ -340,6 +341,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do _ ) do with {:ok, announce} <- CommonAPI.repeat(ap_id_or_id, user, params), + # CommonAPI already checks whether user is allowed to reblog %Activity{} = announce <- Activity.normalize(announce.data) do try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) end @@ -364,6 +366,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do _ ) do with {:ok, _fav} <- CommonAPI.favorite(activity_id, user), + # CommonAPI already checks whether user is allowed to reblog %Activity{} = activity <- Activity.get_by_id(activity_id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) end @@ -390,6 +393,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) else + # Order matters, if status is not owned by user and is not visible to user + # return 404 just like other endpoints {:error, :pinned_statuses_limit_reached} -> {:error, "You have already pinned the maximum number of statuses"} @@ -397,6 +402,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do {:error, :unprocessable_entity, "Someone else's status cannot be pinned"} {:error, :visibility_error} -> + {:error, :not_found, "Record not found"} + + {:error, :non_public_error} -> {:error, :unprocessable_entity, "Non-public status cannot be pinned"} error -> @@ -410,8 +418,20 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do conn, _ ) do + # CommonAPI already checks whether user can unpin with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + # Order matters, if status is not owned by user and is not visible to user + # return 404 just like other endpoints + {:error, :visibility_error} -> + {:error, :not_found, "Record not found"} + + {:error, :ownership_error} -> + {:error, :unprocessable_entity, "Someone else's status cannot be unpinned"} + + error -> + error end end @@ -434,6 +454,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do ), {:ok, _bookmark} <- Bookmark.create(user.id, activity.id, folder_id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + false -> + {:error, :not_found, "Record not found"} + + error -> + error end end @@ -447,6 +473,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do true <- Visibility.visible_for_user?(activity, user), {:ok, _bookmark} <- Bookmark.destroy(user.id, activity.id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + false -> + {:error, :not_found, "Record not found"} + + error -> + error end end @@ -459,8 +491,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do _ ) do with %Activity{} = activity <- Activity.get_by_id(id), + # CommonAPI already checks whether user is allowed to mute {:ok, activity} <- CommonAPI.add_mute(activity, user, params) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + {:error, :visibility_error} -> + {:error, :not_found, "Record not found"} + + error -> + error end end @@ -473,8 +512,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do _ ) do with %Activity{} = activity <- Activity.get_by_id(id), + # CommonAPI already checks whether user is allowed to unmute {:ok, activity} <- CommonAPI.remove_mute(activity, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + {:error, :visibility_error} -> + {:error, :not_found, "Record not found"} + + error -> + error end enddiff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex@@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do alias Pleroma.Activity alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -28,6 +29,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do with true <- Pleroma.Config.get([:instance, :show_reactions]), %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), + {_, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, %Object{} = object <- Object.normalize(activity, fetch: false), reactions <- Object.get_emoji_reactions(object) do reactions = @@ -37,6 +39,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do render(conn, "index.json", emoji_reactions: reactions, user: user) else + {:visible, _} -> {:error, :not_found} _e -> json(conn, []) end end @@ -76,6 +79,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do |> Pleroma.Emoji.fully_qualify_emoji() |> Pleroma.Emoji.maybe_quote() + # CommonAPI checks if allowed to react with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji) do activity = Activity.get_by_id(activity_id) @@ -91,6 +95,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do |> Pleroma.Emoji.fully_qualify_emoji() |> Pleroma.Emoji.maybe_quote() + # CommonAPI checks only author can revoke reactions with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji) do activity = Activity.get_by_id(activity_id)diff --git a/test/pleroma/conversation/participation_test.exs b/test/pleroma/conversation/participation_test.exs@@ -332,7 +332,7 @@ defmodule Pleroma.Conversation.ParticipationTest do # When it's a reply from the blocked user {:ok, _direct2} = CommonAPI.post(blocked, %{ - status: "reply", + status: "@#{third_user.nickname}, #{blocker.nickname} reply", visibility: "direct", in_reply_to_conversation_id: blocked_participation.id })diff --git a/test/pleroma/conversation_test.exs b/test/pleroma/conversation_test.exs@@ -66,8 +66,10 @@ defmodule Pleroma.ConversationTest do jafnhar = insert(:user, local: false) tridi = insert(:user) + to = [har.nickname, jafnhar.nickname, tridi.nickname] + {:ok, activity} = - CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct"}) + CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct", to: to}) object = Pleroma.Object.normalize(activity, fetch: false) context = object.data["context"] @@ -88,7 +90,8 @@ defmodule Pleroma.ConversationTest do CommonAPI.post(jafnhar, %{ status: "Hey @#{har.nickname}", visibility: "direct", - in_reply_to_status_id: activity.id + in_reply_to_status_id: activity.id, + to: to }) object = Pleroma.Object.normalize(activity, fetch: false) @@ -112,7 +115,8 @@ defmodule Pleroma.ConversationTest do CommonAPI.post(tridi, %{ status: "Hey @#{har.nickname}", visibility: "direct", - in_reply_to_status_id: activity.id + in_reply_to_status_id: activity.id, + to: to }) object = Pleroma.Object.normalize(activity, fetch: false)diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs@@ -1580,6 +1580,41 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert object["content"] == activity["object"]["content"] end + test "it inserts an incoming reply create activity into the database", %{conn: conn} do + user = insert(:user) + replying_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "cofe"}) + + data = %{ + type: "Create", + object: %{ + to: [Pleroma.Constants.as_public(), user.ap_id], + cc: [replying_user.follower_address], + inReplyTo: activity.object.data["id"], + content: "green tea", + type: "Note" + } + } + + result = + conn + |> assign(:user, replying_user) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{replying_user.nickname}/outbox", data) + |> json_response(201) + + updated_object = Object.normalize(activity.object.data["id"], fetch: false) + + assert Activity.get_by_ap_id(result["id"]) + assert result["object"] + assert %Object{data: object} = Object.normalize(result["object"], fetch: false) + assert object["content"] == data.object.content + assert Pleroma.Web.ActivityPub.Visibility.public?(object) + assert object["inReplyTo"] == activity.object.data["id"] + assert updated_object.data["repliesCount"] == 1 + end + test "it rejects anything beyond 'Note' creations", %{conn: conn, activity: activity} do user = insert(:user) @@ -1706,6 +1741,289 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert note_object == Object.normalize(note_activity, fetch: false) end + test "it rejects Add to other user's collection", %{conn: conn} do + user = insert(:user) + target_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "Post"}) + object = Object.normalize(activity, fetch: false) + object_id = object.data["id"] + + data = %{ + type: "Add", + target: + "#{Pleroma.Web.Endpoint.url()}/users/#{target_user.nickname}/collections/featured", + object: object_id + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/outbox", data) + + assert json_response(conn, 400) + end + + test "it rejects Remove to other user's collection", %{conn: conn} do + user = insert(:user) + target_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "Post"}) + object = Object.normalize(activity, fetch: false) + object_id = object.data["id"] + + data = %{ + type: "Remove", + target: + "#{Pleroma.Web.Endpoint.url()}/users/#{target_user.nickname}/collections/featured", + object: object_id + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/outbox", data) + + assert json_response(conn, 400) + end + + test "it rejects updating Actor's profile", %{conn: conn} do + user = insert(:user, local: true) + + user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) + user_object_new = Map.put(user_object, "name", "lain") + + data = %{ + type: "Update", + object: user_object_new + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/json") + |> post("/users/#{user.nickname}/outbox", data) + + updated_user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) + + assert updated_user_object == user_object + assert json_response(conn, 400) + end + + # Actor publicKey tests are redundant with above test, + # left here for the case that Updating Actors is ever supported + test "it rejects updating Actor's publicKey", %{conn: conn} do + user = insert(:user, local: true) + + {:ok, pem} = Pleroma.Keys.generate_rsa_pem() + {:ok, _, public_key} = Pleroma.Keys.keys_from_pem(pem) + # Taken from UserView + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + public_key = :public_key.pem_encode([public_key]) + + user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) + user_object_public_key = Map.fetch!(user_object, "publicKey") + user_object_public_key = Map.put(user_object_public_key, "publicKeyPem", public_key) + user_object_new = Map.put(user_object, "publicKey", user_object_public_key) + + refute user_object == user_object_new + + data = %{ + type: "Update", + object: user_object_new + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/json") + |> post("/users/#{user.nickname}/outbox", data) + + new_user_object = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) + + assert user_object == new_user_object + assert json_response(conn, 400) + end + + test "it rejects updating Actor's publicKey of another user", %{conn: conn} do + user = insert(:user) + target_user = insert(:user, local: true) + + {:ok, pem} = Pleroma.Keys.generate_rsa_pem() + {:ok, _, public_key} = Pleroma.Keys.keys_from_pem(pem) + # Taken from UserView + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + public_key = :public_key.pem_encode([public_key]) + + target_user_object = + Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: target_user}) + + target_user_object_public_key = Map.fetch!(target_user_object, "publicKey") + + target_user_object_public_key = + Map.put(target_user_object_public_key, "publicKeyPem", public_key) + + target_user_object_new = + Map.put(target_user_object, "publicKey", target_user_object_public_key) + + refute target_user_object == target_user_object_new + + data = %{ + type: "Update", + object: target_user_object_new + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/json") + |> post("/users/#{target_user.nickname}/outbox", data) + + new_target_user_object = + Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: target_user}) + + assert target_user_object == new_target_user_object + assert json_response(conn, 403) + end + + test "it rejects creating Actors of type Application", %{conn: conn} do + user = insert(:user, local: true) + + data = %{ + type: "Create", + object: %{ + type: "Application" + } + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/json") + |> post("/users/#{user.nickname}/outbox", data) + + assert json_response(conn, 400) + end + + test "it rejects creating Actors of type Person", %{conn: conn} do + user = insert(:user, local: true) + + data = %{ + type: "Create", + object: %{ + type: "Person" + } + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/json") + |> post("/users/#{user.nickname}/outbox", data) + + assert json_response(conn, 400) + end + + test "it rejects creating Actors of type Service", %{conn: conn} do + user = insert(:user, local: true) + + data = %{ + type: "Create", + object: %{ + type: "Service" + } + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/json") + |> post("/users/#{user.nickname}/outbox", data) + + assert json_response(conn, 400) + end + + test "it rejects like activity to object invisible to actor", %{conn: conn} do + user = insert(:user) + stranger = insert(:user, local: true) + {:ok, post} = CommonAPI.post(user, %{status: "cofe", visibility: "private"}) + + assert Pleroma.Web.ActivityPub.Visibility.private?(post) + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(post, stranger) + + post_object = Object.normalize(post, fetch: false) + + data = %{ + type: "Like", + object: %{ + id: post_object.data["id"] + } + } + + conn = + conn + |> assign(:user, stranger) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{stranger.nickname}/outbox", data) + + assert json_response(conn, 403) + end + + test "it rejects announce activity to object invisible to actor", %{conn: conn} do + user = insert(:user) + stranger = insert(:user, local: true) + {:ok, post} = CommonAPI.post(user, %{status: "cofe", visibility: "private"}) + + assert Pleroma.Web.ActivityPub.Visibility.private?(post) + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(post, stranger) + + post_object = Object.normalize(post, fetch: false) + + data = %{ + type: "Announce", + object: %{ + id: post_object.data["id"] + } + } + + conn = + conn + |> assign(:user, stranger) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{stranger.nickname}/outbox", data) + + assert json_response(conn, 403) + end + + test "it rejects emojireact activity to object invisible to actor", %{conn: conn} do + user = insert(:user) + stranger = insert(:user, local: true) + {:ok, post} = CommonAPI.post(user, %{status: "cofe", visibility: "private"}) + + assert Pleroma.Web.ActivityPub.Visibility.private?(post) + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(post, stranger) + + post_object = Object.normalize(post, fetch: false) + + data = %{ + type: "EmojiReact", + object: %{ + id: post_object.data["id"] + }, + content: "😀" + } + + conn = + conn + |> assign(:user, stranger) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{stranger.nickname}/outbox", data) + + assert json_response(conn, 403) + end + test "it increases like count when receiving a like action", %{conn: conn} do note_activity = insert(:note_activity) note_object = Object.normalize(note_activity, fetch: false)diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs@@ -9,7 +9,10 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do alias Pleroma.Activity alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.CommonAPI @@ -530,6 +533,9 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do assert is_nil(modified["object"]["announcements"]) assert is_nil(modified["object"]["announcement_count"]) assert is_nil(modified["object"]["generator"]) + assert is_nil(modified["object"]["rules"]) + assert is_nil(modified["object"]["language"]) + assert is_nil(modified["object"]["voters"]) end test "it strips internal fields of article" do @@ -587,6 +593,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do test "it can handle Listen activities" do listen_activity = insert(:listen) + # This has an inlined object as in ObjectView {:ok, modified} = Transmogrifier.prepare_outgoing(listen_activity.data) assert modified["type"] == "Listen" @@ -595,7 +602,36 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do {:ok, activity} = CommonAPI.listen(user, %{"title" => "lain radio episode 1"}) - {:ok, _modified} = Transmogrifier.prepare_outgoing(activity.data) + user_ap_id = user.ap_id + activity_ap_id = activity.data["id"] + activity_to = activity.data["to"] + activity_cc = activity.data["cc"] + object_ap_id = activity.data["object"] + object_type = activity.object.data["type"] + + # This does not have an inlined object + {:ok, modified2} = Transmogrifier.prepare_outgoing(activity.data) + + assert match?( + %{ + "@context" => [_ | _], + "type" => "Listen", + "actor" => ^user_ap_id, + "to" => ^activity_to, + "cc" => ^activity_cc, + "context" => "http://localhost" <> _, + "id" => ^activity_ap_id, + "object" => %{ + "actor" => ^user_ap_id, + "attributedTo" => ^user_ap_id, + "id" => ^object_ap_id, + "type" => ^object_type, + "to" => ^activity_to, + "cc" => ^activity_cc + } + }, + modified2 + ) end test "custom emoji urls are URI encoded" do @@ -635,6 +671,94 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do } = prepared["object"] end + test "Updates of Actors are handled" do + user = insert(:user, local: true) + + changeset = User.update_changeset(user, %{name: "new name"}) + {:ok, unpersisted_user} = Ecto.Changeset.apply_action(changeset, :update) + + updated_object = + UserView.render("user.json", user: unpersisted_user) + |> Map.delete("@context") + + {:ok, update_data, []} = Builder.update(user, updated_object) + + {:ok, activity, _} = + Pipeline.common_pipeline(update_data, + local: true, + user_update_changeset: changeset + ) + + assert {:ok, prepared} = Transmogrifier.prepare_outgoing(activity.data) + assert prepared["type"] == "Update" + assert prepared["@context"] + assert prepared["object"]["type"] == user.actor_type + end + + test "Correctly handles Undo activities" do + blocked = insert(:user) + blocker = insert(:user, local: true) + + blocked_ap_id = blocked.ap_id + blocker_ap_id = blocker.ap_id + + {:ok, %Activity{} = block_activity} = CommonAPI.block(blocked, blocker) + {:ok, %Activity{} = undo_activity} = CommonAPI.unblock(blocked, blocker) + {:ok, data} = Transmogrifier.prepare_outgoing(undo_activity.data) + + block_ap_id = block_activity.data["id"] + assert is_binary(block_ap_id) + + assert match?( + %{ + "@context" => [_ | _], + "type" => "Undo", + "id" => "http://localhost" <> _, + "actor" => ^blocker_ap_id, + "object" => ^block_ap_id, + "to" => [^blocked_ap_id], + "cc" => [], + "bto" => [], + "bcc" => [] + }, + data + ) + end + + test "Correctly handles EmojiReact activities" do + user = insert(:user, local: true) + note_activity = insert(:note_activity) + + user_ap_id = user.ap_id + user_followers = user.follower_address + note_author = note_activity.data["actor"] + note_ap_id = note_activity.data["object"] + + assert is_binary(note_author) + assert is_binary(note_ap_id) + + {:ok, react_activity} = CommonAPI.react_with_emoji(note_activity.id, user, "🐈") + {:ok, data} = Transmogrifier.prepare_outgoing(react_activity.data) + + assert match?( + %{ + "@context" => [_ | _], + "type" => "EmojiReact", + "actor" => ^user_ap_id, + "to" => [^user_followers, ^note_author], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "bto" => [], + "bcc" => [], + "content" => "🐈", + "context" => "2hu", + "id" => "http://localhost" <> _, + "object" => ^note_ap_id, + "tag" => [] + }, + data + ) + end + test "it prepares a quote post" do user = insert(:user)diff --git a/test/pleroma/web/activity_pub/views/object_view_test.exs b/test/pleroma/web/activity_pub/views/object_view_test.exs@@ -95,4 +95,23 @@ defmodule Pleroma.Web.ActivityPub.ObjectViewTest do assert result["object"] == announce.data["id"] assert result["type"] == "Undo" end + + test "renders a listen activity" do + audio = insert(:audio) + user = insert(:user) + + {:ok, listen_activity} = CommonAPI.listen(user, audio.data) + + result = ObjectView.render("object.json", %{object: listen_activity}) + + assert result["id"] == listen_activity.data["id"] + assert result["to"] == listen_activity.data["to"] + assert result["type"] == "Listen" + assert result["object"]["album"] == listen_activity.data["album"] + assert result["object"]["artist"] == listen_activity.data["artist"] + assert result["object"]["length"] == listen_activity.data["length"] + assert result["object"]["title"] == listen_activity.data["title"] + assert result["object"]["type"] == "Audio" + assert result["@context"] + end enddiff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs@@ -1086,7 +1086,7 @@ defmodule Pleroma.Web.CommonAPITest do test "only public can be pinned", %{user: user} do {:ok, activity} = CommonAPI.post(user, %{status: "private status", visibility: "private"}) - {:error, :visibility_error} = CommonAPI.pin(activity.id, user) + {:error, :non_public_error} = CommonAPI.pin(activity.id, user) end test "unpin status", %{user: user, activity: activity} do @@ -1300,6 +1300,47 @@ defmodule Pleroma.Web.CommonAPITest do } = flag_activity end + test "doesn't create a report when post is not visible to user" do + reporter = insert(:user) + target_user = insert(:user) + {:ok, post} = CommonAPI.post(target_user, %{status: "Eric", visibility: "private"}) + + assert Pleroma.Web.ActivityPub.Visibility.private?(post) + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(post, reporter) + + # Fails when all status are invisible + report_data = %{ + account_id: target_user.id, + comment: "foobar", + status_ids: [post.id] + } + + assert {:error, :visibility_error} = CommonAPI.report(reporter, report_data) + end + + test "doesn't create a report when some posts are not visible to user" do + reporter = insert(:user) + target_user = insert(:user) + + {:ok, visible_activity} = CommonAPI.post(target_user, %{status: "cofe"}) + + {:ok, invisibile_activity} = + CommonAPI.post(target_user, %{status: "cawfee", visibility: "private"}) + + assert Pleroma.Web.ActivityPub.Visibility.private?(invisibile_activity) + assert Pleroma.Web.ActivityPub.Visibility.public?(visible_activity) + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(invisibile_activity, reporter) + + # Fails when some statuses are invisible + report_data_partial = %{ + account_id: target_user.id, + comment: "foobar", + status_ids: [visible_activity.id, invisibile_activity.id] + } + + assert {:error, :visibility_error} = CommonAPI.report(reporter, report_data_partial) + end + test "updates report state" do [reporter, target_user] = insert_pair(:user) activity = insert(:note_activity, user: target_user)diff --git a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs@@ -316,6 +316,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do user = insert(:user) %{user: other_user, conn: conn} = oauth_access(["read:notifications"]) + {:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(other_user, user) + {:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(user, other_user) + {:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"}) {:ok, direct_activity} =diff --git a/test/pleroma/web/mastodon_api/controllers/report_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/report_controller_test.exs@@ -147,7 +147,7 @@ defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do |> json_response_and_validate_schema(400) end - test "returns error when account is not exist", %{ + test "returns error when account does not exist", %{ conn: conn, activity: activity } do @@ -159,6 +159,51 @@ defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do assert json_response_and_validate_schema(conn, 400) == %{"error" => "Account not found"} end + test "returns not found when post isn't visible to reporter", %{user: target_user} do + %{conn: conn, user: reporter} = oauth_access(["write:reports"]) + + {:ok, invisible_activity} = + CommonAPI.post(target_user, %{status: "Invisible!", visibility: "private"}) + + assert Pleroma.Web.ActivityPub.Visibility.private?(invisible_activity) + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(invisible_activity, reporter) + + assert %{"error" => "Record not found"} = + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/v1/reports", + %{"account_id" => target_user.id, "status_ids" => [invisible_activity.id]} + ) + |> json_response_and_validate_schema(404) + end + + test "returns not found when some post aren't visible to reporter", %{ + activity: activity, + user: target_user + } do + %{conn: conn, user: reporter} = oauth_access(["write:reports"]) + + {:ok, invisible_activity} = + CommonAPI.post(target_user, %{status: "Invisible!", visibility: "private"}) + + assert Pleroma.Web.ActivityPub.Visibility.private?(invisible_activity) + assert Pleroma.Web.ActivityPub.Visibility.visible_for_user?(activity, reporter) + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(invisible_activity, reporter) + + assert %{"error" => "Record not found"} = + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/v1/reports", + %{ + "account_id" => target_user.id, + "status_ids" => [activity.id, invisible_activity.id] + } + ) + |> json_response_and_validate_schema(404) + end + test "doesn't fail if an admin has no email", %{conn: conn, target_user: target_user} do insert(:user, %{is_admin: true, email: nil})diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs@@ -17,6 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI alias Pleroma.Workers.ScheduledActivityWorker @@ -267,6 +268,73 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do end) end + test "replying to a post the current user can't access fails", %{user: user, conn: conn} do + stranger = insert(:user) + + {:ok, priv_post_act} = + CommonAPI.post(stranger, %{status: "forbidden knowledge", visibility: "private"}) + + assert Visibility.visible_for_user?(priv_post_act, stranger) + refute Visibility.visible_for_user?(priv_post_act, user) + + resp = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "@#{stranger.nickname} :peek:", + "in_reply_to_id" => priv_post_act.id, + "visibility" => "private" + }) + |> json_response_and_validate_schema(422) + + assert match?(%{"error" => _}, resp) + end + + test "replying to own DM succeeds", %{user: user, conn: conn} do + # this is an "edge" case for visibility: replying user is not + # part of addressed users (but is the author) + stranger = insert(:user) + + {:ok, %{id: dm_id} = dm_post_act} = + CommonAPI.post(user, %{ + status: "@#{stranger.nickname} wanna lose your mind to forbidden knowledge?", + visibility: "direct" + }) + + assert Visibility.visible_for_user?(dm_post_act, stranger) + assert Visibility.visible_for_user?(dm_post_act, user) + + resp = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "@#{stranger.nickname} :peek:", + "in_reply_to_id" => dm_id, + "visibility" => "direct" + }) + |> json_response_and_validate_schema(200) + + assert match?(%{"in_reply_to_id" => ^dm_id}, resp) + end + + test "replying to a non-post activity fails", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) + assert Visibility.visible_for_user?(follow_activity, user) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ + "status" => "hiiii!", + "in_reply_to_id" => to_string(follow_activity.id) + }) + + assert %{"error" => "Can only reply to posts, not \"Follow\" activities"} = + json_response_and_validate_schema(conn, 422) + end + test "posting a status with an invalid in_reply_to_id", %{conn: conn} do conn = conn @@ -1416,6 +1484,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert to_string(activity.id) == id end + + test "cannot reblog private status of others (even if visible)", %{conn: conn, user: user} do + followed = insert(:user, local: true) + + {:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(followed, user) + + {:ok, activity} = CommonAPI.post(followed, %{status: "cofe", visibility: "private"}) + + assert Visibility.visible_for_user?(activity, user) + + resp = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/reblog") + |> json_response_and_validate_schema(404) + + assert match?(%{"error" => _}, resp) + end end describe "unreblogging" do @@ -1445,6 +1531,34 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end + + test "can't unreblog someone else's reblog", %{user: user, conn: conn} do + activity = insert(:note_activity) + other_user = insert(:user) + + {:ok, %{id: reblog_id}} = CommonAPI.repeat(activity.id, other_user) + + # unreblog by base post + resp1 = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unreblog") + |> json_response(400) + + assert match?(%{"error" => _}, resp1) + + # unreblog by reblog ID (reblog IDs are accepted by some APIs; + # ensure it fails here one way or another) + resp2 = + build_conn() + |> assign(:user, user) + |> assign(:token, insert(:oauth_token, user: user, scopes: ["write", "read"])) + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{reblog_id}/unreblog") + |> json_response_and_validate_schema(404) + + assert match?(%{"error" => _}, resp2) + end end describe "favoriting" do @@ -1477,13 +1591,23 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(200) end - test "returns 404 error for a wrong id", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/statuses/1/favourite") + test "a status you cannot see fails", %{conn: conn} do + stranger = insert(:user) - assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} + {:ok, activity} = + CommonAPI.post(stranger, %{status: "it can eternal lie", visibility: "private"}) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/favourite") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + + test "returns 404 error for a wrong id", %{conn: conn} do + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/1/favourite") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} end end @@ -1506,6 +1630,54 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert to_string(activity.id) == id end + test "can't unfavourite post that isn't visible to user" do + user = insert(:user) + %{conn: conn, user: stranger} = oauth_access(["write:favourites"]) + {:ok, activity} = CommonAPI.post(user, %{status: "invisible", visibility: "private"}) + + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(activity, stranger) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unfavourite") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + + test "can't unfavourite post that isn't favourited", %{conn: conn} do + activity = insert(:note_activity) + + # using base post ID + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unfavourite") + |> json_response_and_validate_schema(400) == %{"error" => "Could not unfavorite"} + end + + test "can't unfavourite other user's favs", %{conn: conn} do + activity = insert(:note_activity) + + other = insert(:user) + {:ok, _} = CommonAPI.favorite(activity.id, other) + + # using base post ID + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unfavourite") + |> json_response_and_validate_schema(400) == %{"error" => "Could not unfavorite"} + end + + test "can't unfavourite other user's favs using their activity", %{conn: conn} do + activity = insert(:note_activity) + + other = insert(:user) + {:ok, fav_activity} = CommonAPI.favorite(activity.id, other) + # some APIs (used to) take IDs of any activity type, make sure this fails one way or another + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{fav_activity.id}/unfavourite") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + test "returns 404 error for a wrong id", %{conn: conn} do conn = conn @@ -1516,6 +1688,19 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do end end + test "can't favourite post that isn't visible to user" do + user = insert(:user) + %{conn: conn, user: stranger} = oauth_access(["write:favourites"]) + {:ok, activity} = CommonAPI.post(user, %{status: "invisible", visibility: "private"}) + + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(activity, stranger) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/favourite") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + describe "pinned statuses" do setup do: oauth_access(["write:accounts"]) @@ -1549,7 +1734,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response(403) == %{"error" => "Invalid credentials."} end - test "/pin: returns 400 error when activity is not public", %{conn: conn, user: user} do + test "/pin: returns 422 error when activity is not public", %{conn: conn, user: user} do {:ok, dm} = CommonAPI.post(user, %{status: "test", visibility: "direct"}) conn = @@ -1562,6 +1747,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do } end + test "/pin: returns 404 error when activity not visible to user", %{user: user} do + %{conn: conn, user: stranger} = oauth_access(["write:accounts"]) + {:ok, activity} = CommonAPI.post(user, %{status: "invisible", visibility: "private"}) + + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(activity, stranger) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/pin") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + test "pin by another user", %{activity: activity} do %{conn: conn} = oauth_access(["write:accounts"]) @@ -1596,6 +1793,32 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} end + test "/unpin: returns 404 error when activity not visible to user", %{user: user} do + %{conn: conn, user: stranger} = oauth_access(["write:accounts"]) + {:ok, activity} = CommonAPI.post(user, %{status: "yumi", visibility: "private"}) + + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(activity, stranger) + + assert conn + |> assign(:user, stranger) + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unpin") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + + test "/unpin: returns 422 error when activity not owned by user", %{activity: activity} do + %{conn: conn, user: user} = oauth_access(["write:accounts"]) + + assert Pleroma.Web.ActivityPub.Visibility.visible_for_user?(activity, user) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unpin") + |> json_response_and_validate_schema(422) == %{ + "error" => "Someone else's status cannot be unpinned" + } + end + test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"}) @@ -1707,6 +1930,28 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do json_response_and_validate_schema(bookmarks, 200) end + test "cannot bookmark invisible post" do + user = insert(:user) + %{conn: conn, user: stranger} = oauth_access(["write:bookmarks"]) + {:ok, activity} = CommonAPI.post(user, %{status: "mocha", visibility: "private"}) + + refute Pleroma.Web.ActivityPub.Visibility.visible_for_user?(activity, stranger) + + resp1 = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/bookmark") + + assert json_response_and_validate_schema(resp1, 404) == %{"error" => "Record not found"} + + resp2 = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unbookmark") + + assert json_response_and_validate_schema(resp2, 404) == %{"error" => "Record not found"} + end + test "bookmark folders" do %{conn: conn, user: user} = oauth_access(["write:bookmarks", "read:bookmarks"]) @@ -1804,6 +2049,30 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> post("/api/v1/statuses/#{activity.id}/unmute") |> json_response_and_validate_schema(200) end + + test "cannot mute not visible conversation", %{user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "Invisible!", visibility: "private"}) + %{conn: conn} = oauth_access(["write:mutes"]) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/mute") + |> json_response_and_validate_schema(404) == %{ + "error" => "Record not found" + } + end + + test "cannot unmute not visible conversation", %{user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "Invisible!", visibility: "private"}) + %{conn: conn} = oauth_access(["write:mutes"]) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unmute") + |> json_response_and_validate_schema(404) == %{ + "error" => "Record not found" + } + end end test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{conn: conn} do @@ -1970,6 +2239,22 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert id == other_user.id end + test "fails when base post not visible to current user", %{user: user} do + other_user = insert(:user, local: true) + %{conn: conn} = oauth_access(["read:accounts"]) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "craving tea and mochi rn", + visibility: "private" + }) + + assert conn + |> assign(:user, other_user) + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + test "returns empty array when :show_reactions is disabled", %{conn: conn, activity: activity} do clear_config([:instance, :show_reactions], false) @@ -2088,6 +2373,25 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert [] == response end + + test "does fail when requesting for a non-visible status", %{user: user} do + other_user = insert(:user, local: true) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "deep below it sleeps and mustn't wake", + visibility: "private" + }) + + response = + build_conn() + |> assign(:user, other_user) + |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read"])) + |> get("/api/v1/statuses/#{activity.id}/reblogged_by") + |> json_response_and_validate_schema(404) + + assert match?(%{"error" => _}, response) + end end test "context" do @@ -2110,6 +2414,34 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do } = response end + test "context doesn't leak priv posts" do + %{user: user, conn: conn} = oauth_access(["read:statuses"]) + stranger = insert(:user) + + {:ok, %{id: id1}} = CommonAPI.post(stranger, %{status: "1", visibility: "public"}) + + {:ok, %{id: id2}} = + CommonAPI.post(stranger, %{status: "2", visibility: "unlisted", in_reply_to_status_id: id1}) + + {:ok, %{id: _id_boo} = act_boo} = + CommonAPI.post(stranger, %{status: "boo", visibility: "private", in_reply_to_status_id: id1}) + + refute Visibility.visible_for_user?(act_boo, user) + + response = + conn + |> get("/api/v1/statuses/#{id1}/context") + |> json_response_and_validate_schema(:ok) + + assert match?( + %{ + "ancestors" => [], + "descendants" => [%{"id" => ^id2}] + }, + response + ) + end + test "favorites paginate correctly" do %{user: user, conn: conn} = oauth_access(["read:favourites"]) other_user = insert(:user)diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs@@ -9,10 +9,38 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do alias Pleroma.Object alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI import Pleroma.Factory + defp prepare_reacted_post(visibility \\ "private") do + unrelated_user = insert(:user, local: true) + poster = insert(:user, local: true) + follower = insert(:user, local: true) + {:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(poster, follower) + + {:ok, post_activity} = CommonAPI.post(poster, %{status: "miaow!", visibility: visibility}) + + if visibility != "direct" do + assert Visibility.visible_for_user?(post_activity, follower) + end + + if visibility in ["direct", "private"] do + refute Visibility.visible_for_user?(post_activity, unrelated_user) + end + + {:ok, _react_activity} = CommonAPI.react_with_emoji(post_activity.id, follower, "🐾") + + {post_activity, poster, follower, unrelated_user} + end + + defp prepare_conn_of_user(conn, user) do + conn + |> assign(:user, user) + |> assign(:token, insert(:oauth_token, user: user, scopes: ["write", "read"])) + end + setup do Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig) :ok @@ -137,6 +165,28 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do |> json_response_and_validate_schema(400) end + test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji not allowed for non-visible posts", %{ + conn: conn + } do + {%{id: activity_id} = _activity, _author, follower, stranger} = prepare_reacted_post() + + # Works for follower + resp = + prepare_conn_of_user(conn, follower) + |> put("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈") + |> json_response_and_validate_schema(200) + + assert match?(%{"id" => ^activity_id}, resp) + + # Fails for stranger + resp = + prepare_conn_of_user(conn, stranger) + |> put("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈") + |> json_response_and_validate_schema(404) + + assert match?(%{"error" => "Record not found"}, resp) + end + test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -211,6 +261,26 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do |> json_response(400) end + test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji only allows original reacter to revoke", + %{conn: conn} do + {%{id: activity_id} = _activity, author, follower, unrelated} = prepare_reacted_post("public") + + # Works for original reacter + prepare_conn_of_user(conn, follower) + |> delete("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐾") + |> json_response_and_validate_schema(200) + + # Fails for anyone else + for u <- [author, unrelated] do + resp = + prepare_conn_of_user(conn, u) + |> delete("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐾") + |> json_response(400) + + assert match?(%{"error" => _}, resp) + end + end + test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -324,6 +394,25 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do assert [%{"name" => "🎅", "count" => 2}] = result end + test "GET /api/v1/pleroma/statuses/:id/reactions not allowed for non-visible posts", %{ + conn: conn + } do + {%{id: activity_id} = _activity, _author, follower, stranger} = prepare_reacted_post() + + # Works for follower + resp = + prepare_conn_of_user(conn, follower) + |> get("/api/v1/pleroma/statuses/#{activity_id}/reactions") + |> json_response_and_validate_schema(200) + + assert match?([%{"name" => _, "count" => _} | _], resp) + + # Fails for stranger + assert prepare_conn_of_user(conn, stranger) + |> get("/api/v1/pleroma/statuses/#{activity_id}/reactions") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end + test "GET /api/v1/pleroma/statuses/:id/reactions with :show_reactions disabled", %{conn: conn} do clear_config([:instance, :show_reactions], false) @@ -372,4 +461,20 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do assert represented_user["id"] == other_user.id end + + test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji not allowed for non-visible posts", %{ + conn: conn + } do + {%{id: activity_id} = _activity, _author, follower, stranger} = prepare_reacted_post() + + # Works for follower + assert prepare_conn_of_user(conn, follower) + |> get("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈") + |> json_response_and_validate_schema(200) + + # Fails for stranger + assert prepare_conn_of_user(conn, stranger) + |> get("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} + end end