ActivityPub: Change addressing of Undo.
[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, "object" => object}} = activity,
465 activity_id
466 ) do
467 object = Object.normalize(object)
468
469 %{
470 "type" => "Undo",
471 "actor" => ap_id,
472 "object" => activity.data,
473 "to" => [user.follower_address, object.data["actor"]],
474 "cc" => [Pleroma.Constants.as_public()],
475 "context" => context
476 }
477 |> maybe_put("id", activity_id)
478 end
479
480 def make_unlike_data(
481 %User{ap_id: ap_id} = user,
482 %Activity{data: %{"context" => context, "object" => object}} = activity,
483 activity_id
484 ) do
485 object = Object.normalize(object)
486
487 %{
488 "type" => "Undo",
489 "actor" => ap_id,
490 "object" => activity.data,
491 "to" => [user.follower_address, object.data["actor"]],
492 "cc" => [Pleroma.Constants.as_public()],
493 "context" => context
494 }
495 |> maybe_put("id", activity_id)
496 end
497
498 @spec add_announce_to_object(Activity.t(), Object.t()) ::
499 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
500 def add_announce_to_object(
501 %Activity{data: %{"actor" => actor}},
502 object
503 ) do
504 announcements = take_announcements(object)
505
506 with announcements <- Enum.uniq([actor | announcements]) do
507 update_element_in_object("announcement", announcements, object)
508 end
509 end
510
511 def add_announce_to_object(_, object), do: {:ok, object}
512
513 @spec remove_announce_from_object(Activity.t(), Object.t()) ::
514 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
515 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
516 with announcements <- List.delete(take_announcements(object), actor) do
517 update_element_in_object("announcement", announcements, object)
518 end
519 end
520
521 defp take_announcements(%{data: %{"announcements" => announcements}} = _)
522 when is_list(announcements),
523 do: announcements
524
525 defp take_announcements(_), do: []
526
527 #### Unfollow-related helpers
528
529 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
530 %{
531 "type" => "Undo",
532 "actor" => follower.ap_id,
533 "to" => [followed.ap_id],
534 "object" => follow_activity.data
535 }
536 |> maybe_put("id", activity_id)
537 end
538
539 #### Block-related helpers
540 @spec fetch_latest_block(User.t(), User.t()) :: Activity.t() | nil
541 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
542 "Block"
543 |> Activity.Queries.by_type()
544 |> where(actor: ^blocker_id)
545 # this is to use the index
546 |> Activity.Queries.by_object_id(blocked_id)
547 |> order_by([activity], fragment("? desc nulls last", activity.id))
548 |> limit(1)
549 |> Repo.one()
550 end
551
552 def make_block_data(blocker, blocked, activity_id) do
553 %{
554 "type" => "Block",
555 "actor" => blocker.ap_id,
556 "to" => [blocked.ap_id],
557 "object" => blocked.ap_id
558 }
559 |> maybe_put("id", activity_id)
560 end
561
562 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
563 %{
564 "type" => "Undo",
565 "actor" => blocker.ap_id,
566 "to" => [blocked.ap_id],
567 "object" => block_activity.data
568 }
569 |> maybe_put("id", activity_id)
570 end
571
572 #### Create-related helpers
573
574 def make_create_data(params, additional) do
575 published = params.published || make_date()
576
577 %{
578 "type" => "Create",
579 "to" => params.to |> Enum.uniq(),
580 "actor" => params.actor.ap_id,
581 "object" => params.object,
582 "published" => published,
583 "context" => params.context
584 }
585 |> Map.merge(additional)
586 end
587
588 #### Listen-related helpers
589 def make_listen_data(params, additional) do
590 published = params.published || make_date()
591
592 %{
593 "type" => "Listen",
594 "to" => params.to |> Enum.uniq(),
595 "actor" => params.actor.ap_id,
596 "object" => params.object,
597 "published" => published,
598 "context" => params.context
599 }
600 |> Map.merge(additional)
601 end
602
603 #### Flag-related helpers
604 @spec make_flag_data(map(), map()) :: map()
605 def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do
606 %{
607 "type" => "Flag",
608 "actor" => actor.ap_id,
609 "content" => content,
610 "object" => build_flag_object(params),
611 "context" => context,
612 "state" => "open"
613 }
614 |> Map.merge(additional)
615 end
616
617 def make_flag_data(_, _), do: %{}
618
619 defp build_flag_object(%{account: account, statuses: statuses} = _) do
620 [account.ap_id] ++
621 Enum.map(statuses || [], fn
622 %Activity{} = act -> act.data["id"]
623 act when is_map(act) -> act["id"]
624 act when is_binary(act) -> act
625 end)
626 end
627
628 defp build_flag_object(_), do: []
629
630 @doc """
631 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
632 the first one to `pages_left` pages.
633 If the amount of pages is higher than the collection has, it returns whatever was there.
634 """
635 def fetch_ordered_collection(from, pages_left, acc \\ []) do
636 with {:ok, response} <- Tesla.get(from),
637 {:ok, collection} <- Jason.decode(response.body) do
638 case collection["type"] do
639 "OrderedCollection" ->
640 # If we've encountered the OrderedCollection and not the page,
641 # just call the same function on the page address
642 fetch_ordered_collection(collection["first"], pages_left)
643
644 "OrderedCollectionPage" ->
645 if pages_left > 0 do
646 # There are still more pages
647 if Map.has_key?(collection, "next") do
648 # There are still more pages, go deeper saving what we have into the accumulator
649 fetch_ordered_collection(
650 collection["next"],
651 pages_left - 1,
652 acc ++ collection["orderedItems"]
653 )
654 else
655 # No more pages left, just return whatever we already have
656 acc ++ collection["orderedItems"]
657 end
658 else
659 # Got the amount of pages needed, add them all to the accumulator
660 acc ++ collection["orderedItems"]
661 end
662
663 _ ->
664 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
665 end
666 end
667 end
668
669 #### Report-related helpers
670
671 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
672 new_data = Map.put(activity.data, "state", state)
673
674 activity
675 |> Changeset.change(data: new_data)
676 |> Repo.update()
677 end
678
679 def update_report_state(_, _), do: {:error, "Unsupported state"}
680
681 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
682 [to, cc, recipients] =
683 activity
684 |> get_updated_targets(visibility)
685 |> Enum.map(&Enum.uniq/1)
686
687 object_data =
688 activity.object.data
689 |> Map.put("to", to)
690 |> Map.put("cc", cc)
691
692 {:ok, object} =
693 activity.object
694 |> Object.change(%{data: object_data})
695 |> Object.update_and_set_cache()
696
697 activity_data =
698 activity.data
699 |> Map.put("to", to)
700 |> Map.put("cc", cc)
701
702 activity
703 |> Map.put(:object, object)
704 |> Activity.change(%{data: activity_data, recipients: recipients})
705 |> Repo.update()
706 end
707
708 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
709
710 defp get_updated_targets(
711 %Activity{data: %{"to" => to} = data, recipients: recipients},
712 visibility
713 ) do
714 cc = Map.get(data, "cc", [])
715 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
716 public = Pleroma.Constants.as_public()
717
718 case visibility do
719 "public" ->
720 to = [public | List.delete(to, follower_address)]
721 cc = [follower_address | List.delete(cc, public)]
722 recipients = [public | recipients]
723 [to, cc, recipients]
724
725 "private" ->
726 to = [follower_address | List.delete(to, public)]
727 cc = List.delete(cc, public)
728 recipients = List.delete(recipients, public)
729 [to, cc, recipients]
730
731 "unlisted" ->
732 to = [follower_address | List.delete(to, public)]
733 cc = [public | List.delete(cc, follower_address)]
734 recipients = recipients ++ [follower_address, public]
735 [to, cc, recipients]
736
737 _ ->
738 [to, cc, recipients]
739 end
740 end
741
742 def get_existing_votes(actor, %{data: %{"id" => id}}) do
743 actor
744 |> Activity.Queries.by_actor()
745 |> Activity.Queries.by_type("Create")
746 |> Activity.with_preloaded_object()
747 |> where([a, object: o], fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(id)))
748 |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data))
749 |> Repo.all()
750 end
751
752 defp maybe_put(map, _key, nil), do: map
753 defp maybe_put(map, key, value), do: Map.put(map, key, value)
754 end