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