[Pleroma.Web.ActivityPub.Transmogrifier]: Fix when inReplyTo is a inlined post [kroeg]
[akkoma] / lib / pleroma / web / activity_pub / transmogrifier.ex
1 defmodule Pleroma.Web.ActivityPub.Transmogrifier do
2 @moduledoc """
3 A module to handle coding from internal to wire ActivityPub and back.
4 """
5 alias Pleroma.User
6 alias Pleroma.Object
7 alias Pleroma.Activity
8 alias Pleroma.Repo
9 alias Pleroma.Web.ActivityPub.ActivityPub
10 alias Pleroma.Web.ActivityPub.Utils
11
12 import Ecto.Query
13
14 require Logger
15
16 def get_actor(%{"actor" => actor}) when is_binary(actor) do
17 actor
18 end
19
20 def get_actor(%{"actor" => actor}) when is_list(actor) do
21 if is_binary(Enum.at(actor, 0)) do
22 Enum.at(actor, 0)
23 else
24 Enum.find(actor, fn %{"type" => type} -> type == "Person" end)
25 |> Map.get("id")
26 end
27 end
28
29 def get_actor(%{"actor" => actor}) when is_map(actor) do
30 actor["id"]
31 end
32
33 @doc """
34 Checks that an imported AP object's actor matches the domain it came from.
35 """
36 def contain_origin(id, %{"actor" => actor} = params) do
37 id_uri = URI.parse(id)
38 actor_uri = URI.parse(get_actor(params))
39
40 if id_uri.host == actor_uri.host do
41 :ok
42 else
43 :error
44 end
45 end
46
47 @doc """
48 Modifies an incoming AP object (mastodon format) to our internal format.
49 """
50 def fix_object(object) do
51 object
52 |> fix_actor
53 |> fix_attachments
54 |> fix_context
55 |> fix_in_reply_to
56 |> fix_emoji
57 |> fix_tag
58 |> fix_content_map
59 |> fix_likes
60 |> fix_addressing
61 end
62
63 def fix_addressing_list(map, field) do
64 if is_binary(map[field]) do
65 map
66 |> Map.put(field, [map[field]])
67 else
68 map
69 end
70 end
71
72 def fix_addressing(map) do
73 map
74 |> fix_addressing_list("to")
75 |> fix_addressing_list("cc")
76 |> fix_addressing_list("bto")
77 |> fix_addressing_list("bcc")
78 end
79
80 def fix_actor(%{"attributedTo" => actor} = object) do
81 object
82 |> Map.put("actor", get_actor(%{"actor" => actor}))
83 end
84
85 def fix_likes(%{"likes" => likes} = object)
86 when is_bitstring(likes) do
87 # Check for standardisation
88 # This is what Peertube does
89 # curl -H 'Accept: application/activity+json' $likes | jq .totalItems
90 object
91 |> Map.put("likes", [])
92 |> Map.put("like_count", 0)
93 end
94
95 def fix_likes(object) do
96 object
97 end
98
99 def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
100 when not is_nil(in_reply_to) do
101 in_reply_to_id =
102 if is_bitstring(in_reply_to) do
103 in_reply_to
104 else
105 if is_map(in_reply_to) && in_reply_to["id"] do
106 in_reply_to["id"]
107 end
108 end
109
110 case ActivityPub.fetch_object_from_id(in_reply_to_id) do
111 {:ok, replied_object} ->
112 with %Activity{} = activity <-
113 Activity.get_create_activity_by_object_ap_id(replied_object.data["id"]) do
114 object
115 |> Map.put("inReplyTo", replied_object.data["id"])
116 |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id)
117 |> Map.put("inReplyToStatusId", activity.id)
118 |> Map.put("conversation", replied_object.data["context"] || object["conversation"])
119 |> Map.put("context", replied_object.data["context"] || object["conversation"])
120 else
121 e ->
122 Logger.error("Couldn't fetch #{object["inReplyTo"]} #{inspect(e)}")
123 object
124 end
125
126 e ->
127 Logger.error("Couldn't fetch #{object["inReplyTo"]} #{inspect(e)}")
128 object
129 end
130 end
131
132 def fix_in_reply_to(object), do: object
133
134 def fix_context(object) do
135 context = object["context"] || object["conversation"] || Utils.generate_context_id()
136
137 object
138 |> Map.put("context", context)
139 |> Map.put("conversation", context)
140 end
141
142 def fix_attachments(object) do
143 attachments =
144 (object["attachment"] || [])
145 |> Enum.map(fn data ->
146 url = [%{"type" => "Link", "mediaType" => data["mediaType"], "href" => data["url"]}]
147 Map.put(data, "url", url)
148 end)
149
150 object
151 |> Map.put("attachment", attachments)
152 end
153
154 def fix_emoji(object) do
155 tags = object["tag"] || []
156 emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end)
157
158 emoji =
159 emoji
160 |> Enum.reduce(%{}, fn data, mapping ->
161 name = data["name"]
162
163 name =
164 if String.starts_with?(name, ":") do
165 name |> String.slice(1..-2)
166 else
167 name
168 end
169
170 mapping |> Map.put(name, data["icon"]["url"])
171 end)
172
173 # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats
174 emoji = Map.merge(object["emoji"] || %{}, emoji)
175
176 object
177 |> Map.put("emoji", emoji)
178 end
179
180 def fix_tag(object) do
181 tags =
182 (object["tag"] || [])
183 |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
184 |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
185
186 combined = (object["tag"] || []) ++ tags
187
188 object
189 |> Map.put("tag", combined)
190 end
191
192 # content map usually only has one language so this will do for now.
193 def fix_content_map(%{"contentMap" => content_map} = object) do
194 content_groups = Map.to_list(content_map)
195 {_, content} = Enum.at(content_groups, 0)
196
197 object
198 |> Map.put("content", content)
199 end
200
201 def fix_content_map(object), do: object
202
203 # disallow objects with bogus IDs
204 def handle_incoming(%{"id" => nil}), do: :error
205 def handle_incoming(%{"id" => ""}), do: :error
206 # length of https:// = 8, should validate better, but good enough for now.
207 def handle_incoming(%{"id" => id}) when not (is_binary(id) and length(id) > 8), do: :error
208
209 # TODO: validate those with a Ecto scheme
210 # - tags
211 # - emoji
212 def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data)
213 when objtype in ["Article", "Note", "Video"] do
214 actor = get_actor(data)
215
216 data =
217 Map.put(data, "actor", actor)
218 |> fix_addressing
219
220 with nil <- Activity.get_create_activity_by_object_ap_id(object["id"]),
221 %User{} = user <- User.get_or_fetch_by_ap_id(data["actor"]) do
222 object = fix_object(data["object"])
223
224 params = %{
225 to: data["to"],
226 object: object,
227 actor: user,
228 context: object["conversation"],
229 local: false,
230 published: data["published"],
231 additional:
232 Map.take(data, [
233 "cc",
234 "id"
235 ])
236 }
237
238 ActivityPub.create(params)
239 else
240 %Activity{} = activity -> {:ok, activity}
241 _e -> :error
242 end
243 end
244
245 def handle_incoming(
246 %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data
247 ) do
248 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
249 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
250 {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do
251 if not User.locked?(followed) do
252 ActivityPub.accept(%{
253 to: [follower.ap_id],
254 actor: followed.ap_id,
255 object: data,
256 local: true
257 })
258
259 User.follow(follower, followed)
260 end
261
262 {:ok, activity}
263 else
264 _e -> :error
265 end
266 end
267
268 defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do
269 with true <- id =~ "follows",
270 %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id),
271 %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do
272 {:ok, activity}
273 else
274 _ -> {:error, nil}
275 end
276 end
277
278 defp mastodon_follow_hack(_), do: {:error, nil}
279
280 defp get_follow_activity(follow_object, followed) do
281 with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object),
282 {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do
283 {:ok, activity}
284 else
285 # Can't find the activity. This might a Mastodon 2.3 "Accept"
286 {:activity, nil} ->
287 mastodon_follow_hack(follow_object, followed)
288
289 _ ->
290 {:error, nil}
291 end
292 end
293
294 def handle_incoming(
295 %{"type" => "Accept", "object" => follow_object, "actor" => actor, "id" => id} = data
296 ) do
297 with %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
298 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
299 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
300 {:ok, activity} <-
301 ActivityPub.accept(%{
302 to: follow_activity.data["to"],
303 type: "Accept",
304 actor: followed.ap_id,
305 object: follow_activity.data["id"],
306 local: false
307 }) do
308 if not User.following?(follower, followed) do
309 {:ok, follower} = User.follow(follower, followed)
310 end
311
312 {:ok, activity}
313 else
314 _e -> :error
315 end
316 end
317
318 def handle_incoming(
319 %{"type" => "Reject", "object" => follow_object, "actor" => actor, "id" => id} = data
320 ) do
321 with %User{} = followed <- User.get_or_fetch_by_ap_id(actor),
322 {:ok, follow_activity} <- get_follow_activity(follow_object, followed),
323 %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
324 {:ok, activity} <-
325 ActivityPub.accept(%{
326 to: follow_activity.data["to"],
327 type: "Accept",
328 actor: followed.ap_id,
329 object: follow_activity.data["id"],
330 local: false
331 }) do
332 User.unfollow(follower, followed)
333
334 {:ok, activity}
335 else
336 _e -> :error
337 end
338 end
339
340 def handle_incoming(
341 %{"type" => "Like", "object" => object_id, "actor" => actor, "id" => id} = _data
342 ) do
343 with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
344 {:ok, object} <-
345 get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
346 {:ok, activity, _object} <- ActivityPub.like(actor, object, id, false) do
347 {:ok, activity}
348 else
349 _e -> :error
350 end
351 end
352
353 def handle_incoming(
354 %{"type" => "Announce", "object" => object_id, "actor" => actor, "id" => id} = _data
355 ) do
356 with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
357 {:ok, object} <-
358 get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
359 {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false) do
360 {:ok, activity}
361 else
362 _e -> :error
363 end
364 end
365
366 def handle_incoming(
367 %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
368 data
369 )
370 when object_type in ["Person", "Application", "Service", "Organization"] do
371 with %User{ap_id: ^actor_id} = actor <- User.get_by_ap_id(object["id"]) do
372 {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object)
373
374 banner = new_user_data[:info]["banner"]
375 locked = new_user_data[:info]["locked"] || false
376
377 update_data =
378 new_user_data
379 |> Map.take([:name, :bio, :avatar])
380 |> Map.put(:info, Map.merge(actor.info, %{"banner" => banner, "locked" => locked}))
381
382 actor
383 |> User.upgrade_changeset(update_data)
384 |> User.update_and_set_cache()
385
386 ActivityPub.update(%{
387 local: false,
388 to: data["to"] || [],
389 cc: data["cc"] || [],
390 object: object,
391 actor: actor_id
392 })
393 else
394 e ->
395 Logger.error(e)
396 :error
397 end
398 end
399
400 # TODO: Make secure.
401 def handle_incoming(
402 %{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => _id} = _data
403 ) do
404 object_id = Utils.get_ap_id(object_id)
405
406 with %User{} = _actor <- User.get_or_fetch_by_ap_id(actor),
407 {:ok, object} <-
408 get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
409 {:ok, activity} <- ActivityPub.delete(object, false) do
410 {:ok, activity}
411 else
412 _e -> :error
413 end
414 end
415
416 def handle_incoming(
417 %{
418 "type" => "Undo",
419 "object" => %{"type" => "Announce", "object" => object_id},
420 "actor" => actor,
421 "id" => id
422 } = _data
423 ) do
424 with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
425 {:ok, object} <-
426 get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
427 {:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
428 {:ok, activity}
429 else
430 _e -> :error
431 end
432 end
433
434 def handle_incoming(
435 %{
436 "type" => "Undo",
437 "object" => %{"type" => "Follow", "object" => followed},
438 "actor" => follower,
439 "id" => id
440 } = _data
441 ) do
442 with %User{local: true} = followed <- User.get_cached_by_ap_id(followed),
443 %User{} = follower <- User.get_or_fetch_by_ap_id(follower),
444 {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do
445 User.unfollow(follower, followed)
446 {:ok, activity}
447 else
448 e -> :error
449 end
450 end
451
452 @ap_config Application.get_env(:pleroma, :activitypub)
453 @accept_blocks Keyword.get(@ap_config, :accept_blocks)
454
455 def handle_incoming(
456 %{
457 "type" => "Undo",
458 "object" => %{"type" => "Block", "object" => blocked},
459 "actor" => blocker,
460 "id" => id
461 } = _data
462 ) do
463 with true <- @accept_blocks,
464 %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
465 %User{} = blocker <- User.get_or_fetch_by_ap_id(blocker),
466 {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
467 User.unblock(blocker, blocked)
468 {:ok, activity}
469 else
470 e -> :error
471 end
472 end
473
474 def handle_incoming(
475 %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = data
476 ) do
477 with true <- @accept_blocks,
478 %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
479 %User{} = blocker = User.get_or_fetch_by_ap_id(blocker),
480 {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
481 User.unfollow(blocker, blocked)
482 User.block(blocker, blocked)
483 {:ok, activity}
484 else
485 e -> :error
486 end
487 end
488
489 def handle_incoming(
490 %{
491 "type" => "Undo",
492 "object" => %{"type" => "Like", "object" => object_id},
493 "actor" => actor,
494 "id" => id
495 } = _data
496 ) do
497 with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
498 {:ok, object} <-
499 get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
500 {:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
501 {:ok, activity}
502 else
503 _e -> :error
504 end
505 end
506
507 def handle_incoming(_), do: :error
508
509 def get_obj_helper(id) do
510 if object = Object.normalize(id), do: {:ok, object}, else: nil
511 end
512
513 def set_reply_to_uri(%{"inReplyTo" => inReplyTo} = object) do
514 with false <- String.starts_with?(inReplyTo, "http"),
515 {:ok, %{data: replied_to_object}} <- get_obj_helper(inReplyTo) do
516 Map.put(object, "inReplyTo", replied_to_object["external_url"] || inReplyTo)
517 else
518 _e -> object
519 end
520 end
521
522 def set_reply_to_uri(obj), do: obj
523
524 # Prepares the object of an outgoing create activity.
525 def prepare_object(object) do
526 object
527 |> set_sensitive
528 |> add_hashtags
529 |> add_mention_tags
530 |> add_emoji_tags
531 |> add_attributed_to
532 |> prepare_attachments
533 |> set_conversation
534 |> set_reply_to_uri
535 end
536
537 # @doc
538 # """
539 # internal -> Mastodon
540 # """
541
542 def prepare_outgoing(%{"type" => "Create", "object" => %{"type" => "Note"} = object} = data) do
543 object =
544 object
545 |> prepare_object
546
547 data =
548 data
549 |> Map.put("object", object)
550 |> Map.put("@context", "https://www.w3.org/ns/activitystreams")
551
552 {:ok, data}
553 end
554
555 # Mastodon Accept/Reject requires a non-normalized object containing the actor URIs,
556 # because of course it does.
557 def prepare_outgoing(%{"type" => "Accept"} = data) do
558 with follow_activity <- Activity.normalize(data["object"]) do
559 object = %{
560 "actor" => follow_activity.actor,
561 "object" => follow_activity.data["object"],
562 "id" => follow_activity.data["id"],
563 "type" => "Follow"
564 }
565
566 data =
567 data
568 |> Map.put("object", object)
569 |> Map.put("@context", "https://www.w3.org/ns/activitystreams")
570
571 {:ok, data}
572 end
573 end
574
575 def prepare_outgoing(%{"type" => "Reject"} = data) do
576 with follow_activity <- Activity.normalize(data["object"]) do
577 object = %{
578 "actor" => follow_activity.actor,
579 "object" => follow_activity.data["object"],
580 "id" => follow_activity.data["id"],
581 "type" => "Follow"
582 }
583
584 data =
585 data
586 |> Map.put("object", object)
587 |> Map.put("@context", "https://www.w3.org/ns/activitystreams")
588
589 {:ok, data}
590 end
591 end
592
593 def prepare_outgoing(%{"type" => _type} = data) do
594 data =
595 data
596 |> maybe_fix_object_url
597 |> Map.put("@context", "https://www.w3.org/ns/activitystreams")
598
599 {:ok, data}
600 end
601
602 def maybe_fix_object_url(data) do
603 if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
604 case ActivityPub.fetch_object_from_id(data["object"]) do
605 {:ok, relative_object} ->
606 if relative_object.data["external_url"] do
607 _data =
608 data
609 |> Map.put("object", relative_object.data["external_url"])
610 else
611 data
612 end
613
614 e ->
615 Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
616 data
617 end
618 else
619 data
620 end
621 end
622
623 def add_hashtags(object) do
624 tags =
625 (object["tag"] || [])
626 |> Enum.map(fn tag ->
627 %{
628 "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}",
629 "name" => "##{tag}",
630 "type" => "Hashtag"
631 }
632 end)
633
634 object
635 |> Map.put("tag", tags)
636 end
637
638 def add_mention_tags(object) do
639 recipients = object["to"] ++ (object["cc"] || [])
640
641 mentions =
642 recipients
643 |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
644 |> Enum.filter(& &1)
645 |> Enum.map(fn user ->
646 %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"}
647 end)
648
649 tags = object["tag"] || []
650
651 object
652 |> Map.put("tag", tags ++ mentions)
653 end
654
655 # TODO: we should probably send mtime instead of unix epoch time for updated
656 def add_emoji_tags(object) do
657 tags = object["tag"] || []
658 emoji = object["emoji"] || []
659
660 out =
661 emoji
662 |> Enum.map(fn {name, url} ->
663 %{
664 "icon" => %{"url" => url, "type" => "Image"},
665 "name" => ":" <> name <> ":",
666 "type" => "Emoji",
667 "updated" => "1970-01-01T00:00:00Z",
668 "id" => url
669 }
670 end)
671
672 object
673 |> Map.put("tag", tags ++ out)
674 end
675
676 def set_conversation(object) do
677 Map.put(object, "conversation", object["context"])
678 end
679
680 def set_sensitive(object) do
681 tags = object["tag"] || []
682 Map.put(object, "sensitive", "nsfw" in tags)
683 end
684
685 def add_attributed_to(object) do
686 attributedTo = object["attributedTo"] || object["actor"]
687
688 object
689 |> Map.put("attributedTo", attributedTo)
690 end
691
692 def prepare_attachments(object) do
693 attachments =
694 (object["attachment"] || [])
695 |> Enum.map(fn data ->
696 [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
697 %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
698 end)
699
700 object
701 |> Map.put("attachment", attachments)
702 end
703
704 defp user_upgrade_task(user) do
705 old_follower_address = User.ap_followers(user)
706
707 q =
708 from(
709 u in User,
710 where: ^old_follower_address in u.following,
711 update: [
712 set: [
713 following:
714 fragment(
715 "array_replace(?,?,?)",
716 u.following,
717 ^old_follower_address,
718 ^user.follower_address
719 )
720 ]
721 ]
722 )
723
724 Repo.update_all(q, [])
725
726 maybe_retire_websub(user.ap_id)
727
728 # Only do this for recent activties, don't go through the whole db.
729 # Only look at the last 1000 activities.
730 since = (Repo.aggregate(Activity, :max, :id) || 0) - 1_000
731
732 q =
733 from(
734 a in Activity,
735 where: ^old_follower_address in a.recipients,
736 where: a.id > ^since,
737 update: [
738 set: [
739 recipients:
740 fragment(
741 "array_replace(?,?,?)",
742 a.recipients,
743 ^old_follower_address,
744 ^user.follower_address
745 )
746 ]
747 ]
748 )
749
750 Repo.update_all(q, [])
751 end
752
753 def upgrade_user_from_ap_id(ap_id, async \\ true) do
754 with %User{local: false} = user <- User.get_by_ap_id(ap_id),
755 {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id) do
756 data =
757 data
758 |> Map.put(:info, Map.merge(user.info, data[:info]))
759
760 already_ap = User.ap_enabled?(user)
761
762 {:ok, user} =
763 User.upgrade_changeset(user, data)
764 |> Repo.update()
765
766 if !already_ap do
767 # This could potentially take a long time, do it in the background
768 if async do
769 Task.start(fn ->
770 user_upgrade_task(user)
771 end)
772 else
773 user_upgrade_task(user)
774 end
775 end
776
777 {:ok, user}
778 else
779 e -> e
780 end
781 end
782
783 def maybe_retire_websub(ap_id) do
784 # some sanity checks
785 if is_binary(ap_id) && String.length(ap_id) > 8 do
786 q =
787 from(
788 ws in Pleroma.Web.Websub.WebsubClientSubscription,
789 where: fragment("? like ?", ws.topic, ^"#{ap_id}%")
790 )
791
792 Repo.delete_all(q)
793 end
794 end
795
796 def maybe_fix_user_url(data) do
797 if is_map(data["url"]) do
798 Map.put(data, "url", data["url"]["href"])
799 else
800 data
801 end
802 end
803
804 def maybe_fix_user_object(data) do
805 data
806 |> maybe_fix_user_url
807 end
808 end