utils.ex 16.3 KB
Newer Older
1
# Pleroma: A lightweight social networking server
kaniini's avatar
kaniini committed
2
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 4
# SPDX-License-Identifier: AGPL-3.0-only

lain's avatar
lain committed
5
defmodule Pleroma.Web.ActivityPub.Utils do
Haelwenn's avatar
Haelwenn committed
6 7 8 9 10 11
  alias Pleroma.Repo
  alias Pleroma.Web
  alias Pleroma.Object
  alias Pleroma.Activity
  alias Pleroma.User
  alias Pleroma.Notification
lain's avatar
lain committed
12 13
  alias Pleroma.Web.Router.Helpers
  alias Pleroma.Web.Endpoint
Haelwenn's avatar
Haelwenn committed
14 15 16
  alias Ecto.Changeset
  alias Ecto.UUID

lain's avatar
lain committed
17
  import Ecto.Query
Haelwenn's avatar
Haelwenn committed
18

kaniini's avatar
kaniini committed
19
  require Logger
lain's avatar
lain committed
20

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,
  # so figure out what the actor's URI is based on what we have.
lain's avatar
lain committed
25 26 27 28
  def get_ap_id(object) do
    case object do
      %{"id" => id} -> id
      id -> id
29 30 31 32
    end
  end

  def normalize_params(params) do
lain's avatar
lain committed
33
    Map.put(params, "actor", get_ap_id(params["actor"]))
34 35
  end

36 37 38 39 40 41 42 43 44 45 46 47 48 49
  def determine_explicit_mentions(%{"tag" => tag} = _object) when is_list(tag) do
    tag
    |> Enum.filter(fn x -> is_map(x) end)
    |> Enum.filter(fn x -> x["type"] == "Mention" end)
    |> Enum.map(fn x -> x["href"] end)
  end

  def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do
    Map.put(object, "tag", [tag])
    |> determine_explicit_mentions()
  end

  def determine_explicit_mentions(_), do: []

50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
  defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
  defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
  defp recipient_in_collection(_, _), do: false

  def recipient_in_message(ap_id, params) do
    cond do
      recipient_in_collection(ap_id, params["to"]) ->
        true

      recipient_in_collection(ap_id, params["cc"]) ->
        true

      recipient_in_collection(ap_id, params["bto"]) ->
        true

      recipient_in_collection(ap_id, params["bcc"]) ->
        true

68 69 70 71 72
      # if the message is unaddressed at all, then assume it is directly addressed
      # to the recipient
      !params["to"] && !params["cc"] && !params["bto"] && !params["bcc"] ->
        true

73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
      true ->
        false
    end
  end

  defp extract_list(target) when is_binary(target), do: [target]
  defp extract_list(lst) when is_list(lst), do: lst
  defp extract_list(_), do: []

  def maybe_splice_recipient(ap_id, params) do
    need_splice =
      !recipient_in_collection(ap_id, params["to"]) &&
        !recipient_in_collection(ap_id, params["cc"])

    cc_list = extract_list(params["cc"])

    if need_splice do
      params
kaniini's avatar
kaniini committed
91
      |> Map.put("cc", [ap_id | cc_list])
92 93 94 95 96
    else
      params
    end
  end

97 98 99 100
  def make_json_ld_header do
    %{
      "@context" => [
        "https://www.w3.org/ns/activitystreams",
101
        "#{Web.base_url()}/schemas/litepub-0.1.jsonld"
102 103 104 105
      ]
    }
  end

lain's avatar
lain committed
106
  def make_date do
lain's avatar
lain committed
107
    DateTime.utc_now() |> DateTime.to_iso8601()
lain's avatar
lain committed
108 109 110 111 112 113 114 115 116 117 118
  end

  def generate_activity_id do
    generate_id("activities")
  end

  def generate_context_id do
    generate_id("contexts")
  end

  def generate_object_id do
lain's avatar
lain committed
119
    Helpers.o_status_url(Endpoint, :object, UUID.generate())
lain's avatar
lain committed
120 121 122
  end

  def generate_id(type) do
lain's avatar
lain committed
123
    "#{Web.base_url()}/#{type}/#{UUID.generate()}"
lain's avatar
lain committed
124 125
  end

126
  def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
127 128 129 130 131 132 133 134 135 136
    fake_create_activity = %{
      "to" => object["to"],
      "cc" => object["cc"],
      "type" => "Create",
      "object" => object
    }

    Notification.get_notified_from_activity(%Activity{data: fake_create_activity}, false)
  end

137
  def get_notified_from_object(object) do
138
    Notification.get_notified_from_activity(%Activity{data: object}, false)
139 140
  end

141 142 143 144
  def create_context(context) do
    context = context || generate_id("contexts")
    changeset = Object.context_mapping(context)

145 146 147 148 149 150 151 152
    case Repo.insert(changeset) do
      {:ok, object} ->
        object

      # This should be solved by an upsert, but it seems ecto
      # has problems accessing the constraint inside the jsonb.
      {:error, _} ->
        Object.get_cached_by_ap_id(context)
153 154 155
    end
  end

lain's avatar
lain committed
156 157 158 159
  @doc """
  Enqueues an activity for federation if it's local
  """
  def maybe_federate(%Activity{local: true} = activity) do
lain's avatar
lain committed
160 161 162 163 164 165 166
    priority =
      case activity.data["type"] do
        "Delete" -> 10
        "Create" -> 1
        _ -> 5
      end

minibikini's avatar
minibikini committed
167
    Pleroma.Web.Federator.publish(activity, priority)
lain's avatar
lain committed
168 169
    :ok
  end
lain's avatar
lain committed
170

lain's avatar
lain committed
171 172 173 174 175 176 177
  def maybe_federate(_), do: :ok

  @doc """
  Adds an id and a published data if they aren't there,
  also adds it to an included object
  """
  def lazy_put_activity_defaults(map) do
178 179
    %{data: %{"id" => context}, id: context_id} = create_context(map["context"])

lain's avatar
lain committed
180 181 182 183
    map =
      map
      |> Map.put_new_lazy("id", &generate_activity_id/0)
      |> Map.put_new_lazy("published", &make_date/0)
184 185
      |> Map.put_new("context", context)
      |> Map.put_new("context_id", context_id)
lain's avatar
lain committed
186 187

    if is_map(map["object"]) do
188
      object = lazy_put_object_defaults(map["object"], map)
lain's avatar
lain committed
189 190 191 192 193 194 195 196 197
      %{map | "object" => object}
    else
      map
    end
  end

  @doc """
  Adds an id and published date if they aren't there.
  """
198
  def lazy_put_object_defaults(map, activity \\ %{}) do
lain's avatar
lain committed
199 200 201
    map
    |> Map.put_new_lazy("id", &generate_object_id/0)
    |> Map.put_new_lazy("published", &make_date/0)
202 203
    |> Map.put_new("context", activity["context"])
    |> Map.put_new("context_id", activity["context_id"])
lain's avatar
lain committed
204 205 206 207 208
  end

  @doc """
  Inserts a full object if it is contained in an activity.
  """
lain's avatar
lain committed
209
  def insert_full_object(%{"object" => %{"type" => type} = object_data})
210
      when is_map(object_data) and type in @supported_object_types do
Thog's avatar
Thog committed
211
    with {:ok, _} <- Object.create(object_data) do
lain's avatar
lain committed
212 213 214
      :ok
    end
  end
lain's avatar
lain committed
215

lain's avatar
lain committed
216 217 218 219 220 221 222
  def insert_full_object(_), do: :ok

  def update_object_in_activities(%{data: %{"id" => id}} = object) do
    # TODO
    # Update activities that already had this. Could be done in a seperate process.
    # Alternatively, just don't do this and fetch the current object each time. Most
    # could probably be taken from cache.
223
    relevant_activities = Activity.get_all_create_by_object_ap_id(id)
lain's avatar
lain committed
224 225

    Enum.map(relevant_activities, fn activity ->
lain's avatar
lain committed
226 227 228 229 230 231 232 233 234 235 236
      new_activity_data = activity.data |> Map.put("object", object.data)
      changeset = Changeset.change(activity, data: new_activity_data)
      Repo.update(changeset)
    end)
  end

  #### Like-related helpers

  @doc """
  Returns an existing like if a user already liked an object
  """
Thog's avatar
Thog committed
237
  def get_existing_like(actor, %{data: %{"id" => id}}) do
lain's avatar
lain committed
238 239 240 241 242 243 244 245 246 247 248 249 250 251
    query =
      from(
        activity in Activity,
        where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
        # this is to use the index
        where:
          fragment(
            "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
            activity.data,
            activity.data,
            ^id
          ),
        where: fragment("(?)->>'type' = 'Like'", activity.data)
      )
252

lain's avatar
lain committed
253 254 255
    Repo.one(query)
  end

256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
  @doc """
  Returns like activities targeting an object
  """
  def get_object_likes(%{data: %{"id" => id}}) do
    query =
      from(
        activity in Activity,
        # this is to use the index
        where:
          fragment(
            "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
            activity.data,
            activity.data,
            ^id
          ),
        where: fragment("(?)->>'type' = 'Like'", activity.data)
      )

    Repo.all(query)
  end

lain's avatar
lain committed
277 278 279 280 281
  def make_like_data(%User{ap_id: ap_id} = actor, %{data: %{"id" => id}} = object, activity_id) do
    data = %{
      "type" => "Like",
      "actor" => ap_id,
      "object" => id,
lain's avatar
lain committed
282
      "to" => [actor.follower_address, object.data["actor"]],
lain's avatar
lain committed
283
      "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
lain's avatar
lain committed
284 285 286 287 288 289 290
      "context" => object.data["context"]
    }

    if activity_id, do: Map.put(data, "id", activity_id), else: data
  end

  def update_element_in_object(property, element, object) do
lain's avatar
lain committed
291
    with new_data <-
lain's avatar
lain committed
292 293
           object.data
           |> Map.put("#{property}_count", length(element))
lain's avatar
lain committed
294
           |> Map.put("#{property}s", element),
lain's avatar
lain committed
295
         changeset <- Changeset.change(object, data: new_data),
lain's avatar
lain committed
296
         {:ok, object} <- Object.update_and_set_cache(changeset),
lain's avatar
lain committed
297
         _ <- update_object_in_activities(object) do
lain's avatar
lain committed
298 299 300 301 302 303 304 305 306
      {:ok, object}
    end
  end

  def update_likes_in_object(likes, object) do
    update_element_in_object("like", likes, object)
  end

  def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
Haelwenn's avatar
Haelwenn committed
307 308 309
    likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []

    with likes <- [actor | likes] |> Enum.uniq() do
lain's avatar
lain committed
310 311 312 313 314
      update_likes_in_object(likes, object)
    end
  end

  def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
Haelwenn's avatar
Haelwenn committed
315 316 317
    likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []

    with likes <- likes |> List.delete(actor) do
lain's avatar
lain committed
318 319 320 321 322 323
      update_likes_in_object(likes, object)
    end
  end

  #### Follow-related helpers

kaniini's avatar
kaniini committed
324 325 326
  @doc """
  Updates a follow activity's state (for locked accounts).
  """
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345
  def update_follow_state(
        %Activity{data: %{"actor" => actor, "object" => object, "state" => "pending"}} = activity,
        state
      ) do
    try do
      Ecto.Adapters.SQL.query!(
        Repo,
        "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'",
        [state, actor, object]
      )

      activity = Repo.get(Activity, activity.id)
      {:ok, activity}
    rescue
      e ->
        {:error, e}
    end
  end

kaniini's avatar
kaniini committed
346 347 348 349 350 351 352 353 354 355
  def update_follow_state(%Activity{} = activity, state) do
    with new_data <-
           activity.data
           |> Map.put("state", state),
         changeset <- Changeset.change(activity, data: new_data),
         {:ok, activity} <- Repo.update(changeset) do
      {:ok, activity}
    end
  end

lain's avatar
lain committed
356 357 358
  @doc """
  Makes a follow activity data for the given follower and followed
  """
kaniini's avatar
kaniini committed
359 360
  def make_follow_data(
        %User{ap_id: follower_id},
Maksim's avatar
Maksim committed
361
        %User{ap_id: followed_id} = _followed,
kaniini's avatar
kaniini committed
362 363
        activity_id
      ) do
lain's avatar
lain committed
364 365 366 367
    data = %{
      "type" => "Follow",
      "actor" => follower_id,
      "to" => [followed_id],
lain's avatar
lain committed
368
      "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
369 370
      "object" => followed_id,
      "state" => "pending"
lain's avatar
lain committed
371 372
    }

kaniini's avatar
kaniini committed
373 374 375
    data = if activity_id, do: Map.put(data, "id", activity_id), else: data

    data
lain's avatar
lain committed
376 377
  end

lain's avatar
lain committed
378 379 380 381
  def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
    query =
      from(
        activity in Activity,
382 383 384 385 386 387
        where:
          fragment(
            "? ->> 'type' = 'Follow'",
            activity.data
          ),
        where: activity.actor == ^follower_id,
lain's avatar
lain committed
388 389 390 391
        where:
          fragment(
            "? @> ?",
            activity.data,
392
            ^%{object: followed_id}
lain's avatar
lain committed
393 394 395 396 397
          ),
        order_by: [desc: :id],
        limit: 1
      )

lain's avatar
lain committed
398 399 400 401 402 403
    Repo.one(query)
  end

  #### Announce-related helpers

  @doc """
404
  Retruns an existing announce activity if the notice has already been announced
lain's avatar
lain committed
405
  """
normandy's avatar
normandy committed
406 407 408 409
  def get_existing_announce(actor, %{data: %{"id" => id}}) do
    query =
      from(
        activity in Activity,
lain's avatar
lain committed
410
        where: activity.actor == ^actor,
normandy's avatar
normandy committed
411 412 413 414 415 416 417 418 419 420 421 422 423 424
        # this is to use the index
        where:
          fragment(
            "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
            activity.data,
            activity.data,
            ^id
          ),
        where: fragment("(?)->>'type' = 'Announce'", activity.data)
      )

    Repo.one(query)
  end

normandy's avatar
normandy committed
425 426 427
  @doc """
  Make announce activity data for the given actor and object
  """
428 429
  # for relayed messages, we only want to send to subscribers
  def make_announce_data(
430
        %User{ap_id: ap_id} = user,
431
        %Object{data: %{"id" => id}} = object,
432 433
        activity_id,
        false
434 435 436 437 438 439 440 441 442 443 444 445 446
      ) do
    data = %{
      "type" => "Announce",
      "actor" => ap_id,
      "object" => id,
      "to" => [user.follower_address],
      "cc" => [],
      "context" => object.data["context"]
    }

    if activity_id, do: Map.put(data, "id", activity_id), else: data
  end

lain's avatar
lain committed
447 448 449
  def make_announce_data(
        %User{ap_id: ap_id} = user,
        %Object{data: %{"id" => id}} = object,
450 451
        activity_id,
        true
lain's avatar
lain committed
452
      ) do
lain's avatar
lain committed
453 454 455 456
    data = %{
      "type" => "Announce",
      "actor" => ap_id,
      "object" => id,
lain's avatar
lain committed
457
      "to" => [user.follower_address, object.data["actor"]],
lain's avatar
lain committed
458
      "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
lain's avatar
lain committed
459 460 461 462 463 464
      "context" => object.data["context"]
    }

    if activity_id, do: Map.put(data, "id", activity_id), else: data
  end

465 466 467 468 469
  @doc """
  Make unannounce activity data for the given actor and object
  """
  def make_unannounce_data(
        %User{ap_id: ap_id} = user,
470 471
        %Activity{data: %{"context" => context}} = activity,
        activity_id
472
      ) do
473
    data = %{
474 475
      "type" => "Undo",
      "actor" => ap_id,
476
      "object" => activity.data,
normandy's avatar
normandy committed
477
      "to" => [user.follower_address, activity.data["actor"]],
478
      "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
normandy's avatar
normandy committed
479
      "context" => context
480
    }
481 482

    if activity_id, do: Map.put(data, "id", activity_id), else: data
483 484
  end

Thog's avatar
Thog committed
485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501
  def make_unlike_data(
        %User{ap_id: ap_id} = user,
        %Activity{data: %{"context" => context}} = activity,
        activity_id
      ) do
    data = %{
      "type" => "Undo",
      "actor" => ap_id,
      "object" => activity.data,
      "to" => [user.follower_address, activity.data["actor"]],
      "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
      "context" => context
    }

    if activity_id, do: Map.put(data, "id", activity_id), else: data
  end

502 503 504 505 506 507
  def add_announce_to_object(
        %Activity{
          data: %{"actor" => actor, "cc" => ["https://www.w3.org/ns/activitystreams#Public"]}
        },
        object
      ) do
Haelwenn's avatar
Haelwenn committed
508 509 510 511
    announcements =
      if is_list(object.data["announcements"]), do: object.data["announcements"], else: []

    with announcements <- [actor | announcements] |> Enum.uniq() do
lain's avatar
lain committed
512 513 514 515
      update_element_in_object("announcement", announcements, object)
    end
  end

516 517
  def add_announce_to_object(_, object), do: {:ok, object}

normandy's avatar
normandy committed
518
  def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
Haelwenn's avatar
Haelwenn committed
519 520 521 522
    announcements =
      if is_list(object.data["announcements"]), do: object.data["announcements"], else: []

    with announcements <- announcements |> List.delete(actor) do
normandy's avatar
normandy committed
523 524 525 526
      update_element_in_object("announcement", announcements, object)
    end
  end

lain's avatar
lain committed
527 528
  #### Unfollow-related helpers

normandy's avatar
normandy committed
529 530
  def make_unfollow_data(follower, followed, follow_activity, activity_id) do
    data = %{
lain's avatar
lain committed
531 532 533
      "type" => "Undo",
      "actor" => follower.ap_id,
      "to" => [followed.ap_id],
normandy's avatar
normandy committed
534
      "object" => follow_activity.data
lain's avatar
lain committed
535
    }
normandy's avatar
Format  
normandy committed
536

normandy's avatar
normandy committed
537
    if activity_id, do: Map.put(data, "id", activity_id), else: data
lain's avatar
lain committed
538 539
  end

normandy's avatar
normandy committed
540 541 542 543 544
  #### Block-related helpers
  def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
    query =
      from(
        activity in Activity,
545 546 547 548 549 550
        where:
          fragment(
            "? ->> 'type' = 'Block'",
            activity.data
          ),
        where: activity.actor == ^blocker_id,
normandy's avatar
normandy committed
551 552 553 554
        where:
          fragment(
            "? @> ?",
            activity.data,
555
            ^%{object: blocked_id}
normandy's avatar
normandy committed
556 557 558 559 560 561 562 563
          ),
        order_by: [desc: :id],
        limit: 1
      )

    Repo.one(query)
  end

normandy's avatar
normandy committed
564 565
  def make_block_data(blocker, blocked, activity_id) do
    data = %{
normandy's avatar
normandy committed
566 567 568 569 570
      "type" => "Block",
      "actor" => blocker.ap_id,
      "to" => [blocked.ap_id],
      "object" => blocked.ap_id
    }
normandy's avatar
Format  
normandy committed
571

normandy's avatar
normandy committed
572
    if activity_id, do: Map.put(data, "id", activity_id), else: data
normandy's avatar
normandy committed
573 574
  end

normandy's avatar
normandy committed
575 576
  def make_unblock_data(blocker, blocked, block_activity, activity_id) do
    data = %{
normandy's avatar
normandy committed
577 578 579 580
      "type" => "Undo",
      "actor" => blocker.ap_id,
      "to" => [blocked.ap_id],
      "object" => block_activity.data
lain's avatar
lain committed
581
    }
normandy's avatar
Format  
normandy committed
582

normandy's avatar
normandy committed
583
    if activity_id, do: Map.put(data, "id", activity_id), else: data
lain's avatar
lain committed
584 585 586 587 588 589
  end

  #### Create-related helpers

  def make_create_data(params, additional) do
    published = params.published || make_date()
lain's avatar
lain committed
590

Thog's avatar
Thog committed
591
    %{
lain's avatar
lain committed
592
      "type" => "Create",
lain's avatar
lain committed
593
      "to" => params.to |> Enum.uniq(),
lain's avatar
lain committed
594 595 596 597 598 599 600
      "actor" => params.actor.ap_id,
      "object" => params.object,
      "published" => published,
      "context" => params.context
    }
    |> Map.merge(additional)
  end
minibikini's avatar
Reports  
minibikini committed
601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616

  #### Flag-related helpers

  def make_flag_data(params, additional) do
    status_ap_ids = Enum.map(params.statuses || [], & &1.data["id"])
    object = [params.account.ap_id] ++ status_ap_ids

    %{
      "type" => "Flag",
      "actor" => params.actor.ap_id,
      "content" => params.content,
      "object" => object,
      "context" => params.context
    }
    |> Map.merge(additional)
  end
lain's avatar
lain committed
617
end