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