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