Merge branch 'cleanup-activity' 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"]
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 def determine_explicit_mentions(%{"tag" => tag} = _object) when is_list(tag) do
37 tag
38 |> Enum.filter(fn x -> is_map(x) end)
39 |> Enum.filter(fn x -> x["type"] == "Mention" end)
40 |> Enum.map(fn x -> x["href"] end)
41 end
42
43 def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do
44 Map.put(object, "tag", [tag])
45 |> determine_explicit_mentions()
46 end
47
48 def determine_explicit_mentions(_), do: []
49
50 defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
51 defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
52 defp recipient_in_collection(_, _), do: false
53
54 def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do
55 cond do
56 recipient_in_collection(ap_id, params["to"]) ->
57 true
58
59 recipient_in_collection(ap_id, params["cc"]) ->
60 true
61
62 recipient_in_collection(ap_id, params["bto"]) ->
63 true
64
65 recipient_in_collection(ap_id, params["bcc"]) ->
66 true
67
68 # if the message is unaddressed at all, then assume it is directly addressed
69 # to the recipient
70 !params["to"] && !params["cc"] && !params["bto"] && !params["bcc"] ->
71 true
72
73 # if the message is sent from somebody the user is following, then assume it
74 # is addressed to the recipient
75 User.following?(recipient, actor) ->
76 true
77
78 true ->
79 false
80 end
81 end
82
83 defp extract_list(target) when is_binary(target), do: [target]
84 defp extract_list(lst) when is_list(lst), do: lst
85 defp extract_list(_), do: []
86
87 def maybe_splice_recipient(ap_id, params) do
88 need_splice? =
89 !recipient_in_collection(ap_id, params["to"]) &&
90 !recipient_in_collection(ap_id, params["cc"])
91
92 if need_splice? do
93 cc_list = extract_list(params["cc"])
94 Map.put(params, "cc", [ap_id | cc_list])
95 else
96 params
97 end
98 end
99
100 def make_json_ld_header do
101 %{
102 "@context" => [
103 "https://www.w3.org/ns/activitystreams",
104 "#{Web.base_url()}/schemas/litepub-0.1.jsonld",
105 %{
106 "@language" => "und"
107 }
108 ]
109 }
110 end
111
112 def make_date do
113 DateTime.utc_now() |> DateTime.to_iso8601()
114 end
115
116 def generate_activity_id do
117 generate_id("activities")
118 end
119
120 def generate_context_id do
121 generate_id("contexts")
122 end
123
124 def generate_object_id do
125 Helpers.o_status_url(Endpoint, :object, UUID.generate())
126 end
127
128 def generate_id(type) do
129 "#{Web.base_url()}/#{type}/#{UUID.generate()}"
130 end
131
132 def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
133 fake_create_activity = %{
134 "to" => object["to"],
135 "cc" => object["cc"],
136 "type" => "Create",
137 "object" => object
138 }
139
140 get_notified_from_object(fake_create_activity)
141 end
142
143 def get_notified_from_object(object) do
144 Notification.get_notified_from_activity(%Activity{data: object}, false)
145 end
146
147 def create_context(context) do
148 context = context || generate_id("contexts")
149
150 # Ecto has problems accessing the constraint inside the jsonb,
151 # so we explicitly check for the existed object before insert
152 object = Object.get_cached_by_ap_id(context)
153
154 with true <- is_nil(object),
155 changeset <- Object.context_mapping(context),
156 {:ok, inserted_object} <- Repo.insert(changeset) do
157 inserted_object
158 else
159 _ ->
160 object
161 end
162 end
163
164 @doc """
165 Enqueues an activity for federation if it's local
166 """
167 @spec maybe_federate(any()) :: :ok
168 def maybe_federate(%Activity{local: true} = activity) do
169 if Pleroma.Config.get!([:instance, :federating]) do
170 priority =
171 case activity.data["type"] do
172 "Delete" -> 10
173 "Create" -> 1
174 _ -> 5
175 end
176
177 Pleroma.Web.Federator.publish(activity, priority)
178 end
179
180 :ok
181 end
182
183 def maybe_federate(_), do: :ok
184
185 @doc """
186 Adds an id and a published data if they aren't there,
187 also adds it to an included object
188 """
189 def lazy_put_activity_defaults(map, fake? \\ false) do
190 map =
191 if not 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 else
200 map
201 |> Map.put_new("id", "pleroma:fakeid")
202 |> Map.put_new_lazy("published", &make_date/0)
203 |> Map.put_new("context", "pleroma:fakecontext")
204 |> Map.put_new("context_id", -1)
205 end
206
207 if is_map(map["object"]) do
208 object = lazy_put_object_defaults(map["object"], map, fake?)
209 %{map | "object" => object}
210 else
211 map
212 end
213 end
214
215 @doc """
216 Adds an id and published date if they aren't there.
217 """
218 def lazy_put_object_defaults(map, activity \\ %{}, fake?)
219
220 def lazy_put_object_defaults(map, activity, true = _fake?) do
221 map
222 |> Map.put_new_lazy("published", &make_date/0)
223 |> Map.put_new("id", "pleroma:fake_object_id")
224 |> Map.put_new("context", activity["context"])
225 |> Map.put_new("fake", true)
226 |> Map.put_new("context_id", activity["context_id"])
227 end
228
229 def lazy_put_object_defaults(map, activity, _fake?) do
230 map
231 |> Map.put_new_lazy("id", &generate_object_id/0)
232 |> Map.put_new_lazy("published", &make_date/0)
233 |> Map.put_new("context", activity["context"])
234 |> Map.put_new("context_id", activity["context_id"])
235 end
236
237 @doc """
238 Inserts a full object if it is contained in an activity.
239 """
240 def insert_full_object(%{"object" => %{"type" => type} = object_data} = map)
241 when is_map(object_data) and type in @supported_object_types do
242 with {:ok, object} <- Object.create(object_data) do
243 map = Map.put(map, "object", object.data["id"])
244
245 {:ok, map, object}
246 end
247 end
248
249 def insert_full_object(map), do: {:ok, map, nil}
250
251 #### Like-related helpers
252
253 @doc """
254 Returns an existing like if a user already liked an object
255 """
256 @spec get_existing_like(String.t(), map()) :: Activity.t() | nil
257 def get_existing_like(actor, %{data: %{"id" => id}}) do
258 actor
259 |> Activity.Queries.by_actor()
260 |> Activity.Queries.by_object_id(id)
261 |> Activity.Queries.by_type("Like")
262 |> limit(1)
263 |> Repo.one()
264 end
265
266 @doc """
267 Returns like activities targeting an object
268 """
269 def get_object_likes(%{data: %{"id" => id}}) do
270 id
271 |> Activity.Queries.by_object_id()
272 |> Activity.Queries.by_type("Like")
273 |> Repo.all()
274 end
275
276 @spec make_like_data(User.t(), map(), String.t()) :: map()
277 def make_like_data(
278 %User{ap_id: ap_id} = actor,
279 %{data: %{"actor" => object_actor_id, "id" => id}} = object,
280 activity_id
281 ) do
282 object_actor = User.get_cached_by_ap_id(object_actor_id)
283
284 to =
285 if Visibility.is_public?(object) do
286 [actor.follower_address, object.data["actor"]]
287 else
288 [object.data["actor"]]
289 end
290
291 cc =
292 (object.data["to"] ++ (object.data["cc"] || []))
293 |> List.delete(actor.ap_id)
294 |> List.delete(object_actor.follower_address)
295
296 %{
297 "type" => "Like",
298 "actor" => ap_id,
299 "object" => id,
300 "to" => to,
301 "cc" => cc,
302 "context" => object.data["context"]
303 }
304 |> maybe_put("id", activity_id)
305 end
306
307 @spec update_element_in_object(String.t(), list(any), Object.t()) ::
308 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
309 def update_element_in_object(property, element, object) do
310 data =
311 Map.merge(
312 object.data,
313 %{"#{property}_count" => length(element), "#{property}s" => element}
314 )
315
316 object
317 |> Changeset.change(data: data)
318 |> Object.update_and_set_cache()
319 end
320
321 @spec add_like_to_object(Activity.t(), Object.t()) ::
322 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
323 def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
324 [actor | fetch_likes(object)]
325 |> Enum.uniq()
326 |> update_likes_in_object(object)
327 end
328
329 @spec remove_like_from_object(Activity.t(), Object.t()) ::
330 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
331 def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
332 object
333 |> fetch_likes()
334 |> List.delete(actor)
335 |> update_likes_in_object(object)
336 end
337
338 defp update_likes_in_object(likes, object) do
339 update_element_in_object("like", likes, object)
340 end
341
342 defp fetch_likes(object) do
343 if is_list(object.data["likes"]) do
344 object.data["likes"]
345 else
346 []
347 end
348 end
349
350 #### Follow-related helpers
351
352 @doc """
353 Updates a follow activity's state (for locked accounts).
354 """
355 def update_follow_state_for_all(
356 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
357 state
358 ) do
359 try do
360 Ecto.Adapters.SQL.query!(
361 Repo,
362 "UPDATE activities SET data = jsonb_set(data, '{state}', $1) WHERE data->>'type' = 'Follow' AND data->>'actor' = $2 AND data->>'object' = $3 AND data->>'state' = 'pending'",
363 [state, actor, object]
364 )
365
366 User.set_follow_state_cache(actor, object, state)
367 activity = Activity.get_by_id(activity.id)
368 {:ok, activity}
369 rescue
370 e ->
371 {:error, e}
372 end
373 end
374
375 def update_follow_state(
376 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
377 state
378 ) do
379 new_data = Map.put(activity.data, "state", state)
380 changeset = Changeset.change(activity, data: new_data)
381
382 with {:ok, activity} <- Repo.update(changeset) do
383 User.set_follow_state_cache(actor, object, state)
384 {:ok, activity}
385 end
386 end
387
388 @doc """
389 Makes a follow activity data for the given follower and followed
390 """
391 def make_follow_data(
392 %User{ap_id: follower_id},
393 %User{ap_id: followed_id} = _followed,
394 activity_id
395 ) do
396 %{
397 "type" => "Follow",
398 "actor" => follower_id,
399 "to" => [followed_id],
400 "cc" => [Pleroma.Constants.as_public()],
401 "object" => followed_id,
402 "state" => "pending"
403 }
404 |> maybe_put("id", activity_id)
405 end
406
407 def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
408 "Follow"
409 |> Activity.Queries.by_type()
410 |> where(actor: ^follower_id)
411 # this is to use the index
412 |> Activity.Queries.by_object_id(followed_id)
413 |> order_by([activity], fragment("? desc nulls last", activity.id))
414 |> limit(1)
415 |> Repo.one()
416 end
417
418 #### Announce-related helpers
419
420 @doc """
421 Retruns an existing announce activity if the notice has already been announced
422 """
423 def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do
424 "Announce"
425 |> Activity.Queries.by_type()
426 |> where(actor: ^actor)
427 # this is to use the index
428 |> Activity.Queries.by_object_id(ap_id)
429 |> Repo.one()
430 end
431
432 @doc """
433 Make announce activity data for the given actor and object
434 """
435 # for relayed messages, we only want to send to subscribers
436 def make_announce_data(
437 %User{ap_id: ap_id} = user,
438 %Object{data: %{"id" => id}} = object,
439 activity_id,
440 false
441 ) do
442 %{
443 "type" => "Announce",
444 "actor" => ap_id,
445 "object" => id,
446 "to" => [user.follower_address],
447 "cc" => [],
448 "context" => object.data["context"]
449 }
450 |> maybe_put("id", activity_id)
451 end
452
453 def make_announce_data(
454 %User{ap_id: ap_id} = user,
455 %Object{data: %{"id" => id}} = object,
456 activity_id,
457 true
458 ) do
459 %{
460 "type" => "Announce",
461 "actor" => ap_id,
462 "object" => id,
463 "to" => [user.follower_address, object.data["actor"]],
464 "cc" => [Pleroma.Constants.as_public()],
465 "context" => object.data["context"]
466 }
467 |> maybe_put("id", activity_id)
468 end
469
470 @doc """
471 Make unannounce activity data for the given actor and object
472 """
473 def make_unannounce_data(
474 %User{ap_id: ap_id} = user,
475 %Activity{data: %{"context" => context}} = activity,
476 activity_id
477 ) do
478 %{
479 "type" => "Undo",
480 "actor" => ap_id,
481 "object" => activity.data,
482 "to" => [user.follower_address, activity.data["actor"]],
483 "cc" => [Pleroma.Constants.as_public()],
484 "context" => context
485 }
486 |> maybe_put("id", activity_id)
487 end
488
489 def make_unlike_data(
490 %User{ap_id: ap_id} = user,
491 %Activity{data: %{"context" => context}} = activity,
492 activity_id
493 ) do
494 %{
495 "type" => "Undo",
496 "actor" => ap_id,
497 "object" => activity.data,
498 "to" => [user.follower_address, activity.data["actor"]],
499 "cc" => [Pleroma.Constants.as_public()],
500 "context" => context
501 }
502 |> maybe_put("id", activity_id)
503 end
504
505 def add_announce_to_object(
506 %Activity{
507 data: %{"actor" => actor, "cc" => [Pleroma.Constants.as_public()]}
508 },
509 object
510 ) do
511 announcements =
512 if is_list(object.data["announcements"]) do
513 Enum.uniq([actor | object.data["announcements"]])
514 else
515 [actor]
516 end
517
518 update_element_in_object("announcement", announcements, object)
519 end
520
521 def add_announce_to_object(_, object), do: {:ok, object}
522
523 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
524 announcements =
525 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
526
527 with announcements <- announcements |> List.delete(actor) do
528 update_element_in_object("announcement", announcements, object)
529 end
530 end
531
532 #### Unfollow-related helpers
533
534 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
535 %{
536 "type" => "Undo",
537 "actor" => follower.ap_id,
538 "to" => [followed.ap_id],
539 "object" => follow_activity.data
540 }
541 |> maybe_put("id", activity_id)
542 end
543
544 #### Block-related helpers
545 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
546 "Block"
547 |> Activity.Queries.by_type()
548 |> where(actor: ^blocker_id)
549 # this is to use the index
550 |> Activity.Queries.by_object_id(blocked_id)
551 |> order_by([activity], fragment("? desc nulls last", activity.id))
552 |> limit(1)
553 |> Repo.one()
554 end
555
556 def make_block_data(blocker, blocked, activity_id) do
557 %{
558 "type" => "Block",
559 "actor" => blocker.ap_id,
560 "to" => [blocked.ap_id],
561 "object" => blocked.ap_id
562 }
563 |> maybe_put("id", activity_id)
564 end
565
566 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
567 %{
568 "type" => "Undo",
569 "actor" => blocker.ap_id,
570 "to" => [blocked.ap_id],
571 "object" => block_activity.data
572 }
573 |> maybe_put("id", activity_id)
574 end
575
576 #### Create-related helpers
577
578 def make_create_data(params, additional) do
579 published = params.published || make_date()
580
581 %{
582 "type" => "Create",
583 "to" => params.to |> Enum.uniq(),
584 "actor" => params.actor.ap_id,
585 "object" => params.object,
586 "published" => published,
587 "context" => params.context
588 }
589 |> Map.merge(additional)
590 end
591
592 #### Flag-related helpers
593
594 def make_flag_data(params, additional) do
595 status_ap_ids =
596 Enum.map(params.statuses || [], fn
597 %Activity{} = act -> act.data["id"]
598 act when is_map(act) -> act["id"]
599 act when is_binary(act) -> act
600 end)
601
602 object = [params.account.ap_id] ++ status_ap_ids
603
604 %{
605 "type" => "Flag",
606 "actor" => params.actor.ap_id,
607 "content" => params.content,
608 "object" => object,
609 "context" => params.context,
610 "state" => "open"
611 }
612 |> Map.merge(additional)
613 end
614
615 @doc """
616 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
617 the first one to `pages_left` pages.
618 If the amount of pages is higher than the collection has, it returns whatever was there.
619 """
620 def fetch_ordered_collection(from, pages_left, acc \\ []) do
621 with {:ok, response} <- Tesla.get(from),
622 {:ok, collection} <- Jason.decode(response.body) do
623 case collection["type"] do
624 "OrderedCollection" ->
625 # If we've encountered the OrderedCollection and not the page,
626 # just call the same function on the page address
627 fetch_ordered_collection(collection["first"], pages_left)
628
629 "OrderedCollectionPage" ->
630 if pages_left > 0 do
631 # There are still more pages
632 if Map.has_key?(collection, "next") do
633 # There are still more pages, go deeper saving what we have into the accumulator
634 fetch_ordered_collection(
635 collection["next"],
636 pages_left - 1,
637 acc ++ collection["orderedItems"]
638 )
639 else
640 # No more pages left, just return whatever we already have
641 acc ++ collection["orderedItems"]
642 end
643 else
644 # Got the amount of pages needed, add them all to the accumulator
645 acc ++ collection["orderedItems"]
646 end
647
648 _ ->
649 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
650 end
651 end
652 end
653
654 #### Report-related helpers
655
656 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
657 new_data = Map.put(activity.data, "state", state)
658
659 activity
660 |> Changeset.change(data: new_data)
661 |> Repo.update()
662 end
663
664 def update_report_state(_, _), do: {:error, "Unsupported state"}
665
666 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
667 [to, cc, recipients] =
668 activity
669 |> get_updated_targets(visibility)
670 |> Enum.map(&Enum.uniq/1)
671
672 object_data =
673 activity.object.data
674 |> Map.put("to", to)
675 |> Map.put("cc", cc)
676
677 {:ok, object} =
678 activity.object
679 |> Object.change(%{data: object_data})
680 |> Object.update_and_set_cache()
681
682 activity_data =
683 activity.data
684 |> Map.put("to", to)
685 |> Map.put("cc", cc)
686
687 activity
688 |> Map.put(:object, object)
689 |> Activity.change(%{data: activity_data, recipients: recipients})
690 |> Repo.update()
691 end
692
693 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
694
695 defp get_updated_targets(
696 %Activity{data: %{"to" => to} = data, recipients: recipients},
697 visibility
698 ) do
699 cc = Map.get(data, "cc", [])
700 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
701 public = Pleroma.Constants.as_public()
702
703 case visibility do
704 "public" ->
705 to = [public | List.delete(to, follower_address)]
706 cc = [follower_address | List.delete(cc, public)]
707 recipients = [public | recipients]
708 [to, cc, recipients]
709
710 "private" ->
711 to = [follower_address | List.delete(to, public)]
712 cc = List.delete(cc, public)
713 recipients = List.delete(recipients, public)
714 [to, cc, recipients]
715
716 "unlisted" ->
717 to = [follower_address | List.delete(to, public)]
718 cc = [public | List.delete(cc, follower_address)]
719 recipients = recipients ++ [follower_address, public]
720 [to, cc, recipients]
721
722 _ ->
723 [to, cc, recipients]
724 end
725 end
726
727 def get_existing_votes(actor, %{data: %{"id" => id}}) do
728 actor
729 |> Activity.Queries.by_actor()
730 |> Activity.Queries.by_type("Create")
731 |> Activity.with_preloaded_object()
732 |> where([a, object: o], fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(id)))
733 |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data))
734 |> Repo.all()
735 end
736
737 defp maybe_put(map, _key, nil), do: map
738 defp maybe_put(map, key, value), do: Map.put(map, key, value)
739 end