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