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(
69 %{"to" => to, "cc" => cc} = object,
75 |> Enum.filter(fn x -> x in explicit_mentions end)
79 |> Enum.filter(fn x -> x not in explicit_mentions end)
83 |> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end)
87 |> Map.put("to", explicit_to)
88 |> Map.put("cc", final_cc)
91 def fix_explicit_addressing(object, _explicit_mentions, _followers_collection), do: object
93 # if directMessage flag is set to true, leave the addressing alone
94 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
96 def fix_explicit_addressing(object) do
99 |> Utils.determine_explicit_mentions()
101 follower_collection = User.get_cached_by_ap_id(Containment.get_actor(object)).follower_address
104 explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public", follower_collection]
106 fix_explicit_addressing(object, explicit_mentions, follower_collection)
109 # if as:Public is addressed, then make sure the followers collection is also addressed
110 # so that the activities will be delivered to local users.
111 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
112 recipients = to ++ cc
114 if followers_collection not in recipients do
116 "https://www.w3.org/ns/activitystreams#Public" in cc ->
117 to = to ++ [followers_collection]
118 Map.put(object, "to", to)
120 "https://www.w3.org/ns/activitystreams#Public" in to ->
121 cc = cc ++ [followers_collection]
122 Map.put(object, "cc", cc)
132 def fix_implicit_addressing(object, _), do: object
134 def fix_addressing(object) do
135 {:ok, %User{} = user} = User.get_or_fetch_by_ap_id(object["actor"])
136 followers_collection = User.ap_followers(user)
139 |> fix_addressing_list("to")
140 |> fix_addressing_list("cc")
141 |> fix_addressing_list("bto")
142 |> fix_addressing_list("bcc")
143 |> fix_explicit_addressing()
144 |> fix_implicit_addressing(followers_collection)
147 def fix_actor(%{"attributedTo" => actor} = object) do
149 |> Map.put("actor", Containment.get_actor(%{"actor" => actor}))
152 # Check for standardisation
153 # This is what Peertube does
154 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
155 # Prismo returns only an integer (count) as "likes"
156 def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do
158 |> Map.put("likes", [])
159 |> Map.put("like_count", 0)
162 def fix_likes(object) do
166 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
167 when not is_nil(in_reply_to) do
170 is_bitstring(in_reply_to) ->
173 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
176 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
177 Enum.at(in_reply_to, 0)
179 # Maybe I should output an error too?
184 case get_obj_helper(in_reply_to_id) do
185 {:ok, replied_object} ->
186 with %Activity{} = _activity <-
187 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
189 |> Map.put("inReplyTo", replied_object.data["id"])
190 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
191 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
192 |> Map.put("context", replied_object.data["context"] || object["conversation"])
195 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
200 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
205 def fix_in_reply_to(object), do: object
207 def fix_context(object) do
208 context = object["context"] || object["conversation"] || Utils.generate_context_id()
211 |> Map.put("context", context)
212 |> Map.put("conversation", context)
215 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
218 |> Enum.map(fn data ->
219 media_type = data["mediaType"] || data["mimeType"]
220 href = data["url"] || data["href"]
222 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
225 |> Map.put("mediaType", media_type)
226 |> Map.put("url", url)
230 |> Map.put("attachment", attachments)
233 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
234 Map.put(object, "attachment", [attachment])
238 def fix_attachments(object), do: object
240 def fix_url(%{"url" => url} = object) when is_map(url) do
242 |> Map.put("url", url["href"])
245 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
246 first_element = Enum.at(url, 0)
250 |> Enum.filter(fn x -> is_map(x) end)
251 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
255 |> Map.put("attachment", [first_element])
256 |> Map.put("url", link_element["href"])
259 def fix_url(%{"type" => object_type, "url" => url} = object)
260 when object_type != "Video" and is_list(url) do
261 first_element = Enum.at(url, 0)
265 is_bitstring(first_element) -> first_element
266 is_map(first_element) -> first_element["href"] || ""
271 |> Map.put("url", url_string)
274 def fix_url(object), do: object
276 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
277 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
281 |> Enum.reduce(%{}, fn data, mapping ->
282 name = String.trim(data["name"], ":")
284 mapping |> Map.put(name, data["icon"]["url"])
287 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
288 emoji = Map.merge(object["emoji"] || %{}, emoji)
291 |> Map.put("emoji", emoji)
294 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
295 name = String.trim(tag["name"], ":")
296 emoji = %{name => tag["icon"]["url"]}
299 |> Map.put("emoji", emoji)
302 def fix_emoji(object), do: object
304 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
307 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
308 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
310 combined = tag ++ tags
313 |> Map.put("tag", combined)
316 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
317 combined = [tag, String.slice(hashtag, 1..-1)]
320 |> Map.put("tag", combined)
323 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
325 def fix_tag(object), do: object
327 # content map usually only has one language so this will do for now.
328 def fix_content_map(%{"contentMap" => content_map} = object) do
329 content_groups = Map.to_list(content_map)
330 {_, content} = Enum.at(content_groups, 0)
333 |> Map.put("content", content)
336 def fix_content_map(object), do: object
338 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
339 with true <- id =~ "follows",
340 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
341 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
348 defp mastodon_follow_hack(_, _), do: {:error, nil}
350 defp get_follow_activity(follow_object, followed) do
351 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
352 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
355 # Can't find the activity. This might a Mastodon 2.3 "Accept"
357 mastodon_follow_hack(follow_object, followed)
364 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
366 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do
367 with context <- data["context"] || Utils.generate_context_id(),
368 content <- data["content"] || "",
369 %User{} = actor <- User.get_cached_by_ap_id(actor),
371 # Reduce the object list to find the reported user.
373 Enum.reduce_while(objects, nil, fn ap_id, _ ->
374 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
381 # Remove the reported user from the object list.
382 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
390 "cc" => [account.ap_id]
394 ActivityPub.flag(params)
398 # disallow objects with bogus IDs
399 def handle_incoming(%{"id" => nil}), do: :error
400 def handle_incoming(%{"id" => ""}), do: :error
401 # length of https:// = 8, should validate better, but good enough for now.
402 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
404 # TODO: validate those with a Ecto scheme
407 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
408 when objtype in ["Article", "Note", "Video", "Page"] do
409 actor = Containment.get_actor(data)
412 Map.put(data, "actor", actor)
415 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
416 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
417 object = fix_object(data["object"])
423 context: object["conversation"],
425 published: data["published"],
434 ActivityPub.create(params)
436 %Activity{} = activity -> {:ok, activity}
442 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
444 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
445 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
446 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
447 with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]),
448 {:user_blocked, false} <-
449 {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked},
450 {:user_locked, false} <- {:user_locked, User.locked?(followed)},
451 {:follow, {:ok, follower}} <- {:follow, User.follow(follower, followed)} do
452 ActivityPub.accept(%{
453 to: [follower.ap_id],
459 {:user_blocked, true} ->
460 {:ok, _} = Utils.update_follow_state(activity, "reject")
462 ActivityPub.reject(%{
463 to: [follower.ap_id],
469 {:follow, {:error, _}} ->
470 {:ok, _} = Utils.update_follow_state(activity, "reject")
472 ActivityPub.reject(%{
473 to: [follower.ap_id],
479 {:user_locked, true} ->
491 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
493 with actor <- Containment.get_actor(data),
494 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
495 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
496 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
497 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
499 ActivityPub.accept(%{
500 to: follow_activity.data["to"],
503 object: follow_activity.data["id"],
506 if not User.following?(follower, followed) do
507 {:ok, _follower} = User.follow(follower, followed)
517 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
519 with actor <- Containment.get_actor(data),
520 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
521 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
522 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
523 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
525 ActivityPub.reject(%{
526 to: follow_activity.data["to"],
529 object: follow_activity.data["id"],
532 User.unfollow(follower, followed)
541 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
543 with actor <- Containment.get_actor(data),
544 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
545 {:ok, object} <- get_obj_helper(object_id),
546 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
554 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
556 with actor <- Containment.get_actor(data),
557 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
558 {:ok, object} <- get_obj_helper(object_id),
559 public <- Visibility.is_public?(data),
560 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
568 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
571 when object_type in ["Person", "Application", "Service", "Organization"] do
572 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
573 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
575 banner = new_user_data[:info]["banner"]
576 locked = new_user_data[:info]["locked"] || false
580 |> Map.take([:name, :bio, :avatar])
581 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
584 |> User.upgrade_changeset(update_data)
585 |> User.update_and_set_cache()
587 ActivityPub.update(%{
589 to: data["to"] || [],
590 cc: data["cc"] || [],
601 # TODO: We presently assume that any actor on the same origin domain as the object being
602 # deleted has the rights to delete that object. A better way to validate whether or not
603 # the object should be deleted is to refetch the object URI, which should return either
604 # an error or a tombstone. This would allow us to verify that a deletion actually took
607 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
609 object_id = Utils.get_ap_id(object_id)
611 with actor <- Containment.get_actor(data),
612 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
613 {:ok, object} <- get_obj_helper(object_id),
614 :ok <- Containment.contain_origin(actor.ap_id, object.data),
615 {:ok, activity} <- ActivityPub.delete(object, false) do
625 "object" => %{"type" => "Announce", "object" => object_id},
630 with actor <- Containment.get_actor(data),
631 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
632 {:ok, object} <- get_obj_helper(object_id),
633 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
643 "object" => %{"type" => "Follow", "object" => followed},
648 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
649 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
650 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
651 User.unfollow(follower, followed)
661 "object" => %{"type" => "Block", "object" => blocked},
666 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
667 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
668 {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
669 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
670 User.unblock(blocker, blocked)
678 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
680 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
681 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
682 {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
683 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
684 User.unfollow(blocker, blocked)
685 User.block(blocker, blocked)
695 "object" => %{"type" => "Like", "object" => object_id},
700 with actor <- Containment.get_actor(data),
701 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
702 {:ok, object} <- get_obj_helper(object_id),
703 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
710 def handle_incoming(_), do: :error
712 def get_obj_helper(id) do
713 if object = Object.normalize(id), do: {:ok, object}, else: nil
716 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
717 with false <- String.starts_with?(in_reply_to, "http"),
718 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
719 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
725 def set_reply_to_uri(obj), do: obj
727 # Prepares the object of an outgoing create activity.
728 def prepare_object(object) do
736 |> prepare_attachments
739 |> strip_internal_fields
740 |> strip_internal_tags
745 # internal -> Mastodon
748 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
750 Object.normalize(object_id).data
755 |> Map.put("object", object)
756 |> Map.merge(Utils.make_json_ld_header())
761 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
762 # because of course it does.
763 def prepare_outgoing(%{"type" => "Accept"} = data) do
764 with follow_activity <- Activity.normalize(data["object"]) do
766 "actor" => follow_activity.actor,
767 "object" => follow_activity.data["object"],
768 "id" => follow_activity.data["id"],
774 |> Map.put("object", object)
775 |> Map.merge(Utils.make_json_ld_header())
781 def prepare_outgoing(%{"type" => "Reject"} = data) do
782 with follow_activity <- Activity.normalize(data["object"]) do
784 "actor" => follow_activity.actor,
785 "object" => follow_activity.data["object"],
786 "id" => follow_activity.data["id"],
792 |> Map.put("object", object)
793 |> Map.merge(Utils.make_json_ld_header())
799 def prepare_outgoing(%{"type" => _type} = data) do
802 |> strip_internal_fields
803 |> maybe_fix_object_url
804 |> Map.merge(Utils.make_json_ld_header())
809 def maybe_fix_object_url(data) do
810 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
811 case get_obj_helper(data["object"]) do
812 {:ok, relative_object} ->
813 if relative_object.data["external_url"] do
816 |> Map.put("object", relative_object.data["external_url"])
822 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
830 def add_hashtags(object) do
832 (object["tag"] || [])
834 # Expand internal representation tags into AS2 tags.
835 tag when is_binary(tag) ->
837 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
842 # Do not process tags which are already AS2 tag objects.
843 tag when is_map(tag) ->
848 |> Map.put("tag", tags)
851 def add_mention_tags(object) do
854 |> Utils.get_notified_from_object()
855 |> Enum.map(fn user ->
856 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
859 tags = object["tag"] || []
862 |> Map.put("tag", tags ++ mentions)
865 def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do
866 user_info = add_emoji_tags(user_info)
869 |> Map.put(:info, user_info)
872 # TODO: we should probably send mtime instead of unix epoch time for updated
873 def add_emoji_tags(%{"emoji" => emoji} = object) do
874 tags = object["tag"] || []
878 |> Enum.map(fn {name, url} ->
880 "icon" => %{"url" => url, "type" => "Image"},
881 "name" => ":" <> name <> ":",
883 "updated" => "1970-01-01T00:00:00Z",
889 |> Map.put("tag", tags ++ out)
892 def add_emoji_tags(object) do
896 def set_conversation(object) do
897 Map.put(object, "conversation", object["context"])
900 def set_sensitive(object) do
901 tags = object["tag"] || []
902 Map.put(object, "sensitive", "nsfw" in tags)
905 def add_attributed_to(object) do
906 attributed_to = object["attributedTo"] || object["actor"]
909 |> Map.put("attributedTo", attributed_to)
912 def add_likes(%{"id" => id, "like_count" => likes} = object) do
914 "id" => "#{id}/likes",
915 "first" => "#{id}/likes?page=1",
916 "type" => "OrderedCollection",
917 "totalItems" => likes
921 |> Map.put("likes", likes)
924 def add_likes(object) do
928 def prepare_attachments(object) do
930 (object["attachment"] || [])
931 |> Enum.map(fn data ->
932 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
933 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
937 |> Map.put("attachment", attachments)
940 defp strip_internal_fields(object) do
945 "announcement_count",
948 "deleted_activity_id"
952 defp strip_internal_tags(%{"tag" => tags} = object) do
955 |> Enum.filter(fn x -> is_map(x) end)
958 |> Map.put("tag", tags)
961 defp strip_internal_tags(object), do: object
963 def perform(:user_upgrade, user) do
964 # we pass a fake user so that the followers collection is stripped away
965 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
970 where: ^old_follower_address in u.following,
975 "array_replace(?,?,?)",
977 ^old_follower_address,
978 ^user.follower_address
984 Repo.update_all(q, [])
986 maybe_retire_websub(user.ap_id)
991 where: ^old_follower_address in a.recipients,
996 "array_replace(?,?,?)",
998 ^old_follower_address,
999 ^user.follower_address
1005 Repo.update_all(q, [])
1008 def upgrade_user_from_ap_id(ap_id) do
1009 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1010 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1011 already_ap <- User.ap_enabled?(user),
1012 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1013 unless already_ap do
1014 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1019 %User{} = user -> {:ok, user}
1024 def maybe_retire_websub(ap_id) do
1025 # some sanity checks
1026 if is_binary(ap_id) && String.length(ap_id) > 8 do
1029 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1030 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1037 def maybe_fix_user_url(data) do
1038 if is_map(data["url"]) do
1039 Map.put(data, "url", data["url"]["href"])
1045 def maybe_fix_user_object(data) do
1047 |> maybe_fix_user_url