1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 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.
10 alias Pleroma.EarmarkRenderer
11 alias Pleroma.EctoType.ActivityPub.ObjectValidators
14 alias Pleroma.Object.Containment
17 alias Pleroma.Web.ActivityPub.ActivityPub
18 alias Pleroma.Web.ActivityPub.Builder
19 alias Pleroma.Web.ActivityPub.ObjectValidator
20 alias Pleroma.Web.ActivityPub.Pipeline
21 alias Pleroma.Web.ActivityPub.Utils
22 alias Pleroma.Web.ActivityPub.Visibility
23 alias Pleroma.Web.Federator
24 alias Pleroma.Workers.TransmogrifierWorker
29 require Pleroma.Constants
32 Modifies an incoming AP object (mastodon format) to our internal format.
34 def fix_object(object, options \\ []) do
36 |> strip_internal_fields
41 |> fix_in_reply_to(options)
51 def fix_summary(%{"summary" => nil} = object) do
52 Map.put(object, "summary", "")
55 def fix_summary(%{"summary" => _} = object) do
56 # summary is present, nothing to do
60 def fix_summary(object), do: Map.put(object, "summary", "")
62 def fix_addressing_list(map, field) do
67 Map.put(map, field, Enum.filter(addrs, &is_binary/1))
70 Map.put(map, field, [addrs])
73 Map.put(map, field, [])
77 def fix_explicit_addressing(
78 %{"to" => to, "cc" => cc} = object,
82 explicit_to = Enum.filter(to, fn x -> x in explicit_mentions end)
84 explicit_cc = Enum.filter(to, fn x -> x not in explicit_mentions end)
88 |> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end)
92 |> Map.put("to", explicit_to)
93 |> Map.put("cc", final_cc)
96 def fix_explicit_addressing(object, _explicit_mentions, _followers_collection), do: object
98 # if directMessage flag is set to true, leave the addressing alone
99 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
101 def fix_explicit_addressing(object) do
102 explicit_mentions = Utils.determine_explicit_mentions(object)
104 %User{follower_address: follower_collection} =
106 |> Containment.get_actor()
107 |> User.get_cached_by_ap_id()
112 Pleroma.Constants.as_public(),
116 fix_explicit_addressing(object, explicit_mentions, follower_collection)
119 # if as:Public is addressed, then make sure the followers collection is also addressed
120 # so that the activities will be delivered to local users.
121 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
122 recipients = to ++ cc
124 if followers_collection not in recipients do
126 Pleroma.Constants.as_public() in cc ->
127 to = to ++ [followers_collection]
128 Map.put(object, "to", to)
130 Pleroma.Constants.as_public() in to ->
131 cc = cc ++ [followers_collection]
132 Map.put(object, "cc", cc)
142 def fix_implicit_addressing(object, _), do: object
144 def fix_addressing(object) do
145 {:ok, %User{} = user} = User.get_or_fetch_by_ap_id(object["actor"])
146 followers_collection = User.ap_followers(user)
149 |> fix_addressing_list("to")
150 |> fix_addressing_list("cc")
151 |> fix_addressing_list("bto")
152 |> fix_addressing_list("bcc")
153 |> fix_explicit_addressing()
154 |> fix_implicit_addressing(followers_collection)
157 def fix_actor(%{"attributedTo" => actor} = object) do
158 actor = Containment.get_actor(%{"actor" => actor})
160 # TODO: Remove actor field for Objects
162 |> Map.put("actor", actor)
163 |> Map.put("attributedTo", actor)
166 def fix_in_reply_to(object, options \\ [])
168 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options)
169 when not is_nil(in_reply_to) do
170 in_reply_to_id = prepare_in_reply_to(in_reply_to)
171 depth = (options[:depth] || 0) + 1
173 if Federator.allowed_thread_distance?(depth) do
174 with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options),
175 %Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
177 |> Map.put("inReplyTo", replied_object.data["id"])
178 |> Map.put("context", replied_object.data["context"] || object["conversation"])
179 |> Map.drop(["conversation", "inReplyToAtomUri"])
182 Logger.warn("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}")
190 def fix_in_reply_to(object, _options), do: object
192 defp prepare_in_reply_to(in_reply_to) do
194 is_bitstring(in_reply_to) ->
197 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
200 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
201 Enum.at(in_reply_to, 0)
208 def fix_context(object) do
209 context = object["context"] || object["conversation"] || Utils.generate_context_id()
212 |> Map.put("context", context)
213 |> Map.drop(["conversation"])
216 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
218 Enum.map(attachment, fn data ->
221 is_list(data["url"]) -> List.first(data["url"])
222 is_map(data["url"]) -> data["url"]
228 is_map(url) && MIME.valid?(url["mediaType"]) -> url["mediaType"]
229 MIME.valid?(data["mediaType"]) -> data["mediaType"]
230 MIME.valid?(data["mimeType"]) -> data["mimeType"]
236 is_map(url) && is_binary(url["href"]) -> url["href"]
237 is_binary(data["url"]) -> data["url"]
238 is_binary(data["href"]) -> data["href"]
246 "type" => Map.get(url || %{}, "type", "Link")
248 |> Maps.put_if_present("mediaType", media_type)
251 "url" => [attachment_url],
252 "type" => data["type"] || "Document"
254 |> Maps.put_if_present("mediaType", media_type)
255 |> Maps.put_if_present("name", data["name"])
262 Map.put(object, "attachment", attachments)
265 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
267 |> Map.put("attachment", [attachment])
271 def fix_attachments(object), do: object
273 def fix_url(%{"url" => url} = object) when is_map(url) do
274 Map.put(object, "url", url["href"])
277 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
279 Enum.find(url, fn x ->
280 media_type = x["mediaType"] || x["mimeType"] || ""
282 is_map(x) and String.starts_with?(media_type, "video/")
286 Enum.find(url, fn x -> is_map(x) and (x["mediaType"] || x["mimeType"]) == "text/html" end)
289 |> Map.put("attachment", [attachment])
290 |> Map.put("url", link_element["href"])
293 def fix_url(%{"type" => object_type, "url" => url} = object)
294 when object_type != "Video" and is_list(url) do
295 first_element = Enum.at(url, 0)
299 is_bitstring(first_element) -> first_element
300 is_map(first_element) -> first_element["href"] || ""
304 Map.put(object, "url", url_string)
307 def fix_url(object), do: object
309 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
312 |> Enum.filter(fn data -> is_map(data) and data["type"] == "Emoji" and data["icon"] end)
313 |> Enum.reduce(%{}, fn data, mapping ->
314 name = String.trim(data["name"], ":")
316 Map.put(mapping, name, data["icon"]["url"])
319 Map.put(object, "emoji", emoji)
322 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
323 name = String.trim(tag["name"], ":")
324 emoji = %{name => tag["icon"]["url"]}
326 Map.put(object, "emoji", emoji)
329 def fix_emoji(object), do: object
331 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
334 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
335 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
337 Map.put(object, "tag", tag ++ tags)
340 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
341 combined = [tag, String.slice(hashtag, 1..-1)]
343 Map.put(object, "tag", combined)
346 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
348 def fix_tag(object), do: object
350 # content map usually only has one language so this will do for now.
351 def fix_content_map(%{"contentMap" => content_map} = object) do
352 content_groups = Map.to_list(content_map)
353 {_, content} = Enum.at(content_groups, 0)
355 Map.put(object, "content", content)
358 def fix_content_map(object), do: object
360 def fix_type(object, options \\ [])
362 def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options)
363 when is_binary(reply_id) do
364 with true <- Federator.allowed_thread_distance?(options[:depth]),
365 {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do
366 Map.put(object, "type", "Answer")
372 def fix_type(object, _), do: object
374 defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = object)
375 when is_binary(content) do
378 |> Earmark.as_html!(%Earmark.Options{renderer: EarmarkRenderer})
379 |> Pleroma.HTML.filter_tags()
381 Map.merge(object, %{"content" => html_content, "mediaType" => "text/html"})
384 defp fix_content(object), do: object
386 # Reduce the object list to find the reported user.
387 defp get_reported(objects) do
388 Enum.reduce_while(objects, nil, fn ap_id, _ ->
389 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
397 # Compatibility wrapper for Mastodon votes
398 defp handle_create(%{"object" => %{"type" => "Answer"}} = data, _user) do
399 handle_incoming(data)
402 defp handle_create(%{"object" => object} = data, user) do
407 context: object["context"],
409 published: data["published"],
417 |> ActivityPub.create()
420 def handle_incoming(data, options \\ [])
422 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
424 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data, _options) do
425 with context <- data["context"] || Utils.generate_context_id(),
426 content <- data["content"] || "",
427 %User{} = actor <- User.get_cached_by_ap_id(actor),
428 # Reduce the object list to find the reported user.
429 %User{} = account <- get_reported(objects),
430 # Remove the reported user from the object list.
431 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
438 additional: %{"cc" => [account.ap_id]}
440 |> ActivityPub.flag()
444 # disallow objects with bogus IDs
445 def handle_incoming(%{"id" => nil}, _options), do: :error
446 def handle_incoming(%{"id" => ""}, _options), do: :error
447 # length of https:// = 8, should validate better, but good enough for now.
448 def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id) < 8,
451 # TODO: validate those with a Ecto scheme
455 %{"type" => "Create", "object" => %{"type" => objtype} = object} = data,
458 when objtype in ~w{Article Note Video Page} do
459 actor = Containment.get_actor(data)
461 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
462 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(actor) do
465 |> Map.put("object", fix_object(object, options))
466 |> Map.put("actor", actor)
469 with {:ok, created_activity} <- handle_create(data, user) do
470 reply_depth = (options[:depth] || 0) + 1
472 if Federator.allowed_thread_distance?(reply_depth) do
473 for reply_id <- replies(object) do
474 Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{
476 "depth" => reply_depth
481 {:ok, created_activity}
484 %Activity{} = activity -> {:ok, activity}
490 %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data,
493 actor = Containment.get_actor(data)
496 Map.put(data, "actor", actor)
499 with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
500 reply_depth = (options[:depth] || 0) + 1
501 options = Keyword.put(options, :depth, reply_depth)
502 object = fix_object(object, options)
510 published: data["published"],
511 additional: Map.take(data, ["cc", "id"])
514 ActivityPub.listen(params)
520 @misskey_reactions %{
534 @doc "Rewrite misskey likes into EmojiReacts"
538 "_misskey_reaction" => reaction
543 |> Map.put("type", "EmojiReact")
544 |> Map.put("content", @misskey_reactions[reaction] || reaction)
545 |> handle_incoming(options)
549 %{"type" => "Create", "object" => %{"type" => objtype}} = data,
552 when objtype in ~w{Question Answer ChatMessage Audio Event} do
553 with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
554 {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
559 def handle_incoming(%{"type" => type} = data, _options)
560 when type in ~w{Like EmojiReact Announce} do
561 with :ok <- ObjectValidator.fetch_actor_and_object(data),
562 {:ok, activity, _meta} <-
563 Pipeline.common_pipeline(data, local: false) do
571 %{"type" => type} = data,
574 when type in ~w{Update Block Follow Accept Reject} do
575 with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
576 {:ok, activity, _} <-
577 Pipeline.common_pipeline(data, local: false) do
583 %{"type" => "Delete"} = data,
586 with {:ok, activity, _} <-
587 Pipeline.common_pipeline(data, local: false) do
590 {:error, {:validate_object, _}} = e ->
591 # Check if we have a create activity for this
592 with {:ok, object_id} <- ObjectValidators.ObjectID.cast(data["object"]),
593 %Activity{data: %{"actor" => actor}} <-
594 Activity.create_by_object_ap_id(object_id) |> Repo.one(),
595 # We have one, insert a tombstone and retry
596 {:ok, tombstone_data, _} <- Builder.tombstone(actor, object_id),
597 {:ok, _tombstone} <- Object.create(tombstone_data) do
598 handle_incoming(data)
608 "object" => %{"type" => "Follow", "object" => followed},
614 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
615 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
616 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
617 User.unfollow(follower, followed)
627 "object" => %{"type" => type}
631 when type in ["Like", "EmojiReact", "Announce", "Block"] do
632 with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
637 # For Undos that don't have the complete object attached, try to find it in our database.
645 when is_binary(object) do
646 with %Activity{data: data} <- Activity.get_by_ap_id(object) do
648 |> Map.put("object", data)
649 |> handle_incoming(options)
658 "actor" => origin_actor,
659 "object" => origin_actor,
660 "target" => target_actor
664 with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor),
665 {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor),
666 true <- origin_actor in target_user.also_known_as do
667 ActivityPub.move(origin_user, target_user, false)
673 def handle_incoming(_, _), do: :error
675 @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil
676 def get_obj_helper(id, options \\ []) do
677 case Object.normalize(id, true, options) do
678 %Object{} = object -> {:ok, object}
683 @spec get_embedded_obj_helper(String.t() | Object.t(), User.t()) :: {:ok, Object.t()} | nil
684 def get_embedded_obj_helper(%{"attributedTo" => attributed_to, "id" => object_id} = data, %User{
687 when attributed_to == ap_id do
688 with {:ok, activity} <-
693 "actor" => attributed_to,
696 {:ok, Object.normalize(activity)}
698 _ -> get_obj_helper(object_id)
702 def get_embedded_obj_helper(object_id, _) do
703 get_obj_helper(object_id)
706 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
707 with false <- String.starts_with?(in_reply_to, "http"),
708 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
709 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
715 def set_reply_to_uri(obj), do: obj
718 Serialized Mastodon-compatible `replies` collection containing _self-replies_.
719 Based on Mastodon's ActivityPub::NoteSerializer#replies.
721 def set_replies(obj_data) do
723 with limit when limit > 0 <-
724 Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
725 %Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do
727 |> Object.self_replies()
728 |> select([o], fragment("?->>'id'", o.data))
735 set_replies(obj_data, replies_uris)
738 defp set_replies(obj, []) do
742 defp set_replies(obj, replies_uris) do
743 replies_collection = %{
744 "type" => "Collection",
745 "items" => replies_uris
748 Map.merge(obj, %{"replies" => replies_collection})
751 def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
755 def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do
759 def replies(_), do: []
761 # Prepares the object of an outgoing create activity.
762 def prepare_object(object) do
769 |> prepare_attachments
773 |> strip_internal_fields
774 |> strip_internal_tags
780 # internal -> Mastodon
783 def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data)
784 when activity_type in ["Create", "Listen"] do
787 |> Object.normalize()
793 |> Map.put("object", object)
794 |> Map.merge(Utils.make_json_ld_header())
800 def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
803 |> Object.normalize()
806 if Visibility.is_private?(object) && object.data["actor"] == ap_id do
807 data |> Map.put("object", object |> Map.get(:data) |> prepare_object)
809 data |> maybe_fix_object_url
814 |> strip_internal_fields
815 |> Map.merge(Utils.make_json_ld_header())
821 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
822 # because of course it does.
823 def prepare_outgoing(%{"type" => "Accept"} = data) do
824 with follow_activity <- Activity.normalize(data["object"]) do
826 "actor" => follow_activity.actor,
827 "object" => follow_activity.data["object"],
828 "id" => follow_activity.data["id"],
834 |> Map.put("object", object)
835 |> Map.merge(Utils.make_json_ld_header())
841 def prepare_outgoing(%{"type" => "Reject"} = data) do
842 with follow_activity <- Activity.normalize(data["object"]) do
844 "actor" => follow_activity.actor,
845 "object" => follow_activity.data["object"],
846 "id" => follow_activity.data["id"],
852 |> Map.put("object", object)
853 |> Map.merge(Utils.make_json_ld_header())
859 def prepare_outgoing(%{"type" => _type} = data) do
862 |> strip_internal_fields
863 |> maybe_fix_object_url
864 |> Map.merge(Utils.make_json_ld_header())
869 def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do
870 with false <- String.starts_with?(object, "http"),
871 {:fetch, {:ok, relative_object}} <- {:fetch, get_obj_helper(object)},
872 %{data: %{"external_url" => external_url}} when not is_nil(external_url) <-
874 Map.put(data, "object", external_url)
877 Logger.error("Couldn't fetch #{object} #{inspect(e)}")
885 def maybe_fix_object_url(data), do: data
887 def add_hashtags(object) do
889 (object["tag"] || [])
891 # Expand internal representation tags into AS2 tags.
892 tag when is_binary(tag) ->
894 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
899 # Do not process tags which are already AS2 tag objects.
900 tag when is_map(tag) ->
904 Map.put(object, "tag", tags)
907 # TODO These should be added on our side on insertion, it doesn't make much
908 # sense to regenerate these all the time
909 def add_mention_tags(object) do
910 to = object["to"] || []
911 cc = object["cc"] || []
912 mentioned = User.get_users_from_set(to ++ cc, local_only: false)
914 mentions = Enum.map(mentioned, &build_mention_tag/1)
916 tags = object["tag"] || []
917 Map.put(object, "tag", tags ++ mentions)
920 defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do
921 %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"}
924 def take_emoji_tags(%User{emoji: emoji}) do
927 |> Enum.map(&build_emoji_tag/1)
930 # TODO: we should probably send mtime instead of unix epoch time for updated
931 def add_emoji_tags(%{"emoji" => emoji} = object) do
932 tags = object["tag"] || []
934 out = Enum.map(emoji, &build_emoji_tag/1)
936 Map.put(object, "tag", tags ++ out)
939 def add_emoji_tags(object), do: object
941 defp build_emoji_tag({name, url}) do
943 "icon" => %{"url" => url, "type" => "Image"},
944 "name" => ":" <> name <> ":",
946 "updated" => "1970-01-01T00:00:00Z",
951 def set_conversation(object) do
952 Map.put(object, "conversation", object["context"])
955 def set_sensitive(%{"sensitive" => true} = object) do
959 def set_sensitive(object) do
960 tags = object["tag"] || []
961 Map.put(object, "sensitive", "nsfw" in tags)
964 def set_type(%{"type" => "Answer"} = object) do
965 Map.put(object, "type", "Note")
968 def set_type(object), do: object
970 def add_attributed_to(object) do
971 attributed_to = object["attributedTo"] || object["actor"]
972 Map.put(object, "attributedTo", attributed_to)
976 def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object
978 def prepare_attachments(object) do
981 |> Map.get("attachment", [])
982 |> Enum.map(fn data ->
983 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
987 "mediaType" => media_type,
988 "name" => data["name"],
993 Map.put(object, "attachment", attachments)
996 def strip_internal_fields(object) do
997 Map.drop(object, Pleroma.Constants.object_internal_fields())
1000 defp strip_internal_tags(%{"tag" => tags} = object) do
1001 tags = Enum.filter(tags, fn x -> is_map(x) end)
1003 Map.put(object, "tag", tags)
1006 defp strip_internal_tags(object), do: object
1008 def perform(:user_upgrade, user) do
1009 # we pass a fake user so that the followers collection is stripped away
1010 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
1014 where: ^old_follower_address in a.recipients,
1019 "array_replace(?,?,?)",
1021 ^old_follower_address,
1022 ^user.follower_address
1027 |> Repo.update_all([])
1030 def upgrade_user_from_ap_id(ap_id) do
1031 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1032 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1033 {:ok, user} <- update_user(user, data) do
1034 TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id})
1037 %User{} = user -> {:ok, user}
1042 defp update_user(user, data) do
1044 |> User.remote_user_changeset(data)
1045 |> User.update_and_set_cache()
1048 def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do
1049 Map.put(data, "url", url["href"])
1052 def maybe_fix_user_url(data), do: data
1054 def maybe_fix_user_object(data), do: maybe_fix_user_url(data)