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