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