From 300d94c62829d0ec961f3ed6c0242dea102ab0ad Mon Sep 17 00:00:00 2001
From: rinpatch <rinpatch@sdf.org>
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