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