Merge branch 'develop' into issue/1218
[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 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, "cc" => [Pleroma.Constants.as_public()]}},
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 #### Flag-related helpers
585 @spec make_flag_data(map(), map()) :: map()
586 def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do
587 %{
588 "type" => "Flag",
589 "actor" => actor.ap_id,
590 "content" => content,
591 "object" => build_flag_object(params),
592 "context" => context,
593 "state" => "open"
594 }
595 |> Map.merge(additional)
596 end
597
598 def make_flag_data(_, _), do: %{}
599
600 defp build_flag_object(%{account: account, statuses: statuses} = _) do
601 [account.ap_id] ++
602 Enum.map(statuses || [], fn
603 %Activity{} = act -> act.data["id"]
604 act when is_map(act) -> act["id"]
605 act when is_binary(act) -> act
606 end)
607 end
608
609 defp build_flag_object(_), do: []
610
611 @doc """
612 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
613 the first one to `pages_left` pages.
614 If the amount of pages is higher than the collection has, it returns whatever was there.
615 """
616 def fetch_ordered_collection(from, pages_left, acc \\ []) do
617 with {:ok, response} <- Tesla.get(from),
618 {:ok, collection} <- Jason.decode(response.body) do
619 case collection["type"] do
620 "OrderedCollection" ->
621 # If we've encountered the OrderedCollection and not the page,
622 # just call the same function on the page address
623 fetch_ordered_collection(collection["first"], pages_left)
624
625 "OrderedCollectionPage" ->
626 if pages_left > 0 do
627 # There are still more pages
628 if Map.has_key?(collection, "next") do
629 # There are still more pages, go deeper saving what we have into the accumulator
630 fetch_ordered_collection(
631 collection["next"],
632 pages_left - 1,
633 acc ++ collection["orderedItems"]
634 )
635 else
636 # No more pages left, just return whatever we already have
637 acc ++ collection["orderedItems"]
638 end
639 else
640 # Got the amount of pages needed, add them all to the accumulator
641 acc ++ collection["orderedItems"]
642 end
643
644 _ ->
645 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
646 end
647 end
648 end
649
650 #### Report-related helpers
651
652 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
653 new_data = Map.put(activity.data, "state", state)
654
655 activity
656 |> Changeset.change(data: new_data)
657 |> Repo.update()
658 end
659
660 def update_report_state(_, _), do: {:error, "Unsupported state"}
661
662 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
663 [to, cc, recipients] =
664 activity
665 |> get_updated_targets(visibility)
666 |> Enum.map(&Enum.uniq/1)
667
668 object_data =
669 activity.object.data
670 |> Map.put("to", to)
671 |> Map.put("cc", cc)
672
673 {:ok, object} =
674 activity.object
675 |> Object.change(%{data: object_data})
676 |> Object.update_and_set_cache()
677
678 activity_data =
679 activity.data
680 |> Map.put("to", to)
681 |> Map.put("cc", cc)
682
683 activity
684 |> Map.put(:object, object)
685 |> Activity.change(%{data: activity_data, recipients: recipients})
686 |> Repo.update()
687 end
688
689 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
690
691 defp get_updated_targets(
692 %Activity{data: %{"to" => to} = data, recipients: recipients},
693 visibility
694 ) do
695 cc = Map.get(data, "cc", [])
696 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
697 public = Pleroma.Constants.as_public()
698
699 case visibility do
700 "public" ->
701 to = [public | List.delete(to, follower_address)]
702 cc = [follower_address | List.delete(cc, public)]
703 recipients = [public | recipients]
704 [to, cc, recipients]
705
706 "private" ->
707 to = [follower_address | List.delete(to, public)]
708 cc = List.delete(cc, public)
709 recipients = List.delete(recipients, public)
710 [to, cc, recipients]
711
712 "unlisted" ->
713 to = [follower_address | List.delete(to, public)]
714 cc = [public | List.delete(cc, follower_address)]
715 recipients = recipients ++ [follower_address, public]
716 [to, cc, recipients]
717
718 _ ->
719 [to, cc, recipients]
720 end
721 end
722
723 def get_existing_votes(actor, %{data: %{"id" => id}}) do
724 actor
725 |> Activity.Queries.by_actor()
726 |> Activity.Queries.by_type("Create")
727 |> Activity.with_preloaded_object()
728 |> where([a, object: o], fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(id)))
729 |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data))
730 |> Repo.all()
731 end
732
733 defp maybe_put(map, _key, nil), do: map
734 defp maybe_put(map, key, value), do: Map.put(map, key, value)
735 end