Enforce poll limits and add error handling for MastodonAPI's post endpoint
authorrinpatch <rinpatch@sdf.org>
Tue, 21 May 2019 07:54:20 +0000 (10:54 +0300)
committerrinpatch <rinpatch@sdf.org>
Tue, 21 May 2019 07:54:20 +0000 (10:54 +0300)
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/mastodon_api/mastodon_api_controller.ex
test/web/mastodon_api/mastodon_api_controller_test.exs

index cd8483c11614742ba450e9e07926cde4f8e0d4c2..97172fd941aa4fd09e74fe7579f9d8e39f85c570 100644 (file)
@@ -104,28 +104,61 @@ defmodule Pleroma.Web.CommonAPI.Utils do
 
   def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data)
       when is_list(options) and is_integer(expires_in) do
-    {poll, emoji} =
-      Enum.map_reduce(options, %{}, fn option, emoji ->
-        {%{
-           "name" => option,
-           "type" => "Note",
-           "replies" => %{"type" => "Collection", "totalItems" => 0}
-         }, Map.merge(emoji, Formatter.get_emoji_map(option))}
-      end)
+    %{max_expiration: max_expiration, min_expiration: min_expiration} =
+      limits = Pleroma.Config.get([:instance, :poll_limits])
 
-    end_time =
-      NaiveDateTime.utc_now()
-      |> NaiveDateTime.add(expires_in)
-      |> NaiveDateTime.to_iso8601()
+    # XXX: There is probably a cleaner way of doing this
+    try do
+      if Enum.count(options) > limits.max_options do
+        raise ArgumentError, message: "Poll can't contain more than #{limits.max_options} options"
+      end
 
-    poll =
-      if Pleroma.Web.ControllerHelper.truthy_param?(data["poll"]["multiple"]) do
-        %{"type" => "Question", "anyOf" => poll, "closed" => end_time}
-      else
-        %{"type" => "Question", "oneOf" => poll, "closed" => end_time}
+      {poll, emoji} =
+        Enum.map_reduce(options, %{}, fn option, emoji ->
+          if String.length(option) > limits.max_option_chars do
+            raise ArgumentError,
+              message:
+                "Poll options cannot be longer than #{limits.max_option_chars} characters each"
+          end
+
+          {%{
+             "name" => option,
+             "type" => "Note",
+             "replies" => %{"type" => "Collection", "totalItems" => 0}
+           }, Map.merge(emoji, Formatter.get_emoji_map(option))}
+        end)
+
+      case expires_in do
+        expires_in when expires_in > max_expiration ->
+          raise ArgumentError, message: "Expiration date is too far in the future"
+
+        expires_in when expires_in < min_expiration ->
+          raise ArgumentError, message: "Expiration date is too soon"
+
+        _ ->
+          :noop
       end
 
-    {poll, emoji}
+      end_time =
+        NaiveDateTime.utc_now()
+        |> NaiveDateTime.add(expires_in)
+        |> NaiveDateTime.to_iso8601()
+
+      poll =
+        if Pleroma.Web.ControllerHelper.truthy_param?(data["poll"]["multiple"]) do
+          %{"type" => "Question", "anyOf" => poll, "closed" => end_time}
+        else
+          %{"type" => "Question", "oneOf" => poll, "closed" => end_time}
+        end
+
+      {poll, emoji}
+    rescue
+      e in ArgumentError -> e.message
+    end
+  end
+
+  def make_poll_data(%{"poll" => _}) do
+    "Invalid poll"
   end
 
   def make_poll_data(_data) do
index 81cc5a972f82a8636db84ab1c674906f1644003c..e2cab86f1a28737d31ff5dfa294466c1ede6a900 100644 (file)
@@ -473,12 +473,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
       params
       |> Map.put("in_reply_to_status_id", params["in_reply_to_id"])
 
-    idempotency_key =
-      case get_req_header(conn, "idempotency-key") do
-        [key] -> key
-        _ -> Ecto.UUID.generate()
-      end
-
     scheduled_at = params["scheduled_at"]
 
     if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do
@@ -491,17 +485,40 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
     else
       params = Map.drop(params, ["scheduled_at"])
 
-      {:ok, activity} =
-        Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ ->
-          CommonAPI.post(user, params)
-        end)
-
-      conn
-      |> put_view(StatusView)
-      |> try_render("status.json", %{activity: activity, for: user, as: :activity})
+      case get_cached_status_or_post(conn, params) do
+        {:ignore, message} ->
+          conn
+          |> put_status(401)
+          |> json(%{error: message})
+
+        {:error, message} ->
+          conn
+          |> put_status(401)
+          |> json(%{error: message})
+
+        {_, activity} ->
+          conn
+          |> put_view(StatusView)
+          |> try_render("status.json", %{activity: activity, for: user, as: :activity})
+      end
     end
   end
 
+  defp get_cached_status_or_post(%{assigns: %{user: user}} = conn, params) do
+    idempotency_key =
+      case get_req_header(conn, "idempotency-key") do
+        [key] -> key
+        _ -> Ecto.UUID.generate()
+      end
+
+    Cachex.fetch(:idempotency_cache, idempotency_key, fn _ ->
+      case CommonAPI.post(user, params) do
+        {:ok, activity} -> activity
+        {:error, message} -> {:ignore, message}
+      end
+    end)
+  end
+
   def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do
     with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do
       json(conn, %{})
index 48268d4f7533a549836cbb3031d2ab5847f1baa1..e1df79ffbb6d6638a3377b0ff1d86d4804b62478 100644 (file)
@@ -146,26 +146,101 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do
     refute id == third_id
   end
 
-  test "posting a poll", %{conn: conn} do
-    user = insert(:user)
-    time = NaiveDateTime.utc_now()
+  describe "posting polls" do
+    test "posting a poll", %{conn: conn} do
+      user = insert(:user)
+      time = NaiveDateTime.utc_now()
 
-    conn =
-      conn
-      |> assign(:user, user)
-      |> post("/api/v1/statuses", %{
-        "status" => "Who is the best girl?",
-        "poll" => %{"options" => ["Rei", "Asuka", "Misato"], "expires_in" => 420}
-      })
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "status" => "Who is the #bestgrill?",
+          "poll" => %{"options" => ["Rei", "Asuka", "Misato"], "expires_in" => 420}
+        })
+
+      response = json_response(conn, 200)
 
-    response = json_response(conn, 200)
+      assert Enum.all?(response["poll"]["options"], fn %{"title" => title} ->
+               title in ["Rei", "Asuka", "Misato"]
+             end)
 
-    assert Enum.all?(response["poll"]["options"], fn %{"title" => title} ->
-             title in ["Rei", "Asuka", "Misato"]
-           end)
+      assert NaiveDateTime.diff(NaiveDateTime.from_iso8601!(response["poll"]["expires_at"]), time) in 420..430
+      refute response["poll"]["expred"]
+    end
 
-    assert NaiveDateTime.diff(NaiveDateTime.from_iso8601!(response["poll"]["expires_at"]), time) in 420..430
-    refute response["poll"]["expred"]
+    test "option limit is enforced", %{conn: conn} do
+      user = insert(:user)
+      limit = Pleroma.Config.get([:instance, :poll_limits, :max_options])
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "status" => "desu~",
+          "poll" => %{"options" => Enum.map(0..limit, fn _ -> "desu" end), "expires_in" => 1}
+        })
+
+      %{"error" => error} = json_response(conn, 401)
+      assert error == "Poll can't contain more than #{limit} options"
+    end
+
+    test "option character limit is enforced", %{conn: conn} do
+      user = insert(:user)
+      limit = Pleroma.Config.get([:instance, :poll_limits, :max_option_chars])
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "status" => "...",
+          "poll" => %{
+            "options" => [Enum.reduce(0..limit, "", fn _, acc -> acc <> "." end)],
+            "expires_in" => 1
+          }
+        })
+
+      %{"error" => error} = json_response(conn, 401)
+      assert error == "Poll options cannot be longer than #{limit} characters each"
+    end
+
+    test "minimal date limit is enforced", %{conn: conn} do
+      user = insert(:user)
+      limit = Pleroma.Config.get([:instance, :poll_limits, :min_expiration])
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "status" => "imagine arbitrary limits",
+          "poll" => %{
+            "options" => ["this post was made by pleroma gang"],
+            "expires_in" => limit - 1
+          }
+        })
+
+      %{"error" => error} = json_response(conn, 401)
+      assert error == "Expiration date is too soon"
+    end
+
+    test "maximum date limit is enforced", %{conn: conn} do
+      user = insert(:user)
+      limit = Pleroma.Config.get([:instance, :poll_limits, :max_expiration])
+
+      conn =
+        conn
+        |> assign(:user, user)
+        |> post("/api/v1/statuses", %{
+          "status" => "imagine arbitrary limits",
+          "poll" => %{
+            "options" => ["this post was made by pleroma gang"],
+            "expires_in" => limit + 1
+          }
+        })
+
+      %{"error" => error} = json_response(conn, 401)
+      assert error == "Expiration date is too far in the future"
+    end
   end
 
   test "posting a sensitive status", %{conn: conn} do