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 {:ok, %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 {:ok, %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 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
440 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
441 with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]),
442 {:user_blocked, false} <-
443 {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked},
444 {:user_locked, false} <- {:user_locked, User.locked?(followed)},
445 {:follow, {:ok, follower}} <- {:follow, User.follow(follower, followed)} do
446 ActivityPub.accept(%{
447 to: [follower.ap_id],
453 {:user_blocked, true} ->
454 {:ok, _} = Utils.update_follow_state(activity, "reject")
456 ActivityPub.reject(%{
457 to: [follower.ap_id],
463 {:follow, {:error, _}} ->
464 {:ok, _} = Utils.update_follow_state(activity, "reject")
466 ActivityPub.reject(%{
467 to: [follower.ap_id],
473 {:user_locked, true} ->
485 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
487 with actor <- Containment.get_actor(data),
488 {:ok, %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, "accept"),
491 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
493 ActivityPub.accept(%{
494 to: follow_activity.data["to"],
497 object: follow_activity.data["id"],
500 if not User.following?(follower, followed) do
501 {:ok, _follower} = User.follow(follower, followed)
511 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
513 with actor <- Containment.get_actor(data),
514 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
515 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
516 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
517 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
519 ActivityPub.reject(%{
520 to: follow_activity.data["to"],
523 object: follow_activity.data["id"],
526 User.unfollow(follower, followed)
535 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
537 with actor <- Containment.get_actor(data),
538 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
539 {:ok, object} <- get_obj_helper(object_id),
540 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
548 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
550 with actor <- Containment.get_actor(data),
551 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
552 {:ok, object} <- get_obj_helper(object_id),
553 public <- Visibility.is_public?(data),
554 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
562 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
565 when object_type in ["Person", "Application", "Service", "Organization"] do
566 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
567 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
569 banner = new_user_data[:info]["banner"]
570 locked = new_user_data[:info]["locked"] || false
574 |> Map.take([:name, :bio, :avatar])
575 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
578 |> User.upgrade_changeset(update_data)
579 |> User.update_and_set_cache()
581 ActivityPub.update(%{
583 to: data["to"] || [],
584 cc: data["cc"] || [],
595 # TODO: We presently assume that any actor on the same origin domain as the object being
596 # deleted has the rights to delete that object. A better way to validate whether or not
597 # the object should be deleted is to refetch the object URI, which should return either
598 # an error or a tombstone. This would allow us to verify that a deletion actually took
601 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
603 object_id = Utils.get_ap_id(object_id)
605 with actor <- Containment.get_actor(data),
606 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
607 {:ok, object} <- get_obj_helper(object_id),
608 :ok <- Containment.contain_origin(actor.ap_id, object.data),
609 {:ok, activity} <- ActivityPub.delete(object, false) do
619 "object" => %{"type" => "Announce", "object" => object_id},
624 with actor <- Containment.get_actor(data),
625 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
626 {:ok, object} <- get_obj_helper(object_id),
627 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
637 "object" => %{"type" => "Follow", "object" => followed},
642 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
643 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
644 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
645 User.unfollow(follower, followed)
655 "object" => %{"type" => "Block", "object" => blocked},
660 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
661 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
662 {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
663 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
664 User.unblock(blocker, blocked)
672 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
674 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
675 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
676 {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
677 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
678 User.unfollow(blocker, blocked)
679 User.block(blocker, blocked)
689 "object" => %{"type" => "Like", "object" => object_id},
694 with actor <- Containment.get_actor(data),
695 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
696 {:ok, object} <- get_obj_helper(object_id),
697 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
704 def handle_incoming(_), do: :error
706 def get_obj_helper(id) do
707 if object = Object.normalize(id), do: {:ok, object}, else: nil
710 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
711 with false <- String.starts_with?(in_reply_to, "http"),
712 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
713 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
719 def set_reply_to_uri(obj), do: obj
721 # Prepares the object of an outgoing create activity.
722 def prepare_object(object) do
730 |> prepare_attachments
733 |> strip_internal_fields
734 |> strip_internal_tags
739 # internal -> Mastodon
742 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
744 Object.normalize(object_id).data
749 |> Map.put("object", object)
750 |> Map.merge(Utils.make_json_ld_header())
755 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
756 # because of course it does.
757 def prepare_outgoing(%{"type" => "Accept"} = data) do
758 with follow_activity <- Activity.normalize(data["object"]) do
760 "actor" => follow_activity.actor,
761 "object" => follow_activity.data["object"],
762 "id" => follow_activity.data["id"],
768 |> Map.put("object", object)
769 |> Map.merge(Utils.make_json_ld_header())
775 def prepare_outgoing(%{"type" => "Reject"} = data) do
776 with follow_activity <- Activity.normalize(data["object"]) do
778 "actor" => follow_activity.actor,
779 "object" => follow_activity.data["object"],
780 "id" => follow_activity.data["id"],
786 |> Map.put("object", object)
787 |> Map.merge(Utils.make_json_ld_header())
793 def prepare_outgoing(%{"type" => _type} = data) do
796 |> strip_internal_fields
797 |> maybe_fix_object_url
798 |> Map.merge(Utils.make_json_ld_header())
803 def maybe_fix_object_url(data) do
804 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
805 case get_obj_helper(data["object"]) do
806 {:ok, relative_object} ->
807 if relative_object.data["external_url"] do
810 |> Map.put("object", relative_object.data["external_url"])
816 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
824 def add_hashtags(object) do
826 (object["tag"] || [])
828 # Expand internal representation tags into AS2 tags.
829 tag when is_binary(tag) ->
831 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
836 # Do not process tags which are already AS2 tag objects.
837 tag when is_map(tag) ->
842 |> Map.put("tag", tags)
845 def add_mention_tags(object) do
848 |> Utils.get_notified_from_object()
849 |> Enum.map(fn user ->
850 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
853 tags = object["tag"] || []
856 |> Map.put("tag", tags ++ mentions)
859 def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do
860 user_info = add_emoji_tags(user_info)
863 |> Map.put(:info, user_info)
866 # TODO: we should probably send mtime instead of unix epoch time for updated
867 def add_emoji_tags(%{"emoji" => emoji} = object) do
868 tags = object["tag"] || []
872 |> Enum.map(fn {name, url} ->
874 "icon" => %{"url" => url, "type" => "Image"},
875 "name" => ":" <> name <> ":",
877 "updated" => "1970-01-01T00:00:00Z",
883 |> Map.put("tag", tags ++ out)
886 def add_emoji_tags(object) do
890 def set_conversation(object) do
891 Map.put(object, "conversation", object["context"])
894 def set_sensitive(object) do
895 tags = object["tag"] || []
896 Map.put(object, "sensitive", "nsfw" in tags)
899 def add_attributed_to(object) do
900 attributed_to = object["attributedTo"] || object["actor"]
903 |> Map.put("attributedTo", attributed_to)
906 def add_likes(%{"id" => id, "like_count" => likes} = object) do
908 "id" => "#{id}/likes",
909 "first" => "#{id}/likes?page=1",
910 "type" => "OrderedCollection",
911 "totalItems" => likes
915 |> Map.put("likes", likes)
918 def add_likes(object) do
922 def prepare_attachments(object) do
924 (object["attachment"] || [])
925 |> Enum.map(fn data ->
926 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
927 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
931 |> Map.put("attachment", attachments)
934 defp strip_internal_fields(object) do
939 "announcement_count",
942 "deleted_activity_id"
946 defp strip_internal_tags(%{"tag" => tags} = object) do
949 |> Enum.filter(fn x -> is_map(x) end)
952 |> Map.put("tag", tags)
955 defp strip_internal_tags(object), do: object
957 def perform(:user_upgrade, user) do
958 # we pass a fake user so that the followers collection is stripped away
959 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
964 where: ^old_follower_address in u.following,
969 "array_replace(?,?,?)",
971 ^old_follower_address,
972 ^user.follower_address
978 Repo.update_all(q, [])
980 maybe_retire_websub(user.ap_id)
985 where: ^old_follower_address in a.recipients,
990 "array_replace(?,?,?)",
992 ^old_follower_address,
993 ^user.follower_address
999 Repo.update_all(q, [])
1002 def upgrade_user_from_ap_id(ap_id) do
1003 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1004 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1005 already_ap <- User.ap_enabled?(user),
1006 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1007 unless already_ap do
1008 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1013 %User{} = user -> {:ok, user}
1018 def maybe_retire_websub(ap_id) do
1019 # some sanity checks
1020 if is_binary(ap_id) && String.length(ap_id) > 8 do
1023 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1024 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1031 def maybe_fix_user_url(data) do
1032 if is_map(data["url"]) do
1033 Map.put(data, "url", data["url"]["href"])
1039 def maybe_fix_user_object(data) do
1041 |> maybe_fix_user_url