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