utils.ex 18.2 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
  alias Pleroma.Repo
  alias Pleroma.Web
  alias Pleroma.Object
  alias Pleroma.Activity
10
  alias Pleroma.Web.ActivityPub.Visibility
Haelwenn's avatar
Haelwenn committed
11 12
  alias Pleroma.User
  alias Pleroma.Notification
lain's avatar
lain committed
13 14
  alias Pleroma.Web.Router.Helpers
  alias Pleroma.Web.Endpoint
Haelwenn's avatar
Haelwenn committed
15 16 17
  alias Ecto.Changeset
  alias Ecto.UUID

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

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

22 23
  @supported_object_types ["Article", "Note", "Video", "Page"]

24 25
  # 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
26 27 28 29
  def get_ap_id(object) do
    case object do
      %{"id" => id} -> id
      id -> id
30 31 32 33
    end
  end

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

37 38 39 40 41 42 43 44 45 46 47 48 49 50
  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: []

51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
  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

69 70 71 72 73
      # 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

74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
      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
92
      |> Map.put("cc", [ap_id | cc_list])
93 94 95 96 97
    else
      params
    end
  end

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

lain's avatar
lain committed
107
  def make_date do
lain's avatar
lain committed
108
    DateTime.utc_now() |> DateTime.to_iso8601()
lain's avatar
lain committed
109 110 111 112 113 114 115 116 117 118 119
  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
120
    Helpers.o_status_url(Endpoint, :object, UUID.generate())
lain's avatar
lain committed
121 122 123
  end

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

127
  def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
128 129 130 131 132 133 134 135 136 137
    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

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

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

146 147 148 149 150 151 152 153
    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)
154 155 156
    end
  end

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

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

lain's avatar
lain committed
172 173 174 175 176 177 178
  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
179 180
    %{data: %{"id" => context}, id: context_id} = create_context(map["context"])

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

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

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

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

lain's avatar
lain committed
217 218 219 220 221 222 223
  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.
224
    relevant_activities = Activity.get_all_create_by_object_ap_id(id)
lain's avatar
lain committed
225 226

    Enum.map(relevant_activities, fn activity ->
lain's avatar
lain committed
227 228 229 230 231 232 233 234 235 236 237
      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
238
  def get_existing_like(actor, %{data: %{"id" => id}}) do
lain's avatar
lain committed
239 240 241 242 243 244 245 246 247 248 249 250 251 252
    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)
      )
253

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

257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277
  @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

278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296
  def make_like_data(
        %User{ap_id: ap_id} = actor,
        %{data: %{"actor" => object_actor_id, "id" => id}} = object,
        activity_id
      ) do
    object_actor = User.get_cached_by_ap_id(object_actor_id)

    to =
      if Visibility.is_public?(object) do
        [actor.follower_address, object.data["actor"]]
      else
        [object.data["actor"]]
      end

    cc =
      (object.data["to"] ++ (object.data["cc"] || []))
      |> List.delete(actor.ap_id)
      |> List.delete(object_actor.follower_address)

lain's avatar
lain committed
297 298 299 300
    data = %{
      "type" => "Like",
      "actor" => ap_id,
      "object" => id,
301 302
      "to" => to,
      "cc" => cc,
lain's avatar
lain committed
303 304 305 306 307 308 309
      "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
310
    with new_data <-
lain's avatar
lain committed
311 312
           object.data
           |> Map.put("#{property}_count", length(element))
lain's avatar
lain committed
313
           |> Map.put("#{property}s", element),
lain's avatar
lain committed
314
         changeset <- Changeset.change(object, data: new_data),
lain's avatar
lain committed
315
         {:ok, object} <- Object.update_and_set_cache(changeset),
lain's avatar
lain committed
316
         _ <- update_object_in_activities(object) do
lain's avatar
lain committed
317 318 319 320 321 322 323 324 325
      {: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
326 327 328
    likes = if is_list(object.data["likes"]), do: object.data["likes"], else: []

    with likes <- [actor | likes] |> Enum.uniq() do
lain's avatar
lain committed
329 330 331 332 333
      update_likes_in_object(likes, object)
    end
  end

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

    with likes <- likes |> List.delete(actor) do
lain's avatar
lain committed
337 338 339 340 341 342
      update_likes_in_object(likes, object)
    end
  end

  #### Follow-related helpers

kaniini's avatar
kaniini committed
343 344 345
  @doc """
  Updates a follow activity's state (for locked accounts).
  """
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364
  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
365 366 367 368 369 370 371 372 373 374
  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
375 376 377
  @doc """
  Makes a follow activity data for the given follower and followed
  """
kaniini's avatar
kaniini committed
378 379
  def make_follow_data(
        %User{ap_id: follower_id},
Maksim's avatar
Maksim committed
380
        %User{ap_id: followed_id} = _followed,
kaniini's avatar
kaniini committed
381 382
        activity_id
      ) do
lain's avatar
lain committed
383 384 385 386
    data = %{
      "type" => "Follow",
      "actor" => follower_id,
      "to" => [followed_id],
lain's avatar
lain committed
387
      "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
388 389
      "object" => followed_id,
      "state" => "pending"
lain's avatar
lain committed
390 391
    }

kaniini's avatar
kaniini committed
392 393 394
    data = if activity_id, do: Map.put(data, "id", activity_id), else: data

    data
lain's avatar
lain committed
395 396
  end

lain's avatar
lain committed
397 398 399 400
  def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
    query =
      from(
        activity in Activity,
401 402 403 404 405 406
        where:
          fragment(
            "? ->> 'type' = 'Follow'",
            activity.data
          ),
        where: activity.actor == ^follower_id,
lain's avatar
lain committed
407 408 409 410
        where:
          fragment(
            "? @> ?",
            activity.data,
411
            ^%{object: followed_id}
lain's avatar
lain committed
412 413 414 415 416
          ),
        order_by: [desc: :id],
        limit: 1
      )

lain's avatar
lain committed
417 418 419 420 421 422
    Repo.one(query)
  end

  #### Announce-related helpers

  @doc """
423
  Retruns an existing announce activity if the notice has already been announced
lain's avatar
lain committed
424
  """
normandy's avatar
normandy committed
425 426 427 428
  def get_existing_announce(actor, %{data: %{"id" => id}}) do
    query =
      from(
        activity in Activity,
lain's avatar
lain committed
429
        where: activity.actor == ^actor,
normandy's avatar
normandy committed
430 431 432 433 434 435 436 437 438 439 440 441 442 443
        # 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
444 445 446
  @doc """
  Make announce activity data for the given actor and object
  """
447 448
  # for relayed messages, we only want to send to subscribers
  def make_announce_data(
449
        %User{ap_id: ap_id} = user,
450
        %Object{data: %{"id" => id}} = object,
451 452
        activity_id,
        false
453 454 455 456 457 458 459 460 461 462 463 464 465
      ) 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
466 467 468
  def make_announce_data(
        %User{ap_id: ap_id} = user,
        %Object{data: %{"id" => id}} = object,
469 470
        activity_id,
        true
lain's avatar
lain committed
471
      ) do
lain's avatar
lain committed
472 473 474 475
    data = %{
      "type" => "Announce",
      "actor" => ap_id,
      "object" => id,
lain's avatar
lain committed
476
      "to" => [user.follower_address, object.data["actor"]],
lain's avatar
lain committed
477
      "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
lain's avatar
lain committed
478 479 480 481 482 483
      "context" => object.data["context"]
    }

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

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

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

Thog's avatar
Thog committed
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520
  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

521 522 523 524 525 526
  def add_announce_to_object(
        %Activity{
          data: %{"actor" => actor, "cc" => ["https://www.w3.org/ns/activitystreams#Public"]}
        },
        object
      ) do
Haelwenn's avatar
Haelwenn committed
527 528 529 530
    announcements =
      if is_list(object.data["announcements"]), do: object.data["announcements"], else: []

    with announcements <- [actor | announcements] |> Enum.uniq() do
lain's avatar
lain committed
531 532 533 534
      update_element_in_object("announcement", announcements, object)
    end
  end

535 536
  def add_announce_to_object(_, object), do: {:ok, object}

normandy's avatar
normandy committed
537
  def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
Haelwenn's avatar
Haelwenn committed
538 539 540 541
    announcements =
      if is_list(object.data["announcements"]), do: object.data["announcements"], else: []

    with announcements <- announcements |> List.delete(actor) do
normandy's avatar
normandy committed
542 543 544 545
      update_element_in_object("announcement", announcements, object)
    end
  end

lain's avatar
lain committed
546 547
  #### Unfollow-related helpers

normandy's avatar
normandy committed
548 549
  def make_unfollow_data(follower, followed, follow_activity, activity_id) do
    data = %{
lain's avatar
lain committed
550 551 552
      "type" => "Undo",
      "actor" => follower.ap_id,
      "to" => [followed.ap_id],
normandy's avatar
normandy committed
553
      "object" => follow_activity.data
lain's avatar
lain committed
554
    }
normandy's avatar
Format  
normandy committed
555

normandy's avatar
normandy committed
556
    if activity_id, do: Map.put(data, "id", activity_id), else: data
lain's avatar
lain committed
557 558
  end

normandy's avatar
normandy committed
559 560 561 562 563
  #### Block-related helpers
  def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
    query =
      from(
        activity in Activity,
564 565 566 567 568 569
        where:
          fragment(
            "? ->> 'type' = 'Block'",
            activity.data
          ),
        where: activity.actor == ^blocker_id,
normandy's avatar
normandy committed
570 571 572 573
        where:
          fragment(
            "? @> ?",
            activity.data,
574
            ^%{object: blocked_id}
normandy's avatar
normandy committed
575 576 577 578 579 580 581 582
          ),
        order_by: [desc: :id],
        limit: 1
      )

    Repo.one(query)
  end

normandy's avatar
normandy committed
583 584
  def make_block_data(blocker, blocked, activity_id) do
    data = %{
normandy's avatar
normandy committed
585 586 587 588 589
      "type" => "Block",
      "actor" => blocker.ap_id,
      "to" => [blocked.ap_id],
      "object" => blocked.ap_id
    }
normandy's avatar
Format  
normandy committed
590

normandy's avatar
normandy committed
591
    if activity_id, do: Map.put(data, "id", activity_id), else: data
normandy's avatar
normandy committed
592 593
  end

normandy's avatar
normandy committed
594 595
  def make_unblock_data(blocker, blocked, block_activity, activity_id) do
    data = %{
normandy's avatar
normandy committed
596 597 598 599
      "type" => "Undo",
      "actor" => blocker.ap_id,
      "to" => [blocked.ap_id],
      "object" => block_activity.data
lain's avatar
lain committed
600
    }
normandy's avatar
Format  
normandy committed
601

normandy's avatar
normandy committed
602
    if activity_id, do: Map.put(data, "id", activity_id), else: data
lain's avatar
lain committed
603 604 605 606 607 608
  end

  #### Create-related helpers

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

Thog's avatar
Thog committed
610
    %{
lain's avatar
lain committed
611
      "type" => "Create",
lain's avatar
lain committed
612
      "to" => params.to |> Enum.uniq(),
lain's avatar
lain committed
613 614 615 616 617 618 619
      "actor" => params.actor.ap_id,
      "object" => params.object,
      "published" => published,
      "context" => params.context
    }
    |> Map.merge(additional)
  end
minibikini's avatar
Reports  
minibikini committed
620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635

  #### 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
636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674

  @doc """
  Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
  the first one to `pages_left` pages.
  If the amount of pages is higher than the collection has, it returns whatever was there.
  """
  def fetch_ordered_collection(from, pages_left, acc \\ []) do
    with {:ok, response} <- Tesla.get(from),
         {:ok, collection} <- Poison.decode(response.body) do
      case collection["type"] do
        "OrderedCollection" ->
          # If we've encountered the OrderedCollection and not the page,
          # just call the same function on the page address
          fetch_ordered_collection(collection["first"], pages_left)

        "OrderedCollectionPage" ->
          if pages_left > 0 do
            # There are still more pages
            if Map.has_key?(collection, "next") do
              # There are still more pages, go deeper saving what we have into the accumulator
              fetch_ordered_collection(
                collection["next"],
                pages_left - 1,
                acc ++ collection["orderedItems"]
              )
            else
              # No more pages left, just return whatever we already have
              acc ++ collection["orderedItems"]
            end
          else
            # Got the amount of pages needed, add them all to the accumulator
            acc ++ collection["orderedItems"]
          end

        _ ->
          {:error, "Not an OrderedCollection or OrderedCollectionPage"}
      end
    end
  end
lain's avatar
lain committed
675
end