[#1149] Merge remote-tracking branch 'remotes/upstream/develop' into 1149-oban-job...
[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 Pleroma.Web.Federator.publish(activity)
172 end
173
174 :ok
175 end
176
177 def maybe_federate(_), do: :ok
178
179 @doc """
180 Adds an id and a published data if they aren't there,
181 also adds it to an included object
182 """
183 def lazy_put_activity_defaults(map, fake \\ false) do
184 map =
185 unless fake do
186 %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
187
188 map
189 |> Map.put_new_lazy("id", &generate_activity_id/0)
190 |> Map.put_new_lazy("published", &make_date/0)
191 |> Map.put_new("context", context)
192 |> Map.put_new("context_id", context_id)
193 else
194 map
195 |> Map.put_new("id", "pleroma:fakeid")
196 |> Map.put_new_lazy("published", &make_date/0)
197 |> Map.put_new("context", "pleroma:fakecontext")
198 |> Map.put_new("context_id", -1)
199 end
200
201 if is_map(map["object"]) do
202 object = lazy_put_object_defaults(map["object"], map, fake)
203 %{map | "object" => object}
204 else
205 map
206 end
207 end
208
209 @doc """
210 Adds an id and published date if they aren't there.
211 """
212 def lazy_put_object_defaults(map, activity \\ %{}, fake)
213
214 def lazy_put_object_defaults(map, activity, true = _fake) do
215 map
216 |> Map.put_new_lazy("published", &make_date/0)
217 |> Map.put_new("id", "pleroma:fake_object_id")
218 |> Map.put_new("context", activity["context"])
219 |> Map.put_new("fake", true)
220 |> Map.put_new("context_id", activity["context_id"])
221 end
222
223 def lazy_put_object_defaults(map, activity, _fake) do
224 map
225 |> Map.put_new_lazy("id", &generate_object_id/0)
226 |> Map.put_new_lazy("published", &make_date/0)
227 |> Map.put_new("context", activity["context"])
228 |> Map.put_new("context_id", activity["context_id"])
229 end
230
231 @doc """
232 Inserts a full object if it is contained in an activity.
233 """
234 def insert_full_object(%{"object" => %{"type" => type} = object_data} = map)
235 when is_map(object_data) and type in @supported_object_types do
236 with {:ok, object} <- Object.create(object_data) do
237 map =
238 map
239 |> Map.put("object", object.data["id"])
240
241 {:ok, map, object}
242 end
243 end
244
245 def insert_full_object(map), do: {:ok, map, nil}
246
247 #### Like-related helpers
248
249 @doc """
250 Returns an existing like if a user already liked an object
251 """
252 def get_existing_like(actor, %{data: %{"id" => id}}) do
253 query =
254 from(
255 activity in Activity,
256 where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
257 # this is to use the index
258 where:
259 fragment(
260 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
261 activity.data,
262 activity.data,
263 ^id
264 ),
265 where: fragment("(?)->>'type' = 'Like'", activity.data)
266 )
267
268 Repo.one(query)
269 end
270
271 @doc """
272 Returns like activities targeting an object
273 """
274 def get_object_likes(%{data: %{"id" => id}}) do
275 query =
276 from(
277 activity in Activity,
278 # this is to use the index
279 where:
280 fragment(
281 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
282 activity.data,
283 activity.data,
284 ^id
285 ),
286 where: fragment("(?)->>'type' = 'Like'", activity.data)
287 )
288
289 Repo.all(query)
290 end
291
292 def make_like_data(
293 %User{ap_id: ap_id} = actor,
294 %{data: %{"actor" => object_actor_id, "id" => id}} = object,
295 activity_id
296 ) do
297 object_actor = User.get_cached_by_ap_id(object_actor_id)
298
299 to =
300 if Visibility.is_public?(object) do
301 [actor.follower_address, object.data["actor"]]
302 else
303 [object.data["actor"]]
304 end
305
306 cc =
307 (object.data["to"] ++ (object.data["cc"] || []))
308 |> List.delete(actor.ap_id)
309 |> List.delete(object_actor.follower_address)
310
311 data = %{
312 "type" => "Like",
313 "actor" => ap_id,
314 "object" => id,
315 "to" => to,
316 "cc" => cc,
317 "context" => object.data["context"]
318 }
319
320 if activity_id, do: Map.put(data, "id", activity_id), else: data
321 end
322
323 def update_element_in_object(property, element, object) do
324 with new_data <-
325 object.data
326 |> Map.put("#{property}_count", length(element))
327 |> Map.put("#{property}s", element),
328 changeset <- Changeset.change(object, data: new_data),
329 {:ok, object} <- Object.update_and_set_cache(changeset) do
330 {:ok, object}
331 end
332 end
333
334 def update_likes_in_object(likes, object) do
335 update_element_in_object("like", likes, object)
336 end
337
338 def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
339 likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
340
341 with likes <- [actor | likes] |> Enum.uniq() do
342 update_likes_in_object(likes, object)
343 end
344 end
345
346 def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
347 likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
348
349 with likes <- likes |> List.delete(actor) do
350 update_likes_in_object(likes, object)
351 end
352 end
353
354 #### Follow-related helpers
355
356 @doc """
357 Updates a follow activity's state (for locked accounts).
358 """
359 def update_follow_state_for_all(
360 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
361 state
362 ) do
363 try do
364 Ecto.Adapters.SQL.query!(
365 Repo,
366 "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'",
367 [state, actor, object]
368 )
369
370 User.set_follow_state_cache(actor, object, state)
371 activity = Activity.get_by_id(activity.id)
372 {:ok, activity}
373 rescue
374 e ->
375 {:error, e}
376 end
377 end
378
379 def update_follow_state(
380 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
381 state
382 ) do
383 with new_data <-
384 activity.data
385 |> Map.put("state", state),
386 changeset <- Changeset.change(activity, data: new_data),
387 {:ok, activity} <- Repo.update(changeset),
388 _ <- User.set_follow_state_cache(actor, object, state) do
389 {:ok, activity}
390 end
391 end
392
393 @doc """
394 Makes a follow activity data for the given follower and followed
395 """
396 def make_follow_data(
397 %User{ap_id: follower_id},
398 %User{ap_id: followed_id} = _followed,
399 activity_id
400 ) do
401 data = %{
402 "type" => "Follow",
403 "actor" => follower_id,
404 "to" => [followed_id],
405 "cc" => [Pleroma.Constants.as_public()],
406 "object" => followed_id,
407 "state" => "pending"
408 }
409
410 data = if activity_id, do: Map.put(data, "id", activity_id), else: data
411
412 data
413 end
414
415 def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
416 query =
417 from(
418 activity in Activity,
419 where:
420 fragment(
421 "? ->> 'type' = 'Follow'",
422 activity.data
423 ),
424 where: activity.actor == ^follower_id,
425 # this is to use the index
426 where:
427 fragment(
428 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
429 activity.data,
430 activity.data,
431 ^followed_id
432 ),
433 order_by: [fragment("? desc nulls last", activity.id)],
434 limit: 1
435 )
436
437 Repo.one(query)
438 end
439
440 #### Announce-related helpers
441
442 @doc """
443 Retruns an existing announce activity if the notice has already been announced
444 """
445 def get_existing_announce(actor, %{data: %{"id" => id}}) do
446 query =
447 from(
448 activity in Activity,
449 where: activity.actor == ^actor,
450 # this is to use the index
451 where:
452 fragment(
453 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
454 activity.data,
455 activity.data,
456 ^id
457 ),
458 where: fragment("(?)->>'type' = 'Announce'", activity.data)
459 )
460
461 Repo.one(query)
462 end
463
464 @doc """
465 Make announce activity data for the given actor and object
466 """
467 # for relayed messages, we only want to send to subscribers
468 def make_announce_data(
469 %User{ap_id: ap_id} = user,
470 %Object{data: %{"id" => id}} = object,
471 activity_id,
472 false
473 ) do
474 data = %{
475 "type" => "Announce",
476 "actor" => ap_id,
477 "object" => id,
478 "to" => [user.follower_address],
479 "cc" => [],
480 "context" => object.data["context"]
481 }
482
483 if activity_id, do: Map.put(data, "id", activity_id), else: data
484 end
485
486 def make_announce_data(
487 %User{ap_id: ap_id} = user,
488 %Object{data: %{"id" => id}} = object,
489 activity_id,
490 true
491 ) do
492 data = %{
493 "type" => "Announce",
494 "actor" => ap_id,
495 "object" => id,
496 "to" => [user.follower_address, object.data["actor"]],
497 "cc" => [Pleroma.Constants.as_public()],
498 "context" => object.data["context"]
499 }
500
501 if activity_id, do: Map.put(data, "id", activity_id), else: data
502 end
503
504 @doc """
505 Make unannounce activity data for the given actor and object
506 """
507 def make_unannounce_data(
508 %User{ap_id: ap_id} = user,
509 %Activity{data: %{"context" => context}} = activity,
510 activity_id
511 ) do
512 data = %{
513 "type" => "Undo",
514 "actor" => ap_id,
515 "object" => activity.data,
516 "to" => [user.follower_address, activity.data["actor"]],
517 "cc" => [Pleroma.Constants.as_public()],
518 "context" => context
519 }
520
521 if activity_id, do: Map.put(data, "id", activity_id), else: data
522 end
523
524 def make_unlike_data(
525 %User{ap_id: ap_id} = user,
526 %Activity{data: %{"context" => context}} = activity,
527 activity_id
528 ) do
529 data = %{
530 "type" => "Undo",
531 "actor" => ap_id,
532 "object" => activity.data,
533 "to" => [user.follower_address, activity.data["actor"]],
534 "cc" => [Pleroma.Constants.as_public()],
535 "context" => context
536 }
537
538 if activity_id, do: Map.put(data, "id", activity_id), else: data
539 end
540
541 def add_announce_to_object(
542 %Activity{
543 data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}
544 },
545 object
546 ) do
547 announcements =
548 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
549
550 with announcements <- [actor | announcements] |> Enum.uniq() do
551 update_element_in_object("announcement", announcements, object)
552 end
553 end
554
555 def add_announce_to_object(_, object), do: {:ok, object}
556
557 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
558 announcements =
559 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
560
561 with announcements <- announcements |> List.delete(actor) do
562 update_element_in_object("announcement", announcements, object)
563 end
564 end
565
566 #### Unfollow-related helpers
567
568 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
569 data = %{
570 "type" => "Undo",
571 "actor" => follower.ap_id,
572 "to" => [followed.ap_id],
573 "object" => follow_activity.data
574 }
575
576 if activity_id, do: Map.put(data, "id", activity_id), else: data
577 end
578
579 #### Block-related helpers
580 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
581 query =
582 from(
583 activity in Activity,
584 where:
585 fragment(
586 "? ->> 'type' = 'Block'",
587 activity.data
588 ),
589 where: activity.actor == ^blocker_id,
590 # this is to use the index
591 where:
592 fragment(
593 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
594 activity.data,
595 activity.data,
596 ^blocked_id
597 ),
598 order_by: [fragment("? desc nulls last", activity.id)],
599 limit: 1
600 )
601
602 Repo.one(query)
603 end
604
605 def make_block_data(blocker, blocked, activity_id) do
606 data = %{
607 "type" => "Block",
608 "actor" => blocker.ap_id,
609 "to" => [blocked.ap_id],
610 "object" => blocked.ap_id
611 }
612
613 if activity_id, do: Map.put(data, "id", activity_id), else: data
614 end
615
616 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
617 data = %{
618 "type" => "Undo",
619 "actor" => blocker.ap_id,
620 "to" => [blocked.ap_id],
621 "object" => block_activity.data
622 }
623
624 if activity_id, do: Map.put(data, "id", activity_id), else: data
625 end
626
627 #### Create-related helpers
628
629 def make_create_data(params, additional) do
630 published = params.published || make_date()
631
632 %{
633 "type" => "Create",
634 "to" => params.to |> Enum.uniq(),
635 "actor" => params.actor.ap_id,
636 "object" => params.object,
637 "published" => published,
638 "context" => params.context
639 }
640 |> Map.merge(additional)
641 end
642
643 #### Flag-related helpers
644
645 def make_flag_data(params, additional) do
646 status_ap_ids =
647 Enum.map(params.statuses || [], fn
648 %Activity{} = act -> act.data["id"]
649 act when is_map(act) -> act["id"]
650 act when is_binary(act) -> act
651 end)
652
653 object = [params.account.ap_id] ++ status_ap_ids
654
655 %{
656 "type" => "Flag",
657 "actor" => params.actor.ap_id,
658 "content" => params.content,
659 "object" => object,
660 "context" => params.context,
661 "state" => "open"
662 }
663 |> Map.merge(additional)
664 end
665
666 @doc """
667 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
668 the first one to `pages_left` pages.
669 If the amount of pages is higher than the collection has, it returns whatever was there.
670 """
671 def fetch_ordered_collection(from, pages_left, acc \\ []) do
672 with {:ok, response} <- Tesla.get(from),
673 {:ok, collection} <- Jason.decode(response.body) do
674 case collection["type"] do
675 "OrderedCollection" ->
676 # If we've encountered the OrderedCollection and not the page,
677 # just call the same function on the page address
678 fetch_ordered_collection(collection["first"], pages_left)
679
680 "OrderedCollectionPage" ->
681 if pages_left > 0 do
682 # There are still more pages
683 if Map.has_key?(collection, "next") do
684 # There are still more pages, go deeper saving what we have into the accumulator
685 fetch_ordered_collection(
686 collection["next"],
687 pages_left - 1,
688 acc ++ collection["orderedItems"]
689 )
690 else
691 # No more pages left, just return whatever we already have
692 acc ++ collection["orderedItems"]
693 end
694 else
695 # Got the amount of pages needed, add them all to the accumulator
696 acc ++ collection["orderedItems"]
697 end
698
699 _ ->
700 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
701 end
702 end
703 end
704
705 #### Report-related helpers
706
707 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
708 with new_data <- Map.put(activity.data, "state", state),
709 changeset <- Changeset.change(activity, data: new_data),
710 {:ok, activity} <- Repo.update(changeset) do
711 {:ok, activity}
712 end
713 end
714
715 def update_report_state(_, _), do: {:error, "Unsupported state"}
716
717 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
718 [to, cc, recipients] =
719 activity
720 |> get_updated_targets(visibility)
721 |> Enum.map(&Enum.uniq/1)
722
723 object_data =
724 activity.object.data
725 |> Map.put("to", to)
726 |> Map.put("cc", cc)
727
728 {:ok, object} =
729 activity.object
730 |> Object.change(%{data: object_data})
731 |> Object.update_and_set_cache()
732
733 activity_data =
734 activity.data
735 |> Map.put("to", to)
736 |> Map.put("cc", cc)
737
738 activity
739 |> Map.put(:object, object)
740 |> Activity.change(%{data: activity_data, recipients: recipients})
741 |> Repo.update()
742 end
743
744 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
745
746 defp get_updated_targets(
747 %Activity{data: %{"to" => to} = data, recipients: recipients},
748 visibility
749 ) do
750 cc = Map.get(data, "cc", [])
751 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
752 public = Pleroma.Constants.as_public()
753
754 case visibility do
755 "public" ->
756 to = [public | List.delete(to, follower_address)]
757 cc = [follower_address | List.delete(cc, public)]
758 recipients = [public | recipients]
759 [to, cc, recipients]
760
761 "private" ->
762 to = [follower_address | List.delete(to, public)]
763 cc = List.delete(cc, public)
764 recipients = List.delete(recipients, public)
765 [to, cc, recipients]
766
767 "unlisted" ->
768 to = [follower_address | List.delete(to, public)]
769 cc = [public | List.delete(cc, follower_address)]
770 recipients = recipients ++ [follower_address, public]
771 [to, cc, recipients]
772
773 _ ->
774 [to, cc, recipients]
775 end
776 end
777
778 def get_existing_votes(actor, %{data: %{"id" => id}}) do
779 query =
780 from(
781 [activity, object: object] in Activity.with_preloaded_object(Activity),
782 where: fragment("(?)->>'type' = 'Create'", activity.data),
783 where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
784 where:
785 fragment(
786 "(?)->>'inReplyTo' = ?",
787 object.data,
788 ^to_string(id)
789 ),
790 where: fragment("(?)->>'type' = 'Answer'", object.data)
791 )
792
793 Repo.all(query)
794 end
795 end