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
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 follower_collection = User.get_cached_by_ap_id(Containment.get_actor(object)).follower_address
100 explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public", follower_collection]
103 |> fix_explicit_addressing(explicit_mentions)
106 # if as:Public is addressed, then make sure the followers collection is also addressed
107 # so that the activities will be delivered to local users.
108 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
109 recipients = to ++ cc
111 if followers_collection not in recipients do
113 "https://www.w3.org/ns/activitystreams#Public" in cc ->
114 to = to ++ [followers_collection]
115 Map.put(object, "to", to)
117 "https://www.w3.org/ns/activitystreams#Public" in to ->
118 cc = cc ++ [followers_collection]
119 Map.put(object, "cc", cc)
129 def fix_implicit_addressing(object, _), do: object
131 def fix_addressing(object) do
132 {:ok, %User{} = user} = User.get_or_fetch_by_ap_id(object["actor"])
133 followers_collection = User.ap_followers(user)
136 |> fix_addressing_list("to")
137 |> fix_addressing_list("cc")
138 |> fix_addressing_list("bto")
139 |> fix_addressing_list("bcc")
140 |> fix_explicit_addressing
141 |> fix_implicit_addressing(followers_collection)
144 def fix_actor(%{"attributedTo" => actor} = object) do
146 |> Map.put("actor", Containment.get_actor(%{"actor" => actor}))
149 # Check for standardisation
150 # This is what Peertube does
151 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
152 # Prismo returns only an integer (count) as "likes"
153 def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do
155 |> Map.put("likes", [])
156 |> Map.put("like_count", 0)
159 def fix_likes(object) do
163 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
164 when not is_nil(in_reply_to) do
167 is_bitstring(in_reply_to) ->
170 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
173 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
174 Enum.at(in_reply_to, 0)
176 # Maybe I should output an error too?
181 case get_obj_helper(in_reply_to_id) do
182 {:ok, replied_object} ->
183 with %Activity{} = _activity <-
184 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
186 |> Map.put("inReplyTo", replied_object.data["id"])
187 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
188 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
189 |> Map.put("context", replied_object.data["context"] || object["conversation"])
192 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
197 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
202 def fix_in_reply_to(object), do: object
204 def fix_context(object) do
205 context = object["context"] || object["conversation"] || Utils.generate_context_id()
208 |> Map.put("context", context)
209 |> Map.put("conversation", context)
212 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
215 |> Enum.map(fn data ->
216 media_type = data["mediaType"] || data["mimeType"]
217 href = data["url"] || data["href"]
219 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
222 |> Map.put("mediaType", media_type)
223 |> Map.put("url", url)
227 |> Map.put("attachment", attachments)
230 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
231 Map.put(object, "attachment", [attachment])
235 def fix_attachments(object), do: object
237 def fix_url(%{"url" => url} = object) when is_map(url) do
239 |> Map.put("url", url["href"])
242 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
243 first_element = Enum.at(url, 0)
247 |> Enum.filter(fn x -> is_map(x) end)
248 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
252 |> Map.put("attachment", [first_element])
253 |> Map.put("url", link_element["href"])
256 def fix_url(%{"type" => object_type, "url" => url} = object)
257 when object_type != "Video" and is_list(url) do
258 first_element = Enum.at(url, 0)
262 is_bitstring(first_element) -> first_element
263 is_map(first_element) -> first_element["href"] || ""
268 |> Map.put("url", url_string)
271 def fix_url(object), do: object
273 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
274 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
278 |> Enum.reduce(%{}, fn data, mapping ->
279 name = String.trim(data["name"], ":")
281 mapping |> Map.put(name, data["icon"]["url"])
284 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
285 emoji = Map.merge(object["emoji"] || %{}, emoji)
288 |> Map.put("emoji", emoji)
291 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
292 name = String.trim(tag["name"], ":")
293 emoji = %{name => tag["icon"]["url"]}
296 |> Map.put("emoji", emoji)
299 def fix_emoji(object), do: object
301 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
304 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
305 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
307 combined = tag ++ tags
310 |> Map.put("tag", combined)
313 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
314 combined = [tag, String.slice(hashtag, 1..-1)]
317 |> Map.put("tag", combined)
320 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
322 def fix_tag(object), do: object
324 # content map usually only has one language so this will do for now.
325 def fix_content_map(%{"contentMap" => content_map} = object) do
326 content_groups = Map.to_list(content_map)
327 {_, content} = Enum.at(content_groups, 0)
330 |> Map.put("content", content)
333 def fix_content_map(object), do: object
335 def fix_type(%{"inReplyTo" => reply_id} = object) when is_binary(reply_id) do
336 reply = Object.normalize(reply_id)
338 if reply.data["type"] == "Question" and object["name"] do
339 Map.put(object, "type", "Answer")
345 def fix_type(object), do: object
347 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
348 with true <- id =~ "follows",
349 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
350 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
357 defp mastodon_follow_hack(_, _), do: {:error, nil}
359 defp get_follow_activity(follow_object, followed) do
360 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
361 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
364 # Can't find the activity. This might a Mastodon 2.3 "Accept"
366 mastodon_follow_hack(follow_object, followed)
373 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
375 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do
376 with context <- data["context"] || Utils.generate_context_id(),
377 content <- data["content"] || "",
378 %User{} = actor <- User.get_cached_by_ap_id(actor),
380 # Reduce the object list to find the reported user.
382 Enum.reduce_while(objects, nil, fn ap_id, _ ->
383 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
390 # Remove the reported user from the object list.
391 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
399 "cc" => [account.ap_id]
403 ActivityPub.flag(params)
407 # disallow objects with bogus IDs
408 def handle_incoming(%{"id" => nil}), do: :error
409 def handle_incoming(%{"id" => ""}), do: :error
410 # length of https:// = 8, should validate better, but good enough for now.
411 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
413 # TODO: validate those with a Ecto scheme
416 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
417 when objtype in ["Article", "Note", "Video", "Page", "Question", "Answer"] do
418 actor = Containment.get_actor(data)
421 Map.put(data, "actor", actor)
424 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
425 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
426 object = fix_object(data["object"])
432 context: object["conversation"],
434 published: data["published"],
443 ActivityPub.create(params)
445 %Activity{} = activity -> {:ok, activity}
451 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
453 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
454 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
455 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
456 with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]),
457 {:user_blocked, false} <-
458 {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked},
459 {:user_locked, false} <- {:user_locked, User.locked?(followed)},
460 {:follow, {:ok, follower}} <- {:follow, User.follow(follower, followed)} do
461 ActivityPub.accept(%{
462 to: [follower.ap_id],
468 {:user_blocked, true} ->
469 {:ok, _} = Utils.update_follow_state(activity, "reject")
471 ActivityPub.reject(%{
472 to: [follower.ap_id],
478 {:follow, {:error, _}} ->
479 {:ok, _} = Utils.update_follow_state(activity, "reject")
481 ActivityPub.reject(%{
482 to: [follower.ap_id],
488 {:user_locked, true} ->
500 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
502 with actor <- Containment.get_actor(data),
503 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
504 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
505 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
506 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
508 ActivityPub.accept(%{
509 to: follow_activity.data["to"],
512 object: follow_activity.data["id"],
515 if not User.following?(follower, followed) do
516 {:ok, _follower} = User.follow(follower, followed)
526 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
528 with actor <- Containment.get_actor(data),
529 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
530 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
531 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
532 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
534 ActivityPub.reject(%{
535 to: follow_activity.data["to"],
538 object: follow_activity.data["id"],
541 User.unfollow(follower, followed)
550 %{"type" => "Like", "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 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
563 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
565 with actor <- Containment.get_actor(data),
566 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
567 {:ok, object} <- get_obj_helper(object_id),
568 public <- Visibility.is_public?(data),
569 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
577 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
580 when object_type in ["Person", "Application", "Service", "Organization"] do
581 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
582 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
584 banner = new_user_data[:info]["banner"]
585 locked = new_user_data[:info]["locked"] || false
589 |> Map.take([:name, :bio, :avatar])
590 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
593 |> User.upgrade_changeset(update_data)
594 |> User.update_and_set_cache()
596 ActivityPub.update(%{
598 to: data["to"] || [],
599 cc: data["cc"] || [],
610 # TODO: We presently assume that any actor on the same origin domain as the object being
611 # deleted has the rights to delete that object. A better way to validate whether or not
612 # the object should be deleted is to refetch the object URI, which should return either
613 # an error or a tombstone. This would allow us to verify that a deletion actually took
616 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
618 object_id = Utils.get_ap_id(object_id)
620 with actor <- Containment.get_actor(data),
621 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
622 {:ok, object} <- get_obj_helper(object_id),
623 :ok <- Containment.contain_origin(actor.ap_id, object.data),
624 {:ok, activity} <- ActivityPub.delete(object, false) do
634 "object" => %{"type" => "Announce", "object" => object_id},
639 with actor <- Containment.get_actor(data),
640 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
641 {:ok, object} <- get_obj_helper(object_id),
642 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
652 "object" => %{"type" => "Follow", "object" => followed},
657 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
658 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
659 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
660 User.unfollow(follower, followed)
670 "object" => %{"type" => "Block", "object" => blocked},
675 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
676 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
677 {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
678 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
679 User.unblock(blocker, blocked)
687 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
689 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
690 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
691 {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
692 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
693 User.unfollow(blocker, blocked)
694 User.block(blocker, blocked)
704 "object" => %{"type" => "Like", "object" => object_id},
709 with actor <- Containment.get_actor(data),
710 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
711 {:ok, object} <- get_obj_helper(object_id),
712 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
719 def handle_incoming(_), do: :error
721 def get_obj_helper(id) do
722 if object = Object.normalize(id), do: {:ok, object}, else: nil
725 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
726 with false <- String.starts_with?(in_reply_to, "http"),
727 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
728 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
734 def set_reply_to_uri(obj), do: obj
736 # Prepares the object of an outgoing create activity.
737 def prepare_object(object) do
745 |> prepare_attachments
748 |> strip_internal_fields
749 |> strip_internal_tags
755 # internal -> Mastodon
758 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
760 Object.normalize(object_id).data
765 |> Map.put("object", object)
766 |> Map.merge(Utils.make_json_ld_header())
771 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
772 # because of course it does.
773 def prepare_outgoing(%{"type" => "Accept"} = data) do
774 with follow_activity <- Activity.normalize(data["object"]) do
776 "actor" => follow_activity.actor,
777 "object" => follow_activity.data["object"],
778 "id" => follow_activity.data["id"],
784 |> Map.put("object", object)
785 |> Map.merge(Utils.make_json_ld_header())
791 def prepare_outgoing(%{"type" => "Reject"} = data) do
792 with follow_activity <- Activity.normalize(data["object"]) do
794 "actor" => follow_activity.actor,
795 "object" => follow_activity.data["object"],
796 "id" => follow_activity.data["id"],
802 |> Map.put("object", object)
803 |> Map.merge(Utils.make_json_ld_header())
809 def prepare_outgoing(%{"type" => _type} = data) do
812 |> strip_internal_fields
813 |> maybe_fix_object_url
814 |> Map.merge(Utils.make_json_ld_header())
819 def maybe_fix_object_url(data) do
820 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
821 case get_obj_helper(data["object"]) do
822 {:ok, relative_object} ->
823 if relative_object.data["external_url"] do
826 |> Map.put("object", relative_object.data["external_url"])
832 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
840 def add_hashtags(object) do
842 (object["tag"] || [])
844 # Expand internal representation tags into AS2 tags.
845 tag when is_binary(tag) ->
847 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
852 # Do not process tags which are already AS2 tag objects.
853 tag when is_map(tag) ->
858 |> Map.put("tag", tags)
861 def add_mention_tags(object) do
864 |> Utils.get_notified_from_object()
865 |> Enum.map(fn user ->
866 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
869 tags = object["tag"] || []
872 |> Map.put("tag", tags ++ mentions)
875 def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do
876 user_info = add_emoji_tags(user_info)
879 |> Map.put(:info, user_info)
882 # TODO: we should probably send mtime instead of unix epoch time for updated
883 def add_emoji_tags(%{"emoji" => emoji} = object) do
884 tags = object["tag"] || []
888 |> Enum.map(fn {name, url} ->
890 "icon" => %{"url" => url, "type" => "Image"},
891 "name" => ":" <> name <> ":",
893 "updated" => "1970-01-01T00:00:00Z",
899 |> Map.put("tag", tags ++ out)
902 def add_emoji_tags(object) do
906 def set_conversation(object) do
907 Map.put(object, "conversation", object["context"])
910 def set_sensitive(object) do
911 tags = object["tag"] || []
912 Map.put(object, "sensitive", "nsfw" in tags)
915 def set_type(%{"type" => "Answer"} = object) do
916 Map.put(object, "type", "Note")
919 def set_type(object), do: object
921 def add_attributed_to(object) do
922 attributed_to = object["attributedTo"] || object["actor"]
925 |> Map.put("attributedTo", attributed_to)
928 def add_likes(%{"id" => id, "like_count" => likes} = object) do
930 "id" => "#{id}/likes",
931 "first" => "#{id}/likes?page=1",
932 "type" => "OrderedCollection",
933 "totalItems" => likes
937 |> Map.put("likes", likes)
940 def add_likes(object) do
944 def prepare_attachments(object) do
946 (object["attachment"] || [])
947 |> Enum.map(fn data ->
948 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
949 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
953 |> Map.put("attachment", attachments)
956 defp strip_internal_fields(object) do
961 "announcement_count",
964 "deleted_activity_id"
968 defp strip_internal_tags(%{"tag" => tags} = object) do
971 |> Enum.filter(fn x -> is_map(x) end)
974 |> Map.put("tag", tags)
977 defp strip_internal_tags(object), do: object
979 def perform(:user_upgrade, user) do
980 # we pass a fake user so that the followers collection is stripped away
981 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
986 where: ^old_follower_address in u.following,
991 "array_replace(?,?,?)",
993 ^old_follower_address,
994 ^user.follower_address
1000 Repo.update_all(q, [])
1002 maybe_retire_websub(user.ap_id)
1007 where: ^old_follower_address in a.recipients,
1012 "array_replace(?,?,?)",
1014 ^old_follower_address,
1015 ^user.follower_address
1021 Repo.update_all(q, [])
1024 def upgrade_user_from_ap_id(ap_id) do
1025 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1026 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1027 already_ap <- User.ap_enabled?(user),
1028 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1029 unless already_ap do
1030 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1035 %User{} = user -> {:ok, user}
1040 def maybe_retire_websub(ap_id) do
1041 # some sanity checks
1042 if is_binary(ap_id) && String.length(ap_id) > 8 do
1045 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1046 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1053 def maybe_fix_user_url(data) do
1054 if is_map(data["url"]) do
1055 Map.put(data, "url", data["url"]["href"])
1061 def maybe_fix_user_object(data) do
1063 |> maybe_fix_user_url