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