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
41 def fix_summary(%{"summary" => nil} = object) do
43 |> Map.put("summary", "")
46 def fix_summary(%{"summary" => _} = object) do
47 # summary is present, nothing to do
51 def fix_summary(object) do
53 |> Map.put("summary", "")
56 def fix_addressing_list(map, field) do
58 is_binary(map[field]) ->
59 Map.put(map, field, [map[field]])
62 Map.put(map, field, [])
69 def fix_explicit_addressing(
70 %{"to" => to, "cc" => cc} = object,
76 |> Enum.filter(fn x -> x in explicit_mentions end)
80 |> Enum.filter(fn x -> x not in explicit_mentions end)
84 |> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end)
88 |> Map.put("to", explicit_to)
89 |> Map.put("cc", final_cc)
92 def fix_explicit_addressing(object, _explicit_mentions, _followers_collection), do: object
94 # if directMessage flag is set to true, leave the addressing alone
95 def fix_explicit_addressing(%{"directMessage" => true} = object), do: object
97 def fix_explicit_addressing(object) do
100 |> Utils.determine_explicit_mentions()
102 follower_collection = User.get_cached_by_ap_id(Containment.get_actor(object)).follower_address
105 explicit_mentions ++ ["https://www.w3.org/ns/activitystreams#Public", follower_collection]
107 fix_explicit_addressing(object, explicit_mentions, follower_collection)
110 # if as:Public is addressed, then make sure the followers collection is also addressed
111 # so that the activities will be delivered to local users.
112 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
113 recipients = to ++ cc
115 if followers_collection not in recipients do
117 "https://www.w3.org/ns/activitystreams#Public" in cc ->
118 to = to ++ [followers_collection]
119 Map.put(object, "to", to)
121 "https://www.w3.org/ns/activitystreams#Public" in to ->
122 cc = cc ++ [followers_collection]
123 Map.put(object, "cc", cc)
133 def fix_implicit_addressing(object, _), do: object
135 def fix_addressing(object) do
136 {:ok, %User{} = user} = User.get_or_fetch_by_ap_id(object["actor"])
137 followers_collection = User.ap_followers(user)
140 |> fix_addressing_list("to")
141 |> fix_addressing_list("cc")
142 |> fix_addressing_list("bto")
143 |> fix_addressing_list("bcc")
144 |> fix_explicit_addressing()
145 |> fix_implicit_addressing(followers_collection)
148 def fix_actor(%{"attributedTo" => actor} = object) do
150 |> Map.put("actor", Containment.get_actor(%{"actor" => actor}))
153 # Check for standardisation
154 # This is what Peertube does
155 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
156 # Prismo returns only an integer (count) as "likes"
157 def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do
159 |> Map.put("likes", [])
160 |> Map.put("like_count", 0)
163 def fix_likes(object) do
167 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
168 when not is_nil(in_reply_to) do
171 is_bitstring(in_reply_to) ->
174 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
177 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
178 Enum.at(in_reply_to, 0)
180 # Maybe I should output an error too?
185 case get_obj_helper(in_reply_to_id) do
186 {:ok, replied_object} ->
187 with %Activity{} = _activity <-
188 Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
190 |> Map.put("inReplyTo", replied_object.data["id"])
191 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
192 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
193 |> Map.put("context", replied_object.data["context"] || object["conversation"])
196 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
201 Logger.error("Couldn't fetch \"#{inspect(in_reply_to_id)}\", error: #{inspect(e)}")
206 def fix_in_reply_to(object), do: object
208 def fix_context(object) do
209 context = object["context"] || object["conversation"] || Utils.generate_context_id()
212 |> Map.put("context", context)
213 |> Map.put("conversation", context)
216 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
219 |> Enum.map(fn data ->
220 media_type = data["mediaType"] || data["mimeType"]
221 href = data["url"] || data["href"]
223 url = [%{"type" => "Link", "mediaType" => media_type, "href" => href}]
226 |> Map.put("mediaType", media_type)
227 |> Map.put("url", url)
231 |> Map.put("attachment", attachments)
234 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
235 Map.put(object, "attachment", [attachment])
239 def fix_attachments(object), do: object
241 def fix_url(%{"url" => url} = object) when is_map(url) do
243 |> Map.put("url", url["href"])
246 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
247 first_element = Enum.at(url, 0)
251 |> Enum.filter(fn x -> is_map(x) end)
252 |> Enum.filter(fn x -> x["mimeType"] == "text/html" end)
256 |> Map.put("attachment", [first_element])
257 |> Map.put("url", link_element["href"])
260 def fix_url(%{"type" => object_type, "url" => url} = object)
261 when object_type != "Video" and is_list(url) do
262 first_element = Enum.at(url, 0)
266 is_bitstring(first_element) -> first_element
267 is_map(first_element) -> first_element["href"] || ""
272 |> Map.put("url", url_string)
275 def fix_url(object), do: object
277 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
278 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
282 |> Enum.reduce(%{}, fn data, mapping ->
283 name = String.trim(data["name"], ":")
285 mapping |> Map.put(name, data["icon"]["url"])
288 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
289 emoji = Map.merge(object["emoji"] || %{}, emoji)
292 |> Map.put("emoji", emoji)
295 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
296 name = String.trim(tag["name"], ":")
297 emoji = %{name => tag["icon"]["url"]}
300 |> Map.put("emoji", emoji)
303 def fix_emoji(object), do: object
305 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
308 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
309 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
311 combined = tag ++ tags
314 |> Map.put("tag", combined)
317 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
318 combined = [tag, String.slice(hashtag, 1..-1)]
321 |> Map.put("tag", combined)
324 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
326 def fix_tag(object), do: object
328 # content map usually only has one language so this will do for now.
329 def fix_content_map(%{"contentMap" => content_map} = object) do
330 content_groups = Map.to_list(content_map)
331 {_, content} = Enum.at(content_groups, 0)
334 |> Map.put("content", content)
337 def fix_content_map(object), do: object
339 def fix_type(%{"inReplyTo" => reply_id} = object) when is_binary(reply_id) do
340 reply = Object.normalize(reply_id)
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 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
379 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data) do
380 with context <- data["context"] || Utils.generate_context_id(),
381 content <- data["content"] || "",
382 %User{} = actor <- User.get_cached_by_ap_id(actor),
384 # Reduce the object list to find the reported user.
386 Enum.reduce_while(objects, nil, fn ap_id, _ ->
387 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
394 # Remove the reported user from the object list.
395 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
403 "cc" => [account.ap_id]
407 ActivityPub.flag(params)
411 # disallow objects with bogus IDs
412 def handle_incoming(%{"id" => nil}), do: :error
413 def handle_incoming(%{"id" => ""}), do: :error
414 # length of https:// = 8, should validate better, but good enough for now.
415 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
417 # TODO: validate those with a Ecto scheme
420 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
421 when objtype in ["Article", "Note", "Video", "Page", "Question", "Answer"] do
422 actor = Containment.get_actor(data)
425 Map.put(data, "actor", actor)
428 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
429 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
430 object = fix_object(data["object"])
436 context: object["conversation"],
438 published: data["published"],
447 ActivityPub.create(params)
449 %Activity{} = activity -> {:ok, activity}
455 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
457 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
458 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
459 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
460 with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]),
462 {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked},
463 {_, false} <- {:user_locked, User.locked?(followed)},
464 {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)},
466 {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")} do
467 ActivityPub.accept(%{
468 to: [follower.ap_id],
474 {:user_blocked, true} ->
475 {:ok, _} = Utils.update_follow_state_for_all(activity, "reject")
477 ActivityPub.reject(%{
478 to: [follower.ap_id],
484 {:follow, {:error, _}} ->
485 {:ok, _} = Utils.update_follow_state_for_all(activity, "reject")
487 ActivityPub.reject(%{
488 to: [follower.ap_id],
494 {:user_locked, true} ->
506 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
508 with actor <- Containment.get_actor(data),
509 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
510 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
511 {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"),
512 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
513 {:ok, _follower} = User.follow(follower, followed) do
514 ActivityPub.accept(%{
515 to: follow_activity.data["to"],
518 object: follow_activity.data["id"],
527 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
529 with actor <- Containment.get_actor(data),
530 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
531 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
532 {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"),
533 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
535 ActivityPub.reject(%{
536 to: follow_activity.data["to"],
539 object: follow_activity.data["id"],
542 User.unfollow(follower, followed)
551 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
553 with actor <- Containment.get_actor(data),
554 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
555 {:ok, object} <- get_obj_helper(object_id),
556 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
564 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
566 with actor <- Containment.get_actor(data),
567 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
568 {:ok, object} <- get_obj_helper(object_id),
569 public <- Visibility.is_public?(data),
570 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
578 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
581 when object_type in ["Person", "Application", "Service", "Organization"] do
582 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
583 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
585 banner = new_user_data[:info]["banner"]
586 locked = new_user_data[:info]["locked"] || false
590 |> Map.take([:name, :bio, :avatar])
591 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
594 |> User.upgrade_changeset(update_data)
595 |> User.update_and_set_cache()
597 ActivityPub.update(%{
599 to: data["to"] || [],
600 cc: data["cc"] || [],
611 # TODO: We presently assume that any actor on the same origin domain as the object being
612 # deleted has the rights to delete that object. A better way to validate whether or not
613 # the object should be deleted is to refetch the object URI, which should return either
614 # an error or a tombstone. This would allow us to verify that a deletion actually took
617 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
619 object_id = Utils.get_ap_id(object_id)
621 with actor <- Containment.get_actor(data),
622 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
623 {:ok, object} <- get_obj_helper(object_id),
624 :ok <- Containment.contain_origin(actor.ap_id, object.data),
625 {:ok, activity} <- ActivityPub.delete(object, false) do
635 "object" => %{"type" => "Announce", "object" => object_id},
640 with actor <- Containment.get_actor(data),
641 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
642 {:ok, object} <- get_obj_helper(object_id),
643 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
653 "object" => %{"type" => "Follow", "object" => followed},
658 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
659 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
660 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
661 User.unfollow(follower, followed)
671 "object" => %{"type" => "Block", "object" => blocked},
676 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
677 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
678 {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
679 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
680 User.unblock(blocker, blocked)
688 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
690 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
691 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
692 {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
693 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
694 User.unfollow(blocker, blocked)
695 User.block(blocker, blocked)
705 "object" => %{"type" => "Like", "object" => object_id},
710 with actor <- Containment.get_actor(data),
711 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
712 {:ok, object} <- get_obj_helper(object_id),
713 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
720 def handle_incoming(_), do: :error
722 def get_obj_helper(id) do
723 if object = Object.normalize(id), do: {:ok, object}, else: nil
726 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
727 with false <- String.starts_with?(in_reply_to, "http"),
728 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
729 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
735 def set_reply_to_uri(obj), do: obj
737 # Prepares the object of an outgoing create activity.
738 def prepare_object(object) do
746 |> prepare_attachments
749 |> strip_internal_fields
750 |> strip_internal_tags
756 # internal -> Mastodon
759 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
761 Object.normalize(object_id).data
766 |> Map.put("object", object)
767 |> Map.merge(Utils.make_json_ld_header())
772 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
773 # because of course it does.
774 def prepare_outgoing(%{"type" => "Accept"} = data) do
775 with follow_activity <- Activity.normalize(data["object"]) do
777 "actor" => follow_activity.actor,
778 "object" => follow_activity.data["object"],
779 "id" => follow_activity.data["id"],
785 |> Map.put("object", object)
786 |> Map.merge(Utils.make_json_ld_header())
792 def prepare_outgoing(%{"type" => "Reject"} = data) do
793 with follow_activity <- Activity.normalize(data["object"]) do
795 "actor" => follow_activity.actor,
796 "object" => follow_activity.data["object"],
797 "id" => follow_activity.data["id"],
803 |> Map.put("object", object)
804 |> Map.merge(Utils.make_json_ld_header())
810 def prepare_outgoing(%{"type" => _type} = data) do
813 |> strip_internal_fields
814 |> maybe_fix_object_url
815 |> Map.merge(Utils.make_json_ld_header())
820 def maybe_fix_object_url(data) do
821 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
822 case get_obj_helper(data["object"]) do
823 {:ok, relative_object} ->
824 if relative_object.data["external_url"] do
827 |> Map.put("object", relative_object.data["external_url"])
833 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
841 def add_hashtags(object) do
843 (object["tag"] || [])
845 # Expand internal representation tags into AS2 tags.
846 tag when is_binary(tag) ->
848 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
853 # Do not process tags which are already AS2 tag objects.
854 tag when is_map(tag) ->
859 |> Map.put("tag", tags)
862 def add_mention_tags(object) do
865 |> Utils.get_notified_from_object()
866 |> Enum.map(fn user ->
867 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
870 tags = object["tag"] || []
873 |> Map.put("tag", tags ++ mentions)
876 def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do
877 user_info = add_emoji_tags(user_info)
880 |> Map.put(:info, user_info)
883 # TODO: we should probably send mtime instead of unix epoch time for updated
884 def add_emoji_tags(%{"emoji" => emoji} = object) do
885 tags = object["tag"] || []
889 |> Enum.map(fn {name, url} ->
891 "icon" => %{"url" => url, "type" => "Image"},
892 "name" => ":" <> name <> ":",
894 "updated" => "1970-01-01T00:00:00Z",
900 |> Map.put("tag", tags ++ out)
903 def add_emoji_tags(object) do
907 def set_conversation(object) do
908 Map.put(object, "conversation", object["context"])
911 def set_sensitive(object) do
912 tags = object["tag"] || []
913 Map.put(object, "sensitive", "nsfw" in tags)
916 def set_type(%{"type" => "Answer"} = object) do
917 Map.put(object, "type", "Note")
920 def set_type(object), do: object
922 def add_attributed_to(object) do
923 attributed_to = object["attributedTo"] || object["actor"]
926 |> Map.put("attributedTo", attributed_to)
929 def add_likes(%{"id" => id, "like_count" => likes} = object) do
931 "id" => "#{id}/likes",
932 "first" => "#{id}/likes?page=1",
933 "type" => "OrderedCollection",
934 "totalItems" => likes
938 |> Map.put("likes", likes)
941 def add_likes(object) do
945 def prepare_attachments(object) do
947 (object["attachment"] || [])
948 |> Enum.map(fn data ->
949 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
950 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
954 |> Map.put("attachment", attachments)
957 defp strip_internal_fields(object) do
962 "announcement_count",
965 "deleted_activity_id"
969 defp strip_internal_tags(%{"tag" => tags} = object) do
972 |> Enum.filter(fn x -> is_map(x) end)
975 |> Map.put("tag", tags)
978 defp strip_internal_tags(object), do: object
980 def perform(:user_upgrade, user) do
981 # we pass a fake user so that the followers collection is stripped away
982 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
987 where: ^old_follower_address in u.following,
992 "array_replace(?,?,?)",
994 ^old_follower_address,
995 ^user.follower_address
1001 Repo.update_all(q, [])
1003 maybe_retire_websub(user.ap_id)
1008 where: ^old_follower_address in a.recipients,
1013 "array_replace(?,?,?)",
1015 ^old_follower_address,
1016 ^user.follower_address
1022 Repo.update_all(q, [])
1025 def upgrade_user_from_ap_id(ap_id) do
1026 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1027 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1028 already_ap <- User.ap_enabled?(user),
1029 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1030 unless already_ap do
1031 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1036 %User{} = user -> {:ok, user}
1041 def maybe_retire_websub(ap_id) do
1042 # some sanity checks
1043 if is_binary(ap_id) && String.length(ap_id) > 8 do
1046 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1047 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1054 def maybe_fix_user_url(data) do
1055 if is_map(data["url"]) do
1056 Map.put(data, "url", data["url"]["href"])
1062 def maybe_fix_user_object(data) do
1064 |> maybe_fix_user_url