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
17 alias Pleroma.Web.Federator
22 require Pleroma.Constants
25 Modifies an incoming AP object (mastodon format) to our internal format.
27 def fix_object(object, options \\ []) do
29 |> strip_internal_fields
34 |> fix_in_reply_to(options)
43 def fix_summary(%{"summary" => nil} = object) do
45 |> Map.put("summary", "")
48 def fix_summary(%{"summary" => _} = object) do
49 # summary is present, nothing to do
53 def fix_summary(object) do
55 |> Map.put("summary", "")
58 def fix_addressing_list(map, field) do
60 is_binary(map[field]) ->
61 Map.put(map, field, [map[field]])
64 Map.put(map, field, [])
71 def fix_explicit_addressing(
72 %{"to" => to, "cc" => cc} = object,
78 |> Enum.filter(fn x -> x in explicit_mentions end)
82 |> Enum.filter(fn x -> x not in explicit_mentions end)
86 |> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end)
90 |> Map.put("to", explicit_to)
91 |> Map.put("cc", final_cc)
94 def fix_explicit_addressing(object, _explicit_mentions, _followers_collection), do: object
96 # if directMessage flag is set to true, leave the addressing alone
97 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
99 def fix_explicit_addressing(object) do
102 |> Utils.determine_explicit_mentions()
104 follower_collection = User.get_cached_by_ap_id(Containment.get_actor(object)).follower_address
106 explicit_mentions = explicit_mentions ++ [Pleroma.Constants.as_public(), follower_collection]
108 fix_explicit_addressing(object, explicit_mentions, follower_collection)
111 # if as:Public is addressed, then make sure the followers collection is also addressed
112 # so that the activities will be delivered to local users.
113 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
114 recipients = to ++ cc
116 if followers_collection not in recipients do
118 Pleroma.Constants.as_public() in cc ->
119 to = to ++ [followers_collection]
120 Map.put(object, "to", to)
122 Pleroma.Constants.as_public() in to ->
123 cc = cc ++ [followers_collection]
124 Map.put(object, "cc", cc)
134 def fix_implicit_addressing(object, _), do: object
136 def fix_addressing(object) do
137 {:ok, %User{} = user} = User.get_or_fetch_by_ap_id(object["actor"])
138 followers_collection = User.ap_followers(user)
141 |> fix_addressing_list("to")
142 |> fix_addressing_list("cc")
143 |> fix_addressing_list("bto")
144 |> fix_addressing_list("bcc")
145 |> fix_explicit_addressing()
146 |> fix_implicit_addressing(followers_collection)
149 def fix_actor(%{"attributedTo" => actor} = object) do
151 |> Map.put("actor", Containment.get_actor(%{"actor" => actor}))
154 def fix_in_reply_to(object, options \\ [])
156 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options)
157 when not is_nil(in_reply_to) do
160 is_bitstring(in_reply_to) ->
163 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
166 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
167 Enum.at(in_reply_to, 0)
169 # Maybe I should output an error too?
174 object = Map.put(object, "inReplyToAtomUri", in_reply_to_id)
176 if Federator.allowed_incoming_reply_depth?(options[:depth]) do
177 case get_obj_helper(in_reply_to_id, options) do
178 {:ok, replied_object} ->
179 with %Activity{} = _activity <-
180 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
182 |> Map.put("inReplyTo", replied_object.data["id"])
183 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
184 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
185 |> Map.put("context", replied_object.data["context"] || object["conversation"])
188 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
193 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
201 def fix_in_reply_to(object, _options), do: object
203 def fix_context(object) do
204 context = object["context"] || object["conversation"] || Utils.generate_context_id()
207 |> Map.put("context", context)
208 |> Map.put("conversation", context)
211 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
214 |> Enum.map(fn data ->
215 media_type = data["mediaType"] || data["mimeType"]
216 href = data["url"] || data["href"]
218 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
221 |> Map.put("mediaType", media_type)
222 |> Map.put("url", url)
226 |> Map.put("attachment", attachments)
229 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
230 Map.put(object, "attachment", [attachment])
234 def fix_attachments(object), do: object
236 def fix_url(%{"url" => url} = object) when is_map(url) do
238 |> Map.put("url", url["href"])
241 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
242 first_element = Enum.at(url, 0)
246 |> Enum.filter(fn x -> is_map(x) end)
247 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
251 |> Map.put("attachment", [first_element])
252 |> Map.put("url", link_element["href"])
255 def fix_url(%{"type" => object_type, "url" => url} = object)
256 when object_type != "Video" and is_list(url) do
257 first_element = Enum.at(url, 0)
261 is_bitstring(first_element) -> first_element
262 is_map(first_element) -> first_element["href"] || ""
267 |> Map.put("url", url_string)
270 def fix_url(object), do: object
272 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
273 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
277 |> Enum.reduce(%{}, fn data, mapping ->
278 name = String.trim(data["name"], ":")
280 mapping |> Map.put(name, data["icon"]["url"])
283 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
284 emoji = Map.merge(object["emoji"] || %{}, emoji)
287 |> Map.put("emoji", emoji)
290 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
291 name = String.trim(tag["name"], ":")
292 emoji = %{name => tag["icon"]["url"]}
295 |> Map.put("emoji", emoji)
298 def fix_emoji(object), do: object
300 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
303 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
304 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
306 combined = tag ++ tags
309 |> Map.put("tag", combined)
312 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
313 combined = [tag, String.slice(hashtag, 1..-1)]
316 |> Map.put("tag", combined)
319 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
321 def fix_tag(object), do: object
323 # content map usually only has one language so this will do for now.
324 def fix_content_map(%{"contentMap" => content_map} = object) do
325 content_groups = Map.to_list(content_map)
326 {_, content} = Enum.at(content_groups, 0)
329 |> Map.put("content", content)
332 def fix_content_map(object), do: object
334 def fix_type(object, options \\ [])
336 def fix_type(%{"inReplyTo" => reply_id} = object, options) when is_binary(reply_id) do
338 if Federator.allowed_incoming_reply_depth?(options[:depth]) do
339 Object.normalize(reply_id, true)
342 if reply && (reply.data["type"] == "Question" and object["name"]) do
343 Map.put(object, "type", "Answer")
349 def fix_type(object, _), do: object
351 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
352 with true <- id =~ "follows",
353 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
354 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
361 defp mastodon_follow_hack(_, _), do: {:error, nil}
363 defp get_follow_activity(follow_object, followed) do
364 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
365 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
368 # Can't find the activity. This might a Mastodon 2.3 "Accept"
370 mastodon_follow_hack(follow_object, followed)
377 def handle_incoming(data, options \\ [])
379 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
381 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data, _options) do
382 with context <- data["context"] || Utils.generate_context_id(),
383 content <- data["content"] || "",
384 %User{} = actor <- User.get_cached_by_ap_id(actor),
386 # Reduce the object list to find the reported user.
388 Enum.reduce_while(objects, nil, fn ap_id, _ ->
389 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
396 # Remove the reported user from the object list.
397 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
405 "cc" => [account.ap_id]
409 ActivityPub.flag(params)
413 # disallow objects with bogus IDs
414 def handle_incoming(%{"id" => nil}, _options), do: :error
415 def handle_incoming(%{"id" => ""}, _options), do: :error
416 # length of https:// = 8, should validate better, but good enough for now.
417 def handle_incoming(%{"id" => id}, _options) when not (is_binary(id) and length(id) > 8),
420 # TODO: validate those with a Ecto scheme
424 %{"type" => "Create", "object" => %{"type" => objtype} = object} = data,
427 when objtype in ["Article", "Note", "Video", "Page", "Question", "Answer"] do
428 actor = Containment.get_actor(data)
431 Map.put(data, "actor", actor)
434 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
435 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
436 options = Keyword.put(options, :depth, (options[:depth] || 0) + 1)
437 object = fix_object(data["object"], options)
443 context: object["conversation"],
445 published: data["published"],
454 ActivityPub.create(params)
456 %Activity{} = activity -> {:ok, activity}
462 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data,
465 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
466 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
467 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
468 with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]),
469 {_, false} <- {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked},
470 {_, false} <- {:user_locked, User.locked?(followed)},
471 {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)},
473 {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")} do
474 ActivityPub.accept(%{
475 to: [follower.ap_id],
481 {:user_blocked, true} ->
482 {:ok, _} = Utils.update_follow_state_for_all(activity, "reject")
484 ActivityPub.reject(%{
485 to: [follower.ap_id],
491 {:follow, {:error, _}} ->
492 {:ok, _} = Utils.update_follow_state_for_all(activity, "reject")
494 ActivityPub.reject(%{
495 to: [follower.ap_id],
501 {:user_locked, true} ->
513 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data,
516 with actor <- Containment.get_actor(data),
517 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
518 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
519 {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"),
520 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
521 {:ok, _follower} = User.follow(follower, followed) do
522 ActivityPub.accept(%{
523 to: follow_activity.data["to"],
526 object: follow_activity.data["id"],
535 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data,
538 with actor <- Containment.get_actor(data),
539 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
540 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
541 {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"),
542 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
544 ActivityPub.reject(%{
545 to: follow_activity.data["to"],
548 object: follow_activity.data["id"],
551 User.unfollow(follower, followed)
560 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data,
563 with actor <- Containment.get_actor(data),
564 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
565 {:ok, object} <- get_obj_helper(object_id),
566 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
574 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data,
577 with actor <- Containment.get_actor(data),
578 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
579 {:ok, object} <- get_obj_helper(object_id),
580 public <- Visibility.is_public?(data),
581 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
589 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
593 when object_type in ["Person", "Application", "Service", "Organization"] do
594 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
595 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
597 banner = new_user_data[:info][:banner]
598 locked = new_user_data[:info][:locked] || false
602 |> Map.take([:name, :bio, :avatar])
603 |> Map.put(:info, %{banner: banner, locked: locked})
606 |> User.upgrade_changeset(update_data)
607 |> User.update_and_set_cache()
609 ActivityPub.update(%{
611 to: data["to"] || [],
612 cc: data["cc"] || [],
623 # TODO: We presently assume that any actor on the same origin domain as the object being
624 # deleted has the rights to delete that object. A better way to validate whether or not
625 # the object should be deleted is to refetch the object URI, which should return either
626 # an error or a tombstone. This would allow us to verify that a deletion actually took
629 %{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => _id} = data,
632 object_id = Utils.get_ap_id(object_id)
634 with actor <- Containment.get_actor(data),
635 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
636 {:ok, object} <- get_obj_helper(object_id),
637 :ok <- Containment.contain_origin(actor.ap_id, object.data),
638 {:ok, activity} <- ActivityPub.delete(object, false) do
642 case User.get_cached_by_ap_id(object_id) do
643 %User{ap_id: ^actor} = user ->
658 "object" => %{"type" => "Announce", "object" => object_id},
664 with actor <- Containment.get_actor(data),
665 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
666 {:ok, object} <- get_obj_helper(object_id),
667 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
677 "object" => %{"type" => "Follow", "object" => followed},
683 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
684 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
685 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
686 User.unfollow(follower, followed)
696 "object" => %{"type" => "Block", "object" => blocked},
702 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
703 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
704 {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
705 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
706 User.unblock(blocker, blocked)
714 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data,
717 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
718 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
719 {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
720 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
721 User.unfollow(blocker, blocked)
722 User.block(blocker, blocked)
732 "object" => %{"type" => "Like", "object" => object_id},
738 with actor <- Containment.get_actor(data),
739 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
740 {:ok, object} <- get_obj_helper(object_id),
741 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
748 def handle_incoming(_, _), do: :error
750 def get_obj_helper(id, options \\ []) do
751 if object = Object.normalize(id, true, options), do: {:ok, object}, else: nil
754 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
755 with false <- String.starts_with?(in_reply_to, "http"),
756 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
757 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
763 def set_reply_to_uri(obj), do: obj
765 # Prepares the object of an outgoing create activity.
766 def prepare_object(object) do
773 |> prepare_attachments
776 |> strip_internal_fields
777 |> strip_internal_tags
783 # internal -> Mastodon
786 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
789 |> Object.normalize()
795 |> Map.put("object", object)
796 |> Map.merge(Utils.make_json_ld_header())
802 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
803 # because of course it does.
804 def prepare_outgoing(%{"type" => "Accept"} = data) do
805 with follow_activity <- Activity.normalize(data["object"]) do
807 "actor" => follow_activity.actor,
808 "object" => follow_activity.data["object"],
809 "id" => follow_activity.data["id"],
815 |> Map.put("object", object)
816 |> Map.merge(Utils.make_json_ld_header())
822 def prepare_outgoing(%{"type" => "Reject"} = data) do
823 with follow_activity <- Activity.normalize(data["object"]) do
825 "actor" => follow_activity.actor,
826 "object" => follow_activity.data["object"],
827 "id" => follow_activity.data["id"],
833 |> Map.put("object", object)
834 |> Map.merge(Utils.make_json_ld_header())
840 def prepare_outgoing(%{"type" => _type} = data) do
843 |> strip_internal_fields
844 |> maybe_fix_object_url
845 |> Map.merge(Utils.make_json_ld_header())
850 def maybe_fix_object_url(data) do
851 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
852 case get_obj_helper(data["object"]) do
853 {:ok, relative_object} ->
854 if relative_object.data["external_url"] do
857 |> Map.put("object", relative_object.data["external_url"])
863 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
871 def add_hashtags(object) do
873 (object["tag"] || [])
875 # Expand internal representation tags into AS2 tags.
876 tag when is_binary(tag) ->
878 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
883 # Do not process tags which are already AS2 tag objects.
884 tag when is_map(tag) ->
889 |> Map.put("tag", tags)
892 def add_mention_tags(object) do
895 |> Utils.get_notified_from_object()
896 |> Enum.map(fn user ->
897 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
900 tags = object["tag"] || []
903 |> Map.put("tag", tags ++ mentions)
906 def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do
907 user_info = add_emoji_tags(user_info)
910 |> Map.put(:info, user_info)
913 # TODO: we should probably send mtime instead of unix epoch time for updated
914 def add_emoji_tags(%{"emoji" => emoji} = object) do
915 tags = object["tag"] || []
919 |> Enum.map(fn {name, url} ->
921 "icon" => %{"url" => url, "type" => "Image"},
922 "name" => ":" <> name <> ":",
924 "updated" => "1970-01-01T00:00:00Z",
930 |> Map.put("tag", tags ++ out)
933 def add_emoji_tags(object) do
937 def set_conversation(object) do
938 Map.put(object, "conversation", object["context"])
941 def set_sensitive(object) do
942 tags = object["tag"] || []
943 Map.put(object, "sensitive", "nsfw" in tags)
946 def set_type(%{"type" => "Answer"} = object) do
947 Map.put(object, "type", "Note")
950 def set_type(object), do: object
952 def add_attributed_to(object) do
953 attributed_to = object["attributedTo"] || object["actor"]
956 |> Map.put("attributedTo", attributed_to)
959 def prepare_attachments(object) do
961 (object["attachment"] || [])
962 |> Enum.map(fn data ->
963 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
964 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
968 |> Map.put("attachment", attachments)
971 defp strip_internal_fields(object) do
977 "announcement_count",
980 "deleted_activity_id"
984 defp strip_internal_tags(%{"tag" => tags} = object) do
987 |> Enum.filter(fn x -> is_map(x) end)
990 |> Map.put("tag", tags)
993 defp strip_internal_tags(object), do: object
995 def perform(:user_upgrade, user) do
996 # we pass a fake user so that the followers collection is stripped away
997 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
1002 where: ^old_follower_address in u.following,
1007 "array_replace(?,?,?)",
1009 ^old_follower_address,
1010 ^user.follower_address
1016 Repo.update_all(q, [])
1018 maybe_retire_websub(user.ap_id)
1023 where: ^old_follower_address in a.recipients,
1028 "array_replace(?,?,?)",
1030 ^old_follower_address,
1031 ^user.follower_address
1037 Repo.update_all(q, [])
1040 def upgrade_user_from_ap_id(ap_id) do
1041 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1042 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1043 already_ap <- User.ap_enabled?(user),
1044 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1045 unless already_ap do
1046 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1051 %User{} = user -> {:ok, user}
1056 def maybe_retire_websub(ap_id) do
1057 # some sanity checks
1058 if is_binary(ap_id) && String.length(ap_id) > 8 do
1061 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1062 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1069 def maybe_fix_user_url(data) do
1070 if is_map(data["url"]) do
1071 Map.put(data, "url", data["url"]["href"])
1077 def maybe_fix_user_object(data) do
1079 |> maybe_fix_user_url