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.
13 alias Pleroma.Web.ActivityPub.ActivityPub
14 alias Pleroma.Web.ActivityPub.Utils
15 alias Pleroma.Web.ActivityPub.Visibility
21 def get_actor(%{"actor" => actor}) when is_binary(actor) do
25 def get_actor(%{"actor" => actor}) when is_list(actor) do
26 if is_binary(Enum.at(actor, 0)) do
29 Enum.find(actor, fn %{"type" => type} -> type in ["Person", "Service", "Application"] end)
34 def get_actor(%{"actor" => %{"id" => id}}) when is_bitstring(id) do
38 def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do
39 get_actor(%{"actor" => actor})
43 Checks that an imported AP object's actor matches the domain it came from.
45 def contain_origin(_id, %{"actor" => nil}), do: :error
47 def contain_origin(id, %{"actor" => _actor} = params) do
48 id_uri = URI.parse(id)
49 actor_uri = URI.parse(get_actor(params))
51 if id_uri.host == actor_uri.host do
58 def contain_origin_from_id(_id, %{"id" => nil}), do: :error
60 def contain_origin_from_id(id, %{"id" => other_id} = _params) do
61 id_uri = URI.parse(id)
62 other_uri = URI.parse(other_id)
64 if id_uri.host == other_uri.host do
72 Modifies an incoming AP object (mastodon format) to our internal format.
74 def fix_object(object) do
89 def fix_summary(%{"summary" => nil} = object) do
91 |> Map.put("summary", "")
94 def fix_summary(%{"summary" => _} = object) do
95 # summary is present, nothing to do
99 def fix_summary(object) do
101 |> Map.put("summary", "")
104 def fix_addressing_list(map, field) do
106 is_binary(map[field]) ->
107 Map.put(map, field, [map[field]])
109 is_nil(map[field]) ->
110 Map.put(map, field, [])
117 def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, explicit_mentions) do
120 |> Enum.filter(fn x -> x in explicit_mentions end)
124 |> Enum.filter(fn x -> x not in explicit_mentions end)
131 |> Map.put("to", explicit_to)
132 |> Map.put("cc", final_cc)
135 def fix_explicit_addressing(object, _explicit_mentions), do: object
137 # if directMessage flag is set to true, leave the addressing alone
138 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
140 def fix_explicit_addressing(object) do
143 |> Utils.determine_explicit_mentions()
145 explicit_mentions = explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public"]
148 |> fix_explicit_addressing(explicit_mentions)
151 # if as:Public is addressed, then make sure the followers collection is also addressed
152 # so that the activities will be delivered to local users.
153 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
154 recipients = to ++ cc
156 if followers_collection not in recipients do
158 "https://www.w3.org/ns/activitystreams#Public" in cc ->
159 to = to ++ [followers_collection]
160 Map.put(object, "to", to)
162 "https://www.w3.org/ns/activitystreams#Public" in to ->
163 cc = cc ++ [followers_collection]
164 Map.put(object, "cc", cc)
174 def fix_implicit_addressing(object, _), do: object
176 def fix_addressing(object) do
177 %User{} = user = User.get_or_fetch_by_ap_id(object["actor"])
178 followers_collection = User.ap_followers(user)
181 |> fix_addressing_list("to")
182 |> fix_addressing_list("cc")
183 |> fix_addressing_list("bto")
184 |> fix_addressing_list("bcc")
185 |> fix_explicit_addressing
186 |> fix_implicit_addressing(followers_collection)
189 def fix_actor(%{"attributedTo" => actor} = object) do
191 |> Map.put("actor", get_actor(%{"actor" => actor}))
194 # Check for standardisation
195 # This is what Peertube does
196 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
197 # Prismo returns only an integer (count) as "likes"
198 def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do
200 |> Map.put("likes", [])
201 |> Map.put("like_count", 0)
204 def fix_likes(object) do
208 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
209 when not is_nil(in_reply_to) do
212 is_bitstring(in_reply_to) ->
215 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
218 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
219 Enum.at(in_reply_to, 0)
221 # Maybe I should output an error too?
226 case fetch_obj_helper(in_reply_to_id) do
227 {:ok, replied_object} ->
228 with %Activity{} = _activity <-
229 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
231 |> Map.put("inReplyTo", replied_object.data["id"])
232 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
233 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
234 |> Map.put("context", replied_object.data["context"] || object["conversation"])
237 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
242 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
247 def fix_in_reply_to(object), do: object
249 def fix_context(object) do
250 context = object["context"] || object["conversation"] || Utils.generate_context_id()
253 |> Map.put("context", context)
254 |> Map.put("conversation", context)
257 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
260 |> Enum.map(fn data ->
261 media_type = data["mediaType"] || data["mimeType"]
262 href = data["url"] || data["href"]
264 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
267 |> Map.put("mediaType", media_type)
268 |> Map.put("url", url)
272 |> Map.put("attachment", attachments)
275 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
276 Map.put(object, "attachment", [attachment])
280 def fix_attachments(object), do: object
282 def fix_url(%{"url" => url} = object) when is_map(url) do
284 |> Map.put("url", url["href"])
287 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
288 first_element = Enum.at(url, 0)
292 |> Enum.filter(fn x -> is_map(x) end)
293 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
297 |> Map.put("attachment", [first_element])
298 |> Map.put("url", link_element["href"])
301 def fix_url(%{"type" => object_type, "url" => url} = object)
302 when object_type != "Video" and is_list(url) do
303 first_element = Enum.at(url, 0)
307 is_bitstring(first_element) -> first_element
308 is_map(first_element) -> first_element["href"] || ""
313 |> Map.put("url", url_string)
316 def fix_url(object), do: object
318 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
319 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
323 |> Enum.reduce(%{}, fn data, mapping ->
324 name = String.trim(data["name"], ":")
326 mapping |> Map.put(name, data["icon"]["url"])
329 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
330 emoji = Map.merge(object["emoji"] || %{}, emoji)
333 |> Map.put("emoji", emoji)
336 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
337 name = String.trim(tag["name"], ":")
338 emoji = %{name => tag["icon"]["url"]}
341 |> Map.put("emoji", emoji)
344 def fix_emoji(object), do: object
346 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
349 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
350 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
352 combined = tag ++ tags
355 |> Map.put("tag", combined)
358 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
359 combined = [tag, String.slice(hashtag, 1..-1)]
362 |> Map.put("tag", combined)
365 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
367 def fix_tag(object), do: object
369 # content map usually only has one language so this will do for now.
370 def fix_content_map(%{"contentMap" => content_map} = object) do
371 content_groups = Map.to_list(content_map)
372 {_, content} = Enum.at(content_groups, 0)
375 |> Map.put("content", content)
378 def fix_content_map(object), do: object
380 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
381 with true <- id =~ "follows",
382 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
383 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
390 defp mastodon_follow_hack(_, _), do: {:error, nil}
392 defp get_follow_activity(follow_object, followed) do
393 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
394 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
397 # Can't find the activity. This might a Mastodon 2.3 "Accept"
399 mastodon_follow_hack(follow_object, followed)
406 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
408 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do
409 with context <- data["context"] || Utils.generate_context_id(),
410 content <- data["content"] || "",
411 %User{} = actor <- User.get_cached_by_ap_id(actor),
413 # Reduce the object list to find the reported user.
415 Enum.reduce_while(objects, nil, fn ap_id, _ ->
416 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
423 # Remove the reported user from the object list.
424 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
432 "cc" => [account.ap_id]
436 ActivityPub.flag(params)
440 # disallow objects with bogus IDs
441 def handle_incoming(%{"id" => nil}), do: :error
442 def handle_incoming(%{"id" => ""}), do: :error
443 # length of https:// = 8, should validate better, but good enough for now.
444 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
446 # TODO: validate those with a Ecto scheme
449 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
450 when objtype in ["Article", "Note", "Video", "Page"] do
451 actor = get_actor(data)
454 Map.put(data, "actor", actor)
457 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
458 %User{} = user <- User.get_or_fetch_by_ap_id(data["actor"]) do
459 object = fix_object(data["object"])
465 context: object["conversation"],
467 published: data["published"],
476 ActivityPub.create(params)
478 %Activity{} = activity -> {:ok, activity}
484 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
486 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
487 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
488 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
489 if not User.locked?(followed) do
490 ActivityPub.accept(%{
491 to: [follower.ap_id],
497 User.follow(follower, followed)
507 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
509 with actor <- get_actor(data),
510 %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
511 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
512 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
513 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
515 ActivityPub.accept(%{
516 to: follow_activity.data["to"],
519 object: follow_activity.data["id"],
522 if not User.following?(follower, followed) do
523 {:ok, _follower} = User.follow(follower, followed)
533 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
535 with actor <- get_actor(data),
536 %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
537 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
538 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
539 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
541 ActivityPub.reject(%{
542 to: follow_activity.data["to"],
545 object: follow_activity.data["id"],
548 User.unfollow(follower, followed)
557 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
559 with actor <- get_actor(data),
560 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
561 {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id),
562 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
570 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
572 with actor <- get_actor(data),
573 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
574 {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id),
575 public <- Visibility.is_public?(data),
576 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
584 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
587 when object_type in ["Person", "Application", "Service", "Organization"] do
588 with %User{ap_id: ^actor_id} = actor <- User.get_by_ap_id(object["id"]) do
589 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
591 banner = new_user_data[:info]["banner"]
592 locked = new_user_data[:info]["locked"] || false
596 |> Map.take([:name, :bio, :avatar])
597 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
600 |> User.upgrade_changeset(update_data)
601 |> User.update_and_set_cache()
603 ActivityPub.update(%{
605 to: data["to"] || [],
606 cc: data["cc"] || [],
617 # TODO: We presently assume that any actor on the same origin domain as the object being
618 # deleted has the rights to delete that object. A better way to validate whether or not
619 # the object should be deleted is to refetch the object URI, which should return either
620 # an error or a tombstone. This would allow us to verify that a deletion actually took
623 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
625 object_id = Utils.get_ap_id(object_id)
627 with actor <- get_actor(data),
628 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
629 {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id),
630 :ok <- contain_origin(actor.ap_id, object.data),
631 {:ok, activity} <- ActivityPub.delete(object, false) do
641 "object" => %{"type" => "Announce", "object" => object_id},
646 with actor <- get_actor(data),
647 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
648 {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id),
649 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
659 "object" => %{"type" => "Follow", "object" => followed},
664 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
665 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
666 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
667 User.unfollow(follower, followed)
677 "object" => %{"type" => "Block", "object" => blocked},
682 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
683 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
684 %User{} = blocker <- User.get_or_fetch_by_ap_id(blocker),
685 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
686 User.unblock(blocker, blocked)
694 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
696 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
697 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
698 %User{} = blocker = User.get_or_fetch_by_ap_id(blocker),
699 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
700 User.unfollow(blocker, blocked)
701 User.block(blocker, blocked)
711 "object" => %{"type" => "Like", "object" => object_id},
716 with actor <- get_actor(data),
717 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
718 {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id),
719 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
726 def handle_incoming(_), do: :error
728 def fetch_obj_helper(id) when is_bitstring(id), do: ActivityPub.fetch_object_from_id(id)
729 def fetch_obj_helper(obj) when is_map(obj), do: ActivityPub.fetch_object_from_id(obj["id"])
731 def get_obj_helper(id) do
732 if object = Object.normalize(id), do: {:ok, object}, else: nil
735 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
736 with false <- String.starts_with?(in_reply_to, "http"),
737 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
738 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
744 def set_reply_to_uri(obj), do: obj
746 # Prepares the object of an outgoing create activity.
747 def prepare_object(object) do
755 |> prepare_attachments
758 |> strip_internal_fields
759 |> strip_internal_tags
764 # internal -> Mastodon
767 def prepare_outgoing(%{"type" => "Create", "object" => object} = data) do
774 |> Map.put("object", object)
775 |> Map.merge(Utils.make_json_ld_header())
780 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
781 # because of course it does.
782 def prepare_outgoing(%{"type" => "Accept"} = data) do
783 with follow_activity <- Activity.normalize(data["object"]) do
785 "actor" => follow_activity.actor,
786 "object" => follow_activity.data["object"],
787 "id" => follow_activity.data["id"],
793 |> Map.put("object", object)
794 |> Map.merge(Utils.make_json_ld_header())
800 def prepare_outgoing(%{"type" => "Reject"} = data) do
801 with follow_activity <- Activity.normalize(data["object"]) do
803 "actor" => follow_activity.actor,
804 "object" => follow_activity.data["object"],
805 "id" => follow_activity.data["id"],
811 |> Map.put("object", object)
812 |> Map.merge(Utils.make_json_ld_header())
818 def prepare_outgoing(%{"type" => _type} = data) do
821 |> strip_internal_fields
822 |> maybe_fix_object_url
823 |> Map.merge(Utils.make_json_ld_header())
828 def maybe_fix_object_url(data) do
829 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
830 case fetch_obj_helper(data["object"]) do
831 {:ok, relative_object} ->
832 if relative_object.data["external_url"] do
835 |> Map.put("object", relative_object.data["external_url"])
841 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
849 def add_hashtags(object) do
851 (object["tag"] || [])
853 # Expand internal representation tags into AS2 tags.
854 tag when is_binary(tag) ->
856 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
861 # Do not process tags which are already AS2 tag objects.
862 tag when is_map(tag) ->
867 |> Map.put("tag", tags)
870 def add_mention_tags(object) do
873 |> Utils.get_notified_from_object()
874 |> Enum.map(fn user ->
875 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
878 tags = object["tag"] || []
881 |> Map.put("tag", tags ++ mentions)
884 # TODO: we should probably send mtime instead of unix epoch time for updated
885 def add_emoji_tags(object) do
886 tags = object["tag"] || []
887 emoji = object["emoji"] || []
891 |> Enum.map(fn {name, url} ->
893 "icon" => %{"url" => url, "type" => "Image"},
894 "name" => ":" <> name <> ":",
896 "updated" => "1970-01-01T00:00:00Z",
902 |> Map.put("tag", tags ++ out)
905 def set_conversation(object) do
906 Map.put(object, "conversation", object["context"])
909 def set_sensitive(object) do
910 tags = object["tag"] || []
911 Map.put(object, "sensitive", "nsfw" in tags)
914 def add_attributed_to(object) do
915 attributed_to = object["attributedTo"] || object["actor"]
918 |> Map.put("attributedTo", attributed_to)
921 def add_likes(%{"id" => id, "like_count" => likes} = object) do
923 "id" => "#{id}/likes",
924 "first" => "#{id}/likes?page=1",
925 "type" => "OrderedCollection",
926 "totalItems" => likes
930 |> Map.put("likes", likes)
933 def add_likes(object) do
937 def prepare_attachments(object) do
939 (object["attachment"] || [])
940 |> Enum.map(fn data ->
941 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
942 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
946 |> Map.put("attachment", attachments)
949 defp strip_internal_fields(object) do
954 "announcement_count",
957 "deleted_activity_id"
961 defp strip_internal_tags(%{"tag" => tags} = object) do
964 |> Enum.filter(fn x -> is_map(x) end)
967 |> Map.put("tag", tags)
970 defp strip_internal_tags(object), do: object
972 def perform(:user_upgrade, user) do
973 # we pass a fake user so that the followers collection is stripped away
974 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
979 where: ^old_follower_address in u.following,
984 "array_replace(?,?,?)",
986 ^old_follower_address,
987 ^user.follower_address
993 Repo.update_all(q, [])
995 maybe_retire_websub(user.ap_id)
1000 where: ^old_follower_address in a.recipients,
1005 "array_replace(?,?,?)",
1007 ^old_follower_address,
1008 ^user.follower_address
1014 Repo.update_all(q, [])
1017 def upgrade_user_from_ap_id(ap_id) do
1018 with %User{local: false} = user <- User.get_by_ap_id(ap_id),
1019 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1020 already_ap <- User.ap_enabled?(user),
1021 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1022 unless already_ap do
1023 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1028 %User{} = user -> {:ok, user}
1033 def maybe_retire_websub(ap_id) do
1034 # some sanity checks
1035 if is_binary(ap_id) && String.length(ap_id) > 8 do
1038 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1039 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1046 def maybe_fix_user_url(data) do
1047 if is_map(data["url"]) do
1048 Map.put(data, "url", data["url"]["href"])
1054 def maybe_fix_user_object(data) do
1056 |> maybe_fix_user_url