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