Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma into develop
[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 User.set_follow_state_cache(actor, object, state)
378 activity = Activity.get_by_id(activity.id)
379 {:ok, activity}
380 rescue
381 e ->
382 {:error, e}
383 end
384 end
385
386 def update_follow_state(
387 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
388 state
389 ) do
390 with new_data <-
391 activity.data
392 |> Map.put("state", state),
393 changeset <- Changeset.change(activity, data: new_data),
394 {:ok, activity} <- Repo.update(changeset),
395 _ <- User.set_follow_state_cache(actor, object, state) do
396 {:ok, activity}
397 end
398 end
399
400 @doc """
401 Makes a follow activity data for the given follower and followed
402 """
403 def make_follow_data(
404 %User{ap_id: follower_id},
405 %User{ap_id: followed_id} = _followed,
406 activity_id
407 ) do
408 data = %{
409 "type" => "Follow",
410 "actor" => follower_id,
411 "to" => [followed_id],
412 "cc" => [Pleroma.Constants.as_public()],
413 "object" => followed_id,
414 "state" => "pending"
415 }
416
417 data = if activity_id, do: Map.put(data, "id", activity_id), else: data
418
419 data
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 data = %{
482 "type" => "Announce",
483 "actor" => ap_id,
484 "object" => id,
485 "to" => [user.follower_address],
486 "cc" => [],
487 "context" => object.data["context"]
488 }
489
490 if activity_id, do: Map.put(data, "id", activity_id), else: data
491 end
492
493 def make_announce_data(
494 %User{ap_id: ap_id} = user,
495 %Object{data: %{"id" => id}} = object,
496 activity_id,
497 true
498 ) do
499 data = %{
500 "type" => "Announce",
501 "actor" => ap_id,
502 "object" => id,
503 "to" => [user.follower_address, object.data["actor"]],
504 "cc" => [Pleroma.Constants.as_public()],
505 "context" => object.data["context"]
506 }
507
508 if activity_id, do: Map.put(data, "id", activity_id), else: data
509 end
510
511 @doc """
512 Make unannounce activity data for the given actor and object
513 """
514 def make_unannounce_data(
515 %User{ap_id: ap_id} = user,
516 %Activity{data: %{"context" => context}} = activity,
517 activity_id
518 ) do
519 data = %{
520 "type" => "Undo",
521 "actor" => ap_id,
522 "object" => activity.data,
523 "to" => [user.follower_address, activity.data["actor"]],
524 "cc" => [Pleroma.Constants.as_public()],
525 "context" => context
526 }
527
528 if activity_id, do: Map.put(data, "id", activity_id), else: data
529 end
530
531 def make_unlike_data(
532 %User{ap_id: ap_id} = user,
533 %Activity{data: %{"context" => context}} = activity,
534 activity_id
535 ) do
536 data = %{
537 "type" => "Undo",
538 "actor" => ap_id,
539 "object" => activity.data,
540 "to" => [user.follower_address, activity.data["actor"]],
541 "cc" => [Pleroma.Constants.as_public()],
542 "context" => context
543 }
544
545 if activity_id, do: Map.put(data, "id", activity_id), else: data
546 end
547
548 def add_announce_to_object(
549 %Activity{
550 data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}
551 },
552 object
553 ) do
554 announcements =
555 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
556
557 with announcements <- [actor | announcements] |> Enum.uniq() do
558 update_element_in_object("announcement", announcements, object)
559 end
560 end
561
562 def add_announce_to_object(_, object), do: {:ok, object}
563
564 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
565 announcements =
566 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
567
568 with announcements <- announcements |> List.delete(actor) do
569 update_element_in_object("announcement", announcements, object)
570 end
571 end
572
573 #### Unfollow-related helpers
574
575 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
576 data = %{
577 "type" => "Undo",
578 "actor" => follower.ap_id,
579 "to" => [followed.ap_id],
580 "object" => follow_activity.data
581 }
582
583 if activity_id, do: Map.put(data, "id", activity_id), else: data
584 end
585
586 #### Block-related helpers
587 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
588 query =
589 from(
590 activity in Activity,
591 where:
592 fragment(
593 "? ->> 'type' = 'Block'",
594 activity.data
595 ),
596 where: activity.actor == ^blocker_id,
597 # this is to use the index
598 where:
599 fragment(
600 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
601 activity.data,
602 activity.data,
603 ^blocked_id
604 ),
605 order_by: [fragment("? desc nulls last", activity.id)],
606 limit: 1
607 )
608
609 Repo.one(query)
610 end
611
612 def make_block_data(blocker, blocked, activity_id) do
613 data = %{
614 "type" => "Block",
615 "actor" => blocker.ap_id,
616 "to" => [blocked.ap_id],
617 "object" => blocked.ap_id
618 }
619
620 if activity_id, do: Map.put(data, "id", activity_id), else: data
621 end
622
623 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
624 data = %{
625 "type" => "Undo",
626 "actor" => blocker.ap_id,
627 "to" => [blocked.ap_id],
628 "object" => block_activity.data
629 }
630
631 if activity_id, do: Map.put(data, "id", activity_id), else: data
632 end
633
634 #### Create-related helpers
635
636 def make_create_data(params, additional) do
637 published = params.published || make_date()
638
639 %{
640 "type" => "Create",
641 "to" => params.to |> Enum.uniq(),
642 "actor" => params.actor.ap_id,
643 "object" => params.object,
644 "published" => published,
645 "context" => params.context
646 }
647 |> Map.merge(additional)
648 end
649
650 #### Flag-related helpers
651
652 def make_flag_data(params, additional) do
653 status_ap_ids =
654 Enum.map(params.statuses || [], fn
655 %Activity{} = act -> act.data["id"]
656 act when is_map(act) -> act["id"]
657 act when is_binary(act) -> act
658 end)
659
660 object = [params.account.ap_id] ++ status_ap_ids
661
662 %{
663 "type" => "Flag",
664 "actor" => params.actor.ap_id,
665 "content" => params.content,
666 "object" => object,
667 "context" => params.context,
668 "state" => "open"
669 }
670 |> Map.merge(additional)
671 end
672
673 @doc """
674 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
675 the first one to `pages_left` pages.
676 If the amount of pages is higher than the collection has, it returns whatever was there.
677 """
678 def fetch_ordered_collection(from, pages_left, acc \\ []) do
679 with {:ok, response} <- Tesla.get(from),
680 {:ok, collection} <- Jason.decode(response.body) do
681 case collection["type"] do
682 "OrderedCollection" ->
683 # If we've encountered the OrderedCollection and not the page,
684 # just call the same function on the page address
685 fetch_ordered_collection(collection["first"], pages_left)
686
687 "OrderedCollectionPage" ->
688 if pages_left > 0 do
689 # There are still more pages
690 if Map.has_key?(collection, "next") do
691 # There are still more pages, go deeper saving what we have into the accumulator
692 fetch_ordered_collection(
693 collection["next"],
694 pages_left - 1,
695 acc ++ collection["orderedItems"]
696 )
697 else
698 # No more pages left, just return whatever we already have
699 acc ++ collection["orderedItems"]
700 end
701 else
702 # Got the amount of pages needed, add them all to the accumulator
703 acc ++ collection["orderedItems"]
704 end
705
706 _ ->
707 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
708 end
709 end
710 end
711
712 #### Report-related helpers
713
714 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
715 with new_data <- Map.put(activity.data, "state", state),
716 changeset <- Changeset.change(activity, data: new_data),
717 {:ok, activity} <- Repo.update(changeset) do
718 {:ok, activity}
719 end
720 end
721
722 def update_report_state(_, _), do: {:error, "Unsupported state"}
723
724 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
725 [to, cc, recipients] =
726 activity
727 |> get_updated_targets(visibility)
728 |> Enum.map(&Enum.uniq/1)
729
730 object_data =
731 activity.object.data
732 |> Map.put("to", to)
733 |> Map.put("cc", cc)
734
735 {:ok, object} =
736 activity.object
737 |> Object.change(%{data: object_data})
738 |> Object.update_and_set_cache()
739
740 activity_data =
741 activity.data
742 |> Map.put("to", to)
743 |> Map.put("cc", cc)
744
745 activity
746 |> Map.put(:object, object)
747 |> Activity.change(%{data: activity_data, recipients: recipients})
748 |> Repo.update()
749 end
750
751 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
752
753 defp get_updated_targets(
754 %Activity{data: %{"to" => to} = data, recipients: recipients},
755 visibility
756 ) do
757 cc = Map.get(data, "cc", [])
758 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
759 public = Pleroma.Constants.as_public()
760
761 case visibility do
762 "public" ->
763 to = [public | List.delete(to, follower_address)]
764 cc = [follower_address | List.delete(cc, public)]
765 recipients = [public | recipients]
766 [to, cc, recipients]
767
768 "private" ->
769 to = [follower_address | List.delete(to, public)]
770 cc = List.delete(cc, public)
771 recipients = List.delete(recipients, public)
772 [to, cc, recipients]
773
774 "unlisted" ->
775 to = [follower_address | List.delete(to, public)]
776 cc = [public | List.delete(cc, follower_address)]
777 recipients = recipients ++ [follower_address, public]
778 [to, cc, recipients]
779
780 _ ->
781 [to, cc, recipients]
782 end
783 end
784
785 def get_existing_votes(actor, %{data: %{"id" => id}}) do
786 query =
787 from(
788 [activity, object: object] in Activity.with_preloaded_object(Activity),
789 where: fragment("(?)->>'type' = 'Create'", activity.data),
790 where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
791 where:
792 fragment(
793 "(?)->>'inReplyTo' = ?",
794 object.data,
795 ^to_string(id)
796 ),
797 where: fragment("(?)->>'type' = 'Answer'", object.data)
798 )
799
800 Repo.all(query)
801 end
802 end