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