1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.Web.ActivityPub.Transmogrifier do
7 A module to handle coding from internal to wire ActivityPub and back.
11 alias Pleroma.Object.Containment
15 alias Pleroma.Web.ActivityPub.ActivityPub
16 alias Pleroma.Web.ActivityPub.Utils
17 alias Pleroma.Web.ActivityPub.Visibility
24 Modifies an incoming AP object (mastodon format) to our internal format.
26 def fix_object(object) do
41 def fix_summary(%{"summary" => nil} = object) do
43 |> Map.put("summary", "")
46 def fix_summary(%{"summary" => _} = object) do
47 # summary is present, nothing to do
51 def fix_summary(object) do
53 |> Map.put("summary", "")
56 def fix_addressing_list(map, field) do
58 is_binary(map[field]) ->
59 Map.put(map, field, [map[field]])
62 Map.put(map, field, [])
69 def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, explicit_mentions) do
72 |> Enum.filter(fn x -> x in explicit_mentions end)
76 |> Enum.filter(fn x -> x not in explicit_mentions end)
83 |> Map.put("to", explicit_to)
84 |> Map.put("cc", final_cc)
87 def fix_explicit_addressing(object, _explicit_mentions), do: object
89 # if directMessage flag is set to true, leave the addressing alone
90 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
92 def fix_explicit_addressing(object) do
95 |> Utils.determine_explicit_mentions()
97 explicit_mentions = explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public"]
100 |> fix_explicit_addressing(explicit_mentions)
103 # if as:Public is addressed, then make sure the followers collection is also addressed
104 # so that the activities will be delivered to local users.
105 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
106 recipients = to ++ cc
108 if followers_collection not in recipients do
110 "https://www.w3.org/ns/activitystreams#Public" in cc ->
111 to = to ++ [followers_collection]
112 Map.put(object, "to", to)
114 "https://www.w3.org/ns/activitystreams#Public" in to ->
115 cc = cc ++ [followers_collection]
116 Map.put(object, "cc", cc)
126 def fix_implicit_addressing(object, _), do: object
128 def fix_addressing(object) do
129 %User{} = user = User.get_or_fetch_by_ap_id(object["actor"])
130 followers_collection = User.ap_followers(user)
133 |> fix_addressing_list("to")
134 |> fix_addressing_list("cc")
135 |> fix_addressing_list("bto")
136 |> fix_addressing_list("bcc")
137 |> fix_explicit_addressing
138 |> fix_implicit_addressing(followers_collection)
141 def fix_actor(%{"attributedTo" => actor} = object) do
143 |> Map.put("actor", Containment.get_actor(%{"actor" => actor}))
146 # Check for standardisation
147 # This is what Peertube does
148 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
149 # Prismo returns only an integer (count) as "likes"
150 def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do
152 |> Map.put("likes", [])
153 |> Map.put("like_count", 0)
156 def fix_likes(object) do
160 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
161 when not is_nil(in_reply_to) do
164 is_bitstring(in_reply_to) ->
167 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
170 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
171 Enum.at(in_reply_to, 0)
173 # Maybe I should output an error too?
178 case get_obj_helper(in_reply_to_id) do
179 {:ok, replied_object} ->
180 with %Activity{} = _activity <-
181 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
183 |> Map.put("inReplyTo", replied_object.data["id"])
184 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
185 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
186 |> Map.put("context", replied_object.data["context"] || object["conversation"])
189 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
194 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
199 def fix_in_reply_to(object), do: object
201 def fix_context(object) do
202 context = object["context"] || object["conversation"] || Utils.generate_context_id()
205 |> Map.put("context", context)
206 |> Map.put("conversation", context)
209 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
212 |> Enum.map(fn data ->
213 media_type = data["mediaType"] || data["mimeType"]
214 href = data["url"] || data["href"]
216 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
219 |> Map.put("mediaType", media_type)
220 |> Map.put("url", url)
224 |> Map.put("attachment", attachments)
227 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
228 Map.put(object, "attachment", [attachment])
232 def fix_attachments(object), do: object
234 def fix_url(%{"url" => url} = object) when is_map(url) do
236 |> Map.put("url", url["href"])
239 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
240 first_element = Enum.at(url, 0)
244 |> Enum.filter(fn x -> is_map(x) end)
245 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
249 |> Map.put("attachment", [first_element])
250 |> Map.put("url", link_element["href"])
253 def fix_url(%{"type" => object_type, "url" => url} = object)
254 when object_type != "Video" and is_list(url) do
255 first_element = Enum.at(url, 0)
259 is_bitstring(first_element) -> first_element
260 is_map(first_element) -> first_element["href"] || ""
265 |> Map.put("url", url_string)
268 def fix_url(object), do: object
270 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
271 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
275 |> Enum.reduce(%{}, fn data, mapping ->
276 name = String.trim(data["name"], ":")
278 mapping |> Map.put(name, data["icon"]["url"])
281 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
282 emoji = Map.merge(object["emoji"] || %{}, emoji)
285 |> Map.put("emoji", emoji)
288 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
289 name = String.trim(tag["name"], ":")
290 emoji = %{name => tag["icon"]["url"]}
293 |> Map.put("emoji", emoji)
296 def fix_emoji(object), do: object
298 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
301 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
302 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
304 combined = tag ++ tags
307 |> Map.put("tag", combined)
310 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
311 combined = [tag, String.slice(hashtag, 1..-1)]
314 |> Map.put("tag", combined)
317 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
319 def fix_tag(object), do: object
321 # content map usually only has one language so this will do for now.
322 def fix_content_map(%{"contentMap" => content_map} = object) do
323 content_groups = Map.to_list(content_map)
324 {_, content} = Enum.at(content_groups, 0)
327 |> Map.put("content", content)
330 def fix_content_map(object), do: object
332 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
333 with true <- id =~ "follows",
334 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
335 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
342 defp mastodon_follow_hack(_, _), do: {:error, nil}
344 defp get_follow_activity(follow_object, followed) do
345 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
346 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
349 # Can't find the activity. This might a Mastodon 2.3 "Accept"
351 mastodon_follow_hack(follow_object, followed)
358 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
360 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do
361 with context <- data["context"] || Utils.generate_context_id(),
362 content <- data["content"] || "",
363 %User{} = actor <- User.get_cached_by_ap_id(actor),
365 # Reduce the object list to find the reported user.
367 Enum.reduce_while(objects, nil, fn ap_id, _ ->
368 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
375 # Remove the reported user from the object list.
376 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
384 "cc" => [account.ap_id]
388 ActivityPub.flag(params)
392 # disallow objects with bogus IDs
393 def handle_incoming(%{"id" => nil}), do: :error
394 def handle_incoming(%{"id" => ""}), do: :error
395 # length of https:// = 8, should validate better, but good enough for now.
396 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
398 # TODO: validate those with a Ecto scheme
401 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
402 when objtype in ["Article", "Note", "Video", "Page"] do
403 actor = Containment.get_actor(data)
406 Map.put(data, "actor", actor)
409 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
410 %User{} = user <- User.get_or_fetch_by_ap_id(data["actor"]) do
411 object = fix_object(data["object"])
417 context: object["conversation"],
419 published: data["published"],
428 ActivityPub.create(params)
430 %Activity{} = activity -> {:ok, activity}
436 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
438 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
439 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
440 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
441 if not User.locked?(followed) do
442 ActivityPub.accept(%{
443 to: [follower.ap_id],
449 User.follow(follower, followed)
459 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
461 with actor <- Containment.get_actor(data),
462 %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
463 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
464 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
465 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
467 ActivityPub.accept(%{
468 to: follow_activity.data["to"],
471 object: follow_activity.data["id"],
474 if not User.following?(follower, followed) do
475 {:ok, _follower} = User.follow(follower, followed)
485 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
487 with actor <- Containment.get_actor(data),
488 %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
489 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
490 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
491 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
493 ActivityPub.reject(%{
494 to: follow_activity.data["to"],
497 object: follow_activity.data["id"],
500 User.unfollow(follower, followed)
509 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
511 with actor <- Containment.get_actor(data),
512 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
513 {:ok, object} <- get_obj_helper(object_id),
514 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
522 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
524 with actor <- Containment.get_actor(data),
525 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
526 {:ok, object} <- get_obj_helper(object_id),
527 public <- Visibility.is_public?(data),
528 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
536 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
539 when object_type in ["Person", "Application", "Service", "Organization"] do
540 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
541 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
543 banner = new_user_data[:info]["banner"]
544 locked = new_user_data[:info]["locked"] || false
548 |> Map.take([:name, :bio, :avatar])
549 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
552 |> User.upgrade_changeset(update_data)
553 |> User.update_and_set_cache()
555 ActivityPub.update(%{
557 to: data["to"] || [],
558 cc: data["cc"] || [],
569 # TODO: We presently assume that any actor on the same origin domain as the object being
570 # deleted has the rights to delete that object. A better way to validate whether or not
571 # the object should be deleted is to refetch the object URI, which should return either
572 # an error or a tombstone. This would allow us to verify that a deletion actually took
575 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
577 object_id = Utils.get_ap_id(object_id)
579 with actor <- Containment.get_actor(data),
580 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
581 {:ok, object} <- get_obj_helper(object_id),
582 :ok <- Containment.contain_origin(actor.ap_id, object.data),
583 {:ok, activity} <- ActivityPub.delete(object, false) do
593 "object" => %{"type" => "Announce", "object" => object_id},
598 with actor <- Containment.get_actor(data),
599 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
600 {:ok, object} <- get_obj_helper(object_id),
601 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
611 "object" => %{"type" => "Follow", "object" => followed},
616 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
617 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
618 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
619 User.unfollow(follower, followed)
629 "object" => %{"type" => "Block", "object" => blocked},
634 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
635 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
636 %User{} = blocker <- User.get_or_fetch_by_ap_id(blocker),
637 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
638 User.unblock(blocker, blocked)
646 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
648 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
649 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
650 %User{} = blocker = User.get_or_fetch_by_ap_id(blocker),
651 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
652 User.unfollow(blocker, blocked)
653 User.block(blocker, blocked)
663 "object" => %{"type" => "Like", "object" => object_id},
668 with actor <- Containment.get_actor(data),
669 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
670 {:ok, object} <- get_obj_helper(object_id),
671 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
678 def handle_incoming(_), do: :error
680 def get_obj_helper(id) do
681 if object = Object.normalize(id), do: {:ok, object}, else: nil
684 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
685 with false <- String.starts_with?(in_reply_to, "http"),
686 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
687 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
693 def set_reply_to_uri(obj), do: obj
695 # Prepares the object of an outgoing create activity.
696 def prepare_object(object) do
704 |> prepare_attachments
707 |> strip_internal_fields
708 |> strip_internal_tags
713 # internal -> Mastodon
716 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
718 Object.normalize(object_id).data
723 |> Map.put("object", object)
724 |> Map.merge(Utils.make_json_ld_header())
729 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
730 # because of course it does.
731 def prepare_outgoing(%{"type" => "Accept"} = data) do
732 with follow_activity <- Activity.normalize(data["object"]) do
734 "actor" => follow_activity.actor,
735 "object" => follow_activity.data["object"],
736 "id" => follow_activity.data["id"],
742 |> Map.put("object", object)
743 |> Map.merge(Utils.make_json_ld_header())
749 def prepare_outgoing(%{"type" => "Reject"} = data) do
750 with follow_activity <- Activity.normalize(data["object"]) do
752 "actor" => follow_activity.actor,
753 "object" => follow_activity.data["object"],
754 "id" => follow_activity.data["id"],
760 |> Map.put("object", object)
761 |> Map.merge(Utils.make_json_ld_header())
767 def prepare_outgoing(%{"type" => _type} = data) do
770 |> strip_internal_fields
771 |> maybe_fix_object_url
772 |> Map.merge(Utils.make_json_ld_header())
777 def maybe_fix_object_url(data) do
778 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
779 case get_obj_helper(data["object"]) do
780 {:ok, relative_object} ->
781 if relative_object.data["external_url"] do
784 |> Map.put("object", relative_object.data["external_url"])
790 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
798 def add_hashtags(object) do
800 (object["tag"] || [])
802 # Expand internal representation tags into AS2 tags.
803 tag when is_binary(tag) ->
805 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
810 # Do not process tags which are already AS2 tag objects.
811 tag when is_map(tag) ->
816 |> Map.put("tag", tags)
819 def add_mention_tags(object) do
822 |> Utils.get_notified_from_object()
823 |> Enum.map(fn user ->
824 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
827 tags = object["tag"] || []
830 |> Map.put("tag", tags ++ mentions)
833 # TODO: we should probably send mtime instead of unix epoch time for updated
834 def add_emoji_tags(object) do
835 tags = object["tag"] || []
836 emoji = object["emoji"] || []
840 |> Enum.map(fn {name, url} ->
842 "icon" => %{"url" => url, "type" => "Image"},
843 "name" => ":" <> name <> ":",
845 "updated" => "1970-01-01T00:00:00Z",
851 |> Map.put("tag", tags ++ out)
854 def set_conversation(object) do
855 Map.put(object, "conversation", object["context"])
858 def set_sensitive(object) do
859 tags = object["tag"] || []
860 Map.put(object, "sensitive", "nsfw" in tags)
863 def add_attributed_to(object) do
864 attributed_to = object["attributedTo"] || object["actor"]
867 |> Map.put("attributedTo", attributed_to)
870 def add_likes(%{"id" => id, "like_count" => likes} = object) do
872 "id" => "#{id}/likes",
873 "first" => "#{id}/likes?page=1",
874 "type" => "OrderedCollection",
875 "totalItems" => likes
879 |> Map.put("likes", likes)
882 def add_likes(object) do
886 def prepare_attachments(object) do
888 (object["attachment"] || [])
889 |> Enum.map(fn data ->
890 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
891 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
895 |> Map.put("attachment", attachments)
898 defp strip_internal_fields(object) do
903 "announcement_count",
906 "deleted_activity_id"
910 defp strip_internal_tags(%{"tag" => tags} = object) do
913 |> Enum.filter(fn x -> is_map(x) end)
916 |> Map.put("tag", tags)
919 defp strip_internal_tags(object), do: object
921 def perform(:user_upgrade, user) do
922 # we pass a fake user so that the followers collection is stripped away
923 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
928 where: ^old_follower_address in u.following,
933 "array_replace(?,?,?)",
935 ^old_follower_address,
936 ^user.follower_address
942 Repo.update_all(q, [])
944 maybe_retire_websub(user.ap_id)
949 where: ^old_follower_address in a.recipients,
954 "array_replace(?,?,?)",
956 ^old_follower_address,
957 ^user.follower_address
963 Repo.update_all(q, [])
966 def upgrade_user_from_ap_id(ap_id) do
967 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
968 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
969 already_ap <- User.ap_enabled?(user),
970 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
972 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
977 %User{} = user -> {:ok, user}
982 def maybe_retire_websub(ap_id) do
984 if is_binary(ap_id) && String.length(ap_id) > 8 do
987 ws in Pleroma.Web.Websub.WebsubClientSubscription,
988 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
995 def maybe_fix_user_url(data) do
996 if is_map(data["url"]) do
997 Map.put(data, "url", data["url"]["href"])
1003 def maybe_fix_user_object(data) do
1005 |> maybe_fix_user_url