37a7f521fd
Cannot be done in Ecto schemas because only one type is allowed in arrays, and needs to be done before the MRFs.
573 lines
19 KiB
Elixir
573 lines
19 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.Web.CommonAPI do
|
|
alias Pleroma.Activity
|
|
alias Pleroma.Conversation.Participation
|
|
alias Pleroma.Formatter
|
|
alias Pleroma.Object
|
|
alias Pleroma.ThreadMute
|
|
alias Pleroma.User
|
|
alias Pleroma.UserRelationship
|
|
alias Pleroma.Web.ActivityPub.ActivityPub
|
|
alias Pleroma.Web.ActivityPub.Builder
|
|
alias Pleroma.Web.ActivityPub.Pipeline
|
|
alias Pleroma.Web.ActivityPub.Utils
|
|
alias Pleroma.Web.ActivityPub.Visibility
|
|
alias Pleroma.Web.CommonAPI.ActivityDraft
|
|
|
|
import Pleroma.Web.Gettext
|
|
import Pleroma.Web.CommonAPI.Utils
|
|
|
|
require Pleroma.Constants
|
|
require Logger
|
|
|
|
def block(blocker, blocked) do
|
|
with {:ok, block_data, _} <- Builder.block(blocker, blocked),
|
|
{:ok, block, _} <- Pipeline.common_pipeline(block_data, local: true) do
|
|
{:ok, block}
|
|
end
|
|
end
|
|
|
|
def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do
|
|
with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]),
|
|
:ok <- validate_chat_content_length(content, !!maybe_attachment),
|
|
{_, {:ok, chat_message_data, _meta}} <-
|
|
{:build_object,
|
|
Builder.chat_message(
|
|
user,
|
|
recipient.ap_id,
|
|
content |> format_chat_content,
|
|
attachment: maybe_attachment
|
|
)},
|
|
{_, {:ok, create_activity_data, _meta}} <-
|
|
{:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])},
|
|
{_, {:ok, %Activity{} = activity, _meta}} <-
|
|
{:common_pipeline,
|
|
Pipeline.common_pipeline(create_activity_data,
|
|
local: true,
|
|
idempotency_key: opts[:idempotency_key]
|
|
)} do
|
|
{:ok, activity}
|
|
else
|
|
{:common_pipeline, {:reject, _} = e} -> e
|
|
e -> e
|
|
end
|
|
end
|
|
|
|
defp format_chat_content(nil), do: nil
|
|
|
|
defp format_chat_content(content) do
|
|
{text, _, _} =
|
|
content
|
|
|> Formatter.html_escape("text/plain")
|
|
|> Formatter.linkify()
|
|
|> (fn {text, mentions, tags} ->
|
|
{String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
|
|
end).()
|
|
|
|
text
|
|
end
|
|
|
|
defp validate_chat_content_length(_, true), do: :ok
|
|
defp validate_chat_content_length(nil, false), do: {:error, :no_content}
|
|
|
|
defp validate_chat_content_length(content, _) do
|
|
if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do
|
|
:ok
|
|
else
|
|
{:error, :content_too_long}
|
|
end
|
|
end
|
|
|
|
def unblock(blocker, blocked) do
|
|
with {_, %Activity{} = block} <- {:fetch_block, Utils.fetch_latest_block(blocker, blocked)},
|
|
{:ok, unblock_data, _} <- Builder.undo(blocker, block),
|
|
{:ok, unblock, _} <- Pipeline.common_pipeline(unblock_data, local: true) do
|
|
{:ok, unblock}
|
|
else
|
|
{:fetch_block, nil} ->
|
|
if User.blocks?(blocker, blocked) do
|
|
User.unblock(blocker, blocked)
|
|
{:ok, :no_activity}
|
|
else
|
|
{:error, :not_blocking}
|
|
end
|
|
|
|
e ->
|
|
e
|
|
end
|
|
end
|
|
|
|
def follow(follower, followed) do
|
|
timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout])
|
|
|
|
with {:ok, follow_data, _} <- Builder.follow(follower, followed),
|
|
{:ok, activity, _} <- Pipeline.common_pipeline(follow_data, local: true),
|
|
{:ok, follower, followed} <- User.wait_and_refresh(timeout, follower, followed) do
|
|
if activity.data["state"] == "reject" do
|
|
{:error, :rejected}
|
|
else
|
|
{:ok, follower, followed, activity}
|
|
end
|
|
end
|
|
end
|
|
|
|
def unfollow(follower, unfollowed) do
|
|
with {:ok, follower, _follow_activity} <- User.unfollow(follower, unfollowed),
|
|
{:ok, _activity} <- ActivityPub.unfollow(follower, unfollowed),
|
|
{:ok, _subscription} <- User.unsubscribe(follower, unfollowed) do
|
|
{:ok, follower}
|
|
end
|
|
end
|
|
|
|
def accept_follow_request(follower, followed) do
|
|
with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
|
|
{:ok, accept_data, _} <- Builder.accept(followed, follow_activity),
|
|
{:ok, _activity, _} <- Pipeline.common_pipeline(accept_data, local: true) do
|
|
{:ok, follower}
|
|
end
|
|
end
|
|
|
|
def reject_follow_request(follower, followed) do
|
|
with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed),
|
|
{:ok, reject_data, _} <- Builder.reject(followed, follow_activity),
|
|
{:ok, _activity, _} <- Pipeline.common_pipeline(reject_data, local: true) do
|
|
{:ok, follower}
|
|
end
|
|
end
|
|
|
|
def delete(activity_id, user) do
|
|
with {_, %Activity{data: %{"object" => _, "type" => "Create"}} = activity} <-
|
|
{:find_activity, Activity.get_by_id(activity_id)},
|
|
{_, %Object{} = object, _} <-
|
|
{:find_object, Object.normalize(activity, fetch: false), activity},
|
|
true <- User.superuser?(user) || user.ap_id == object.data["actor"],
|
|
{:ok, delete_data, _} <- Builder.delete(user, object.data["id"]),
|
|
{:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
|
|
{:ok, delete}
|
|
else
|
|
{:find_activity, _} ->
|
|
{:error, :not_found}
|
|
|
|
{:find_object, nil, %Activity{data: %{"actor" => actor, "object" => object}}} ->
|
|
# We have the create activity, but not the object, it was probably pruned.
|
|
# Insert a tombstone and try again
|
|
with {:ok, tombstone_data, _} <- Builder.tombstone(actor, object),
|
|
{:ok, _tombstone} <- Object.create(tombstone_data) do
|
|
delete(activity_id, user)
|
|
else
|
|
_ ->
|
|
Logger.error(
|
|
"Could not insert tombstone for missing object on deletion. Object is #{object}."
|
|
)
|
|
|
|
{:error, dgettext("errors", "Could not delete")}
|
|
end
|
|
|
|
_ ->
|
|
{:error, dgettext("errors", "Could not delete")}
|
|
end
|
|
end
|
|
|
|
def repeat(id, user, params \\ %{}) do
|
|
with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id),
|
|
object = %Object{} <- Object.normalize(activity, fetch: false),
|
|
{_, nil} <- {:existing_announce, Utils.get_existing_announce(user.ap_id, object)},
|
|
public = public_announce?(object, params),
|
|
{:ok, announce, _} <- Builder.announce(user, object, public: public),
|
|
{:ok, activity, _} <- Pipeline.common_pipeline(announce, local: true) do
|
|
{:ok, activity}
|
|
else
|
|
{:existing_announce, %Activity{} = announce} ->
|
|
{:ok, announce}
|
|
|
|
_ ->
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
|
|
def unrepeat(id, user) do
|
|
with {_, %Activity{data: %{"type" => "Create"}} = activity} <-
|
|
{:find_activity, Activity.get_by_id(id)},
|
|
%Object{} = note <- Object.normalize(activity, fetch: false),
|
|
%Activity{} = announce <- Utils.get_existing_announce(user.ap_id, note),
|
|
{:ok, undo, _} <- Builder.undo(user, announce),
|
|
{:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
|
|
{:ok, activity}
|
|
else
|
|
{:find_activity, _} -> {:error, :not_found}
|
|
_ -> {:error, dgettext("errors", "Could not unrepeat")}
|
|
end
|
|
end
|
|
|
|
@spec favorite(User.t(), binary()) :: {:ok, Activity.t() | :already_liked} | {:error, any()}
|
|
def favorite(%User{} = user, id) do
|
|
case favorite_helper(user, id) do
|
|
{:ok, _} = res ->
|
|
res
|
|
|
|
{:error, :not_found} = res ->
|
|
res
|
|
|
|
{:error, e} ->
|
|
Logger.error("Could not favorite #{id}. Error: #{inspect(e, pretty: true)}")
|
|
{:error, dgettext("errors", "Could not favorite")}
|
|
end
|
|
end
|
|
|
|
def favorite_helper(user, id) do
|
|
with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)},
|
|
{_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)},
|
|
{_, {:ok, %Activity{} = activity, _meta}} <-
|
|
{:common_pipeline,
|
|
Pipeline.common_pipeline(like_object, Keyword.put(meta, :local, true))} do
|
|
{:ok, activity}
|
|
else
|
|
{:find_object, _} ->
|
|
{:error, :not_found}
|
|
|
|
{:common_pipeline, {:error, {:validate, {:error, changeset}}}} = e ->
|
|
if {:object, {"already liked by this actor", []}} in changeset.errors do
|
|
{:ok, :already_liked}
|
|
else
|
|
{:error, e}
|
|
end
|
|
|
|
e ->
|
|
{:error, e}
|
|
end
|
|
end
|
|
|
|
def unfavorite(id, user) do
|
|
with {_, %Activity{data: %{"type" => "Create"}} = activity} <-
|
|
{:find_activity, Activity.get_by_id(id)},
|
|
%Object{} = note <- Object.normalize(activity, fetch: false),
|
|
%Activity{} = like <- Utils.get_existing_like(user.ap_id, note),
|
|
{:ok, undo, _} <- Builder.undo(user, like),
|
|
{:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
|
|
{:ok, activity}
|
|
else
|
|
{:find_activity, _} -> {:error, :not_found}
|
|
_ -> {:error, dgettext("errors", "Could not unfavorite")}
|
|
end
|
|
end
|
|
|
|
def react_with_emoji(id, user, emoji) do
|
|
with %Activity{} = activity <- Activity.get_by_id(id),
|
|
object <- Object.normalize(activity, fetch: false),
|
|
{:ok, emoji_react, _} <- Builder.emoji_react(user, object, emoji),
|
|
{:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do
|
|
{:ok, activity}
|
|
else
|
|
_ ->
|
|
{:error, dgettext("errors", "Could not add reaction emoji")}
|
|
end
|
|
end
|
|
|
|
def unreact_with_emoji(id, user, emoji) do
|
|
with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji),
|
|
{:ok, undo, _} <- Builder.undo(user, reaction_activity),
|
|
{:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do
|
|
{:ok, activity}
|
|
else
|
|
_ ->
|
|
{:error, dgettext("errors", "Could not remove reaction emoji")}
|
|
end
|
|
end
|
|
|
|
def vote(user, %{data: %{"type" => "Question"}} = object, choices) do
|
|
with :ok <- validate_not_author(object, user),
|
|
:ok <- validate_existing_votes(user, object),
|
|
{:ok, options, choices} <- normalize_and_validate_choices(choices, object) do
|
|
answer_activities =
|
|
Enum.map(choices, fn index ->
|
|
{:ok, answer_object, _meta} =
|
|
Builder.answer(user, object, Enum.at(options, index)["name"])
|
|
|
|
{:ok, activity_data, _meta} = Builder.create(user, answer_object, [])
|
|
|
|
{:ok, activity, _meta} =
|
|
activity_data
|
|
|> Map.put("cc", answer_object["cc"])
|
|
|> Map.put("context", answer_object["context"])
|
|
|> Pipeline.common_pipeline(local: true)
|
|
|
|
# TODO: Do preload of Pleroma.Object in Pipeline
|
|
Activity.normalize(activity.data)
|
|
end)
|
|
|
|
object = Object.get_cached_by_ap_id(object.data["id"])
|
|
{:ok, answer_activities, object}
|
|
end
|
|
end
|
|
|
|
defp validate_not_author(%{data: %{"actor" => ap_id}}, %{ap_id: ap_id}),
|
|
do: {:error, dgettext("errors", "Poll's author can't vote")}
|
|
|
|
defp validate_not_author(_, _), do: :ok
|
|
|
|
defp validate_existing_votes(%{ap_id: ap_id}, object) do
|
|
if Utils.get_existing_votes(ap_id, object) == [] do
|
|
:ok
|
|
else
|
|
{:error, dgettext("errors", "Already voted")}
|
|
end
|
|
end
|
|
|
|
defp get_options_and_max_count(%{data: %{"anyOf" => any_of}})
|
|
when is_list(any_of) and any_of != [],
|
|
do: {any_of, Enum.count(any_of)}
|
|
|
|
defp get_options_and_max_count(%{data: %{"oneOf" => one_of}})
|
|
when is_list(one_of) and one_of != [],
|
|
do: {one_of, 1}
|
|
|
|
defp normalize_and_validate_choices(choices, object) do
|
|
choices = Enum.map(choices, fn i -> if is_binary(i), do: String.to_integer(i), else: i end)
|
|
{options, max_count} = get_options_and_max_count(object)
|
|
count = Enum.count(options)
|
|
|
|
with {_, true} <- {:valid_choice, Enum.all?(choices, &(&1 < count))},
|
|
{_, true} <- {:count_check, Enum.count(choices) <= max_count} do
|
|
{:ok, options, choices}
|
|
else
|
|
{:valid_choice, _} -> {:error, dgettext("errors", "Invalid indices")}
|
|
{:count_check, _} -> {:error, dgettext("errors", "Too many choices")}
|
|
end
|
|
end
|
|
|
|
def public_announce?(_, %{visibility: visibility})
|
|
when visibility in ~w{public unlisted private direct},
|
|
do: visibility in ~w(public unlisted)
|
|
|
|
def public_announce?(object, _) do
|
|
Visibility.is_public?(object)
|
|
end
|
|
|
|
def get_visibility(_, _, %Participation{}), do: {"direct", "direct"}
|
|
|
|
def get_visibility(%{visibility: visibility}, in_reply_to, _)
|
|
when visibility in ~w{public local unlisted private direct},
|
|
do: {visibility, get_replied_to_visibility(in_reply_to)}
|
|
|
|
def get_visibility(%{visibility: "list:" <> list_id}, in_reply_to, _) do
|
|
visibility = {:list, String.to_integer(list_id)}
|
|
{visibility, get_replied_to_visibility(in_reply_to)}
|
|
end
|
|
|
|
def get_visibility(_, in_reply_to, _) when not is_nil(in_reply_to) do
|
|
visibility = get_replied_to_visibility(in_reply_to)
|
|
{visibility, visibility}
|
|
end
|
|
|
|
def get_visibility(_, in_reply_to, _), do: {"public", get_replied_to_visibility(in_reply_to)}
|
|
|
|
def get_replied_to_visibility(nil), do: nil
|
|
|
|
def get_replied_to_visibility(activity) do
|
|
with %Object{} = object <- Object.normalize(activity, fetch: false) do
|
|
Visibility.get_visibility(object)
|
|
end
|
|
end
|
|
|
|
def check_expiry_date({:ok, nil} = res), do: res
|
|
|
|
def check_expiry_date({:ok, in_seconds}) do
|
|
expiry = DateTime.add(DateTime.utc_now(), in_seconds)
|
|
|
|
if Pleroma.Workers.PurgeExpiredActivity.expires_late_enough?(expiry) do
|
|
{:ok, expiry}
|
|
else
|
|
{:error, "Expiry date is too soon"}
|
|
end
|
|
end
|
|
|
|
def check_expiry_date(expiry_str) do
|
|
Ecto.Type.cast(:integer, expiry_str)
|
|
|> check_expiry_date()
|
|
end
|
|
|
|
def listen(user, data) do
|
|
with {:ok, draft} <- ActivityDraft.listen(user, data) do
|
|
ActivityPub.listen(draft.changes)
|
|
end
|
|
end
|
|
|
|
def post(user, %{status: _} = data) do
|
|
with {:ok, draft} <- ActivityDraft.create(user, data) do
|
|
ActivityPub.create(draft.changes, draft.preview?)
|
|
end
|
|
end
|
|
|
|
def pin(id, %{ap_id: user_ap_id} = user) do
|
|
with %Activity{
|
|
actor: ^user_ap_id,
|
|
data: %{"type" => "Create"},
|
|
object: %Object{data: %{"type" => object_type}}
|
|
} = activity <- Activity.get_by_id_with_object(id),
|
|
true <- object_type in ["Note", "Article", "Question"],
|
|
true <- Visibility.is_public?(activity),
|
|
{:ok, _user} <- User.add_pinnned_activity(user, activity) do
|
|
{:ok, activity}
|
|
else
|
|
{:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err}
|
|
_ -> {:error, dgettext("errors", "Could not pin")}
|
|
end
|
|
end
|
|
|
|
def unpin(id, user) do
|
|
with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id),
|
|
{:ok, _user} <- User.remove_pinnned_activity(user, activity) do
|
|
{:ok, activity}
|
|
else
|
|
{:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err}
|
|
_ -> {:error, dgettext("errors", "Could not unpin")}
|
|
end
|
|
end
|
|
|
|
def add_mute(user, activity, params \\ %{}) do
|
|
expires_in = Map.get(params, :expires_in, 0)
|
|
|
|
with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]),
|
|
_ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do
|
|
if expires_in > 0 do
|
|
Pleroma.Workers.MuteExpireWorker.enqueue(
|
|
"unmute_conversation",
|
|
%{"user_id" => user.id, "activity_id" => activity.id},
|
|
schedule_in: expires_in
|
|
)
|
|
end
|
|
|
|
{:ok, activity}
|
|
else
|
|
{:error, _} -> {:error, dgettext("errors", "conversation is already muted")}
|
|
end
|
|
end
|
|
|
|
def remove_mute(%User{} = user, %Activity{} = activity) do
|
|
ThreadMute.remove_mute(user.id, activity.data["context"])
|
|
{:ok, activity}
|
|
end
|
|
|
|
def remove_mute(user_id, activity_id) do
|
|
with {:user, %User{} = user} <- {:user, User.get_by_id(user_id)},
|
|
{:activity, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)} do
|
|
remove_mute(user, activity)
|
|
else
|
|
{what, result} = error ->
|
|
Logger.warn(
|
|
"CommonAPI.remove_mute/2 failed. #{what}: #{result}, user_id: #{user_id}, activity_id: #{
|
|
activity_id
|
|
}"
|
|
)
|
|
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}})
|
|
when is_binary(context) do
|
|
ThreadMute.exists?(user_id, context)
|
|
end
|
|
|
|
def thread_muted?(_, _), do: false
|
|
|
|
def report(user, data) do
|
|
with {:ok, account} <- get_reported_account(data.account_id),
|
|
{:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]),
|
|
{:ok, statuses} <- get_report_statuses(account, data) do
|
|
ActivityPub.flag(%{
|
|
context: Utils.generate_context_id(),
|
|
actor: user,
|
|
account: account,
|
|
statuses: statuses,
|
|
content: content_html,
|
|
forward: Map.get(data, :forward, false)
|
|
})
|
|
end
|
|
end
|
|
|
|
defp get_reported_account(account_id) do
|
|
case User.get_cached_by_id(account_id) do
|
|
%User{} = account -> {:ok, account}
|
|
_ -> {:error, dgettext("errors", "Account not found")}
|
|
end
|
|
end
|
|
|
|
def update_report_state(activity_ids, state) when is_list(activity_ids) do
|
|
case Utils.update_report_state(activity_ids, state) do
|
|
:ok -> {:ok, activity_ids}
|
|
_ -> {:error, dgettext("errors", "Could not update state")}
|
|
end
|
|
end
|
|
|
|
def update_report_state(activity_id, state) do
|
|
with %Activity{} = activity <- Activity.get_by_id(activity_id) do
|
|
Utils.update_report_state(activity, state)
|
|
else
|
|
nil -> {:error, :not_found}
|
|
_ -> {:error, dgettext("errors", "Could not update state")}
|
|
end
|
|
end
|
|
|
|
def update_activity_scope(activity_id, opts \\ %{}) do
|
|
with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id),
|
|
{:ok, activity} <- toggle_sensitive(activity, opts) do
|
|
set_visibility(activity, opts)
|
|
else
|
|
nil -> {:error, :not_found}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
defp toggle_sensitive(activity, %{sensitive: sensitive}) when sensitive in ~w(true false) do
|
|
toggle_sensitive(activity, %{sensitive: String.to_existing_atom(sensitive)})
|
|
end
|
|
|
|
defp toggle_sensitive(%Activity{object: object} = activity, %{sensitive: sensitive})
|
|
when is_boolean(sensitive) do
|
|
new_data = Map.put(object.data, "sensitive", sensitive)
|
|
|
|
{:ok, object} =
|
|
object
|
|
|> Object.change(%{data: new_data})
|
|
|> Object.update_and_set_cache()
|
|
|
|
{:ok, Map.put(activity, :object, object)}
|
|
end
|
|
|
|
defp toggle_sensitive(activity, _), do: {:ok, activity}
|
|
|
|
defp set_visibility(activity, %{visibility: visibility}) do
|
|
Utils.update_activity_visibility(activity, visibility)
|
|
end
|
|
|
|
defp set_visibility(activity, _), do: {:ok, activity}
|
|
|
|
def hide_reblogs(%User{} = user, %User{} = target) do
|
|
UserRelationship.create_reblog_mute(user, target)
|
|
end
|
|
|
|
def show_reblogs(%User{} = user, %User{} = target) do
|
|
UserRelationship.delete_reblog_mute(user, target)
|
|
end
|
|
|
|
def get_user(ap_id, fake_record_fallback \\ true) do
|
|
cond do
|
|
user = User.get_cached_by_ap_id(ap_id) ->
|
|
user
|
|
|
|
user = User.get_by_guessed_nickname(ap_id) ->
|
|
user
|
|
|
|
fake_record_fallback ->
|
|
# TODO: refactor (fake records is never a good idea)
|
|
User.error_user(ap_id)
|
|
|
|
true ->
|
|
nil
|
|
end
|
|
end
|
|
end
|