Merge branch 'develop' of git.pleroma.social:pleroma/pleroma into feature/jobs
[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 Pleroma.Repo
7 alias Pleroma.Web
8 alias Pleroma.Object
9 alias Pleroma.Activity
10 alias Pleroma.User
11 alias Pleroma.Notification
12 alias Pleroma.Web.Router.Helpers
13 alias Pleroma.Web.Endpoint
14 alias Ecto.Changeset
15 alias Ecto.UUID
16
17 import Ecto.Query
18
19 require Logger
20
21 @supported_object_types ["Article", "Note", "Video", "Page"]
22
23 # Some implementations send the actor URI as the actor field, others send the entire actor object,
24 # so figure out what the actor's URI is based on what we have.
25 def get_ap_id(object) do
26 case object do
27 %{"id" => id} -> id
28 id -> id
29 end
30 end
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(ap_id, 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 true ->
74 false
75 end
76 end
77
78 defp extract_list(target) when is_binary(target), do: [target]
79 defp extract_list(lst) when is_list(lst), do: lst
80 defp extract_list(_), do: []
81
82 def maybe_splice_recipient(ap_id, params) do
83 need_splice =
84 !recipient_in_collection(ap_id, params["to"]) &&
85 !recipient_in_collection(ap_id, params["cc"])
86
87 cc_list = extract_list(params["cc"])
88
89 if need_splice do
90 params
91 |> Map.put("cc", [ap_id | cc_list])
92 else
93 params
94 end
95 end
96
97 def make_json_ld_header do
98 %{
99 "@context" => [
100 "https://www.w3.org/ns/activitystreams",
101 "#{Web.base_url()}/schemas/litepub-0.1.jsonld"
102 ]
103 }
104 end
105
106 def make_date do
107 DateTime.utc_now() |> DateTime.to_iso8601()
108 end
109
110 def generate_activity_id do
111 generate_id("activities")
112 end
113
114 def generate_context_id do
115 generate_id("contexts")
116 end
117
118 def generate_object_id do
119 Helpers.o_status_url(Endpoint, :object, UUID.generate())
120 end
121
122 def generate_id(type) do
123 "#{Web.base_url()}/#{type}/#{UUID.generate()}"
124 end
125
126 def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
127 fake_create_activity = %{
128 "to" => object["to"],
129 "cc" => object["cc"],
130 "type" => "Create",
131 "object" => object
132 }
133
134 Notification.get_notified_from_activity(%Activity{data: fake_create_activity}, false)
135 end
136
137 def get_notified_from_object(object) do
138 Notification.get_notified_from_activity(%Activity{data: object}, false)
139 end
140
141 def create_context(context) do
142 context = context || generate_id("contexts")
143 changeset = Object.context_mapping(context)
144
145 with {:ok, object} <- Object.insert_or_get(changeset) do
146 object
147 end
148 end
149
150 @doc """
151 Enqueues an activity for federation if it's local
152 """
153 def maybe_federate(%Activity{local: true} = activity) do
154 priority =
155 case activity.data["type"] do
156 "Delete" -> 10
157 "Create" -> 1
158 _ -> 5
159 end
160
161 Pleroma.Web.Federator.publish(activity, priority)
162 :ok
163 end
164
165 def maybe_federate(_), do: :ok
166
167 @doc """
168 Adds an id and a published data if they aren't there,
169 also adds it to an included object
170 """
171 def lazy_put_activity_defaults(map) do
172 %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
173
174 map =
175 map
176 |> Map.put_new_lazy("id", &generate_activity_id/0)
177 |> Map.put_new_lazy("published", &make_date/0)
178 |> Map.put_new("context", context)
179 |> Map.put_new("context_id", context_id)
180
181 if is_map(map["object"]) do
182 object = lazy_put_object_defaults(map["object"], map)
183 %{map | "object" => object}
184 else
185 map
186 end
187 end
188
189 @doc """
190 Adds an id and published date if they aren't there.
191 """
192 def lazy_put_object_defaults(map, activity \\ %{}) do
193 map
194 |> Map.put_new_lazy("id", &generate_object_id/0)
195 |> Map.put_new_lazy("published", &make_date/0)
196 |> Map.put_new("context", activity["context"])
197 |> Map.put_new("context_id", activity["context_id"])
198 end
199
200 @doc """
201 Inserts a full object if it is contained in an activity.
202 """
203 def insert_full_object(%{"object" => %{"type" => type} = object_data})
204 when is_map(object_data) and type in @supported_object_types do
205 with {:ok, _} <- Object.create(object_data) do
206 :ok
207 end
208 end
209
210 def insert_full_object(_), do: :ok
211
212 def update_object_in_activities(%{data: %{"id" => id}} = object) do
213 # TODO
214 # Update activities that already had this. Could be done in a seperate process.
215 # Alternatively, just don't do this and fetch the current object each time. Most
216 # could probably be taken from cache.
217 relevant_activities = Activity.get_all_create_by_object_ap_id(id)
218
219 Enum.map(relevant_activities, fn activity ->
220 new_activity_data = activity.data |> Map.put("object", object.data)
221 changeset = Changeset.change(activity, data: new_activity_data)
222 Repo.update(changeset)
223 end)
224 end
225
226 #### Like-related helpers
227
228 @doc """
229 Returns an existing like if a user already liked an object
230 """
231 def get_existing_like(actor, %{data: %{"id" => id}}) do
232 query =
233 from(
234 activity in Activity,
235 where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
236 # this is to use the index
237 where:
238 fragment(
239 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
240 activity.data,
241 activity.data,
242 ^id
243 ),
244 where: fragment("(?)->>'type' = 'Like'", activity.data)
245 )
246
247 Repo.one(query)
248 end
249
250 @doc """
251 Returns like activities targeting an object
252 """
253 def get_object_likes(%{data: %{"id" => id}}) do
254 query =
255 from(
256 activity in Activity,
257 # this is to use the index
258 where:
259 fragment(
260 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
261 activity.data,
262 activity.data,
263 ^id
264 ),
265 where: fragment("(?)->>'type' = 'Like'", activity.data)
266 )
267
268 Repo.all(query)
269 end
270
271 def make_like_data(%User{ap_id: ap_id} = actor, %{data: %{"id" => id}} = object, activity_id) do
272 data = %{
273 "type" => "Like",
274 "actor" => ap_id,
275 "object" => id,
276 "to" => [actor.follower_address, object.data["actor"]],
277 "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
278 "context" => object.data["context"]
279 }
280
281 if activity_id, do: Map.put(data, "id", activity_id), else: data
282 end
283
284 def update_element_in_object(property, element, object) do
285 with new_data <-
286 object.data
287 |> Map.put("#{property}_count", length(element))
288 |> Map.put("#{property}s", element),
289 changeset <- Changeset.change(object, data: new_data),
290 {:ok, object} <- Object.update_and_set_cache(changeset),
291 _ <- update_object_in_activities(object) do
292 {:ok, object}
293 end
294 end
295
296 def update_likes_in_object(likes, object) do
297 update_element_in_object("like", likes, object)
298 end
299
300 def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
301 likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
302
303 with likes <- [actor | likes] |> Enum.uniq() do
304 update_likes_in_object(likes, object)
305 end
306 end
307
308 def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
309 likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []
310
311 with likes <- likes |> List.delete(actor) do
312 update_likes_in_object(likes, object)
313 end
314 end
315
316 #### Follow-related helpers
317
318 @doc """
319 Updates a follow activity's state (for locked accounts).
320 """
321 def update_follow_state(
322 %Activity{data: %{"actor" => actor, "object" => object, "state" => "pending"}} = activity,
323 state
324 ) do
325 try do
326 Ecto.Adapters.SQL.query!(
327 Repo,
328 "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'",
329 [state, actor, object]
330 )
331
332 activity = Repo.get(Activity, activity.id)
333 {:ok, activity}
334 rescue
335 e ->
336 {:error, e}
337 end
338 end
339
340 def update_follow_state(%Activity{} = activity, state) do
341 with new_data <-
342 activity.data
343 |> Map.put("state", state),
344 changeset <- Changeset.change(activity, data: new_data),
345 {:ok, activity} <- Repo.update(changeset) do
346 {:ok, activity}
347 end
348 end
349
350 @doc """
351 Makes a follow activity data for the given follower and followed
352 """
353 def make_follow_data(
354 %User{ap_id: follower_id},
355 %User{ap_id: followed_id} = _followed,
356 activity_id
357 ) do
358 data = %{
359 "type" => "Follow",
360 "actor" => follower_id,
361 "to" => [followed_id],
362 "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
363 "object" => followed_id,
364 "state" => "pending"
365 }
366
367 data = if activity_id, do: Map.put(data, "id", activity_id), else: data
368
369 data
370 end
371
372 def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
373 query =
374 from(
375 activity in Activity,
376 where:
377 fragment(
378 "? ->> 'type' = 'Follow'",
379 activity.data
380 ),
381 where: activity.actor == ^follower_id,
382 where:
383 fragment(
384 "? @> ?",
385 activity.data,
386 ^%{object: followed_id}
387 ),
388 order_by: [desc: :id],
389 limit: 1
390 )
391
392 Repo.one(query)
393 end
394
395 #### Announce-related helpers
396
397 @doc """
398 Retruns an existing announce activity if the notice has already been announced
399 """
400 def get_existing_announce(actor, %{data: %{"id" => id}}) do
401 query =
402 from(
403 activity in Activity,
404 where: activity.actor == ^actor,
405 # this is to use the index
406 where:
407 fragment(
408 "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
409 activity.data,
410 activity.data,
411 ^id
412 ),
413 where: fragment("(?)->>'type' = 'Announce'", activity.data)
414 )
415
416 Repo.one(query)
417 end
418
419 @doc """
420 Make announce activity data for the given actor and object
421 """
422 # for relayed messages, we only want to send to subscribers
423 def make_announce_data(
424 %User{ap_id: ap_id} = user,
425 %Object{data: %{"id" => id}} = object,
426 activity_id,
427 false
428 ) do
429 data = %{
430 "type" => "Announce",
431 "actor" => ap_id,
432 "object" => id,
433 "to" => [user.follower_address],
434 "cc" => [],
435 "context" => object.data["context"]
436 }
437
438 if activity_id, do: Map.put(data, "id", activity_id), else: data
439 end
440
441 def make_announce_data(
442 %User{ap_id: ap_id} = user,
443 %Object{data: %{"id" => id}} = object,
444 activity_id,
445 true
446 ) do
447 data = %{
448 "type" => "Announce",
449 "actor" => ap_id,
450 "object" => id,
451 "to" => [user.follower_address, object.data["actor"]],
452 "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
453 "context" => object.data["context"]
454 }
455
456 if activity_id, do: Map.put(data, "id", activity_id), else: data
457 end
458
459 @doc """
460 Make unannounce activity data for the given actor and object
461 """
462 def make_unannounce_data(
463 %User{ap_id: ap_id} = user,
464 %Activity{data: %{"context" => context}} = activity,
465 activity_id
466 ) do
467 data = %{
468 "type" => "Undo",
469 "actor" => ap_id,
470 "object" => activity.data,
471 "to" => [user.follower_address, activity.data["actor"]],
472 "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
473 "context" => context
474 }
475
476 if activity_id, do: Map.put(data, "id", activity_id), else: data
477 end
478
479 def make_unlike_data(
480 %User{ap_id: ap_id} = user,
481 %Activity{data: %{"context" => context}} = activity,
482 activity_id
483 ) do
484 data = %{
485 "type" => "Undo",
486 "actor" => ap_id,
487 "object" => activity.data,
488 "to" => [user.follower_address, activity.data["actor"]],
489 "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
490 "context" => context
491 }
492
493 if activity_id, do: Map.put(data, "id", activity_id), else: data
494 end
495
496 def add_announce_to_object(
497 %Activity{
498 data: %{"actor" => actor, "cc" => ["https://www.w3.org/ns/activitystreams#Public"]}
499 },
500 object
501 ) do
502 announcements =
503 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
504
505 with announcements <- [actor | announcements] |> Enum.uniq() do
506 update_element_in_object("announcement", announcements, object)
507 end
508 end
509
510 def add_announce_to_object(_, object), do: {:ok, object}
511
512 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
513 announcements =
514 if is_list(object.data["announcements"]), do: object.data["announcements"], else: []
515
516 with announcements <- announcements |> List.delete(actor) do
517 update_element_in_object("announcement", announcements, object)
518 end
519 end
520
521 #### Unfollow-related helpers
522
523 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
524 data = %{
525 "type" => "Undo",
526 "actor" => follower.ap_id,
527 "to" => [followed.ap_id],
528 "object" => follow_activity.data
529 }
530
531 if activity_id, do: Map.put(data, "id", activity_id), else: data
532 end
533
534 #### Block-related helpers
535 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
536 query =
537 from(
538 activity in Activity,
539 where:
540 fragment(
541 "? ->> 'type' = 'Block'",
542 activity.data
543 ),
544 where: activity.actor == ^blocker_id,
545 where:
546 fragment(
547 "? @> ?",
548 activity.data,
549 ^%{object: blocked_id}
550 ),
551 order_by: [desc: :id],
552 limit: 1
553 )
554
555 Repo.one(query)
556 end
557
558 def make_block_data(blocker, blocked, activity_id) do
559 data = %{
560 "type" => "Block",
561 "actor" => blocker.ap_id,
562 "to" => [blocked.ap_id],
563 "object" => blocked.ap_id
564 }
565
566 if activity_id, do: Map.put(data, "id", activity_id), else: data
567 end
568
569 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
570 data = %{
571 "type" => "Undo",
572 "actor" => blocker.ap_id,
573 "to" => [blocked.ap_id],
574 "object" => block_activity.data
575 }
576
577 if activity_id, do: Map.put(data, "id", activity_id), else: data
578 end
579
580 #### Create-related helpers
581
582 def make_create_data(params, additional) do
583 published = params.published || make_date()
584
585 %{
586 "type" => "Create",
587 "to" => params.to |> Enum.uniq(),
588 "actor" => params.actor.ap_id,
589 "object" => params.object,
590 "published" => published,
591 "context" => params.context
592 }
593 |> Map.merge(additional)
594 end
595 end