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
12 alias Pleroma.Activity
16 alias Pleroma.Web.ActivityPub.ActivityPub
17 alias Pleroma.Web.ActivityPub.Utils
18 alias Pleroma.Web.ActivityPub.Visibility
25 Modifies an incoming AP object (mastodon format) to our internal format.
27 def fix_object(object) do
42 def fix_summary(%{"summary" => nil} = object) do
44 |> Map.put("summary", "")
47 def fix_summary(%{"summary" => _} = object) do
48 # summary is present, nothing to do
52 def fix_summary(object) do
54 |> Map.put("summary", "")
57 def fix_addressing_list(map, field) do
59 is_binary(map[field]) ->
60 Map.put(map, field, [map[field]])
63 Map.put(map, field, [])
70 def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, explicit_mentions) do
73 |> Enum.filter(fn x -> x in explicit_mentions end)
77 |> Enum.filter(fn x -> x not in explicit_mentions end)
84 |> Map.put("to", explicit_to)
85 |> Map.put("cc", final_cc)
88 def fix_explicit_addressing(object, _explicit_mentions), do: object
90 # if directMessage flag is set to true, leave the addressing alone
91 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
93 def fix_explicit_addressing(object) do
96 |> Utils.determine_explicit_mentions()
98 explicit_mentions = explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public"]
101 |> fix_explicit_addressing(explicit_mentions)
104 # if as:Public is addressed, then make sure the followers collection is also addressed
105 # so that the activities will be delivered to local users.
106 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
107 recipients = to ++ cc
109 if followers_collection not in recipients do
111 "https://www.w3.org/ns/activitystreams#Public" in cc ->
112 to = to ++ [followers_collection]
113 Map.put(object, "to", to)
115 "https://www.w3.org/ns/activitystreams#Public" in to ->
116 cc = cc ++ [followers_collection]
117 Map.put(object, "cc", cc)
127 def fix_implicit_addressing(object, _), do: object
129 def fix_addressing(object) do
130 %User{} = user = User.get_or_fetch_by_ap_id(object["actor"])
131 followers_collection = User.ap_followers(user)
134 |> fix_addressing_list("to")
135 |> fix_addressing_list("cc")
136 |> fix_addressing_list("bto")
137 |> fix_addressing_list("bcc")
138 |> fix_explicit_addressing
139 |> fix_implicit_addressing(followers_collection)
142 def fix_actor(%{"attributedTo" => actor} = object) do
144 |> Map.put("actor", Containment.get_actor(%{"actor" => actor}))
147 # Check for standardisation
148 # This is what Peertube does
149 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
150 # Prismo returns only an integer (count) as "likes"
151 def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do
153 |> Map.put("likes", [])
154 |> Map.put("like_count", 0)
157 def fix_likes(object) do
161 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
162 when not is_nil(in_reply_to) do
165 is_bitstring(in_reply_to) ->
168 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
171 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
172 Enum.at(in_reply_to, 0)
174 # Maybe I should output an error too?
179 case get_obj_helper(in_reply_to_id) do
180 {:ok, replied_object} ->
181 with %Activity{} = _activity <-
182 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
184 |> Map.put("inReplyTo", replied_object.data["id"])
185 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
186 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
187 |> Map.put("context", replied_object.data["context"] || object["conversation"])
190 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
195 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
200 def fix_in_reply_to(object), do: object
202 def fix_context(object) do
203 context = object["context"] || object["conversation"] || Utils.generate_context_id()
206 |> Map.put("context", context)
207 |> Map.put("conversation", context)
210 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
213 |> Enum.map(fn data ->
214 media_type = data["mediaType"] || data["mimeType"]
215 href = data["url"] || data["href"]
217 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
220 |> Map.put("mediaType", media_type)
221 |> Map.put("url", url)
225 |> Map.put("attachment", attachments)
228 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
229 Map.put(object, "attachment", [attachment])
233 def fix_attachments(object), do: object
235 def fix_url(%{"url" => url} = object) when is_map(url) do
237 |> Map.put("url", url["href"])
240 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
241 first_element = Enum.at(url, 0)
245 |> Enum.filter(fn x -> is_map(x) end)
246 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
250 |> Map.put("attachment", [first_element])
251 |> Map.put("url", link_element["href"])
254 def fix_url(%{"type" => object_type, "url" => url} = object)
255 when object_type != "Video" and is_list(url) do
256 first_element = Enum.at(url, 0)
260 is_bitstring(first_element) -> first_element
261 is_map(first_element) -> first_element["href"] || ""
266 |> Map.put("url", url_string)
269 def fix_url(object), do: object
271 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
272 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
276 |> Enum.reduce(%{}, fn data, mapping ->
277 name = String.trim(data["name"], ":")
279 mapping |> Map.put(name, data["icon"]["url"])
282 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
283 emoji = Map.merge(object["emoji"] || %{}, emoji)
286 |> Map.put("emoji", emoji)
289 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
290 name = String.trim(tag["name"], ":")
291 emoji = %{name => tag["icon"]["url"]}
294 |> Map.put("emoji", emoji)
297 def fix_emoji(object), do: object
299 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
302 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
303 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
305 combined = tag ++ tags
308 |> Map.put("tag", combined)
311 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
312 combined = [tag, String.slice(hashtag, 1..-1)]
315 |> Map.put("tag", combined)
318 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
320 def fix_tag(object), do: object
322 # content map usually only has one language so this will do for now.
323 def fix_content_map(%{"contentMap" => content_map} = object) do
324 content_groups = Map.to_list(content_map)
325 {_, content} = Enum.at(content_groups, 0)
328 |> Map.put("content", content)
331 def fix_content_map(object), do: object
333 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
334 with true <- id =~ "follows",
335 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
336 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
343 defp mastodon_follow_hack(_, _), do: {:error, nil}
345 defp get_follow_activity(follow_object, followed) do
346 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
347 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
350 # Can't find the activity. This might a Mastodon 2.3 "Accept"
352 mastodon_follow_hack(follow_object, followed)
359 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
361 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do
362 with context <- data["context"] || Utils.generate_context_id(),
363 content <- data["content"] || "",
364 %User{} = actor <- User.get_cached_by_ap_id(actor),
366 # Reduce the object list to find the reported user.
368 Enum.reduce_while(objects, nil, fn ap_id, _ ->
369 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
376 # Remove the reported user from the object list.
377 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
385 "cc" => [account.ap_id]
389 ActivityPub.flag(params)
393 # disallow objects with bogus IDs
394 def handle_incoming(%{"id" => nil}), do: :error
395 def handle_incoming(%{"id" => ""}), do: :error
396 # length of https:// = 8, should validate better, but good enough for now.
397 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
399 # TODO: validate those with a Ecto scheme
402 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
403 when objtype in ["Article", "Note", "Video", "Page"] do
404 actor = Containment.get_actor(data)
407 Map.put(data, "actor", actor)
410 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
411 %User{} = user <- User.get_or_fetch_by_ap_id(data["actor"]) do
412 object = fix_object(data["object"])
418 context: object["conversation"],
420 published: data["published"],
429 ActivityPub.create(params)
431 %Activity{} = activity -> {:ok, activity}
437 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
439 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
440 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
441 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
442 if not User.locked?(followed) do
443 ActivityPub.accept(%{
444 to: [follower.ap_id],
450 User.follow(follower, followed)
460 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
462 with actor <- Containment.get_actor(data),
463 %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
464 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
465 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
466 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
468 ActivityPub.accept(%{
469 to: follow_activity.data["to"],
472 object: follow_activity.data["id"],
475 if not User.following?(follower, followed) do
476 {:ok, _follower} = User.follow(follower, followed)
486 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
488 with actor <- Containment.get_actor(data),
489 %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
490 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
491 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
492 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
494 ActivityPub.reject(%{
495 to: follow_activity.data["to"],
498 object: follow_activity.data["id"],
501 User.unfollow(follower, followed)
510 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
512 with actor <- Containment.get_actor(data),
513 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
514 {:ok, object} <- get_obj_helper(object_id),
515 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
523 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
525 with actor <- Containment.get_actor(data),
526 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
527 {:ok, object} <- get_obj_helper(object_id),
528 public <- Visibility.is_public?(data),
529 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
537 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
540 when object_type in ["Person", "Application", "Service", "Organization"] do
541 with %User{ap_id: ^actor_id} = actor <- User.get_by_ap_id(object["id"]) do
542 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
544 banner = new_user_data[:info]["banner"]
545 locked = new_user_data[:info]["locked"] || false
549 |> Map.take([:name, :bio, :avatar])
550 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
553 |> User.upgrade_changeset(update_data)
554 |> User.update_and_set_cache()
556 ActivityPub.update(%{
558 to: data["to"] || [],
559 cc: data["cc"] || [],
570 # TODO: We presently assume that any actor on the same origin domain as the object being
571 # deleted has the rights to delete that object. A better way to validate whether or not
572 # the object should be deleted is to refetch the object URI, which should return either
573 # an error or a tombstone. This would allow us to verify that a deletion actually took
576 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
578 object_id = Utils.get_ap_id(object_id)
580 with actor <- Containment.get_actor(data),
581 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
582 {:ok, object} <- get_obj_helper(object_id),
583 :ok <- Containment.contain_origin(actor.ap_id, object.data),
584 {:ok, activity} <- ActivityPub.delete(object, false) do
594 "object" => %{"type" => "Announce", "object" => object_id},
599 with actor <- Containment.get_actor(data),
600 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
601 {:ok, object} <- get_obj_helper(object_id),
602 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
612 "object" => %{"type" => "Follow", "object" => followed},
617 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
618 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
619 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
620 User.unfollow(follower, followed)
630 "object" => %{"type" => "Block", "object" => blocked},
635 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
636 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
637 %User{} = blocker <- User.get_or_fetch_by_ap_id(blocker),
638 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
639 User.unblock(blocker, blocked)
647 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
649 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
650 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
651 %User{} = blocker = User.get_or_fetch_by_ap_id(blocker),
652 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
653 User.unfollow(blocker, blocked)
654 User.block(blocker, blocked)
664 "object" => %{"type" => "Like", "object" => object_id},
669 with actor <- Containment.get_actor(data),
670 %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
671 {:ok, object} <- get_obj_helper(object_id),
672 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
679 def handle_incoming(_), do: :error
681 def get_obj_helper(id) do
682 if object = Object.normalize(id), do: {:ok, object}, else: nil
685 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
686 with false <- String.starts_with?(in_reply_to, "http"),
687 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
688 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
694 def set_reply_to_uri(obj), do: obj
696 # Prepares the object of an outgoing create activity.
697 def prepare_object(object) do
705 |> prepare_attachments
708 |> strip_internal_fields
709 |> strip_internal_tags
714 # internal -> Mastodon
717 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
719 Object.normalize(object_id).data
724 |> Map.put("object", object)
725 |> Map.merge(Utils.make_json_ld_header())
730 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
731 # because of course it does.
732 def prepare_outgoing(%{"type" => "Accept"} = data) do
733 with follow_activity <- Activity.normalize(data["object"]) do
735 "actor" => follow_activity.actor,
736 "object" => follow_activity.data["object"],
737 "id" => follow_activity.data["id"],
743 |> Map.put("object", object)
744 |> Map.merge(Utils.make_json_ld_header())
750 def prepare_outgoing(%{"type" => "Reject"} = data) do
751 with follow_activity <- Activity.normalize(data["object"]) do
753 "actor" => follow_activity.actor,
754 "object" => follow_activity.data["object"],
755 "id" => follow_activity.data["id"],
761 |> Map.put("object", object)
762 |> Map.merge(Utils.make_json_ld_header())
768 def prepare_outgoing(%{"type" => _type} = data) do
771 |> strip_internal_fields
772 |> maybe_fix_object_url
773 |> Map.merge(Utils.make_json_ld_header())
778 def maybe_fix_object_url(data) do
779 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
780 case get_obj_helper(data["object"]) do
781 {:ok, relative_object} ->
782 if relative_object.data["external_url"] do
785 |> Map.put("object", relative_object.data["external_url"])
791 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
799 def add_hashtags(object) do
801 (object["tag"] || [])
803 # Expand internal representation tags into AS2 tags.
804 tag when is_binary(tag) ->
806 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
811 # Do not process tags which are already AS2 tag objects.
812 tag when is_map(tag) ->
817 |> Map.put("tag", tags)
820 def add_mention_tags(object) do
823 |> Utils.get_notified_from_object()
824 |> Enum.map(fn user ->
825 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
828 tags = object["tag"] || []
831 |> Map.put("tag", tags ++ mentions)
834 # TODO: we should probably send mtime instead of unix epoch time for updated
835 def add_emoji_tags(object) do
836 tags = object["tag"] || []
837 emoji = object["emoji"] || []
841 |> Enum.map(fn {name, url} ->
843 "icon" => %{"url" => url, "type" => "Image"},
844 "name" => ":" <> name <> ":",
846 "updated" => "1970-01-01T00:00:00Z",
852 |> Map.put("tag", tags ++ out)
855 def set_conversation(object) do
856 Map.put(object, "conversation", object["context"])
859 def set_sensitive(object) do
860 tags = object["tag"] || []
861 Map.put(object, "sensitive", "nsfw" in tags)
864 def add_attributed_to(object) do
865 attributed_to = object["attributedTo"] || object["actor"]
868 |> Map.put("attributedTo", attributed_to)
871 def add_likes(%{"id" => id, "like_count" => likes} = object) do
873 "id" => "#{id}/likes",
874 "first" => "#{id}/likes?page=1",
875 "type" => "OrderedCollection",
876 "totalItems" => likes
880 |> Map.put("likes", likes)
883 def add_likes(object) do
887 def prepare_attachments(object) do
889 (object["attachment"] || [])
890 |> Enum.map(fn data ->
891 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
892 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
896 |> Map.put("attachment", attachments)
899 defp strip_internal_fields(object) do
904 "announcement_count",
907 "deleted_activity_id"
911 defp strip_internal_tags(%{"tag" => tags} = object) do
914 |> Enum.filter(fn x -> is_map(x) end)
917 |> Map.put("tag", tags)
920 defp strip_internal_tags(object), do: object
922 def perform(:user_upgrade, user) do
923 # we pass a fake user so that the followers collection is stripped away
924 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
929 where: ^old_follower_address in u.following,
934 "array_replace(?,?,?)",
936 ^old_follower_address,
937 ^user.follower_address
943 Repo.update_all(q, [])
945 maybe_retire_websub(user.ap_id)
950 where: ^old_follower_address in a.recipients,
955 "array_replace(?,?,?)",
957 ^old_follower_address,
958 ^user.follower_address
964 Repo.update_all(q, [])
967 def upgrade_user_from_ap_id(ap_id) do
968 with %User{local: false} = user <- User.get_by_ap_id(ap_id),
969 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
970 already_ap <- User.ap_enabled?(user),
971 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
973 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
978 %User{} = user -> {:ok, user}
983 def maybe_retire_websub(ap_id) do
985 if is_binary(ap_id) && String.length(ap_id) > 8 do
988 ws in Pleroma.Web.Websub.WebsubClientSubscription,
989 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
996 def maybe_fix_user_url(data) do
997 if is_map(data["url"]) do
998 Map.put(data, "url", data["url"]["href"])
1004 def maybe_fix_user_object(data) do
1006 |> maybe_fix_user_url