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