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