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
14 alias Pleroma.Web.ActivityPub.ActivityPub
15 alias Pleroma.Web.ActivityPub.Utils
16 alias Pleroma.Web.ActivityPub.Visibility
23 Modifies an incoming AP object (mastodon format) to our internal format.
25 def fix_object(object) do
40 def fix_summary(%{"summary" => nil} = object) do
42 |> Map.put("summary", "")
45 def fix_summary(%{"summary" => _} = object) do
46 # summary is present, nothing to do
50 def fix_summary(object) do
52 |> Map.put("summary", "")
55 def fix_addressing_list(map, field) do
57 is_binary(map[field]) ->
58 Map.put(map, field, [map[field]])
61 Map.put(map, field, [])
68 def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, explicit_mentions) do
71 |> Enum.filter(fn x -> x in explicit_mentions end)
75 |> Enum.filter(fn x -> x not in explicit_mentions end)
82 |> Map.put("to", explicit_to)
83 |> Map.put("cc", final_cc)
86 def fix_explicit_addressing(object, _explicit_mentions), do: object
88 # if directMessage flag is set to true, leave the addressing alone
89 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
91 def fix_explicit_addressing(object) do
94 |> Utils.determine_explicit_mentions()
96 follower_collection = User.get_cached_by_ap_id(Containment.get_actor(object)).follower_address
99 explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public", follower_collection]
102 |> fix_explicit_addressing(explicit_mentions)
105 # if as:Public is addressed, then make sure the followers collection is also addressed
106 # so that the activities will be delivered to local users.
107 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
108 recipients = to ++ cc
110 if followers_collection not in recipients do
112 "https://www.w3.org/ns/activitystreams#Public" in cc ->
113 to = to ++ [followers_collection]
114 Map.put(object, "to", to)
116 "https://www.w3.org/ns/activitystreams#Public" in to ->
117 cc = cc ++ [followers_collection]
118 Map.put(object, "cc", cc)
128 def fix_implicit_addressing(object, _), do: object
130 def fix_addressing(object) do
131 {:ok, %User{} = user} = User.get_or_fetch_by_ap_id(object["actor"])
132 followers_collection = User.ap_followers(user)
135 |> fix_addressing_list("to")
136 |> fix_addressing_list("cc")
137 |> fix_addressing_list("bto")
138 |> fix_addressing_list("bcc")
139 |> fix_explicit_addressing
140 |> fix_implicit_addressing(followers_collection)
143 def fix_actor(%{"attributedTo" => actor} = object) do
145 |> Map.put("actor", Containment.get_actor(%{"actor" => actor}))
148 # Check for standardisation
149 # This is what Peertube does
150 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
151 # Prismo returns only an integer (count) as "likes"
152 def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do
154 |> Map.put("likes", [])
155 |> Map.put("like_count", 0)
158 def fix_likes(object) do
162 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
163 when not is_nil(in_reply_to) do
166 is_bitstring(in_reply_to) ->
169 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
172 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
173 Enum.at(in_reply_to, 0)
175 # Maybe I should output an error too?
180 case get_obj_helper(in_reply_to_id) do
181 {:ok, replied_object} ->
182 with %Activity{} = _activity <-
183 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
185 |> Map.put("inReplyTo", replied_object.data["id"])
186 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
187 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
188 |> Map.put("context", replied_object.data["context"] || object["conversation"])
191 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
196 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
201 def fix_in_reply_to(object), do: object
203 def fix_context(object) do
204 context = object["context"] || object["conversation"] || Utils.generate_context_id()
207 |> Map.put("context", context)
208 |> Map.put("conversation", context)
211 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
214 |> Enum.map(fn data ->
215 media_type = data["mediaType"] || data["mimeType"]
216 href = data["url"] || data["href"]
218 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
221 |> Map.put("mediaType", media_type)
222 |> Map.put("url", url)
226 |> Map.put("attachment", attachments)
229 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
230 Map.put(object, "attachment", [attachment])
234 def fix_attachments(object), do: object
236 def fix_url(%{"url" => url} = object) when is_map(url) do
238 |> Map.put("url", url["href"])
241 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
242 first_element = Enum.at(url, 0)
246 |> Enum.filter(fn x -> is_map(x) end)
247 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
251 |> Map.put("attachment", [first_element])
252 |> Map.put("url", link_element["href"])
255 def fix_url(%{"type" => object_type, "url" => url} = object)
256 when object_type != "Video" and is_list(url) do
257 first_element = Enum.at(url, 0)
261 is_bitstring(first_element) -> first_element
262 is_map(first_element) -> first_element["href"] || ""
267 |> Map.put("url", url_string)
270 def fix_url(object), do: object
272 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
273 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
277 |> Enum.reduce(%{}, fn data, mapping ->
278 name = String.trim(data["name"], ":")
280 mapping |> Map.put(name, data["icon"]["url"])
283 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
284 emoji = Map.merge(object["emoji"] || %{}, emoji)
287 |> Map.put("emoji", emoji)
290 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
291 name = String.trim(tag["name"], ":")
292 emoji = %{name => tag["icon"]["url"]}
295 |> Map.put("emoji", emoji)
298 def fix_emoji(object), do: object
300 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
303 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
304 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
306 combined = tag ++ tags
309 |> Map.put("tag", combined)
312 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
313 combined = [tag, String.slice(hashtag, 1..-1)]
316 |> Map.put("tag", combined)
319 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
321 def fix_tag(object), do: object
323 # content map usually only has one language so this will do for now.
324 def fix_content_map(%{"contentMap" => content_map} = object) do
325 content_groups = Map.to_list(content_map)
326 {_, content} = Enum.at(content_groups, 0)
329 |> Map.put("content", content)
332 def fix_content_map(object), do: object
334 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
335 with true <- id =~ "follows",
336 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
337 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
344 defp mastodon_follow_hack(_, _), do: {:error, nil}
346 defp get_follow_activity(follow_object, followed) do
347 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
348 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
351 # Can't find the activity. This might a Mastodon 2.3 "Accept"
353 mastodon_follow_hack(follow_object, followed)
360 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
362 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do
363 with context <- data["context"] || Utils.generate_context_id(),
364 content <- data["content"] || "",
365 %User{} = actor <- User.get_cached_by_ap_id(actor),
367 # Reduce the object list to find the reported user.
369 Enum.reduce_while(objects, nil, fn ap_id, _ ->
370 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
377 # Remove the reported user from the object list.
378 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
386 "cc" => [account.ap_id]
390 ActivityPub.flag(params)
394 # disallow objects with bogus IDs
395 def handle_incoming(%{"id" => nil}), do: :error
396 def handle_incoming(%{"id" => ""}), do: :error
397 # length of https:// = 8, should validate better, but good enough for now.
398 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
400 # TODO: validate those with a Ecto scheme
403 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
404 when objtype in ["Article", "Note", "Video", "Page"] do
405 actor = Containment.get_actor(data)
408 Map.put(data, "actor", actor)
411 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
412 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
413 object = fix_object(data["object"])
419 context: object["conversation"],
421 published: data["published"],
430 ActivityPub.create(params)
432 %Activity{} = activity -> {:ok, activity}
438 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
440 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
441 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
442 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
443 with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]),
444 {:user_blocked, false} <-
445 {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked},
446 {:user_locked, false} <- {:user_locked, User.locked?(followed)},
447 {:follow, {:ok, follower}} <- {:follow, User.follow(follower, followed)} do
448 ActivityPub.accept(%{
449 to: [follower.ap_id],
455 {:user_blocked, true} ->
456 {:ok, _} = Utils.update_follow_state(activity, "reject")
458 ActivityPub.reject(%{
459 to: [follower.ap_id],
465 {:follow, {:error, _}} ->
466 {:ok, _} = Utils.update_follow_state(activity, "reject")
468 ActivityPub.reject(%{
469 to: [follower.ap_id],
475 {:user_locked, true} ->
487 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
489 with actor <- Containment.get_actor(data),
490 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
491 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
492 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
493 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
495 ActivityPub.accept(%{
496 to: follow_activity.data["to"],
499 object: follow_activity.data["id"],
502 if not User.following?(follower, followed) do
503 {:ok, _follower} = User.follow(follower, followed)
513 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
515 with actor <- Containment.get_actor(data),
516 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
517 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
518 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
519 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
521 ActivityPub.reject(%{
522 to: follow_activity.data["to"],
525 object: follow_activity.data["id"],
528 User.unfollow(follower, followed)
537 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
539 with actor <- Containment.get_actor(data),
540 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
541 {:ok, object} <- get_obj_helper(object_id),
542 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
550 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
552 with actor <- Containment.get_actor(data),
553 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
554 {:ok, object} <- get_obj_helper(object_id),
555 public <- Visibility.is_public?(data),
556 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
564 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
567 when object_type in ["Person", "Application", "Service", "Organization"] do
568 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
569 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
571 banner = new_user_data[:info]["banner"]
572 locked = new_user_data[:info]["locked"] || false
576 |> Map.take([:name, :bio, :avatar])
577 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
580 |> User.upgrade_changeset(update_data)
581 |> User.update_and_set_cache()
583 ActivityPub.update(%{
585 to: data["to"] || [],
586 cc: data["cc"] || [],
597 # TODO: We presently assume that any actor on the same origin domain as the object being
598 # deleted has the rights to delete that object. A better way to validate whether or not
599 # the object should be deleted is to refetch the object URI, which should return either
600 # an error or a tombstone. This would allow us to verify that a deletion actually took
603 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
605 object_id = Utils.get_ap_id(object_id)
607 with actor <- Containment.get_actor(data),
608 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
609 {:ok, object} <- get_obj_helper(object_id),
610 :ok <- Containment.contain_origin(actor.ap_id, object.data),
611 {:ok, activity} <- ActivityPub.delete(object, false) do
621 "object" => %{"type" => "Announce", "object" => object_id},
626 with actor <- Containment.get_actor(data),
627 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
628 {:ok, object} <- get_obj_helper(object_id),
629 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
639 "object" => %{"type" => "Follow", "object" => followed},
644 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
645 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
646 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
647 User.unfollow(follower, followed)
657 "object" => %{"type" => "Block", "object" => blocked},
662 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
663 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
664 {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
665 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
666 User.unblock(blocker, blocked)
674 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
676 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
677 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
678 {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
679 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
680 User.unfollow(blocker, blocked)
681 User.block(blocker, blocked)
691 "object" => %{"type" => "Like", "object" => object_id},
696 with actor <- Containment.get_actor(data),
697 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
698 {:ok, object} <- get_obj_helper(object_id),
699 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
706 def handle_incoming(_), do: :error
708 def get_obj_helper(id) do
709 if object = Object.normalize(id), do: {:ok, object}, else: nil
712 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
713 with false <- String.starts_with?(in_reply_to, "http"),
714 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
715 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
721 def set_reply_to_uri(obj), do: obj
723 # Prepares the object of an outgoing create activity.
724 def prepare_object(object) do
732 |> prepare_attachments
735 |> strip_internal_fields
736 |> strip_internal_tags
741 # internal -> Mastodon
744 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
746 Object.normalize(object_id).data
751 |> Map.put("object", object)
752 |> Map.merge(Utils.make_json_ld_header())
757 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
758 # because of course it does.
759 def prepare_outgoing(%{"type" => "Accept"} = data) do
760 with follow_activity <- Activity.normalize(data["object"]) do
762 "actor" => follow_activity.actor,
763 "object" => follow_activity.data["object"],
764 "id" => follow_activity.data["id"],
770 |> Map.put("object", object)
771 |> Map.merge(Utils.make_json_ld_header())
777 def prepare_outgoing(%{"type" => "Reject"} = data) do
778 with follow_activity <- Activity.normalize(data["object"]) do
780 "actor" => follow_activity.actor,
781 "object" => follow_activity.data["object"],
782 "id" => follow_activity.data["id"],
788 |> Map.put("object", object)
789 |> Map.merge(Utils.make_json_ld_header())
795 def prepare_outgoing(%{"type" => _type} = data) do
798 |> strip_internal_fields
799 |> maybe_fix_object_url
800 |> Map.merge(Utils.make_json_ld_header())
805 def maybe_fix_object_url(data) do
806 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
807 case get_obj_helper(data["object"]) do
808 {:ok, relative_object} ->
809 if relative_object.data["external_url"] do
812 |> Map.put("object", relative_object.data["external_url"])
818 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
826 def add_hashtags(object) do
828 (object["tag"] || [])
830 # Expand internal representation tags into AS2 tags.
831 tag when is_binary(tag) ->
833 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
838 # Do not process tags which are already AS2 tag objects.
839 tag when is_map(tag) ->
844 |> Map.put("tag", tags)
847 def add_mention_tags(object) do
850 |> Utils.get_notified_from_object()
851 |> Enum.map(fn user ->
852 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
855 tags = object["tag"] || []
858 |> Map.put("tag", tags ++ mentions)
861 def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do
862 user_info = add_emoji_tags(user_info)
865 |> Map.put(:info, user_info)
868 # TODO: we should probably send mtime instead of unix epoch time for updated
869 def add_emoji_tags(%{"emoji" => emoji} = object) do
870 tags = object["tag"] || []
874 |> Enum.map(fn {name, url} ->
876 "icon" => %{"url" => url, "type" => "Image"},
877 "name" => ":" <> name <> ":",
879 "updated" => "1970-01-01T00:00:00Z",
885 |> Map.put("tag", tags ++ out)
888 def add_emoji_tags(object) do
892 def set_conversation(object) do
893 Map.put(object, "conversation", object["context"])
896 def set_sensitive(object) do
897 tags = object["tag"] || []
898 Map.put(object, "sensitive", "nsfw" in tags)
901 def add_attributed_to(object) do
902 attributed_to = object["attributedTo"] || object["actor"]
905 |> Map.put("attributedTo", attributed_to)
908 def add_likes(%{"id" => id, "like_count" => likes} = object) do
910 "id" => "#{id}/likes",
911 "first" => "#{id}/likes?page=1",
912 "type" => "OrderedCollection",
913 "totalItems" => likes
917 |> Map.put("likes", likes)
920 def add_likes(object) do
924 def prepare_attachments(object) do
926 (object["attachment"] || [])
927 |> Enum.map(fn data ->
928 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
929 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
933 |> Map.put("attachment", attachments)
936 defp strip_internal_fields(object) do
941 "announcement_count",
944 "deleted_activity_id"
948 defp strip_internal_tags(%{"tag" => tags} = object) do
951 |> Enum.filter(fn x -> is_map(x) end)
954 |> Map.put("tag", tags)
957 defp strip_internal_tags(object), do: object
959 def perform(:user_upgrade, user) do
960 # we pass a fake user so that the followers collection is stripped away
961 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
966 where: ^old_follower_address in u.following,
971 "array_replace(?,?,?)",
973 ^old_follower_address,
974 ^user.follower_address
980 Repo.update_all(q, [])
982 maybe_retire_websub(user.ap_id)
987 where: ^old_follower_address in a.recipients,
992 "array_replace(?,?,?)",
994 ^old_follower_address,
995 ^user.follower_address
1001 Repo.update_all(q, [])
1004 def upgrade_user_from_ap_id(ap_id) do
1005 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1006 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1007 already_ap <- User.ap_enabled?(user),
1008 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1009 unless already_ap do
1010 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1015 %User{} = user -> {:ok, user}
1020 def maybe_retire_websub(ap_id) do
1021 # some sanity checks
1022 if is_binary(ap_id) && String.length(ap_id) > 8 do
1025 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1026 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1033 def maybe_fix_user_url(data) do
1034 if is_map(data["url"]) do
1035 Map.put(data, "url", data["url"]["href"])
1041 def maybe_fix_user_object(data) do
1043 |> maybe_fix_user_url