From 300d94c62829d0ec961f3ed6c0242dea102ab0ad Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 1 Jun 2019 16:07:01 +0300 Subject: [PATCH] Add poll votes Also in this commit by accident: - Fix query ordering causing exclude_poll_votes to not work - Do not create notifications for Answer objects --- lib/pleroma/notification.ex | 11 ++- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- lib/pleroma/web/activity_pub/utils.ex | 17 +++++ lib/pleroma/web/common_api/common_api.ex | 46 +++++++++++ lib/pleroma/web/common_api/utils.ex | 11 +++ .../mastodon_api/mastodon_api_controller.ex | 27 +++++++ lib/pleroma/web/router.ex | 2 + .../mastodon_api_controller_test.exs | 76 +++++++++++++++++++ 8 files changed, 188 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 844264307..80e2800ae 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -127,10 +127,15 @@ defmodule Pleroma.Notification do def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) when type in ["Create", "Like", "Announce", "Follow"] do - users = get_notified_from_activity(activity) + object = Object.normalize(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) - {:ok, notifications} + unless object && object.data["type"] == "Answer" do + users = get_notified_from_activity(activity) + notifications = Enum.map(users, fn user -> create_notification(activity, user) end) + {:ok, notifications} + else + {:ok, []} + end end def create_notifications(_), do: {:ok, []} diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index b06200cb5..5a11dadcf 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -878,7 +878,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_order(opts) - |> exclude_poll_votes(opts) |> restrict_recipients(recipients, opts["user"]) |> restrict_tag(opts) |> restrict_tag_reject(opts) @@ -899,6 +898,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> restrict_pinned(opts) |> restrict_muted_reblogs(opts) |> Activity.restrict_deactivated_users() + |> exclude_poll_votes(opts) end def fetch_activities(recipients, opts \\ %{}) do diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 9646bbee9..b292d7d8d 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -789,4 +789,21 @@ defmodule Pleroma.Web.ActivityPub.Utils do [to, cc, recipients] end end + + def get_existing_votes(actor, %{data: %{"id" => id}}) do + query = + from( + [activity, object: object] in Activity.with_preloaded_object(Activity), + where: fragment("(?)->>'actor' = ?", activity.data, ^actor), + where: + fragment( + "(?)->'inReplyTo' = ?", + object.data, + ^to_string(id) + ), + where: fragment("(?)->>'type' = 'Answer'", object.data) + ) + + Repo.all(query) + end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 374967a1b..f54f8a7b9 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -119,6 +119,52 @@ defmodule Pleroma.Web.CommonAPI do end end + def vote(user, object, choices) do + with "Question" <- object.data["type"], + {:author, false} <- {:author, object.data["actor"] == user.ap_id}, + {:existing_votes, []} <- {:existing_votes, Utils.get_existing_votes(user.ap_id, object)}, + {options, max_count} <- get_options_and_max_count(object), + option_count <- Enum.count(options), + {:choice_check, {choices, true}} <- + {:choice_check, normalize_and_validate_choice_indices(choices, option_count)}, + {:count_check, true} <- {:count_check, Enum.count(choices) <= max_count} do + answer_activities = + Enum.map(choices, fn index -> + answer_data = make_answer_data(user, object, Enum.at(options, index)["name"]) + + ActivityPub.create(%{ + to: answer_data["to"], + actor: user, + context: object.data["context"], + object: answer_data, + additional: %{"cc" => answer_data["cc"]} + }) + end) + + {:ok, answer_activities, object} + else + {:author, _} -> {:error, "Already voted"} + {:existing_votes, _} -> {:error, "Already voted"} + {:choice_check, {_, false}} -> {:error, "Invalid indices"} + {:count_check, false} -> {:error, "Too many choices"} + end + end + + defp get_options_and_max_count(object) do + if Map.has_key?(object.data, "anyOf") do + {object.data["anyOf"], Enum.count(object.data["anyOf"])} + else + {object.data["oneOf"], 1} + end + end + + defp normalize_and_validate_choice_indices(choices, count) do + Enum.map_reduce(choices, true, fn index, valid -> + index = if is_binary(index), do: String.to_integer(index), else: index + {index, if(valid, do: index < count, else: valid)} + end) + end + def get_visibility(%{"visibility" => visibility}, in_reply_to) when visibility in ~w{public unlisted private direct}, do: {visibility, get_replied_to_visibility(in_reply_to)} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 1a239de97..f35ed36ab 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -491,4 +491,15 @@ defmodule Pleroma.Web.CommonAPI.Utils do {:error, "No such conversation"} end end + + def make_answer_data(%User{ap_id: ap_id}, object, name) do + %{ + "type" => "Answer", + "actor" => ap_id, + "cc" => [object.data["actor"]], + "to" => [], + "name" => name, + "inReplyTo" => object.data["id"] + } + end end diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index ecb7df459..13bb609e5 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -430,6 +430,33 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do end end + def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do + with %Object{} = object <- Object.get_by_id(id), + true <- object.data["type"] == "Question", + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), + true <- Visibility.visible_for_user?(activity, user), + {:ok, _activities, object} <- CommonAPI.vote(user, object, choices) do + conn + |> put_view(StatusView) + |> try_render("poll.json", %{object: object, for: user}) + else + nil -> + conn + |> put_status(404) + |> json(%{error: "Record not found"}) + + false -> + conn + |> put_status(404) + |> json(%{error: "Record not found"}) + + {:error, message} -> + conn + |> put_status(422) + |> json(%{error: message}) + end + end + def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do conn diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e0611e3fc..d17f58f52 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -335,6 +335,8 @@ defmodule Pleroma.Web.Router do put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status) delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status) + post("/polls/:id/votes", MastodonAPIController, :poll_vote) + post("/media", MastodonAPIController, :upload) put("/media/:id", MastodonAPIController, :update_media) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 0d56b6ff2..b160a4db0 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -3497,4 +3497,80 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do assert json_response(conn, 404) end end + + describe "POST /api/v1/polls/:id/votes" do + test "votes are added to the poll", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "A very delicious sandwich", + "poll" => %{ + "options" => ["Lettuce", "Grilled Bacon", "Tomato"], + "expires_in" => 20, + "multiple" => true + } + }) + + object = Object.normalize(activity) + + conn = + conn + |> assign(:user, other_user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]}) + + assert json_response(conn, 200) + object = Object.get_by_id(object.id) + + assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => totalItems}} -> + totalItems == 1 + end) + end + + test "author can't vote", %{conn: conn} do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "Am I cute?", + "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} + }) + + object = Object.normalize(activity) + + assert conn + |> assign(:user, user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]}) + |> json_response(422) == %{"error" => "Already voted"} + + object = Object.get_by_id(object.id) + + refute Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 1 + end + + test "does not allow multiple choices on a single-choice question", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + "status" => "The glass is", + "poll" => %{"options" => ["half empty", "half full"], "expires_in" => 20} + }) + + object = Object.normalize(activity) + + assert conn + |> assign(:user, other_user) + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]}) + |> json_response(422) == %{"error" => "Too many choices"} + + object = Object.get_by_id(object.id) + + refute Enum.any?(object.data["oneOf"], fn %{"replies" => %{"totalItems" => totalItems}} -> + totalItems == 1 + end) + end + end end