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