+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
defmodule Pleroma.Web.ActivityPub.Utils do
- alias Pleroma.{Repo, Web, Object, Activity, User}
- alias Pleroma.Web.Router.Helpers
+ alias Ecto.Changeset
+ alias Ecto.UUID
+ alias Pleroma.Activity
+ alias Pleroma.Notification
+ alias Pleroma.Object
+ alias Pleroma.Repo
+ alias Pleroma.User
+ alias Pleroma.Web
+ alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Endpoint
- alias Ecto.{Changeset, UUID}
+ alias Pleroma.Web.Router.Helpers
+
import Ecto.Query
+
require Logger
+ @supported_object_types ["Article", "Note", "Video", "Page"]
+
# Some implementations send the actor URI as the actor field, others send the entire actor object,
# so figure out what the actor's URI is based on what we have.
def get_ap_id(object) do
Map.put(params, "actor", get_ap_id(params["actor"]))
end
+ def determine_explicit_mentions(%{"tag" => tag} = _object) when is_list(tag) do
+ tag
+ |> Enum.filter(fn x -> is_map(x) end)
+ |> Enum.filter(fn x -> x["type"] == "Mention" end)
+ |> Enum.map(fn x -> x["href"] end)
+ end
+
+ def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do
+ Map.put(object, "tag", [tag])
+ |> determine_explicit_mentions()
+ end
+
+ def determine_explicit_mentions(_), do: []
+
+ defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
+ defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
+ defp recipient_in_collection(_, _), do: false
+
+ def recipient_in_message(ap_id, params) do
+ cond do
+ recipient_in_collection(ap_id, params["to"]) ->
+ true
+
+ recipient_in_collection(ap_id, params["cc"]) ->
+ true
+
+ recipient_in_collection(ap_id, params["bto"]) ->
+ true
+
+ recipient_in_collection(ap_id, params["bcc"]) ->
+ true
+
+ # if the message is unaddressed at all, then assume it is directly addressed
+ # to the recipient
+ !params["to"] && !params["cc"] && !params["bto"] && !params["bcc"] ->
+ true
+
+ true ->
+ false
+ end
+ end
+
+ defp extract_list(target) when is_binary(target), do: [target]
+ defp extract_list(lst) when is_list(lst), do: lst
+ defp extract_list(_), do: []
+
+ def maybe_splice_recipient(ap_id, params) do
+ need_splice =
+ !recipient_in_collection(ap_id, params["to"]) &&
+ !recipient_in_collection(ap_id, params["cc"])
+
+ cc_list = extract_list(params["cc"])
+
+ if need_splice do
+ params
+ |> Map.put("cc", [ap_id | cc_list])
+ else
+ params
+ end
+ end
+
def make_json_ld_header do
%{
"@context" => [
"https://www.w3.org/ns/activitystreams",
- "https://w3id.org/security/v1",
+ "#{Web.base_url()}/schemas/litepub-0.1.jsonld",
%{
- "manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
- "sensitive" => "as:sensitive",
- "Hashtag" => "as:Hashtag",
- "ostatus" => "http://ostatus.org#",
- "atomUri" => "ostatus:atomUri",
- "inReplyToAtomUri" => "ostatus:inReplyToAtomUri",
- "conversation" => "ostatus:conversation",
- "toot" => "http://joinmastodon.org/ns#",
- "Emoji" => "toot:Emoji"
+ "@language" => "und"
}
]
}
"#{Web.base_url()}/#{type}/#{UUID.generate()}"
end
+ def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
+ fake_create_activity = %{
+ "to" => object["to"],
+ "cc" => object["cc"],
+ "type" => "Create",
+ "object" => object
+ }
+
+ Notification.get_notified_from_activity(%Activity{data: fake_create_activity}, false)
+ end
+
+ def get_notified_from_object(object) do
+ Notification.get_notified_from_activity(%Activity{data: object}, false)
+ end
+
def create_context(context) do
context = context || generate_id("contexts")
changeset = Object.context_mapping(context)
_ -> 5
end
- Pleroma.Web.Federator.enqueue(:publish, activity, priority)
+ Pleroma.Web.Federator.publish(activity, priority)
:ok
end
Adds an id and a published data if they aren't there,
also adds it to an included object
"""
- def lazy_put_activity_defaults(map) do
- %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
-
+ def lazy_put_activity_defaults(map, fake \\ false) do
map =
- map
- |> Map.put_new_lazy("id", &generate_activity_id/0)
- |> Map.put_new_lazy("published", &make_date/0)
- |> Map.put_new("context", context)
- |> Map.put_new("context_id", context_id)
+ unless fake do
+ %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
+
+ map
+ |> Map.put_new_lazy("id", &generate_activity_id/0)
+ |> Map.put_new_lazy("published", &make_date/0)
+ |> Map.put_new("context", context)
+ |> Map.put_new("context_id", context_id)
+ else
+ map
+ |> Map.put_new("id", "pleroma:fakeid")
+ |> Map.put_new_lazy("published", &make_date/0)
+ |> Map.put_new("context", "pleroma:fakecontext")
+ |> Map.put_new("context_id", -1)
+ end
if is_map(map["object"]) do
- object = lazy_put_object_defaults(map["object"], map)
+ object = lazy_put_object_defaults(map["object"], map, fake)
%{map | "object" => object}
else
map
@doc """
Adds an id and published date if they aren't there.
"""
- def lazy_put_object_defaults(map, activity \\ %{}) do
+ def lazy_put_object_defaults(map, activity \\ %{}, fake)
+
+ def lazy_put_object_defaults(map, activity, true = _fake) do
+ map
+ |> Map.put_new_lazy("published", &make_date/0)
+ |> Map.put_new("id", "pleroma:fake_object_id")
+ |> Map.put_new("context", activity["context"])
+ |> Map.put_new("fake", true)
+ |> Map.put_new("context_id", activity["context_id"])
+ end
+
+ def lazy_put_object_defaults(map, activity, _fake) do
map
|> Map.put_new_lazy("id", &generate_object_id/0)
|> Map.put_new_lazy("published", &make_date/0)
Inserts a full object if it is contained in an activity.
"""
def insert_full_object(%{"object" => %{"type" => type} = object_data})
- when is_map(object_data) and type in ["Article", "Note"] do
- with {:ok, _} <- Object.create(object_data) do
- :ok
+ when is_map(object_data) and type in @supported_object_types do
+ with {:ok, object} <- Object.create(object_data) do
+ {:ok, object}
end
end
- def insert_full_object(_), do: :ok
+ def insert_full_object(_), do: {:ok, nil}
def update_object_in_activities(%{data: %{"id" => id}} = object) do
# TODO
# Update activities that already had this. Could be done in a seperate process.
# Alternatively, just don't do this and fetch the current object each time. Most
# could probably be taken from cache.
- relevant_activities = Activity.all_by_object_ap_id(id)
+ relevant_activities = Activity.get_all_create_by_object_ap_id(id)
Enum.map(relevant_activities, fn activity ->
new_activity_data = activity.data |> Map.put("object", object.data)
Repo.one(query)
end
- def make_like_data(%User{ap_id: ap_id} = actor, %{data: %{"id" => id}} = object, activity_id) do
+ @doc """
+ Returns like activities targeting an object
+ """
+ def get_object_likes(%{data: %{"id" => id}}) do
+ query =
+ from(
+ activity in Activity,
+ # this is to use the index
+ where:
+ fragment(
+ "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
+ activity.data,
+ activity.data,
+ ^id
+ ),
+ where: fragment("(?)->>'type' = 'Like'", activity.data)
+ )
+
+ Repo.all(query)
+ end
+
+ def make_like_data(
+ %User{ap_id: ap_id} = actor,
+ %{data: %{"actor" => object_actor_id, "id" => id}} = object,
+ activity_id
+ ) do
+ object_actor = User.get_cached_by_ap_id(object_actor_id)
+
+ to =
+ if Visibility.is_public?(object) do
+ [actor.follower_address, object.data["actor"]]
+ else
+ [object.data["actor"]]
+ end
+
+ cc =
+ (object.data["to"] ++ (object.data["cc"] || []))
+ |> List.delete(actor.ap_id)
+ |> List.delete(object_actor.follower_address)
+
data = %{
"type" => "Like",
"actor" => ap_id,
"object" => id,
- "to" => [actor.follower_address, object.data["actor"]],
- "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "to" => to,
+ "cc" => cc,
"context" => object.data["context"]
}
|> Map.put("#{property}_count", length(element))
|> Map.put("#{property}s", element),
changeset <- Changeset.change(object, data: new_data),
- {:ok, object} <- Repo.update(changeset),
+ {:ok, object} <- Object.update_and_set_cache(changeset),
_ <- update_object_in_activities(object) do
{:ok, object}
end
end
def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
- with likes <- [actor | object.data["likes"] || []] |> Enum.uniq() do
+ likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
+
+ with likes <- [actor | likes] |> Enum.uniq() do
update_likes_in_object(likes, object)
end
end
def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
- with likes <- (object.data["likes"] || []) |> List.delete(actor) do
+ likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
+
+ with likes <- likes |> List.delete(actor) do
update_likes_in_object(likes, object)
end
end
@doc """
Updates a follow activity's state (for locked accounts).
"""
+ def update_follow_state(
+ %Activity{data: %{"actor" => actor, "object" => object, "state" => "pending"}} = activity,
+ state
+ ) do
+ try do
+ Ecto.Adapters.SQL.query!(
+ Repo,
+ "UPDATE activities SET data = jsonb_set(data, '{state}', $1) WHERE data->>'type' = 'Follow' AND data->>'actor' = $2 AND data->>'object' = $3 AND data->>'state' = 'pending'",
+ [state, actor, object]
+ )
+
+ activity = Activity.get_by_id(activity.id)
+ {:ok, activity}
+ rescue
+ e ->
+ {:error, e}
+ end
+ end
+
def update_follow_state(%Activity{} = activity, state) do
with new_data <-
activity.data
"""
def make_follow_data(
%User{ap_id: follower_id},
- %User{ap_id: followed_id} = followed,
+ %User{ap_id: followed_id} = _followed,
activity_id
) do
data = %{
"actor" => follower_id,
"to" => [followed_id],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"],
- "object" => followed_id
+ "object" => followed_id,
+ "state" => "pending"
}
data = if activity_id, do: Map.put(data, "id", activity_id), else: data
- data = if User.locked?(followed), do: Map.put(data, "state", "pending"), else: data
data
end
activity.data
),
where: activity.actor == ^follower_id,
+ # this is to use the index
where:
fragment(
- "? @> ?",
+ "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
+ activity.data,
activity.data,
- ^%{object: followed_id}
+ ^followed_id
),
- order_by: [desc: :id],
+ order_by: [fragment("? desc nulls last", activity.id)],
limit: 1
)
@doc """
Make announce activity data for the given actor and object
"""
+ # for relayed messages, we only want to send to subscribers
def make_announce_data(
%User{ap_id: ap_id} = user,
%Object{data: %{"id" => id}} = object,
- activity_id
+ activity_id,
+ false
+ ) do
+ data = %{
+ "type" => "Announce",
+ "actor" => ap_id,
+ "object" => id,
+ "to" => [user.follower_address],
+ "cc" => [],
+ "context" => object.data["context"]
+ }
+
+ if activity_id, do: Map.put(data, "id", activity_id), else: data
+ end
+
+ def make_announce_data(
+ %User{ap_id: ap_id} = user,
+ %Object{data: %{"id" => id}} = object,
+ activity_id,
+ true
) do
data = %{
"type" => "Announce",
if activity_id, do: Map.put(data, "id", activity_id), else: data
end
- def add_announce_to_object(%Activity{data: %{"actor" => actor}}, object) do
- with announcements <- [actor | object.data["announcements"] || []] |> Enum.uniq() do
+ def add_announce_to_object(
+ %Activity{
+ data: %{"actor" => actor, "cc" => ["https://www.w3.org/ns/activitystreams#Public"]}
+ },
+ object
+ ) do
+ announcements =
+ if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
+
+ with announcements <- [actor | announcements] |> Enum.uniq() do
update_element_in_object("announcement", announcements, object)
end
end
+ def add_announce_to_object(_, object), do: {:ok, object}
+
def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
- with announcements <- (object.data["announcements"] || []) |> List.delete(actor) do
+ announcements =
+ if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
+
+ with announcements <- announcements |> List.delete(actor) do
update_element_in_object("announcement", announcements, object)
end
end
activity.data
),
where: activity.actor == ^blocker_id,
+ # this is to use the index
where:
fragment(
- "? @> ?",
+ "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
+ activity.data,
activity.data,
- ^%{object: blocked_id}
+ ^blocked_id
),
- order_by: [desc: :id],
+ order_by: [fragment("? desc nulls last", activity.id)],
limit: 1
)
}
|> Map.merge(additional)
end
+
+ #### Flag-related helpers
+
+ def make_flag_data(params, additional) do
+ status_ap_ids =
+ Enum.map(params.statuses || [], fn
+ %Activity{} = act -> act.data["id"]
+ act when is_map(act) -> act["id"]
+ act when is_binary(act) -> act
+ end)
+
+ object = [params.account.ap_id] ++ status_ap_ids
+
+ %{
+ "type" => "Flag",
+ "actor" => params.actor.ap_id,
+ "content" => params.content,
+ "object" => object,
+ "context" => params.context
+ }
+ |> Map.merge(additional)
+ end
+
+ @doc """
+ Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
+ the first one to `pages_left` pages.
+ If the amount of pages is higher than the collection has, it returns whatever was there.
+ """
+ def fetch_ordered_collection(from, pages_left, acc \\ []) do
+ with {:ok, response} <- Tesla.get(from),
+ {:ok, collection} <- Poison.decode(response.body) do
+ case collection["type"] do
+ "OrderedCollection" ->
+ # If we've encountered the OrderedCollection and not the page,
+ # just call the same function on the page address
+ fetch_ordered_collection(collection["first"], pages_left)
+
+ "OrderedCollectionPage" ->
+ if pages_left > 0 do
+ # There are still more pages
+ if Map.has_key?(collection, "next") do
+ # There are still more pages, go deeper saving what we have into the accumulator
+ fetch_ordered_collection(
+ collection["next"],
+ pages_left - 1,
+ acc ++ collection["orderedItems"]
+ )
+ else
+ # No more pages left, just return whatever we already have
+ acc ++ collection["orderedItems"]
+ end
+ else
+ # Got the amount of pages needed, add them all to the accumulator
+ acc ++ collection["orderedItems"]
+ end
+
+ _ ->
+ {:error, "Not an OrderedCollection or OrderedCollectionPage"}
+ end
+ end
+ end
end