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