--- /dev/null
+# 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
alias Pleroma.Activity
alias Pleroma.ActivityExpiration
alias Pleroma.Conversation.Participation
- alias Pleroma.Emoji
alias Pleroma.Object
alias Pleroma.ThreadMute
alias Pleroma.User
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},
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
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)
defmodule Pleroma.Web.CommonAPI.Utils do
import Pleroma.Web.Gettext
+ import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1]
alias Calendar.Strftime
alias Pleroma.Activity
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
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(
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
}
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
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
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
|> 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