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 object = Map.put(object, "inReplyToAtomUri", in_reply_to_id)
172 depth = (options[:depth] || 0) + 1
174 if Federator.allowed_thread_distance?(depth) do
175 with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options),
176 %Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
178 |> Map.put("inReplyTo", replied_object.data["id"])
179 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
180 |> Map.put("context", replied_object.data["context"] || object["conversation"])
181 |> Map.drop(["conversation"])
184 Logger.warn("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}")
192 def fix_in_reply_to(object, _options), do: object
194 defp prepare_in_reply_to(in_reply_to) do
196 is_bitstring(in_reply_to) ->
199 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
202 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
203 Enum.at(in_reply_to, 0)
210 def fix_context(object) do
211 context = object["context"] || object["conversation"] || Utils.generate_context_id()
214 |> Map.put("context", context)
215 |> Map.drop(["conversation"])
218 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
220 Enum.map(attachment, fn data ->
223 is_list(data["url"]) -> List.first(data["url"])
224 is_map(data["url"]) -> data["url"]
230 is_map(url) && MIME.valid?(url["mediaType"]) -> url["mediaType"]
231 MIME.valid?(data["mediaType"]) -> data["mediaType"]
232 MIME.valid?(data["mimeType"]) -> data["mimeType"]
238 is_map(url) && is_binary(url["href"]) -> url["href"]
239 is_binary(data["url"]) -> data["url"]
240 is_binary(data["href"]) -> data["href"]
248 "type" => Map.get(url || %{}, "type", "Link")
250 |> Maps.put_if_present("mediaType", media_type)
253 "url" => [attachment_url],
254 "type" => data["type"] || "Document"
256 |> Maps.put_if_present("mediaType", media_type)
257 |> Maps.put_if_present("name", data["name"])
264 Map.put(object, "attachment", attachments)
267 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
269 |> Map.put("attachment", [attachment])
273 def fix_attachments(object), do: object
275 def fix_url(%{"url" => url} = object) when is_map(url) do
276 Map.put(object, "url", url["href"])
279 def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do
281 Enum.find(url, fn x ->
282 media_type = x["mediaType"] || x["mimeType"] || ""
284 is_map(x) and String.starts_with?(media_type, "video/")
288 Enum.find(url, fn x -> is_map(x) and (x["mediaType"] || x["mimeType"]) == "text/html" end)
291 |> Map.put("attachment", [attachment])
292 |> Map.put("url", link_element["href"])
295 def fix_url(%{"type" => object_type, "url" => url} = object)
296 when object_type != "Video" and is_list(url) do
297 first_element = Enum.at(url, 0)
301 is_bitstring(first_element) -> first_element
302 is_map(first_element) -> first_element["href"] || ""
306 Map.put(object, "url", url_string)
309 def fix_url(object), do: object
311 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
314 |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
315 |> Enum.reduce(%{}, fn data, mapping ->
316 name = String.trim(data["name"], ":")
318 Map.put(mapping, name, data["icon"]["url"])
321 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
322 emoji = Map.merge(object["emoji"] || %{}, emoji)
324 Map.put(object, "emoji", emoji)
327 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
328 name = String.trim(tag["name"], ":")
329 emoji = %{name => tag["icon"]["url"]}
331 Map.put(object, "emoji", emoji)
334 def fix_emoji(object), do: object
336 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
339 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
340 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
342 Map.put(object, "tag", tag ++ tags)
345 def fix_tag(%{"tag" => %{"type" => "Hashtag", "name" => hashtag} = tag} = object) do
346 combined = [tag, String.slice(hashtag, 1..-1)]
348 Map.put(object, "tag", combined)
351 def fix_tag(%{"tag" => %{} = tag} = object), do: Map.put(object, "tag", [tag])
353 def fix_tag(object), do: object
355 # content map usually only has one language so this will do for now.
356 def fix_content_map(%{"contentMap" => content_map} = object) do
357 content_groups = Map.to_list(content_map)
358 {_, content} = Enum.at(content_groups, 0)
360 Map.put(object, "content", content)
363 def fix_content_map(object), do: object
365 def fix_type(object, options \\ [])
367 def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options)
368 when is_binary(reply_id) do
369 with true <- Federator.allowed_thread_distance?(options[:depth]),
370 {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do
371 Map.put(object, "type", "Answer")
377 def fix_type(object, _), do: object
379 defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = object)
380 when is_binary(content) do
383 |> Earmark.as_html!(%Earmark.Options{renderer: EarmarkRenderer})
384 |> Pleroma.HTML.filter_tags()
386 Map.merge(object, %{"content" => html_content, "mediaType" => "text/html"})
389 defp fix_content(object), do: object
391 # Reduce the object list to find the reported user.
392 defp get_reported(objects) do
393 Enum.reduce_while(objects, nil, fn ap_id, _ ->
394 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
402 # Compatibility wrapper for Mastodon votes
403 defp handle_create(%{"object" => %{"type" => "Answer"}} = data, _user) do
404 handle_incoming(data)
407 defp handle_create(%{"object" => object} = data, user) do
412 context: object["context"],
414 published: data["published"],
422 |> ActivityPub.create()
425 def handle_incoming(data, options \\ [])
427 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
429 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data, _options) do
430 with context <- data["context"] || Utils.generate_context_id(),
431 content <- data["content"] || "",
432 %User{} = actor <- User.get_cached_by_ap_id(actor),
433 # Reduce the object list to find the reported user.
434 %User{} = account <- get_reported(objects),
435 # Remove the reported user from the object list.
436 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
443 additional: %{"cc" => [account.ap_id]}
445 |> ActivityPub.flag()
449 # disallow objects with bogus IDs
450 def handle_incoming(%{"id" => nil}, _options), do: :error
451 def handle_incoming(%{"id" => ""}, _options), do: :error
452 # length of https:// = 8, should validate better, but good enough for now.
453 def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id) < 8,
456 # TODO: validate those with a Ecto scheme
460 %{"type" => "Create", "object" => %{"type" => objtype} = object} = data,
463 when objtype in ~w{Article Note Video Page} do
464 actor = Containment.get_actor(data)
466 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
467 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(actor) do
470 |> Map.put("object", fix_object(object, options))
471 |> Map.put("actor", actor)
474 with {:ok, created_activity} <- handle_create(data, user) do
475 reply_depth = (options[:depth] || 0) + 1
477 if Federator.allowed_thread_distance?(reply_depth) do
478 for reply_id <- replies(object) do
479 Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{
481 "depth" => reply_depth
486 {:ok, created_activity}
489 %Activity{} = activity -> {:ok, activity}
495 %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data,
498 actor = Containment.get_actor(data)
501 Map.put(data, "actor", actor)
504 with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
505 reply_depth = (options[:depth] || 0) + 1
506 options = Keyword.put(options, :depth, reply_depth)
507 object = fix_object(object, options)
515 published: data["published"],
516 additional: Map.take(data, ["cc", "id"])
519 ActivityPub.listen(params)
525 @misskey_reactions %{
539 @doc "Rewrite misskey likes into EmojiReacts"
543 "_misskey_reaction" => reaction
548 |> Map.put("type", "EmojiReact")
549 |> Map.put("content", @misskey_reactions[reaction] || reaction)
550 |> handle_incoming(options)
554 %{"type" => "Create", "object" => %{"type" => objtype}} = data,
557 when objtype in ~w{Question Answer ChatMessage Audio Event} do
558 with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
559 {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
564 def handle_incoming(%{"type" => type} = data, _options)
565 when type in ~w{Like EmojiReact Announce} do
566 with :ok <- ObjectValidator.fetch_actor_and_object(data),
567 {:ok, activity, _meta} <-
568 Pipeline.common_pipeline(data, local: false) do
576 %{"type" => type} = data,
579 when type in ~w{Update Block Follow Accept Reject} do
580 with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
581 {:ok, activity, _} <-
582 Pipeline.common_pipeline(data, local: false) do
588 %{"type" => "Delete"} = data,
591 with {:ok, activity, _} <-
592 Pipeline.common_pipeline(data, local: false) do
595 {:error, {:validate_object, _}} = e ->
596 # Check if we have a create activity for this
597 with {:ok, object_id} <- ObjectValidators.ObjectID.cast(data["object"]),
598 %Activity{data: %{"actor" => actor}} <-
599 Activity.create_by_object_ap_id(object_id) |> Repo.one(),
600 # We have one, insert a tombstone and retry
601 {:ok, tombstone_data, _} <- Builder.tombstone(actor, object_id),
602 {:ok, _tombstone} <- Object.create(tombstone_data) do
603 handle_incoming(data)
613 "object" => %{"type" => "Follow", "object" => followed},
619 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
620 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
621 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
622 User.unfollow(follower, followed)
632 "object" => %{"type" => type}
636 when type in ["Like", "EmojiReact", "Announce", "Block"] do
637 with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
642 # For Undos that don't have the complete object attached, try to find it in our database.
650 when is_binary(object) do
651 with %Activity{data: data} <- Activity.get_by_ap_id(object) do
653 |> Map.put("object", data)
654 |> handle_incoming(options)
663 "actor" => origin_actor,
664 "object" => origin_actor,
665 "target" => target_actor
669 with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor),
670 {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor),
671 true <- origin_actor in target_user.also_known_as do
672 ActivityPub.move(origin_user, target_user, false)
678 def handle_incoming(_, _), do: :error
680 @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil
681 def get_obj_helper(id, options \\ []) do
682 case Object.normalize(id, true, options) do
683 %Object{} = object -> {:ok, object}
688 @spec get_embedded_obj_helper(String.t() | Object.t(), User.t()) :: {:ok, Object.t()} | nil
689 def get_embedded_obj_helper(%{"attributedTo" => attributed_to, "id" => object_id} = data, %User{
692 when attributed_to == ap_id do
693 with {:ok, activity} <-
698 "actor" => attributed_to,
701 {:ok, Object.normalize(activity)}
703 _ -> get_obj_helper(object_id)
707 def get_embedded_obj_helper(object_id, _) do
708 get_obj_helper(object_id)
711 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
712 with false <- String.starts_with?(in_reply_to, "http"),
713 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
714 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
720 def set_reply_to_uri(obj), do: obj
723 Serialized Mastodon-compatible `replies` collection containing _self-replies_.
724 Based on Mastodon's ActivityPub::NoteSerializer#replies.
726 def set_replies(obj_data) do
728 with limit when limit > 0 <-
729 Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
730 %Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do
732 |> Object.self_replies()
733 |> select([o], fragment("?->>'id'", o.data))
740 set_replies(obj_data, replies_uris)
743 defp set_replies(obj, []) do
747 defp set_replies(obj, replies_uris) do
748 replies_collection = %{
749 "type" => "Collection",
750 "items" => replies_uris
753 Map.merge(obj, %{"replies" => replies_collection})
756 def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
760 def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do
764 def replies(_), do: []
766 # Prepares the object of an outgoing create activity.
767 def prepare_object(object) do
774 |> prepare_attachments
778 |> strip_internal_fields
779 |> strip_internal_tags
785 # internal -> Mastodon
788 def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data)
789 when activity_type in ["Create", "Listen"] do
792 |> Object.normalize()
798 |> Map.put("object", object)
799 |> Map.merge(Utils.make_json_ld_header())
805 def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
808 |> Object.normalize()
811 if Visibility.is_private?(object) && object.data["actor"] == ap_id do
812 data |> Map.put("object", object |> Map.get(:data) |> prepare_object)
814 data |> maybe_fix_object_url
819 |> strip_internal_fields
820 |> Map.merge(Utils.make_json_ld_header())
826 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
827 # because of course it does.
828 def prepare_outgoing(%{"type" => "Accept"} = data) do
829 with follow_activity <- Activity.normalize(data["object"]) do
831 "actor" => follow_activity.actor,
832 "object" => follow_activity.data["object"],
833 "id" => follow_activity.data["id"],
839 |> Map.put("object", object)
840 |> Map.merge(Utils.make_json_ld_header())
846 def prepare_outgoing(%{"type" => "Reject"} = data) do
847 with follow_activity <- Activity.normalize(data["object"]) do
849 "actor" => follow_activity.actor,
850 "object" => follow_activity.data["object"],
851 "id" => follow_activity.data["id"],
857 |> Map.put("object", object)
858 |> Map.merge(Utils.make_json_ld_header())
864 def prepare_outgoing(%{"type" => _type} = data) do
867 |> strip_internal_fields
868 |> maybe_fix_object_url
869 |> Map.merge(Utils.make_json_ld_header())
874 def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do
875 with false <- String.starts_with?(object, "http"),
876 {:fetch, {:ok, relative_object}} <- {:fetch, get_obj_helper(object)},
877 %{data: %{"external_url" => external_url}} when not is_nil(external_url) <-
879 Map.put(data, "object", external_url)
882 Logger.error("Couldn't fetch #{object} #{inspect(e)}")
890 def maybe_fix_object_url(data), do: data
892 def add_hashtags(object) do
894 (object["tag"] || [])
896 # Expand internal representation tags into AS2 tags.
897 tag when is_binary(tag) ->
899 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
904 # Do not process tags which are already AS2 tag objects.
905 tag when is_map(tag) ->
909 Map.put(object, "tag", tags)
912 # TODO These should be added on our side on insertion, it doesn't make much
913 # sense to regenerate these all the time
914 def add_mention_tags(object) do
915 to = object["to"] || []
916 cc = object["cc"] || []
917 mentioned = User.get_users_from_set(to ++ cc, local_only: false)
919 mentions = Enum.map(mentioned, &build_mention_tag/1)
921 tags = object["tag"] || []
922 Map.put(object, "tag", tags ++ mentions)
925 defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do
926 %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"}
929 def take_emoji_tags(%User{emoji: emoji}) do
932 |> Enum.map(&build_emoji_tag/1)
935 # TODO: we should probably send mtime instead of unix epoch time for updated
936 def add_emoji_tags(%{"emoji" => emoji} = object) do
937 tags = object["tag"] || []
939 out = Enum.map(emoji, &build_emoji_tag/1)
941 Map.put(object, "tag", tags ++ out)
944 def add_emoji_tags(object), do: object
946 defp build_emoji_tag({name, url}) do
948 "icon" => %{"url" => url, "type" => "Image"},
949 "name" => ":" <> name <> ":",
951 "updated" => "1970-01-01T00:00:00Z",
956 def set_conversation(object) do
957 Map.put(object, "conversation", object["context"])
960 def set_sensitive(%{"sensitive" => true} = object) do
964 def set_sensitive(object) do
965 tags = object["tag"] || []
966 Map.put(object, "sensitive", "nsfw" in tags)
969 def set_type(%{"type" => "Answer"} = object) do
970 Map.put(object, "type", "Note")
973 def set_type(object), do: object
975 def add_attributed_to(object) do
976 attributed_to = object["attributedTo"] || object["actor"]
977 Map.put(object, "attributedTo", attributed_to)
981 def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object
983 def prepare_attachments(object) do
986 |> Map.get("attachment", [])
987 |> Enum.map(fn data ->
988 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
992 "mediaType" => media_type,
993 "name" => data["name"],
998 Map.put(object, "attachment", attachments)
1001 def strip_internal_fields(object) do
1002 Map.drop(object, Pleroma.Constants.object_internal_fields())
1005 defp strip_internal_tags(%{"tag" => tags} = object) do
1006 tags = Enum.filter(tags, fn x -> is_map(x) end)
1008 Map.put(object, "tag", tags)
1011 defp strip_internal_tags(object), do: object
1013 def perform(:user_upgrade, user) do
1014 # we pass a fake user so that the followers collection is stripped away
1015 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
1019 where: ^old_follower_address in a.recipients,
1024 "array_replace(?,?,?)",
1026 ^old_follower_address,
1027 ^user.follower_address
1032 |> Repo.update_all([])
1035 def upgrade_user_from_ap_id(ap_id) do
1036 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1037 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1038 {:ok, user} <- update_user(user, data) do
1039 TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id})
1042 %User{} = user -> {:ok, user}
1047 defp update_user(user, data) do
1049 |> User.remote_user_changeset(data)
1050 |> User.update_and_set_cache()
1053 def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do
1054 Map.put(data, "url", url["href"])
1057 def maybe_fix_user_url(data), do: data
1059 def maybe_fix_user_object(data), do: maybe_fix_user_url(data)