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