Merge branch 'bugfix/erlang-24' into 'develop'
[akkoma] / lib / pleroma / web / activity_pub / transmogrifier.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.ActivityPub.Transmogrifier do
6 @moduledoc """
7 A module to handle coding from internal to wire ActivityPub and back.
8 """
9 alias Pleroma.Activity
10 alias Pleroma.EctoType.ActivityPub.ObjectValidators
11 alias Pleroma.Maps
12 alias Pleroma.Object
13 alias Pleroma.Object.Containment
14 alias Pleroma.Repo
15 alias Pleroma.User
16 alias Pleroma.Web.ActivityPub.ActivityPub
17 alias Pleroma.Web.ActivityPub.Builder
18 alias Pleroma.Web.ActivityPub.ObjectValidator
19 alias Pleroma.Web.ActivityPub.Pipeline
20 alias Pleroma.Web.ActivityPub.Utils
21 alias Pleroma.Web.ActivityPub.Visibility
22 alias Pleroma.Web.Federator
23 alias Pleroma.Workers.TransmogrifierWorker
24
25 import Ecto.Query
26
27 require Logger
28 require Pleroma.Constants
29
30 @doc """
31 Modifies an incoming AP object (mastodon format) to our internal format.
32 """
33 def fix_object(object, options \\ []) do
34 object
35 |> strip_internal_fields()
36 |> fix_actor()
37 |> fix_url()
38 |> fix_attachments()
39 |> fix_context()
40 |> fix_in_reply_to(options)
41 |> fix_emoji()
42 |> fix_tag()
43 |> fix_content_map()
44 |> fix_addressing()
45 |> fix_summary()
46 end
47
48 def fix_summary(%{"summary" => nil} = object) do
49 Map.put(object, "summary", "")
50 end
51
52 def fix_summary(%{"summary" => _} = object) do
53 # summary is present, nothing to do
54 object
55 end
56
57 def fix_summary(object), do: Map.put(object, "summary", "")
58
59 def fix_addressing_list(map, field) do
60 addrs = map[field]
61
62 cond do
63 is_list(addrs) ->
64 Map.put(map, field, Enum.filter(addrs, &is_binary/1))
65
66 is_binary(addrs) ->
67 Map.put(map, field, [addrs])
68
69 true ->
70 Map.put(map, field, [])
71 end
72 end
73
74 # if directMessage flag is set to true, leave the addressing alone
75 def fix_explicit_addressing(%{"directMessage" => true} = object, _follower_collection),
76 do: object
77
78 def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, follower_collection) do
79 explicit_mentions =
80 Utils.determine_explicit_mentions(object) ++
81 [Pleroma.Constants.as_public(), follower_collection]
82
83 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)
85
86 final_cc =
87 (cc ++ explicit_cc)
88 |> Enum.filter(& &1)
89 |> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end)
90 |> Enum.uniq()
91
92 object
93 |> Map.put("to", explicit_to)
94 |> Map.put("cc", final_cc)
95 end
96
97 # if as:Public is addressed, then make sure the followers collection is also addressed
98 # so that the activities will be delivered to local users.
99 def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
100 recipients = to ++ cc
101
102 if followers_collection not in recipients do
103 cond do
104 Pleroma.Constants.as_public() in cc ->
105 to = to ++ [followers_collection]
106 Map.put(object, "to", to)
107
108 Pleroma.Constants.as_public() in to ->
109 cc = cc ++ [followers_collection]
110 Map.put(object, "cc", cc)
111
112 true ->
113 object
114 end
115 else
116 object
117 end
118 end
119
120 def fix_addressing(object) do
121 {:ok, %User{follower_address: follower_collection}} =
122 object
123 |> Containment.get_actor()
124 |> User.get_or_fetch_by_ap_id()
125
126 object
127 |> fix_addressing_list("to")
128 |> fix_addressing_list("cc")
129 |> fix_addressing_list("bto")
130 |> fix_addressing_list("bcc")
131 |> fix_explicit_addressing(follower_collection)
132 |> fix_implicit_addressing(follower_collection)
133 end
134
135 def fix_actor(%{"attributedTo" => actor} = object) do
136 actor = Containment.get_actor(%{"actor" => actor})
137
138 # TODO: Remove actor field for Objects
139 object
140 |> Map.put("actor", actor)
141 |> Map.put("attributedTo", actor)
142 end
143
144 def fix_in_reply_to(object, options \\ [])
145
146 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options)
147 when not is_nil(in_reply_to) do
148 in_reply_to_id = prepare_in_reply_to(in_reply_to)
149 depth = (options[:depth] || 0) + 1
150
151 if Federator.allowed_thread_distance?(depth) do
152 with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options),
153 %Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
154 object
155 |> Map.put("inReplyTo", replied_object.data["id"])
156 |> Map.put("context", replied_object.data["context"] || object["conversation"])
157 |> Map.drop(["conversation", "inReplyToAtomUri"])
158 else
159 e ->
160 Logger.warn("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}")
161 object
162 end
163 else
164 object
165 end
166 end
167
168 def fix_in_reply_to(object, _options), do: object
169
170 defp prepare_in_reply_to(in_reply_to) do
171 cond do
172 is_bitstring(in_reply_to) ->
173 in_reply_to
174
175 is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
176 in_reply_to["id"]
177
178 is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
179 Enum.at(in_reply_to, 0)
180
181 true ->
182 ""
183 end
184 end
185
186 def fix_context(object) do
187 context = object["context"] || object["conversation"] || Utils.generate_context_id()
188
189 object
190 |> Map.put("context", context)
191 |> Map.drop(["conversation"])
192 end
193
194 def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
195 attachments =
196 Enum.map(attachment, fn data ->
197 url =
198 cond do
199 is_list(data["url"]) -> List.first(data["url"])
200 is_map(data["url"]) -> data["url"]
201 true -> nil
202 end
203
204 media_type =
205 cond do
206 is_map(url) && MIME.extensions(url["mediaType"]) != [] ->
207 url["mediaType"]
208
209 is_bitstring(data["mediaType"]) && MIME.extensions(data["mediaType"]) != [] ->
210 data["mediaType"]
211
212 is_bitstring(data["mimeType"]) && MIME.extensions(data["mimeType"]) != [] ->
213 data["mimeType"]
214
215 true ->
216 nil
217 end
218
219 href =
220 cond do
221 is_map(url) && is_binary(url["href"]) -> url["href"]
222 is_binary(data["url"]) -> data["url"]
223 is_binary(data["href"]) -> data["href"]
224 true -> nil
225 end
226
227 if href do
228 attachment_url =
229 %{
230 "href" => href,
231 "type" => Map.get(url || %{}, "type", "Link")
232 }
233 |> Maps.put_if_present("mediaType", media_type)
234 |> Maps.put_if_present("width", (url || %{})["width"] || data["width"])
235 |> Maps.put_if_present("height", (url || %{})["height"] || data["height"])
236
237 %{
238 "url" => [attachment_url],
239 "type" => data["type"] || "Document"
240 }
241 |> Maps.put_if_present("mediaType", media_type)
242 |> Maps.put_if_present("name", data["name"])
243 |> Maps.put_if_present("blurhash", data["blurhash"])
244 else
245 nil
246 end
247 end)
248 |> Enum.filter(& &1)
249
250 Map.put(object, "attachment", attachments)
251 end
252
253 def fix_attachments(%{"attachment" => attachment} = object) when is_map(attachment) do
254 object
255 |> Map.put("attachment", [attachment])
256 |> fix_attachments()
257 end
258
259 def fix_attachments(object), do: object
260
261 def fix_url(%{"url" => url} = object) when is_map(url) do
262 Map.put(object, "url", url["href"])
263 end
264
265 def fix_url(%{"url" => url} = object) when is_list(url) do
266 first_element = Enum.at(url, 0)
267
268 url_string =
269 cond do
270 is_bitstring(first_element) -> first_element
271 is_map(first_element) -> first_element["href"] || ""
272 true -> ""
273 end
274
275 Map.put(object, "url", url_string)
276 end
277
278 def fix_url(object), do: object
279
280 def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
281 emoji =
282 tags
283 |> Enum.filter(fn data -> is_map(data) and data["type"] == "Emoji" and data["icon"] end)
284 |> Enum.reduce(%{}, fn data, mapping ->
285 name = String.trim(data["name"], ":")
286
287 Map.put(mapping, name, data["icon"]["url"])
288 end)
289
290 Map.put(object, "emoji", emoji)
291 end
292
293 def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
294 name = String.trim(tag["name"], ":")
295 emoji = %{name => tag["icon"]["url"]}
296
297 Map.put(object, "emoji", emoji)
298 end
299
300 def fix_emoji(object), do: object
301
302 def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
303 tags =
304 tag
305 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
306 |> Enum.map(fn
307 %{"name" => "#" <> hashtag} -> String.downcase(hashtag)
308 %{"name" => hashtag} -> String.downcase(hashtag)
309 end)
310
311 Map.put(object, "tag", tag ++ tags)
312 end
313
314 def fix_tag(%{"tag" => %{} = tag} = object) do
315 object
316 |> Map.put("tag", [tag])
317 |> fix_tag
318 end
319
320 def fix_tag(object), do: object
321
322 # content map usually only has one language so this will do for now.
323 def fix_content_map(%{"contentMap" => content_map} = object) do
324 content_groups = Map.to_list(content_map)
325 {_, content} = Enum.at(content_groups, 0)
326
327 Map.put(object, "content", content)
328 end
329
330 def fix_content_map(object), do: object
331
332 defp fix_type(%{"type" => "Note", "inReplyTo" => reply_id, "name" => _} = object, options)
333 when is_binary(reply_id) do
334 options = Keyword.put(options, :fetch, true)
335
336 with %Object{data: %{"type" => "Question"}} <- Object.normalize(reply_id, options) do
337 Map.put(object, "type", "Answer")
338 else
339 _ -> object
340 end
341 end
342
343 defp fix_type(object, _options), do: object
344
345 # Reduce the object list to find the reported user.
346 defp get_reported(objects) do
347 Enum.reduce_while(objects, nil, fn ap_id, _ ->
348 with %User{} = user <- User.get_cached_by_ap_id(ap_id) do
349 {:halt, user}
350 else
351 _ -> {:cont, nil}
352 end
353 end)
354 end
355
356 # Compatibility wrapper for Mastodon votes
357 defp handle_create(%{"object" => %{"type" => "Answer"}} = data, _user) do
358 handle_incoming(data)
359 end
360
361 defp handle_create(%{"object" => object} = data, user) do
362 %{
363 to: data["to"],
364 object: object,
365 actor: user,
366 context: object["context"],
367 local: false,
368 published: data["published"],
369 additional:
370 Map.take(data, [
371 "cc",
372 "directMessage",
373 "id"
374 ])
375 }
376 |> ActivityPub.create()
377 end
378
379 def handle_incoming(data, options \\ [])
380
381 # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
382 # with nil ID.
383 def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data, _options) do
384 with context <- data["context"] || Utils.generate_context_id(),
385 content <- data["content"] || "",
386 %User{} = actor <- User.get_cached_by_ap_id(actor),
387 # Reduce the object list to find the reported user.
388 %User{} = account <- get_reported(objects),
389 # Remove the reported user from the object list.
390 statuses <- Enum.filter(objects, fn ap_id -> ap_id != account.ap_id end) do
391 %{
392 actor: actor,
393 context: context,
394 account: account,
395 statuses: statuses,
396 content: content,
397 additional: %{"cc" => [account.ap_id]}
398 }
399 |> ActivityPub.flag()
400 end
401 end
402
403 # disallow objects with bogus IDs
404 def handle_incoming(%{"id" => nil}, _options), do: :error
405 def handle_incoming(%{"id" => ""}, _options), do: :error
406 # length of https:// = 8, should validate better, but good enough for now.
407 def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id) < 8,
408 do: :error
409
410 # TODO: validate those with a Ecto scheme
411 # - tags
412 # - emoji
413 def handle_incoming(
414 %{"type" => "Create", "object" => %{"type" => "Page"} = object} = data,
415 options
416 ) do
417 actor = Containment.get_actor(data)
418
419 with nil <- Activity.get_create_by_object_ap_id(object["id"]),
420 {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(actor) do
421 data =
422 data
423 |> Map.put("object", fix_object(object, options))
424 |> Map.put("actor", actor)
425 |> fix_addressing()
426
427 with {:ok, created_activity} <- handle_create(data, user) do
428 reply_depth = (options[:depth] || 0) + 1
429
430 if Federator.allowed_thread_distance?(reply_depth) do
431 for reply_id <- replies(object) do
432 Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{
433 "id" => reply_id,
434 "depth" => reply_depth
435 })
436 end
437 end
438
439 {:ok, created_activity}
440 end
441 else
442 %Activity{} = activity -> {:ok, activity}
443 _e -> :error
444 end
445 end
446
447 def handle_incoming(
448 %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data,
449 options
450 ) do
451 actor = Containment.get_actor(data)
452
453 data =
454 Map.put(data, "actor", actor)
455 |> fix_addressing
456
457 with {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do
458 reply_depth = (options[:depth] || 0) + 1
459 options = Keyword.put(options, :depth, reply_depth)
460 object = fix_object(object, options)
461
462 params = %{
463 to: data["to"],
464 object: object,
465 actor: user,
466 context: nil,
467 local: false,
468 published: data["published"],
469 additional: Map.take(data, ["cc", "id"])
470 }
471
472 ActivityPub.listen(params)
473 else
474 _e -> :error
475 end
476 end
477
478 @misskey_reactions %{
479 "like" => "👍",
480 "love" => "❤️",
481 "laugh" => "😆",
482 "hmm" => "🤔",
483 "surprise" => "😮",
484 "congrats" => "🎉",
485 "angry" => "💢",
486 "confused" => "😥",
487 "rip" => "😇",
488 "pudding" => "🍮",
489 "star" => "⭐"
490 }
491
492 @doc "Rewrite misskey likes into EmojiReacts"
493 def handle_incoming(
494 %{
495 "type" => "Like",
496 "_misskey_reaction" => reaction
497 } = data,
498 options
499 ) do
500 data
501 |> Map.put("type", "EmojiReact")
502 |> Map.put("content", @misskey_reactions[reaction] || reaction)
503 |> handle_incoming(options)
504 end
505
506 def handle_incoming(
507 %{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data,
508 options
509 )
510 when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note} do
511 fetch_options = Keyword.put(options, :depth, (options[:depth] || 0) + 1)
512
513 object =
514 data["object"]
515 |> strip_internal_fields()
516 |> fix_type(fetch_options)
517 |> fix_in_reply_to(fetch_options)
518
519 data = Map.put(data, "object", object)
520 options = Keyword.put(options, :local, false)
521
522 with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
523 nil <- Activity.get_create_by_object_ap_id(obj_id),
524 {:ok, activity, _} <- Pipeline.common_pipeline(data, options) do
525 {:ok, activity}
526 else
527 %Activity{} = activity -> {:ok, activity}
528 e -> e
529 end
530 end
531
532 def handle_incoming(%{"type" => type} = data, _options)
533 when type in ~w{Like EmojiReact Announce Add Remove} do
534 with :ok <- ObjectValidator.fetch_actor_and_object(data),
535 {:ok, activity, _meta} <-
536 Pipeline.common_pipeline(data, local: false) do
537 {:ok, activity}
538 else
539 e -> {:error, e}
540 end
541 end
542
543 def handle_incoming(
544 %{"type" => type} = data,
545 _options
546 )
547 when type in ~w{Update Block Follow Accept Reject} do
548 with {:ok, %User{}} <- ObjectValidator.fetch_actor(data),
549 {:ok, activity, _} <-
550 Pipeline.common_pipeline(data, local: false) do
551 {:ok, activity}
552 end
553 end
554
555 def handle_incoming(
556 %{"type" => "Delete"} = data,
557 _options
558 ) do
559 with {:ok, activity, _} <-
560 Pipeline.common_pipeline(data, local: false) do
561 {:ok, activity}
562 else
563 {:error, {:validate, _}} = e ->
564 # Check if we have a create activity for this
565 with {:ok, object_id} <- ObjectValidators.ObjectID.cast(data["object"]),
566 %Activity{data: %{"actor" => actor}} <-
567 Activity.create_by_object_ap_id(object_id) |> Repo.one(),
568 # We have one, insert a tombstone and retry
569 {:ok, tombstone_data, _} <- Builder.tombstone(actor, object_id),
570 {:ok, _tombstone} <- Object.create(tombstone_data) do
571 handle_incoming(data)
572 else
573 _ -> e
574 end
575 end
576 end
577
578 def handle_incoming(
579 %{
580 "type" => "Undo",
581 "object" => %{"type" => "Follow", "object" => followed},
582 "actor" => follower,
583 "id" => id
584 } = _data,
585 _options
586 ) do
587 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
588 {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower),
589 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
590 User.unfollow(follower, followed)
591 {:ok, activity}
592 else
593 _e -> :error
594 end
595 end
596
597 def handle_incoming(
598 %{
599 "type" => "Undo",
600 "object" => %{"type" => type}
601 } = data,
602 _options
603 )
604 when type in ["Like", "EmojiReact", "Announce", "Block"] do
605 with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
606 {:ok, activity}
607 end
608 end
609
610 # For Undos that don't have the complete object attached, try to find it in our database.
611 def handle_incoming(
612 %{
613 "type" => "Undo",
614 "object" => object
615 } = activity,
616 options
617 )
618 when is_binary(object) do
619 with %Activity{data: data} <- Activity.get_by_ap_id(object) do
620 activity
621 |> Map.put("object", data)
622 |> handle_incoming(options)
623 else
624 _e -> :error
625 end
626 end
627
628 def handle_incoming(
629 %{
630 "type" => "Move",
631 "actor" => origin_actor,
632 "object" => origin_actor,
633 "target" => target_actor
634 },
635 _options
636 ) do
637 with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor),
638 {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor),
639 true <- origin_actor in target_user.also_known_as do
640 ActivityPub.move(origin_user, target_user, false)
641 else
642 _e -> :error
643 end
644 end
645
646 def handle_incoming(_, _), do: :error
647
648 @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil
649 def get_obj_helper(id, options \\ []) do
650 options = Keyword.put(options, :fetch, true)
651
652 case Object.normalize(id, options) do
653 %Object{} = object -> {:ok, object}
654 _ -> nil
655 end
656 end
657
658 @spec get_embedded_obj_helper(String.t() | Object.t(), User.t()) :: {:ok, Object.t()} | nil
659 def get_embedded_obj_helper(%{"attributedTo" => attributed_to, "id" => object_id} = data, %User{
660 ap_id: ap_id
661 })
662 when attributed_to == ap_id do
663 with {:ok, activity} <-
664 handle_incoming(%{
665 "type" => "Create",
666 "to" => data["to"],
667 "cc" => data["cc"],
668 "actor" => attributed_to,
669 "object" => data
670 }) do
671 {:ok, Object.normalize(activity, fetch: false)}
672 else
673 _ -> get_obj_helper(object_id)
674 end
675 end
676
677 def get_embedded_obj_helper(object_id, _) do
678 get_obj_helper(object_id)
679 end
680
681 def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) when is_binary(in_reply_to) do
682 with false <- String.starts_with?(in_reply_to, "http"),
683 {:ok, %{data: replied_to_object}} <- get_obj_helper(in_reply_to) do
684 Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
685 else
686 _e -> object
687 end
688 end
689
690 def set_reply_to_uri(obj), do: obj
691
692 @doc """
693 Serialized Mastodon-compatible `replies` collection containing _self-replies_.
694 Based on Mastodon's ActivityPub::NoteSerializer#replies.
695 """
696 def set_replies(obj_data) do
697 replies_uris =
698 with limit when limit > 0 <-
699 Pleroma.Config.get([:activitypub, :note_replies_output_limit], 0),
700 %Object{} = object <- Object.get_cached_by_ap_id(obj_data["id"]) do
701 object
702 |> Object.self_replies()
703 |> select([o], fragment("?->>'id'", o.data))
704 |> limit(^limit)
705 |> Repo.all()
706 else
707 _ -> []
708 end
709
710 set_replies(obj_data, replies_uris)
711 end
712
713 defp set_replies(obj, []) do
714 obj
715 end
716
717 defp set_replies(obj, replies_uris) do
718 replies_collection = %{
719 "type" => "Collection",
720 "items" => replies_uris
721 }
722
723 Map.merge(obj, %{"replies" => replies_collection})
724 end
725
726 def replies(%{"replies" => %{"first" => %{"items" => items}}}) when not is_nil(items) do
727 items
728 end
729
730 def replies(%{"replies" => %{"items" => items}}) when not is_nil(items) do
731 items
732 end
733
734 def replies(_), do: []
735
736 # Prepares the object of an outgoing create activity.
737 def prepare_object(object) do
738 object
739 |> add_hashtags
740 |> add_mention_tags
741 |> add_emoji_tags
742 |> add_attributed_to
743 |> prepare_attachments
744 |> set_conversation
745 |> set_reply_to_uri
746 |> set_replies
747 |> strip_internal_fields
748 |> strip_internal_tags
749 |> set_type
750 end
751
752 # @doc
753 # """
754 # internal -> Mastodon
755 # """
756
757 def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data)
758 when activity_type in ["Create", "Listen"] do
759 object =
760 object_id
761 |> Object.normalize(fetch: false)
762 |> Map.get(:data)
763 |> prepare_object
764
765 data =
766 data
767 |> Map.put("object", object)
768 |> Map.merge(Utils.make_json_ld_header())
769 |> Map.delete("bcc")
770
771 {:ok, data}
772 end
773
774 def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
775 object =
776 object_id
777 |> Object.normalize(fetch: false)
778
779 data =
780 if Visibility.is_private?(object) && object.data["actor"] == ap_id do
781 data |> Map.put("object", object |> Map.get(:data) |> prepare_object)
782 else
783 data |> maybe_fix_object_url
784 end
785
786 data =
787 data
788 |> strip_internal_fields
789 |> Map.merge(Utils.make_json_ld_header())
790 |> Map.delete("bcc")
791
792 {:ok, data}
793 end
794
795 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
796 # because of course it does.
797 def prepare_outgoing(%{"type" => "Accept"} = data) do
798 with follow_activity <- Activity.normalize(data["object"]) do
799 object = %{
800 "actor" => follow_activity.actor,
801 "object" => follow_activity.data["object"],
802 "id" => follow_activity.data["id"],
803 "type" => "Follow"
804 }
805
806 data =
807 data
808 |> Map.put("object", object)
809 |> Map.merge(Utils.make_json_ld_header())
810
811 {:ok, data}
812 end
813 end
814
815 def prepare_outgoing(%{"type" => "Reject"} = data) do
816 with follow_activity <- Activity.normalize(data["object"]) do
817 object = %{
818 "actor" => follow_activity.actor,
819 "object" => follow_activity.data["object"],
820 "id" => follow_activity.data["id"],
821 "type" => "Follow"
822 }
823
824 data =
825 data
826 |> Map.put("object", object)
827 |> Map.merge(Utils.make_json_ld_header())
828
829 {:ok, data}
830 end
831 end
832
833 def prepare_outgoing(%{"type" => _type} = data) do
834 data =
835 data
836 |> strip_internal_fields
837 |> maybe_fix_object_url
838 |> Map.merge(Utils.make_json_ld_header())
839
840 {:ok, data}
841 end
842
843 def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do
844 with false <- String.starts_with?(object, "http"),
845 {:fetch, {:ok, relative_object}} <- {:fetch, get_obj_helper(object)},
846 %{data: %{"external_url" => external_url}} when not is_nil(external_url) <-
847 relative_object do
848 Map.put(data, "object", external_url)
849 else
850 {:fetch, e} ->
851 Logger.error("Couldn't fetch #{object} #{inspect(e)}")
852 data
853
854 _ ->
855 data
856 end
857 end
858
859 def maybe_fix_object_url(data), do: data
860
861 def add_hashtags(object) do
862 tags =
863 (object["tag"] || [])
864 |> Enum.map(fn
865 # Expand internal representation tags into AS2 tags.
866 tag when is_binary(tag) ->
867 %{
868 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
869 "name" => "##{tag}",
870 "type" => "Hashtag"
871 }
872
873 # Do not process tags which are already AS2 tag objects.
874 tag when is_map(tag) ->
875 tag
876 end)
877
878 Map.put(object, "tag", tags)
879 end
880
881 # TODO These should be added on our side on insertion, it doesn't make much
882 # sense to regenerate these all the time
883 def add_mention_tags(object) do
884 to = object["to"] || []
885 cc = object["cc"] || []
886 mentioned = User.get_users_from_set(to ++ cc, local_only: false)
887
888 mentions = Enum.map(mentioned, &build_mention_tag/1)
889
890 tags = object["tag"] || []
891 Map.put(object, "tag", tags ++ mentions)
892 end
893
894 defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do
895 %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"}
896 end
897
898 def take_emoji_tags(%User{emoji: emoji}) do
899 emoji
900 |> Map.to_list()
901 |> Enum.map(&build_emoji_tag/1)
902 end
903
904 # TODO: we should probably send mtime instead of unix epoch time for updated
905 def add_emoji_tags(%{"emoji" => emoji} = object) do
906 tags = object["tag"] || []
907
908 out = Enum.map(emoji, &build_emoji_tag/1)
909
910 Map.put(object, "tag", tags ++ out)
911 end
912
913 def add_emoji_tags(object), do: object
914
915 defp build_emoji_tag({name, url}) do
916 %{
917 "icon" => %{"url" => "#{URI.encode(url)}", "type" => "Image"},
918 "name" => ":" <> name <> ":",
919 "type" => "Emoji",
920 "updated" => "1970-01-01T00:00:00Z",
921 "id" => url
922 }
923 end
924
925 def set_conversation(object) do
926 Map.put(object, "conversation", object["context"])
927 end
928
929 def set_type(%{"type" => "Answer"} = object) do
930 Map.put(object, "type", "Note")
931 end
932
933 def set_type(object), do: object
934
935 def add_attributed_to(object) do
936 attributed_to = object["attributedTo"] || object["actor"]
937 Map.put(object, "attributedTo", attributed_to)
938 end
939
940 # TODO: Revisit this
941 def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object
942
943 def prepare_attachments(object) do
944 attachments =
945 object
946 |> Map.get("attachment", [])
947 |> Enum.map(fn data ->
948 [%{"mediaType" => media_type, "href" => href} = url | _] = data["url"]
949
950 %{
951 "url" => href,
952 "mediaType" => media_type,
953 "name" => data["name"],
954 "type" => "Document"
955 }
956 |> Maps.put_if_present("width", url["width"])
957 |> Maps.put_if_present("height", url["height"])
958 |> Maps.put_if_present("blurhash", data["blurhash"])
959 end)
960
961 Map.put(object, "attachment", attachments)
962 end
963
964 def strip_internal_fields(object) do
965 Map.drop(object, Pleroma.Constants.object_internal_fields())
966 end
967
968 defp strip_internal_tags(%{"tag" => tags} = object) do
969 tags = Enum.filter(tags, fn x -> is_map(x) end)
970
971 Map.put(object, "tag", tags)
972 end
973
974 defp strip_internal_tags(object), do: object
975
976 def perform(:user_upgrade, user) do
977 # we pass a fake user so that the followers collection is stripped away
978 old_follower_address = User.ap_followers(%User{nickname: user.nickname})
979
980 from(
981 a in Activity,
982 where: ^old_follower_address in a.recipients,
983 update: [
984 set: [
985 recipients:
986 fragment(
987 "array_replace(?,?,?)",
988 a.recipients,
989 ^old_follower_address,
990 ^user.follower_address
991 )
992 ]
993 ]
994 )
995 |> Repo.update_all([])
996 end
997
998 def upgrade_user_from_ap_id(ap_id) do
999 with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id),
1000 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id),
1001 {:ok, user} <- update_user(user, data) do
1002 {:ok, _pid} = Task.start(fn -> ActivityPub.pinned_fetch_task(user) end)
1003 TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id})
1004 {:ok, user}
1005 else
1006 %User{} = user -> {:ok, user}
1007 e -> e
1008 end
1009 end
1010
1011 defp update_user(user, data) do
1012 user
1013 |> User.remote_user_changeset(data)
1014 |> User.update_and_set_cache()
1015 end
1016
1017 def maybe_fix_user_url(%{"url" => url} = data) when is_map(url) do
1018 Map.put(data, "url", url["href"])
1019 end
1020
1021 def maybe_fix_user_url(data), do: data
1022
1023 def maybe_fix_user_object(data), do: maybe_fix_user_url(data)
1024 end