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