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