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