fix formatting
[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 "Follow"
360 |> Activity.Queries.by_type()
361 |> Activity.Queries.by_actor(actor)
362 |> Activity.Queries.by_object_id(object)
363 |> where(fragment("data->>'state' = 'pending'"))
364 |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)])
365 |> Repo.update_all([])
366
367 User.set_follow_state_cache(actor, object, state)
368
369 activity = Activity.get_by_id(activity.id)
370
371 {:ok, activity}
372 end
373
374 def update_follow_state(
375 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
376 state
377 ) do
378 with new_data <- Map.put(activity.data, "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 follower_id
408 |> Activity.Queries.by_actor()
409 |> Activity.Queries.by_type("Follow")
410 |> Activity.Queries.by_object_id(followed_id)
411 |> Activity.Queries.limit(1)
412
413 from(
414 activity in query,
415 order_by: [fragment("? desc nulls last", activity.id)]
416 )
417 |> Repo.one()
418 end
419
420 #### Announce-related helpers
421
422 @doc """
423 Retruns an existing announce activity if the notice has already been announced
424 """
425 @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil
426 def get_existing_announce(actor, %{data: %{"id" => id}}) do
427 actor
428 |> Activity.Queries.by_actor()
429 |> Activity.Queries.by_type("Announce")
430 |> Activity.Queries.by_object_id(id)
431 |> Activity.Queries.limit(1)
432 |> Repo.one()
433 end
434
435 @doc """
436 Make announce activity data for the given actor and object
437 """
438 # for relayed messages, we only want to send to subscribers
439 def make_announce_data(
440 %User{ap_id: ap_id} = user,
441 %Object{data: %{"id" => id}} = object,
442 activity_id,
443 false
444 ) do
445 %{
446 "type" => "Announce",
447 "actor" => ap_id,
448 "object" => id,
449 "to" => [user.follower_address],
450 "cc" => [],
451 "context" => object.data["context"]
452 }
453 |> maybe_put("id", activity_id)
454 end
455
456 def make_announce_data(
457 %User{ap_id: ap_id} = user,
458 %Object{data: %{"id" => id}} = object,
459 activity_id,
460 true
461 ) do
462 %{
463 "type" => "Announce",
464 "actor" => ap_id,
465 "object" => id,
466 "to" => [user.follower_address, object.data["actor"]],
467 "cc" => [Pleroma.Constants.as_public()],
468 "context" => object.data["context"]
469 }
470 |> maybe_put("id", activity_id)
471 end
472
473 @doc """
474 Make unannounce activity data for the given actor and object
475 """
476 def make_unannounce_data(
477 %User{ap_id: ap_id} = user,
478 %Activity{data: %{"context" => context}} = activity,
479 activity_id
480 ) do
481 %{
482 "type" => "Undo",
483 "actor" => ap_id,
484 "object" => activity.data,
485 "to" => [user.follower_address, activity.data["actor"]],
486 "cc" => [Pleroma.Constants.as_public()],
487 "context" => context
488 }
489 |> maybe_put("id", activity_id)
490 end
491
492 def make_unlike_data(
493 %User{ap_id: ap_id} = user,
494 %Activity{data: %{"context" => context}} = activity,
495 activity_id
496 ) do
497 %{
498 "type" => "Undo",
499 "actor" => ap_id,
500 "object" => activity.data,
501 "to" => [user.follower_address, activity.data["actor"]],
502 "cc" => [Pleroma.Constants.as_public()],
503 "context" => context
504 }
505 |> maybe_put("id", activity_id)
506 end
507
508 @spec add_announce_to_object(Activity.t(), Object.t()) ::
509 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
510 def add_announce_to_object(
511 %Activity{data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}},
512 object
513 ) do
514 announcements = take_announcements(object)
515
516 with announcements <- Enum.uniq([actor | announcements]) do
517 update_element_in_object("announcement", announcements, object)
518 end
519 end
520
521 def add_announce_to_object(_, object), do: {:ok, object}
522
523 @spec remove_announce_from_object(Activity.t(), Object.t()) ::
524 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
525 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
526 with announcements <- List.delete(take_announcements(object), actor) do
527 update_element_in_object("announcement", announcements, object)
528 end
529 end
530
531 defp take_announcements(%{data: %{"announcements" => announcements}} = _)
532 when is_list(announcements),
533 do: announcements
534
535 defp take_announcements(_), do: []
536
537 #### Unfollow-related helpers
538
539 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
540 %{
541 "type" => "Undo",
542 "actor" => follower.ap_id,
543 "to" => [followed.ap_id],
544 "object" => follow_activity.data
545 }
546 |> maybe_put("id", activity_id)
547 end
548
549 #### Block-related helpers
550 @spec fetch_latest_block(User.t(), User.t()) :: Activity.t() | nil
551 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
552 query =
553 blocker_id
554 |> Activity.Queries.by_actor()
555 |> Activity.Queries.by_type("Block")
556 |> Activity.Queries.by_object_id(blocked_id)
557 |> Activity.Queries.limit(1)
558
559 from(
560 activity in query,
561 order_by: [fragment("? desc nulls last", activity.id)]
562 )
563 |> Repo.one()
564 end
565
566 def make_block_data(blocker, blocked, activity_id) do
567 %{
568 "type" => "Block",
569 "actor" => blocker.ap_id,
570 "to" => [blocked.ap_id],
571 "object" => blocked.ap_id
572 }
573 |> maybe_put("id", activity_id)
574 end
575
576 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
577 %{
578 "type" => "Undo",
579 "actor" => blocker.ap_id,
580 "to" => [blocked.ap_id],
581 "object" => block_activity.data
582 }
583 |> maybe_put("id", activity_id)
584 end
585
586 #### Create-related helpers
587
588 def make_create_data(params, additional) do
589 published = params.published || make_date()
590
591 %{
592 "type" => "Create",
593 "to" => params.to |> Enum.uniq(),
594 "actor" => params.actor.ap_id,
595 "object" => params.object,
596 "published" => published,
597 "context" => params.context
598 }
599 |> Map.merge(additional)
600 end
601
602 #### Flag-related helpers
603 @spec make_flag_data(map(), map()) :: map()
604 def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do
605 %{
606 "type" => "Flag",
607 "actor" => actor.ap_id,
608 "content" => content,
609 "object" => build_flag_object(params),
610 "context" => context,
611 "state" => "open"
612 }
613 |> Map.merge(additional)
614 end
615
616 def make_flag_data(_, _), do: %{}
617
618 defp build_flag_object(%{account: account, statuses: statuses} = _) do
619 [account.ap_id] ++
620 Enum.map(statuses || [], fn
621 %Activity{} = act -> act.data["id"]
622 act when is_map(act) -> act["id"]
623 act when is_binary(act) -> act
624 end)
625 end
626
627 defp build_flag_object(_), do: []
628
629 @doc """
630 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
631 the first one to `pages_left` pages.
632 If the amount of pages is higher than the collection has, it returns whatever was there.
633 """
634 def fetch_ordered_collection(from, pages_left, acc \\ []) do
635 with {:ok, response} <- Tesla.get(from),
636 {:ok, collection} <- Jason.decode(response.body) do
637 case collection["type"] do
638 "OrderedCollection" ->
639 # If we've encountered the OrderedCollection and not the page,
640 # just call the same function on the page address
641 fetch_ordered_collection(collection["first"], pages_left)
642
643 "OrderedCollectionPage" ->
644 if pages_left > 0 do
645 # There are still more pages
646 if Map.has_key?(collection, "next") do
647 # There are still more pages, go deeper saving what we have into the accumulator
648 fetch_ordered_collection(
649 collection["next"],
650 pages_left - 1,
651 acc ++ collection["orderedItems"]
652 )
653 else
654 # No more pages left, just return whatever we already have
655 acc ++ collection["orderedItems"]
656 end
657 else
658 # Got the amount of pages needed, add them all to the accumulator
659 acc ++ collection["orderedItems"]
660 end
661
662 _ ->
663 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
664 end
665 end
666 end
667
668 #### Report-related helpers
669
670 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
671 with new_data <- Map.put(activity.data, "state", state),
672 changeset <- Changeset.change(activity, data: new_data),
673 {:ok, activity} <- Repo.update(changeset) do
674 {:ok, activity}
675 end
676 end
677
678 def update_report_state(_, _), do: {:error, "Unsupported state"}
679
680 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
681 [to, cc, recipients] =
682 activity
683 |> get_updated_targets(visibility)
684 |> Enum.map(&Enum.uniq/1)
685
686 object_data =
687 activity.object.data
688 |> Map.put("to", to)
689 |> Map.put("cc", cc)
690
691 {:ok, object} =
692 activity.object
693 |> Object.change(%{data: object_data})
694 |> Object.update_and_set_cache()
695
696 activity_data =
697 activity.data
698 |> Map.put("to", to)
699 |> Map.put("cc", cc)
700
701 activity
702 |> Map.put(:object, object)
703 |> Activity.change(%{data: activity_data, recipients: recipients})
704 |> Repo.update()
705 end
706
707 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
708
709 defp get_updated_targets(
710 %Activity{data: %{"to" => to} = data, recipients: recipients},
711 visibility
712 ) do
713 cc = Map.get(data, "cc", [])
714 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
715 public = Pleroma.Constants.as_public()
716
717 case visibility do
718 "public" ->
719 to = [public | List.delete(to, follower_address)]
720 cc = [follower_address | List.delete(cc, public)]
721 recipients = [public | recipients]
722 [to, cc, recipients]
723
724 "private" ->
725 to = [follower_address | List.delete(to, public)]
726 cc = List.delete(cc, public)
727 recipients = List.delete(recipients, public)
728 [to, cc, recipients]
729
730 "unlisted" ->
731 to = [follower_address | List.delete(to, public)]
732 cc = [public | List.delete(cc, follower_address)]
733 recipients = recipients ++ [follower_address, public]
734 [to, cc, recipients]
735
736 _ ->
737 [to, cc, recipients]
738 end
739 end
740
741 def get_existing_votes(actor, %{data: %{"id" => id}}) do
742 query =
743 from(
744 [activity, object: object] in Activity.with_preloaded_object(Activity),
745 where: fragment("(?)->>'type' = 'Create'", activity.data),
746 where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
747 where:
748 fragment(
749 "(?)->>'inReplyTo' = ?",
750 object.data,
751 ^to_string(id)
752 ),
753 where: fragment("(?)->>'type' = 'Answer'", object.data)
754 )
755
756 Repo.all(query)
757 end
758
759 defp maybe_put(map, _key, nil), do: map
760 defp maybe_put(map, key, value), do: Map.put(map, key, value)
761 end