Add CommonAPI.ActivityDraft
authorEgor Kislitsyn <egor@kislitsyn.com>
Tue, 24 Sep 2019 09:10:54 +0000 (16:10 +0700)
committerEgor Kislitsyn <egor@kislitsyn.com>
Thu, 26 Sep 2019 03:29:34 +0000 (10:29 +0700)
lib/pleroma/web/common_api/activity_draft.ex [new file with mode: 0644]
lib/pleroma/web/common_api/common_api.ex
lib/pleroma/web/common_api/utils.ex
lib/pleroma/web/controller_helper.ex
lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex

diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex
new file mode 100644 (file)
index 0000000..b4480bd
--- /dev/null
@@ -0,0 +1,222 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.CommonAPI.ActivityDraft do
+  alias Pleroma.Activity
+  alias Pleroma.Conversation.Participation
+  alias Pleroma.Web.CommonAPI
+  alias Pleroma.Web.CommonAPI.Utils
+
+  import Pleroma.Web.Gettext
+
+  defstruct valid?: true,
+            errors: [],
+            user: nil,
+            params: %{},
+            status: nil,
+            summary: nil,
+            full_payload: nil,
+            attachments: [],
+            in_reply_to: nil,
+            in_reply_to_conversation: nil,
+            visibility: nil,
+            expires_at: nil,
+            poll: nil,
+            emoji: %{},
+            content_html: nil,
+            mentions: [],
+            tags: [],
+            to: [],
+            cc: [],
+            context: nil,
+            sensitive: false,
+            object: nil,
+            preview?: false,
+            changes: %{}
+
+  def create(user, params) do
+    %__MODULE__{user: user}
+    |> put_params(params)
+    |> status()
+    |> summary()
+    |> attachments()
+    |> full_payload()
+    |> in_reply_to()
+    |> in_reply_to_conversation()
+    |> visibility()
+    |> expires_at()
+    |> poll()
+    |> content()
+    |> to_and_cc()
+    |> context()
+    |> sensitive()
+    |> object()
+    |> preview?()
+    |> changes()
+    |> validate()
+  end
+
+  defp put_params(draft, params) do
+    params = Map.put_new(params, "in_reply_to_status_id", params["in_reply_to_id"])
+    %__MODULE__{draft | params: params}
+  end
+
+  defp status(%{params: %{"status" => status}} = draft) do
+    %__MODULE__{draft | status: String.trim(status)}
+  end
+
+  defp summary(%{params: params} = draft) do
+    %__MODULE__{draft | summary: Map.get(params, "spoiler_text", "")}
+  end
+
+  defp full_payload(%{status: status, summary: summary} = draft) do
+    full_payload = String.trim(status <> summary)
+
+    case Utils.validate_character_limit(full_payload, draft.attachments) do
+      :ok -> %__MODULE__{draft | full_payload: full_payload}
+      {:error, message} -> add_error(draft, message)
+    end
+  end
+
+  defp attachments(%{params: params} = draft) do
+    attachments = Utils.attachments_from_ids(params)
+    %__MODULE__{draft | attachments: attachments}
+  end
+
+  defp in_reply_to(draft) do
+    case Map.get(draft.params, "in_reply_to_status_id") do
+      "" -> draft
+      nil -> draft
+      id -> %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)}
+    end
+  end
+
+  defp in_reply_to_conversation(draft) do
+    in_reply_to_conversation = Participation.get(draft.params["in_reply_to_conversation_id"])
+    %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation}
+  end
+
+  defp visibility(%{params: params} = draft) do
+    case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do
+      {visibility, "direct"} when visibility != "direct" ->
+        add_error(draft, dgettext("errors", "The message visibility must be direct"))
+
+      {visibility, _} ->
+        %__MODULE__{draft | visibility: visibility}
+    end
+  end
+
+  defp expires_at(draft) do
+    case CommonAPI.check_expiry_date(draft.params["expires_in"]) do
+      {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at}
+      {:error, message} -> add_error(draft, message)
+    end
+  end
+
+  defp poll(draft) do
+    case Utils.make_poll_data(draft.params) do
+      {:ok, {poll, poll_emoji}} ->
+        %__MODULE__{draft | poll: poll, emoji: Map.merge(draft.emoji, poll_emoji)}
+
+      {:error, message} ->
+        add_error(draft, message)
+    end
+  end
+
+  defp content(draft) do
+    {content_html, mentions, tags} =
+      Utils.make_content_html(
+        draft.status,
+        draft.attachments,
+        draft.params,
+        draft.visibility
+      )
+
+    %__MODULE__{draft | content_html: content_html, mentions: mentions, tags: tags}
+  end
+
+  defp to_and_cc(%{valid?: false} = draft), do: draft
+
+  defp to_and_cc(draft) do
+    addressed_users =
+      draft.mentions
+      |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end)
+      |> Utils.get_addressed_users(draft.params["to"])
+
+    {to, cc} =
+      Utils.get_to_and_cc(
+        draft.user,
+        addressed_users,
+        draft.in_reply_to,
+        draft.visibility,
+        draft.in_reply_to_conversation
+      )
+
+    %__MODULE__{draft | to: to, cc: cc}
+  end
+
+  defp context(draft) do
+    context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation)
+    %__MODULE__{draft | context: context}
+  end
+
+  defp sensitive(draft) do
+    sensitive = draft.params["sensitive"] || Enum.member?(draft.tags, {"#nsfw", "nsfw"})
+    %__MODULE__{draft | sensitive: sensitive}
+  end
+
+  defp object(%{valid?: false} = draft), do: draft
+
+  defp object(draft) do
+    emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji)
+
+    object =
+      Utils.make_note_data(
+        draft.user.ap_id,
+        draft.to,
+        draft.context,
+        draft.content_html,
+        draft.attachments,
+        draft.in_reply_to,
+        draft.tags,
+        draft.summary,
+        draft.cc,
+        draft.sensitive,
+        draft.poll
+      )
+      |> Map.put("emoji", emoji)
+
+    %__MODULE__{draft | object: object}
+  end
+
+  defp preview?(draft) do
+    preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params["preview"]) || false
+    %__MODULE__{draft | preview?: preview?}
+  end
+
+  defp changes(%{valid?: false} = draft), do: draft
+
+  defp changes(draft) do
+    direct? = draft.visibility == "direct"
+
+    changes =
+      %{
+        to: draft.to,
+        actor: draft.user,
+        context: draft.context,
+        object: draft.object,
+        additional: %{"cc" => draft.cc, "directMessage" => direct?}
+      }
+      |> Utils.maybe_add_list_data(draft.user, draft.visibility)
+
+    %__MODULE__{draft | changes: changes}
+  end
+
+  defp add_error(draft, message) do
+    %__MODULE__{draft | valid?: false, errors: [message | draft.errors]}
+  end
+
+  defp validate(%{valid?: true} = draft), do: {:ok, draft}
+  defp validate(%{errors: [message | _]}), do: {:error, message}
+end
index 4a74dc16f128cd459cf04efdf63211666f073d24..d34bb72859b242cb3f867a47c1f0daaa844fcabb 100644 (file)
@@ -6,7 +6,6 @@ defmodule Pleroma.Web.CommonAPI do
   alias Pleroma.Activity
   alias Pleroma.ActivityExpiration
   alias Pleroma.Conversation.Participation
-  alias Pleroma.Emoji
   alias Pleroma.Object
   alias Pleroma.ThreadMute
   alias Pleroma.User
@@ -173,9 +172,7 @@ defmodule Pleroma.Web.CommonAPI do
     end)
   end
 
-  def get_visibility(_, _, %Participation{}) do
-    {"direct", "direct"}
-  end
+  def get_visibility(_, _, %Participation{}), do: {"direct", "direct"}
 
   def get_visibility(%{"visibility" => visibility}, in_reply_to, _)
       when visibility in ~w{public unlisted private direct},
@@ -201,9 +198,9 @@ defmodule Pleroma.Web.CommonAPI do
     end
   end
 
-  defp check_expiry_date({:ok, nil} = res), do: res
+  def check_expiry_date({:ok, nil} = res), do: res
 
-  defp check_expiry_date({:ok, in_seconds}) do
+  def check_expiry_date({:ok, in_seconds}) do
     expiry = NaiveDateTime.utc_now() |> NaiveDateTime.add(in_seconds)
 
     if ActivityExpiration.expires_late_enough?(expiry) do
@@ -213,97 +210,27 @@ defmodule Pleroma.Web.CommonAPI do
     end
   end
 
-  defp check_expiry_date(expiry_str) do
+  def check_expiry_date(expiry_str) do
     Ecto.Type.cast(:integer, expiry_str)
     |> check_expiry_date()
   end
 
-  def post(user, %{"status" => status} = data) do
-    limit = Pleroma.Config.get([:instance, :limit])
-
-    with status <- String.trim(status),
-         attachments <- attachments_from_ids(data),
-         in_reply_to <- get_replied_to_activity(data["in_reply_to_status_id"]),
-         in_reply_to_conversation <- Participation.get(data["in_reply_to_conversation_id"]),
-         {visibility, in_reply_to_visibility} <-
-           get_visibility(data, in_reply_to, in_reply_to_conversation),
-         {_, false} <-
-           {:private_to_public, in_reply_to_visibility == "direct" && visibility != "direct"},
-         {content_html, mentions, tags} <-
-           make_content_html(
-             status,
-             attachments,
-             data,
-             visibility
-           ),
-         mentioned_users <- for({_, mentioned_user} <- mentions, do: mentioned_user.ap_id),
-         addressed_users <- get_addressed_users(mentioned_users, data["to"]),
-         {poll, poll_emoji} <- make_poll_data(data),
-         {to, cc} <-
-           get_to_and_cc(user, addressed_users, in_reply_to, visibility, in_reply_to_conversation),
-         context <- make_context(in_reply_to, in_reply_to_conversation),
-         cw <- data["spoiler_text"] || "",
-         sensitive <- data["sensitive"] || Enum.member?(tags, {"#nsfw", "nsfw"}),
-         {:ok, expires_at} <- check_expiry_date(data["expires_in"]),
-         full_payload <- String.trim(status <> cw),
-         :ok <- validate_character_limit(full_payload, attachments, limit),
-         object <-
-           make_note_data(
-             user.ap_id,
-             to,
-             context,
-             content_html,
-             attachments,
-             in_reply_to,
-             tags,
-             cw,
-             cc,
-             sensitive,
-             poll
-           ),
-         object <- put_emoji(object, full_payload, poll_emoji) do
-      preview? = Pleroma.Web.ControllerHelper.truthy_param?(data["preview"]) || false
-      direct? = visibility == "direct"
-
-      result =
-        %{
-          to: to,
-          actor: user,
-          context: context,
-          object: object,
-          additional: %{"cc" => cc, "directMessage" => direct?}
-        }
-        |> maybe_add_list_data(user, visibility)
-        |> ActivityPub.create(preview?)
-
-      if expires_at do
-        with {:ok, activity} <- result do
-          {:ok, _} = ActivityExpiration.create(activity, expires_at)
-        end
-      end
-
-      result
-    else
-      {:private_to_public, true} ->
-        {:error, dgettext("errors", "The message visibility must be direct")}
-
-      {:error, _} = e ->
-        e
-
-      e ->
-        {:error, e}
+  def post(user, %{"status" => _} = data) do
+    with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do
+      draft.changes
+      |> ActivityPub.create(draft.preview?)
+      |> maybe_create_activity_expiration(draft.expires_at)
     end
   end
 
-  # parse and put emoji to object data
-  defp put_emoji(map, text, emojis) do
-    Map.put(
-      map,
-      "emoji",
-      Map.merge(Emoji.Formatter.get_emoji_map(text), emojis)
-    )
+  defp maybe_create_activity_expiration({:ok, activity}, %NaiveDateTime{} = expires_at) do
+    with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do
+      {:ok, activity}
+    end
   end
 
+  defp maybe_create_activity_expiration(result, _), do: result
+
   # Updates the emojis for a user based on their profile
   def update(user) do
     emoji = emoji_from_profile(user)
index 52fbc162be6920156b867fe678137327670945ab..8093a56a683f134005d414869eae53b221199801 100644 (file)
@@ -4,6 +4,7 @@
 
 defmodule Pleroma.Web.CommonAPI.Utils do
   import Pleroma.Web.Gettext
+  import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1]
 
   alias Calendar.Strftime
   alias Pleroma.Activity
@@ -41,14 +42,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do
       end
   end
 
-  def get_replied_to_activity(""), do: nil
-
-  def get_replied_to_activity(id) when not is_nil(id) do
-    Activity.get_by_id(id)
-  end
-
-  def get_replied_to_activity(_), do: nil
-
   def attachments_from_ids(%{"media_ids" => ids, "descriptions" => desc} = _) do
     attachments_from_ids_descs(ids, desc)
   end
@@ -159,70 +152,74 @@ defmodule Pleroma.Web.CommonAPI.Utils do
 
   def maybe_add_list_data(activity_params, _, _), do: activity_params
 
+  def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
+      when is_binary(expires_in) do
+    # In some cases mastofe sends out strings instead of integers
+    data
+    |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
+    |> make_poll_data()
+  end
+
   def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data)
       when is_list(options) do
-    %{max_expiration: max_expiration, min_expiration: min_expiration} =
-      limits = Pleroma.Config.get([:instance, :poll_limits])
-
-    # XXX: There is probably a cleaner way of doing this
-    try do
-      # In some cases mastofe sends out strings instead of integers
-      expires_in = if is_binary(expires_in), do: String.to_integer(expires_in), else: expires_in
-
-      if Enum.count(options) > limits.max_options do
-        raise ArgumentError, message: "Poll can't contain more than #{limits.max_options} options"
-      end
+    limits = Pleroma.Config.get([:instance, :poll_limits])
 
-      {poll, emoji} =
+    with :ok <- validate_poll_expiration(expires_in, limits),
+         :ok <- validate_poll_options_amount(options, limits),
+         :ok <- validate_poll_options_length(options, limits) do
+      {option_notes, 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, 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"
+          note = %{
+            "name" => option,
+            "type" => "Note",
+            "replies" => %{"type" => "Collection", "totalItems" => 0}
+          }
 
-        _ ->
-          :noop
-      end
+          {note, Map.merge(emoji, Emoji.Formatter.get_emoji_map(option))}
+        end)
 
       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
+      key = if truthy_param?(data["poll"]["multiple"]), do: "anyOf", else: "oneOf"
+      poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
 
-      {poll, emoji}
-    rescue
-      e in ArgumentError -> e.message
+      {:ok, {poll, emoji}}
     end
   end
 
   def make_poll_data(%{"poll" => poll}) when is_map(poll) do
-    "Invalid poll"
+    {:error, "Invalid poll"}
   end
 
   def make_poll_data(_data) do
-    {%{}, %{}}
+    {:ok, {%{}, %{}}}
+  end
+
+  defp validate_poll_options_amount(options, %{max_options: max_options}) do
+    if Enum.count(options) > max_options do
+      {:error, "Poll can't contain more than #{max_options} options"}
+    else
+      :ok
+    end
+  end
+
+  defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
+    if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
+      {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
+    else
+      :ok
+    end
+  end
+
+  defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
+    cond do
+      expires_in > max -> {:error, "Expiration date is too far in the future"}
+      expires_in < min -> {:error, "Expiration date is too soon"}
+      true -> :ok
+    end
   end
 
   def make_content_html(
@@ -347,25 +344,25 @@ defmodule Pleroma.Web.CommonAPI.Utils do
         attachments,
         in_reply_to,
         tags,
-        cw \\ nil,
+        summary \\ nil,
         cc \\ [],
         sensitive \\ false,
-        merge \\ %{}
+        extra_params \\ %{}
       ) do
     %{
       "type" => "Note",
       "to" => to,
       "cc" => cc,
       "content" => content_html,
-      "summary" => cw,
-      "sensitive" => !Enum.member?(["false", "False", "0", false], sensitive),
+      "summary" => summary,
+      "sensitive" => truthy_param?(sensitive),
       "context" => context,
       "attachment" => attachments,
       "actor" => actor,
       "tag" => Keyword.values(tags) |> Enum.uniq()
     }
     |> add_in_reply_to(in_reply_to)
-    |> Map.merge(merge)
+    |> Map.merge(extra_params)
   end
 
   defp add_in_reply_to(object, nil), do: object
@@ -571,15 +568,16 @@ defmodule Pleroma.Web.CommonAPI.Utils do
     }
   end
 
-  def validate_character_limit(full_payload, attachments, limit) do
+  def validate_character_limit("" = _full_payload, [] = _attachments) do
+    {:error, dgettext("errors", "Cannot post an empty status without attachments")}
+  end
+
+  def validate_character_limit(full_payload, _attachments) do
+    limit = Pleroma.Config.get([:instance, :limit])
     length = String.length(full_payload)
 
     if length < limit do
-      if length > 0 or Enum.count(attachments) > 0 do
-        :ok
-      else
-        {:error, dgettext("errors", "Cannot post an empty status without attachments")}
-      end
+      :ok
     else
       {:error, dgettext("errors", "The status is over the character limit")}
     end
index b53a01955d3cf0c1b60a7072385ec3a8b4c0b3f9..e90bf842ee37bf625dea3da85accc85f4a227865 100644 (file)
@@ -6,7 +6,7 @@ defmodule Pleroma.Web.ControllerHelper do
   use Pleroma.Web, :controller
 
   # As in MastoAPI, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html
-  @falsy_param_values [false, 0, "0", "f", "F", "false", "FALSE", "off", "OFF"]
+  @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"]
   def truthy_param?(blank_value) when blank_value in [nil, ""], do: nil
   def truthy_param?(value), do: value not in @falsy_param_values
 
index 1e88ff7feec2c1dba5a2772911f0d8881e17a2c6..28d0e58f38f5a814ec1d6acac4360b4a487d3337 100644 (file)
@@ -575,14 +575,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
     end
   end
 
-  def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do
-    params =
-      params
-      |> Map.put("in_reply_to_status_id", params["in_reply_to_id"])
-
-    scheduled_at = params["scheduled_at"]
-
-    if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do
+  def post_status(
+        %{assigns: %{user: user}} = conn,
+        %{"status" => _, "scheduled_at" => scheduled_at} = params
+      ) do
+    if ScheduledActivity.far_enough?(scheduled_at) do
       with {:ok, scheduled_activity} <-
              ScheduledActivity.create(user, %{"params" => params, "scheduled_at" => scheduled_at}) do
         conn
@@ -590,24 +587,26 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
         |> render("show.json", %{scheduled_activity: scheduled_activity})
       end
     else
-      params = Map.drop(params, ["scheduled_at"])
-
-      case CommonAPI.post(user, params) do
-        {:error, message} ->
-          conn
-          |> put_status(:unprocessable_entity)
-          |> json(%{error: message})
-
-        {:ok, activity} ->
-          conn
-          |> put_view(StatusView)
-          |> try_render("status.json", %{
-            activity: activity,
-            for: user,
-            as: :activity,
-            with_direct_conversation_id: true
-          })
-      end
+      post_status(conn, Map.drop(params, ["scheduled_at"]))
+    end
+  end
+
+  def post_status(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do
+    case CommonAPI.post(user, params) do
+      {:ok, activity} ->
+        conn
+        |> put_view(StatusView)
+        |> try_render("status.json", %{
+          activity: activity,
+          for: user,
+          as: :activity,
+          with_direct_conversation_id: true
+        })
+
+      {:error, message} ->
+        conn
+        |> put_status(:unprocessable_entity)
+        |> json(%{error: message})
     end
   end