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