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