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