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