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