fc5305c589a426f9965db3a20aa78e6271aea276
[akkoma] / lib / pleroma / web / activity_pub / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.ActivityPub.Utils do
6 alias Ecto.Changeset
7 alias Ecto.UUID
8 alias Pleroma.Activity
9 alias Pleroma.Notification
10 alias Pleroma.Object
11 alias Pleroma.Repo
12 alias Pleroma.User
13 alias Pleroma.Web
14 alias Pleroma.Web.ActivityPub.Visibility
15 alias Pleroma.Web.Endpoint
16 alias Pleroma.Web.Router.Helpers
17
18 import Ecto.Query
19
20 require Logger
21 require Pleroma.Constants
22
23 @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer"]
24 @supported_report_states ~w(open closed resolved)
25 @valid_visibilities ~w(public unlisted private direct)
26
27 # Some implementations send the actor URI as the actor field, others send the entire actor object,
28 # so figure out what the actor's URI is based on what we have.
29 def get_ap_id(%{"id" => id} = _), do: id
30 def get_ap_id(id), do: id
31
32 def normalize_params(params) do
33 Map.put(params, "actor", get_ap_id(params["actor"]))
34 end
35
36 def determine_explicit_mentions(%{"tag" => tag} = _object) when is_list(tag) do
37 tag
38 |> Enum.filter(fn x -> is_map(x) end)
39 |> Enum.filter(fn x -> x["type"] == "Mention" end)
40 |> Enum.map(fn x -> x["href"] end)
41 end
42
43 def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do
44 Map.put(object, "tag", [tag])
45 |> determine_explicit_mentions()
46 end
47
48 def determine_explicit_mentions(_), do: []
49
50 defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
51 defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
52 defp recipient_in_collection(_, _), do: false
53
54 def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do
55 cond do
56 recipient_in_collection(ap_id, params["to"]) ->
57 true
58
59 recipient_in_collection(ap_id, params["cc"]) ->
60 true
61
62 recipient_in_collection(ap_id, params["bto"]) ->
63 true
64
65 recipient_in_collection(ap_id, params["bcc"]) ->
66 true
67
68 # if the message is unaddressed at all, then assume it is directly addressed
69 # to the recipient
70 !params["to"] && !params["cc"] && !params["bto"] && !params["bcc"] ->
71 true
72
73 # if the message is sent from somebody the user is following, then assume it
74 # is addressed to the recipient
75 User.following?(recipient, actor) ->
76 true
77
78 true ->
79 false
80 end
81 end
82
83 defp extract_list(target) when is_binary(target), do: [target]
84 defp extract_list(lst) when is_list(lst), do: lst
85 defp extract_list(_), do: []
86
87 def maybe_splice_recipient(ap_id, params) do
88 need_splice =
89 !recipient_in_collection(ap_id, params["to"]) &&
90 !recipient_in_collection(ap_id, params["cc"])
91
92 cc_list = extract_list(params["cc"])
93
94 if need_splice do
95 params
96 |> Map.put("cc", [ap_id | cc_list])
97 else
98 params
99 end
100 end
101
102 def make_json_ld_header do
103 %{
104 "@context" => [
105 "https://www.w3.org/ns/activitystreams",
106 "#{Web.base_url()}/schemas/litepub-0.1.jsonld",
107 %{
108 "@language" => "und"
109 }
110 ]
111 }
112 end
113
114 def make_date do
115 DateTime.utc_now() |> DateTime.to_iso8601()
116 end
117
118 def generate_activity_id do
119 generate_id("activities")
120 end
121
122 def generate_context_id do
123 generate_id("contexts")
124 end
125
126 def generate_object_id do
127 Helpers.o_status_url(Endpoint, :object, UUID.generate())
128 end
129
130 def generate_id(type) do
131 "#{Web.base_url()}/#{type}/#{UUID.generate()}"
132 end
133
134 def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
135 fake_create_activity = %{
136 "to" => object["to"],
137 "cc" => object["cc"],
138 "type" => "Create",
139 "object" => object
140 }
141
142 Notification.get_notified_from_activity(%Activity{data: fake_create_activity}, false)
143 end
144
145 def get_notified_from_object(object) do
146 Notification.get_notified_from_activity(%Activity{data: object}, false)
147 end
148
149 def create_context(context) do
150 context = context || generate_id("contexts")
151
152 # Ecto has problems accessing the constraint inside the jsonb,
153 # so we explicitly check for the existed object before insert
154 object = Object.get_cached_by_ap_id(context)
155
156 with true <- is_nil(object),
157 changeset <- Object.context_mapping(context),
158 {:ok, inserted_object} <- Repo.insert(changeset) do
159 inserted_object
160 else
161 _ ->
162 object
163 end
164 end
165
166 @doc """
167 Enqueues an activity for federation if it's local
168 """
169 def maybe_federate(%Activity{local: true} = activity) do
170 if Pleroma.Config.get!([:instance, :federating]) do
171 priority =
172 case activity.data["type"] do
173 "Delete" -> 10
174 "Create" -> 1
175 _ -> 5
176 end
177
178 Pleroma.Web.Federator.publish(activity, priority)
179 end
180
181 :ok
182 end
183
184 def maybe_federate(_), do: :ok
185
186 @doc """
187 Adds an id and a published data if they aren't there,
188 also adds it to an included object
189 """
190 def lazy_put_activity_defaults(map, fake \\ false) do
191 map =
192 unless fake do
193 %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
194
195 map
196 |> Map.put_new_lazy("id", &generate_activity_id/0)
197 |> Map.put_new_lazy("published", &make_date/0)
198 |> Map.put_new("context", context)
199 |> Map.put_new("context_id", context_id)
200 else
201 map
202 |> Map.put_new("id", "pleroma:fakeid")
203 |> Map.put_new_lazy("published", &make_date/0)
204 |> Map.put_new("context", "pleroma:fakecontext")
205 |> Map.put_new("context_id", -1)
206 end
207
208 if is_map(map["object"]) do
209 object = lazy_put_object_defaults(map["object"], map, fake)
210 %{map | "object" => object}
211 else
212 map
213 end
214 end
215
216 @doc """
217 Adds an id and published date if they aren't there.
218 """
219 def lazy_put_object_defaults(map, activity \\ %{}, fake)
220
221 def lazy_put_object_defaults(map, activity, true = _fake) do
222 map
223 |> Map.put_new_lazy("published", &make_date/0)
224 |> Map.put_new("id", "pleroma:fake_object_id")
225 |> Map.put_new("context", activity["context"])
226 |> Map.put_new("fake", true)
227 |> Map.put_new("context_id", activity["context_id"])
228 end
229
230 def lazy_put_object_defaults(map, activity, _fake) do
231 map
232 |> Map.put_new_lazy("id", &generate_object_id/0)
233 |> Map.put_new_lazy("published", &make_date/0)
234 |> Map.put_new("context", activity["context"])
235 |> Map.put_new("context_id", activity["context_id"])
236 end
237
238 @doc """
239 Inserts a full object if it is contained in an activity.
240 """
241 def insert_full_object(%{"object" => %{"type" => type} = object_data} = map)
242 when is_map(object_data) and type in @supported_object_types do
243 with {:ok, object} <- Object.create(object_data) do
244 map =
245 map
246 |> Map.put("object", object.data["id"])
247
248 {:ok, map, object}
249 end
250 end
251
252 def insert_full_object(map), do: {:ok, map, nil}
253
254 #### Like-related helpers
255
256 @doc """
257 Returns an existing like if a user already liked an object
258 """
259 def get_existing_like(actor, %{data: %{"id" => id}}) do
260 query =
261 from(
262 activity in Activity,
263 where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
264 # this is to use the index
265 where:
266 fragment(
267 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
268 activity.data,
269 activity.data,
270 ^id
271 ),
272 where: fragment("(?)->>'type' = 'Like'", activity.data)
273 )
274
275 Repo.one(query)
276 end
277
278 @doc """
279 Returns like activities targeting an object
280 """
281 def get_object_likes(%{data: %{"id" => id}}) do
282 query =
283 from(
284 activity in Activity,
285 # this is to use the index
286 where:
287 fragment(
288 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
289 activity.data,
290 activity.data,
291 ^id
292 ),
293 where: fragment("(?)->>'type' = 'Like'", activity.data)
294 )
295
296 Repo.all(query)
297 end
298
299 def make_like_data(
300 %User{ap_id: ap_id} = actor,
301 %{data: %{"actor" => object_actor_id, "id" => id}} = object,
302 activity_id
303 ) do
304 object_actor = User.get_cached_by_ap_id(object_actor_id)
305
306 to =
307 if Visibility.is_public?(object) do
308 [actor.follower_address, object.data["actor"]]
309 else
310 [object.data["actor"]]
311 end
312
313 cc =
314 (object.data["to"] ++ (object.data["cc"] || []))
315 |> List.delete(actor.ap_id)
316 |> List.delete(object_actor.follower_address)
317
318 data = %{
319 "type" => "Like",
320 "actor" => ap_id,
321 "object" => id,
322 "to" => to,
323 "cc" => cc,
324 "context" => object.data["context"]
325 }
326
327 if activity_id, do: Map.put(data, "id", activity_id), else: data
328 end
329
330 def update_element_in_object(property, element, object) do
331 with new_data <-
332 object.data
333 |> Map.put("#{property}_count", length(element))
334 |> Map.put("#{property}s", element),
335 changeset <- Changeset.change(object, data: new_data),
336 {:ok, object} <- Object.update_and_set_cache(changeset) do
337 {:ok, object}
338 end
339 end
340
341 def update_likes_in_object(likes, object) do
342 update_element_in_object("like", likes, object)
343 end
344
345 def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
346 likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
347
348 with likes <- [actor | likes] |> Enum.uniq() do
349 update_likes_in_object(likes, object)
350 end
351 end
352
353 def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
354 likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
355
356 with likes <- likes |> List.delete(actor) do
357 update_likes_in_object(likes, object)
358 end
359 end
360
361 #### Follow-related helpers
362
363 @doc """
364 Updates a follow activity's state (for locked accounts).
365 """
366 def update_follow_state_for_all(
367 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
368 state
369 ) do
370 try do
371 Ecto.Adapters.SQL.query!(
372 Repo,
373 "UPDATE activities SET data = jsonb_set(data, '{state}', $1) WHERE data->>'type' = 'Follow' AND data->>'actor' = $2 AND data->>'object' = $3 AND data->>'state' = 'pending'",
374 [state, actor, object]
375 )
376
377 activity = Activity.get_by_id(activity.id)
378 {:ok, activity}
379 rescue
380 e ->
381 {:error, e}
382 end
383 end
384
385 def update_follow_state(%Activity{} = activity, state) do
386 with new_data <-
387 activity.data
388 |> Map.put("state", state),
389 changeset <- Changeset.change(activity, data: new_data),
390 {:ok, activity} <- Repo.update(changeset) do
391 {:ok, activity}
392 end
393 end
394
395 @doc """
396 Makes a follow activity data for the given follower and followed
397 """
398 def make_follow_data(
399 %User{ap_id: follower_id},
400 %User{ap_id: followed_id} = _followed,
401 activity_id
402 ) do
403 data = %{
404 "type" => "Follow",
405 "actor" => follower_id,
406 "to" => [followed_id],
407 "cc" => [Pleroma.Constants.as_public()],
408 "object" => followed_id,
409 "state" => "pending"
410 }
411
412 data = if activity_id, do: Map.put(data, "id", activity_id), else: data
413
414 data
415 end
416
417 def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
418 query =
419 from(
420 activity in Activity,
421 where:
422 fragment(
423 "? ->> 'type' = 'Follow'",
424 activity.data
425 ),
426 where: activity.actor == ^follower_id,
427 # this is to use the index
428 where:
429 fragment(
430 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
431 activity.data,
432 activity.data,
433 ^followed_id
434 ),
435 order_by: [fragment("? desc nulls last", activity.id)],
436 limit: 1
437 )
438
439 Repo.one(query)
440 end
441
442 #### Announce-related helpers
443
444 @doc """
445 Retruns an existing announce activity if the notice has already been announced
446 """
447 def get_existing_announce(actor, %{data: %{"id" => id}}) do
448 query =
449 from(
450 activity in Activity,
451 where: activity.actor == ^actor,
452 # this is to use the index
453 where:
454 fragment(
455 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
456 activity.data,
457 activity.data,
458 ^id
459 ),
460 where: fragment("(?)->>'type' = 'Announce'", activity.data)
461 )
462
463 Repo.one(query)
464 end
465
466 @doc """
467 Make announce activity data for the given actor and object
468 """
469 # for relayed messages, we only want to send to subscribers
470 def make_announce_data(
471 %User{ap_id: ap_id} = user,
472 %Object{data: %{"id" => id}} = object,
473 activity_id,
474 false
475 ) do
476 data = %{
477 "type" => "Announce",
478 "actor" => ap_id,
479 "object" => id,
480 "to" => [user.follower_address],
481 "cc" => [],
482 "context" => object.data["context"]
483 }
484
485 if activity_id, do: Map.put(data, "id", activity_id), else: data
486 end
487
488 def make_announce_data(
489 %User{ap_id: ap_id} = user,
490 %Object{data: %{"id" => id}} = object,
491 activity_id,
492 true
493 ) do
494 data = %{
495 "type" => "Announce",
496 "actor" => ap_id,
497 "object" => id,
498 "to" => [user.follower_address, object.data["actor"]],
499 "cc" => [Pleroma.Constants.as_public()],
500 "context" => object.data["context"]
501 }
502
503 if activity_id, do: Map.put(data, "id", activity_id), else: data
504 end
505
506 @doc """
507 Make unannounce activity data for the given actor and object
508 """
509 def make_unannounce_data(
510 %User{ap_id: ap_id} = user,
511 %Activity{data: %{"context" => context}} = activity,
512 activity_id
513 ) do
514 data = %{
515 "type" => "Undo",
516 "actor" => ap_id,
517 "object" => activity.data,
518 "to" => [user.follower_address, activity.data["actor"]],
519 "cc" => [Pleroma.Constants.as_public()],
520 "context" => context
521 }
522
523 if activity_id, do: Map.put(data, "id", activity_id), else: data
524 end
525
526 def make_unlike_data(
527 %User{ap_id: ap_id} = user,
528 %Activity{data: %{"context" => context}} = activity,
529 activity_id
530 ) do
531 data = %{
532 "type" => "Undo",
533 "actor" => ap_id,
534 "object" => activity.data,
535 "to" => [user.follower_address, activity.data["actor"]],
536 "cc" => [Pleroma.Constants.as_public()],
537 "context" => context
538 }
539
540 if activity_id, do: Map.put(data, "id", activity_id), else: data
541 end
542
543 def add_announce_to_object(
544 %Activity{
545 data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}
546 },
547 object
548 ) do
549 announcements =
550 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
551
552 with announcements <- [actor | announcements] |> Enum.uniq() do
553 update_element_in_object("announcement", announcements, object)
554 end
555 end
556
557 def add_announce_to_object(_, object), do: {:ok, object}
558
559 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
560 announcements =
561 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
562
563 with announcements <- announcements |> List.delete(actor) do
564 update_element_in_object("announcement", announcements, object)
565 end
566 end
567
568 #### Unfollow-related helpers
569
570 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
571 data = %{
572 "type" => "Undo",
573 "actor" => follower.ap_id,
574 "to" => [followed.ap_id],
575 "object" => follow_activity.data
576 }
577
578 if activity_id, do: Map.put(data, "id", activity_id), else: data
579 end
580
581 #### Block-related helpers
582 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
583 query =
584 from(
585 activity in Activity,
586 where:
587 fragment(
588 "? ->> 'type' = 'Block'",
589 activity.data
590 ),
591 where: activity.actor == ^blocker_id,
592 # this is to use the index
593 where:
594 fragment(
595 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
596 activity.data,
597 activity.data,
598 ^blocked_id
599 ),
600 order_by: [fragment("? desc nulls last", activity.id)],
601 limit: 1
602 )
603
604 Repo.one(query)
605 end
606
607 def make_block_data(blocker, blocked, activity_id) do
608 data = %{
609 "type" => "Block",
610 "actor" => blocker.ap_id,
611 "to" => [blocked.ap_id],
612 "object" => blocked.ap_id
613 }
614
615 if activity_id, do: Map.put(data, "id", activity_id), else: data
616 end
617
618 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
619 data = %{
620 "type" => "Undo",
621 "actor" => blocker.ap_id,
622 "to" => [blocked.ap_id],
623 "object" => block_activity.data
624 }
625
626 if activity_id, do: Map.put(data, "id", activity_id), else: data
627 end
628
629 #### Create-related helpers
630
631 def make_create_data(params, additional) do
632 published = params.published || make_date()
633
634 %{
635 "type" => "Create",
636 "to" => params.to |> Enum.uniq(),
637 "actor" => params.actor.ap_id,
638 "object" => params.object,
639 "published" => published,
640 "context" => params.context
641 }
642 |> Map.merge(additional)
643 end
644
645 #### Flag-related helpers
646
647 def make_flag_data(params, additional) do
648 status_ap_ids =
649 Enum.map(params.statuses || [], fn
650 %Activity{} = act -> act.data["id"]
651 act when is_map(act) -> act["id"]
652 act when is_binary(act) -> act
653 end)
654
655 object = [params.account.ap_id] ++ status_ap_ids
656
657 %{
658 "type" => "Flag",
659 "actor" => params.actor.ap_id,
660 "content" => params.content,
661 "object" => object,
662 "context" => params.context,
663 "state" => "open"
664 }
665 |> Map.merge(additional)
666 end
667
668 @doc """
669 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
670 the first one to `pages_left` pages.
671 If the amount of pages is higher than the collection has, it returns whatever was there.
672 """
673 def fetch_ordered_collection(from, pages_left, acc \\ []) do
674 with {:ok, response} <- Tesla.get(from),
675 {:ok, collection} <- Jason.decode(response.body) do
676 case collection["type"] do
677 "OrderedCollection" ->
678 # If we've encountered the OrderedCollection and not the page,
679 # just call the same function on the page address
680 fetch_ordered_collection(collection["first"], pages_left)
681
682 "OrderedCollectionPage" ->
683 if pages_left > 0 do
684 # There are still more pages
685 if Map.has_key?(collection, "next") do
686 # There are still more pages, go deeper saving what we have into the accumulator
687 fetch_ordered_collection(
688 collection["next"],
689 pages_left - 1,
690 acc ++ collection["orderedItems"]
691 )
692 else
693 # No more pages left, just return whatever we already have
694 acc ++ collection["orderedItems"]
695 end
696 else
697 # Got the amount of pages needed, add them all to the accumulator
698 acc ++ collection["orderedItems"]
699 end
700
701 _ ->
702 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
703 end
704 end
705 end
706
707 #### Report-related helpers
708
709 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
710 with new_data <- Map.put(activity.data, "state", state),
711 changeset <- Changeset.change(activity, data: new_data),
712 {:ok, activity} <- Repo.update(changeset) do
713 {:ok, activity}
714 end
715 end
716
717 def update_report_state(_, _), do: {:error, "Unsupported state"}
718
719 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
720 [to, cc, recipients] =
721 activity
722 |> get_updated_targets(visibility)
723 |> Enum.map(&Enum.uniq/1)
724
725 object_data =
726 activity.object.data
727 |> Map.put("to", to)
728 |> Map.put("cc", cc)
729
730 {:ok, object} =
731 activity.object
732 |> Object.change(%{data: object_data})
733 |> Object.update_and_set_cache()
734
735 activity_data =
736 activity.data
737 |> Map.put("to", to)
738 |> Map.put("cc", cc)
739
740 activity
741 |> Map.put(:object, object)
742 |> Activity.change(%{data: activity_data, recipients: recipients})
743 |> Repo.update()
744 end
745
746 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
747
748 defp get_updated_targets(
749 %Activity{data: %{"to" => to} = data, recipients: recipients},
750 visibility
751 ) do
752 cc = Map.get(data, "cc", [])
753 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
754 public = Pleroma.Constants.as_public()
755
756 case visibility do
757 "public" ->
758 to = [public | List.delete(to, follower_address)]
759 cc = [follower_address | List.delete(cc, public)]
760 recipients = [public | recipients]
761 [to, cc, recipients]
762
763 "private" ->
764 to = [follower_address | List.delete(to, public)]
765 cc = List.delete(cc, public)
766 recipients = List.delete(recipients, public)
767 [to, cc, recipients]
768
769 "unlisted" ->
770 to = [follower_address | List.delete(to, public)]
771 cc = [public | List.delete(cc, follower_address)]
772 recipients = recipients ++ [follower_address, public]
773 [to, cc, recipients]
774
775 _ ->
776 [to, cc, recipients]
777 end
778 end
779
780 def get_existing_votes(actor, %{data: %{"id" => id}}) do
781 query =
782 from(
783 [activity, object: object] in Activity.with_preloaded_object(Activity),
784 where: fragment("(?)->>'type' = 'Create'", activity.data),
785 where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
786 where:
787 fragment(
788 "(?)->>'inReplyTo' = ?",
789 object.data,
790 ^to_string(id)
791 ),
792 where: fragment("(?)->>'type' = 'Answer'", object.data)
793 )
794
795 Repo.all(query)
796 end
797 end