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