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