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.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)},
465 {_, {:ok, _}} <- {:follow_state_update, Utils.update_follow_state(activity, "accept")} do
466 ActivityPub.accept(%{
467 to: [follower.ap_id],
473 {:user_blocked, true} ->
474 {:ok, _} = Utils.update_follow_state(activity, "reject")
476 ActivityPub.reject(%{
477 to: [follower.ap_id],
483 {:follow, {:error, _}} ->
484 {:ok, _} = Utils.update_follow_state(activity, "reject")
486 ActivityPub.reject(%{
487 to: [follower.ap_id],
493 {:user_locked, true} ->
505 %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => _id} = data
507 with actor <- Containment.get_actor(data),
508 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
509 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
510 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "accept"),
511 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
512 {:ok, _follower} = User.follow(follower, followed) do
513 ActivityPub.accept(%{
514 to: follow_activity.data["to"],
517 object: follow_activity.data["id"],
526 %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => _id} = data
528 with actor <- Containment.get_actor(data),
529 {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor),
530 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
531 {:ok, follow_activity} <- Utils.update_follow_state(follow_activity, "reject"),
532 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
534 ActivityPub.reject(%{
535 to: follow_activity.data["to"],
538 object: follow_activity.data["id"],
541 User.unfollow(follower, followed)
550 %{"type" => "Like", "object" => object_id, "actor" => _actor, "id" => id} = data
552 with actor <- Containment.get_actor(data),
553 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
554 {:ok, object} <- get_obj_helper(object_id),
555 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
563 %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data
565 with actor <- Containment.get_actor(data),
566 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
567 {:ok, object} <- get_obj_helper(object_id),
568 public <- Visibility.is_public?(data),
569 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
577 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
580 when object_type in ["Person", "Application", "Service", "Organization"] do
581 with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do
582 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
584 banner = new_user_data[:info]["banner"]
585 locked = new_user_data[:info]["locked"] || false
589 |> Map.take([:name, :bio, :avatar])
590 |> Map.put(:info, %{"banner" => banner, "locked" => locked})
593 |> User.upgrade_changeset(update_data)
594 |> User.update_and_set_cache()
596 ActivityPub.update(%{
598 to: data["to"] || [],
599 cc: data["cc"] || [],
610 # TODO: We presently assume that any actor on the same origin domain as the object being
611 # deleted has the rights to delete that object. A better way to validate whether or not
612 # the object should be deleted is to refetch the object URI, which should return either
613 # an error or a tombstone. This would allow us to verify that a deletion actually took
616 %{"type" => "Delete", "object" => object_id, "actor" => _actor, "id" => _id} = data
618 object_id = Utils.get_ap_id(object_id)
620 with actor <- Containment.get_actor(data),
621 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
622 {:ok, object} <- get_obj_helper(object_id),
623 :ok <- Containment.contain_origin(actor.ap_id, object.data),
624 {:ok, activity} <- ActivityPub.delete(object, false) do
634 "object" => %{"type" => "Announce", "object" => object_id},
639 with actor <- Containment.get_actor(data),
640 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
641 {:ok, object} <- get_obj_helper(object_id),
642 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
652 "object" => %{"type" => "Follow", "object" => followed},
657 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
658 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
659 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
660 User.unfollow(follower, followed)
670 "object" => %{"type" => "Block", "object" => blocked},
675 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
676 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
677 {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
678 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
679 User.unblock(blocker, blocked)
687 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data
689 with true <- Pleroma.Config.get([:activitypub, :accept_blocks]),
690 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
691 {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
692 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
693 User.unfollow(blocker, blocked)
694 User.block(blocker, blocked)
704 "object" => %{"type" => "Like", "object" => object_id},
709 with actor <- Containment.get_actor(data),
710 {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
711 {:ok, object} <- get_obj_helper(object_id),
712 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
719 def handle_incoming(_), do: :error
721 def get_obj_helper(id) do
722 if object = Object.normalize(id), do: {:ok, object}, else: nil
725 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
726 with false <- String.starts_with?(in_reply_to, "http"),
727 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
728 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
734 def set_reply_to_uri(obj), do: obj
736 # Prepares the object of an outgoing create activity.
737 def prepare_object(object) do
745 |> prepare_attachments
748 |> strip_internal_fields
749 |> strip_internal_tags
755 # internal -> Mastodon
758 def prepare_outgoing(%{"type" => "Create", "object" => object_id} = data) do
760 Object.normalize(object_id).data
765 |> Map.put("object", object)
766 |> Map.merge(Utils.make_json_ld_header())
771 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
772 # because of course it does.
773 def prepare_outgoing(%{"type" => "Accept"} = data) do
774 with follow_activity <- Activity.normalize(data["object"]) do
776 "actor" => follow_activity.actor,
777 "object" => follow_activity.data["object"],
778 "id" => follow_activity.data["id"],
784 |> Map.put("object", object)
785 |> Map.merge(Utils.make_json_ld_header())
791 def prepare_outgoing(%{"type" => "Reject"} = data) do
792 with follow_activity <- Activity.normalize(data["object"]) do
794 "actor" => follow_activity.actor,
795 "object" => follow_activity.data["object"],
796 "id" => follow_activity.data["id"],
802 |> Map.put("object", object)
803 |> Map.merge(Utils.make_json_ld_header())
809 def prepare_outgoing(%{"type" => _type} = data) do
812 |> strip_internal_fields
813 |> maybe_fix_object_url
814 |> Map.merge(Utils.make_json_ld_header())
819 def maybe_fix_object_url(data) do
820 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
821 case get_obj_helper(data["object"]) do
822 {:ok, relative_object} ->
823 if relative_object.data["external_url"] do
826 |> Map.put("object", relative_object.data["external_url"])
832 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
840 def add_hashtags(object) do
842 (object["tag"] || [])
844 # Expand internal representation tags into AS2 tags.
845 tag when is_binary(tag) ->
847 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
852 # Do not process tags which are already AS2 tag objects.
853 tag when is_map(tag) ->
858 |> Map.put("tag", tags)
861 def add_mention_tags(object) do
864 |> Utils.get_notified_from_object()
865 |> Enum.map(fn user ->
866 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
869 tags = object["tag"] || []
872 |> Map.put("tag", tags ++ mentions)
875 def add_emoji_tags(%User{info: %{"emoji" => _emoji} = user_info} = object) do
876 user_info = add_emoji_tags(user_info)
879 |> Map.put(:info, user_info)
882 # TODO: we should probably send mtime instead of unix epoch time for updated
883 def add_emoji_tags(%{"emoji" => emoji} = object) do
884 tags = object["tag"] || []
888 |> Enum.map(fn {name, url} ->
890 "icon" => %{"url" => url, "type" => "Image"},
891 "name" => ":" <> name <> ":",
893 "updated" => "1970-01-01T00:00:00Z",
899 |> Map.put("tag", tags ++ out)
902 def add_emoji_tags(object) do
906 def set_conversation(object) do
907 Map.put(object, "conversation", object["context"])
910 def set_sensitive(object) do
911 tags = object["tag"] || []
912 Map.put(object, "sensitive", "nsfw" in tags)
915 def set_type(%{"type" => "Answer"} = object) do
916 Map.put(object, "type", "Note")
919 def set_type(object), do: object
921 def add_attributed_to(object) do
922 attributed_to = object["attributedTo"] || object["actor"]
925 |> Map.put("attributedTo", attributed_to)
928 def add_likes(%{"id" => id, "like_count" => likes} = object) do
930 "id" => "#{id}/likes",
931 "first" => "#{id}/likes?page=1",
932 "type" => "OrderedCollection",
933 "totalItems" => likes
937 |> Map.put("likes", likes)
940 def add_likes(object) do
944 def prepare_attachments(object) do
946 (object["attachment"] || [])
947 |> Enum.map(fn data ->
948 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
949 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
953 |> Map.put("attachment", attachments)
956 defp strip_internal_fields(object) do
961 "announcement_count",
964 "deleted_activity_id"
968 defp strip_internal_tags(%{"tag" => tags} = object) do
971 |> Enum.filter(fn x -> is_map(x) end)
974 |> Map.put("tag", tags)
977 defp strip_internal_tags(object), do: object
979 def perform(:user_upgrade, user) do
980 # we pass a fake user so that the followers collection is stripped away
981 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
986 where: ^old_follower_address in u.following,
991 "array_replace(?,?,?)",
993 ^old_follower_address,
994 ^user.follower_address
1000 Repo.update_all(q, [])
1002 maybe_retire_websub(user.ap_id)
1007 where: ^old_follower_address in a.recipients,
1012 "array_replace(?,?,?)",
1014 ^old_follower_address,
1015 ^user.follower_address
1021 Repo.update_all(q, [])
1024 def upgrade_user_from_ap_id(ap_id) do
1025 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1026 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1027 already_ap <- User.ap_enabled?(user),
1028 {:ok, user} <- user |> User.upgrade_changeset(data) |> User.update_and_set_cache() do
1029 unless already_ap do
1030 PleromaJobQueue.enqueue(:transmogrifier, __MODULE__, [:user_upgrade, user])
1035 %User{} = user -> {:ok, user}
1040 def maybe_retire_websub(ap_id) do
1041 # some sanity checks
1042 if is_binary(ap_id) && String.length(ap_id) > 8 do
1045 ws in Pleroma.Web.Websub.WebsubClientSubscription,
1046 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
1053 def maybe_fix_user_url(data) do
1054 if is_map(data["url"]) do
1055 Map.put(data, "url", data["url"]["href"])
1061 def maybe_fix_user_object(data) do
1063 |> maybe_fix_user_url