Merge remote-tracking branch 'origin/develop' into reactions
[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 def is_emoji?(emoji) do
281 String.length(emoji) == 1
282 end
283
284 def make_emoji_reaction_data(user, object, emoji, activity_id) do
285 make_like_data(user, object, activity_id)
286 |> Map.put("type", "EmojiReaction")
287 |> Map.put("content", emoji)
288 end
289
290 @spec make_like_data(User.t(), map(), String.t()) :: map()
291 def make_like_data(
292 %User{ap_id: ap_id} = actor,
293 %{data: %{"actor" => object_actor_id, "id" => id}} = object,
294 activity_id
295 ) do
296 object_actor = User.get_cached_by_ap_id(object_actor_id)
297
298 to =
299 if Visibility.is_public?(object) do
300 [actor.follower_address, object.data["actor"]]
301 else
302 [object.data["actor"]]
303 end
304
305 cc =
306 (object.data["to"] ++ (object.data["cc"] || []))
307 |> List.delete(actor.ap_id)
308 |> List.delete(object_actor.follower_address)
309
310 %{
311 "type" => "Like",
312 "actor" => ap_id,
313 "object" => id,
314 "to" => to,
315 "cc" => cc,
316 "context" => object.data["context"]
317 }
318 |> maybe_put("id", activity_id)
319 end
320
321 @spec update_element_in_object(String.t(), list(any), Object.t()) ::
322 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
323 def update_element_in_object(property, element, object) do
324 data =
325 Map.merge(
326 object.data,
327 %{"#{property}_count" => length(element), "#{property}s" => element}
328 )
329
330 object
331 |> Changeset.change(data: data)
332 |> Object.update_and_set_cache()
333 end
334
335 @spec add_like_to_object(Activity.t(), Object.t()) ::
336 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
337 def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
338 [actor | fetch_likes(object)]
339 |> Enum.uniq()
340 |> update_likes_in_object(object)
341 end
342
343 @spec remove_like_from_object(Activity.t(), Object.t()) ::
344 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
345 def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
346 object
347 |> fetch_likes()
348 |> List.delete(actor)
349 |> update_likes_in_object(object)
350 end
351
352 defp update_likes_in_object(likes, object) do
353 update_element_in_object("like", likes, object)
354 end
355
356 defp fetch_likes(object) do
357 if is_list(object.data["likes"]) do
358 object.data["likes"]
359 else
360 []
361 end
362 end
363
364 #### Follow-related helpers
365
366 @doc """
367 Updates a follow activity's state (for locked accounts).
368 """
369 def update_follow_state_for_all(
370 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
371 state
372 ) do
373 try do
374 Ecto.Adapters.SQL.query!(
375 Repo,
376 "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'",
377 [state, actor, object]
378 )
379
380 User.set_follow_state_cache(actor, object, state)
381 activity = Activity.get_by_id(activity.id)
382 {:ok, activity}
383 rescue
384 e ->
385 {:error, e}
386 end
387 end
388
389 def update_follow_state(
390 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
391 state
392 ) do
393 with new_data <-
394 activity.data
395 |> Map.put("state", state),
396 changeset <- Changeset.change(activity, data: new_data),
397 {:ok, activity} <- Repo.update(changeset),
398 _ <- User.set_follow_state_cache(actor, object, state) do
399 {:ok, activity}
400 end
401 end
402
403 @doc """
404 Makes a follow activity data for the given follower and followed
405 """
406 def make_follow_data(
407 %User{ap_id: follower_id},
408 %User{ap_id: followed_id} = _followed,
409 activity_id
410 ) do
411 %{
412 "type" => "Follow",
413 "actor" => follower_id,
414 "to" => [followed_id],
415 "cc" => [Pleroma.Constants.as_public()],
416 "object" => followed_id,
417 "state" => "pending"
418 }
419 |> maybe_put("id", activity_id)
420 end
421
422 def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
423 query =
424 from(
425 activity in Activity,
426 where:
427 fragment(
428 "? ->> 'type' = 'Follow'",
429 activity.data
430 ),
431 where: activity.actor == ^follower_id,
432 # this is to use the index
433 where:
434 fragment(
435 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
436 activity.data,
437 activity.data,
438 ^followed_id
439 ),
440 order_by: [fragment("? desc nulls last", activity.id)],
441 limit: 1
442 )
443
444 Repo.one(query)
445 end
446
447 #### Announce-related helpers
448
449 @doc """
450 Retruns an existing announce activity if the notice has already been announced
451 """
452 def get_existing_announce(actor, %{data: %{"id" => id}}) do
453 query =
454 from(
455 activity in Activity,
456 where: activity.actor == ^actor,
457 # this is to use the index
458 where:
459 fragment(
460 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
461 activity.data,
462 activity.data,
463 ^id
464 ),
465 where: fragment("(?)->>'type' = 'Announce'", activity.data)
466 )
467
468 Repo.one(query)
469 end
470
471 @doc """
472 Make announce activity data for the given actor and object
473 """
474 # for relayed messages, we only want to send to subscribers
475 def make_announce_data(
476 %User{ap_id: ap_id} = user,
477 %Object{data: %{"id" => id}} = object,
478 activity_id,
479 false
480 ) do
481 %{
482 "type" => "Announce",
483 "actor" => ap_id,
484 "object" => id,
485 "to" => [user.follower_address],
486 "cc" => [],
487 "context" => object.data["context"]
488 }
489 |> maybe_put("id", activity_id)
490 end
491
492 def make_announce_data(
493 %User{ap_id: ap_id} = user,
494 %Object{data: %{"id" => id}} = object,
495 activity_id,
496 true
497 ) do
498 %{
499 "type" => "Announce",
500 "actor" => ap_id,
501 "object" => id,
502 "to" => [user.follower_address, object.data["actor"]],
503 "cc" => [Pleroma.Constants.as_public()],
504 "context" => object.data["context"]
505 }
506 |> maybe_put("id", activity_id)
507 end
508
509 @doc """
510 Make unannounce activity data for the given actor and object
511 """
512 def make_unannounce_data(
513 %User{ap_id: ap_id} = user,
514 %Activity{data: %{"context" => context}} = activity,
515 activity_id
516 ) do
517 %{
518 "type" => "Undo",
519 "actor" => ap_id,
520 "object" => activity.data,
521 "to" => [user.follower_address, activity.data["actor"]],
522 "cc" => [Pleroma.Constants.as_public()],
523 "context" => context
524 }
525 |> maybe_put("id", activity_id)
526 end
527
528 def make_unlike_data(
529 %User{ap_id: ap_id} = user,
530 %Activity{data: %{"context" => context}} = activity,
531 activity_id
532 ) do
533 %{
534 "type" => "Undo",
535 "actor" => ap_id,
536 "object" => activity.data,
537 "to" => [user.follower_address, activity.data["actor"]],
538 "cc" => [Pleroma.Constants.as_public()],
539 "context" => context
540 }
541 |> maybe_put("id", activity_id)
542 end
543
544 def add_announce_to_object(
545 %Activity{
546 data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}
547 },
548 object
549 ) do
550 announcements =
551 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
552
553 with announcements <- [actor | announcements] |> Enum.uniq() do
554 update_element_in_object("announcement", announcements, object)
555 end
556 end
557
558 def add_announce_to_object(_, object), do: {:ok, object}
559
560 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
561 announcements =
562 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
563
564 with announcements <- announcements |> List.delete(actor) do
565 update_element_in_object("announcement", announcements, object)
566 end
567 end
568
569 #### Unfollow-related helpers
570
571 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
572 %{
573 "type" => "Undo",
574 "actor" => follower.ap_id,
575 "to" => [followed.ap_id],
576 "object" => follow_activity.data
577 }
578 |> maybe_put("id", activity_id)
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 %{
609 "type" => "Block",
610 "actor" => blocker.ap_id,
611 "to" => [blocked.ap_id],
612 "object" => blocked.ap_id
613 }
614 |> maybe_put("id", activity_id)
615 end
616
617 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
618 %{
619 "type" => "Undo",
620 "actor" => blocker.ap_id,
621 "to" => [blocked.ap_id],
622 "object" => block_activity.data
623 }
624 |> maybe_put("id", activity_id)
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
796 defp maybe_put(map, _key, nil), do: map
797 defp maybe_put(map, key, value), do: Map.put(map, key, value)
798 end